Merge pull request 'fix(repo): 恢复阶段 F 收尾前的 main 文件树' (#21) from fix/restore-main-review-flow into main

Reviewed-on: #21
This commit was merged in pull request #21.
This commit is contained in:
2026-09-05 02:12:13 +08:00
33 changed files with 85 additions and 1120 deletions
-1
View File
@@ -7,7 +7,6 @@ frontend/*.tsbuildinfo
# Backend # Backend
backend/.venv/ backend/.venv/
backend/.venv-models/ backend/.venv-models/
backend/.venv-models-cuda/
backend/data/models/ backend/data/models/
backend/data/attachments/ backend/data/attachments/
backend/.uv-cache/ backend/.uv-cache/
-1
View File
@@ -1050,7 +1050,6 @@ class TranscriptEditRequest(Contract):
class TranscriptNoteRequest(Contract): class TranscriptNoteRequest(Contract):
update_existing: bool = False
title: str = Field(min_length=1, max_length=200) title: str = Field(min_length=1, max_length=200)
folder: str | None = None folder: str | None = None
include_timestamps: bool = True include_timestamps: bool = True
+3 -18
View File
@@ -1,30 +1,15 @@
import asyncio
from fastapi import APIRouter from fastapi import APIRouter
from app.services import model_diagnostics
from app.local_models import manager from app.local_models import manager
from app.local_models.runtime import RuntimeConfig, configuration, configure, interpreter, runtime from app.local_models.runtime import RuntimeConfig, configuration, configure, interpreter, runtime
router = APIRouter(prefix="/api/local-models", tags=["Local models"]) 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("") @router.get("")
async def list_models(): async def list_models():
items, diagnostics = await asyncio.gather(asyncio.to_thread(manager.describe), asyncio.to_thread(model_diagnostics.recent)) return {**manager.describe(), "runtime_installed": interpreter().is_file(), "config": configuration(),
return {**items, "runtime_installed": interpreter().is_file(), "config": configuration(),
"active_models": list(runtime.active.values()), "queued_requests": len(runtime.waiters), "active_models": list(runtime.active.values()), "queued_requests": len(runtime.waiters),
"last_inference": diagnostics[-1] if diagnostics else None} "last_inference": runtime.diagnostics[-1] if runtime.diagnostics else None}
@router.put("/config") @router.put("/config")
@@ -49,5 +34,5 @@ async def delete(key: str):
@router.get("/diagnostics") @router.get("/diagnostics")
async def diagnostics(): async def diagnostics():
return {"items": await asyncio.to_thread(model_diagnostics.recent), "config": configuration(), "scope": "application_last_200_attempts", return {"items": runtime.diagnostics, "config": configuration(), "scope": "current_process",
"contains": "model_revision_device_timing_resources_only"} "contains": "model_revision_device_timing_resources_only"}
-111
View File
@@ -1,111 +0,0 @@
"""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'
+1 -13
View File
@@ -49,20 +49,8 @@ def task_key(key):
return str(model_path(key)), key return str(model_path(key)), key
def disk_bytes(key):
total = 0
try:
root = model_path(key).resolve()
for path in root.rglob("*"):
if not path.is_symlink() and path.is_file() and path.resolve().is_relative_to(root):
total += path.stat().st_size
except OSError:
return None
return total
def describe(): def describe():
return {"items": [{**spec.public(), **read_state(key), "disk_bytes": disk_bytes(key)} for key, spec in CATALOG.items()]} return {"items": [{**spec.public(), **read_state(key)} for key, spec in CATALOG.items()]}
async def download(key): async def download(key):
+26 -87
View File
@@ -4,10 +4,8 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import os import os
import time
from contextlib import closing from contextlib import closing
from contextvars import ContextVar from contextvars import ContextVar
from functools import wraps
from pathlib import Path from pathlib import Path
from typing import Literal from typing import Literal
@@ -33,18 +31,6 @@ class RuntimeConfig(BaseModel):
runtime_context = ContextVar("runtime_config", default=None) runtime_context = ContextVar("runtime_config", default=None)
runtime_progress = ContextVar("runtime_progress", default=None) runtime_progress = ContextVar("runtime_progress", default=None)
embedding_priority = ContextVar("embedding_priority", default=0)
def background_embeddings(operation):
@wraps(operation)
async def wrapped(*args, **kwargs):
token = embedding_priority.set(20)
try:
return await operation(*args, **kwargs)
finally:
embedding_priority.reset(token)
return wrapped
def configuration(): def configuration():
@@ -70,9 +56,6 @@ def configure(request):
def interpreter(): 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")))) return Path(os.getenv("APP_MODEL_PYTHON", str(BACKEND_DIR / ".venv-models" / ("Scripts/python.exe" if os.name == "nt" else "bin/python"))))
@@ -92,81 +75,28 @@ class Runtime:
return any(target in paths for paths in self.active_files.values()) return any(target in paths for paths in self.active_files.values())
async def infer(self, key, operation, payload, *, priority=10): async def infer(self, key, operation, payload, *, priority=10):
from app.services import model_diagnostics if read_state(key)["status"] != "installed":
config = configuration().model_copy(deep=True) raise ProviderError("LOCAL_MODEL_NOT_INSTALLED", "请先在模型配置中下载本地模型。")
if not interpreter().is_file():
raise ProviderError("LOCAL_RUNTIME_NOT_INSTALLED", "请先运行本地模型 CPU/CUDA 安装脚本。")
config = configuration()
self.counter += 1 self.counter += 1
ticket = (priority, self.counter) ticket = (priority, self.counter)
self.waiters.append(ticket) self.waiters.append(ticket)
queued_at = time.monotonic() process = None
reason = None attempt = None
from app.services.usage_service import usage_context
from uuid import uuid4
context = dict(usage_context.get() or {})
context.setdefault("request_id", uuid4().hex)
usage_token = usage_context.set(context)
try: try:
# One resident model at a time prevents overlapping CPU/GPU allocations.
while self.active or ticket != min(self.waiters): while self.active or ticket != min(self.waiters):
await asyncio.sleep(0.05) await asyncio.sleep(0.05)
self.waiters.remove(ticket) self.waiters.remove(ticket)
self.active[ticket] = key self.active[ticket] = key
self.active_files[ticket] = {str(Path(payload[name]).resolve()) for name in ("source", "reference") if payload.get(name)} self.active_files[ticket] = {str(Path(payload[name]).resolve()) for name in ("source", "reference") if payload.get(name)}
queue_seconds = time.monotonic() - queued_at # Deletion may have occurred while this request was queued.
# Keep the reservation while replacing a failed CUDA process with CPU. if read_state(key)["status"] != "installed":
for device in (["cuda", "cpu"] if config.device == "cuda" else ["cpu"]): raise ProviderError("LOCAL_MODEL_NOT_INSTALLED", "模型文件已被删除。")
started = time.monotonic() from app.services.usage_service import UsageAttempt
diagnostics = dict(model=CATALOG[key].repository, revision=CATALOG[key].revision, attempt = UsageAttempt("local-models", CATALOG[key].repository, "local", operation, source="local")
operation=operation, source="local", requested_device=config.device,
attempted_device=device, queue_seconds=queue_seconds, fallback_reason=reason, request_id=context["request_id"])
try:
result = await self._execute(key, operation, payload, config.model_copy(update={"device": device}), diagnostics)
diagnostics.update(result.get("diagnostics", {}))
diagnostics.update(requested_device=config.device, status="completed")
if reason:
diagnostics["fallback_reason"] = reason
return result["result"]
except asyncio.CancelledError:
diagnostics.update(status="cancelled", error_code="LOCAL_MODEL_CANCELLED")
raise
except ProviderError as exc:
diagnostics.update(status="failed", error_code=exc.code)
if device == "cuda" and exc.code in {"LOCAL_CUDA_INIT_FAILED", "LOCAL_CUDA_OOM"}:
reason = exc.code
callback = runtime_progress.get()
if callback:
callback({"reset": True, "progress": 0})
continue
raise
except Exception:
diagnostics.update(status="failed", error_code="LOCAL_MODEL_INVALID_RESPONSE")
raise ProviderError("LOCAL_MODEL_INVALID_RESPONSE", "本地模型返回无效数据。") from None
finally:
diagnostics["requested_device"] = config.device
diagnostics["elapsed_seconds"] = time.monotonic() - started
self.diagnostics.append(model_diagnostics.record(**diagnostics))
self.diagnostics = self.diagnostics[-100:]
except asyncio.CancelledError:
if ticket not in self.active:
model_diagnostics.record(model=CATALOG[key].repository, operation=operation,
source="local", status="cancelled", error_code="LOCAL_QUEUE_CANCELLED",
requested_device=config.device, queue_seconds=time.monotonic() - queued_at)
raise
finally:
if ticket in self.waiters:
self.waiters.remove(ticket)
self.active.pop(ticket, None)
self.active_files.pop(ticket, None)
usage_context.reset(usage_token)
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():
raise ProviderError("LOCAL_RUNTIME_NOT_INSTALLED", "请先安装本地模型运行环境。")
from app.services.usage_service import UsageAttempt
attempt = UsageAttempt("local-models", CATALOG[key].repository, "local", operation, source="local")
diagnostics.update(attempt_id=attempt.attempt_id, request_id=attempt.request_id)
process = None
try:
env = {**os.environ, "HF_HUB_OFFLINE": "1", "TRANSFORMERS_OFFLINE": "1", env = {**os.environ, "HF_HUB_OFFLINE": "1", "TRANSFORMERS_OFFLINE": "1",
"HF_HUB_DISABLE_TELEMETRY": "1", "OMP_NUM_THREADS": str(config.cpu_threads), "HF_HUB_DISABLE_TELEMETRY": "1", "OMP_NUM_THREADS": str(config.cpu_threads),
"PYTHONIOENCODING": "utf-8"} "PYTHONIOENCODING": "utf-8"}
@@ -188,6 +118,8 @@ class Runtime:
process.stdin.close() process.stdin.close()
final = None final = None
while line := await process.stdout.readline(): while line := await process.stdout.readline():
if len(line) > 16 * 1024 * 1024:
raise ProviderError("LOCAL_MODEL_INVALID_RESPONSE", "本地模型输出超限。")
message = json.loads(line) message = json.loads(line)
if "progress" in message: if "progress" in message:
callback = runtime_progress.get() callback = runtime_progress.get()
@@ -205,19 +137,26 @@ class Runtime:
raise ProviderError("LOCAL_MODEL_PROCESS_FAILED", "本地模型进程退出,请检查依赖与资源预算。") raise ProviderError("LOCAL_MODEL_PROCESS_FAILED", "本地模型进程退出,请检查依赖与资源预算。")
if not isinstance(result, dict): if not isinstance(result, dict):
raise ProviderError("LOCAL_MODEL_INVALID_RESPONSE", "本地模型进程未返回有效结果。") raise ProviderError("LOCAL_MODEL_INVALID_RESPONSE", "本地模型进程未返回有效结果。")
diagnostics.update(result.get("diagnostics", {}))
if "error_code" in result: if "error_code" in result:
raise ProviderError(result["error_code"], result.get("message", "本地推理失败。")) raise ProviderError(result["error_code"], result.get("message", "本地推理失败。"))
attempt.observe(result) attempt.observe(result)
attempt.completed = True attempt.completed = True
return result self.diagnostics.append({"model": CATALOG[key].repository, "revision": CATALOG[key].revision,
**result.get("diagnostics", {})})
self.diagnostics = self.diagnostics[-100:]
return result["result"]
finally: finally:
if ticket in self.waiters:
self.waiters.remove(ticket)
if process is not None and process.returncode is None: if process is not None and process.returncode is None:
process.kill() process.kill()
await process.wait() await process.wait()
if process is not None and hasattr(process, "close"): if process is not None and hasattr(process, "close"):
await process.close() await process.close()
attempt.persist() self.active.pop(ticket, None)
self.active_files.pop(ticket, None)
if attempt:
attempt.persist()
runtime = Runtime() runtime = Runtime()
@@ -249,7 +188,7 @@ class LocalEmbedding:
config = (self._config or configuration()).model_copy(deep=True) config = (self._config or configuration()).model_copy(deep=True)
token = runtime_context.set(config) token = runtime_context.set(config)
try: try:
return await runtime.infer(config.embedding_model, "embedding", {"texts": texts}, priority=embedding_priority.get()) return await runtime.infer(config.embedding_model, "embedding", {"texts": texts}, priority=0)
finally: finally:
runtime_context.reset(token) runtime_context.reset(token)
+7 -28
View File
@@ -76,25 +76,16 @@ def voice_embedding(model, audio, device):
return torch.nn.functional.normalize(vector, dim=0) return torch.nn.functional.normalize(vector, dim=0)
class CudaInitializationError(RuntimeError):
pass
def run(request): def run(request):
import torch import torch
import psutil import psutil
config, payload = request["config"], request["payload"] config, payload = request["config"], request["payload"]
torch.set_num_threads(config["cpu_threads"]) torch.set_num_threads(config["cpu_threads"])
requested = config["device"] requested = config["device"]
try: device = "cuda:0" if requested == "cuda" and torch.cuda.is_available() else "cpu"
device = "cuda:0" if requested == "cuda" and torch.cuda.is_available() else "cpu" if device != "cpu":
if device != "cpu": total = torch.cuda.get_device_properties(0).total_memory
torch.cuda.init() torch.cuda.set_per_process_memory_fraction(min(1.0, config["gpu_memory_limit_mb"] * 1024 ** 2 / total))
total = torch.cuda.get_device_properties(0).total_memory
torch.cuda.set_per_process_memory_fraction(min(1.0, config["gpu_memory_limit_mb"] * 1024 ** 2 / total))
except Exception as exc:
raise CudaInitializationError() from exc
request["_actual_device"] = device
process = psutil.Process() process = psutil.Process()
peak = [0] peak = [0]
stop = threading.Event() stop = threading.Event()
@@ -111,7 +102,6 @@ def run(request):
path, operation = request["model_path"], request["operation"] path, operation = request["model_path"], request["operation"]
try: try:
usage = {} usage = {}
audio_seconds = None
if operation == "embedding": if operation == "embedding":
from sentence_transformers import SentenceTransformer from sentence_transformers import SentenceTransformer
model = SentenceTransformer(path, device=device, local_files_only=True, trust_remote_code=False, model = SentenceTransformer(path, device=device, local_files_only=True, trust_remote_code=False,
@@ -126,7 +116,6 @@ def run(request):
device_map=device, attn_implementation="sdpa", max_inference_batch_size=1, max_new_tokens=512) device_map=device, attn_implementation="sdpa", max_inference_batch_size=1, max_new_tokens=512)
loaded = time.monotonic() loaded = time.monotonic()
audio = decode(payload["source"]) audio = decode(payload["source"])
audio_seconds = len(audio) / 16000
regions = speech_regions(audio) regions = speech_regions(audio)
language = {"zh": "Chinese", "en": "English", "ja": "Japanese", "yue": "Cantonese"}.get(payload.get("language"), payload.get("language")) language = {"zh": "Chinese", "en": "English", "ja": "Japanese", "yue": "Cantonese"}.get(payload.get("language"), payload.get("language"))
segments = [] segments = []
@@ -165,7 +154,7 @@ def run(request):
result = {"speakers": speakers} result = {"speakers": speakers}
else: else:
raise ValueError("Unknown inference operation") raise ValueError("Unknown inference operation")
return {"result": result, "usage": usage, "audio_seconds": audio_seconds, "diagnostics": {"requested_device": requested, "actual_device": device, return {"result": result, "usage": usage, "diagnostics": {"requested_device": requested, "actual_device": device,
"fallback_reason": "CUDA_UNAVAILABLE" if requested == "cuda" and device == "cpu" else None, "fallback_reason": "CUDA_UNAVAILABLE" if requested == "cuda" and device == "cpu" else None,
"load_seconds": loaded - started, "inference_seconds": time.monotonic() - loaded, "load_seconds": loaded - started, "inference_seconds": time.monotonic() - loaded,
"peak_memory_bytes": max(peak[0], process.memory_info().rss), "operation": operation}} "peak_memory_bytes": max(peak[0], process.memory_info().rss), "operation": operation}}
@@ -181,16 +170,6 @@ if __name__ == "__main__":
response = run(request) response = run(request)
except (ImportError, ModuleNotFoundError): except (ImportError, ModuleNotFoundError):
response = {"error_code": "LOCAL_RUNTIME_DEPENDENCY_MISSING", "message": "本地模型运行依赖不完整,请重新运行安装脚本。"} response = {"error_code": "LOCAL_RUNTIME_DEPENDENCY_MISSING", "message": "本地模型运行依赖不完整,请重新运行安装脚本。"}
except Exception as exc: except Exception:
# Only device failures allow the host to retry once in a fresh CPU process. response = {"error_code": "LOCAL_INFERENCE_FAILED", "message": "本地推理失败,请检查媒体格式、模型和设备配置。"}
import torch
cuda_failure = isinstance(exc, CudaInitializationError)
cuda_oom = request.get("_actual_device") == "cuda:0" and isinstance(exc, torch.cuda.OutOfMemoryError)
if cuda_failure or cuda_oom:
response = {"error_code": "LOCAL_CUDA_OOM" if cuda_oom else "LOCAL_CUDA_INIT_FAILED",
"message": "CUDA 运行失败,将释放进程并重试 CPU。"}
else:
response = {"error_code": "LOCAL_INFERENCE_FAILED", "message": "本地推理失败,请检查媒体格式、模型和设备配置。"}
if "error_code" in response:
response["diagnostics"] = {"requested_device": request["config"]["device"], "actual_device": request.get("_actual_device", "unknown")}
sys.stdout.buffer.write((json.dumps(response, ensure_ascii=False, allow_nan=False) + "\n").encode("utf-8")) sys.stdout.buffer.write((json.dumps(response, ensure_ascii=False, allow_nan=False) + "\n").encode("utf-8"))
-2
View File
@@ -26,8 +26,6 @@ async def lifespan(_: FastAPI):
yield yield
finally: finally:
await transcription_service.shutdown() await transcription_service.shutdown()
from app.local_models import components
await components.shutdown()
from app.local_models import manager from app.local_models import manager
for _, key in list(manager._downloads): for _, key in list(manager._downloads):
await manager.cancel_download(key) await manager.cancel_download(key)
+4 -13
View File
@@ -3,7 +3,6 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import hashlib
from contextlib import closing from contextlib import closing
from pathlib import Path from pathlib import Path
from uuid import uuid4 from uuid import uuid4
@@ -23,17 +22,14 @@ MEDIA_SUFFIXES = {".wav", ".mp3", ".flac", ".ogg", ".m4a", ".mp4", ".webm", ".tx
@router.post("/attachments", status_code=201) @router.post("/attachments", status_code=201)
async def upload_attachment(request: Request, filename: str = Query(min_length=1, max_length=255), async def upload_attachment(request: Request, filename: str = Query(min_length=1, max_length=255)):
idempotency_key: str | None = Header(None, min_length=16, max_length=100, pattern=r"^[a-zA-Z0-9_-]+$")):
suffix = Path(filename).suffix.lower() suffix = Path(filename).suffix.lower()
if suffix not in MEDIA_SUFFIXES: if suffix not in MEDIA_SUFFIXES:
raise ApiError(422, "UNSUPPORTED_MEDIA", "Unsupported attachment extension.") raise ApiError(422, "UNSUPPORTED_MEDIA", "Unsupported attachment extension.")
identity = hashlib.sha256(idempotency_key.encode()).hexdigest() if idempotency_key else uuid4().hex attachment_id = f"media_{uuid4().hex}{suffix}"
attachment_id = f"media_{identity}{suffix}"
destination = attachment_path(attachment_id) destination = attachment_path(attachment_id)
destination.parent.mkdir(parents=True, exist_ok=True) destination.parent.mkdir(parents=True, exist_ok=True)
temporary = destination.with_suffix(destination.suffix + f".{uuid4().hex}.upload") temporary = destination.with_suffix(destination.suffix + ".upload")
digest = hashlib.sha256()
size = 0 size = 0
try: try:
with temporary.open("xb") as stream: with temporary.open("xb") as stream:
@@ -41,15 +37,10 @@ async def upload_attachment(request: Request, filename: str = Query(min_length=1
size += len(chunk) size += len(chunk)
if size > MAX_UPLOAD_BYTES: if size > MAX_UPLOAD_BYTES:
raise ApiError(413, "ATTACHMENT_TOO_LARGE", "Attachment exceeds 25 MiB.") raise ApiError(413, "ATTACHMENT_TOO_LARGE", "Attachment exceeds 25 MiB.")
digest.update(chunk)
stream.write(chunk) stream.write(chunk)
if not size: if not size:
raise ApiError(422, "EMPTY_ATTACHMENT", "Attachment is empty.") raise ApiError(422, "EMPTY_ATTACHMENT", "Attachment is empty.")
if destination.exists(): temporary.replace(destination)
if hashlib.sha256(destination.read_bytes()).digest() != digest.digest():
raise ApiError(409, "IDEMPOTENCY_CONFLICT", "同一上传标识不能用于不同附件。")
else:
temporary.replace(destination)
finally: finally:
temporary.unlink(missing_ok=True) temporary.unlink(missing_ok=True)
return {"attachment_id": attachment_id, "filename": Path(filename).name, "size": size} return {"attachment_id": attachment_id, "filename": Path(filename).name, "size": size}
+2 -57
View File
@@ -1,67 +1,12 @@
from fastapi import APIRouter from fastapi import APIRouter
from pydantic import BaseModel, Field from pydantic import BaseModel
from app.contracts import ProviderCreateRequest, ProviderConfig, ModelRequest, Message, MessageRole from app.contracts import ProviderCreateRequest, ProviderConfig, ModelRequest, Message, MessageRole
from app.providers.factory import ProviderFactory from app.providers.factory import ProviderFactory
from app.request_overrides import RequestOverride, apply_overrides from app.request_overrides import apply_overrides
router = APIRouter(prefix="/api/providers", tags=["Providers"]) router = APIRouter(prefix="/api/providers", tags=["Providers"])
class RulesTransfer(BaseModel):
version: int = Field(default=1, ge=1, le=1)
request_overrides: list[RequestOverride] = Field(max_length=100)
@router.post("/request-rules/validate")
async def validate_rules(request: RulesTransfer):
return request
class ProbeRequest(BaseModel):
provider: ProviderCreateRequest
stream: bool = True
@router.post("/request-probe")
async def probe(request: ProbeRequest):
"""Explicit user-triggered inference; no vault context, tools or media uploads."""
import asyncio
from contextlib import aclosing
from app.container import container
from app.errors import ApiError
from app.providers.base import ProviderError
from app.providers.factory import UnsupportedProviderError
config = ProviderConfig(provider_id="request-probe", **request.provider.model_dump())
if not config.default_model:
raise ApiError(422, "MODEL_REQUIRED", "请填写要验证的模型 ID。")
try:
adapter = container.provider_factory.build(config)
model_request = ModelRequest(provider_id=config.provider_id, model=config.default_model,
messages=[Message(role=MessageRole.user, content="Reply with OK.")], max_tokens=32)
received = False
async with asyncio.timeout(45):
if request.stream:
async with aclosing(adapter.stream(model_request)) as events:
async for event in events:
if event.event.value in {"TextDelta", "ThinkingDelta"}:
received = received or bool(str(event.data.get("text") or "").strip())
if event.event.value == "Error":
raise ProviderError("PROVIDER_PROBE_FAILED", "模型返回了错误事件。")
else:
response = await adapter.complete(model_request)
received = bool(response.text and response.text.strip())
if not received:
raise ApiError(422, "PROVIDER_EMPTY_RESPONSE", "请求未返回有效文本,不能标记验证通过。")
except ProviderError as exc:
raise ApiError(502, exc.code, "推理验证失败,请检查模型、凭据和自定义参数。") from exc
except TimeoutError as exc:
raise ApiError(504, "PROVIDER_TIMEOUT", "推理验证超时。") from exc
except UnsupportedProviderError as exc:
raise ApiError(422, "PROVIDER_TYPE_UNSUPPORTED", "该协议不支持推理验证。") from exc
return {"success": True, "stream": request.stream, "model": config.default_model,
"message": "当前请求配置已通过实际推理验证。"}
class PreviewRequest(BaseModel): class PreviewRequest(BaseModel):
provider: ProviderCreateRequest provider: ProviderCreateRequest
stream: bool = True stream: bool = True
-17
View File
@@ -6,8 +6,6 @@ available only for explicitly injected tests and protocol fixtures.
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import asyncio
import time
import json import json
import math import math
from dataclasses import dataclass, field, replace from dataclasses import dataclass, field, replace
@@ -181,7 +179,6 @@ class ModelRoutingService:
payload = apply_overrides(kwargs.get(field, {}), provider.request_overrides, capability) payload = apply_overrides(kwargs.get(field, {}), provider.request_overrides, capability)
kwargs[field] = payload if field == "json" else {key: json.dumps(value) if isinstance(value, (dict, list, bool)) or value is None else value for key, value in payload.items()} kwargs[field] = payload if field == "json" else {key: json.dumps(value) if isinstance(value, (dict, list, bool)) or value is None else value for key, value in payload.items()}
attempt = UsageAttempt(binding.provider_id, binding.model, provider.provider_type.value, capability) attempt = UsageAttempt(binding.provider_id, binding.model, provider.provider_type.value, capability)
started = time.monotonic()
try: try:
async with httpx.AsyncClient(timeout=30, transport=self.transport) as client: async with httpx.AsyncClient(timeout=30, transport=self.transport) as client:
async with client.stream("POST", url, headers=headers, **kwargs) as response: async with client.stream("POST", url, headers=headers, **kwargs) as response:
@@ -205,11 +202,6 @@ class ModelRoutingService:
raise invalid_response() from exc raise invalid_response() from exc
finally: finally:
attempt.persist() attempt.persist()
from app.services.model_diagnostics import record
task = asyncio.current_task()
status = "completed" if attempt.completed else ("cancelled" if task and task.cancelling() else "failed")
record(model=binding.model, operation=capability, source="api", status=status,
attempt_id=attempt.attempt_id, request_id=attempt.request_id, elapsed_seconds=time.monotonic() - started)
if not isinstance(data, dict) or data.get("error"): if not isinstance(data, dict) or data.get("error"):
raise invalid_response() raise invalid_response()
return data, url return data, url
@@ -263,9 +255,6 @@ class ModelRoutingService:
model_id="api-" + hashlib.sha256(identity.encode()).hexdigest()) model_id="api-" + hashlib.sha256(identity.encode()).hexdigest())
except ProviderError as exc: except ProviderError as exc:
reason = exc.code reason = exc.code
from app.services.model_diagnostics import record
record(model=binding.model, source="api", status="fallback", error_code=reason,
fallback_reason=reason, operation="model_routing")
from app.local_models.runtime import LocalEmbedding from app.local_models.runtime import LocalEmbedding
local_embedding = self.local_embedding.snapshot() if isinstance(self.local_embedding, LocalEmbedding) else self.local_embedding local_embedding = self.local_embedding.snapshot() if isinstance(self.local_embedding, LocalEmbedding) else self.local_embedding
try: try:
@@ -325,9 +314,6 @@ class ModelRoutingService:
return RoutedTranscript(text=text, source="api", segments=segments) return RoutedTranscript(text=text, source="api", segments=segments)
except ProviderError as exc: except ProviderError as exc:
reason = exc.code reason = exc.code
from app.services.model_diagnostics import record
record(model=binding.model, source="api", status="fallback", error_code=reason,
fallback_reason=reason, operation="model_routing")
try: try:
text = await self.local_speech.transcribe(source, language) text = await self.local_speech.transcribe(source, language)
if isinstance(text, RoutedTranscript): if isinstance(text, RoutedTranscript):
@@ -360,9 +346,6 @@ class ModelRoutingService:
return SpeakerMatchResult(score=score, source="api") return SpeakerMatchResult(score=score, source="api")
except ProviderError as exc: except ProviderError as exc:
reason = exc.code reason = exc.code
from app.services.model_diagnostics import record
record(model=binding.model, source="api", status="fallback", error_code=reason,
fallback_reason=reason, operation="model_routing")
try: try:
score = await self.local_speech.match(source, reference) score = await self.local_speech.match(source, reference)
if not finite_number(score) or not 0 <= score <= 1: if not finite_number(score) or not 0 <= score <= 1:
+10 -31
View File
@@ -19,10 +19,8 @@ async def create_transcript_note(job_id, options):
job = require_job(job_id) job = require_job(job_id)
if job.status != "completed": if job.status != "completed":
raise ApiError(409, "TRANSCRIPT_NOT_READY", "Only completed transcripts can become notes.") raise ApiError(409, "TRANSCRIPT_NOT_READY", "Only completed transcripts can become notes.")
options_hash = hashlib.sha256(options.model_copy(update={"update_existing": False}).model_dump_json(exclude={"update_existing"}).encode()).hexdigest() options_hash = hashlib.sha256(options.model_dump_json().encode()).hexdigest()
with closing(connect()) as conn: with closing(connect()) as conn:
conn.execute("CREATE TABLE IF NOT EXISTS media_note_baselines (note_id TEXT PRIMARY KEY, content_hash TEXT NOT NULL)")
previous = conn.execute("SELECT m.note_id,b.content_hash FROM media_notes m LEFT JOIN media_note_baselines b ON b.note_id=m.note_id WHERE m.job_id=? AND m.options_hash=? ORDER BY m.revision DESC LIMIT 1", (job_id, options_hash)).fetchone()
row = conn.execute("SELECT note_id FROM media_notes WHERE job_id=? AND revision=? AND options_hash=?", row = conn.execute("SELECT note_id FROM media_notes WHERE job_id=? AND revision=? AND options_hash=?",
(job_id, job.revision, options_hash)).fetchone() (job_id, job.revision, options_hash)).fetchone()
if row: if row:
@@ -46,34 +44,15 @@ async def create_transcript_note(job_id, options):
if job.local_only: if job.local_only:
# Persist the indexing policy in the Vault, including later rebuilds. # Persist the indexing policy in the Vault, including later rebuilds.
lines = ["---", "embedding_local_only: true", "---", "", *lines] lines = ["---", "embedding_local_only: true", "---", "", *lines]
markdown = "\n".join(lines) try:
if options.update_existing: note = await note_service.create_note(title=title, markdown="\n".join(lines), folder=options.folder, tags=["转写"])
if previous is None or previous[1] is None: except ApiError as exc:
raise ApiError(409, "NOTE_UPDATE_BASELINE_MISSING", "没有可安全更新的导出记录,请先创建新笔记。") if exc.code != "RESOURCE_CONFLICT" or "note_id" not in exc.details:
current = await note_service.get_note(previous[0]) raise
if current is None: # Recover a crash between successful note creation and linking the job.
raise ApiError(404, "RESOURCE_NOT_FOUND", "已导出笔记不存在。") note = await note_service.get_note(exc.details["note_id"])
# Recover a successful update if linking failed after the Vault write. if note is None or marker not in note.markdown:
if current.markdown == markdown: raise
note = current
else:
note = await note_service.update_note(previous[0], markdown=markdown, expected_content_hash=previous[1])
else:
note = await _create_note(title, markdown, options, marker)
with closing(connect()) as conn, transaction(conn): with closing(connect()) as conn, transaction(conn):
conn.execute("INSERT OR IGNORE INTO media_notes VALUES (?,?,?,?)", (job_id, job.revision, options_hash, note.note_id)) conn.execute("INSERT OR IGNORE INTO media_notes VALUES (?,?,?,?)", (job_id, job.revision, options_hash, note.note_id))
conn.execute("INSERT OR REPLACE INTO media_note_baselines VALUES (?,?)", (note.note_id, hashlib.sha256(markdown.encode()).hexdigest()))
return note return note
async def _create_note(title, markdown, options, marker):
try:
note = await note_service.create_note(title=title, markdown=markdown, folder=options.folder, tags=["转写"])
except ApiError as exc:
if exc.code != "RESOURCE_CONFLICT" or "note_id" not in exc.details:
raise
# Recover a crash between successful note creation and linking the job.
note = await note_service.get_note(exc.details["note_id"])
if note is None or marker not in note.markdown:
raise
return note
-37
View File
@@ -1,37 +0,0 @@
"""Bounded, durable diagnostics. No payloads, paths, exception text or credentials."""
import json
import logging
import math
from contextlib import closing
from datetime import datetime, timezone
from app.database.db import connect, transaction
TEXT = {"model", "revision", "operation", "source", "requested_device", "actual_device",
"attempted_device", "fallback_reason", "error_code", "status", "request_id", "attempt_id"}
NUMBERS = {"load_seconds", "inference_seconds", "elapsed_seconds", "peak_memory_bytes", "queue_seconds"}
def connection():
conn = connect()
conn.execute("CREATE TABLE IF NOT EXISTS model_diagnostics (id INTEGER PRIMARY KEY AUTOINCREMENT, record_json TEXT NOT NULL)")
return conn
def record(**values):
safe = {key: value[:240] for key, value in values.items() if key in TEXT and isinstance(value, str)}
safe.update({key: value for key, value in values.items()
if key in NUMBERS and type(value) in (float, int) and math.isfinite(value) and value >= 0})
safe["timestamp"] = datetime.now(timezone.utc).isoformat()
try:
with closing(connection()) as conn, transaction(conn):
conn.execute("INSERT INTO model_diagnostics(record_json) VALUES (?)", (json.dumps(safe),))
conn.execute("DELETE FROM model_diagnostics WHERE id NOT IN (SELECT id FROM model_diagnostics ORDER BY id DESC LIMIT 200)")
except Exception:
logging.getLogger(__name__).warning("Model diagnostic persistence failed")
return safe
def recent():
with closing(connection()) as conn:
return [json.loads(row[0]) for row in conn.execute("SELECT record_json FROM model_diagnostics ORDER BY id")]
+2 -8
View File
@@ -17,7 +17,7 @@ from app.contracts import Note, NoteBlock, NoteSummary
from app.database.db import connect, transaction from app.database.db import connect, transaction
from app.errors import ApiError from app.errors import ApiError
from app.knowledge.parser import ParsedNote, parse_note from app.knowledge.parser import ParsedNote, parse_note
from app.local_models.runtime import LocalEmbedding, background_embeddings from app.local_models.runtime import LocalEmbedding
from app.retrieval import routed_vectors from app.retrieval import routed_vectors
from app.retrieval.vectorstore import SqliteVecStore, VectorRecord from app.retrieval.vectorstore import SqliteVecStore, VectorRecord
from app.services.coordination import serialized_vault_mutation from app.services.coordination import serialized_vault_mutation
@@ -77,7 +77,6 @@ def _delete_markdown(rel_path: str) -> None:
PreparedIndex = tuple[list[list[float]], routed_vectors.RemoteEmbeddings | None] PreparedIndex = tuple[list[list[float]], routed_vectors.RemoteEmbeddings | None]
@background_embeddings
async def prepare_note_index(parsed: ParsedNote, *, strict=False) -> PreparedIndex: async def prepare_note_index(parsed: ParsedNote, *, strict=False) -> PreparedIndex:
"""Compute vectors before opening a write transaction (including API I/O).""" """Compute vectors before opening a write transaction (including API I/O)."""
texts = [block.content for block in parsed.blocks] texts = [block.content for block in parsed.blocks]
@@ -181,18 +180,13 @@ async def get_note(note_id: str) -> Note | None:
@serialized_vault_mutation @serialized_vault_mutation
async def update_note( async def update_note(
note_id: str, *, title: str | None = None, markdown: str | None = None, tags: list[str] | None = None, expected_content_hash: str | None = None note_id: str, *, title: str | None = None, markdown: str | None = None, tags: list[str] | None = None
) -> Note: ) -> Note:
record = repository.get_note_record(note_id) record = repository.get_note_record(note_id)
if record is None: if record is None:
raise ApiError(404, "RESOURCE_NOT_FOUND", "note not found", {"note_id": note_id}) raise ApiError(404, "RESOURCE_NOT_FOUND", "note not found", {"note_id": note_id})
old_md = _read_markdown(record.file_path) old_md = _read_markdown(record.file_path)
if expected_content_hash is not None:
import hashlib
if hashlib.sha256(old_md.encode()).hexdigest() != expected_content_hash:
raise ApiError(409, "NOTE_CONTENT_CONFLICT", "笔记已被编辑,请保留现有内容或导出为新笔记。")
new_md = old_md if markdown is None else markdown new_md = old_md if markdown is None else markdown
# PATCH 语义:tags=None 保持原标签;[] 清空;非空列表替换(区别于 create 的 frontmatter 推导) # PATCH 语义:tags=None 保持原标签;[] 清空;非空列表替换(区别于 create 的 frontmatter 推导)
effective_tags = record.tags if tags is None else tags effective_tags = record.tags if tags is None else tags
@@ -134,10 +134,6 @@ async def _execute(job_id, request, routing=None):
from app.contracts import TranscriptSegment from app.contracts import TranscriptSegment
token = runtime_context.set(RuntimeConfig.model_validate(job.model_snapshot.get("local_runtime", {}))) token = runtime_context.set(RuntimeConfig.model_validate(job.model_snapshot.get("local_runtime", {})))
def progress(message): def progress(message):
if message.get("reset"):
job.segments = []; job.progress = 0
save(job, "AttemptRestarted")
return
job.progress = max(0.0, min(0.99, message["progress"])) job.progress = max(0.0, min(0.99, message["progress"]))
job.segments.append(TranscriptSegment.model_validate(message["segment"])) job.segments.append(TranscriptSegment.model_validate(message["segment"]))
save(job, "SegmentReady") save(job, "SegmentReady")
+3 -14
View File
@@ -3,7 +3,6 @@ from __future__ import annotations
import json import json
import logging import logging
import math
from contextlib import closing from contextlib import closing
from contextvars import ContextVar from contextvars import ContextVar
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -54,7 +53,6 @@ class UsageAttempt:
self.capability, self.source = capability, source self.capability, self.source = capability, source
self.started_at = datetime.now(timezone.utc).isoformat() self.started_at = datetime.now(timezone.utc).isoformat()
self.raw = {} self.raw = {}
self.audio_seconds = None
self.completed = False self.completed = False
context = usage_context.get() or {} context = usage_context.get() or {}
self.request_id = context.get("request_id") or uuid4().hex self.request_id = context.get("request_id") or uuid4().hex
@@ -63,9 +61,6 @@ class UsageAttempt:
def observe(self, data): def observe(self, data):
if not isinstance(data, dict): if not isinstance(data, dict):
return return
duration = data.get("audio_seconds", data.get("duration"))
if self.capability in {"transcription", "speaker_matching"} and type(duration) in (int, float) and math.isfinite(duration) and 0 <= duration <= 7200:
self.audio_seconds = max(self.audio_seconds or 0, duration)
values = [data.get("usage"), (data.get("message") or {}).get("usage") if isinstance(data.get("message"), dict) else None, values = [data.get("usage"), (data.get("message") or {}).get("usage") if isinstance(data.get("message"), dict) else None,
(data.get("response") or {}).get("usage") if isinstance(data.get("response"), dict) else None] (data.get("response") or {}).get("usage") if isinstance(data.get("response"), dict) else None]
if self.protocol == "ollama": if self.protocol == "ollama":
@@ -92,7 +87,7 @@ class UsageAttempt:
miss = inputs - hit miss = inputs - hit
if hit is not None and inputs is not None and hit > inputs: if hit is not None and inputs is not None and hit > inputs:
hit, miss = None, None hit, miss = None, None
return dict(audio_seconds=self.audio_seconds, input_tokens=inputs, output_tokens=outputs, return dict(input_tokens=inputs, output_tokens=outputs,
total_tokens=inputs + outputs if inputs is not None and outputs is not None else first("total_tokens"), total_tokens=inputs + outputs if inputs is not None and outputs is not None else first("total_tokens"),
cache_hit_tokens=hit, cache_miss_tokens=miss, cache_write_tokens=write, cache_hit_tokens=hit, cache_miss_tokens=miss, cache_write_tokens=write,
reasoning_tokens=first("output_tokens_details.reasoning_tokens", "completion_tokens_details.reasoning_tokens")) reasoning_tokens=first("output_tokens_details.reasoning_tokens", "completion_tokens_details.reasoning_tokens"))
@@ -108,7 +103,7 @@ class UsageAttempt:
def aggregate(start, end, provider_id=None, model=None, source=None): def aggregate(start, end, provider_id=None, model=None, source=None):
query = "SELECT counters_json,completed,capability FROM model_usage WHERE started_at>=? AND started_at<?" query = "SELECT counters_json,completed FROM model_usage WHERE started_at>=? AND started_at<?"
args = [start.astimezone(timezone.utc).isoformat(), end.astimezone(timezone.utc).isoformat()] args = [start.astimezone(timezone.utc).isoformat(), end.astimezone(timezone.utc).isoformat()]
for column, value in (("provider_id", provider_id), ("model", model), ("source", source)): for column, value in (("provider_id", provider_id), ("model", model), ("source", source)):
if value: if value:
@@ -120,14 +115,8 @@ def aggregate(start, end, provider_id=None, model=None, source=None):
totals = {key: None for key in METRICS} totals = {key: None for key in METRICS}
coverage = {key: 0 for key in METRICS} coverage = {key: 0 for key in METRICS}
hits, eligible_input, cache_requests = 0, 0, 0 hits, eligible_input, cache_requests = 0, 0, 0
audio_requests, audio_covered, audio_seconds = 0, 0, None
for row in rows: for row in rows:
if row[2] in {"transcription", "speaker_matching"}:
audio_requests += 1
counts = json.loads(row[0]) counts = json.loads(row[0])
if counts.get("audio_seconds") is not None:
audio_covered += 1
audio_seconds = (audio_seconds or 0) + counts["audio_seconds"]
for key in METRICS: for key in METRICS:
if counts.get(key) is not None: if counts.get(key) is not None:
totals[key] = (totals[key] or 0) + counts[key] totals[key] = (totals[key] or 0) + counts[key]
@@ -136,7 +125,7 @@ def aggregate(start, end, provider_id=None, model=None, source=None):
hits += counts["cache_hit_tokens"] hits += counts["cache_hit_tokens"]
eligible_input += counts["input_tokens"] if counts.get("input_tokens") is not None else counts["cache_hit_tokens"] + counts["cache_miss_tokens"] eligible_input += counts["input_tokens"] if counts.get("input_tokens") is not None else counts["cache_hit_tokens"] + counts["cache_miss_tokens"]
cache_requests += 1 cache_requests += 1
return {"audio_request_count": audio_requests, "audio_seconds": audio_seconds, "audio_covered_requests": audio_covered, "totals": totals, "coverage": coverage, "request_count": len(rows), return {"totals": totals, "coverage": coverage, "request_count": len(rows),
"complete_requests": sum(row[1] for row in rows), "cache_covered_requests": cache_requests, "complete_requests": sum(row[1] for row in rows), "cache_covered_requests": cache_requests,
"cache_hit_rate": hits / eligible_input if eligible_input else None, "cache_hit_rate": hits / eligible_input if eligible_input else None,
"options": [dict(row) for row in options], "start": start, "end": end, "options": [dict(row) for row in options], "start": start, "end": end,
+4 -12
View File
@@ -1,12 +1,9 @@
param( param(
[ValidateSet('cpu', 'cuda')][string]$Device = 'cpu', [ValidateSet('cpu', 'cuda')][string]$Device = 'cpu'
[string]$RuntimeDirectory = '',
[switch]$QuietProgress
) )
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$uvOptions = if ($QuietProgress) { @('--quiet') } else { @() }
$backendRoot = Split-Path $PSScriptRoot -Parent $backendRoot = Split-Path $PSScriptRoot -Parent
$runtimeRoot = if ($RuntimeDirectory) { [IO.Path]::GetFullPath($RuntimeDirectory) } else { Join-Path $backendRoot '.venv-models' } $runtimeRoot = Join-Path $backendRoot '.venv-models'
$runtimePython = Join-Path $runtimeRoot 'Scripts/python.exe' $runtimePython = Join-Path $runtimeRoot 'Scripts/python.exe'
if (!(Test-Path -LiteralPath $runtimePython)) { if (!(Test-Path -LiteralPath $runtimePython)) {
& uv venv --python 3.12 $runtimeRoot & uv venv --python 3.12 $runtimeRoot
@@ -14,14 +11,9 @@ if (!(Test-Path -LiteralPath $runtimePython)) {
} }
# CPU is the default. CUDA wheels include the runtime, not the NVIDIA driver. # CPU is the default. CUDA wheels include the runtime, not the NVIDIA driver.
$torchIndex = if ($Device -eq 'cuda') { 'https://download.pytorch.org/whl/cu128' } else { 'https://download.pytorch.org/whl/cpu' } $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' } & uv pip install --python $runtimePython --index-url $torchIndex 'torch==2.9.1' 'torchaudio==2.9.1'
# Pin the local version too: ==2.9.1 alone also accepts an already-installed CPU wheel.
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 安装失败' } if ($LASTEXITCODE -ne 0) { throw 'PyTorch 安装失败' }
Write-Output 'COMPONENT:dependencies' & uv pip install --python $runtimePython -r (Join-Path $PSScriptRoot 'model-requirements.lock') -c (Join-Path $PSScriptRoot 'model-requirements.txt')
& 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 '模型依赖安装失败' } if ($LASTEXITCODE -ne 0) { throw '模型依赖安装失败' }
Write-Output 'COMPONENT:verify'
& $runtimePython -c 'import torch; print({"torch":torch.__version__,"cuda_available":torch.cuda.is_available()})' & $runtimePython -c 'import torch; print({"torch":torch.__version__,"cuda_available":torch.cuda.is_available()})'
if ($LASTEXITCODE -ne 0) { throw '模型运行环境检查失败' } if ($LASTEXITCODE -ne 0) { throw '模型运行环境检查失败' }
@@ -1,221 +0,0 @@
"""Finalization regressions: device recovery, durable facts and guarded writes."""
import asyncio
import json
import sys
from contextlib import closing
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from app.errors import ApiError
from app.providers.base import ProviderError
@pytest.mark.parametrize('code,retries', [('LOCAL_CUDA_OOM', True), ('LOCAL_CUDA_INIT_FAILED', True),
('LOCAL_INFERENCE_FAILED', False), ('LOCAL_RUNTIME_DEPENDENCY_MISSING', False)])
def test_cuda_retries_only_device_failures_in_reaped_process(monkeypatch, code, retries):
import app.local_models.runtime as module
from app.services import model_diagnostics
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))
events = []
class Process:
def __init__(self):
from types import SimpleNamespace
self.stdin = SimpleNamespace(write=self.write, drain=self.drain, close=lambda: None)
self.stdout = asyncio.StreamReader()
self.returncode = None
self.device = None
def write(self, raw):
self.device = json.loads(raw)['config']['device']
events.append('start-' + self.device)
result = {'error_code': code} if self.device == 'cuda' else {'result': [[1, 0]], 'usage': {'input_tokens': 2}, 'diagnostics': {'actual_device': 'cpu'}}
self.stdout.feed_data((json.dumps(result) + '\n').encode())
self.stdout.feed_eof()
async def drain(self):
pass
async def close(self):
pass
async def wait(self):
self.returncode = 0
events.append('reaped-' + self.device)
def kill(self):
self.returncode = -9
async def spawn(*args, **kwargs):
if events:
assert events[-1] == 'reaped-cuda'
return Process()
monkeypatch.setattr(module.asyncio, 'create_subprocess_exec', spawn)
async def scenario():
runtime = module.Runtime()
if retries:
assert await runtime.infer('bekko', 'embedding', {'texts': ['private text']}) == [[1, 0]]
else:
with pytest.raises(ProviderError) as error:
await runtime.infer('bekko', 'embedding', {'texts': ['private text']})
assert error.value.code == code
assert not runtime.active and not runtime.waiters
asyncio.run(scenario())
assert events == (['start-cuda', 'reaped-cuda', 'start-cpu', 'reaped-cpu'] if retries else ['start-cuda', 'reaped-cuda'])
records = model_diagnostics.recent()
assert records[0]['error_code'] == code
assert 'private text' not in json.dumps(records)
if retries:
assert records[-1]['requested_device'] == 'cuda' and records[-1]['actual_device'] == 'cpu'
assert records[-1]['fallback_reason'] == code
assert records[0]['request_id'] == records[1]['request_id']
assert records[0]['attempt_id'] != records[1]['attempt_id']
with closing(connection()) as conn:
assert conn.execute('SELECT COUNT(*) FROM model_usage').fetchone()[0] == (2 if retries else 1)
def test_cpu_failure_does_not_loop_and_interactive_precedes_index(monkeypatch):
import app.local_models.runtime as module
async def scenario():
runtime = module.Runtime()
entered, release = asyncio.Event(), asyncio.Event()
order = []
async def execute(key, operation, payload, config, diagnostics):
order.append(payload['name'])
if payload['name'] == 'running':
entered.set()
await release.wait()
return {'result': []}
monkeypatch.setattr(runtime, '_execute', execute)
first = asyncio.create_task(runtime.infer('bekko', 'embedding', {'name': 'running'}))
await entered.wait()
background = asyncio.create_task(runtime.infer('bekko', 'embedding', {'name': 'index'}, priority=20))
query = asyncio.create_task(runtime.infer('bekko', 'embedding', {'name': 'query'}, priority=0))
await asyncio.sleep(0)
release.set()
await asyncio.gather(first, background, query)
assert order == ['running', 'query', 'index']
calls = []
async def failed(key, operation, payload, config, diagnostics):
calls.append(config.device)
raise ProviderError('LOCAL_CUDA_OOM', 'simulated')
monkeypatch.setattr(runtime, '_execute', failed)
monkeypatch.setattr(module, 'configuration', lambda: module.RuntimeConfig(device='cuda'))
with pytest.raises(ProviderError):
await runtime.infer('bekko', 'embedding', {})
assert calls == ['cuda', 'cpu'] and not runtime.active
asyncio.run(scenario())
def test_durable_diagnostics_are_bounded_and_disk_size_is_real():
from app.services import model_diagnostics
from app.local_models import manager
for index in range(205):
model_diagnostics.record(model='bekko', status='failed', error_code='TEST', payload='secret', elapsed_seconds=index)
records = model_diagnostics.recent()
assert len(records) == 200 and records[0]['elapsed_seconds'] == 5
assert 'secret' not in json.dumps(records)
path = manager.model_path('bekko')
path.mkdir(parents=True)
(path / 'weights.partial').write_bytes(b'1234567')
assert manager.disk_bytes('bekko') == 7
def test_upload_key_replay_and_content_conflict():
from app.main import app
with TestClient(app) as client:
headers = {'Idempotency-Key': 'stable-upload-123456'}
first = client.post('/api/media/attachments?filename=lecture.txt', content=b'original', headers=headers)
again = client.post('/api/media/attachments?filename=lecture.txt', content=b'original', headers=headers)
assert first.status_code == again.status_code == 201
assert first.json()['attachment_id'] == again.json()['attachment_id']
assert client.post('/api/media/attachments?filename=lecture.txt', content=b'changed', headers=headers).status_code == 409
assert client.get('/api/media/attachments/' + first.json()['attachment_id']).content == b'original'
def test_updated_transcript_note_keeps_identity_and_rejects_user_edits():
from app.contracts import TranscriptNoteRequest, TranscriptEditRequest, IndexRebuildRequest
from app.services import transcription_service as jobs, note_service, index_service
from app.services.media_notes import create_transcript_note
from app.services.attachment_service import attachment_path
path = attachment_path('lecture.txt')
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text('original', encoding='utf-8')
async def scenario():
job = await jobs.create_transcription('lecture.txt', local_only=True)
options = TranscriptNoteRequest(title='Lecture')
first = await create_transcript_note(job.job_id, options)
await index_service.rebuild(IndexRebuildRequest())
jobs.edit(job.job_id, TranscriptEditRequest(revision=1, text='revised'))
update = options.model_copy(update={'update_existing': True})
second = await create_transcript_note(job.job_id, update)
assert first.note_id == second.note_id and 'revised' in second.markdown
assert 'embedding_local_only: true' in second.markdown
again = await create_transcript_note(job.job_id, update)
assert again.note_id == first.note_id
await note_service.update_note(first.note_id, markdown='User edits')
jobs.edit(job.job_id, TranscriptEditRequest(revision=2, text='third revision'))
with pytest.raises(ApiError) as error:
await create_transcript_note(job.job_id, update)
assert error.value.code == 'NOTE_CONTENT_CONFLICT'
assert (await note_service.get_note(first.note_id)).markdown == 'User edits'
copy = await create_transcript_note(job.job_id, options)
assert copy.note_id != first.note_id
asyncio.run(scenario())
def test_audio_usage_is_separate_and_unknown_durations_stay_null():
from app.services.usage_service import UsageAttempt, aggregate
now = datetime.now(timezone.utc)
first = UsageAttempt('local', 'asr', 'local', 'transcription', source='local')
first.observe({'audio_seconds': 2.25, 'usage': {}})
first.persist(); first.persist()
unknown = UsageAttempt('remote', 'asr', 'openai_compatible', 'transcription')
unknown.persist()
result = aggregate(now - timedelta(days=1), now + timedelta(days=1))
assert result['audio_request_count'] == 2 and result['audio_covered_requests'] == 1
assert result['audio_seconds'] == 2.25 and result['totals']['input_tokens'] is None
remote = aggregate(now - timedelta(days=1), now + timedelta(days=1), source='api')
assert remote['audio_seconds'] is None
def test_request_rule_import_rejects_credentials_and_host_fields():
from app.main import app
with TestClient(app) as client:
path = '/api/providers/request-rules/validate'
body = {'version': 1, 'request_overrides': [{'body': {'enable_thinking': False}}]}
assert client.post(path, json=body).status_code == 200
for bad in ({'api_key': 'secret'}, {'nested': {'authorization': 'secret'}}, {'stream': False}):
body['request_overrides'][0]['body'] = bad
assert client.post(path, json=body).status_code == 422
@pytest.mark.parametrize('stream', [False, True])
def test_inference_probe_uses_adapter_body_and_no_vault_context(monkeypatch, stream):
import httpx
from app.container import container
from app.main import app
original = container.provider_factory.build
requests = []
def respond(request):
data = json.loads(request.content)
requests.append(data)
assert data['enable_thinking'] is False and data['stream'] == stream
assert data['messages'] == [{'role': 'user', 'content': 'Reply with OK.'}]
assert not data.get('tools')
if stream:
return httpx.Response(200, text='data: {"choices":[{"delta":{"content":"OK"},"finish_reason":null}]}\n\ndata: [DONE]\n\n')
return httpx.Response(200, json={'choices': [{'message': {'role': 'assistant', 'content': 'OK'}, 'finish_reason': 'stop'}]})
def build(config):
adapter = original(config)
adapter.transport = httpx.MockTransport(respond)
return adapter
monkeypatch.setattr(container.provider_factory, 'build', build)
with TestClient(app) as client:
response = client.post('/api/providers/request-probe', json={'stream': stream, 'provider': {
'name': 'Probe', 'provider_type': 'openai_compatible', 'base_url': 'https://fixture.invalid/v1',
'default_model': 'test', 'request_overrides': [{'body': {'enable_thinking': False}}]}})
assert response.status_code == 200, response.text
assert len(requests) == 1
-85
View File
@@ -1,85 +0,0 @@
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'
@@ -1573,19 +1573,3 @@ frontend/src/
### Benchmark Embedding 运行归属(阶段 E 集成修复) ### Benchmark Embedding 运行归属(阶段 E 集成修复)
`config_snapshot.local_embedding` 仅表示本地基线;`config_snapshot.embedding``{ "policy": "per_case", "details": "cases[].embedding" }`。报告与 CaseCompleted 事件的逐样本 `embedding` 包含实际 sourceapi/local/not_used/unavailable)、model_id、dimensions,以及可选 version、fallback_reason、requested_route、route_version、attempted_space。requested_route 仅含提供商引用、模型、相对端点和维度,不包含 API Key 或凭据引用。FTS 不使用 Embedding,标记 not_used;远程失败或索引不完整回退时记录实际本地模型及原因。 `config_snapshot.local_embedding` 仅表示本地基线;`config_snapshot.embedding``{ "policy": "per_case", "details": "cases[].embedding" }`。报告与 CaseCompleted 事件的逐样本 `embedding` 包含实际 sourceapi/local/not_used/unavailable)、model_id、dimensions,以及可选 version、fallback_reason、requested_route、route_version、attempted_space。requested_route 仅含提供商引用、模型、相对端点和维度,不包含 API Key 或凭据引用。FTS 不使用 Embedding,标记 not_used;远程失败或索引不完整回退时记录实际本地模型及原因。
### 阶段 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 限制。 |
| `TranscriptNoteRequest.update_existing` | 默认 false;true 时将新修订安全写入相同导出选项对应的笔记。无基线返回 409 `NOTE_UPDATE_BASELINE_MISSING`;正文改变返回 409 `NOTE_CONTENT_CONFLICT`。同修订重复调用保持幂等。 |
| 本地模型 `disk_bytes` | 权重目录实际字节数;无法读取为 null。与下载 bytes/total 分开。 |
| `GET /api/local-models/diagnostics` | `scope=application_last_200_attempts`,应用 SQLite 中最近 200 条诊断,包含调用及回退事件。未实际开始推理时不伪造 actual_device。 |
| `GET /api/usage` | 增加 `audio_request_count`、可空的 `audio_seconds``audio_covered_requests`,适用原有时间/提供商/模型/来源过滤。次数按 transcription/speaker_matching 实际 attempt;未报告时长不估算。 |
| `POST /api/providers/request-rules/validate` | 输入/输出 `{version:1, request_overrides:[...]}`;最多 100 条,复用请求扩展校验,不保存提供商。 |
| `POST /api/providers/request-probe` | 输入 `{provider:ProviderCreateRequest, stream:boolean}`;固定短消息真实聊天推理,45 秒超时。成功返回 success/stream/model/message;空响应 422、供应商错误 502、超时 504。只使用 credential_id,不接收明文密钥。 |
请求预览新增 capability 选择(chat/embedding/transcription/speaker_matching),仍只返回隐藏正文的请求体。实际扩展字段是否被供应商接受,以推理响应为准。
@@ -93,30 +93,6 @@ 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 一次。其他错误不触发设备重试;用户取消不会启动后续尝试。重试会清除上一尝试的部分转写片段。
- 安装脚本固定 CPU/CUDA wheel 为 `2.9.1+cpu` / `2.9.1+cu128`,避免已有 CPU wheel 被误认为满足 CUDA 安装。可用 `-RuntimeDirectory` 指定独立环境,后端通过 `APP_MODEL_PYTHON` 选择;不自动更换显卡驱动。
- 运行诊断写入应用 SQLite,保留最近 200 条,覆盖本地成功、失败、取消及能力 API 调用/回退事件。仅保留模型、设备、数值耗时、资源、状态码及请求标识,不保存输入、文件路径、密钥或异常全文。排队取消不记作实际模型用量;设备重试有独立 attempt,共享逻辑 request_id。
- 前端同一次提交在响应丢失后复用上传和任务幂等键;收到附件 ID 后只重试创建任务。“重新处理为新任务”明确创建新标识。客户端待提交状态仅在当前页面内存中,已接收任务和结果由后端持久化。
- 转写修订可选择“更新已导出笔记”。后端在 Vault 写锁内校验上次导出内容摘要,保留 note_id 和本地索引限制。用户编辑过正文时返回冲突,不覆盖;旧记录没有摘要时需先创建新笔记。重建索引保留导出基线与关联。
- 用量卡片单列音频实际调用次数、已报告时长和覆盖次数;时长不换算为 Token。重试分别计数,历史未知数据保持“未提供”。
- 请求 JSON 可导入、导出和恢复默认。文件格式为 `{ "version": 1, "request_overrides": [...] }`,只包含扩展规则;服务端复用受保护字段与凭据校验,导入成功仍需保存提供商才生效。
- 请求预览不联网。聊天“发送测试推理请求”使用当前草稿、已保存的凭据引用和固定短消息,支持流式/非流式,不读取知识库、工具或附件,并计入真实用量。更改模型、连接、规则或 JSON 有效性后,旧结果和迟到响应失效;媒体规则继续通过真实媒体操作验收。
独立 CUDA 环境示例(不改变默认 CPU 环境):
```powershell
./backend/scripts/install-model-runtime.ps1 -Device cuda -RuntimeDirectory ./backend/.venv-models-cuda
$env:APP_MODEL_PYTHON = (Resolve-Path ./backend/.venv-models-cuda/Scripts/python.exe).Path
```
设置环境变量后需从同一终端重启后端;CPU 默认仍可用。模型权重与运行环境不提交仓库。
### 2026-09-04 联调修复补充 ### 2026-09-04 联调修复补充
Windows 热重载不支持异步子进程时使用线程管道兼容路径。本地 Embedding 进入推理前冻结模型与设备配置,向量空间标识来自同一快照;普通 API 成功及失败回退策略保持不变。 Windows 热重载不支持异步子进程时使用线程管道兼容路径。本地 Embedding 进入推理前冻结模型与设备配置,向量空间标识来自同一快照;普通 API 成功及失败回退策略保持不变。
@@ -1,55 +0,0 @@
# 阶段 F 收尾验收记录
日期:2026-09-05。对应 `feat/multimodal-finalization`,基于 `6bdba2c`(阶段 F 主分支合并)。
## 完成范围
本轮补齐运行诊断持久化、真实磁盘占用、后台索引优先级、CUDA 设备失败时 CPU 单次重试、上传与任务重试幂等、跨修订安全更新笔记、音频用量分项,以及请求 JSON 导入/导出/重置和实际聊天推理验证。原有 API 优先、无配置/无效响应使用本地模型、local_only 禁止远程调用的流程继续保留。
具体行为见[开发说明](多模态管线与模型运行开发说明.md),接口见[开发版契约](../contracts/第二阶段接口契约-开发版.md),故障与修复见[问题记录 F-13F-15](../retrospectives/阶段F-Embedding与知识库问题与解决方案.md)。
## 自动化与页面验证
| 项目 | 结果 |
| --- | --- |
| 后端全量 `python -m pytest -q -p no:cacheprovider` | 555 通过;1 条已有 Starlette/httpx 弃用提示 |
| 前端全量 `npm test -- --run` | 28 个文件、100 项通过 |
| 类型与生产构建 `npm run build` | vue-tsc 与 Vite 构建通过,仍有既有大 bundle 提示 |
| `git diff --check` | 通过 |
| 真实页面 | 模型卡片读取实际大小;音频统计显示真实缺失;提供商表单展示请求编辑、恢复默认、导入/导出及推理验证入口 |
| 请求 Adapter 验证 | 隔离 HTTP Transport 检查最终流式/非流式请求和扩展字段,不访问外部供应商 |
| 失败恢复 | 初始化/OOM 故障注入、CPU 再失败、进程回收、队列顺序、重复提交、修订冲突和旧结果失效均覆盖 |
## CPU / CUDA 真实模型闭环
Windows、Python 3.12。保留原 `.venv-models` CPU 环境,独立安装 `.venv-models-cuda`;安装后检查 `torch=2.9.1+cu128``cuda_available=True`。显卡为 NVIDIA GeForce RTX 4060 Laptop GPU。
CPU 与 CUDA 分别创建隔离 Vault、附件目录和 SQLite,只读取固定 revision 权重;运行短中文音频 → Qwen3-ASR → ERes2NetV2 片段聚类 → Markdown 笔记 → Bekko 语义检索 → 修订更新。两次均返回已完成,检索命中同一笔记,更新保留 note_id 和 `embedding_local_only: true`,结束后本地运行队列无活跃任务。CPU 实际设备为 `cpu`CUDA 各次实际设备为 `cuda:0`
| CUDA 环节 | 权重 revision | 加载 / 推理耗时 |
| --- | --- | --- |
| Qwen3-ASR-0.6B | `5eb144179a02acc5e5ba31e748d22b0cf3e303b0` | 30.375 / 3.234 秒 |
| ERes2NetV2 片段聚类 | `3317286545c587ae682dbc166831d9448780eebb` | 5.735 / 0.578 秒 |
| Bekko 首次笔记索引 | `c721113d59a1d91b447450324f51c4b3332c924a` | 19.860 / 0.656 秒 |
这些是单次功能冒烟观察值;运行期间有其他验证任务,不用于宣称吞吐或 CPU/GPU 性能倍率。短样本只产生 1 个片段和 1 个 speaker,不能验证多人重叠语音质量。CUDA OOM 恢复使用故障注入,并非实机显存耗尽测试。
## 中文 Embedding 小样本对照
固定 8 篇人工构造的短文,主题为线性代数、死锁、Python 函数、语义检索、光合作用、备份及两个无关干扰项(晚餐、篮球)。6 条改写查询,各有一个预期相关文档;对全部文档做余弦排序。
| 模型 | revision | Hit@1 / Recall@5 / MRR |
| --- | --- | --- |
| Bekko A8M | `c721113d59a1d91b447450324f51c4b3332c924a` | 1.0 / 1.0 / 1.0 |
| Granite 97M Multilingual r2 | `835ad14087e140460703cf0fae09f97d469d65c2` | 1.0 / 1.0 / 1.0 |
两者在这 6 条查询上的目标排名均为 1。该结果仅证明中文检索冒烟可运行,样本量不足以区分模型优劣;继续保留 Bekko 默认、Granite 可选。
## 未关闭的专项验收
- 带参考转写和说话人标注的真实课程长录音尚未提供,不能报告 CER/WER、DER、阈值或长音频吞吐达标。
- 现阶段时间戳为片段级;逐字强制对齐、同段多人/重叠语音仍未实现,不将片段聚类视为完整说话人分离。
- 外部供应商特殊 JSON 的兼容性,需要在目标账号和模型上点击实际推理验证;离线协议通过不替代厂商验收。
- Tauri/Rust Host 和生产 MCP 沙箱按后续阶段安排;本轮数据持久化在后端 SQLite/Vault,为桌面集成保留稳定接口。
结论:阶段 F 本轮工程收尾已实现并完成 CPU/CUDA 功能验收;上述质量及外部服务专项保持待验收状态,不标记为全部通过。分支仍需独立审阅后决定合并。
@@ -138,38 +138,6 @@ embedding_local_only: true
## 9. 工程经验 ## 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 初始化失败、显存不足会直接使任务失败;诊断只留在进程内存中,重启后无法解释当时的失败和回退。
实际方案:在同一队列占位内完成 CUDA → CPU 单次重试,先回收失败子进程再启动 CPU。只接受初始化失败、CUDA OOM 两类重试原因,普通模型错误不扩大重试范围。转写部分结果随尝试重置,取消仍终止流程。诊断按白名单写入 SQLite,保留最近 200 条,记录请求设备、实际设备、尝试设备、状态和耗时;未知设备不冒充实际使用设备。
验证:故障注入覆盖初始化失败、OOM、普通错误、CPU 再失败、资源释放顺序、队列顺序和请求用量归属。Windows RTX 4060 Laptop 实机安装 `torch 2.9.1+cu128`ASR、片段声纹和 Embedding 的实际设备均为 `cuda:0`,完成笔记生成、检索与修订更新。实机正常 CUDA 路径通过;OOM 回退是确定性注入验证,未人为耗尽用户显存。
### F-14:响应丢失重复上传与转写笔记无法安全更新
问题:前端每次点击都生成新幂等键,上传或创建任务已成功但响应丢失时,重试可能制造重复附件/任务。已有导出幂等只能返回相同修订,缺少新修订更新原笔记的保护机制。
实际方案:同一次页面提交冻结文件与选项,复用上传/任务键,已获得的附件 ID 继续使用;主动重新处理才重置标识。后端重复上传校验内容摘要。导出基线保存正文摘要,跨修订更新在 Vault 写锁内核对基线,用户编辑冲突返回 409,允许改为创建新笔记;旧无基线记录不强行覆盖。索引重建不丢失基线,本地限制继续随笔记持久化。
验证:覆盖上传响应丢失、任务响应丢失、主动重跑、重复键内容冲突、修订更新保持 note_id、重复导出、重建恢复和用户正文冲突。CPU/CUDA 两次真实本地管线均在隔离 Vault/SQLite 中通过检索与修订闭环,不写入用户笔记库。
### F-15:运行管理与请求配置验收缺项
问题:下载计数不能反映实际占用,后台索引与交互查询同优先级;音频调用没有独立时长统计;请求规则缺少导入/导出/恢复默认和真实推理验证,草稿改变后旧验证结果可能误导用户。
实际方案:磁盘大小读取目录文件,查询/媒体/后台索引分别排队;音频次数、已报告时长与 Token 分开聚合,保留覆盖数。规则文件由服务端验证后替换草稿,保存后生效;验证按钮使用固定短消息走实际 Adapter。草稿变化使预览与验证失效,包括无效 JSON 和迟到响应。
验证:增加实际目录统计、音频缺失值与去重、规则拒绝受保护字段、隔离 HTTP 协议测试及前端迟到响应测试。最终后端 555 项、前端 100 项通过。真实供应商兼容性仍须使用目标账号验证;本轮不将 MockTransport 协议测试称为厂商实测。
### F-12:普通分割线与元数据头部消歧 ### F-12:普通分割线与元数据头部消歧
F-11 修复后,`---``---\n\n# Title\n\n正文` 等合法 Markdown 被误判为未闭合 frontmatter,原先能够保存的笔记被拒绝;库中已有此类文件时全量重建也会失败。 F-11 修复后,`---``---\n\n# Title\n\n正文` 等合法 Markdown 被误判为未闭合 frontmatter,原先能够保存的笔记被拒绝;库中已有此类文件时全量重建也会失败。
+7 -9
View File
@@ -1,11 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { mediaService, createMediaSubmission, type MediaJob } from '@/services/mediaService' import { mediaService, type MediaJob } from '@/services/mediaService'
const route = useRoute() const route = useRoute()
const submission = createMediaSubmission()
const updateExisting = ref(false)
const jobs = ref<MediaJob[]>([]) const jobs = ref<MediaJob[]>([])
const selected = ref<MediaJob | null>(null) const selected = ref<MediaJob | null>(null)
const file = ref<File | null>(null) const file = ref<File | null>(null)
@@ -42,7 +40,6 @@ async function choose(job: MediaJob) {
selected.value = JSON.parse(JSON.stringify(job)); dirty.value = false; history.value = [] selected.value = JSON.parse(JSON.stringify(job)); dirty.value = false; history.value = []
} }
async function action(work: () => Promise<void>) { async function action(work: () => Promise<void>) {
if (busy.value) return
busy.value = true; error.value = ''; notice.value = '' busy.value = true; error.value = ''; notice.value = ''
try { await work() } catch (e) { error.value = (e as Error).message } finally { busy.value = false } try { await work() } catch (e) { error.value = (e as Error).message } finally { busy.value = false }
} }
@@ -54,10 +51,11 @@ async function submit() {
terms = JSON.parse(terminology.value) terms = JSON.parse(terminology.value)
if (!terms || typeof terms !== 'object' || Array.isArray(terms) || Object.values(terms).some(v => typeof v !== 'string')) throw new Error('术语表需要 JSON 对象,值为替换后的文本。') if (!terms || typeof terms !== 'object' || Array.isArray(terms) || Object.values(terms).some(v => typeof v !== 'string')) throw new Error('术语表需要 JSON 对象,值为替换后的文本。')
} }
selected.value = await submission.submit(file.value!, {local_only: localOnly.value, const uploaded = await mediaService.upload(file.value!)
diarization: diarization.value, terminology: terms}) selected.value = await mediaService.create({attachment_id: uploaded.attachment_id, local_only: localOnly.value,
diarization: diarization.value, idempotency_key: crypto.randomUUID(), terminology: terms})
dirty.value = false dirty.value = false
jobs.value = [selected.value, ...jobs.value.filter(job => job.job_id !== selected.value?.job_id)] jobs.value.unshift(selected.value)
}) })
} }
function seek(seconds: number) { if (player.value) { player.value.currentTime = seconds; position.value = seconds } } function seek(seconds: number) { if (player.value) { player.value.currentTime = seconds; position.value = seconds } }
@@ -106,7 +104,7 @@ onUnmounted(() => { stopped = true; clearTimeout(timer) })
<label><input v-model="diarization" type="checkbox" />识别不同说话人</label> <label><input v-model="diarization" type="checkbox" />识别不同说话人</label>
<p class="subtle">{{ localOnly ? '本次任务不调用远程模型 API,模型需预先下载。' : '若配置了转写 API,将上传所选附件;API 失败后回退到本地模型。' }}</p> <p class="subtle">{{ localOnly ? '本次任务不调用远程模型 API,模型需预先下载。' : '若配置了转写 API,将上传所选附件;API 失败后回退到本地模型。' }}</p>
<details><summary>术语校对</summary><p class="subtle">在识别完成后替换文本原始识别结果会保留</p><textarea v-model="terminology" class="input" rows="3" placeholder='{"错误术语": "正确术语"}' /></details> <details><summary>术语校对</summary><p class="subtle">在识别完成后替换文本原始识别结果会保留</p><textarea v-model="terminology" class="input" rows="3" placeholder='{"错误术语": "正确术语"}' /></details>
<button type="button" class="button-secondary" :disabled="busy" @click="submission.reset(); notice = '下一次提交将作为新任务处理'">重新处理为新任务</button><button class="button-primary" :disabled="busy || !file">{{ busy ? '处理中…' : '上传并转写' }}</button> <button class="button-primary" :disabled="busy || !file">{{ busy ? '处理中…' : '上传并转写' }}</button>
<details><summary>声纹参考比对</summary><p class="subtle">将所选附件与参考音频比对至少各含 1 秒语音分数是相似度不是身份认证概率临时参考文件在比对后清理</p> <details><summary>声纹参考比对</summary><p class="subtle">将所选附件与参考音频比对至少各含 1 秒语音分数是相似度不是身份认证概率临时参考文件在比对后清理</p>
<input type="file" accept=".wav,.mp3,.flac,.ogg,.m4a" aria-label="声纹参考音频" @change="reference = ($event.target as HTMLInputElement).files?.[0] || null" /> <input type="file" accept=".wav,.mp3,.flac,.ogg,.m4a" aria-label="声纹参考音频" @change="reference = ($event.target as HTMLInputElement).files?.[0] || null" />
<button type="button" class="button-secondary" :disabled="busy || !file || !reference" @click="compareSpeaker">比对声纹</button><p v-if="matchResult">{{ matchResult }}</p></details> <button type="button" class="button-secondary" :disabled="busy || !file || !reference" @click="compareSpeaker">比对声纹</button><p v-if="matchResult">{{ matchResult }}</p></details>
@@ -140,7 +138,7 @@ onUnmounted(() => { stopped = true; clearTimeout(timer) })
<button class="button-secondary" @click="action(async () => { history = (await mediaService.revisions(selected!.job_id)).items })">修订历史</button></div> <button class="button-secondary" @click="action(async () => { history = (await mediaService.revisions(selected!.job_id)).items })">修订历史</button></div>
<details><summary>原始识别文本</summary><pre>{{ selected.original_text }}</pre></details> <details><summary>原始识别文本</summary><pre>{{ selected.original_text }}</pre></details>
<details v-for="revision in history" :key="revision.revision"><summary>修订 {{ revision.revision }}</summary><pre>{{ revision.text }}</pre></details> <details v-for="revision in history" :key="revision.revision"><summary>修订 {{ revision.revision }}</summary><pre>{{ revision.text }}</pre></details>
<div class="inline-actions"><label><input v-model="updateExisting" type="checkbox" />更新上次导出的笔记(已手动修改则拒绝)</label><input v-model="title" class="input" aria-label="笔记标题" /><button class="button-primary" :disabled="busy || dirty || !title.trim()" @click="action(async () => { const note = await mediaService.note(selected!.job_id, title, updateExisting); notice = `已保存笔记:${note.title}` })">保存为笔记</button></div> <div class="inline-actions"><input v-model="title" class="input" aria-label="笔记标题" /><button class="button-primary" :disabled="busy || dirty || !title.trim()" @click="action(async () => { const note = await mediaService.note(selected!.job_id, title); notice = `已保存笔记:${note.title}` })">保存为笔记</button></div>
</template> </template>
</article> </article>
<div v-else class="panel subtle">选择任务查看转写结果</div> <div v-else class="panel subtle">选择任务查看转写结果</div>
@@ -1,26 +0,0 @@
// @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()}
})
@@ -2,21 +2,11 @@
import { onMounted, onUnmounted, ref } from 'vue' import { onMounted, onUnmounted, ref } from 'vue'
import { apiClient } from '@/services/apiClient' import { apiClient } from '@/services/apiClient'
interface Config {device: 'cpu'|'cuda'; cpu_threads: number; memory_limit_mb: number; gpu_memory_limit_mb: number; timeout_seconds: number; embedding_model: string; version: number} interface Config {device: 'cpu'|'cuda'; cpu_threads: number; memory_limit_mb: number; gpu_memory_limit_mb: number; timeout_seconds: number; embedding_model: string; version: number}
interface Model {key: string; name: string; capability: string; revision: string; license: string; status: string; disk_bytes: number|null; downloaded_bytes: number; total_bytes: number|null; error_code?: string} interface Model {key: string; name: string; capability: string; revision: string; license: string; status: string; downloaded_bytes: number; total_bytes: number|null; error_code?: string}
const items = ref<Model[]>([]) const items = ref<Model[]>([])
const config = ref<Config | null>(null) const config = ref<Config | null>(null)
const installed = ref(false) const installed = ref(false)
interface CudaComponent {status:string;stage:string;cuda_available:boolean|null;supported:boolean;custom_interpreter:boolean;error?:string;torch?:string} const lastInference = ref<{actual_device:string;requested_device:string;inference_seconds:number}|null>(null)
const cuda = ref<CudaComponent|null>(null)
const cudaError = ref('')
async function loadCuda() {
try { cuda.value = await apiClient.get<CudaComponent>('/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<CudaComponent>('/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 error = ref('')
const dirty = ref(false) const dirty = ref(false)
const busy = ref(false) const busy = ref(false)
@@ -25,7 +15,6 @@ let stopped = false
const size = (bytes: number | null) => bytes === null ? '未知' : `${(bytes / 1024 / 1024).toFixed(1)} MiB` const size = (bytes: number | null) => bytes === null ? '未知' : `${(bytes / 1024 / 1024).toFixed(1)} MiB`
const labels: Record<string,string> = {not_installed:'未下载',downloading:'下载中',installed:'已下载并校验',failed:'下载失败',interrupted:'已中断,可续传'} const labels: Record<string,string> = {not_installed:'未下载',downloading:'下载中',installed:'已下载并校验',failed:'下载失败',interrupted:'已中断,可续传'}
async function load() { async function load() {
await loadCuda()
try { try {
const data = await apiClient.get<{items:Model[];config:Config;runtime_installed:boolean;last_inference:typeof lastInference.value}>('/api/local-models') 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 items.value = data.items; installed.value = data.runtime_installed
@@ -54,22 +43,8 @@ onUnmounted(() => { stopped = true; clearTimeout(timer) })
<section class="local-models"> <section class="local-models">
<h3>本地模型</h3><p class="subtle">默认 CPU下载需要联网推理只读取本地权重文件校验通过不代表当前设备已完成推理验证</p> <h3>本地模型</h3><p class="subtle">默认 CPU下载需要联网推理只读取本地权重文件校验通过不代表当前设备已完成推理验证</p>
<p v-if="error" class="error-banner" role="alert">{{ error }}</p> <p v-if="error" class="error-banner" role="alert">{{ error }}</p>
<p v-if="lastInference" class="subtle">最近实际运行{{ lastInference.actual_device || '未开始推理' }} · 请求设备 {{ lastInference.requested_device }} · 推理 {{ (lastInference.inference_seconds ?? lastInference.elapsed_seconds ?? 0).toFixed(2) }} · {{ lastInference.status }} {{ lastInference.error_code || '' }}</p> <p v-if="lastInference" class="subtle">最近实际运行{{ lastInference.actual_device }} · 请求设备 {{ lastInference.requested_device }} · 推理 {{ lastInference.inference_seconds.toFixed(2) }} </p>
<p v-if="!installed" class="subtle">尚未安装模型运行环境在项目根目录执行 <code>./backend/scripts/install-model-runtime.ps1</code>CUDA 选装追加 <code>-Device cuda</code></p> <p v-if="!installed" class="subtle">尚未安装模型运行环境在项目根目录执行 <code>./backend/scripts/install-model-runtime.ps1</code>CUDA 选装追加 <code>-Device cuda</code></p>
<article class="item-card cuda-components" aria-label="CUDA 运行组件">
<h4>CUDA 运行组件可选</h4>
<p class="subtle">默认使用 CPU需要 NVIDIA GPU 加速时下载此组件 3 GB安装时还需要额外磁盘空间不包含显卡驱动和模型权重</p>
<p v-if="cudaError" class="error-text" role="alert">{{ cudaError }} <button class="button-secondary" @click="loadCuda">重新检查</button></p>
<template v-if="cuda">
<p role="status">{{ cuda.stage }} {{ cuda.torch || '' }}</p>
<progress v-if="['checking','installing'].includes(cuda.status)" aria-label="CUDA 组件安装进度" />
<p v-if="cuda.error" class="error-text">{{ cuda.error }}</p>
<p v-if="!cuda.supported" class="subtle">当前平台暂不支持页面安装请使用对应平台的模型运行环境</p>
<button v-else-if="cuda.status !== 'installed'" class="button-primary" :disabled="busy || ['checking','installing'].includes(cuda.status)" @click="installCuda">{{ cuda.status === 'installing' ? '正在下载并安装' : ['failed','interrupted'].includes(cuda.status) ? '重试安装 CUDA 组件' : '下载并安装 CUDA 组件' }}</button>
<p v-if="cuda.status === 'installed'" class="subtle">{{ cuda.cuda_available ? '组件已就绪在下方选择 CUDA 并保存即可启用' : '组件已安装但当前未检测到可用 CUDA 设备将回退 CPU' }}</p>
<p v-if="cuda.custom_interpreter" class="subtle">当前后端设置了 APP_MODEL_PYTHON优先使用指定环境要使用页面安装的组件请移除该覆盖并重启后端</p>
</template>
</article>
<form v-if="config" @submit.prevent="save" @input="dirty = true" @change="dirty = true"> <form v-if="config" @submit.prevent="save" @input="dirty = true" @change="dirty = true">
<div class="runtime-grid"><label>请求设备<select v-model="config.device" class="select"><option value="cpu">CPU(默认)</option><option value="cuda">CUDA不可用则 CPU</option></select></label> <div class="runtime-grid"><label>请求设备<select v-model="config.device" class="select"><option value="cpu">CPU(默认)</option><option value="cuda">CUDA不可用则 CPU</option></select></label>
<label>Embedding<select v-model="config.embedding_model" class="select"><option value="bekko">Bekko A8M</option><option value="granite">Granite 97M 多语言</option></select></label> <label>Embedding<select v-model="config.embedding_model" class="select"><option value="bekko">Bekko A8M</option><option value="granite">Granite 97M 多语言</option></select></label>
@@ -79,12 +54,12 @@ onUnmounted(() => { stopped = true; clearTimeout(timer) })
<p class="subtle">修改 Embedding 后需要重建索引任务按预算串行运行模型在任务结束后释放</p><button class="button-primary" :disabled="busy || !dirty">保存运行设置</button> <p class="subtle">修改 Embedding 后需要重建索引任务按预算串行运行模型在任务结束后释放</p><button class="button-primary" :disabled="busy || !dirty">保存运行设置</button>
</form> </form>
<div class="model-grid"><article v-for="model in items" :key="model.key" class="item-card"><h4>{{ model.name }}</h4><p>{{ model.license }} · {{ labels[model.status] || model.status }}</p><small :title="model.revision">版本 {{ model.revision.slice(0,12) }}</small> <div class="model-grid"><article v-for="model in items" :key="model.key" class="item-card"><h4>{{ model.name }}</h4><p>{{ model.license }} · {{ labels[model.status] || model.status }}</p><small :title="model.revision">版本 {{ model.revision.slice(0,12) }}</small>
<p>实际磁盘占用 {{ size(model.disk_bytes) }}</p><p>{{ size(model.downloaded_bytes) }} / {{ size(model.total_bytes) }}</p><progress v-if="model.status === 'downloading' && model.total_bytes" :value="model.downloaded_bytes" :max="model.total_bytes" /> <p>{{ size(model.downloaded_bytes) }} / {{ size(model.total_bytes) }}</p><progress v-if="model.status === 'downloading' && model.total_bytes" :value="model.downloaded_bytes" :max="model.total_bytes" />
<p v-if="model.error_code" class="error-text">{{ model.error_code }}</p><div class="inline-actions"> <p v-if="model.error_code" class="error-text">{{ model.error_code }}</p><div class="inline-actions">
<button v-if="model.status !== 'installed' && model.status !== 'downloading'" class="button-secondary" :disabled="busy" @click="act(() => apiClient.post(`/api/local-models/${model.key}/download`))">{{ model.status === 'not_installed' ? '下载模型' : '重试 / 续传' }}</button> <button v-if="model.status !== 'installed' && model.status !== 'downloading'" class="button-secondary" :disabled="busy" @click="act(() => apiClient.post(`/api/local-models/${model.key}/download`))">{{ model.status === 'not_installed' ? '下载模型' : '重试 / 续传' }}</button>
<button v-if="model.status === 'downloading'" class="button-secondary" :disabled="busy" @click="act(() => apiClient.post(`/api/local-models/${model.key}/cancel`))">暂停</button> <button v-if="model.status === 'downloading'" class="button-secondary" :disabled="busy" @click="act(() => apiClient.post(`/api/local-models/${model.key}/cancel`))">暂停</button>
<button v-if="model.status !== 'not_installed'" class="button-danger" :disabled="busy" @click="act(() => apiClient.delete(`/api/local-models/${model.key}`))">删除权重</button></div> <button v-if="model.status !== 'not_installed'" class="button-danger" :disabled="busy" @click="act(() => apiClient.delete(`/api/local-models/${model.key}`))">删除权重</button></div>
</article></div><button class="button-secondary" @click="diagnostics">导出最近运行诊断</button><p class="subtle">诊断仅包含模型设备耗时和资源信息不包含正文音频和密钥</p> </article></div><button class="button-secondary" @click="diagnostics">导出本次运行诊断</button><p class="subtle">诊断仅包含模型设备耗时和资源信息不包含正文音频和密钥</p>
</section> </section>
</template> </template>
<style scoped>.local-models{display:grid;gap:16px}.runtime-grid,.model-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px}label{display:grid;gap:6px}.item-card{padding:16px}progress{width:100%}</style> <style scoped>.local-models{display:grid;gap:16px}.runtime-grid,.model-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px}label{display:grid;gap:6px}.item-card{padding:16px}progress{width:100%}</style>
@@ -5,11 +5,8 @@ import type { ProviderConfig, ProviderPreset } from '@/contracts'
import * as service from '@/services/providerService' import * as service from '@/services/providerService'
import ProviderForm from './ProviderForm.vue' import ProviderForm from './ProviderForm.vue'
import ProviderPresetSelector from './ProviderPresetSelector.vue' import ProviderPresetSelector from './ProviderPresetSelector.vue'
import RequestJsonEditor from './RequestJsonEditor.vue'
import { apiClient } from '@/services/apiClient'
vi.mock('@/services/providerService', () => ({ listProviderPresets: vi.fn(), getCredentialStatus: vi.fn(), putCredential: vi.fn(), createProvider: vi.fn(), updateProvider: vi.fn() })) vi.mock('@/services/providerService', () => ({ listProviderPresets: vi.fn(), getCredentialStatus: vi.fn(), putCredential: vi.fn(), createProvider: vi.fn(), updateProvider: vi.fn() }))
vi.mock('@/services/apiClient', () => ({ apiClient: { post: vi.fn() } }))
const presets: ProviderPreset[] = [ const presets: ProviderPreset[] = [
{ preset_id: 'deepseek', name: 'DeepSeek', provider_type: 'openai_compatible', base_url: 'https://deepseek.example.test', default_credential_id: 'shared-deepseek', requires_credential: true, logo_id: 'deepseek' }, { preset_id: 'deepseek', name: 'DeepSeek', provider_type: 'openai_compatible', base_url: 'https://deepseek.example.test', default_credential_id: 'shared-deepseek', requires_credential: true, logo_id: 'deepseek' },
{ preset_id: 'qwen', name: '通义千问', provider_type: 'openai_compatible', base_url: 'https://qwen.example.test', default_credential_id: 'shared-qwen', requires_credential: true, logo_id: 'qwen' }, { preset_id: 'qwen', name: '通义千问', provider_type: 'openai_compatible', base_url: 'https://qwen.example.test', default_credential_id: 'shared-qwen', requires_credential: true, logo_id: 'qwen' },
@@ -33,21 +30,6 @@ beforeEach(() => {
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()) }) afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()) })
describe('ProviderForm', () => { describe('ProviderForm', () => {
it('invalidates a pending inference result when JSON becomes invalid', async () => {
const wrapper = await render(existing)
let finish!: (value: {message: string}) => void
vi.mocked(apiClient.post).mockReturnValue(new Promise(resolve => { finish = resolve }))
const probe = wrapper.findAll('button').find(button => button.text() === '发送测试推理请求')!
await probe.trigger('click')
expect(apiClient.post).toHaveBeenCalledWith('/api/providers/request-probe', expect.objectContaining({stream:true}))
wrapper.getComponent(RequestJsonEditor).vm.$emit('valid', false)
await flushPromises()
finish({message:'旧配置验证通过'})
await flushPromises()
expect(wrapper.text()).not.toContain('旧配置验证通过')
expect(probe.attributes('disabled')).toBeDefined()
})
it('filters compact preset chips and resolves bundled logos', async () => { it('filters compact preset chips and resolves bundled logos', async () => {
const wrapper = await render() const wrapper = await render()
await wrapper.get('#provider-search').setValue('通义') await wrapper.get('#provider-search').setValue('通义')
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, watch, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import type { ModelInfo, ProviderConfig, ProviderPreset, ProviderType, RequestOverride } from '@/contracts' import type { ModelInfo, ProviderConfig, ProviderPreset, ProviderType, RequestOverride } from '@/contracts'
import * as service from '@/services/providerService' import * as service from '@/services/providerService'
import ProviderPresetSelector from './ProviderPresetSelector.vue' import ProviderPresetSelector from './ProviderPresetSelector.vue'
@@ -27,39 +27,16 @@ const error = ref('')
const requestOverrides = ref<RequestOverride[]>(JSON.parse(JSON.stringify(props.provider?.request_overrides || []))) const requestOverrides = ref<RequestOverride[]>(JSON.parse(JSON.stringify(props.provider?.request_overrides || [])))
const requestJsonValid = ref(true) const requestJsonValid = ref(true)
const requestPreview = ref('') const requestPreview = ref('')
const probeResult = ref('')
const probing = ref(false)
const previewCapability = ref('chat')
const previewStream = ref(true)
let draftGeneration = 0
watch([form, requestOverrides, requestJsonValid, apiKey, previewStream, previewCapability], () => { draftGeneration++; requestPreview.value = ''; probeResult.value = '' }, {deep:true, flush:'sync'})
async function previewRequest() { async function previewRequest() {
const generation = draftGeneration
error.value = '' error.value = ''
try { try {
if (!requestJsonValid.value) throw new Error('请先修正 JSON。') if (!requestJsonValid.value) throw new Error('请先修正 JSON。')
const response = await apiClient.post<{body:Record<string,unknown>}>('/api/providers/request-preview', { const response = await apiClient.post<{body:Record<string,unknown>}>('/api/providers/request-preview', {
provider: {provider_type:form.provider_type,name:form.name || '预览',base_url:form.base_url || null, provider: {provider_type:form.provider_type,name:form.name || '预览',base_url:form.base_url || null,
default_model:form.default_model || null,request_overrides:requestOverrides.value}, stream:previewStream.value, capability:previewCapability.value, default_model:form.default_model || null,request_overrides:requestOverrides.value}, stream:true,
}) })
if (active && generation === draftGeneration) requestPreview.value = JSON.stringify(response.body, null, 2) requestPreview.value = JSON.stringify(response.body, null, 2)
} catch(e) { if (active && generation === draftGeneration) error.value = (e as Error).message } } catch(e) { error.value = (e as Error).message }
}
async function probeRequest() {
if (probing.value) return
error.value = ''; probeResult.value = ''; probing.value = true
const generation = draftGeneration
try {
if (!requestJsonValid.value) throw new Error('请先修正 JSON。')
if (apiKey.value.trim()) throw new Error('请先保存新的 API Key,再进行推理验证。')
const result = await apiClient.post<{message:string}>('/api/providers/request-probe', {
provider: {provider_type:form.provider_type,name:form.name || '推理验证',base_url:form.base_url || null,
default_model:form.default_model || null,request_overrides:JSON.parse(JSON.stringify(requestOverrides.value)),
credential_id:configured.value ? credentialId.value : null}, stream:previewStream.value,
})
if (active && generation === draftGeneration) probeResult.value = result.message
} catch(e) { if (active && generation === draftGeneration) error.value = (e as Error).message }
finally { probing.value = false }
} }
const contextChanged = ref(false) const contextChanged = ref(false)
const dialog = ref<HTMLElement>() const dialog = ref<HTMLElement>()
@@ -197,10 +174,7 @@ async function save() {
</div> </div>
<label class="inline-actions"><input v-model="form.enabled" type="checkbox" /> 启用</label> <label class="inline-actions"><input v-model="form.enabled" type="checkbox" /> 启用</label>
<RequestJsonEditor v-model="requestOverrides" @valid="requestJsonValid = $event" /> <RequestJsonEditor v-model="requestOverrides" @valid="requestJsonValid = $event" />
<div class="inline-actions"><label>预览能力<select v-model="previewCapability" class="select"><option value="chat">聊天</option><option value="embedding">Embedding</option><option value="transcription">转写</option><option value="speaker_matching">声纹</option></select></label><label><input v-model="previewStream" type="checkbox" />流式聊天</label></div> <button type="button" class="button-secondary" @click="previewRequest">预览最终流式请求隐藏正文</button>
<button type="button" class="button-secondary" @click="previewRequest">预览最终请求隐藏正文</button>
<button v-if="previewCapability === 'chat'" type="button" class="button-secondary" :disabled="probing || credentialLoading || !requestJsonValid" @click="probeRequest">{{ probing ? '推理验证中' : '发送测试推理请求' }}</button>
<p class="subtle">推理验证会向当前模型发送固定短消息并计入实际用量媒体参数请通过真实转写或声纹操作验证</p><p v-if="probeResult" role="status">{{ probeResult }}</p>
<pre v-if="requestPreview" class="request-preview">{{ requestPreview }}</pre> <pre v-if="requestPreview" class="request-preview">{{ requestPreview }}</pre>
</fieldset> </fieldset>
<div v-if="error" class="error-banner" role="alert">{{ error }}</div> <div v-if="error" class="error-banner" role="alert">{{ error }}</div>
@@ -18,16 +18,3 @@ it('validates object JSON and prevents host-owned fields from being saved', asyn
expect(wrapper.emitted('valid')?.at(-1)).toEqual([false]) expect(wrapper.emitted('valid')?.at(-1)).toEqual([false])
wrapper.unmount() 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')
expect(wrapper.emitted('valid')?.at(-1)).toEqual([false])
await wrapper.findAll('button').find(button => button.text() === '恢复默认请求')!.trigger('click')
expect(wrapper.findAll('textarea')).toHaveLength(0)
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([[]])
await wrapper.setProps({modelValue:[{capability:'embedding', body:{dimensions:384}}]})
expect(wrapper.get('textarea').element.value).toContain('384')
expect(wrapper.emitted('valid')?.at(-1)).toEqual([true])
wrapper.unmount()
})
@@ -1,11 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { apiClient } from '@/services/apiClient'
import type { RequestOverride } from '@/contracts' import type { RequestOverride } from '@/contracts'
const props = defineProps<{modelValue: RequestOverride[]}>() const props = defineProps<{modelValue: RequestOverride[]}>()
const emit = defineEmits<{ 'update:modelValue': [value:RequestOverride[]]; valid:[value:boolean] }>() const emit = defineEmits<{ 'update:modelValue': [value:RequestOverride[]]; valid:[value:boolean] }>()
const transferError = ref('')
let published = JSON.stringify(props.modelValue)
const rules = ref(props.modelValue.map(rule => ({...rule, draft: JSON.stringify(rule.body, null, 2), error: ''}))) const rules = ref(props.modelValue.map(rule => ({...rule, draft: JSON.stringify(rule.body, null, 2), error: ''})))
const protectedFields = new Set(['model','messages','input','system','instructions','tools','tool_choice','parallel_tool_calls','functions','function_call','file','audio','reference_file','stream','previous_response_id','conversation','background','store']) const protectedFields = new Set(['model','messages','input','system','instructions','tools','tool_choice','parallel_tool_calls','functions','function_call','file','audio','reference_file','stream','previous_response_id','conversation','background','store'])
function publish() { function publish() {
@@ -22,44 +19,11 @@ function publish() {
} catch(e) { rule.error = (e as Error).message; valid = false } } catch(e) { rule.error = (e as Error).message; valid = false }
} }
emit('valid', valid) emit('valid', valid)
if(valid) { published = JSON.stringify(result); emit('update:modelValue', result) } if(valid) emit('update:modelValue', result)
} }
function add() { rules.value.push({capability:'chat',model:null,stream:null,body:{},draft:'{}',error:''}); publish() } function add() { rules.value.push({capability:'chat',model:null,stream:null,body:{},draft:'{}',error:''}); publish() }
function format(index:number) { try { rules.value[index].draft = JSON.stringify(JSON.parse(rules.value[index].draft), null, 2); publish() } catch { publish() } } function format(index:number) { try { rules.value[index].draft = JSON.stringify(JSON.parse(rules.value[index].draft), null, 2); publish() } catch { publish() } }
watch(() => props.modelValue, value => { watch(() => props.modelValue.length, length => { if (length === 0 && rules.value.length && rules.value.every(r => !r.error)) rules.value = [] })
if (JSON.stringify(value) !== published) {
rules.value = value.map(rule => ({...rule, draft: JSON.stringify(rule.body, null, 2), error: ''}))
published = JSON.stringify(value)
emit('valid', true)
}
}, {deep: true})
function reset() { rules.value = []; transferError.value = ''; publish() }
async function importRules(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
transferError.value = ''
try {
if (file.size > 1024 * 1024) throw new Error('配置文件不得超过 1 MiB')
const parsed = JSON.parse(await file.text())
const validated = await apiClient.post<{request_overrides: RequestOverride[]}>('/api/providers/request-rules/validate', parsed)
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 }
}
async function exportRules() {
transferError.value = ''
try {
publish()
if (rules.value.some(rule => rule.error)) throw new Error('请先修正 JSON')
const validated = await apiClient.post('/api/providers/request-rules/validate', {version:1, request_overrides:JSON.parse(published)})
const url = URL.createObjectURL(new Blob([JSON.stringify(validated, null, 2)], {type:'application/json'}))
const link = document.createElement('a'); link.href = url; link.download = 'model-request-rules.json'; link.click()
setTimeout(() => URL.revokeObjectURL(url), 1000)
} catch(e) { transferError.value = (e as Error).message }
}
</script> </script>
<template> <template>
<details class="request-json"><summary>高级自定义请求 JSON</summary> <details class="request-json"><summary>高级自定义请求 JSON</summary>
@@ -73,9 +37,6 @@ async function exportRules() {
<div class="inline-actions"><button type="button" class="button-secondary" @click="format(index)">格式化</button><button type="button" class="button-danger" @click="rules.splice(index,1); publish()">删除规则</button></div> <div class="inline-actions"><button type="button" class="button-secondary" @click="format(index)">格式化</button><button type="button" class="button-danger" @click="rules.splice(index,1); publish()">删除规则</button></div>
</div> </div>
<button type="button" class="button-secondary" @click="add">添加请求规则</button> <button type="button" class="button-secondary" @click="add">添加请求规则</button>
<div class="inline-actions"><button type="button" class="button-secondary" @click="reset">恢复默认请求</button><button type="button" class="button-secondary" @click="exportRules">导出请求配置</button><label>导入请求配置<input type="file" accept=".json" @change="importRules" /></label></div>
<p v-if="transferError" class="error-text" role="alert">{{ transferError }}</p>
<p class="subtle">导入替换当前请求规则保存提供商后生效导出仅包含请求规则不包含凭据引用和 API Key</p>
</details> </details>
</template> </template>
<style scoped>.request-json{display:grid;gap:12px}.rule{padding:12px;border:1px solid var(--border-color);border-radius:8px;margin:12px 0}.rule-selectors{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px}.rule-selectors label{display:grid;gap:5px}.json-body{font-family:monospace;width:100%}</style> <style scoped>.request-json{display:grid;gap:12px}.rule{padding:12px;border:1px solid var(--border-color);border-radius:8px;margin:12px 0}.rule-selectors{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px}.rule-selectors label{display:grid;gap:5px}.json-body{font-family:monospace;width:100%}</style>
+1 -2
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { apiClient } from '@/services/apiClient' import { apiClient } from '@/services/apiClient'
interface Usage {audio_request_count:number;audio_seconds:number|null;audio_covered_requests:number;totals: Record<string,number|null>;coverage:Record<string,number>;request_count:number;complete_requests:number;cache_hit_rate:number|null;cache_covered_requests:number;options:{provider_id:string;model:string;source:string}[]} interface Usage {totals: Record<string,number|null>;coverage:Record<string,number>;request_count:number;complete_requests:number;cache_hit_rate:number|null;cache_covered_requests:number;options:{provider_id:string;model:string;source:string}[]}
const data = ref<Usage | null>(null) const data = ref<Usage | null>(null)
const period = ref('7') const period = ref('7')
const provider = ref('') const provider = ref('')
@@ -37,7 +37,6 @@ onMounted(load)
<template v-if="data"><p v-if="!data.request_count" class="subtle">该时间段没有已记录的模型请求</p> <template v-if="data"><p v-if="!data.request_count" class="subtle">该时间段没有已记录的模型请求</p>
<div class="usage-grid"><div v-for="(label,key) in metrics" :key="key"><small>{{ label }}</small><strong>{{ data.totals[key] === null ? '未提供' : data.totals[key]?.toLocaleString() }}</strong><small>覆盖 {{ data.coverage[key] }} / {{ data.request_count }} </small></div> <div class="usage-grid"><div v-for="(label,key) in metrics" :key="key"><small>{{ label }}</small><strong>{{ data.totals[key] === null ? '未提供' : data.totals[key]?.toLocaleString() }}</strong><small>覆盖 {{ data.coverage[key] }} / {{ data.request_count }} </small></div>
<div><small>缓存命中率</small><strong>{{ data.cache_hit_rate === null ? '未提供' : `${(data.cache_hit_rate * 100).toFixed(1)}%` }}</strong><small>覆盖 {{ data.cache_covered_requests }} </small></div></div> <div><small>缓存命中率</small><strong>{{ data.cache_hit_rate === null ? '未提供' : `${(data.cache_hit_rate * 100).toFixed(1)}%` }}</strong><small>覆盖 {{ data.cache_covered_requests }} </small></div></div>
<p class="subtle">音频调用 {{ data.audio_request_count ?? 0 }} · 时长 {{ data.audio_seconds == null ? '未提供' : `${data.audio_seconds.toFixed(2)}` }}覆盖 {{ data.audio_covered_requests ?? 0 }} 重试分别计数</p>
<p class="subtle">请求 {{ data.request_count }} 其中完整结束 {{ data.complete_requests }} 输入总量包含厂商已报告的缓存推理 Token 不重复加入输出</p> <p class="subtle">请求 {{ data.request_count }} 其中完整结束 {{ data.complete_requests }} 输入总量包含厂商已报告的缓存推理 Token 不重复加入输出</p>
</template><p class="subtle">统计为本应用观测值不是厂商账户账单缺失指标显示未提供历史未记录的数据不补估</p> </template><p class="subtle">统计为本应用观测值不是厂商账户账单缺失指标显示未提供历史未记录的数据不补估</p>
</section> </section>
@@ -1,42 +0,0 @@
// @vitest-environment happy-dom
import { afterEach, expect, it, vi } from 'vitest'
import { createMediaSubmission, mediaService, type MediaJob } from './mediaService'
afterEach(() => vi.restoreAllMocks())
it('reuses upload and job identities after lost responses, until explicitly reset', async () => {
const upload = vi.spyOn(mediaService, 'upload').mockRejectedValueOnce(new Error('response lost'))
.mockResolvedValue({attachment_id:'uploaded'})
const create = vi.spyOn(mediaService, 'create').mockRejectedValueOnce(new Error('response lost'))
.mockResolvedValue({job_id:'same-job'} as MediaJob)
const submission = createMediaSubmission()
const file = new File(['audio'], 'lecture.wav')
const options = {local_only:true}
await expect(submission.submit(file, options)).rejects.toThrow('response lost')
await expect(submission.submit(file, options)).rejects.toThrow('response lost')
expect(await submission.submit(file, options)).toEqual({job_id:'same-job'})
expect(upload).toHaveBeenCalledTimes(2)
expect(upload.mock.calls[0][1]).toBe(upload.mock.calls[1][1])
expect(create.mock.calls[0][0]).toEqual(create.mock.calls[1][0])
submission.reset()
await submission.submit(file, options)
expect(upload.mock.calls[2][1]).not.toBe(upload.mock.calls[1][1])
expect(create.mock.calls[2][0]).not.toEqual(create.mock.calls[1][0])
})
it('freezes options across upload and treats changed options as a new request', async () => {
let release!: (value:{attachment_id:string}) => void
vi.spyOn(mediaService, 'upload').mockImplementationOnce(() => new Promise(resolve => { release = resolve }))
.mockResolvedValue({attachment_id:'next'})
const create = vi.spyOn(mediaService, 'create').mockResolvedValue({job_id:'job'} as MediaJob)
const submission = createMediaSubmission()
const file = new File(['audio'], 'lecture.wav')
const options = {local_only:true}
const pending = submission.submit(file, options)
options.local_only = false
release({attachment_id:'first'})
await pending
expect(create.mock.calls[0][0]).toMatchObject({local_only:true})
await submission.submit(file, options)
expect(create.mock.calls[1][0]).toMatchObject({local_only:false})
})
+3 -21
View File
@@ -18,33 +18,15 @@ export const mediaService = {
revision: job.revision, text: job.text, segments: job.segments, speaker_names: job.speaker_names, revision: job.revision, text: job.text, segments: job.segments, speaker_names: job.speaker_names,
}), }),
revisions: (id: string) => apiClient.get<{items: MediaJob[]}>(`/api/media/transcriptions/${encodeURIComponent(id)}/revisions`), revisions: (id: string) => apiClient.get<{items: MediaJob[]}>(`/api/media/transcriptions/${encodeURIComponent(id)}/revisions`),
note: (id: string, title: string, update_existing = false) => apiClient.post<{note_id: string; title: string}>(`/api/media/transcriptions/${encodeURIComponent(id)}/notes`, { title, update_existing }), note: (id: string, title: string) => apiClient.post<{note_id: string; title: string}>(`/api/media/transcriptions/${encodeURIComponent(id)}/notes`, { title }),
audio: (id: string) => resolveApiUrl(`/api/media/attachments/${encodeURIComponent(id)}`), audio: (id: string) => resolveApiUrl(`/api/media/attachments/${encodeURIComponent(id)}`),
impact: (id: string) => apiClient.get<{message:string;retained_note_ids:string[]}>(`/api/media/attachments/${encodeURIComponent(id)}/cleanup-impact`), impact: (id: string) => apiClient.get<{message:string;retained_note_ids:string[]}>(`/api/media/attachments/${encodeURIComponent(id)}/cleanup-impact`),
purge: (id: string) => apiClient.delete(`/api/media/attachments/${encodeURIComponent(id)}`), purge: (id: string) => apiClient.delete(`/api/media/attachments/${encodeURIComponent(id)}`),
async upload(file: File, idempotencyKey?: string) { async upload(file: File) {
const response = await fetch(resolveApiUrl(`/api/media/attachments?filename=${encodeURIComponent(file.name)}`), { const response = await fetch(resolveApiUrl(`/api/media/attachments?filename=${encodeURIComponent(file.name)}`), {
method: 'POST', headers: {'Content-Type': 'application/octet-stream', ...(idempotencyKey ? {'Idempotency-Key': idempotencyKey} : {})}, body: file, method: 'POST', headers: {'Content-Type': 'application/octet-stream'}, body: file,
}) })
if (!response.ok) throw new Error((await response.json())?.error?.message || '附件上传失败') if (!response.ok) throw new Error((await response.json())?.error?.message || '附件上传失败')
return await response.json() as {attachment_id: string} return await response.json() as {attachment_id: string}
}, },
} }
// Keep one identity until the input/options change, including a lost HTTP response.
// Payloads remain in memory; durable uploads/jobs are owned by the backend.
export function createMediaSubmission() {
let pending: {file: File; options: string; uploadKey: string; jobKey: string; attachmentId?: string} | null = null
return {
reset() { pending = null },
async submit(file: File, options: Record<string, unknown>) {
const serialized = JSON.stringify(options)
if (!pending || pending.file !== file || pending.options !== serialized) {
pending = {file, options: serialized, uploadKey: crypto.randomUUID(), jobKey: crypto.randomUUID()}
}
const current = pending
if (!current.attachmentId) current.attachmentId = (await mediaService.upload(file, current.uploadKey)).attachment_id
return mediaService.create({...JSON.parse(current.options), attachment_id: current.attachmentId, idempotency_key: current.jobKey})
},
}
}