From e5f8cfb8f9617e13ee302ebb424345d7295935af Mon Sep 17 00:00:00 2001 From: wenjinbo <599483010@qq.com> Date: Mon, 22 Sep 2025 15:34:40 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=88=9B=E5=BB=BA?= =?UTF-8?q?=E5=8D=95=E4=B8=AAes=E7=B4=A2=E5=BC=95=E6=8A=A5=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../views/chatweb/components/InputArea.vue | 376 ++++++++++++++++-- .../views/datasets/components/DocUpload.vue | 13 +- .../dify/impl/MetadataServiceImpl.java | 2 +- .../brichat/util/EsTDatasetFilesImporter.java | 2 +- .../src/main/resources/application-wuhan.yml | 3 - 5 files changed, 344 insertions(+), 52 deletions(-) diff --git a/chat-client/src/views/chatweb/components/InputArea.vue b/chat-client/src/views/chatweb/components/InputArea.vue index 722ed92..dcf85b5 100644 --- a/chat-client/src/views/chatweb/components/InputArea.vue +++ b/chat-client/src/views/chatweb/components/InputArea.vue @@ -64,6 +64,23 @@ + +
+
+
🎤
+
+

需要麦克风权限

+

请点击地址栏左侧的麦克风图标,选择"允许"来启用语音功能

