fix(chat): 持久化会话与消息

This commit is contained in:
2026-09-05 10:12:09 +08:00
parent d15ceafbe0
commit feb8cc651f
15 changed files with 726 additions and 125 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
> 本文件用于团队开发期间快速配置环境、启动项目并了解当前实现状态,不是正式的项目 README。
NotesAgent 是本地优先的 AI 笔记与知识库项目。当前可运行形态为 Vue/Vite Web 前端与 FastAPI AI CoreMarkdown 和附件保存在本地 Vault,SQLite 管理元数据、全文索引、向量空间、搜索历史、任务、Agent Trace、多模态任务及运行诊断。AI 对话已接入知识库检索,会话列表与消息持久化接口尚未实现
NotesAgent 是本地优先的 AI 笔记与知识库项目。当前可运行形态为 Vue/Vite Web 前端与 FastAPI AI CoreMarkdown 和附件保存在本地 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 尚未接入。
+2 -2
View File
@@ -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
+47 -1
View File
@@ -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"
+27
View File
@@ -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);
""",
]
+123 -3
View File
@@ -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")
+183
View File
@@ -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),
)
+82
View File
@@ -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"
+1 -1
View File
@@ -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 中引用代码位置,而不是在源码中记录长篇设计讨论。
+1 -1
View File
@@ -46,7 +46,7 @@ pnpm dev
## 数据边界
- 笔记、附件、搜索历史、任务、Trace、模型配置和多模态结果都通过 FastAPI 读写。
- AI 对话生成和知识库检索通过 FastAPI;会话列表与消息当前只保留在页面内存,刷新后会清空,需在桌面集成前补充后端持久化接口
- AI 对话生成和知识库检索通过 FastAPI;会话列表、用户消息、流式助手结果、引用和 Token 用量保存在后端 SQLite,刷新页面后可恢复
- API Key 只存在于密码输入和提交请求期间,不进入 Pinia 或 `localStorage`
- 页面内存可以保存尚未提交的临时状态;后端已经接收的任务和结果由 SQLite/Vault 持久化。
- 主题、编辑器偏好、侧栏状态和最近 Vault 路径目前保存在浏览器 `localStorage`;它们是设备界面偏好,不作为笔记或模型业务数据。Tauri 集成时由桌面配置存储接管。
@@ -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: '<div />' } }))
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())
+3 -3
View File
@@ -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) {
<label class="rag-toggle"><input v-model="chatStore.useRag" type="checkbox" :disabled="chatStore.isStreaming" />{{ t('检索知识库', 'Search knowledge base') }}</label>
<span class="subtle">{{ t('开启后,将相关笔记片段发送给所选模型,并显示来源。技能调用请使用智能体。', 'When enabled, relevant note excerpts are sent to the selected model and citations are shown. Use Agent for skills.') }}</span>
</header>
<div v-if="loadError || providerStore.error" class="error-banner chat-error">{{ loadError || providerStore.error }}</div>
<div v-if="loadError || providerStore.error || chatStore.historyError" class="error-banner chat-error">{{ loadError || providerStore.error || chatStore.historyError }}</div>
<main class="message-timeline">
<div v-if="!chatStore.messages.length" class="empty-state"><div><strong>{{ t('开始一段知识对话', 'Start a knowledge conversation') }}</strong><p>{{ t('请先配置模型提供商。聊天记录仅保留在本次页面会话中。', 'Configure a model provider first. Messages are kept only for this page session.') }}</p></div></div>
<div v-if="!chatStore.messages.length" class="empty-state"><div><strong>{{ t('开始一段知识对话', 'Start a knowledge conversation') }}</strong><p>{{ t('请先配置模型提供商。聊天记录保存在本地数据库中。', 'Configure a model provider first. Messages are saved in the local database.') }}</p></div></div>
<article v-for="message in chatStore.messages" :key="message.message_id" class="message" :class="message.role">
<div class="avatar">{{ message.role === 'user' ? t('你', 'You') : 'AI' }}</div>
<div class="message-body">
@@ -1,8 +1,10 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useChatStore } from '@/stores/chat'
import { t } from '@/i18n'
const chatStore = useChatStore()
onMounted(() => { void chatStore.loadConversations() })
</script>
<template>
+24 -1
View File
@@ -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<Conversation, 'conversation_id' | 'title'>) {
return apiClient.post<Conversation>('/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: {
+63 -11
View File
@@ -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')
})
+163 -101
View File
@@ -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<string | null>(null)
const selectedProviderId = ref('')
const selectedModel = ref('')
const historyError = ref('')
let initialized = false
let loading: Promise<void> | 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<Record<string, ChatMessage[]>>({})
const pendingCreates = new Map<string, Promise<void>>()
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<ChatMessage>({
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<ChatMessage>({
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<string, string>()
@@ -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<string, unknown>,
status: 'running',
tool_call_id: String(event.data.tool_call_id ?? ''), name: String(event.data.name ?? 'unknown'),
parameters: (event.data.arguments ?? {}) as Record<string, unknown>, 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,
}
})