diff --git a/README.md b/README.md index f6ad01d..3e555c2 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > 本文件用于团队开发期间快速配置环境、启动项目并了解当前实现状态,不是正式的项目 README。 -NotesAgent 是本地优先的 AI 笔记与知识库项目。当前可运行形态为 Vue/Vite Web 前端与 FastAPI AI Core:Markdown 和附件保存在本地 Vault,SQLite 管理元数据、全文索引、向量空间、搜索历史、会话、任务、Agent Trace、多模态任务及运行诊断。 +NotesAgent 是本地优先的 AI 笔记与知识库项目。当前可运行形态为 Vue/Vite Web 前端与 FastAPI AI Core:Markdown 和附件保存在本地 Vault,SQLite 管理元数据、全文索引、向量空间、搜索历史、AI 会话、任务、Agent Trace、多模态任务及运行诊断。AI 对话已接入知识库检索,会话与消息由后端持久化并供 Web 和桌面客户端共用。 截至 2026-09-05,第一阶段及第二阶段 A~F 的工程范围已经合并到 `main`。当前已完成真实 Workspace、混合检索与知识库问答、Agent/Tool/Permission、Skill/Plugin、MCP 配置与调用、模型提供商与路由、RAG Benchmark,以及本地 Embedding、音频转写和片段级声纹聚类。Tauri/Rust Host、Stronghold、原生多 Vault 文件系统、生产级 MCP 沙箱和 Sync Server 尚未接入。 @@ -26,6 +26,7 @@ NotesAgent/ - 多模态:API 优先,未配置或响应无效时回退本地;`local_only` 禁止远程调用。任务、修订、事件、来源和回退原因写入 SQLite。 - 模型运行:默认 CPU,可选 CUDA 12.8 组件;固定模型 revision,按需启动独立子进程,交互检索优先排队,CUDA 初始化或显存失败时用同一冻结配置在 CPU 重试一次。 - 可观测性:输入、输出、缓存命中、推理 Token 与音频用量卡片;本地运行诊断保留最近 200 条,不保存正文、文件路径、密钥或异常全文。 +- 界面偏好:设置页可即时切换全局中文/英文界面,并控制由系统词典提供的编辑器拼写检查;偏好目前保存于 Web 端设备配置,后续由 Tauri 配置存储接管。 ## 本地模型 @@ -110,7 +111,7 @@ pnpm test pnpm build ``` -阶段 F 合并时的回归基线为后端 559 项、前端 103 项测试通过,TypeScript 类型检查与生产构建通过。存在一条既有 Starlette/httpx 弃用提示和 Vite 大 bundle 提示;测试数量以当前分支实际输出和 CI 为准。 +当前回归基线为后端 559 项、前端 106 项测试通过,TypeScript 类型检查与生产构建通过。存在一条既有 Starlette/httpx 弃用提示和 Vite 大 bundle 提示;测试数量以当前分支实际输出和 CI 为准。 ## 文档 diff --git a/backend/README.md b/backend/README.md index 0fd7751..9e33bd0 100644 --- a/backend/README.md +++ b/backend/README.md @@ -27,7 +27,7 @@ uv run uvicorn app.main:app --reload --host 127.0.0.1 --port 8000 | `app/extensions` | Skill、Plugin Host、MCP Registry 与 stdio/HTTP/SSE Bridge | | `app/providers` | OpenAI Chat/Compatible、Responses、Anthropic Messages、Ollama 与能力路由 | | `app/local_models` | 模型目录、固定 revision 下载、独立进程、设备回退和队列调度 | -| `app/services` | 索引、知识库上下文、转写、搜索历史、用量和诊断等应用服务 | +| `app/services` | 索引、知识库上下文、聊天记录、转写、搜索历史、用量和诊断等应用服务 | | `app/benchmarks` | 版本化 RAG Dataset、异步评测、指标与报告 | ## 模型路由 @@ -82,7 +82,7 @@ API Key 可由前端设置页写入,也可通过 `OPENAI_API_KEY`、`DEEPSEEK_ uv run pytest ``` -阶段 F 合并基线为 559 项测试通过,另有一条既有 Starlette/httpx 弃用提示。真实模型冒烟脚本: +当前基线为 562 项测试通过,另有一条既有 Starlette/httpx 弃用提示。真实模型冒烟脚本: ```powershell .venv/Scripts/python scripts/local-model-smoke.py bekko --download diff --git a/backend/app/contracts.py b/backend/app/contracts.py index 7d76dd8..554f7ea 100644 --- a/backend/app/contracts.py +++ b/backend/app/contracts.py @@ -255,11 +255,57 @@ class ModelRequest(Contract): class ChatRequest(ModelRequest): - conversation_id: str | None = None + conversation_id: str | None = Field(default=None, min_length=1, max_length=128) + user_message_id: str | None = Field(default=None, min_length=1, max_length=128) + assistant_message_id: str | None = Field(default=None, min_length=1, max_length=128) + conversation_title: str | None = Field(default=None, max_length=120) use_rag: bool = True retrieval: SearchRequest | None = None +class ConversationCreateRequest(Contract): + conversation_id: str | None = Field(default=None, min_length=1, max_length=128) + title: str = Field(min_length=1, max_length=120) + + @field_validator("title") + @classmethod + def title_must_not_be_blank(cls, value: str) -> str: + value = value.strip() + if not value: + raise ValueError("title must not be blank") + return value + + +class Conversation(Contract): + conversation_id: str + title: str + created_at: datetime + updated_at: datetime + message_count: int = 0 + + +class ConversationListResponse(Contract): + items: list[Conversation] = Field(default_factory=list) + page: PageMeta = Field(default_factory=PageMeta) + + +class ChatMessage(Contract): + message_id: str + conversation_id: str + role: Literal["user", "assistant", "system"] + content: str + created_at: datetime + citations: list[dict[str, Any]] = Field(default_factory=list) + tool_calls: list[dict[str, Any]] = Field(default_factory=list) + thinking: str | None = None + usage: dict[str, Any] | None = None + + +class ChatMessageListResponse(Contract): + items: list[ChatMessage] = Field(default_factory=list) + page: PageMeta = Field(default_factory=PageMeta) + + class ModelEventType(str, Enum): citation = "Citation" text_delta = "TextDelta" diff --git a/backend/app/database/migrations.py b/backend/app/database/migrations.py index e6ff960..2bcb133 100644 --- a/backend/app/database/migrations.py +++ b/backend/app/database/migrations.py @@ -132,6 +132,33 @@ MIGRATIONS: list[str] = [ """ ALTER TABLE blocks ADD COLUMN embedding_local_only INTEGER NOT NULL DEFAULT 0; """, + # v7: application-owned chat conversations and messages, shared by web and desktop clients. + """ + CREATE TABLE IF NOT EXISTS chat_conversations ( + conversation_id TEXT PRIMARY KEY, + title TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_chat_conversations_updated + ON chat_conversations(updated_at DESC); + + CREATE TABLE IF NOT EXISTS chat_messages ( + message_id TEXT PRIMARY KEY, + conversation_id TEXT NOT NULL REFERENCES chat_conversations(conversation_id) ON DELETE CASCADE, + sequence INTEGER NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL DEFAULT '', + thinking TEXT, + citations_json TEXT NOT NULL DEFAULT '[]', + tool_calls_json TEXT NOT NULL DEFAULT '[]', + usage_json TEXT, + created_at TEXT NOT NULL, + UNIQUE(conversation_id, sequence) + ); + CREATE INDEX IF NOT EXISTS idx_chat_messages_conversation + ON chat_messages(conversation_id, sequence); + """, ] diff --git a/backend/app/routes.py b/backend/app/routes.py index 642e97a..d4fc4db 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -1,4 +1,5 @@ import asyncio +import json from collections.abc import AsyncIterator from contextlib import aclosing from datetime import datetime, timezone @@ -15,6 +16,10 @@ from app.contracts import ( AgentRunListResponse, AgentTraceResponse, ChatRequest, + ChatMessageListResponse, + Conversation, + ConversationCreateRequest, + ConversationListResponse, BenchmarkDatasetListResponse, BenchmarkEventType, BenchmarkKind, @@ -321,6 +326,40 @@ async def clear_search_history() -> dict[str, list[str]]: return {"queries": []} +@router.get("/chat/conversations", response_model=ConversationListResponse, tags=["Chat"]) +async def list_chat_conversations( + limit: int = Query(default=50, ge=1, le=100), offset: int = Query(default=0, ge=0) +) -> ConversationListResponse: + from app.services import chat_history + items, total = chat_history.list_conversations(limit, offset) + return ConversationListResponse(items=items, page=PageMeta(total=total, limit=limit, offset=offset)) + + +@router.post("/chat/conversations", response_model=Conversation, status_code=201, tags=["Chat"]) +async def create_chat_conversation(request: ConversationCreateRequest) -> Conversation: + from app.services import chat_history + return chat_history.create(request.title, request.conversation_id) + + +@router.get("/chat/conversations/{conversation_id}/messages", response_model=ChatMessageListResponse, tags=["Chat"]) +async def list_chat_messages( + conversation_id: str, + limit: int = Query(default=500, ge=1, le=1000), + offset: int = Query(default=0, ge=0), +) -> ChatMessageListResponse: + from app.services import chat_history + items, total = chat_history.list_messages(conversation_id, limit, offset) + return ChatMessageListResponse(items=items, page=PageMeta(total=total, limit=limit, offset=offset)) + + +@router.delete("/chat/conversations/{conversation_id}", response_model=OperationResponse, tags=["Chat"]) +async def delete_chat_conversation(conversation_id: str) -> OperationResponse: + from app.services import chat_history + if not chat_history.delete(conversation_id): + raise ApiError(404, "CONVERSATION_NOT_FOUND", "conversation not found", {"conversation_id": conversation_id}) + return OperationResponse(status="completed", resource_id=conversation_id, message="deleted") + + @router.post( "/chat", response_class=StreamingResponse, @@ -333,14 +372,38 @@ async def clear_search_history() -> dict[str, list[str]]: tags=["Chat"], ) async def chat(request: ChatRequest) -> StreamingResponse: + from app.services import chat_history + + conversation_id = request.conversation_id + assistant_message_id = request.assistant_message_id or f"message_{uuid4().hex}" + if conversation_id: + user_message = next( + (message for message in reversed(request.messages) if message.role.value == "user" and message.content.strip()), + None, + ) + if user_message is not None: + chat_history.append_message( + conversation_id, + message_id=request.user_message_id or f"message_{uuid4().hex}", + role="user", + content=user_message.content, + title=request.conversation_title or user_message.content[:30], + ) provider = provider_or_404(request.provider_id) async def stream() -> AsyncIterator[str]: sequence = 0 + assistant_content = "" + assistant_thinking = "" + citations: list[dict] = [] + tool_calls: list[dict] = [] + argument_buffers: dict[str, str] = {} + usage: dict | None = None try: from app.services.chat_context import prepare - grounded_request, citations = await prepare(request) - for citation in citations: + grounded_request, grounded_citations = await prepare(request) + for citation in grounded_citations: + citations.append(citation) event = ModelEvent(event=ModelEventType.citation, sequence=sequence, data=citation, timestamp=utc_now()) sequence += 1 @@ -349,13 +412,58 @@ async def chat(request: ChatRequest) -> StreamingResponse: async for event in events: event = event.model_copy(update={"sequence": sequence}) sequence += 1 + if event.event == ModelEventType.text_delta: + assistant_content += str(event.data.get("text", "")) + elif event.event == ModelEventType.thinking_delta: + assistant_thinking += str(event.data.get("text", "")) + elif event.event == ModelEventType.tool_call_start: + tool_calls.append({ + "tool_call_id": str(event.data.get("tool_call_id", "")), + "name": str(event.data.get("name", "unknown")), + "parameters": event.data.get("arguments") if isinstance(event.data.get("arguments"), dict) else {}, + "status": "running", + }) + elif event.event == ModelEventType.tool_call_delta: + call_id = str(event.data.get("tool_call_id", "")) + call = next((item for item in tool_calls if item["tool_call_id"] == call_id), None) + if call is not None: + delta = event.data.get("arguments_delta") + if isinstance(delta, str): + argument_buffers[call_id] = argument_buffers.get(call_id, "") + delta + try: + parsed_arguments = json.loads(argument_buffers[call_id]) + if isinstance(parsed_arguments, dict): + call["parameters"] = parsed_arguments + except ValueError: + pass + arguments = event.data.get("arguments") + if isinstance(arguments, dict): + call["parameters"].update(arguments) + elif event.event == ModelEventType.tool_call_end: + call_id = str(event.data.get("tool_call_id", "")) + call = next((item for item in tool_calls if item["tool_call_id"] == call_id), None) + if call is not None: + call["status"] = "completed" + elif event.event == ModelEventType.usage: + input_tokens = int(event.data.get("input_tokens", 0)) + output_tokens = int(event.data.get("output_tokens", 0)) + usage = {"input_tokens": input_tokens, "output_tokens": output_tokens, + "total_tokens": input_tokens + output_tokens} + elif event.event == ModelEventType.error: + if assistant_content: + assistant_content += "\n\n" + assistant_content += str(event.data.get("message", "Model generation failed.")) yield as_sse(event.event.value, event.model_dump_json()) except Exception as exc: + failure_message = exc.message if isinstance(exc, ApiError) else "知识库检索或模型生成失败,请检查服务状态。" + if assistant_content: + assistant_content += "\n\n" + assistant_content += failure_message error = ModelEvent( event=ModelEventType.error, sequence=sequence, data={"code": exc.code if isinstance(exc, ApiError) else "CHAT_FAILED", - "message": exc.message if isinstance(exc, ApiError) else "知识库检索或模型生成失败,请检查服务状态。"}, + "message": failure_message}, timestamp=utc_now(), ) done = ModelEvent( @@ -364,6 +472,18 @@ async def chat(request: ChatRequest) -> StreamingResponse: ) yield as_sse(error.event.value, error.model_dump_json()) yield as_sse(done.event.value, done.model_dump_json()) + finally: + if conversation_id and (assistant_content or assistant_thinking or citations or tool_calls): + chat_history.append_message( + conversation_id, + message_id=assistant_message_id, + role="assistant", + content=assistant_content, + thinking=assistant_thinking or None, + citations=citations, + tool_calls=tool_calls, + usage=usage, + ) return StreamingResponse(stream(), media_type="text/event-stream") diff --git a/backend/app/services/chat_history.py b/backend/app/services/chat_history.py new file mode 100644 index 0000000..76ceedc --- /dev/null +++ b/backend/app/services/chat_history.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +from contextlib import closing +from datetime import datetime, timezone +import json +import sqlite3 +from typing import Any +from uuid import uuid4 + +from app.contracts import ChatMessage, Conversation +from app.database.db import connect, transaction +from app.errors import ApiError + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _conversation(row) -> Conversation: + return Conversation( + conversation_id=row["conversation_id"], + title=row["title"], + created_at=datetime.fromisoformat(row["created_at"]), + updated_at=datetime.fromisoformat(row["updated_at"]), + message_count=row["message_count"], + ) + + +def _message(row) -> ChatMessage: + citations = json.loads(row["citations_json"]) + for citation in citations: + if isinstance(citation.get("heading_path"), list): + citation["heading_path"] = " / ".join(str(part) for part in citation["heading_path"]) + return ChatMessage( + message_id=row["message_id"], + conversation_id=row["conversation_id"], + role=row["role"], + content=row["content"], + thinking=row["thinking"], + citations=citations, + tool_calls=json.loads(row["tool_calls_json"]), + usage=json.loads(row["usage_json"]) if row["usage_json"] else None, + created_at=datetime.fromisoformat(row["created_at"]), + ) + + +def create(title: str, conversation_id: str | None = None) -> Conversation: + conversation_id = conversation_id or f"conversation_{uuid4().hex}" + now = _now().isoformat() + with closing(connect()) as conn, transaction(conn): + try: + conn.execute( + "INSERT INTO chat_conversations(conversation_id,title,created_at,updated_at) VALUES(?,?,?,?)", + (conversation_id, title.strip(), now, now), + ) + except sqlite3.IntegrityError as exc: + raise ApiError(409, "CONVERSATION_ALREADY_EXISTS", "conversation already exists", {"conversation_id": conversation_id}) from exc + result = get(conversation_id) + assert result is not None + return result + + +def get(conversation_id: str) -> Conversation | None: + with closing(connect()) as conn: + row = conn.execute( + """SELECT c.*, COUNT(m.message_id) AS message_count + FROM chat_conversations c LEFT JOIN chat_messages m USING(conversation_id) + WHERE c.conversation_id=? GROUP BY c.conversation_id""", + (conversation_id,), + ).fetchone() + return _conversation(row) if row else None + + +def list_conversations(limit: int, offset: int) -> tuple[list[Conversation], int]: + with closing(connect()) as conn: + total = conn.execute("SELECT COUNT(*) FROM chat_conversations").fetchone()[0] + rows = conn.execute( + """SELECT c.*, COUNT(m.message_id) AS message_count + FROM chat_conversations c LEFT JOIN chat_messages m USING(conversation_id) + GROUP BY c.conversation_id ORDER BY c.updated_at DESC LIMIT ? OFFSET ?""", + (limit, offset), + ).fetchall() + return [_conversation(row) for row in rows], total + + +def list_messages(conversation_id: str, limit: int, offset: int) -> tuple[list[ChatMessage], int]: + if get(conversation_id) is None: + raise ApiError(404, "CONVERSATION_NOT_FOUND", "conversation not found", {"conversation_id": conversation_id}) + with closing(connect()) as conn: + total = conn.execute("SELECT COUNT(*) FROM chat_messages WHERE conversation_id=?", (conversation_id,)).fetchone()[0] + rows = conn.execute( + "SELECT * FROM chat_messages WHERE conversation_id=? ORDER BY sequence LIMIT ? OFFSET ?", + (conversation_id, limit, offset), + ).fetchall() + return [_message(row) for row in rows], total + + +def delete(conversation_id: str) -> bool: + with closing(connect()) as conn, transaction(conn): + return conn.execute("DELETE FROM chat_conversations WHERE conversation_id=?", (conversation_id,)).rowcount > 0 + + +def append_message( + conversation_id: str, + *, + message_id: str, + role: str, + content: str, + title: str | None = None, + thinking: str | None = None, + citations: list[dict[str, Any]] | None = None, + tool_calls: list[dict[str, Any]] | None = None, + usage: dict[str, Any] | None = None, +) -> None: + now = _now().isoformat() + clean_title = (title or "").strip() or content[:30].strip() or "New conversation" + with closing(connect()) as conn: + conn.execute("BEGIN IMMEDIATE") + try: + _append_message_in_transaction( + conn, conversation_id, message_id=message_id, role=role, content=content, + title=clean_title, thinking=thinking, citations=citations, tool_calls=tool_calls, + usage=usage, now=now, + ) + conn.execute("COMMIT") + except BaseException: + if conn.in_transaction: + conn.execute("ROLLBACK") + raise + + +def _append_message_in_transaction( + conn, + conversation_id: str, + *, + message_id: str, + role: str, + content: str, + title: str, + thinking: str | None, + citations: list[dict[str, Any]] | None, + tool_calls: list[dict[str, Any]] | None, + usage: dict[str, Any] | None, + now: str, +) -> None: + conversation = conn.execute( + "SELECT 1 FROM chat_conversations WHERE conversation_id=?", (conversation_id,) + ).fetchone() + if conversation is None: + # A stream may finish after deletion. Check under BEGIN IMMEDIATE so + # deletion and assistant persistence cannot recreate an orphaned chat. + if role == "assistant": + return + conn.execute( + "INSERT INTO chat_conversations(conversation_id,title,created_at,updated_at) VALUES(?,?,?,?)", + (conversation_id, title, now, now), + ) + count = conn.execute( + "SELECT COUNT(*) FROM chat_messages WHERE conversation_id=?", (conversation_id,) + ).fetchone()[0] + if count == 0: + conn.execute( + "UPDATE chat_conversations SET title=? WHERE conversation_id=?", + (title, conversation_id), + ) + existing = conn.execute( + "SELECT conversation_id FROM chat_messages WHERE message_id=?", (message_id,) + ).fetchone() + if existing: + if existing["conversation_id"] != conversation_id: + raise ApiError(409, "MESSAGE_ID_CONFLICT", "message id belongs to another conversation") + return + sequence = conn.execute( + "SELECT COALESCE(MAX(sequence), -1) + 1 FROM chat_messages WHERE conversation_id=?", + (conversation_id,), + ).fetchone()[0] + conn.execute( + """INSERT INTO chat_messages(message_id,conversation_id,sequence,role,content,thinking,citations_json,tool_calls_json,usage_json,created_at) + VALUES(?,?,?,?,?,?,?,?,?,?)""", + (message_id, conversation_id, sequence, role, content, thinking, + json.dumps(citations or [], ensure_ascii=False), json.dumps(tool_calls or [], ensure_ascii=False), + json.dumps(usage, ensure_ascii=False) if usage is not None else None, now), + ) + conn.execute( + "UPDATE chat_conversations SET updated_at=? WHERE conversation_id=?", + (now, conversation_id), + ) diff --git a/backend/tests/test_chat_history.py b/backend/tests/test_chat_history.py new file mode 100644 index 0000000..f4dec3b --- /dev/null +++ b/backend/tests/test_chat_history.py @@ -0,0 +1,121 @@ +import asyncio +from datetime import datetime, timezone +from types import SimpleNamespace + +from fastapi.testclient import TestClient +import pytest + +from app.contracts import ChatRequest, ModelEvent, ModelEventType +from app.main import app +from app.services import chat_history + + +def test_chat_history_survives_new_connections_and_deletes_messages() -> None: + conversation = chat_history.create("Persistent chat", "conversation-1") + chat_history.append_message( + conversation.conversation_id, + message_id="user-1", + role="user", + content="question", + ) + chat_history.append_message( + conversation.conversation_id, + message_id="assistant-1", + role="assistant", + content="answer", + citations=[{"note_id": "note-1", "heading_path": ["Heading"]}], + usage={"input_tokens": 2, "output_tokens": 1, "total_tokens": 3}, + ) + + listed, total = chat_history.list_conversations(50, 0) + messages, message_total = chat_history.list_messages("conversation-1", 50, 0) + assert total == 1 + assert listed[0].message_count == 2 + assert message_total == 2 + assert messages[1].citations[0]["note_id"] == "note-1" + assert messages[1].usage["total_tokens"] == 3 + + assert chat_history.delete("conversation-1") is True + assert chat_history.list_conversations(50, 0)[1] == 0 + + +def test_chat_stream_persists_user_and_assistant_messages(monkeypatch) -> None: + from app import routes + + class Adapter: + async def stream(self, _request): + now = datetime.now(timezone.utc) + yield ModelEvent(event=ModelEventType.text_delta, data={"text": "persisted answer"}, timestamp=now) + yield ModelEvent(event=ModelEventType.usage, data={"input_tokens": 4, "output_tokens": 2}, timestamp=now) + yield ModelEvent(event=ModelEventType.done, timestamp=now) + + monkeypatch.setattr(routes, "provider_or_404", lambda _provider_id: SimpleNamespace(adapter=Adapter())) + payload = { + "provider_id": "configured", + "model": "model", + "conversation_id": "conversation-stream", + "user_message_id": "user-stream", + "assistant_message_id": "assistant-stream", + "conversation_title": "Persist this", + "use_rag": False, + "messages": [{"role": "user", "content": "question"}], + } + with TestClient(app) as client: + with client.stream("POST", "/api/chat", json=payload) as response: + assert response.status_code == 200 + assert "persisted answer" in "".join(response.iter_text()) + messages = client.get("/api/chat/conversations/conversation-stream/messages").json()["items"] + conversations = client.get("/api/chat/conversations").json()["items"] + assert [message["content"] for message in messages] == ["question", "persisted answer"] + assert messages[1]["usage"]["total_tokens"] == 6 + assert conversations[0]["title"] == "Persist this" + assert conversations[0]["message_count"] == 2 + + +def test_chat_conversation_crud_api() -> None: + with TestClient(app) as client: + created = client.post("/api/chat/conversations", json={"conversation_id": "crud", "title": "CRUD"}) + assert created.status_code == 201 + assert client.get("/api/chat/conversations").json()["page"]["total"] == 1 + assert client.get("/api/chat/conversations/crud/messages").json()["items"] == [] + assert client.delete("/api/chat/conversations/crud").status_code == 200 + missing = client.get("/api/chat/conversations/crud/messages") + assert missing.status_code == 404 + assert missing.json()["error"]["code"] == "CONVERSATION_NOT_FOUND" + + +@pytest.mark.parametrize("close_early", [True, False]) +@pytest.mark.parametrize("deleted", [True, False]) +def test_stream_finalization_respects_conversation_deletion(monkeypatch, close_early, deleted) -> None: + from app import routes + + class Adapter: + async def stream(self, _request): + now = datetime.now(timezone.utc) + yield ModelEvent(event=ModelEventType.text_delta, data={"text": "partial answer"}, timestamp=now) + yield ModelEvent(event=ModelEventType.done, timestamp=now) + + monkeypatch.setattr(routes, "provider_or_404", lambda _: SimpleNamespace(adapter=Adapter())) + + async def scenario(): + response = await routes.chat(ChatRequest( + provider_id="configured", model="model", conversation_id="stream", + use_rag=False, messages=[{"role": "user", "content": "question"}], + )) + await anext(response.body_iterator) + if deleted: + assert chat_history.delete("stream") + if close_early: + await response.body_iterator.aclose() + else: + async for _ in response.body_iterator: + pass + if deleted: + assert chat_history.get("stream") is None + assert chat_history.list_conversations(50, 0)[1] == 0 + else: + messages, total = chat_history.list_messages("stream", 50, 0) + assert total == 2 + assert [message.content for message in messages] == ["question", "partial answer"] + + asyncio.run(scenario()) diff --git a/docs/guides/代码注释与TODO约定.md b/docs/guides/代码注释与TODO约定.md index e285ec1..c5125d1 100644 --- a/docs/guides/代码注释与TODO约定.md +++ b/docs/guides/代码注释与TODO约定.md @@ -37,7 +37,7 @@ | Extension | 扩展安装状态尚未持久化;MCP Host、进程隔离、签名与来源校验属于第二阶段 | | AI Core | 音频转写当前只读取文本或 Host 预生成旁路文本,后续接入本地 ASR 队列 | | Desktop | Web Workspace 已连接 FastAPI 单 Vault;后续由 Tauri IPC 增加原生目录选择、多 Vault 和文件监听 | -| Editor / Chat | 待补文件冲突合并、受控链接对话框及会话持久化 | +| Editor / Chat | 待补文件冲突合并及受控链接对话框;会话持久化已接入后端 SQLite | | Performance | Shiki 已复用单例,后续按首屏指标评估延迟加载或 Web Worker | TODO 完成后应删除对应代码注释并同步更新本索引;若工作超过一个提交,应建立 Issue,并在 Issue 中引用代码位置,而不是在源码中记录长篇设计讨论。 diff --git a/frontend/README.md b/frontend/README.md index 55acd8c..794ca01 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -25,7 +25,7 @@ pnpm dev | `/extensions/mcp` | stdio、Streamable HTTP、旧 SSE Server 配置与工具发现 | | `/extensions/plugins` | Plugin Host、Command、Settings、Secret 与 MCP 状态 | | `/themes` | 内置 Design Token 主题和编辑器显示偏好 | -| `/settings` | Provider、模型路由、本地模型、CPU/CUDA 组件、请求 JSON、用量与诊断 | +| `/settings` | Provider、模型路由、本地模型、CPU/CUDA 组件、请求 JSON、用量与诊断;全局中英文和拼写检查设置 | ## 技术结构 @@ -41,9 +41,12 @@ pnpm dev 编辑器使用 Milkdown/Crepe 与 CodeMirror 6;Markdown 展示使用 marked、DOMPurify 和 Shiki。Provider logo 位于 `src/assets/providers`,授权与来源说明随目录保存。 +语言设置会即时更新主导航、页面标题和各功能页面,并同步更新文档与编辑器的 `lang`。拼写检查使用浏览器或桌面 WebView 提供的本地词典,开关会即时作用于可视化 Markdown、源码编辑器以及普通文本输入;JSON、密码等结构化或敏感输入保持关闭。 + ## 数据边界 -- 笔记、附件、搜索历史、会话、任务、Trace、模型配置和多模态结果都通过 FastAPI 读写。 +- 笔记、附件、搜索历史、任务、Trace、模型配置和多模态结果都通过 FastAPI 读写。 +- AI 对话生成和知识库检索通过 FastAPI;会话列表、用户消息、流式助手结果、引用和 Token 用量保存在后端 SQLite,刷新页面后可恢复。 - API Key 只存在于密码输入和提交请求期间,不进入 Pinia 或 `localStorage`。 - 页面内存可以保存尚未提交的临时状态;后端已经接收的任务和结果由 SQLite/Vault 持久化。 - 主题、编辑器偏好、侧栏状态和最近 Vault 路径目前保存在浏览器 `localStorage`;它们是设备界面偏好,不作为笔记或模型业务数据。Tauri 集成时由桌面配置存储接管。 @@ -66,7 +69,7 @@ pnpm type-check pnpm build ``` -阶段 F 合并基线为 29 个测试文件、103 项测试通过,TypeScript 类型检查与 Vite 生产构建通过;构建仍有既有大 bundle 提示。产物位于 `dist`,不提交 Git。 +当前基线为 30 个测试文件、106 项测试通过,TypeScript 类型检查与 Vite 生产构建通过;构建仍有既有大 bundle 提示。产物位于 `dist`,不提交 Git。 ## 开发约定 diff --git a/frontend/src/api.ts b/frontend/src/api.ts index ea07804..12a64a5 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,3 +1,5 @@ +import { t } from '@/i18n' + export interface ServiceStatus { name: string version: string @@ -10,7 +12,7 @@ const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? '' export async function getServiceStatus(): Promise { const response = await fetch(`${apiBaseUrl}/api/status`) if (!response.ok) { - throw new Error(`后端请求失败:HTTP ${response.status}`) + throw new Error(`${t('后端请求失败:', 'Backend request failed: ')}HTTP ${response.status}`) } return response.json() as Promise } diff --git a/frontend/src/components/common/CommandPalette.vue b/frontend/src/components/common/CommandPalette.vue index b5a99f6..f2efead 100644 --- a/frontend/src/components/common/CommandPalette.vue +++ b/frontend/src/components/common/CommandPalette.vue @@ -8,6 +8,7 @@ import * as workspaceService from '@/services/workspaceService' import * as pluginService from '@/services/pluginService' import type { PluginCommand, PluginCommandEffect } from '@/contracts' import { usePluginStore } from '@/stores/plugin' +import { t } from '@/i18n' const router = useRouter() const editorStore = useEditorStore() @@ -25,15 +26,15 @@ const selectionSnapshot = ref(null) interface Command { id: string; label: string; hint: string; run: () => void | Promise } const builtinCommands = computed(() => [ - { id: 'workspace', label: '打开工作区', hint: '导航', run: () => router.push('/workspace') }, - { id: 'search', label: '全局搜索', hint: '导航', run: () => router.push('/search') }, - { id: 'chat', label: '打开 AI 对话', hint: '导航', run: () => router.push('/chat') }, - { id: 'agent', label: '创建智能体运行', hint: '导航', run: () => router.push('/agent/runs') }, - { id: 'settings', label: '打开设置', hint: '导航', run: () => router.push('/settings') }, - { id: 'mode', label: `切换为${editorStore.mode === 'source' ? '写作' : '源码'}模式`, hint: '编辑器', run: () => editorStore.toggleMode() }, - { id: 'save', label: '保存当前笔记', hint: '编辑器', run: () => editorStore.save() }, - { id: 'theme', label: `切换为${themeStore.isDark ? '浅色' : '深色'}主题`, hint: '外观', run: () => themeStore.toggleTheme() }, - { id: 'new-note', label: '创建笔记', hint: '工作区', run: createNote }, + { id: 'workspace', label: t('打开工作区', 'Open workspace'), hint: t('导航', 'Navigation'), run: () => router.push('/workspace') }, + { id: 'search', label: t('全局搜索', 'Global search'), hint: t('导航', 'Navigation'), run: () => router.push('/search') }, + { id: 'chat', label: t('打开 AI 对话', 'Open AI chat'), hint: t('导航', 'Navigation'), run: () => router.push('/chat') }, + { id: 'agent', label: t('创建智能体运行', 'Create agent run'), hint: t('导航', 'Navigation'), run: () => router.push('/agent/runs') }, + { id: 'settings', label: t('打开设置', 'Open settings'), hint: t('导航', 'Navigation'), run: () => router.push('/settings') }, + { id: 'mode', label: editorStore.mode === 'source' ? t('切换为写作模式', 'Switch to writing mode') : t('切换为源码模式', 'Switch to source mode'), hint: t('编辑器', 'Editor'), run: () => editorStore.toggleMode() }, + { id: 'save', label: t('保存当前笔记', 'Save current note'), hint: t('编辑器', 'Editor'), run: () => editorStore.save() }, + { id: 'theme', label: themeStore.isDark ? t('切换为浅色主题', 'Switch to light theme') : t('切换为深色主题', 'Switch to dark theme'), hint: t('外观', 'Appearance'), run: () => themeStore.toggleTheme() }, + { id: 'new-note', label: t('创建笔记', 'Create note'), hint: t('工作区', 'Workspace'), run: createNote }, ]) const commands = computed(() => [ @@ -78,12 +79,12 @@ async function execute(command: Command | undefined) { try { await command.run() } catch (error) { - commandNotice.value = error instanceof Error ? error.message : '命令执行失败' + commandNotice.value = error instanceof Error ? error.message : t('命令执行失败', 'Command failed') } } async function createNote() { - const rawName = window.prompt('笔记名称')?.trim() + const rawName = window.prompt(t('笔记名称', 'Note name'))?.trim() if (!rawName) return const name = rawName.endsWith('.md') ? rawName : `${rawName}.md` const file = await workspaceService.createFile('/', name, `# ${rawName}\n\n`) @@ -97,7 +98,7 @@ async function loadPluginCommands() { try { pluginCommands.value = await pluginService.listPluginCommands('command_palette') } catch (error) { - commandError.value = error instanceof Error ? error.message : 'Plugin 命令加载失败' + commandError.value = error instanceof Error ? error.message : t('Plugin 命令加载失败', 'Failed to load plugin commands') } } @@ -109,7 +110,7 @@ async function executePluginCommand(command: PluginCommand) { if (hasRequiredArguments(command)) { pluginStore.selectPlugin(command.plugin_id) await router.push('/extensions/plugins') - commandNotice.value = '请在 Plugin 详情页填写参数后执行“' + command.title + '”。' + commandNotice.value = `${t('请在 Plugin 详情页填写参数后执行', 'Enter parameters on the Plugin details page, then run')} “${command.title}”.` return } const result = await pluginService.executePluginCommand(command.command_id, {}, { @@ -135,11 +136,11 @@ async function applyPluginEffect(effect: PluginCommandEffect) { if (effect.type === 'refresh') { if (effect.payload.scope === 'plugins') await pluginStore.loadPlugins() if (effect.payload.scope === 'commands') await loadPluginCommands() - commandNotice.value = '相关数据已刷新。' + commandNotice.value = t('相关数据已刷新。', 'Related data refreshed.') return } - if (effect.type === 'job') { commandNotice.value = '后台任务已创建:' + effect.payload.job_id; return } - commandNotice.value = 'Plugin 命令执行完成。' + if (effect.type === 'job') { commandNotice.value = t('后台任务已创建:', 'Background job created: ') + effect.payload.job_id; return } + commandNotice.value = t('Plugin 命令执行完成。', 'Plugin command completed.') } function handleKeydown(event: KeyboardEvent) { @@ -157,20 +158,20 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown)) - -
+ +
diff --git a/frontend/src/features/mcp/configuration.ts b/frontend/src/features/mcp/configuration.ts index 4afbab7..a12aee4 100644 --- a/frontend/src/features/mcp/configuration.ts +++ b/frontend/src/features/mcp/configuration.ts @@ -1,4 +1,5 @@ import type { McpServerInput } from '@/contracts' +import { t } from '@/i18n' export type SecretKind = 'environment' | 'header' export interface ImportedSecret { kind: SecretKind; key: string; value: string } @@ -26,38 +27,38 @@ export function emptyMcpConfig(): McpServerInput { } function object(value: unknown, label: string): Record { - if (!value || Array.isArray(value) || typeof value !== 'object') throw new Error(`${label}必须是 JSON 对象`) + if (!value || Array.isArray(value) || typeof value !== 'object') throw new Error(`${label}${t('必须是 JSON 对象', ' must be a JSON object')}`) return value as Record } function strings(value: unknown, label: string): string[] { if (value === undefined) return [] - if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) throw new Error(`${label}必须是字符串数组`) + if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) throw new Error(`${label}${t('必须是字符串数组', ' must be a string array')}`) return [...value] } function entries(value: unknown, label: string): Record { if (value === undefined) return {} const result = object(value, label) - if (Object.values(result).some(item => typeof item !== 'string')) throw new Error(`${label}必须是字符串键值 JSON 对象`) + if (Object.values(result).some(item => typeof item !== 'string')) throw new Error(`${label}${t('必须是字符串键值 JSON 对象', ' must be a JSON object with string keys and values')}`) return { ...result } as Record } function timeout(value: unknown, fallback: number, max: number, label: string): number { if (value === undefined) return fallback - if (typeof value !== 'number' || !Number.isFinite(value) || value < 1 || value > max) throw new Error(`${label}必须是 1–${max} 秒之间的数字`) + if (typeof value !== 'number' || !Number.isFinite(value) || value < 1 || value > max) throw new Error(`${label}${t(`必须是 1–${max} 秒之间的数字`, ` must be a number from 1 to ${max} seconds`)}`) return value } // Do not silently rewrite executable arguments or secret values copied from chat. function checkUrl(value: string, label: string) { - if (/^\[https?:\/\//i.test(value)) throw new Error(`${label}请填写纯 URL,不要粘贴 Markdown 链接`) + if (/^\[https?:\/\//i.test(value)) throw new Error(`${label}${t('请填写纯 URL,不要粘贴 Markdown 链接', ': enter a plain URL instead of a Markdown link')}`) } export function parseMcpJson(raw: string, fallbackName = '', requireConnection = true) { let parsed: unknown try { parsed = JSON.parse(raw) } - catch { throw new Error('服务器配置不是有效 JSON;请检查逗号、引号和无效的 \\_ 转义') } + catch { throw new Error(t('服务器配置不是有效 JSON;请检查逗号、引号和无效的 \\_ 转义', 'The server configuration is not valid JSON. Check commas, quotes, and invalid \\_ escapes.')) } return normalizeMcpConfig(parsed, fallbackName, requireConnection) } @@ -65,54 +66,54 @@ export function parseMcpJson(raw: string, fallbackName = '', requireConnection = * Inline secrets leave the public config here and are sent only to the Secret API. */ export function normalizeMcpConfig(parsed: unknown, fallbackName = '', requireConnection = true) { - let raw = object(parsed, '服务器配置') + let raw = object(parsed, t('服务器配置', 'Server configuration')) if ('mcpServers' in raw) { const servers = Object.entries(object(raw.mcpServers, 'mcpServers')) - if (servers.length !== 1) throw new Error('请一次导入一个 MCP 服务器') + if (servers.length !== 1) throw new Error(t('请一次导入一个 MCP 服务器', 'Import one MCP server at a time')) fallbackName = servers[0]![0] - raw = object(servers[0]![1], '服务器配置') + raw = object(servers[0]![1], t('服务器配置', 'Server configuration')) } const allowed = new Set([...Object.keys(emptyMcpConfig()), 'version', 'env', 'type', 'timeout', 'sse_read_timeout']) if (Object.keys(raw).some(key => !allowed.has(key))) { // Never echo arbitrary unknown keys: pasted secrets sometimes become JSON keys. - throw new Error('服务器配置含不支持的字段;API Key 请放在 env/environment 的对应变量中,不要放在顶层') + throw new Error(t('服务器配置含不支持的字段;API Key 请放在 env/environment 的对应变量中,不要放在顶层', 'The server configuration contains unsupported fields. Put API keys in the corresponding env/environment variables, not at the top level.')) } - if (raw.env !== undefined && raw.environment !== undefined) throw new Error('env 与 environment 请只保留一个,避免覆盖配置') + if (raw.env !== undefined && raw.environment !== undefined) throw new Error(t('env 与 environment 请只保留一个,避免覆盖配置', 'Keep either env or environment, not both')) const transport = raw.transport ?? raw.type ?? (raw.url ? 'streamable_http' : 'stdio') - if (!['stdio', 'streamable_http', 'sse'].includes(transport as string)) throw new Error('transport 必须是 stdio、streamable_http 或 sse') + if (!['stdio', 'streamable_http', 'sse'].includes(transport as string)) throw new Error(t('transport 必须是 stdio、streamable_http 或 sse', 'transport must be stdio, streamable_http, or sse')) const config = emptyMcpConfig() config.transport = transport as McpServerInput['transport'] - const name = raw.name ?? (fallbackName || (typeof raw.command === 'string' ? raw.command : 'MCP 服务器')) - if (typeof name !== 'string' || (requireConnection && !name.trim()) || name.trim().length > 80) throw new Error('服务器名称必须为 1–80 个字符') + const name = raw.name ?? (fallbackName || (typeof raw.command === 'string' ? raw.command : t('MCP 服务器', 'MCP Server'))) + if (typeof name !== 'string' || (requireConnection && !name.trim()) || name.trim().length > 80) throw new Error(t('服务器名称必须为 1–80 个字符', 'The server name must contain 1–80 characters')) config.name = name.trim() for (const key of ['command', 'url'] as const) { const value = raw[key] - if (value !== undefined && value !== null && typeof value !== 'string') throw new Error(`${key}必须是字符串`) + if (value !== undefined && value !== null && typeof value !== 'string') throw new Error(`${key}${t('必须是字符串', ' must be a string')}`) config[key] = typeof value === 'string' ? value.trim() : null } config.args = strings(raw.args, 'args') - if (config.args.length > 64) throw new Error('args 最多允许 64 项') - for (const value of config.args) checkUrl(value, 'args 中的地址') + if (config.args.length > 64) throw new Error(t('args 最多允许 64 项', 'args allows at most 64 items')) + for (const value of config.args) checkUrl(value, t('args 中的地址', 'URL in args')) config.environment = entries(raw.environment ?? raw.env, 'environment/env') config.headers = entries(raw.headers, 'headers') config.secret_environment_keys = [...new Set(strings(raw.secret_environment_keys, 'secret_environment_keys'))] config.secret_header_keys = [...new Set(strings(raw.secret_header_keys, 'secret_header_keys'))] config.permissions = strings(raw.permissions, 'permissions') - config.startup_timeout_seconds = timeout(raw.startup_timeout_seconds ?? raw.timeout, 15, 120, '启动超时') + config.startup_timeout_seconds = timeout(raw.startup_timeout_seconds ?? raw.timeout, 15, 120, t('启动超时', 'Startup timeout')) // Compatibility policy: legacy read timeout becomes the tool wait budget, not an SSE transport setting. - config.tool_timeout_seconds = timeout(raw.tool_timeout_seconds ?? raw.sse_read_timeout, 30, 300, '工具超时') + config.tool_timeout_seconds = timeout(raw.tool_timeout_seconds ?? raw.sse_read_timeout, 30, 300, t('工具超时', 'Tool timeout')) if (config.transport === 'stdio') { - if (requireConnection && !config.command) throw new Error('stdio 配置必须填写 command') - if (config.url || Object.keys(config.headers).length || config.secret_header_keys.length) throw new Error('stdio 配置不能包含 URL 或 HTTP Header') + if (requireConnection && !config.command) throw new Error(t('stdio 配置必须填写 command', 'stdio configuration requires command')) + if (config.url || Object.keys(config.headers).length || config.secret_header_keys.length) throw new Error(t('stdio 配置不能包含 URL 或 HTTP Header', 'stdio configuration cannot contain a URL or HTTP headers')) } else { - if (requireConnection && !config.url) throw new Error('HTTP/SSE 配置必须填写 url') + if (requireConnection && !config.url) throw new Error(t('HTTP/SSE 配置必须填写 url', 'HTTP/SSE configuration requires a URL')) if (config.url) { checkUrl(config.url, 'url') let url: URL - try { url = new URL(config.url) } catch { throw new Error('url 必须是有效的 HTTP(S) 地址') } - if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash) throw new Error('url 必须为不含账号密码或片段的 HTTP(S) 地址') + try { url = new URL(config.url) } catch { throw new Error(t('url 必须是有效的 HTTP(S) 地址', 'url must be a valid HTTP(S) address')) } + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash) throw new Error(t('url 必须为不含账号密码或片段的 HTTP(S) 地址', 'url must be an HTTP(S) address without credentials or a fragment')) } - if (config.command || config.args.length || Object.keys(config.environment).length || config.secret_environment_keys.length) throw new Error('HTTP/SSE 配置不能包含 command、args 或环境变量') + if (config.command || config.args.length || Object.keys(config.environment).length || config.secret_environment_keys.length) throw new Error(t('HTTP/SSE 配置不能包含 command、args 或环境变量', 'HTTP/SSE configuration cannot contain command, args, or environment variables')) } const secrets: ImportedSecret[] = [] for (const kind of ['environment', 'header'] as const) { @@ -120,19 +121,19 @@ export function normalizeMcpConfig(parsed: unknown, fallbackName = '', requireCo const keys = kind === 'environment' ? config.secret_environment_keys : config.secret_header_keys const identity = (key: string) => kind === 'header' ? key.toLowerCase() : key const allKeys = [...Object.keys(values), ...keys] - if (kind === 'header' && (new Set(keys.map(identity)).size !== keys.length || new Set(Object.keys(values).map(identity)).size !== Object.keys(values).length)) throw new Error('HTTP Header 名称不能仅大小写不同而重复声明') + if (kind === 'header' && (new Set(keys.map(identity)).size !== keys.length || new Set(Object.keys(values).map(identity)).size !== Object.keys(values).length)) throw new Error(t('HTTP Header 名称不能仅大小写不同而重复声明', 'HTTP header names cannot be duplicated with case-only differences')) const validKey = kind === 'environment' ? /^[A-Za-z_][A-Za-z0-9_]{0,127}$/ : /^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,128}$/ - if (allKeys.some(key => !validKey.test(key))) throw new Error(`${kind === 'environment' ? '环境变量' : 'Header'}名称无效;敏感变量名只能填名称,不能填密钥值`) + if (allKeys.some(key => !validKey.test(key))) throw new Error(`${kind === 'environment' ? t('环境变量', 'Environment variable') : 'Header'}${t('名称无效;敏感变量名只能填名称,不能填密钥值', ' name is invalid; secret variable declarations accept names only, not secret values')}`) for (const [key, value] of Object.entries(values)) { const declared = keys.find(item => identity(item) === identity(key)) const sensitive = /api[_-]?key|token|secret|password|authorization|cookie|credential/i.test(key) if (declared || sensitive) { - if (!value || value.length > 32768) throw new Error('密钥值必须为 1–32768 个字符') + if (!value || value.length > 32768) throw new Error(t('密钥值必须为 1–32768 个字符', 'Secret values must contain 1–32768 characters')) const secretKey = declared ?? key if (!declared) keys.push(key) secrets.push({ kind, key: secretKey, value }) delete values[key] - } else if (/host|url|endpoint/i.test(key)) checkUrl(value, '环境变量或 Header 地址') + } else if (/host|url|endpoint/i.test(key)) checkUrl(value, t('环境变量或 Header 地址', 'Environment variable or Header URL')) } } return { config, secrets } diff --git a/frontend/src/features/media/MediaView.vue b/frontend/src/features/media/MediaView.vue index 11325d7..a5846f0 100644 --- a/frontend/src/features/media/MediaView.vue +++ b/frontend/src/features/media/MediaView.vue @@ -2,6 +2,8 @@ import { computed, onMounted, onUnmounted, ref } from 'vue' import { useRoute } from 'vue-router' import { mediaService, createMediaSubmission, type MediaJob } from '@/services/mediaService' +import { localeTag, t } from '@/i18n' +import FilePicker from '@/components/common/FilePicker.vue' const route = useRoute() const submission = createMediaSubmission() @@ -11,6 +13,7 @@ const selected = ref(null) const file = ref(null) const reference = ref(null) const matchResult = ref('') +const terminologyPlaceholder = computed(() => t('{"错误术语": "正确术语"}', '{"incorrect term": "correct term"}')) const localOnly = ref(false) const diarization = ref(true) const terminology = ref('') @@ -18,17 +21,22 @@ const busy = ref(false) const error = ref('') const notice = ref('') const dirty = ref(false) -const title = ref('课堂转写') +const title = ref(t('课堂转写', 'Class transcript')) const player = ref(null) const position = ref(0) const speed = ref(1) const history = ref([]) let timer: ReturnType | undefined let stopped = false -const labels = {queued: '排队中', running: '转写中', processing: '处理中', completed: '已完成', failed: '失败', cancelled: '已取消'} +const labels = computed(() => ({queued: t('排队中', 'Queued'), running: t('转写中', 'Transcribing'), processing: t('处理中', 'Processing'), completed: t('已完成', 'Completed'), failed: t('失败', 'Failed'), cancelled: t('已取消', 'Cancelled')})) const speakers = computed(() => [...new Set(selected.value?.segments.map(s => s.speaker).filter((s): s is string => !!s) || [])]) const active = (job: MediaJob) => ['queued', 'running', 'processing'].includes(job.status) const stamp = (seconds: number) => `${Math.floor(seconds / 60).toString().padStart(2, '0')}:${Math.floor(seconds % 60).toString().padStart(2, '0')}` +const warningLabel = (warning: string) => ({ + DIARIZATION_UNAVAILABLE: t('当前无法分离说话人', 'Speaker identification is unavailable'), + WORD_TIMESTAMPS_UNAVAILABLE: t('未提供逐字时间戳', 'Word-level timestamps are unavailable'), + DIARIZATION_SEGMENT_LEVEL: t('说话人按音频段估计,同段多人或重叠发言需人工校对', 'Speakers are estimated per segment; multiple or overlapping speakers require manual correction'), +} as Record)[warning] || warning async function refresh() { try { @@ -38,7 +46,7 @@ async function refresh() { if (!stopped) timer = setTimeout(refresh, 2000) } async function choose(job: MediaJob) { - if (dirty.value && !window.confirm('当前校对尚未保存,切换后放弃修改?')) return + if (dirty.value && !window.confirm(t('当前校对尚未保存,切换后放弃修改?', 'The current corrections are unsaved. Discard them and switch?'))) return selected.value = JSON.parse(JSON.stringify(job)); dirty.value = false; history.value = [] } async function action(work: () => Promise) { @@ -52,7 +60,7 @@ async function submit() { let terms = {} if (terminology.value.trim()) { terms = JSON.parse(terminology.value) - if (!terms || typeof terms !== 'object' || Array.isArray(terms) || Object.values(terms).some(v => typeof v !== 'string')) throw new Error('术语表需要 JSON 对象,值为替换后的文本。') + if (!terms || typeof terms !== 'object' || Array.isArray(terms) || Object.values(terms).some(v => typeof v !== 'string')) throw new Error(t('术语表需要 JSON 对象,值为替换后的文本。', 'The terminology map must be a JSON object whose values are replacement text.')) } selected.value = await submission.submit(file.value!, {local_only: localOnly.value, diarization: diarization.value, terminology: terms}) @@ -65,10 +73,10 @@ async function purge() { if (!selected.value) return await action(async () => { const impact = await mediaService.impact(selected.value!.attachment_id) - if (!window.confirm(`${impact.message}\n将保留 ${impact.retained_note_ids.length} 篇已保存笔记。确定清理?`)) return + if (!window.confirm(`${impact.message}\n${t('将保留', 'Will retain')} ${impact.retained_note_ids.length} ${t('篇已保存笔记。确定清理?', 'saved notes. Continue cleanup?')}`)) return await mediaService.purge(selected.value!.attachment_id) selected.value = await mediaService.get(selected.value!.job_id) - dirty.value = false; history.value = []; notice.value = '附件与转写内容已清理' + dirty.value = false; history.value = []; notice.value = t('附件与转写内容已清理', 'Attachment and transcript content were removed') }) } async function compareSpeaker() { @@ -79,10 +87,10 @@ async function compareSpeaker() { const sample = await mediaService.upload(file.value!); temporary.push(sample.attachment_id) const known = await mediaService.upload(reference.value!); temporary.push(known.attachment_id) const result = await mediaService.match(sample.attachment_id, known.attachment_id, localOnly.value) - matchResult.value = `相似度 ${result.score.toFixed(3)} · ${result.source === 'local' ? '本地模型' : 'API'}${result.fallback_reason ? ` · 回退:${result.fallback_reason}` : ''}` + matchResult.value = `${t('相似度', 'Similarity')} ${result.score.toFixed(3)} · ${result.source === 'local' ? t('本地模型', 'Local model') : 'API'}${result.fallback_reason ? ` · ${t('回退:', 'Fallback: ')}${result.fallback_reason}` : ''}` } finally { const cleanup = await Promise.allSettled(temporary.map(id => mediaService.purge(id))) - if (cleanup.some(result => result.status === 'rejected')) notice.value = '部分临时参考附件清理失败,请检查后端连接。' + if (cleanup.some(result => result.status === 'rejected')) notice.value = t('部分临时参考附件清理失败,请检查后端连接。', 'Some temporary reference files could not be removed. Check the backend connection.') } }) } @@ -98,56 +106,56 @@ onUnmounted(() => { stopped = true; clearTimeout(timer) })