feat(multimodal): 完成阶段 F 运行管理与收尾验收 #22

Merged
Kronecker merged 2 commits from feat/multimodal-finalization-review into main 2026-09-05 02:13:21 +08:00
8 changed files with 38 additions and 12 deletions
Showing only changes of commit cb1c6dfcf5 - Show all commits
+6 -4
View File
@@ -69,9 +69,10 @@ def configure(request):
return request
def interpreter():
def interpreter(config=None):
from app.local_models import components
if not os.getenv("APP_MODEL_PYTHON") and configuration().device == "cuda" and components.ready():
requested_device = (config or configuration()).device
if not os.getenv("APP_MODEL_PYTHON") and requested_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"))))
@@ -160,7 +161,8 @@ class Runtime:
async def _execute(self, key, operation, payload, config, diagnostics):
if read_state(key)["status"] != "installed":
raise ProviderError("LOCAL_MODEL_NOT_INSTALLED", "请先下载本地模型。")
if not interpreter().is_file():
executable = interpreter(config)
if not executable.is_file():
raise ProviderError("LOCAL_RUNTIME_NOT_INSTALLED", "请先安装本地模型运行环境。")
from app.services.usage_service import UsageAttempt
attempt = UsageAttempt("local-models", CATALOG[key].repository, "local", operation, source="local")
@@ -170,7 +172,7 @@ class Runtime:
env = {**os.environ, "HF_HUB_OFFLINE": "1", "TRANSFORMERS_OFFLINE": "1",
"HF_HUB_DISABLE_TELEMETRY": "1", "OMP_NUM_THREADS": str(config.cpu_threads),
"PYTHONIOENCODING": "utf-8"}
args = (str(interpreter()), str(Path(__file__).with_name("worker.py")))
args = (str(executable), str(Path(__file__).with_name("worker.py")))
options = {"env": env, "limit": 16 * 1024 * 1024,
**({"creationflags": 0x08000000} if os.name == "nt" else {})}
try:
+2 -2
View File
@@ -47,7 +47,7 @@ def test_local_model_missing_is_explicit():
def test_cancel_reaps_active_model_process(monkeypatch):
import app.local_models.runtime as module
monkeypatch.setattr(module,'read_state',lambda key:{'status':'installed'})
monkeypatch.setattr(module,'interpreter',lambda:Path(sys.executable))
monkeypatch.setattr(module,'interpreter',lambda *_:Path(sys.executable))
class Input:
def write(self, value):
request = json.loads(value)
@@ -92,7 +92,7 @@ def test_subprocess_fallback_runs_and_reaps_real_worker(monkeypatch, tmp_path, c
import app.local_models.process as process_module
monkeypatch.setattr(module, 'read_state', lambda key: {'status': 'installed'})
monkeypatch.setattr(module, 'interpreter', lambda: Path(sys.executable))
monkeypatch.setattr(module, 'interpreter', lambda *_: Path(sys.executable))
worker = tmp_path / 'worker.py'
worker.write_text(
'import json,sys,time\n'
@@ -21,7 +21,7 @@ def test_cuda_retries_only_device_failures_in_reaped_process(monkeypatch, code,
from app.services.usage_service import connection
monkeypatch.setattr(module, 'configuration', lambda: module.RuntimeConfig(device='cuda'))
monkeypatch.setattr(module, 'read_state', lambda key: {'status': 'installed'})
monkeypatch.setattr(module, 'interpreter', lambda: Path(sys.executable))
monkeypatch.setattr(module, 'interpreter', lambda *_: Path(sys.executable))
events = []
class Process:
+3 -2
View File
@@ -79,7 +79,8 @@ def test_interpreter_keeps_cpu_default_and_respects_explicit_override(monkeypatc
(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
# A queued attempt keeps its frozen device even after the saved setting changes.
assert runtime.interpreter(runtime.RuntimeConfig(device='cuda')) == python
assert runtime.interpreter(runtime.RuntimeConfig(device='cpu')) != python
monkeypatch.setenv('APP_MODEL_PYTHON', 'explicit-python.exe')
assert str(runtime.interpreter()) == 'explicit-python.exe'
@@ -13,7 +13,7 @@
| 项目 | 结果 |
| --- | --- |
| 后端全量 `python -m pytest -q -p no:cacheprovider` | 559 通过;1 条已有 Starlette/httpx 弃用提示 |
| 前端全量 `npm test -- --run` | 29 个文件、102 项通过 |
| 前端全量 `npm test -- --run` | 29 个文件、103 项通过 |
| 类型与生产构建 `npm run build` | vue-tsc 与 Vite 构建通过,仍有既有大 bundle 提示 |
| `git diff --check` | 通过 |
| 真实页面 | 模型卡片读取实际大小;音频统计显示真实缺失;提供商表单展示请求编辑、恢复默认、导入/导出及推理验证入口 |
@@ -138,6 +138,14 @@ embedding_local_only: true
## 9. 工程经验
### F-18:设备快照与迟到导入错误未完全隔离
问题:推理任务虽然冻结了 RuntimeConfig,但启动子进程时又从数据库读取最新 device 来选择 Python 环境;排队期间修改设置会改变已提交任务的运行环境,CUDA → CPU 重试也可能继续使用 CUDA 环境。请求规则的迟到成功响应已失效,但迟到失败仍会把旧错误显示到新草稿。
实际方案:`interpreter` 接收本次 attempt 的冻结配置,`_execute` 在检查前解析一次可执行路径并复用;CUDA attempt 使用已验证的独立 CUDA 环境,CPU attempt 使用默认 CPU 环境,显式 APP_MODEL_PYTHON 仍保持最高优先级。导入异常与成功响应使用同一 generation 条件,只允许当前操作更新界面。
验证:增加保存设置变化后仍按显式 attempt 选择环境、CPU 重试环境,以及旧导入失败晚于新编辑的回归。最终后端 559 项、前端 103 项和生产构建通过。
### F-16:CUDA 选装只有脚本,前端缺少安装入口
问题:上一轮完成了独立 CUDA 环境安装和 GPU 实测,但页面只有设备下拉框及脚本说明。用户无法从前端下载组件,工程收尾遗漏了可操作入口。
@@ -1,5 +1,5 @@
// @vitest-environment happy-dom
import { mount } from '@vue/test-utils'
import { flushPromises, mount } from '@vue/test-utils'
import { expect, it, vi } from 'vitest'
import RequestJsonEditor from './RequestJsonEditor.vue'
import { apiClient } from '@/services/apiClient'
@@ -38,6 +38,21 @@ it('ignores an imported configuration that finishes after a newer edit', async (
wrapper.unmount()
})
it('ignores an old import failure after a newer edit', async () => {
let fail!: (reason: Error) => void
vi.mocked(apiClient.post).mockReturnValue(new Promise((_resolve, reject) => { fail = reject }))
const wrapper = mount(RequestJsonEditor, {props:{modelValue:[]}})
const input = wrapper.get('input[type="file"]')
Object.defineProperty(input.element, 'files', {value:[new File(['{}'], 'old.json')], configurable:true})
await input.trigger('change')
await wrapper.findAll('button').find(button => button.text() === '添加请求规则')!.trigger('click')
fail(new Error('旧导入失败'))
await flushPromises()
expect(wrapper.text()).not.toContain('旧导入失败')
expect(wrapper.findAll('textarea')).toHaveLength(1)
wrapper.unmount()
})
it('restores defaults even from an invalid draft and reflects replacement configurations', async () => {
const wrapper = mount(RequestJsonEditor, {props:{modelValue:[{capability:'chat', body:{enable_thinking:false}}]}})
await wrapper.get('textarea').setValue('{invalid')
@@ -51,7 +51,7 @@ async function importRules(event: Event) {
if (current !== generation) return
rules.value = validated.request_overrides.map(rule => ({...rule, draft: JSON.stringify(rule.body, null, 2), error: ''}))
publish()
} catch(e) { transferError.value = (e as Error).message }
} catch(e) { if (current === generation) transferError.value = (e as Error).message }
}
async function exportRules() {
transferError.value = ''