From a7092e7b52c1aab5f45f057c05fa3c4a427ea710 Mon Sep 17 00:00:00 2001 From: shierqi <1070426942@qq.com> Date: Fri, 31 Jul 2026 10:06:27 +0800 Subject: [PATCH] feat: add optional local voice transcription --- README.md | 35 +- THIRD_PARTY_NOTICES.md | 15 + desktop/package.json | 2 +- desktop/scripts/build-backend.cjs | 110 +- docs/rtx5060-faster-whisper-gpu.md | 92 ++ frontend/assets/css/chat.css | 72 ++ frontend/components/SettingsDialog.vue | 153 +++ frontend/components/chat/ChatExportDialog.vue | 68 ++ .../chat/ChatHistoryFloatingWindows.vue | 3 +- frontend/components/chat/ChatOverlays.vue | 24 + frontend/components/chat/MessageContent.vue | 49 +- frontend/components/chat/MessageItem.vue | 4 +- frontend/composables/chat/useChatExport.js | 17 +- frontend/composables/chat/useChatMessages.js | 116 +++ frontend/composables/useApi.js | 44 +- frontend/lib/chat/message-normalizer.js | 5 + frontend/package-lock.json | 703 ++++++++++++- frontend/package.json | 10 +- .../tests/voice-transcription-message.test.js | 127 +++ frontend/vitest.config.js | 17 + main.py | 14 +- native/wce_integrity/src/css_patch.css | 19 +- pyproject.toml | 7 + src/wechat_decrypt_tool/backend_entry.py | 27 +- .../chat_export_service.py | 127 ++- src/wechat_decrypt_tool/export_integrity.py | 29 +- .../native/wce_integrity.pyd | Bin 6985030 -> 3625472 bytes .../routers/chat_export.py | 11 + src/wechat_decrypt_tool/routers/chat_media.py | 115 ++- src/wechat_decrypt_tool/runtime_settings.py | 49 + .../voice_transcription.py | 943 ++++++++++++++++++ tests/test_chat_export_html_format.py | 152 ++- tests/test_export_integrity.py | 5 + tests/test_voice_transcription.py | 465 +++++++++ tests/test_voice_transcription_contract.py | 80 ++ tests/test_voice_transcription_settings.py | 185 ++++ tools/build_wce_integrity.ps1 | 60 +- uv.lock | 405 +++++++- 38 files changed, 4277 insertions(+), 82 deletions(-) create mode 100644 docs/rtx5060-faster-whisper-gpu.md create mode 100644 frontend/tests/voice-transcription-message.test.js create mode 100644 frontend/vitest.config.js create mode 100644 src/wechat_decrypt_tool/voice_transcription.py create mode 100644 tests/test_voice_transcription.py create mode 100644 tests/test_voice_transcription_contract.py create mode 100644 tests/test_voice_transcription_settings.py diff --git a/README.md b/README.md index 51db8c11..d628f950 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,35 @@ > Excel 格式生成 `.xlsx` 文件;聊天记录、朋友圈和收藏会将对应格式文件与必要资源一起打包为 ZIP。 +### 本地语音转文字(Whisper) + +聊天页可以在保留原语音播放的同时,按需将语音识别为中文;HTML、JSON、TXT 和 Excel 导出也可以选择把转写文字与原语音一起写入归档。转写结果按账号、消息 ID、音频内容和模型缓存在账号目录的 `_cache/voice_transcripts.sqlite3`,重复查看或导出不会再次识别。 + +该能力是可选依赖,从源码运行时安装: + +```powershell +uv sync --no-editable --extra voice-transcription +``` + +默认配置使用 CPU、`medium` 模型和中文识别,并且不会自动联网下载模型。推荐提前下载模型并指定本地目录: + +```powershell +$env:WECHAT_TOOL_WHISPER_MODEL = 'D:\models\faster-whisper-medium' +$env:WECHAT_TOOL_WHISPER_DEVICE = 'cpu' +$env:WECHAT_TOOL_WHISPER_COMPUTE_TYPE = 'int8' +``` + +也可以明确允许首次识别时下载模型,此操作会访问 Hugging Face,模型文件较大: + +```powershell +$env:WECHAT_TOOL_WHISPER_MODEL = 'medium' +$env:WECHAT_TOOL_WHISPER_ALLOW_DOWNLOAD = '1' +``` + +使用 NVIDIA GPU 时可改为 `WECHAT_TOOL_WHISPER_DEVICE=cuda` 和 `WECHAT_TOOL_WHISPER_COMPUTE_TYPE=float16`。设置 `WECHAT_TOOL_WHISPER_ENABLED=0` 可以完全关闭该能力。隐私模式导出始终禁用语音转写,不会把语音内容写入归档。 + +应用设置页也可以选择 CPU 或 NVIDIA GPU。GPU 探测、CUDA 初始化失败自动回退 CPU,以及 RTX 5060 验收步骤见 [RTX 5060 faster-whisper CUDA 验收说明](docs/rtx5060-faster-whisper-gpu.md)。 + ## Windows / macOS 兼容性 | 功能 | Windows | macOS | @@ -170,7 +199,7 @@ cd WeChatDataAnalysis ```bash # 使用uv (推荐) -uv sync +uv sync --no-editable ``` #### 2.3 安装前端依赖 @@ -185,9 +214,11 @@ npm install #### 启动后端API服务 ```bash # 在项目根目录 -uv run main.py +uv run --no-sync main.py ``` +`main.py` 会在任何项目包导入前优先加入当前仓库的 `src`,并在启动日志中打印 `wechat_decrypt_tool` 的代码来源。开发运行时应指向当前仓库的 `src/wechat_decrypt_tool/__init__.py`,而不是 `.venv/Lib/site-packages` 中的旧副本。Windows 中文路径下不要改用 editable 安装;Python 3.11 可能按系统代码页读取 uv 生成的 UTF-8 `.pth`,导致解释器启动失败。 + #### 启动前端开发服务器 ```bash # 在frontend目录 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 7dbb2fe3..fdd71b16 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -28,3 +28,18 @@ Desktop distributions include the platform-specific FFmpeg executable from `ffmp - Binary builds: https://github.com/ffbinaries/ffbinaries-prebuilt/releases - Package license: GPL-3.0-or-later - Distributed license files: `ffmpeg/LICENSE` and `ffmpeg/ffmpeg.LICENSE` + +## Optional voice transcription runtime + +The optional local voice transcription feature uses the following Python packages. Windows standalone backends built with the `voice-transcription` extra bundle these packages and their required runtime data. + +| Package | Upstream project | License | +| --- | --- | --- | +| `faster-whisper` | https://github.com/SYSTRAN/faster-whisper | MIT | +| `CTranslate2` | https://github.com/OpenNMT/CTranslate2 | MIT | +| `PyAV` | https://github.com/PyAV-Org/PyAV | BSD-3-Clause | +| `OpenCC Python Reimplemented` | https://github.com/yichen0831/opencc-python | Apache-2.0 | +| `ONNX Runtime` | https://github.com/microsoft/onnxruntime | MIT | +| `Hugging Face tokenizers` | https://github.com/huggingface/tokenizers | Apache-2.0 | + +Whisper model weights are not included in this repository or its pull request. Users download or provide model weights separately and must follow the license and usage terms published with the selected model. diff --git a/desktop/package.json b/desktop/package.json index c9af8e6f..d77ece5a 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -7,7 +7,7 @@ "dev": "node scripts/dev.cjs", "dev:static": "npm --prefix ../frontend run generate && cross-env ELECTRON_START_URL=http://127.0.0.1:10392 electron .", "build:ui": "npm --prefix ../frontend run generate && node scripts/copy-ui.cjs", - "build:backend": "uv sync --extra build && node scripts/build-backend.cjs", + "build:backend": "uv sync --no-editable --extra build --extra voice-transcription && node scripts/build-backend.cjs", "build:icon": "node scripts/build-icon.cjs", "smoke:mac": "node scripts/smoke-macos-package.cjs", "dist": "npm run dist:win", diff --git a/desktop/scripts/build-backend.cjs b/desktop/scripts/build-backend.cjs index 76caae20..63bd3378 100644 --- a/desktop/scripts/build-backend.cjs +++ b/desktop/scripts/build-backend.cjs @@ -1,9 +1,15 @@ const fs = require("fs"); +const os = require("os"); const path = require("path"); const { spawnSync } = require("child_process"); const repoRoot = path.resolve(__dirname, "..", ".."); const entry = path.join(repoRoot, "src", "wechat_decrypt_tool", "backend_entry.py"); +const venvPythonExecutable = path.join( + repoRoot, + ".venv", + process.platform === "win32" ? "Scripts/python.exe" : "bin/python", +); const distDir = path.join(repoRoot, "desktop", "resources", "backend"); const workDir = path.join(repoRoot, "desktop", "build", "pyinstaller"); @@ -16,7 +22,36 @@ fs.mkdirSync(specDir, { recursive: true }); const integrityManifest = path.join(repoRoot, "native", "wce_integrity", "Cargo.toml"); const integrityTargetDir = path.join(repoRoot, "native", "wce_integrity", "target", "release"); let integrityNativeBinary = null; -if (process.platform === "darwin" || process.platform === "linux") { +if (process.platform === "win32") { + const nativeBuild = spawnSync( + "powershell.exe", + [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + path.join(repoRoot, "tools", "build_wce_integrity.ps1"), + ], + { + cwd: repoRoot, + env: { + ...process.env, + WCE_UI_PUBLIC_DIR: path.join(repoRoot, "frontend", ".output", "public"), + }, + stdio: "inherit", + } + ); + if ((nativeBuild.status ?? 1) !== 0) { + console.error("Failed to build the Windows wce_integrity module."); + process.exit(nativeBuild.status ?? 1); + } + const windowsNativeDir = path.join(repoRoot, "src", "wechat_decrypt_tool", "native"); + integrityNativeBinary = path.join(windowsNativeDir, "wce_integrity.pyd"); + if (!fs.existsSync(integrityNativeBinary)) { + console.error("Windows wce_integrity build completed without the canonical wce_integrity.pyd artifact."); + process.exit(1); + } +} else if (process.platform === "darwin" || process.platform === "linux") { const fileName = process.platform === "darwin" ? "libwce_integrity.dylib" : "libwce_integrity.so"; const nativeBuild = spawnSync( "cargo", @@ -41,17 +76,23 @@ if (process.platform === "darwin" || process.platform === "linux") { } } +if (!fs.existsSync(venvPythonExecutable)) { + console.error(`Missing virtual environment Python: ${venvPythonExecutable}`); + process.exit(1); +} const integrityPreflight = spawnSync( - "uv", + venvPythonExecutable, [ - "run", - "python", "-c", [ "from wechat_decrypt_tool.export_integrity import load_wce_integrity_native", "w=load_wce_integrity_native()", "required=('chat','sns','records-project','records-generic','contacts')", "assert all(w.export_css(kind).strip() for kind in required)", + "css=w.export_css('chat')", + "compact=''.join(css.split())", + "assert '.wechat-voice-transcript' in css", + "assert '.wechat-voice-wrapper{display:flex;flex-direction:column' in compact", "assert callable(w.record_file) and callable(w.seal_export)", ].join(";"), ], @@ -132,6 +173,7 @@ if (fs.existsSync(wasmDir)) { if (process.platform === "win32") { for (const item of fs.readdirSync(nativeDir, { withFileTypes: true })) { if (!item.isFile() || !/\.(dll|pyd)$/i.test(item.name)) continue; + if (/^wce_integrity.*\.pyd$/i.test(item.name) && item.name.toLowerCase() !== "wce_integrity.pyd") continue; fs.copyFileSync(path.join(nativeDir, item.name), path.join(runtimeNativeDir, item.name)); } } else if (process.platform === "darwin") { @@ -162,8 +204,6 @@ if (process.platform === "win32") { } const args = [ - "run", - "pyinstaller", "--noconfirm", "--clean", "--name", @@ -179,6 +219,14 @@ const args = [ pyInstallerAddData(runtimeNativeDir, "wechat_decrypt_tool/native"), "--add-data", pyInstallerAddData(skillDir, "skills/wechat-mcp-copilot"), + "--collect-all", + "faster_whisper", + "--collect-all", + "ctranslate2", + "--collect-all", + "av", + "--collect-all", + "opencc", entry, ]; @@ -198,11 +246,59 @@ if (integrityNativeBinary) { ); } -const r = spawnSync("uv", args, { cwd: repoRoot, stdio: "inherit" }); +const pyInstallerExecutable = path.join( + repoRoot, + ".venv", + process.platform === "win32" ? "Scripts/pyinstaller.exe" : "bin/pyinstaller", +); +if (!fs.existsSync(pyInstallerExecutable)) { + console.error(`Missing PyInstaller executable: ${pyInstallerExecutable}`); + process.exit(1); +} +const r = spawnSync(pyInstallerExecutable, args, { cwd: repoRoot, stdio: "inherit" }); if ((r.status ?? 1) !== 0) { process.exit(r.status ?? 1); } +const packagedBackend = path.join( + distDir, + process.platform === "win32" ? "wechat-backend.exe" : "wechat-backend", +); +const openccSmokeDir = fs.mkdtempSync(path.join(os.tmpdir(), "wda-opencc-smoke-")); +try { + const smokeEnv = { ...process.env, PYTHONPATH: "" }; + delete smokeEnv.PYTHONHOME; + const smoke = spawnSync(packagedBackend, ["--smoke-opencc"], { + cwd: openccSmokeDir, + env: smokeEnv, + encoding: "utf8", + windowsHide: true, + }); + if ((smoke.status ?? 1) !== 0) { + process.stderr.write(smoke.stderr || smoke.stdout || "Packaged OpenCC smoke test failed.\n"); + process.exit(smoke.status ?? 1); + } + const outputLines = String(smoke.stdout || "").trim().split(/\r?\n/).filter(Boolean); + let payload; + try { + payload = JSON.parse(outputLines.at(-1) || ""); + } catch { + console.error(`Packaged OpenCC smoke test returned invalid JSON: ${smoke.stdout || ""}`); + process.exit(1); + } + const expected = { + "繁體中文": "繁体中文", + "軟體與資料庫": "软体与资料库", + }; + if (!payload.frozen || JSON.stringify(payload.results) !== JSON.stringify(expected)) { + console.error(`Packaged OpenCC smoke test returned an unexpected result: ${JSON.stringify(payload)}`); + process.exit(1); + } + console.log(`Packaged OpenCC smoke test passed: ${JSON.stringify(payload)}`); +} finally { + fs.rmSync(openccSmokeDir, { recursive: true, force: true }); +} + // Keep a stable external native folder for packaged runtime to avoid relying on // onefile temp extraction paths when wcdb_api.dll performs environment checks. const packagedNativeDir = path.join(distDir, "native"); diff --git a/docs/rtx5060-faster-whisper-gpu.md b/docs/rtx5060-faster-whisper-gpu.md new file mode 100644 index 00000000..e4d56f68 --- /dev/null +++ b/docs/rtx5060-faster-whisper-gpu.md @@ -0,0 +1,92 @@ +# RTX 5060 faster-whisper CUDA 验收说明 + +状态:实现完成,待 RTX 5060 实机验收。更新:2026-07-29。 + +## 适用范围 + +本项目的语音转文字使用 `faster-whisper` 和 CTranslate2,不使用 PyTorch。因此,同类 PyTorch 音色克隆项目采用的 `cu128` wheel 安装路线不能直接复制到这里;本说明只复用其 RTX 50 系显卡驱动、磁盘、模型缓存与验收原则。 + +默认设备是 CPU `int8`。在有 NVIDIA GPU 的机器上,设置页可选择 NVIDIA GPU;首次识别会使用 CUDA `float16` 加载模型。CUDA 探测或模型初始化失败时,会自动改用 CPU `int8`,不影响语音播放、聊天查看或导出任务。 + +## 服务器前置检查 + +先在目标机上执行只读检查: + +```bash +nvidia-smi +python3 --version +ffmpeg -version +df -h +``` + +`nvidia-smi` 必须能列出 RTX 5060。若它不可用,先由服务器管理员修复 NVIDIA driver;不要通过反复重装 Python 包排查驱动问题。 + +本项目不需要安装 `torch`、`torchaudio` 或复用音色克隆项目的 wheelhouse。安装当前项目的语音依赖: + +```bash +uv sync --no-editable --extra voice-transcription +``` + +当前锁定的 `CTranslate2 4.8.1` 在 Linux GPU 环境需要 NVIDIA driver、CUDA 12.x 和 cuDNN 9 运行库;PyTorch 的 `cu128` wheel 不会为 CTranslate2 提供这些库。先完成只读探测,再由服务器管理员或已获授权的维护人员按 CTranslate2 对应版本的官方安装说明补齐运行库。若采用 PyPI 的用户级 CUDA/cuDNN 包,也应只安装到本项目虚拟环境,并在启动后端的同一用户进程中设置其库路径;不要修改系统 `/usr`、全局 `LD_LIBRARY_PATH` 或共享 CUDA 安装。 + +## 模型准备 + +当前默认模型是 `Systran/faster-whisper-medium`,磁盘约 1.43 GiB。生产验收前应将模型放进目标机 Hugging Face 缓存,或将 `WECHAT_TOOL_WHISPER_MODEL` 指向完整的本地模型目录;默认禁止首次识别联网下载。 + +建议把缓存放在数据盘,例如: + +```bash +export HF_HOME=/mnt/sdb/wechat-data-analysis/hf-cache +export WECHAT_TOOL_WHISPER_MODEL=medium +export WECHAT_TOOL_WHISPER_ALLOW_DOWNLOAD=0 +``` + +如果缓存尚未准备好,可在已获联网许可的维护窗口临时把 `WECHAT_TOOL_WHISPER_ALLOW_DOWNLOAD=1`,完成下载后恢复为 `0`。模型与缓存目录包含本地运行资产,不要提交到 Git。 + +## CUDA 验收 + +启动后端后,先检查 CTranslate2 是否能识别 CUDA: + +```bash +python - <<'PY' +import ctranslate2 +print("cuda device count:", ctranslate2.get_cuda_device_count()) +PY +``` + +期望设备数量大于零。然后打开应用的“设置 -> 语音转文字”: + +1. 页面应显示检测到的 NVIDIA GPU;RTX 5060 名称会由 `nvidia-smi` 提供。 +2. 选择“NVIDIA GPU”。该偏好会保存到应用 `output/runtime_settings.json`。 +3. 对一条清晰中文微信语音点击“转文字”,或用同一会话开启“语音转文字”导出。 +4. 重新打开设置页,确认“实际”显示为“NVIDIA GPU”。 +5. 导出结果仍需包含可播放的 `media/voices/*.mp3` 与中文转写文本。 + +若 CUDA 探测通过但模型初始化失败,设置页会保留“NVIDIA GPU”为已选设备,并显示自动回退原因;“实际”应显示为 CPU。此时语音转写仍应成功,随后应记录 CTranslate2、NVIDIA driver 和 CUDA 运行库版本再排查。 + +## 配置优先级 + +推理设备优先级如下: + +```text +WECHAT_TOOL_WHISPER_DEVICE 环境变量 +> 设置页保存的设备偏好 +> 默认 CPU +``` + +部署脚本若设置了 `WECHAT_TOOL_WHISPER_DEVICE`,界面会标记为“由启动环境变量固定”,避免用户误以为修改已生效。未设置该环境变量时,使用设置页选择 CPU 或 NVIDIA GPU。 + +## 验收记录 + +在 RTX 5060 服务器完成实测后,记录以下信息: + +```text +nvidia-smi 的 GPU 名称、Driver Version、CUDA Version +Python / faster-whisper / CTranslate2 版本 +CTranslate2 CUDA device count +设置页检测结果、已选设备、实际设备、是否发生回退 +单条语音首次识别耗时与缓存后二次识别耗时 +导出中的语音数量、成功/失败/缓存统计 +``` + +不要为了制造回退场景而在共享服务器上卸载驱动、修改系统 CUDA、重启机器或停止他人进程。自动回退路径由本地单元测试覆盖;服务器只验证真实 NVIDIA GPU 成功路径。 diff --git a/frontend/assets/css/chat.css b/frontend/assets/css/chat.css index 8fedaae6..8dc1aa0c 100644 --- a/frontend/assets/css/chat.css +++ b/frontend/assets/css/chat.css @@ -213,8 +213,18 @@ /* 语音消息样式 - 微信风格 */ .wechat-voice-wrapper { display: flex; + flex-direction: column; width: 100%; position: relative; + gap: 6px; +} + +.wechat-voice-wrapper--received { + align-items: flex-start; +} + +.wechat-voice-wrapper--sent { + align-items: flex-end; } .wechat-voice-bubble { @@ -267,6 +277,68 @@ border-radius: 2px; } +/* 语音转文字气泡 - 微信风格:语音下方独立气泡,无尖角,随内容收缩 */ +.wechat-voice-transcript { + width: fit-content; + max-width: min(404px, 100%); + padding: 8px 12px; + border-radius: var(--message-radius); + font-size: 14px; + line-height: 1.55; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.wechat-voice-transcript--received { + background: var(--chat-bubble-received); + color: var(--chat-bubble-received-text); +} + +.wechat-voice-transcript--sent { + background: var(--chat-bubble-sent); + color: var(--chat-bubble-sent-text); +} + +.wechat-voice-transcript__action, +.wechat-voice-transcript__retry { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0; + border: none; + background: none; + color: inherit; + font: inherit; + opacity: 0.85; + cursor: pointer; +} + +.wechat-voice-transcript__action:hover, +.wechat-voice-transcript__retry:hover { + opacity: 1; +} + +.wechat-voice-transcript__status { + display: inline-flex; + align-items: center; + gap: 6px; + opacity: 0.75; +} + +.wechat-voice-transcript__text { + margin: 0; +} + +.wechat-voice-transcript--error .wechat-voice-transcript__error { + opacity: 0.85; +} + +.wechat-voice-transcript__retry { + margin-left: 8px; + text-decoration: underline; + text-underline-offset: 2px; +} + .wechat-voice-content { display: flex; align-items: center; diff --git a/frontend/components/SettingsDialog.vue b/frontend/components/SettingsDialog.vue index bb6ce8f2..d5cd7d93 100644 --- a/frontend/components/SettingsDialog.vue +++ b/frontend/components/SettingsDialog.vue @@ -251,6 +251,69 @@ +
+
语音转文字
+
+
+
+
+
推理设备
+
CPU 兼容所有设备;NVIDIA GPU 使用 CUDA 加速,初始化失败会自动回退 CPU。
+
+
+ + +
+
+ +
正在检测本地 Whisper 与 CUDA 状态...
+ +
{{ voiceDeviceMessage }}
+ +
+
+
+
MCP 接入
@@ -457,6 +520,7 @@ const api = useApi() const settingNavItems = [ { key: 'desktop', label: '桌面行为', hint: '启动 / 关闭 / 端口' }, + { key: 'voice', label: '语音转文字', hint: 'CPU / NVIDIA GPU' }, { key: 'mcp', label: 'MCP 接入', hint: '手机 / Skill / 工具' }, { key: 'startup', label: '启动偏好', hint: '默认页面' }, { key: 'updates', label: '更新', hint: '版本信息 / 检查更新' }, @@ -467,6 +531,7 @@ const activeSection = ref(settingNavItems[0].key) const contentScrollRef = ref(null) const desktopSectionRef = ref(null) const desktopLogFileRef = ref(null) +const voiceSectionRef = ref(null) const mcpSectionRef = ref(null) const startupSectionRef = ref(null) const updatesSectionRef = ref(null) @@ -580,6 +645,43 @@ const desktopLogFileText = computed(() => { return v || '—' }) +const voiceStatusLoading = ref(false) +const voiceDeviceBusy = ref(false) +const voiceDeviceError = ref('') +const voiceDeviceMessage = ref('') +const voiceDevicePreference = ref('cpu') +const voiceDeviceSource = ref('default') +const voiceActiveDevice = ref('') +const voiceModel = ref('medium') +const voiceModelReady = ref(false) +const voiceModelDownloadRequired = ref(false) +const voiceStatusReason = ref('') +const voiceCuda = ref(null) +const voiceFallbackReason = ref('') +const voiceDeviceLocked = computed(() => voiceDeviceSource.value === 'env') +const voiceCudaAvailable = computed(() => !!voiceCuda.value?.available) +const voiceCudaReason = computed(() => String(voiceCuda.value?.reason || '').trim()) +const voiceModelText = computed(() => String(voiceModel.value || 'medium').trim() || 'medium') +const voiceModelStatusText = computed(() => { + if (voiceModelReady.value) return '已就绪' + if (voiceModelDownloadRequired.value) return '未缓存,首次使用时下载' + return '未准备好' +}) +const voiceDeviceLabel = computed(() => voiceDevicePreference.value === 'cuda' ? 'NVIDIA GPU' : 'CPU') +const voiceActiveDeviceLabel = computed(() => { + if (voiceActiveDevice.value === 'cuda') return 'NVIDIA GPU' + if (voiceActiveDevice.value === 'cpu') return 'CPU' + return '尚未加载' +}) +const voiceCudaStatusText = computed(() => { + const devices = Array.isArray(voiceCuda.value?.devices) ? voiceCuda.value.devices : [] + const labels = devices.map((item) => String(item?.name || '').trim()).filter(Boolean) + if (voiceCudaAvailable.value) { + return labels.length ? `已检测到 NVIDIA GPU:${labels.join('、')}` : '已检测到可用的 NVIDIA CUDA 设备。' + } + return voiceCudaReason.value || '未检测到可用的 NVIDIA CUDA 设备。' +}) + const mcpLanAccessEnabled = ref(false) const mcpLanAccessLoading = ref(false) const mcpLanAccessError = ref('') @@ -691,6 +793,7 @@ const refreshDesktopOutputDirProgress = async () => { const sectionElements = computed(() => [ { key: 'desktop', el: desktopSectionRef.value }, + { key: 'voice', el: voiceSectionRef.value }, { key: 'mcp', el: mcpSectionRef.value }, { key: 'startup', el: startupSectionRef.value }, { key: 'updates', el: updatesSectionRef.value }, @@ -789,6 +892,55 @@ const waitForBackendHealth = async (timeoutMs = 30_000) => { } } +const applyVoiceTranscriptionStatus = (status) => { + if (!status || typeof status !== 'object') return + const requestedDevice = String(status.requestedDevice || status.device || 'cpu').trim().toLowerCase() + voiceDevicePreference.value = requestedDevice === 'cuda' ? 'cuda' : 'cpu' + voiceDeviceSource.value = String(status.deviceSource || 'default').trim() || 'default' + voiceActiveDevice.value = String(status.activeDevice || '').trim().toLowerCase() + voiceModel.value = String(status.model || 'medium').trim() || 'medium' + voiceModelReady.value = status.modelReady === true + voiceModelDownloadRequired.value = status.modelDownloadRequired === true + voiceStatusReason.value = String(status.reason || '').trim() + voiceCuda.value = status.cuda && typeof status.cuda === 'object' ? status.cuda : null + voiceFallbackReason.value = String(status.fallbackReason || '').trim() +} + +const refreshVoiceTranscriptionStatus = async () => { + if (!process.client || typeof window === 'undefined') return + voiceStatusLoading.value = true + voiceDeviceError.value = '' + try { + applyVoiceTranscriptionStatus(await api.getVoiceTranscriptionStatus()) + } catch (e) { + voiceDeviceError.value = e?.message || '读取语音转文字运行状态失败' + } finally { + voiceStatusLoading.value = false + } +} + +const setVoiceDevice = async (device) => { + const next = String(device || '').trim().toLowerCase() + if (!['cpu', 'cuda'].includes(next) || voiceDeviceBusy.value || voiceDeviceLocked.value) return + if (next === 'cuda' && !voiceCudaAvailable.value) return + + voiceDeviceBusy.value = true + voiceDeviceError.value = '' + voiceDeviceMessage.value = '' + try { + const resp = await api.setVoiceTranscriptionDevice(next) + applyVoiceTranscriptionStatus(resp?.configuration || resp) + voiceDeviceMessage.value = next === 'cuda' + ? '已选择 NVIDIA GPU;下一次语音识别会尝试 CUDA,失败时自动回退 CPU。' + : '已切换为 CPU;下一次语音识别会使用 CPU int8。' + } catch (e) { + voiceDeviceError.value = e?.message || '设置语音转文字推理设备失败' + await refreshVoiceTranscriptionStatus() + } finally { + voiceDeviceBusy.value = false + } +} + const copyMcpText = async (key, text) => { if (!process.client || typeof window === 'undefined') return const value = String(text || '').trim() @@ -1290,6 +1442,7 @@ const refreshSettingsDialogData = async () => { const tasks = [ refreshDesktopBackendPort(), + refreshVoiceTranscriptionStatus(), refreshMcpLanAccess(), refreshMcpToken(), refreshBackendLogFileInfo(), diff --git a/frontend/components/chat/ChatExportDialog.vue b/frontend/components/chat/ChatExportDialog.vue index 11241764..cce0a401 100644 --- a/frontend/components/chat/ChatExportDialog.vue +++ b/frontend/components/chat/ChatExportDialog.vue @@ -206,6 +206,33 @@ {{ opt.label }}
+ +
定位引用消息 + + 正在检查本地模型… + {{ voiceTranscriptionUnavailableReason }} + + + 正在转文字… + +

{{ message.voiceTranscript || '未识别到文字' }}

+ +