diff --git a/.gitignore b/.gitignore index 5e1932b..f83769f 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/backend/app/contracts.py b/backend/app/contracts.py index 7d76dd8..10083ee 100644 --- a/backend/app/contracts.py +++ b/backend/app/contracts.py @@ -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 diff --git a/backend/app/local_model_routes.py b/backend/app/local_model_routes.py index fd79d8c..90f090e 100644 --- a/backend/app/local_model_routes.py +++ b/backend/app/local_model_routes.py @@ -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"} diff --git a/backend/app/local_models/components.py b/backend/app/local_models/components.py deleted file mode 100644 index c908620..0000000 --- a/backend/app/local_models/components.py +++ /dev/null @@ -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' diff --git a/backend/app/local_models/manager.py b/backend/app/local_models/manager.py index a106a87..215d5f2 100644 --- a/backend/app/local_models/manager.py +++ b/backend/app/local_models/manager.py @@ -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): diff --git a/backend/app/local_models/runtime.py b/backend/app/local_models/runtime.py index e362df0..f31e6ed 100644 --- a/backend/app/local_models/runtime.py +++ b/backend/app/local_models/runtime.py @@ -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) diff --git a/backend/app/local_models/worker.py b/backend/app/local_models/worker.py index e3b5d50..98a652f 100644 --- a/backend/app/local_models/worker.py +++ b/backend/app/local_models/worker.py @@ -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")) diff --git a/backend/app/main.py b/backend/app/main.py index d307982..f7b97c6 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/media_routes.py b/backend/app/media_routes.py index aff5917..57582e1 100644 --- a/backend/app/media_routes.py +++ b/backend/app/media_routes.py @@ -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} diff --git a/backend/app/provider_preview_routes.py b/backend/app/provider_preview_routes.py index f6f33f5..5b24afa 100644 --- a/backend/app/provider_preview_routes.py +++ b/backend/app/provider_preview_routes.py @@ -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 diff --git a/backend/app/providers/routing.py b/backend/app/providers/routing.py index d4630fe..dcd009e 100644 --- a/backend/app/providers/routing.py +++ b/backend/app/providers/routing.py @@ -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: diff --git a/backend/app/services/media_notes.py b/backend/app/services/media_notes.py index cd3a04d..e2ec13e 100644 --- a/backend/app/services/media_notes.py +++ b/backend/app/services/media_notes.py @@ -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 diff --git a/backend/app/services/model_diagnostics.py b/backend/app/services/model_diagnostics.py deleted file mode 100644 index 3accbaf..0000000 --- a/backend/app/services/model_diagnostics.py +++ /dev/null @@ -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")] diff --git a/backend/app/services/note_service.py b/backend/app/services/note_service.py index efd9931..a4f4eeb 100644 --- a/backend/app/services/note_service.py +++ b/backend/app/services/note_service.py @@ -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 diff --git a/backend/app/services/transcription_service.py b/backend/app/services/transcription_service.py index aef3d7c..6a816f2 100644 --- a/backend/app/services/transcription_service.py +++ b/backend/app/services/transcription_service.py @@ -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") diff --git a/backend/app/services/usage_service.py b/backend/app/services/usage_service.py index fe13413..097e209 100644 --- a/backend/app/services/usage_service.py +++ b/backend/app/services/usage_service.py @@ -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, diff --git a/backend/scripts/install-model-runtime.ps1 b/backend/scripts/install-model-runtime.ps1 index bfab0ac..403e4d5 100644 --- a/backend/scripts/install-model-runtime.ps1 +++ b/backend/scripts/install-model-runtime.ps1 @@ -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 '模型运行环境检查失败' } diff --git a/backend/tests/test_multimodal_finalization.py b/backend/tests/test_multimodal_finalization.py deleted file mode 100644 index a59a485..0000000 --- a/backend/tests/test_multimodal_finalization.py +++ /dev/null @@ -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 diff --git a/backend/tests/test_runtime_components.py b/backend/tests/test_runtime_components.py deleted file mode 100644 index 6f367e6..0000000 --- a/backend/tests/test_runtime_components.py +++ /dev/null @@ -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' diff --git a/docs/contracts/第二阶段接口契约-开发版.md b/docs/contracts/第二阶段接口契约-开发版.md index 5f93ced..38375e6 100644 --- a/docs/contracts/第二阶段接口契约-开发版.md +++ b/docs/contracts/第二阶段接口契约-开发版.md @@ -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),仍只返回隐藏正文的请求体。实际扩展字段是否被供应商接受,以推理响应为准。 diff --git a/docs/development/多模态管线与模型运行开发说明.md b/docs/development/多模态管线与模型运行开发说明.md index d6175e5..2af4461 100644 --- a/docs/development/多模态管线与模型运行开发说明.md +++ b/docs/development/多模态管线与模型运行开发说明.md @@ -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 成功及失败回退策略保持不变。 diff --git a/docs/development/阶段F收尾验收记录.md b/docs/development/阶段F收尾验收记录.md deleted file mode 100644 index 6ce9290..0000000 --- a/docs/development/阶段F收尾验收记录.md +++ /dev/null @@ -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 功能验收;上述质量及外部服务专项保持待验收状态,不标记为全部通过。分支仍需独立审阅后决定合并。 diff --git a/docs/retrospectives/阶段F-Embedding与知识库问题与解决方案.md b/docs/retrospectives/阶段F-Embedding与知识库问题与解决方案.md index f749073..9314922 100644 --- a/docs/retrospectives/阶段F-Embedding与知识库问题与解决方案.md +++ b/docs/retrospectives/阶段F-Embedding与知识库问题与解决方案.md @@ -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,原先能够保存的笔记被拒绝;库中已有此类文件时全量重建也会失败。 diff --git a/frontend/src/features/media/MediaView.vue b/frontend/src/features/media/MediaView.vue index 11325d7..23f66bb 100644 --- a/frontend/src/features/media/MediaView.vue +++ b/frontend/src/features/media/MediaView.vue @@ -1,11 +1,9 @@ 高级:自定义请求 JSON @@ -73,9 +37,6 @@ async function exportRules() { 格式化删除规则 添加请求规则 - 恢复默认请求导出请求配置导入请求配置 - {{ transferError }} - 导入替换当前请求规则,保存提供商后生效。导出仅包含请求规则,不包含凭据引用和 API Key。 diff --git a/frontend/src/features/settings/UsageCard.vue b/frontend/src/features/settings/UsageCard.vue index 06ef4fe..87a8e9f 100644 --- a/frontend/src/features/settings/UsageCard.vue +++ b/frontend/src/features/settings/UsageCard.vue @@ -1,7 +1,7 @@
{{ transferError }}
导入替换当前请求规则,保存提供商后生效。导出仅包含请求规则,不包含凭据引用和 API Key。