+
+
1. 点击地址栏的 🔒 或 🎤 图标
+
2. 选择"允许"麦克风访问
+
3. 刷新页面后重试
+
+
+ +
+
+ @@ -100,8 +117,13 @@ const inputText = ref(props.modelValue) // 语音录制相关状态 const isRecording = ref(false) const isProcessingVoice = ref(false) +const showPermissionGuide = ref(false) let mediaRecorder: MediaRecorder | null = null let audioChunks: Blob[] = [] +let audioContext: AudioContext | null = null +let mediaStreamSource: MediaStreamAudioSourceNode | null = null +let processor: ScriptProcessorNode | null = null +let recordedBuffer: Float32Array[] = [] // 监听输入值变化 watch(inputText, (newValue) => { @@ -113,67 +135,219 @@ watch(() => props.modelValue, (newValue) => { inputText.value = newValue }) +// WAV格式音频处理工具函数 +const encodeWAV = (samples: Float32Array, sampleRate: number) => { + const buffer = new ArrayBuffer(44 + samples.length * 2) + const view = new DataView(buffer) + + // WAV文件头 + const writeString = (offset: number, string: string) => { + for (let i = 0; i < string.length; i++) { + view.setUint8(offset + i, string.charCodeAt(i)) + } + } + + const floatTo16BitPCM = (output: DataView, offset: number, input: Float32Array) => { + for (let i = 0; i < input.length; i++, offset += 2) { + const s = Math.max(-1, Math.min(1, input[i])) + output.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true) + } + } + + writeString(0, 'RIFF') + view.setUint32(4, 32 + samples.length * 2, true) + writeString(8, 'WAVE') + writeString(12, 'fmt ') + view.setUint32(16, 16, true) + view.setUint16(20, 1, true) + view.setUint16(22, 1, true) + view.setUint32(24, sampleRate, true) + view.setUint32(28, sampleRate * 2, true) + view.setUint16(32, 2, true) + view.setUint16(34, 16, true) + writeString(36, 'data') + view.setUint32(40, samples.length * 2, true) + + floatTo16BitPCM(view, 44, samples) + + return new Blob([view], { type: 'audio/wav' }) +} + +// 合并音频缓冲区 +const mergeBuffers = (buffers: Float32Array[], length: number) => { + const result = new Float32Array(length) + let offset = 0 + for (const buffer of buffers) { + result.set(buffer, offset) + offset += buffer.length + } + return result +} + +// 检查麦克风权限状态 +const checkMicrophonePermission = async () => { + try { + // 检查是否支持权限API + if (!navigator.permissions) { + console.warn('浏览器不支持权限API') + return 'unknown' + } + + const permission = await navigator.permissions.query({ name: 'microphone' as PermissionName }) + return permission.state + } catch (error) { + console.warn('无法查询麦克风权限状态:', error) + return 'unknown' + } +} + +// 检查浏览器环境和支持性 +const checkBrowserSupport = () => { + // 检查是否为HTTPS或localhost + const isSecureContext = window.isSecureContext || location.protocol === 'https:' || location.hostname === 'localhost' + + // 检查API支持 + const hasMediaDevices = !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia) + const hasMediaRecorder = !!window.MediaRecorder + + return { + isSecureContext, + hasMediaDevices, + hasMediaRecorder + } +} + // 开始录音 const startRecording = async () => { if (isRecording.value || isProcessingVoice.value) return + // 检查浏览器支持 + const support = checkBrowserSupport() + + if (!support.isSecureContext) { + ElMessage.error('语音功能需要在HTTPS环境下使用,请使用HTTPS访问此页面') + return + } + + if (!support.hasMediaDevices) { + ElMessage.error('您的浏览器不支持麦克风访问功能') + return + } + + if (!support.hasMediaRecorder) { + ElMessage.error('您的浏览器不支持录音功能') + return + } + try { + // 先检查权限状态 + const permissionState = await checkMicrophonePermission() + console.log('麦克风权限状态:', permissionState) + + if (permissionState === 'denied') { + ElMessage.error('麦克风权限已被拒绝,请在浏览器设置中允许访问麦克风') + return + } + const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, - autoGainControl: true + autoGainControl: true, + sampleRate: 44100 } }) - // 检查浏览器支持的音频格式 - const mimeTypes = [ - 'audio/webm;codecs=opus', - 'audio/webm', - 'audio/mp4', - 'audio/wav' - ] + console.log('麦克风权限获取成功') - let selectedMimeType = 'audio/webm' - for (const mimeType of mimeTypes) { - if (MediaRecorder.isTypeSupported(mimeType)) { - selectedMimeType = mimeType - break - } + // 使用Web Audio API录制WAV格式 + audioContext = new (window.AudioContext || (window as any).webkitAudioContext)() + mediaStreamSource = audioContext.createMediaStreamSource(stream) + + // 创建音频处理器 + processor = audioContext.createScriptProcessor(4096, 1, 1) + recordedBuffer = [] + + processor.onaudioprocess = (e) => { + const inputBuffer = e.inputBuffer.getChannelData(0) + const buffer = new Float32Array(inputBuffer.length) + buffer.set(inputBuffer) + recordedBuffer.push(buffer) } - mediaRecorder = new MediaRecorder(stream, { mimeType: selectedMimeType }) - audioChunks = [] + // 连接音频节点 + mediaStreamSource.connect(processor) + processor.connect(audioContext.destination) - mediaRecorder.ondataavailable = (event) => { - if (event.data.size > 0) { - audioChunks.push(event.data) - } - } - - mediaRecorder.onstop = async () => { - const audioBlob = new Blob(audioChunks, { type: selectedMimeType }) - await processVoiceToText(audioBlob) - - // 停止所有音频轨道 - stream.getTracks().forEach(track => track.stop()) - } - - mediaRecorder.start() + console.log('开始录制WAV格式音频,采样率:', audioContext.sampleRate) isRecording.value = true + // 保存stream引用用于后续停止 + ;(processor as any).stream = stream + } catch (error) { console.error('获取麦克风权限失败:', error) - ElMessage.error('无法访问麦克风,请检查权限设置') + + // 根据不同的错误类型给出具体的提示 + if (error.name === 'NotAllowedError') { + showPermissionGuide.value = true + ElMessage.error('麦克风权限被拒绝,请查看权限引导') + } else if (error.name === 'NotFoundError') { + ElMessage.error('未找到麦克风设备,请检查设备连接') + } else if (error.name === 'NotReadableError') { + ElMessage.error('麦克风被其他应用占用,请关闭其他使用麦克风的程序') + } else if (error.name === 'OverconstrainedError') { + ElMessage.error('麦克风不支持当前配置,请尝试使用其他设备') + } else if (error.name === 'SecurityError') { + ElMessage.error('安全限制:请确保在HTTPS环境下使用语音功能') + } else { + // 可能是权限问题,显示引导 + showPermissionGuide.value = true + ElMessage.error(`无法访问麦克风: ${error.message || '未知错误'}`) + } } } // 停止录音 -const stopRecording = () => { - if (!isRecording.value || !mediaRecorder) return +const stopRecording = async () => { + if (!isRecording.value) return isRecording.value = false - mediaRecorder.stop() + + if (processor && audioContext && mediaStreamSource) { + // 断开音频节点连接 + mediaStreamSource.disconnect() + processor.disconnect() + + // 停止音频流 + const stream = (processor as any).stream + if (stream) { + stream.getTracks().forEach((track: MediaStreamTrack) => track.stop()) + } + + // 处理录制的音频数据 + if (recordedBuffer.length > 0) { + const totalLength = recordedBuffer.reduce((acc, buffer) => acc + buffer.length, 0) + const mergedBuffer = mergeBuffers(recordedBuffer, totalLength) + + // 生成WAV格式音频 + const wavBlob = encodeWAV(mergedBuffer, audioContext.sampleRate) + console.log('生成WAV音频文件,大小:', wavBlob.size, 'bytes') + + await processVoiceToText(wavBlob) + } else { + ElMessage.warning('录音时间过短,请重新录制') + } + + // 清理资源 + if (audioContext.state !== 'closed') { + await audioContext.close() + } + audioContext = null + mediaStreamSource = null + processor = null + recordedBuffer = [] + } } // 处理语音转文字 @@ -188,11 +362,10 @@ const processVoiceToText = async (audioBlob: Blob) => { try { const formData = new FormData() - // 根据录音格式设置文件扩展名 - const fileExtension = audioBlob.type.includes('wav') ? 'wav' : - audioBlob.type.includes('mp4') ? 'mp3' : 'webm' + // 现在我们生成的是WAV格式 + formData.append('file', audioBlob, 'recording.wav') - formData.append('file', audioBlob, `recording.${fileExtension}`) + console.log('上传音频文件:', audioBlob.type, '大小:', audioBlob.size) const response = await voiceToText(formData) @@ -220,10 +393,21 @@ const processVoiceToText = async (audioBlob: Blob) => { } // 组件卸载时清理资源 -onUnmounted(() => { +onUnmounted(async () => { if (mediaRecorder && mediaRecorder.state !== 'inactive') { mediaRecorder.stop() } + + // 清理Web Audio API资源 + if (processor) { + processor.disconnect() + } + if (mediaStreamSource) { + mediaStreamSource.disconnect() + } + if (audioContext && audioContext.state !== 'closed') { + await audioContext.close() + } }) @@ -542,6 +726,118 @@ onUnmounted(() => { animation: spin 0.8s linear infinite; } +/* 权限引导提示样式 */ +.permission-guide { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + animation: fadeIn 0.3s ease-out; +} + +.permission-guide-content { + background: #ffffff; + border-radius: 16px; + padding: 2rem; + max-width: 480px; + margin: 1rem; + position: relative; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15); + animation: slideUp 0.3s ease-out; +} + +.permission-icon { + font-size: 3rem; + text-align: center; + margin-bottom: 1rem; +} + +.permission-text h4 { + font-size: 1.25rem; + font-weight: 600; + color: #1f2937; + margin: 0 0 0.5rem 0; + text-align: center; +} + +.permission-text p { + color: #6b7280; + margin: 0 0 1.5rem 0; + text-align: center; + line-height: 1.5; +} + +.permission-steps { + background: #f9fafb; + border-radius: 8px; + padding: 1rem; + border-left: 4px solid #4f46e5; +} + +.step { + color: #374151; + margin: 0.5rem 0; + font-size: 0.9rem; + line-height: 1.4; +} + +.step:first-child { + margin-top: 0; +} + +.step:last-child { + margin-bottom: 0; +} + +.permission-close { + position: absolute; + top: 1rem; + right: 1rem; + width: 2rem; + height: 2rem; + border: none; + background: #f3f4f6; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.25rem; + color: #6b7280; + transition: all 0.2s ease; +} + +.permission-close:hover { + background: #e5e7eb; + color: #374151; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + .input-footer { display: none; } diff --git a/chat-client/src/views/datasets/components/DocUpload.vue b/chat-client/src/views/datasets/components/DocUpload.vue index e2258ef..adb9f17 100644 --- a/chat-client/src/views/datasets/components/DocUpload.vue +++ b/chat-client/src/views/datasets/components/DocUpload.vue @@ -104,9 +104,9 @@

