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 @@
diff --git a/frontend/src/services/chatService.ts b/frontend/src/services/chatService.ts
index 1976cc7..e54a279 100644
--- a/frontend/src/services/chatService.ts
+++ b/frontend/src/services/chatService.ts
@@ -1,10 +1,14 @@
import { SseClient } from './sseClient'
-import type { ModelEvent } from '@/contracts'
+import { apiClient } from './apiClient'
+import type { ChatMessage, Conversation, ModelEvent, PageMeta } from '@/contracts'
export interface ChatRequest {
provider_id: string
model: string
conversation_id?: string
+ user_message_id?: string
+ assistant_message_id?: string
+ conversation_title?: string
system?: string
messages: Array<{
role: 'system' | 'user' | 'assistant' | 'tool'
@@ -18,6 +22,25 @@ export interface ChatRequest {
max_tokens?: number
}
+export function listConversations(offset = 0, limit = 100) {
+ return apiClient.get<{ items: Conversation[]; page: PageMeta }>('/api/chat/conversations', { params: { limit, offset } })
+}
+
+export function createConversation(conversation: Pick) {
+ return apiClient.post('/api/chat/conversations', {
+ conversation_id: conversation.conversation_id,
+ title: conversation.title,
+ })
+}
+
+export function listConversationMessages(conversationId: string, offset = 0, limit = 500) {
+ return apiClient.get<{ items: ChatMessage[]; page: PageMeta }>(`/api/chat/conversations/${encodeURIComponent(conversationId)}/messages`, { params: { limit, offset } })
+}
+
+export function removeConversation(conversationId: string) {
+ return apiClient.delete(`/api/chat/conversations/${encodeURIComponent(conversationId)}`)
+}
+
export function streamChat(
request: ChatRequest,
handlers: {
diff --git a/frontend/src/stores/chat.spec.ts b/frontend/src/stores/chat.spec.ts
index a89a950..f213f44 100644
--- a/frontend/src/stores/chat.spec.ts
+++ b/frontend/src/stores/chat.spec.ts
@@ -1,36 +1,78 @@
import { beforeEach, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useChatStore } from './chat'
-import { streamChat } from '@/services/chatService'
+import {
+ createConversation,
+ listConversationMessages,
+ listConversations,
+ removeConversation,
+ streamChat,
+} from '@/services/chatService'
+import type { ChatMessage, Conversation } from '@/contracts'
import type { SseClient } from '@/services/sseClient'
-vi.mock('@/services/chatService', () => ({ streamChat: vi.fn() }))
+vi.mock('@/services/chatService', () => ({
+ createConversation: vi.fn(),
+ listConversationMessages: vi.fn(),
+ listConversations: vi.fn(),
+ removeConversation: vi.fn(),
+ streamChat: vi.fn(),
+}))
+
+const page = { total: 0, limit: 100, offset: 0 }
+
beforeEach(() => {
setActivePinia(createPinia())
vi.mocked(streamChat).mockReset().mockReturnValue({ cancel: vi.fn() } as unknown as SseClient)
+ vi.mocked(listConversations).mockReset().mockResolvedValue({ items: [], page })
+ vi.mocked(listConversationMessages).mockReset().mockResolvedValue({ items: [], page: { ...page, limit: 1000 } })
+ vi.mocked(createConversation).mockReset().mockImplementation(async value => ({
+ ...value, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), message_count: 0,
+ }))
+ vi.mocked(removeConversation).mockReset().mockResolvedValue(undefined)
})
-it('sends real user history, applies streaming changes, and restores it when switching conversations', async () => {
+it('sends persistent message ids and restores messages from the backend', async () => {
const store = useChatStore()
store.selectedProviderId = 'real'
store.selectedModel = 'configured-model'
await store.sendMessage('user input')
const [request, handlers] = vi.mocked(streamChat).mock.calls[0]!
expect(request.use_rag).toBe(true)
- handlers.onEvent?.({ event: 'Citation', sequence: 0, timestamp: '', data: { note_id: 'note', block_id: 'block', file_path: 'note.md', content: 'real evidence' } })
- expect(store.messages[1]?.citations?.[0]?.content).toBe('real evidence')
+ expect(request.user_message_id).toBe(store.messages[0]?.message_id)
+ expect(request.assistant_message_id).toBe(store.messages[1]?.message_id)
expect(request.messages).toEqual([{ role: 'user', content: 'user input' }])
- handlers.onEvent?.({ event: 'TextDelta', sequence: 0, timestamp: '', data: { text: 'real response' } })
- expect(store.messages[1]?.content).toBe('real response')
+ handlers.onEvent?.({ event: 'Citation', sequence: 0, timestamp: '', data: { note_id: 'note', block_id: 'block', file_path: 'note.md', heading_path: ['Heading'], content: 'real evidence' } })
+ handlers.onEvent?.({ event: 'TextDelta', sequence: 1, timestamp: '', data: { text: 'real response' } })
handlers.onDone?.()
+
+ const persisted = store.messages.map(message => ({ ...message })) as ChatMessage[]
+ vi.mocked(listConversationMessages).mockResolvedValueOnce({ items: persisted, page: { total: 2, limit: 1000, offset: 0 } })
const id = store.activeConversationId!
- store.createNewConversation()
+ await store.createNewConversation()
expect(store.messages).toEqual([])
await store.setActiveConversation(id)
- expect(store.messages.map(m => m.content)).toEqual(['user input', 'real response'])
+ expect(store.messages.map(message => message.content)).toEqual(['user input', 'real response'])
+ expect(store.messages[1]?.citations?.[0]?.heading_path).toBe('Heading')
})
-it('does not send without a provider and ignores late callbacks from a cancelled conversation', async () => {
+it('loads the newest persisted conversation on initialization', async () => {
+ const conversation: Conversation = {
+ conversation_id: 'persisted', title: 'Saved', created_at: '2026-01-01T00:00:00Z',
+ updated_at: '2026-01-02T00:00:00Z', message_count: 1,
+ }
+ vi.mocked(listConversations).mockResolvedValue({ items: [conversation], page: { ...page, total: 1 } })
+ vi.mocked(listConversationMessages).mockResolvedValue({
+ items: [{ message_id: 'm1', conversation_id: 'persisted', role: 'user', content: 'saved text', created_at: '2026-01-01T00:00:00Z' }],
+ page: { total: 1, limit: 1000, offset: 0 },
+ })
+ const store = useChatStore()
+ await store.loadConversations()
+ expect(store.activeConversationId).toBe('persisted')
+ expect(store.messages[0]?.content).toBe('saved text')
+})
+
+it('does not send without a provider and ignores callbacks from a cancelled conversation', async () => {
const store = useChatStore()
await store.sendMessage('no provider')
expect(streamChat).not.toHaveBeenCalled()
@@ -38,9 +80,19 @@ it('does not send without a provider and ignores late callbacks from a cancelled
store.selectedModel = 'configured-model'
await store.sendMessage('first')
const old = vi.mocked(streamChat).mock.calls[0]![1]
- store.createNewConversation()
+ await store.createNewConversation()
await store.sendMessage('second')
old.onDone?.()
expect(store.isStreaming).toBe(true)
expect(store.messages[0]?.content).toBe('second')
})
+
+it('keeps a conversation visible when backend deletion fails', async () => {
+ const store = useChatStore()
+ await store.createNewConversation()
+ const id = store.activeConversationId!
+ vi.mocked(removeConversation).mockRejectedValueOnce(new Error('offline'))
+ await store.deleteConversation(id)
+ expect(store.conversations.some(item => item.conversation_id === id)).toBe(true)
+ expect(store.historyError).toBe('offline')
+})
diff --git a/frontend/src/stores/chat.ts b/frontend/src/stores/chat.ts
index 45b2754..429b0fe 100644
--- a/frontend/src/stores/chat.ts
+++ b/frontend/src/stores/chat.ts
@@ -1,7 +1,13 @@
+import { computed, reactive, ref } from 'vue'
import { defineStore } from 'pinia'
-import { ref, computed, reactive } from 'vue'
-import type { ChatMessage, Conversation } from '@/contracts'
-import { streamChat } from '@/services/chatService'
+import type { ChatMessage, Citation, Conversation } from '@/contracts'
+import {
+ createConversation as createConversationApi,
+ listConversationMessages,
+ listConversations as listConversationsApi,
+ removeConversation,
+ streamChat,
+} from '@/services/chatService'
import type { SseClient } from '@/services/sseClient'
import { t } from '@/i18n'
@@ -15,68 +21,155 @@ export const useChatStore = defineStore('chat', () => {
const selectedSkillId = ref(null)
const selectedProviderId = ref('')
const selectedModel = ref('')
+ const historyError = ref('')
+ let initialized = false
+ let loading: Promise | null = null
+ let loadVersion = 0
let sseClient: SseClient | null = null
let streamVersion = 0
-
- // User-created conversations live in this browser session; no fabricated history.
- const history = reactive>({})
+ const pendingCreates = new Map>()
const activeConversation = computed(() =>
- conversations.value.find((c) => c.conversation_id === activeConversationId.value) || null
+ conversations.value.find(item => item.conversation_id === activeConversationId.value) || null
)
-
const sortedConversations = computed(() =>
[...conversations.value].sort((a, b) => b.updated_at.localeCompare(a.updated_at))
)
+ function normalizeMessage(message: ChatMessage): ChatMessage {
+ return {
+ ...message,
+ citations: message.citations?.map(citation => ({
+ ...citation,
+ heading_path: Array.isArray(citation.heading_path)
+ ? citation.heading_path.join(' / ')
+ : citation.heading_path,
+ } as Citation)),
+ }
+ }
+
+ async function fetchAllConversations() {
+ const items: Conversation[] = []
+ while (true) {
+ const result = await listConversationsApi(items.length, 100)
+ items.push(...result.items)
+ if (!result.items.length || items.length >= result.page.total) return items
+ }
+ }
+
+ async function fetchAllMessages(conversationId: string) {
+ const items: ChatMessage[] = []
+ while (true) {
+ const result = await listConversationMessages(conversationId, items.length, 500)
+ items.push(...result.items)
+ if (!result.items.length || items.length >= result.page.total) return items
+ }
+ }
+
+ async function loadConversations(force = false) {
+ if (loading) return loading
+ if (initialized && !force) return
+ const version = ++loadVersion
+ loading = (async () => {
+ historyError.value = ''
+ try {
+ const items = await fetchAllConversations()
+ if (version !== loadVersion) return
+ conversations.value = items
+ initialized = true
+ const selected = activeConversationId.value && items.some(item => item.conversation_id === activeConversationId.value)
+ ? activeConversationId.value
+ : items[0]?.conversation_id || null
+ if (selected) await setActiveConversation(selected)
+ else { activeConversationId.value = null; messages.value = [] }
+ } catch (error) {
+ if (version === loadVersion) historyError.value = error instanceof Error ? error.message : t('聊天记录加载失败', 'Failed to load chat history')
+ } finally {
+ loading = null
+ }
+ })()
+ return loading
+ }
+
async function setActiveConversation(id: string) {
stopGeneration()
+ const version = ++loadVersion
activeConversationId.value = id
- messages.value = history[id] ?? []
+ messages.value = []
+ historyError.value = ''
+ try {
+ const loadedMessages = await fetchAllMessages(id)
+ if (version === loadVersion && activeConversationId.value === id) {
+ messages.value = loadedMessages.map(normalizeMessage)
+ }
+ } catch (error) {
+ if (version === loadVersion) historyError.value = error instanceof Error ? error.message : t('消息加载失败', 'Failed to load messages')
+ }
+ }
+
+ function addLocalConversation(title: string) {
+ loadVersion++
+ const now = new Date().toISOString()
+ const conversation: Conversation = {
+ conversation_id: crypto.randomUUID(), title, created_at: now, updated_at: now, message_count: 0,
+ }
+ conversations.value.unshift(conversation)
+ activeConversationId.value = conversation.conversation_id
+ messages.value = []
+ return conversation
+ }
+
+ async function persistConversation(conversation: Conversation) {
+ const promise = createConversationApi(conversation).then(saved => {
+ const index = conversations.value.findIndex(item => item.conversation_id === saved.conversation_id)
+ if (index >= 0) Object.assign(conversations.value[index]!, saved)
+ }).catch(error => {
+ conversations.value = conversations.value.filter(item => item.conversation_id !== conversation.conversation_id)
+ if (activeConversationId.value === conversation.conversation_id) {
+ activeConversationId.value = null
+ messages.value = []
+ }
+ historyError.value = error instanceof Error ? error.message : t('会话创建失败', 'Failed to create conversation')
+ throw error
+ }).finally(() => pendingCreates.delete(conversation.conversation_id))
+ pendingCreates.set(conversation.conversation_id, promise)
+ return promise
+ }
+
+ async function createNewConversation() {
+ stopGeneration()
+ historyError.value = ''
+ const conversation = addLocalConversation(t('新对话', 'New conversation'))
+ try { await persistConversation(conversation) } catch { /* exposed through historyError */ }
}
async function sendMessage(text: string) {
- if (!text.trim() || isStreaming.value || !selectedProviderId.value || !selectedModel.value) return
- const conversationId = activeConversationId.value || crypto.randomUUID()
-
- if (!activeConversationId.value) {
- const newConv: Conversation = {
- conversation_id: conversationId,
- title: text.slice(0, 30),
- created_at: new Date().toISOString(),
- updated_at: new Date().toISOString(),
- message_count: 0,
- }
- conversations.value.unshift(newConv)
- activeConversationId.value = conversationId
+ const content = text.trim()
+ if (!content || isStreaming.value || !selectedProviderId.value || !selectedModel.value) return
+ historyError.value = ''
+ let conversation = activeConversation.value
+ if (!conversation) {
+ conversation = addLocalConversation(content.slice(0, 30))
+ try { await persistConversation(conversation) } catch { return }
+ } else if (pendingCreates.has(conversation.conversation_id)) {
+ try { await pendingCreates.get(conversation.conversation_id) } catch { return }
}
- history[conversationId] = messages.value
- const conversationMessages = messages.value
+ const conversationId = conversation.conversation_id
+ if (conversation.message_count === 0) conversation.title = content.slice(0, 30)
const userMsg: ChatMessage = {
- message_id: crypto.randomUUID(),
- conversation_id: conversationId,
- role: 'user',
- content: text,
+ message_id: crypto.randomUUID(), conversation_id: conversationId, role: 'user', content,
created_at: new Date().toISOString(),
}
- messages.value.push(userMsg)
+ const aiMsg = reactive({
+ message_id: crypto.randomUUID(), conversation_id: conversationId, role: 'assistant', content: '',
+ created_at: new Date().toISOString(), citations: [], tool_calls: [],
+ })
+ messages.value.push(userMsg, aiMsg)
inputText.value = ''
isStreaming.value = true
- const conversation = conversations.value.find(c => c.conversation_id === conversationId)
- if (conversation) { conversation.updated_at = new Date().toISOString(); conversation.message_count = messages.value.length }
-
- // 先插入占位消息,随后将 SSE 增量原位合并,避免每个 token 重建消息列表。
- const aiMsg = reactive({
- message_id: crypto.randomUUID(),
- conversation_id: conversationId,
- role: 'assistant',
- content: '',
- created_at: new Date().toISOString(),
- citations: [],
- tool_calls: [],
- })
- messages.value.push(aiMsg)
+ conversation.updated_at = new Date().toISOString()
+ conversation.message_count = messages.value.length
const version = ++streamVersion
const argumentBuffers = new Map()
@@ -84,10 +177,13 @@ export const useChatStore = defineStore('chat', () => {
provider_id: selectedProviderId.value,
model: selectedModel.value,
conversation_id: conversationId,
+ user_message_id: userMsg.message_id,
+ assistant_message_id: aiMsg.message_id,
+ conversation_title: conversation.title,
use_rag: useRag.value,
messages: messages.value
- .filter((message) => message.message_id !== aiMsg.message_id)
- .map((message) => ({ role: message.role, content: message.content })),
+ .filter(message => message.message_id !== aiMsg.message_id)
+ .map(message => ({ role: message.role, content: message.content })),
}, {
onEvent(event) {
if (version !== streamVersion) return
@@ -95,25 +191,21 @@ export const useChatStore = defineStore('chat', () => {
if (event.event === 'ThinkingDelta') aiMsg.thinking = `${aiMsg.thinking ?? ''}${String(event.data.text ?? '')}`
if (event.event === 'ToolCallStart') {
aiMsg.tool_calls?.push({
- tool_call_id: String(event.data.tool_call_id ?? ''),
- name: String(event.data.name ?? 'unknown'),
- parameters: (event.data.arguments ?? {}) as Record,
- status: 'running',
+ tool_call_id: String(event.data.tool_call_id ?? ''), name: String(event.data.name ?? 'unknown'),
+ parameters: (event.data.arguments ?? {}) as Record, status: 'running',
})
}
if (event.event === 'ToolCallDelta') {
- const call = aiMsg.tool_calls?.find((item) => item.tool_call_id === event.data.tool_call_id)
+ const call = aiMsg.tool_calls?.find(item => item.tool_call_id === event.data.tool_call_id)
if (call && typeof event.data.arguments_delta === 'string') {
const buffer = (argumentBuffers.get(call.tool_call_id) ?? '') + event.data.arguments_delta
argumentBuffers.set(call.tool_call_id, buffer)
try { call.parameters = JSON.parse(buffer) } catch { /* incomplete JSON fragment */ }
}
- if (call && event.data.arguments && typeof event.data.arguments === 'object') {
- Object.assign(call.parameters, event.data.arguments)
- }
+ if (call && event.data.arguments && typeof event.data.arguments === 'object') Object.assign(call.parameters, event.data.arguments)
}
if (event.event === 'ToolCallEnd') {
- const call = aiMsg.tool_calls?.find((item) => item.tool_call_id === event.data.tool_call_id)
+ const call = aiMsg.tool_calls?.find(item => item.tool_call_id === event.data.tool_call_id)
if (call) call.status = 'completed'
}
if (event.event === 'Usage') {
@@ -139,11 +231,8 @@ export const useChatStore = defineStore('chat', () => {
},
onDone() {
if (version !== streamVersion) return
- const conversation = conversations.value.find((item) => item.conversation_id === conversationId)
- if (conversation) {
- conversation.message_count = conversationMessages.length
- conversation.updated_at = new Date().toISOString()
- }
+ conversation!.message_count = messages.value.length
+ conversation!.updated_at = new Date().toISOString()
isStreaming.value = false
sseClient = null
},
@@ -152,57 +241,30 @@ export const useChatStore = defineStore('chat', () => {
function stopGeneration() {
streamVersion++
- if (sseClient) {
- sseClient.cancel()
- sseClient = null
- }
+ if (sseClient) { sseClient.cancel(); sseClient = null }
isStreaming.value = false
}
- function createNewConversation() {
- stopGeneration()
- const newConv: Conversation = {
- conversation_id: crypto.randomUUID(),
- title: t('新对话', 'New conversation'),
- created_at: new Date().toISOString(),
- updated_at: new Date().toISOString(),
- message_count: 0,
- }
- conversations.value.unshift(newConv)
- activeConversationId.value = newConv.conversation_id
- history[newConv.conversation_id] = []
- messages.value = history[newConv.conversation_id]
- }
-
- function deleteConversation(id: string) {
+ async function deleteConversation(id: string) {
if (activeConversationId.value === id) stopGeneration()
- delete history[id]
- const idx = conversations.value.findIndex((c) => c.conversation_id === id)
- if (idx > -1) {
- conversations.value.splice(idx, 1)
+ historyError.value = ''
+ try {
+ if (pendingCreates.has(id)) await pendingCreates.get(id)
+ await removeConversation(id)
+ conversations.value = conversations.value.filter(item => item.conversation_id !== id)
if (activeConversationId.value === id) {
- activeConversationId.value = conversations.value[0]?.conversation_id || null
- messages.value = conversations.value[0] ? history[conversations.value[0].conversation_id] || [] : []
+ const next = sortedConversations.value[0]
+ if (next) await setActiveConversation(next.conversation_id)
+ else { activeConversationId.value = null; messages.value = [] }
}
+ } catch (error) {
+ historyError.value = error instanceof Error ? error.message : t('会话删除失败', 'Failed to delete conversation')
}
}
return {
- conversations,
- activeConversationId,
- activeConversation,
- sortedConversations,
- messages,
- isStreaming,
- inputText,
- useRag,
- selectedSkillId,
- selectedProviderId,
- selectedModel,
- setActiveConversation,
- sendMessage,
- stopGeneration,
- createNewConversation,
- deleteConversation,
+ conversations, activeConversationId, activeConversation, sortedConversations, messages,
+ isStreaming, inputText, useRag, selectedSkillId, selectedProviderId, selectedModel, historyError,
+ loadConversations, setActiveConversation, sendMessage, stopGeneration, createNewConversation, deleteConversation,
}
})