From 5b0aedadf4ccd2b28a7f6e3b75c986d7298d1e0d Mon Sep 17 00:00:00 2001 From: xiaosheng <73678111+xiaoshengbao@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:59:38 +0800 Subject: [PATCH 1/6] feat(stt): install and validate Qwen GPU runtime on demand --- .github/workflows/qwen-gpu-runtime.yml | 54 ++ desktop/scripts/build-backend.cjs | 8 + docs/qwen-gpu-runtime-2026-10-05.md | 56 ++ frontend/components/SettingsDialog.vue | 94 +- frontend/composables/useApi.js | 7 + frontend/tests/voice-model-settings.test.mjs | 4 +- src/wechat_decrypt_tool/asr_models.py | 6 +- src/wechat_decrypt_tool/asr_worker.py | 68 ++ src/wechat_decrypt_tool/qwen_gpu_runtime.py | 305 +++++++ .../resources/qwen_gpu_runtime.json | 845 ++++++++++++++++++ src/wechat_decrypt_tool/routers/chat_media.py | 21 + .../voice_transcription.py | 5 + tests/test_qwen_gpu_runtime.py | 176 ++++ tools/generate_qwen_gpu_manifest.py | 62 ++ tools/qwen_runtime_stdlib.py | 14 + 15 files changed, 1716 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/qwen-gpu-runtime.yml create mode 100644 docs/qwen-gpu-runtime-2026-10-05.md create mode 100644 src/wechat_decrypt_tool/qwen_gpu_runtime.py create mode 100644 src/wechat_decrypt_tool/resources/qwen_gpu_runtime.json create mode 100644 tests/test_qwen_gpu_runtime.py create mode 100644 tools/generate_qwen_gpu_manifest.py create mode 100644 tools/qwen_runtime_stdlib.py diff --git a/.github/workflows/qwen-gpu-runtime.yml b/.github/workflows/qwen-gpu-runtime.yml new file mode 100644 index 00000000..d17489db --- /dev/null +++ b/.github/workflows/qwen-gpu-runtime.yml @@ -0,0 +1,54 @@ +name: Qwen GPU Runtime Checks + +on: + pull_request: + paths: + - '.github/workflows/qwen-gpu-runtime.yml' + - 'src/wechat_decrypt_tool/qwen_gpu_runtime.py' + - 'src/wechat_decrypt_tool/asr_*.py' + - 'src/wechat_decrypt_tool/voice_transcription.py' + - 'src/wechat_decrypt_tool/routers/chat_media.py' + - 'src/wechat_decrypt_tool/resources/qwen_gpu_runtime.json' + - 'tests/test_qwen_gpu_runtime.py' + - 'tests/test_asr_upgrade.py' + - 'tests/test_voice_transcription.py' + - 'frontend/components/SettingsDialog.vue' + - 'frontend/composables/useApi.js' + - 'frontend/tests/voice-model-settings.test.mjs' + - 'desktop/scripts/build-backend.cjs' + - 'tools/*qwen*.py' + - 'pyproject.toml' + - 'uv.lock' + workflow_dispatch: + +permissions: + contents: read + +jobs: + runtime: + strategy: + fail-fast: false + matrix: + os: [windows-2022, macos-14] + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.11' + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + - run: python -m pip install uv + # 公共 runner 不依赖显卡或 3 GB 的组件下载,验证续传、隔离、取消和发布清单。 + - run: uv sync --locked --extra voice-transcription + - run: >- + uv run --no-sync pytest -q + tests/test_qwen_gpu_runtime.py + tests/test_asr_upgrade.py + tests/test_voice_transcription.py + - name: External runtime standard library discovery + run: uv run --no-sync python tools/qwen_runtime_stdlib.py + - name: Voice settings contracts + run: node --test frontend/tests/voice-model-settings.test.mjs diff --git a/desktop/scripts/build-backend.cjs b/desktop/scripts/build-backend.cjs index a0bb2797..4a0d8c3f 100644 --- a/desktop/scripts/build-backend.cjs +++ b/desktop/scripts/build-backend.cjs @@ -655,6 +655,8 @@ function main() { "sherpa_onnx", "--add-data", pyInstallerAddData(path.join(repoRoot, "src/wechat_decrypt_tool/resources/voice_models.json"), "wechat_decrypt_tool/resources"), + "--add-data", + pyInstallerAddData(path.join(repoRoot, "src/wechat_decrypt_tool/resources/qwen_gpu_runtime.json"), "wechat_decrypt_tool/resources"), "--collect-all", "watchfiles", ...aiPackagingArgs(repoRoot), @@ -667,6 +669,12 @@ function main() { } else { args.splice(args.length - 1, 0, "--exclude-module", "torch", "--exclude-module", "transformers"); } + // 下载后的推理库会动态导入标准库,冻结分析无法从排除的 Torch 源码推导这些依赖。 + if (process.platform === "win32") { + const stdlib = spawnSync("uv", ["run", "--no-sync", "python", path.join(repoRoot, "tools/qwen_runtime_stdlib.py")], { cwd: repoRoot, encoding: "utf8", windowsHide: true }); + if (stdlib.status !== 0) throw new Error(`Qwen runtime standard library discovery failed: ${stdlib.stderr}`); + for (const moduleName of JSON.parse(stdlib.stdout)) args.splice(args.length - 1, 0, "--hidden-import", moduleName); + } if (process.platform === "win32") { args.splice( diff --git a/docs/qwen-gpu-runtime-2026-10-05.md b/docs/qwen-gpu-runtime-2026-10-05.md new file mode 100644 index 00000000..6da08171 --- /dev/null +++ b/docs/qwen-gpu-runtime-2026-10-05.md @@ -0,0 +1,56 @@ +# Qwen GPU 组件按需安装与真实验收 + +## 当前行为 + +设置中的 Qwen GPU 卡片提供“下载并启用”,已有模型但缺少依赖时提供“安装 GPU 组件并启用”。组件下载、校验、解压、显卡检查、模型检查全部通过后才修改语音模型设置。失败或暂停时保留原选择。 + +组件安装在应用数据目录的 `voice_runtimes/<版本>/cp311` 等目录,下载缓存独立保留。Windows x64 的 Python 3.11、3.12、3.13 有各自的固定 wheel 清单;下载总量约 2.91 GB(界面按二进制单位显示约 2.71 GB),初次安装预留约 9 GB 可用空间。普通安装包无需预先内置 Torch,也不要求用户安装系统 Python 或 CUDA Toolkit。 + +后台任务支持暂停、重试和 HTTP 续传。应用重启时,未完成任务显示暂停,用户点击继续才恢复。语音工作进程和 CUDA 检测进程加载外置依赖;主进程不会把组件加入全局导入路径。组件按发布清单校验 SHA-256,解压时拒绝越界路径和符号链接,组件目录拒绝 Windows 目录联接。 + +接口: + +- `POST /api/chat/media/voice/transcription/qwen-gpu/prepare`:准备组件及模型,验证后启用。 +- `POST /api/chat/media/voice/transcription/qwen-gpu/pause`:暂停,保留组件下载缓存。 +- `GET /api/chat/media/voice/transcription/qwen-gpu/status`:读取状态及进度。 +- 原有模型下载接口继续只下载模型,保持已有调用的语义。 + +模型下载阶段的暂停会取消原模型下载任务,组件 wheel 支持续传;恢复时未完成的模型下载遵循现有模型管理器行为。完成的模型不会因为暂停而删除。 + +## 真实验收证据 + +本机 Windows x64、CPython 3.11.3、RTX 4070 SUPER 12 GB、NVIDIA 驱动 596.49。测试使用独立的数据和浏览器目录,没有修改真实账号的转写缓存或语音设置。语音来源为此前验收保留的真实 SILK 子集,未使用合成音频。 + +| 项目 | 实际结果 | +| --- | --- | +| 官方组件下载 | 下载全部 34 个 wheel,总计 2,907,393,190 字节,逐文件 SHA-256 通过;中断后保留的下载继续完成 | +| 冻结程序加载外置组件 | 排除内置 Torch、Transformers 的冻结程序成功加载下载目录中的 Torch 2.9.1+cu128,执行 CUDA 张量运算并加载 Qwen 模型 | +| 真实桌面操作 | 在 Electron 应用内看到“模型已下载 · 缺少组件”,点击安装后能读到实际安装任务;点击暂停后任务停止,原模型仍为 CTC | +| 重启恢复 | 停止并重新启动验收应用,任务仍为暂停;界面显示“继续安装并启用”,继续后完成实际解压、CUDA 检查和模型加载 | +| 自动启用 | 组件任务返回 done,界面选择 Qwen GPU,设备显示 NVIDIA GPU / RTX 4070 SUPER | +| 真实语音 | 经真实冻结后端的正式语音 HTTP 接口处理 20 条 SILK,20/20 成功,实际设备 CUDA、精度 BF16 | +| 缓存复查 | 对每条语音再次请求,20/20 命中缓存,文字与首次结果一致 | +| 最终构建复查 | 最终代码重新冻结后,重启隔离应用,20/20 持久化缓存通过;另外强制执行 3 条真实 SILK,全部使用 CUDA / BF16 成功 | + +20 条音频总长 202.54 秒,强制转写合计 52.95 秒,包括首次模型加载。这是本机该语料单轮耗时,不代表其他硬件或录音的性能保证,也不等于识别准确率测评。 + +提交 PR 前,又将本次改动迁入最新主分支 `754b49f3`,在独立工作树中安装锁定的 CPU 依赖(不安装内置 Torch / Transformers),通过项目正式 `desktop/scripts/dev.cjs` 启动 Nuxt、Electron 和后端。桌面语音设置实际显示 Qwen GPU 已选和 RTX 4070 SUPER;后端从已下载的外置组件执行第二轮 20 条真实 SILK 转写及 20 次缓存复查,全部通过,强制转写合计 61.68 秒。隔离语料只有转写所需的媒体与最小数据库标记,不用于验收聊天列表。 + +验收程序和日志保存在 `tmp/qwen-gpu-acceptance/`:`real-api-summary.json`、`real-api.log`、`final-binary-summary.json`、`final-binary.log`、`prepare-result.json`、`frozen-model-check.json`、构建日志及隔离数据。`transcripts.private.json` 保留逐条文字,仅用于本机检查,不进入本说明或 Git。 + +## 自动化检查与构建 + +- 后端组件、语音升级及转写相关检查:94 项通过,另有 6 项子测试通过。 +- 前端现有语音设置契约:10 项通过。 +- Nuxt 静态构建通过;本次设置组件机械检测无新增结果。 +- 最新主分支的完整前端 Vitest:29 个文件、388 项测试通过。 +- 项目正式 CPU 后端构建通过,冻结 OpenCC、watchfiles、AI 运行检查通过。 +- 冻结验证中发现并修正了动态标准库导入缺失,以及 `TorchVersion` 经 Pipe 反序列化时要求父进程导入 Torch 的问题。 + +组件清单由 `tools/generate_qwen_gpu_manifest.py` 根据 `uv.lock` 生成,新增或升级组件时应更新发布版本 ID 并重新进行冻结程序验收。`tools/qwen_runtime_stdlib.py` 保留外置依赖需要的可用标准库,使 CPU 冻结程序也能加载下载后的推理库。 + +## 未完成或未实际覆盖的项目 + +正式安装程序未完成签名验收。本机缺少 `WCE_WINDOWS_CLIENT_CERT_THUMBPRINT` 所需的发布签名证书配置,正式 electron-builder 流程在签名阶段失败,没有关闭签名要求或修改验证策略。桌面功能验收使用已生成的未签名应用目录;将正式后端构建产生、且通过现有校验的 native 资源补入测试目录后运行。此目录仅用于本机验收,不作为正式安装包发布。 + +Python 3.12/3.13 的 wheel 兼容标签和清单已有自动检查,但没有在这些解释器下执行真实模型推理;其他 NVIDIA 显卡、驱动以及无显卡电脑没有做物理换机验收。损坏文件、错误续传位置、路径越界、任务中断及模型下载取消等异常由自动化测试覆盖。 diff --git a/frontend/components/SettingsDialog.vue b/frontend/components/SettingsDialog.vue index 4737ade8..6ea0ff66 100644 --- a/frontend/components/SettingsDialog.vue +++ b/frontend/components/SettingsDialog.vue @@ -364,6 +364,16 @@
{{ qwenStageText(model) }}
++ {{ formatBytes(model.runtimeComponent.job.bytes || 0) }} / {{ formatBytes(model.runtimeComponent.job.total || model.runtimeComponent.size) }} +
+{{ model.runtimeComponent.job.error }}
+