基础设置

- + + + @@ -553,9 +553,8 @@ const canProceedToNext = () => { } const canUpload = () => { - if (uploadForm.analysisStrategyType === 'custom') { - return uploadForm.docAnalysisStrategy.name !== '' - } + // 自定义模式不需要策略名称验证,因为UI中已经隐藏了该字段 + // 只要有基本的配置参数就可以上传 return true } @@ -641,7 +640,7 @@ const handleUpload = async () => { // 如果是自定义模式,添加解析策略 if (uploadForm.analysisStrategyType === 'custom') { requestData.docAnalysisStrategy = { - name: uploadForm.docAnalysisStrategy.name, + name: uploadForm.docAnalysisStrategy.name || '自定义策略', segmentationMode: 'custom', indexingTechnique: uploadForm.docAnalysisStrategy.indexingTechnique, docForm: 'text_model', diff --git a/chat-server/src/main/java/com/bjtds/brichat/service/dify/impl/MetadataServiceImpl.java b/chat-server/src/main/java/com/bjtds/brichat/service/dify/impl/MetadataServiceImpl.java index 8184491..8ab5024 100644 --- a/chat-server/src/main/java/com/bjtds/brichat/service/dify/impl/MetadataServiceImpl.java +++ b/chat-server/src/main/java/com/bjtds/brichat/service/dify/impl/MetadataServiceImpl.java @@ -153,7 +153,7 @@ public class MetadataServiceImpl implements MetadataService { @Override public List handleYsylcMetadata(String datasetId, String documentId, MultipartFile file,String metadataId) { - // 异步执行 + // 异步执行工作流 CompletableFuture.runAsync(() -> { String apiKey = tApiKeyService.getApiKeyFromCache(DifyConstants.API_KEY_YSYLC_DATA_PROC); workFlowService.runWorkflowByFile(documentId, apiKey, file); diff --git a/chat-server/src/main/java/com/bjtds/brichat/util/EsTDatasetFilesImporter.java b/chat-server/src/main/java/com/bjtds/brichat/util/EsTDatasetFilesImporter.java index 1412c7d..44fbb7b 100644 --- a/chat-server/src/main/java/com/bjtds/brichat/util/EsTDatasetFilesImporter.java +++ b/chat-server/src/main/java/com/bjtds/brichat/util/EsTDatasetFilesImporter.java @@ -35,7 +35,7 @@ public class EsTDatasetFilesImporter { if (datasetFiles == null) { throw new IllegalArgumentException("datasetFiles 不存在"); } - String filePath = datasetFiles.getSourceUrl(); + String filePath = datasetFiles.getDifyStoragePath(); File file = new File(filePath); if (!file.exists()) { throw new IllegalArgumentException(filePath + " 不存在"); diff --git a/chat-server/src/main/resources/application-wuhan.yml b/chat-server/src/main/resources/application-wuhan.yml index fa1bb19..8efe4bd 100644 --- a/chat-server/src/main/resources/application-wuhan.yml +++ b/chat-server/src/main/resources/application-wuhan.yml @@ -81,6 +81,3 @@ elasticsearch: #是否删除索引,重新构建索引 deleteIndex: ${es-deleteIndex:false} -voice2text: - url: ${voice2text-url:http://192.168.8.253:11023/v1/audio/transcriptions} - model: ${voice2text-model:whisper-1} \ No newline at end of file