diff --git a/README.md b/README.md index b77b0e8..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、多模态任务及运行诊断。AI 对话已接入知识库检索,但会话列表与消息持久化接口尚未实现。 +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 尚未接入。 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..c98a385 --- /dev/null +++ b/backend/app/services/chat_history.py @@ -0,0 +1,183 @@ +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: + 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..a4ed03f --- /dev/null +++ b/backend/tests/test_chat_history.py @@ -0,0 +1,82 @@ +from datetime import datetime, timezone +from types import SimpleNamespace + +from fastapi.testclient import TestClient + +from app.contracts import 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" 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 3595574..794ca01 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -46,7 +46,7 @@ pnpm dev ## 数据边界 - 笔记、附件、搜索历史、任务、Trace、模型配置和多模态结果都通过 FastAPI 读写。 -- AI 对话生成和知识库检索通过 FastAPI;会话列表与消息当前只保留在页面内存,刷新后会清空,需在桌面集成前补充后端持久化接口。 +- AI 对话生成和知识库检索通过 FastAPI;会话列表、用户消息、流式助手结果、引用和 Token 用量保存在后端 SQLite,刷新页面后可恢复。 - API Key 只存在于密码输入和提交请求期间,不进入 Pinia 或 `localStorage`。 - 页面内存可以保存尚未提交的临时状态;后端已经接收的任务和结果由 SQLite/Vault 持久化。 - 主题、编辑器偏好、侧栏状态和最近 Vault 路径目前保存在浏览器 `localStorage`;它们是设备界面偏好,不作为笔记或模型业务数据。Tauri 集成时由桌面配置存储接管。 diff --git a/frontend/src/features/chat/ChatView.spec.ts b/frontend/src/features/chat/ChatView.spec.ts index fe1d731..be8001f 100644 --- a/frontend/src/features/chat/ChatView.spec.ts +++ b/frontend/src/features/chat/ChatView.spec.ts @@ -11,6 +11,10 @@ vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) })) vi.mock('@/stores/editor', () => ({ useEditorStore: () => ({}) })) vi.mock('@/stores/workspace', () => ({ useWorkspaceStore: () => ({}) })) vi.mock('@/components/common/MarkdownContent.vue', () => ({ default: { template: '
' } })) +vi.mock('@/services/chatService', () => ({ + listConversations: vi.fn().mockResolvedValue({ items: [], page: { total: 0, limit: 100, offset: 0 } }), + listConversationMessages: vi.fn(), createConversation: vi.fn(), removeConversation: vi.fn(), streamChat: vi.fn(), +})) beforeEach(() => { setActivePinia(createPinia()) diff --git a/frontend/src/features/chat/ChatView.vue b/frontend/src/features/chat/ChatView.vue index 28f2474..23e8bd8 100644 --- a/frontend/src/features/chat/ChatView.vue +++ b/frontend/src/features/chat/ChatView.vue @@ -24,7 +24,7 @@ const availableModels = computed(() => providerStore.modelsByProvider[chatStore. onMounted(async () => { try { - await Promise.all([providerStore.loadProviders(), skillStore.loadSkills()]) + await Promise.all([providerStore.loadProviders(), skillStore.loadSkills(), chatStore.loadConversations()]) if (disposed || providerStore.error) return const selected = providerStore.enabledProviders.find(p => p.provider_id === chatStore.selectedProviderId) if (!selected) { @@ -70,9 +70,9 @@ async function openCitation(citation: Citation) { {{ t('开启后,将相关笔记片段发送给所选模型,并显示来源。技能调用请使用智能体。', 'When enabled, relevant note excerpts are sent to the selected model and citations are shown. Use Agent for skills.') }} -
{{ loadError || providerStore.error }}
+
{{ loadError || providerStore.error || chatStore.historyError }}
-
{{ t('开始一段知识对话', 'Start a knowledge conversation') }}

{{ t('请先配置模型提供商。聊天记录仅保留在本次页面会话中。', 'Configure a model provider first. Messages are kept only for this page session.') }}

+
{{ t('开始一段知识对话', 'Start a knowledge conversation') }}

{{ t('请先配置模型提供商。聊天记录保存在本地数据库中。', 'Configure a model provider first. Messages are saved in the local database.') }}

{{ message.role === 'user' ? t('你', 'You') : 'AI' }}
diff --git a/frontend/src/features/chat/ConversationListPanel.vue b/frontend/src/features/chat/ConversationListPanel.vue index bbf8a6c..6a5e9b9 100644 --- a/frontend/src/features/chat/ConversationListPanel.vue +++ b/frontend/src/features/chat/ConversationListPanel.vue @@ -1,8 +1,10 @@