From c912409343ed7a6bd66823e994d25487ed42cf9a Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Sat, 5 Sep 2026 01:25:43 +0800 Subject: [PATCH] =?UTF-8?q?fix(settings):=20=E8=A1=A5=E9=BD=90CUDA?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E7=BB=84=E4=BB=B6=E4=B8=8B=E8=BD=BD=E4=B8=8E?= =?UTF-8?q?=E5=AE=89=E8=A3=85=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/local_model_routes.py | 12 ++ backend/app/local_models/components.py | 111 ++++++++++++++++++ backend/app/local_models/runtime.py | 3 + backend/app/main.py | 2 + backend/scripts/install-model-runtime.ps1 | 11 +- backend/tests/test_runtime_components.py | 85 ++++++++++++++ docs/contracts/第二阶段接口契约-开发版.md | 2 + .../多模态管线与模型运行开发说明.md | 2 + .../阶段F-Embedding与知识库问题与解决方案.md | 8 ++ .../settings/LocalModelSettings.spec.ts | 26 ++++ .../features/settings/LocalModelSettings.vue | 25 ++++ 11 files changed, 284 insertions(+), 3 deletions(-) create mode 100644 backend/app/local_models/components.py create mode 100644 backend/tests/test_runtime_components.py create mode 100644 frontend/src/features/settings/LocalModelSettings.spec.ts diff --git a/backend/app/local_model_routes.py b/backend/app/local_model_routes.py index d3cb20c..fd79d8c 100644 --- a/backend/app/local_model_routes.py +++ b/backend/app/local_model_routes.py @@ -7,6 +7,18 @@ from app.local_models.runtime import RuntimeConfig, configuration, configure, in router = APIRouter(prefix="/api/local-models", tags=["Local models"]) +@router.get("/runtime-components/cuda") +async def cuda_status(): + from app.local_models import components + return await components.status() + + +@router.post("/runtime-components/cuda", status_code=202) +async def install_cuda(): + from app.local_models import components + return await components.install() + + @router.get("") async def list_models(): items, diagnostics = await asyncio.gather(asyncio.to_thread(manager.describe), asyncio.to_thread(model_diagnostics.recent)) diff --git a/backend/app/local_models/components.py b/backend/app/local_models/components.py new file mode 100644 index 0000000..c908620 --- /dev/null +++ b/backend/app/local_models/components.py @@ -0,0 +1,111 @@ +"""User-triggered installation of the fixed optional CUDA runtime on Windows.""" +import asyncio +import json +import os +import shutil +import subprocess + +from app.config import BACKEND_DIR +from app.errors import ApiError +from app.local_models.process import ThreadedProcess + +ROOT = BACKEND_DIR / '.venv-models-cuda' +state = {'status': 'unchecked', 'stage': '', 'cuda_available': None} +task = None + + +def ready(): + return (ROOT / 'ready.json').is_file() and (ROOT / 'Scripts/python.exe').is_file() + + +async def status(): + global task + if state['status'] == 'unchecked': + state.update(status='checking', stage='检查已有 CUDA 组件') + task = asyncio.create_task(run(False)) + return {**state, 'supported': os.name == 'nt', 'custom_interpreter': bool(os.getenv('APP_MODEL_PYTHON'))} + + +async def install(): + global task + from app.local_models.runtime import runtime + if os.name != 'nt': + raise ApiError(422, 'PLATFORM_UNSUPPORTED', '此安装入口目前支持 Windows。') + if task is not None and not task.done(): + return await status() + if runtime.active or runtime.waiters: + raise ApiError(409, 'MODEL_IN_USE', '请等待本地模型任务结束后再安装组件。') + if state['status'] == 'installed': + return await status() + if not shutil.which('uv'): + raise ApiError(422, 'UV_NOT_INSTALLED', '后端未找到 uv,请先安装 uv 并重启后端。') + state.update(status='installing', stage='准备独立 CUDA 环境', error=None) + task = asyncio.create_task(run(True)) + return await status() + + +async def execute(args, timeout): + process = ThreadedProcess(args, env={**os.environ, 'PYTHONIOENCODING': 'utf-8'}, + limit=8192, creationflags=0x08000000 if os.name == 'nt' else 0) + process.stdin.close() + lines = [] + try: + async with asyncio.timeout(timeout): + while line := await process.stdout.readline(): + value = line.decode('utf-8', errors='replace').strip() + stages = {'COMPONENT:torch': '下载并安装 PyTorch CUDA(约 3 GB)', + 'COMPONENT:dependencies': '安装模型依赖', 'COMPONENT:verify': '验证运行组件'} + if value in stages: + state['stage'] = stages[value] + lines = (lines + [value])[-4:] + await process.wait() + if process.returncode: + raise RuntimeError('component command failed') + return lines + finally: + if process.returncode is None: + if os.name == 'nt': + await asyncio.to_thread(subprocess.run, ['taskkill', '/PID', str(process.process.pid), '/T', '/F'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + creationflags=0x08000000) + else: + process.kill() + await process.wait() + await process.close() + + +async def run(download): + marker = ROOT / 'ready.json' + try: + if download: + marker.unlink(missing_ok=True) + await execute(['powershell.exe', '-NoProfile', '-NonInteractive', '-File', + str(BACKEND_DIR / 'scripts/install-model-runtime.ps1'), '-Device', 'cuda', + '-RuntimeDirectory', str(ROOT), '-QuietProgress'], 7200) + python = ROOT / 'Scripts/python.exe' + if not python.is_file(): + state.update(status='not_installed', stage='尚未安装') + return + result = await execute([str(python), '-c', + 'import json, torch, torchaudio, sentence_transformers, qwen_asr; ' + 'assert torch.version.cuda; ' + 'print(json.dumps({"torch":torch.__version__,"cuda_available":torch.cuda.is_available()}))'], 180) + info = json.loads(result[-1]) + marker.write_text(json.dumps(info), encoding='utf-8') + state.update(status='installed', stage='组件已安装', error=None, **info) + except asyncio.CancelledError: + marker.unlink(missing_ok=True) + state.update(status='interrupted', stage='安装检查已中断,可重试') + raise + except Exception: + marker.unlink(missing_ok=True) + state.update(status='failed', stage='组件安装或验证失败', + error='请检查网络、磁盘空间和 uv;可以重试。CPU 环境不受影响。') + + +async def shutdown(): + if task is not None and not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + if state['status'] in {'checking', 'interrupted'}: + state['status'] = 'unchecked' diff --git a/backend/app/local_models/runtime.py b/backend/app/local_models/runtime.py index ae73314..e362df0 100644 --- a/backend/app/local_models/runtime.py +++ b/backend/app/local_models/runtime.py @@ -70,6 +70,9 @@ def configure(request): def interpreter(): + from app.local_models import components + if not os.getenv("APP_MODEL_PYTHON") and configuration().device == "cuda" and components.ready(): + return components.ROOT / "Scripts/python.exe" return Path(os.getenv("APP_MODEL_PYTHON", str(BACKEND_DIR / ".venv-models" / ("Scripts/python.exe" if os.name == "nt" else "bin/python")))) diff --git a/backend/app/main.py b/backend/app/main.py index f7b97c6..d307982 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -26,6 +26,8 @@ async def lifespan(_: FastAPI): yield finally: await transcription_service.shutdown() + from app.local_models import components + await components.shutdown() from app.local_models import manager for _, key in list(manager._downloads): await manager.cancel_download(key) diff --git a/backend/scripts/install-model-runtime.ps1 b/backend/scripts/install-model-runtime.ps1 index fe4b890..bfab0ac 100644 --- a/backend/scripts/install-model-runtime.ps1 +++ b/backend/scripts/install-model-runtime.ps1 @@ -1,8 +1,10 @@ param( [ValidateSet('cpu', 'cuda')][string]$Device = 'cpu', - [string]$RuntimeDirectory = '' + [string]$RuntimeDirectory = '', + [switch]$QuietProgress ) $ErrorActionPreference = 'Stop' +$uvOptions = if ($QuietProgress) { @('--quiet') } else { @() } $backendRoot = Split-Path $PSScriptRoot -Parent $runtimeRoot = if ($RuntimeDirectory) { [IO.Path]::GetFullPath($RuntimeDirectory) } else { Join-Path $backendRoot '.venv-models' } $runtimePython = Join-Path $runtimeRoot 'Scripts/python.exe' @@ -14,9 +16,12 @@ if (!(Test-Path -LiteralPath $runtimePython)) { $torchIndex = if ($Device -eq 'cuda') { 'https://download.pytorch.org/whl/cu128' } else { 'https://download.pytorch.org/whl/cpu' } $wheelVariant = if ($Device -eq 'cuda') { 'cu128' } else { 'cpu' } # Pin the local version too: ==2.9.1 alone also accepts an already-installed CPU wheel. -& uv pip install --python $runtimePython --index-url $torchIndex "torch==2.9.1+$wheelVariant" "torchaudio==2.9.1+$wheelVariant" +Write-Output 'COMPONENT:torch' +& uv @uvOptions pip install --python $runtimePython --index-url $torchIndex "torch==2.9.1+$wheelVariant" "torchaudio==2.9.1+$wheelVariant" if ($LASTEXITCODE -ne 0) { throw 'PyTorch 安装失败' } -& uv pip install --python $runtimePython -r (Join-Path $PSScriptRoot 'model-requirements.lock') -c (Join-Path $PSScriptRoot 'model-requirements.txt') +Write-Output 'COMPONENT:dependencies' +& uv @uvOptions pip install --python $runtimePython -r (Join-Path $PSScriptRoot 'model-requirements.lock') -c (Join-Path $PSScriptRoot 'model-requirements.txt') if ($LASTEXITCODE -ne 0) { throw '模型依赖安装失败' } +Write-Output 'COMPONENT:verify' & $runtimePython -c 'import torch; print({"torch":torch.__version__,"cuda_available":torch.cuda.is_available()})' if ($LASTEXITCODE -ne 0) { throw '模型运行环境检查失败' } diff --git a/backend/tests/test_runtime_components.py b/backend/tests/test_runtime_components.py new file mode 100644 index 0000000..6f367e6 --- /dev/null +++ b/backend/tests/test_runtime_components.py @@ -0,0 +1,85 @@ +import asyncio +import json +import os + +import pytest + +from app.errors import ApiError +from app.local_models import components, runtime + + +@pytest.fixture(autouse=True) +def isolate(monkeypatch, tmp_path): + monkeypatch.setattr(components, 'ROOT', tmp_path / 'cuda') + monkeypatch.setattr(components, 'state', {'status': 'unchecked', 'stage': '', 'cuda_available': None}) + monkeypatch.setattr(components, 'task', None) + + +def test_status_checks_without_installing_and_detects_existing_cuda(monkeypatch): + python = components.ROOT / 'Scripts/python.exe' + python.parent.mkdir(parents=True) + python.touch() + calls = [] + async def execute(args, timeout): + calls.append(args) + return [json.dumps({'torch': '2.9.1+cu128', 'cuda_available': True})] + monkeypatch.setattr(components, 'execute', execute) + async def scenario(): + assert (await components.status())['status'] == 'checking' + await components.task + assert (await components.status())['status'] == 'installed' + assert len(calls) == 1 and calls[0][0] == str(python) + assert components.ready() + asyncio.run(scenario()) + + +@pytest.mark.skipif(os.name != 'nt', reason='Windows installer') +def test_install_deduplicates_and_failure_can_retry(monkeypatch): + monkeypatch.setattr(components.shutil, 'which', lambda name: 'uv.exe') + async def scenario(): + entered, release = asyncio.Event(), asyncio.Event() + calls = [] + async def execute(args, timeout): + calls.append(args) + entered.set() + await release.wait() + raise RuntimeError('private exception') + monkeypatch.setattr(components, 'execute', execute) + await components.install() + await entered.wait() + first = components.task + await components.install() + assert first is components.task + release.set() + await first + assert components.state['status'] == 'failed' + assert 'private exception' not in str(components.state) + await components.install() + await components.task + assert len(calls) == 2 and '-RuntimeDirectory' in calls[0] + assert not components.ready() + asyncio.run(scenario()) + + +@pytest.mark.skipif(os.name != 'nt', reason='Windows installer') +def test_install_refuses_active_inference(monkeypatch): + monkeypatch.setattr(runtime.runtime, 'active', {1: 'bekko'}) + async def scenario(): + with pytest.raises(ApiError) as exc: + await components.install() + assert exc.value.code == 'MODEL_IN_USE' + asyncio.run(scenario()) + + +def test_interpreter_keeps_cpu_default_and_respects_explicit_override(monkeypatch): + monkeypatch.delenv('APP_MODEL_PYTHON', raising=False) + python = components.ROOT / 'Scripts/python.exe' + python.parent.mkdir(parents=True) + python.touch() + (components.ROOT / 'ready.json').write_text('{}') + monkeypatch.setattr(runtime, 'configuration', lambda: runtime.RuntimeConfig(device='cpu')) + assert runtime.interpreter() != python + monkeypatch.setattr(runtime, 'configuration', lambda: runtime.RuntimeConfig(device='cuda')) + assert runtime.interpreter() == python + monkeypatch.setenv('APP_MODEL_PYTHON', 'explicit-python.exe') + assert str(runtime.interpreter()) == 'explicit-python.exe' diff --git a/docs/contracts/第二阶段接口契约-开发版.md b/docs/contracts/第二阶段接口契约-开发版.md index 0a716af..5f93ced 100644 --- a/docs/contracts/第二阶段接口契约-开发版.md +++ b/docs/contracts/第二阶段接口契约-开发版.md @@ -1576,6 +1576,8 @@ frontend/src/ ### 阶段 F 收尾接口补充(2026-09-05) +CUDA 组件:`GET /api/local-models/runtime-components/cuda` 返回 status、stage、supported、custom_interpreter、cuda_available、可选 torch/error。status 为 checking/not_installed/installing/installed/failed/interrupted;读取只检查现有环境,不下载安装。`POST` 同路径明确触发后台安装,返回 202;重复请求复用当前安装任务。正在推理/排队返回 409 MODEL_IN_USE,缺少 uv 返回 422 UV_NOT_INSTALLED,不支持的平台返回 422 PLATFORM_UNSUPPORTED。阶段进度不冒充字节百分比。关闭后端时回收安装进程树,重启后重新验证环境。 + | 接口/字段 | 行为 | | --- | --- | | `POST /api/media/attachments` | 可选 `Idempotency-Key` Header,16–100 位字母、数字、下划线或连字符。同键同扩展名同内容返回同 attachment_id;同键同扩展名内容不一致返回 409 `IDEMPOTENCY_CONFLICT`。上传仍受 25 MiB 限制。 | diff --git a/docs/development/多模态管线与模型运行开发说明.md b/docs/development/多模态管线与模型运行开发说明.md index 1ae5d67..d6175e5 100644 --- a/docs/development/多模态管线与模型运行开发说明.md +++ b/docs/development/多模态管线与模型运行开发说明.md @@ -95,6 +95,8 @@ POST /api/providers/request-preview 不联网,隐藏正文/文件且不包含 ### 阶段 F 收尾行为(2026-09-05) +CUDA 页面安装入口:**设置 → 模型提供商 → 本地模型 → CUDA 运行组件(可选)**。未安装时显示“下载并安装 CUDA 组件”;安装中展示真实阶段和不定进度条,失败可重试。后端仅运行项目内固定安装脚本,写入独立 `.venv-models-cuda`,检查依赖及 CUDA wheel 后才标记就绪。已有环境会先检查;成功后选择 CUDA 并保存运行设置即可使用,CPU 环境保留。显式 `APP_MODEL_PYTHON` 继续优先,页面提示覆盖关系。当前页面安装支持 Windows,需后端能找到 uv;不会自动安装显卡驱动。 + - 模型卡片读取权重目录的实际文件大小,包含未完成下载的文件;下载进度与磁盘占用分别显示。 - 本地任务串行执行;等待队列中交互检索优先级为 0,媒体任务为 10,后台笔记索引为 20。同级 FIFO,不抢占已运行任务。 - 默认 CPU。选择 CUDA 后,设备不可用直接使用 CPU;CUDA 初始化失败或显存不足时先释放原子进程,再用冻结的同一任务配置重试 CPU 一次。其他错误不触发设备重试;用户取消不会启动后续尝试。重试会清除上一尝试的部分转写片段。 diff --git a/docs/retrospectives/阶段F-Embedding与知识库问题与解决方案.md b/docs/retrospectives/阶段F-Embedding与知识库问题与解决方案.md index 71c847f..f749073 100644 --- a/docs/retrospectives/阶段F-Embedding与知识库问题与解决方案.md +++ b/docs/retrospectives/阶段F-Embedding与知识库问题与解决方案.md @@ -138,6 +138,14 @@ embedding_local_only: true ## 9. 工程经验 +### F-16:CUDA 选装只有脚本,前端缺少安装入口 + +问题:上一轮完成了独立 CUDA 环境安装和 GPU 实测,但页面只有设备下拉框及脚本说明。用户无法从前端下载组件,工程收尾遗漏了可操作入口。 + +实际方案:增加独立组件卡片和 GET/POST 状态、安装接口;展示环境检查、下载 PyTorch、安装依赖、验证等真实阶段,失败允许重试。固定脚本、目录和参数,默认 CPU 环境不变;安装成功后 CUDA 模式自动选择已验证环境,显式 Python 覆盖仍优先。推理期间拒绝安装,重复点击不产生多个任务,后端关闭时回收安装子进程树。 + +验证:21 项后端相关测试及新增前端组件测试通过,类型检查和构建通过;真实页面已显示本机 `2.9.1+cu128` 组件就绪,默认 CPU 未改变。本轮复用已安装组件验证识别,未重复下载 3 GB 安装包;下载入口、重复请求和失败重试由隔离测试覆盖。 + ### F-13:CUDA 失败重试与诊断无法追溯 问题:设备不可用时能够使用 CPU,但 CUDA 初始化失败、显存不足会直接使任务失败;诊断只留在进程内存中,重启后无法解释当时的失败和回退。 diff --git a/frontend/src/features/settings/LocalModelSettings.spec.ts b/frontend/src/features/settings/LocalModelSettings.spec.ts new file mode 100644 index 0000000..8b80f57 --- /dev/null +++ b/frontend/src/features/settings/LocalModelSettings.spec.ts @@ -0,0 +1,26 @@ +// @vitest-environment happy-dom +import { mount, flushPromises } from '@vue/test-utils' +import { expect, it, vi } from 'vitest' +import { apiClient } from '@/services/apiClient' +import LocalModelSettings from './LocalModelSettings.vue' + +vi.mock('@/services/apiClient', () => ({apiClient:{get:vi.fn(),post:vi.fn()}})) +it('shows an optional CUDA installer and live installation stage', async () => { + vi.mocked(apiClient.get).mockImplementation(async (url) => url.includes('runtime-components') + ? {status:'not_installed', stage:'尚未安装', supported:true, cuda_available:null,custom_interpreter:false} + : {items:[],config:null,runtime_installed:true,last_inference:null}) + vi.mocked(apiClient.post).mockResolvedValue({status:'installing',stage:'下载并安装 PyTorch CUDA(约 3 GB)',supported:true}) + const wrapper = mount(LocalModelSettings) + try { + await flushPromises() + const button = wrapper.findAll('button').find(b => b.text() === '下载并安装 CUDA 组件')! + expect(button.exists()).toBe(true) + expect(apiClient.post).not.toHaveBeenCalled() + await button.trigger('click') + await flushPromises() + expect(apiClient.post).toHaveBeenCalledWith('/api/local-models/runtime-components/cuda') + expect(wrapper.text()).toContain('下载并安装 PyTorch CUDA') + expect(wrapper.get('progress').attributes('value')).toBeUndefined() + expect(button.attributes('disabled')).toBeDefined() + } finally {wrapper.unmount()} +}) diff --git a/frontend/src/features/settings/LocalModelSettings.vue b/frontend/src/features/settings/LocalModelSettings.vue index b6966c8..e75f7e2 100644 --- a/frontend/src/features/settings/LocalModelSettings.vue +++ b/frontend/src/features/settings/LocalModelSettings.vue @@ -6,6 +6,16 @@ interface Model {key: string; name: string; capability: string; revision: string const items = ref([]) const config = ref(null) const installed = ref(false) +interface CudaComponent {status:string;stage:string;cuda_available:boolean|null;supported:boolean;custom_interpreter:boolean;error?:string;torch?:string} +const cuda = ref(null) +const cudaError = ref('') +async function loadCuda() { + try { cuda.value = await apiClient.get('/api/local-models/runtime-components/cuda'); cudaError.value = '' } + catch(e) { cudaError.value = (e as Error).message } +} +async function installCuda() { + await act(async () => { cuda.value = await apiClient.post('/api/local-models/runtime-components/cuda') }) +} const lastInference = ref<{actual_device:string;requested_device:string;inference_seconds?:number;elapsed_seconds?:number;status?:string;error_code?:string}|null>(null) const error = ref('') const dirty = ref(false) @@ -15,6 +25,7 @@ let stopped = false const size = (bytes: number | null) => bytes === null ? '未知' : `${(bytes / 1024 / 1024).toFixed(1)} MiB` const labels: Record = {not_installed:'未下载',downloading:'下载中',installed:'已下载并校验',failed:'下载失败',interrupted:'已中断,可续传'} async function load() { + await loadCuda() try { const data = await apiClient.get<{items:Model[];config:Config;runtime_installed:boolean;last_inference:typeof lastInference.value}>('/api/local-models') items.value = data.items; installed.value = data.runtime_installed @@ -45,6 +56,20 @@ onUnmounted(() => { stopped = true; clearTimeout(timer) })

最近实际运行:{{ lastInference.actual_device || '未开始推理' }} · 请求设备 {{ lastInference.requested_device }} · 推理 {{ (lastInference.inference_seconds ?? lastInference.elapsed_seconds ?? 0).toFixed(2) }} 秒 · {{ lastInference.status }} {{ lastInference.error_code || '' }}

尚未安装模型运行环境。在项目根目录执行 ./backend/scripts/install-model-runtime.ps1;CUDA 选装追加 -Device cuda

+
+

CUDA 运行组件(可选)

+

默认使用 CPU。需要 NVIDIA GPU 加速时下载此组件,约 3 GB,安装时还需要额外磁盘空间;不包含显卡驱动和模型权重。

+ + +