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:
@@ -7,7 +7,6 @@ frontend/*.tsbuildinfo
|
||||
# Backend
|
||||
backend/.venv/
|
||||
backend/.venv-models/
|
||||
backend/.venv-models-cuda/
|
||||
backend/data/models/
|
||||
backend/data/attachments/
|
||||
backend/.uv-cache/
|
||||
|
||||
@@ -1050,7 +1050,6 @@ class TranscriptEditRequest(Contract):
|
||||
|
||||
|
||||
class TranscriptNoteRequest(Contract):
|
||||
update_existing: bool = False
|
||||
title: str = Field(min_length=1, max_length=200)
|
||||
folder: str | None = None
|
||||
include_timestamps: bool = True
|
||||
|
||||
@@ -1,30 +1,15 @@
|
||||
import asyncio
|
||||
from fastapi import APIRouter
|
||||
from app.services import model_diagnostics
|
||||
from app.local_models import manager
|
||||
from app.local_models.runtime import RuntimeConfig, configuration, configure, interpreter, runtime
|
||||
|
||||
router = APIRouter(prefix="/api/local-models", tags=["Local models"])
|
||||
|
||||
|
||||
@router.get("/runtime-components/cuda")
|
||||
async def cuda_status():
|
||||
from app.local_models import components
|
||||
return await components.status()
|
||||
|
||||
|
||||
@router.post("/runtime-components/cuda", status_code=202)
|
||||
async def install_cuda():
|
||||
from app.local_models import components
|
||||
return await components.install()
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_models():
|
||||
items, diagnostics = await asyncio.gather(asyncio.to_thread(manager.describe), asyncio.to_thread(model_diagnostics.recent))
|
||||
return {**items, "runtime_installed": interpreter().is_file(), "config": configuration(),
|
||||
return {**manager.describe(), "runtime_installed": interpreter().is_file(), "config": configuration(),
|
||||
"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")
|
||||
@@ -49,5 +34,5 @@ async def delete(key: str):
|
||||
|
||||
@router.get("/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"}
|
||||
|
||||
@@ -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'
|
||||
@@ -49,20 +49,8 @@ def task_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():
|
||||
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):
|
||||
|
||||
@@ -4,10 +4,8 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from contextlib import closing
|
||||
from contextvars import ContextVar
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
@@ -33,18 +31,6 @@ class RuntimeConfig(BaseModel):
|
||||
|
||||
runtime_context = ContextVar("runtime_config", 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():
|
||||
@@ -70,9 +56,6 @@ def configure(request):
|
||||
|
||||
|
||||
def interpreter():
|
||||
from app.local_models import components
|
||||
if not os.getenv("APP_MODEL_PYTHON") and configuration().device == "cuda" and components.ready():
|
||||
return components.ROOT / "Scripts/python.exe"
|
||||
return Path(os.getenv("APP_MODEL_PYTHON", str(BACKEND_DIR / ".venv-models" / ("Scripts/python.exe" if os.name == "nt" else "bin/python"))))
|
||||
|
||||
|
||||
@@ -92,81 +75,28 @@ class Runtime:
|
||||
return any(target in paths for paths in self.active_files.values())
|
||||
|
||||
async def infer(self, key, operation, payload, *, priority=10):
|
||||
from app.services import model_diagnostics
|
||||
config = configuration().model_copy(deep=True)
|
||||
if read_state(key)["status"] != "installed":
|
||||
raise ProviderError("LOCAL_MODEL_NOT_INSTALLED", "请先在模型配置中下载本地模型。")
|
||||
if not interpreter().is_file():
|
||||
raise ProviderError("LOCAL_RUNTIME_NOT_INSTALLED", "请先运行本地模型 CPU/CUDA 安装脚本。")
|
||||
config = configuration()
|
||||
self.counter += 1
|
||||
ticket = (priority, self.counter)
|
||||
self.waiters.append(ticket)
|
||||
queued_at = time.monotonic()
|
||||
reason = 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)
|
||||
process = None
|
||||
attempt = None
|
||||
try:
|
||||
# One resident model at a time prevents overlapping CPU/GPU allocations.
|
||||
while self.active or ticket != min(self.waiters):
|
||||
await asyncio.sleep(0.05)
|
||||
self.waiters.remove(ticket)
|
||||
self.active[ticket] = key
|
||||
self.active_files[ticket] = {str(Path(payload[name]).resolve()) for name in ("source", "reference") if payload.get(name)}
|
||||
queue_seconds = time.monotonic() - queued_at
|
||||
# Keep the reservation while replacing a failed CUDA process with CPU.
|
||||
for device in (["cuda", "cpu"] if config.device == "cuda" else ["cpu"]):
|
||||
started = time.monotonic()
|
||||
diagnostics = dict(model=CATALOG[key].repository, revision=CATALOG[key].revision,
|
||||
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:
|
||||
# Deletion may have occurred while this request was queued.
|
||||
if read_state(key)["status"] != "installed":
|
||||
raise ProviderError("LOCAL_MODEL_NOT_INSTALLED", "模型文件已被删除。")
|
||||
from app.services.usage_service import UsageAttempt
|
||||
attempt = UsageAttempt("local-models", CATALOG[key].repository, "local", operation, source="local")
|
||||
env = {**os.environ, "HF_HUB_OFFLINE": "1", "TRANSFORMERS_OFFLINE": "1",
|
||||
"HF_HUB_DISABLE_TELEMETRY": "1", "OMP_NUM_THREADS": str(config.cpu_threads),
|
||||
"PYTHONIOENCODING": "utf-8"}
|
||||
@@ -188,6 +118,8 @@ class Runtime:
|
||||
process.stdin.close()
|
||||
final = None
|
||||
while line := await process.stdout.readline():
|
||||
if len(line) > 16 * 1024 * 1024:
|
||||
raise ProviderError("LOCAL_MODEL_INVALID_RESPONSE", "本地模型输出超限。")
|
||||
message = json.loads(line)
|
||||
if "progress" in message:
|
||||
callback = runtime_progress.get()
|
||||
@@ -205,19 +137,26 @@ class Runtime:
|
||||
raise ProviderError("LOCAL_MODEL_PROCESS_FAILED", "本地模型进程退出,请检查依赖与资源预算。")
|
||||
if not isinstance(result, dict):
|
||||
raise ProviderError("LOCAL_MODEL_INVALID_RESPONSE", "本地模型进程未返回有效结果。")
|
||||
diagnostics.update(result.get("diagnostics", {}))
|
||||
if "error_code" in result:
|
||||
raise ProviderError(result["error_code"], result.get("message", "本地推理失败。"))
|
||||
attempt.observe(result)
|
||||
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:
|
||||
if ticket in self.waiters:
|
||||
self.waiters.remove(ticket)
|
||||
if process is not None and process.returncode is None:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
if process is not None and hasattr(process, "close"):
|
||||
await process.close()
|
||||
attempt.persist()
|
||||
self.active.pop(ticket, None)
|
||||
self.active_files.pop(ticket, None)
|
||||
if attempt:
|
||||
attempt.persist()
|
||||
|
||||
|
||||
runtime = Runtime()
|
||||
@@ -249,7 +188,7 @@ class LocalEmbedding:
|
||||
config = (self._config or configuration()).model_copy(deep=True)
|
||||
token = runtime_context.set(config)
|
||||
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:
|
||||
runtime_context.reset(token)
|
||||
|
||||
|
||||
@@ -76,25 +76,16 @@ def voice_embedding(model, audio, device):
|
||||
return torch.nn.functional.normalize(vector, dim=0)
|
||||
|
||||
|
||||
class CudaInitializationError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def run(request):
|
||||
import torch
|
||||
import psutil
|
||||
config, payload = request["config"], request["payload"]
|
||||
torch.set_num_threads(config["cpu_threads"])
|
||||
requested = config["device"]
|
||||
try:
|
||||
device = "cuda:0" if requested == "cuda" and torch.cuda.is_available() else "cpu"
|
||||
if device != "cpu":
|
||||
torch.cuda.init()
|
||||
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
|
||||
device = "cuda:0" if requested == "cuda" and torch.cuda.is_available() else "cpu"
|
||||
if device != "cpu":
|
||||
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))
|
||||
process = psutil.Process()
|
||||
peak = [0]
|
||||
stop = threading.Event()
|
||||
@@ -111,7 +102,6 @@ def run(request):
|
||||
path, operation = request["model_path"], request["operation"]
|
||||
try:
|
||||
usage = {}
|
||||
audio_seconds = None
|
||||
if operation == "embedding":
|
||||
from sentence_transformers import SentenceTransformer
|
||||
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)
|
||||
loaded = time.monotonic()
|
||||
audio = decode(payload["source"])
|
||||
audio_seconds = len(audio) / 16000
|
||||
regions = speech_regions(audio)
|
||||
language = {"zh": "Chinese", "en": "English", "ja": "Japanese", "yue": "Cantonese"}.get(payload.get("language"), payload.get("language"))
|
||||
segments = []
|
||||
@@ -165,7 +154,7 @@ def run(request):
|
||||
result = {"speakers": speakers}
|
||||
else:
|
||||
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,
|
||||
"load_seconds": loaded - started, "inference_seconds": time.monotonic() - loaded,
|
||||
"peak_memory_bytes": max(peak[0], process.memory_info().rss), "operation": operation}}
|
||||
@@ -181,16 +170,6 @@ if __name__ == "__main__":
|
||||
response = run(request)
|
||||
except (ImportError, ModuleNotFoundError):
|
||||
response = {"error_code": "LOCAL_RUNTIME_DEPENDENCY_MISSING", "message": "本地模型运行依赖不完整,请重新运行安装脚本。"}
|
||||
except Exception as exc:
|
||||
# Only device failures allow the host to retry once in a fresh CPU process.
|
||||
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")}
|
||||
except Exception:
|
||||
response = {"error_code": "LOCAL_INFERENCE_FAILED", "message": "本地推理失败,请检查媒体格式、模型和设备配置。"}
|
||||
sys.stdout.buffer.write((json.dumps(response, ensure_ascii=False, allow_nan=False) + "\n").encode("utf-8"))
|
||||
|
||||
@@ -26,8 +26,6 @@ async def lifespan(_: FastAPI):
|
||||
yield
|
||||
finally:
|
||||
await transcription_service.shutdown()
|
||||
from app.local_models import components
|
||||
await components.shutdown()
|
||||
from app.local_models import manager
|
||||
for _, key in list(manager._downloads):
|
||||
await manager.cancel_download(key)
|
||||
|
||||
@@ -3,7 +3,6 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import hashlib
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
@@ -23,17 +22,14 @@ MEDIA_SUFFIXES = {".wav", ".mp3", ".flac", ".ogg", ".m4a", ".mp4", ".webm", ".tx
|
||||
|
||||
|
||||
@router.post("/attachments", status_code=201)
|
||||
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_-]+$")):
|
||||
async def upload_attachment(request: Request, filename: str = Query(min_length=1, max_length=255)):
|
||||
suffix = Path(filename).suffix.lower()
|
||||
if suffix not in MEDIA_SUFFIXES:
|
||||
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_{identity}{suffix}"
|
||||
attachment_id = f"media_{uuid4().hex}{suffix}"
|
||||
destination = attachment_path(attachment_id)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = destination.with_suffix(destination.suffix + f".{uuid4().hex}.upload")
|
||||
digest = hashlib.sha256()
|
||||
temporary = destination.with_suffix(destination.suffix + ".upload")
|
||||
size = 0
|
||||
try:
|
||||
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)
|
||||
if size > MAX_UPLOAD_BYTES:
|
||||
raise ApiError(413, "ATTACHMENT_TOO_LARGE", "Attachment exceeds 25 MiB.")
|
||||
digest.update(chunk)
|
||||
stream.write(chunk)
|
||||
if not size:
|
||||
raise ApiError(422, "EMPTY_ATTACHMENT", "Attachment is empty.")
|
||||
if destination.exists():
|
||||
if hashlib.sha256(destination.read_bytes()).digest() != digest.digest():
|
||||
raise ApiError(409, "IDEMPOTENCY_CONFLICT", "同一上传标识不能用于不同附件。")
|
||||
else:
|
||||
temporary.replace(destination)
|
||||
temporary.replace(destination)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
return {"attachment_id": attachment_id, "filename": Path(filename).name, "size": size}
|
||||
|
||||
@@ -1,67 +1,12 @@
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel
|
||||
from app.contracts import ProviderCreateRequest, ProviderConfig, ModelRequest, Message, MessageRole
|
||||
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"])
|
||||
|
||||
|
||||
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):
|
||||
provider: ProviderCreateRequest
|
||||
stream: bool = True
|
||||
|
||||
@@ -6,8 +6,6 @@ available only for explicitly injected tests and protocol fixtures.
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import asyncio
|
||||
import time
|
||||
import json
|
||||
import math
|
||||
from dataclasses import dataclass, field, replace
|
||||
@@ -181,7 +179,6 @@ class ModelRoutingService:
|
||||
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()}
|
||||
attempt = UsageAttempt(binding.provider_id, binding.model, provider.provider_type.value, capability)
|
||||
started = time.monotonic()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30, transport=self.transport) as client:
|
||||
async with client.stream("POST", url, headers=headers, **kwargs) as response:
|
||||
@@ -205,11 +202,6 @@ class ModelRoutingService:
|
||||
raise invalid_response() from exc
|
||||
finally:
|
||||
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"):
|
||||
raise invalid_response()
|
||||
return data, url
|
||||
@@ -263,9 +255,6 @@ class ModelRoutingService:
|
||||
model_id="api-" + hashlib.sha256(identity.encode()).hexdigest())
|
||||
except ProviderError as exc:
|
||||
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
|
||||
local_embedding = self.local_embedding.snapshot() if isinstance(self.local_embedding, LocalEmbedding) else self.local_embedding
|
||||
try:
|
||||
@@ -325,9 +314,6 @@ class ModelRoutingService:
|
||||
return RoutedTranscript(text=text, source="api", segments=segments)
|
||||
except ProviderError as exc:
|
||||
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:
|
||||
text = await self.local_speech.transcribe(source, language)
|
||||
if isinstance(text, RoutedTranscript):
|
||||
@@ -360,9 +346,6 @@ class ModelRoutingService:
|
||||
return SpeakerMatchResult(score=score, source="api")
|
||||
except ProviderError as exc:
|
||||
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:
|
||||
score = await self.local_speech.match(source, reference)
|
||||
if not finite_number(score) or not 0 <= score <= 1:
|
||||
|
||||
@@ -19,10 +19,8 @@ async def create_transcript_note(job_id, options):
|
||||
job = require_job(job_id)
|
||||
if job.status != "completed":
|
||||
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:
|
||||
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=?",
|
||||
(job_id, job.revision, options_hash)).fetchone()
|
||||
if row:
|
||||
@@ -46,34 +44,15 @@ async def create_transcript_note(job_id, options):
|
||||
if job.local_only:
|
||||
# Persist the indexing policy in the Vault, including later rebuilds.
|
||||
lines = ["---", "embedding_local_only: true", "---", "", *lines]
|
||||
markdown = "\n".join(lines)
|
||||
if options.update_existing:
|
||||
if previous is None or previous[1] is None:
|
||||
raise ApiError(409, "NOTE_UPDATE_BASELINE_MISSING", "没有可安全更新的导出记录,请先创建新笔记。")
|
||||
current = await note_service.get_note(previous[0])
|
||||
if current is None:
|
||||
raise ApiError(404, "RESOURCE_NOT_FOUND", "已导出笔记不存在。")
|
||||
# Recover a successful update if linking failed after the Vault write.
|
||||
if current.markdown == markdown:
|
||||
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)
|
||||
try:
|
||||
note = await note_service.create_note(title=title, markdown="\n".join(lines), 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
|
||||
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 REPLACE INTO media_note_baselines VALUES (?,?)", (note.note_id, hashlib.sha256(markdown.encode()).hexdigest()))
|
||||
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
|
||||
|
||||
@@ -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")]
|
||||
@@ -17,7 +17,7 @@ from app.contracts import Note, NoteBlock, NoteSummary
|
||||
from app.database.db import connect, transaction
|
||||
from app.errors import ApiError
|
||||
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.vectorstore import SqliteVecStore, VectorRecord
|
||||
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]
|
||||
|
||||
|
||||
@background_embeddings
|
||||
async def prepare_note_index(parsed: ParsedNote, *, strict=False) -> PreparedIndex:
|
||||
"""Compute vectors before opening a write transaction (including API I/O)."""
|
||||
texts = [block.content for block in parsed.blocks]
|
||||
@@ -181,18 +180,13 @@ async def get_note(note_id: str) -> Note | None:
|
||||
|
||||
@serialized_vault_mutation
|
||||
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:
|
||||
record = repository.get_note_record(note_id)
|
||||
if record is None:
|
||||
raise ApiError(404, "RESOURCE_NOT_FOUND", "note not found", {"note_id": note_id})
|
||||
|
||||
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
|
||||
# PATCH 语义:tags=None 保持原标签;[] 清空;非空列表替换(区别于 create 的 frontmatter 推导)
|
||||
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
|
||||
token = runtime_context.set(RuntimeConfig.model_validate(job.model_snapshot.get("local_runtime", {})))
|
||||
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.segments.append(TranscriptSegment.model_validate(message["segment"]))
|
||||
save(job, "SegmentReady")
|
||||
|
||||
@@ -3,7 +3,6 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from contextlib import closing
|
||||
from contextvars import ContextVar
|
||||
from datetime import datetime, timezone
|
||||
@@ -54,7 +53,6 @@ class UsageAttempt:
|
||||
self.capability, self.source = capability, source
|
||||
self.started_at = datetime.now(timezone.utc).isoformat()
|
||||
self.raw = {}
|
||||
self.audio_seconds = None
|
||||
self.completed = False
|
||||
context = usage_context.get() or {}
|
||||
self.request_id = context.get("request_id") or uuid4().hex
|
||||
@@ -63,9 +61,6 @@ class UsageAttempt:
|
||||
def observe(self, data):
|
||||
if not isinstance(data, dict):
|
||||
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,
|
||||
(data.get("response") or {}).get("usage") if isinstance(data.get("response"), dict) else None]
|
||||
if self.protocol == "ollama":
|
||||
@@ -92,7 +87,7 @@ class UsageAttempt:
|
||||
miss = inputs - hit
|
||||
if hit is not None and inputs is not None and hit > inputs:
|
||||
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"),
|
||||
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"))
|
||||
@@ -108,7 +103,7 @@ class UsageAttempt:
|
||||
|
||||
|
||||
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()]
|
||||
for column, value in (("provider_id", provider_id), ("model", model), ("source", source)):
|
||||
if value:
|
||||
@@ -120,14 +115,8 @@ def aggregate(start, end, provider_id=None, model=None, source=None):
|
||||
totals = {key: None for key in METRICS}
|
||||
coverage = {key: 0 for key in METRICS}
|
||||
hits, eligible_input, cache_requests = 0, 0, 0
|
||||
audio_requests, audio_covered, audio_seconds = 0, 0, None
|
||||
for row in rows:
|
||||
if row[2] in {"transcription", "speaker_matching"}:
|
||||
audio_requests += 1
|
||||
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:
|
||||
if counts.get(key) is not None:
|
||||
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"]
|
||||
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
|
||||
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,
|
||||
"cache_hit_rate": hits / eligible_input if eligible_input else None,
|
||||
"options": [dict(row) for row in options], "start": start, "end": end,
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
param(
|
||||
[ValidateSet('cpu', 'cuda')][string]$Device = 'cpu',
|
||||
[string]$RuntimeDirectory = '',
|
||||
[switch]$QuietProgress
|
||||
[ValidateSet('cpu', 'cuda')][string]$Device = 'cpu'
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$uvOptions = if ($QuietProgress) { @('--quiet') } else { @() }
|
||||
$backendRoot = Split-Path $PSScriptRoot -Parent
|
||||
$runtimeRoot = if ($RuntimeDirectory) { [IO.Path]::GetFullPath($RuntimeDirectory) } else { Join-Path $backendRoot '.venv-models' }
|
||||
$runtimeRoot = Join-Path $backendRoot '.venv-models'
|
||||
$runtimePython = Join-Path $runtimeRoot 'Scripts/python.exe'
|
||||
if (!(Test-Path -LiteralPath $runtimePython)) {
|
||||
& 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.
|
||||
$torchIndex = if ($Device -eq 'cuda') { 'https://download.pytorch.org/whl/cu128' } else { 'https://download.pytorch.org/whl/cpu' }
|
||||
$wheelVariant = if ($Device -eq 'cuda') { 'cu128' } else { 'cpu' }
|
||||
# Pin the local version too: ==2.9.1 alone also accepts an already-installed CPU wheel.
|
||||
Write-Output 'COMPONENT:torch'
|
||||
& uv @uvOptions pip install --python $runtimePython --index-url $torchIndex "torch==2.9.1+$wheelVariant" "torchaudio==2.9.1+$wheelVariant"
|
||||
& uv pip install --python $runtimePython --index-url $torchIndex 'torch==2.9.1' 'torchaudio==2.9.1'
|
||||
if ($LASTEXITCODE -ne 0) { throw 'PyTorch 安装失败' }
|
||||
Write-Output 'COMPONENT:dependencies'
|
||||
& uv @uvOptions pip install --python $runtimePython -r (Join-Path $PSScriptRoot 'model-requirements.lock') -c (Join-Path $PSScriptRoot 'model-requirements.txt')
|
||||
& uv pip install --python $runtimePython -r (Join-Path $PSScriptRoot 'model-requirements.lock') -c (Join-Path $PSScriptRoot 'model-requirements.txt')
|
||||
if ($LASTEXITCODE -ne 0) { throw '模型依赖安装失败' }
|
||||
Write-Output 'COMPONENT:verify'
|
||||
& $runtimePython -c 'import torch; print({"torch":torch.__version__,"cuda_available":torch.cuda.is_available()})'
|
||||
if ($LASTEXITCODE -ne 0) { throw '模型运行环境检查失败' }
|
||||
|
||||
@@ -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
|
||||
@@ -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 集成修复)
|
||||
|
||||
`config_snapshot.local_embedding` 仅表示本地基线;`config_snapshot.embedding` 为 `{ "policy": "per_case", "details": "cases[].embedding" }`。报告与 CaseCompleted 事件的逐样本 `embedding` 包含实际 source(api/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 联调修复补充
|
||||
|
||||
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-13~F-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. 工程经验
|
||||
|
||||
### 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-11 修复后,`---` 和 `---\n\n# Title\n\n正文` 等合法 Markdown 被误判为未闭合 frontmatter,原先能够保存的笔记被拒绝;库中已有此类文件时全量重建也会失败。
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { mediaService, createMediaSubmission, type MediaJob } from '@/services/mediaService'
|
||||
import { mediaService, type MediaJob } from '@/services/mediaService'
|
||||
|
||||
const route = useRoute()
|
||||
const submission = createMediaSubmission()
|
||||
const updateExisting = ref(false)
|
||||
const jobs = ref<MediaJob[]>([])
|
||||
const selected = ref<MediaJob | 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 = []
|
||||
}
|
||||
async function action(work: () => Promise<void>) {
|
||||
if (busy.value) return
|
||||
busy.value = true; error.value = ''; notice.value = ''
|
||||
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)
|
||||
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,
|
||||
diarization: diarization.value, terminology: terms})
|
||||
const uploaded = await mediaService.upload(file.value!)
|
||||
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
|
||||
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 } }
|
||||
@@ -106,7 +104,7 @@ onUnmounted(() => { stopped = true; clearTimeout(timer) })
|
||||
<label><input v-model="diarization" type="checkbox" />识别不同说话人</label>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
@@ -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>
|
||||
<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>
|
||||
<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>
|
||||
</article>
|
||||
<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 { 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 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 config = ref<Config | null>(null)
|
||||
const installed = ref(false)
|
||||
interface CudaComponent {status:string;stage:string;cuda_available:boolean|null;supported:boolean;custom_interpreter:boolean;error?:string;torch?:string}
|
||||
const cuda = ref<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 lastInference = ref<{actual_device:string;requested_device:string;inference_seconds:number}|null>(null)
|
||||
const error = ref('')
|
||||
const dirty = 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 labels: Record<string,string> = {not_installed:'未下载',downloading:'下载中',installed:'已下载并校验',failed:'下载失败',interrupted:'已中断,可续传'}
|
||||
async function load() {
|
||||
await loadCuda()
|
||||
try {
|
||||
const data = await apiClient.get<{items:Model[];config:Config;runtime_installed:boolean;last_inference:typeof lastInference.value}>('/api/local-models')
|
||||
items.value = data.items; installed.value = data.runtime_installed
|
||||
@@ -54,22 +43,8 @@ onUnmounted(() => { stopped = true; clearTimeout(timer) })
|
||||
<section class="local-models">
|
||||
<h3>本地模型</h3><p class="subtle">默认 CPU。下载需要联网;推理只读取本地权重。文件校验通过不代表当前设备已完成推理验证。</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>
|
||||
<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">
|
||||
<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>
|
||||
@@ -79,12 +54,12 @@ onUnmounted(() => { stopped = true; clearTimeout(timer) })
|
||||
<p class="subtle">修改 Embedding 后需要重建索引。任务按预算串行运行,模型在任务结束后释放。</p><button class="button-primary" :disabled="busy || !dirty">保存运行设置</button>
|
||||
</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>
|
||||
<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">
|
||||
<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 !== '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>
|
||||
</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>
|
||||
|
||||
@@ -5,11 +5,8 @@ import type { ProviderConfig, ProviderPreset } from '@/contracts'
|
||||
import * as service from '@/services/providerService'
|
||||
import ProviderForm from './ProviderForm.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/apiClient', () => ({ apiClient: { post: vi.fn() } }))
|
||||
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: '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()) })
|
||||
|
||||
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 () => {
|
||||
const wrapper = await render()
|
||||
await wrapper.get('#provider-search').setValue('通义')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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 * as service from '@/services/providerService'
|
||||
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 requestJsonValid = ref(true)
|
||||
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() {
|
||||
const generation = draftGeneration
|
||||
error.value = ''
|
||||
try {
|
||||
if (!requestJsonValid.value) throw new Error('请先修正 JSON。')
|
||||
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,
|
||||
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)
|
||||
} catch(e) { if (active && generation === draftGeneration) 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 }
|
||||
requestPreview.value = JSON.stringify(response.body, null, 2)
|
||||
} catch(e) { error.value = (e as Error).message }
|
||||
}
|
||||
const contextChanged = ref(false)
|
||||
const dialog = ref<HTMLElement>()
|
||||
@@ -197,10 +174,7 @@ async function save() {
|
||||
</div>
|
||||
<label class="inline-actions"><input v-model="form.enabled" type="checkbox" /> 启用</label>
|
||||
<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 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>
|
||||
<button type="button" class="button-secondary" @click="previewRequest">预览最终流式请求(隐藏正文)</button>
|
||||
<pre v-if="requestPreview" class="request-preview">{{ requestPreview }}</pre>
|
||||
</fieldset>
|
||||
<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])
|
||||
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">
|
||||
import { ref, watch } from 'vue'
|
||||
import { apiClient } from '@/services/apiClient'
|
||||
import type { RequestOverride } from '@/contracts'
|
||||
const props = defineProps<{modelValue: RequestOverride[]}>()
|
||||
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 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() {
|
||||
@@ -22,44 +19,11 @@ function publish() {
|
||||
} catch(e) { rule.error = (e as Error).message; valid = false }
|
||||
}
|
||||
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 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 => {
|
||||
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 }
|
||||
}
|
||||
|
||||
watch(() => props.modelValue.length, length => { if (length === 0 && rules.value.length && rules.value.every(r => !r.error)) rules.value = [] })
|
||||
</script>
|
||||
<template>
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
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 period = ref('7')
|
||||
const provider = ref('')
|
||||
@@ -37,7 +37,6 @@ onMounted(load)
|
||||
<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><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>
|
||||
</template><p class="subtle">统计为本应用观测值,不是厂商账户账单。缺失指标显示“未提供”,历史未记录的数据不补估。</p>
|
||||
</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})
|
||||
})
|
||||
@@ -18,33 +18,15 @@ export const mediaService = {
|
||||
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`),
|
||||
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)}`),
|
||||
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)}`),
|
||||
async upload(file: File, idempotencyKey?: string) {
|
||||
async upload(file: File) {
|
||||
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 || '附件上传失败')
|
||||
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})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user