fix(chat): 持久化会话与消息
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> 本文件用于团队开发期间快速配置环境、启动项目并了解当前实现状态,不是正式的项目 README。
|
> 本文件用于团队开发期间快速配置环境、启动项目并了解当前实现状态,不是正式的项目 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 尚未接入。
|
截至 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
@@ -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/extensions` | Skill、Plugin Host、MCP Registry 与 stdio/HTTP/SSE Bridge |
|
||||||
| `app/providers` | OpenAI Chat/Compatible、Responses、Anthropic Messages、Ollama 与能力路由 |
|
| `app/providers` | OpenAI Chat/Compatible、Responses、Anthropic Messages、Ollama 与能力路由 |
|
||||||
| `app/local_models` | 模型目录、固定 revision 下载、独立进程、设备回退和队列调度 |
|
| `app/local_models` | 模型目录、固定 revision 下载、独立进程、设备回退和队列调度 |
|
||||||
| `app/services` | 索引、知识库上下文、转写、搜索历史、用量和诊断等应用服务 |
|
| `app/services` | 索引、知识库上下文、聊天记录、转写、搜索历史、用量和诊断等应用服务 |
|
||||||
| `app/benchmarks` | 版本化 RAG Dataset、异步评测、指标与报告 |
|
| `app/benchmarks` | 版本化 RAG Dataset、异步评测、指标与报告 |
|
||||||
|
|
||||||
## 模型路由
|
## 模型路由
|
||||||
@@ -82,7 +82,7 @@ API Key 可由前端设置页写入,也可通过 `OPENAI_API_KEY`、`DEEPSEEK_
|
|||||||
uv run pytest
|
uv run pytest
|
||||||
```
|
```
|
||||||
|
|
||||||
阶段 F 合并基线为 559 项测试通过,另有一条既有 Starlette/httpx 弃用提示。真实模型冒烟脚本:
|
当前基线为 562 项测试通过,另有一条既有 Starlette/httpx 弃用提示。真实模型冒烟脚本:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.venv/Scripts/python scripts/local-model-smoke.py bekko --download
|
.venv/Scripts/python scripts/local-model-smoke.py bekko --download
|
||||||
|
|||||||
@@ -255,11 +255,57 @@ class ModelRequest(Contract):
|
|||||||
|
|
||||||
|
|
||||||
class ChatRequest(ModelRequest):
|
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
|
use_rag: bool = True
|
||||||
retrieval: SearchRequest | None = None
|
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):
|
class ModelEventType(str, Enum):
|
||||||
citation = "Citation"
|
citation = "Citation"
|
||||||
text_delta = "TextDelta"
|
text_delta = "TextDelta"
|
||||||
|
|||||||
@@ -132,6 +132,33 @@ MIGRATIONS: list[str] = [
|
|||||||
"""
|
"""
|
||||||
ALTER TABLE blocks ADD COLUMN embedding_local_only INTEGER NOT NULL DEFAULT 0;
|
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
@@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from contextlib import aclosing
|
from contextlib import aclosing
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -15,6 +16,10 @@ from app.contracts import (
|
|||||||
AgentRunListResponse,
|
AgentRunListResponse,
|
||||||
AgentTraceResponse,
|
AgentTraceResponse,
|
||||||
ChatRequest,
|
ChatRequest,
|
||||||
|
ChatMessageListResponse,
|
||||||
|
Conversation,
|
||||||
|
ConversationCreateRequest,
|
||||||
|
ConversationListResponse,
|
||||||
BenchmarkDatasetListResponse,
|
BenchmarkDatasetListResponse,
|
||||||
BenchmarkEventType,
|
BenchmarkEventType,
|
||||||
BenchmarkKind,
|
BenchmarkKind,
|
||||||
@@ -321,6 +326,40 @@ async def clear_search_history() -> dict[str, list[str]]:
|
|||||||
return {"queries": []}
|
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(
|
@router.post(
|
||||||
"/chat",
|
"/chat",
|
||||||
response_class=StreamingResponse,
|
response_class=StreamingResponse,
|
||||||
@@ -333,14 +372,38 @@ async def clear_search_history() -> dict[str, list[str]]:
|
|||||||
tags=["Chat"],
|
tags=["Chat"],
|
||||||
)
|
)
|
||||||
async def chat(request: ChatRequest) -> StreamingResponse:
|
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)
|
provider = provider_or_404(request.provider_id)
|
||||||
|
|
||||||
async def stream() -> AsyncIterator[str]:
|
async def stream() -> AsyncIterator[str]:
|
||||||
sequence = 0
|
sequence = 0
|
||||||
|
assistant_content = ""
|
||||||
|
assistant_thinking = ""
|
||||||
|
citations: list[dict] = []
|
||||||
|
tool_calls: list[dict] = []
|
||||||
|
argument_buffers: dict[str, str] = {}
|
||||||
|
usage: dict | None = None
|
||||||
try:
|
try:
|
||||||
from app.services.chat_context import prepare
|
from app.services.chat_context import prepare
|
||||||
grounded_request, citations = await prepare(request)
|
grounded_request, grounded_citations = await prepare(request)
|
||||||
for citation in citations:
|
for citation in grounded_citations:
|
||||||
|
citations.append(citation)
|
||||||
event = ModelEvent(event=ModelEventType.citation, sequence=sequence,
|
event = ModelEvent(event=ModelEventType.citation, sequence=sequence,
|
||||||
data=citation, timestamp=utc_now())
|
data=citation, timestamp=utc_now())
|
||||||
sequence += 1
|
sequence += 1
|
||||||
@@ -349,13 +412,58 @@ async def chat(request: ChatRequest) -> StreamingResponse:
|
|||||||
async for event in events:
|
async for event in events:
|
||||||
event = event.model_copy(update={"sequence": sequence})
|
event = event.model_copy(update={"sequence": sequence})
|
||||||
sequence += 1
|
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())
|
yield as_sse(event.event.value, event.model_dump_json())
|
||||||
except Exception as exc:
|
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(
|
error = ModelEvent(
|
||||||
event=ModelEventType.error,
|
event=ModelEventType.error,
|
||||||
sequence=sequence,
|
sequence=sequence,
|
||||||
data={"code": exc.code if isinstance(exc, ApiError) else "CHAT_FAILED",
|
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(),
|
timestamp=utc_now(),
|
||||||
)
|
)
|
||||||
done = ModelEvent(
|
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(error.event.value, error.model_dump_json())
|
||||||
yield as_sse(done.event.value, done.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")
|
return StreamingResponse(stream(), media_type="text/event-stream")
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
| Extension | 扩展安装状态尚未持久化;MCP Host、进程隔离、签名与来源校验属于第二阶段 |
|
| Extension | 扩展安装状态尚未持久化;MCP Host、进程隔离、签名与来源校验属于第二阶段 |
|
||||||
| AI Core | 音频转写当前只读取文本或 Host 预生成旁路文本,后续接入本地 ASR 队列 |
|
| AI Core | 音频转写当前只读取文本或 Host 预生成旁路文本,后续接入本地 ASR 队列 |
|
||||||
| Desktop | Web Workspace 已连接 FastAPI 单 Vault;后续由 Tauri IPC 增加原生目录选择、多 Vault 和文件监听 |
|
| Desktop | Web Workspace 已连接 FastAPI 单 Vault;后续由 Tauri IPC 增加原生目录选择、多 Vault 和文件监听 |
|
||||||
| Editor / Chat | 待补文件冲突合并、受控链接对话框及会话持久化 |
|
| Editor / Chat | 待补文件冲突合并及受控链接对话框;会话持久化已接入后端 SQLite |
|
||||||
| Performance | Shiki 已复用单例,后续按首屏指标评估延迟加载或 Web Worker |
|
| Performance | Shiki 已复用单例,后续按首屏指标评估延迟加载或 Web Worker |
|
||||||
|
|
||||||
TODO 完成后应删除对应代码注释并同步更新本索引;若工作超过一个提交,应建立 Issue,并在 Issue 中引用代码位置,而不是在源码中记录长篇设计讨论。
|
TODO 完成后应删除对应代码注释并同步更新本索引;若工作超过一个提交,应建立 Issue,并在 Issue 中引用代码位置,而不是在源码中记录长篇设计讨论。
|
||||||
|
|||||||
+1
-1
@@ -46,7 +46,7 @@ pnpm dev
|
|||||||
## 数据边界
|
## 数据边界
|
||||||
|
|
||||||
- 笔记、附件、搜索历史、任务、Trace、模型配置和多模态结果都通过 FastAPI 读写。
|
- 笔记、附件、搜索历史、任务、Trace、模型配置和多模态结果都通过 FastAPI 读写。
|
||||||
- AI 对话生成和知识库检索通过 FastAPI;会话列表与消息当前只保留在页面内存,刷新后会清空,需在桌面集成前补充后端持久化接口。
|
- AI 对话生成和知识库检索通过 FastAPI;会话列表、用户消息、流式助手结果、引用和 Token 用量保存在后端 SQLite,刷新页面后可恢复。
|
||||||
- API Key 只存在于密码输入和提交请求期间,不进入 Pinia 或 `localStorage`。
|
- API Key 只存在于密码输入和提交请求期间,不进入 Pinia 或 `localStorage`。
|
||||||
- 页面内存可以保存尚未提交的临时状态;后端已经接收的任务和结果由 SQLite/Vault 持久化。
|
- 页面内存可以保存尚未提交的临时状态;后端已经接收的任务和结果由 SQLite/Vault 持久化。
|
||||||
- 主题、编辑器偏好、侧栏状态和最近 Vault 路径目前保存在浏览器 `localStorage`;它们是设备界面偏好,不作为笔记或模型业务数据。Tauri 集成时由桌面配置存储接管。
|
- 主题、编辑器偏好、侧栏状态和最近 Vault 路径目前保存在浏览器 `localStorage`;它们是设备界面偏好,不作为笔记或模型业务数据。Tauri 集成时由桌面配置存储接管。
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
|||||||
vi.mock('@/stores/editor', () => ({ useEditorStore: () => ({}) }))
|
vi.mock('@/stores/editor', () => ({ useEditorStore: () => ({}) }))
|
||||||
vi.mock('@/stores/workspace', () => ({ useWorkspaceStore: () => ({}) }))
|
vi.mock('@/stores/workspace', () => ({ useWorkspaceStore: () => ({}) }))
|
||||||
vi.mock('@/components/common/MarkdownContent.vue', () => ({ default: { template: '<div />' } }))
|
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(() => {
|
beforeEach(() => {
|
||||||
setActivePinia(createPinia())
|
setActivePinia(createPinia())
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const availableModels = computed(() => providerStore.modelsByProvider[chatStore.
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
await Promise.all([providerStore.loadProviders(), skillStore.loadSkills()])
|
await Promise.all([providerStore.loadProviders(), skillStore.loadSkills(), chatStore.loadConversations()])
|
||||||
if (disposed || providerStore.error) return
|
if (disposed || providerStore.error) return
|
||||||
const selected = providerStore.enabledProviders.find(p => p.provider_id === chatStore.selectedProviderId)
|
const selected = providerStore.enabledProviders.find(p => p.provider_id === chatStore.selectedProviderId)
|
||||||
if (!selected) {
|
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>
|
<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>
|
<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>
|
</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">
|
<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">
|
<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="avatar">{{ message.role === 'user' ? t('你', 'You') : 'AI' }}</div>
|
||||||
<div class="message-body">
|
<div class="message-body">
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue'
|
||||||
import { useChatStore } from '@/stores/chat'
|
import { useChatStore } from '@/stores/chat'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
const chatStore = useChatStore()
|
const chatStore = useChatStore()
|
||||||
|
onMounted(() => { void chatStore.loadConversations() })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { SseClient } from './sseClient'
|
import { SseClient } from './sseClient'
|
||||||
import type { ModelEvent } from '@/contracts'
|
import { apiClient } from './apiClient'
|
||||||
|
import type { ChatMessage, Conversation, ModelEvent, PageMeta } from '@/contracts'
|
||||||
|
|
||||||
export interface ChatRequest {
|
export interface ChatRequest {
|
||||||
provider_id: string
|
provider_id: string
|
||||||
model: string
|
model: string
|
||||||
conversation_id?: string
|
conversation_id?: string
|
||||||
|
user_message_id?: string
|
||||||
|
assistant_message_id?: string
|
||||||
|
conversation_title?: string
|
||||||
system?: string
|
system?: string
|
||||||
messages: Array<{
|
messages: Array<{
|
||||||
role: 'system' | 'user' | 'assistant' | 'tool'
|
role: 'system' | 'user' | 'assistant' | 'tool'
|
||||||
@@ -18,6 +22,25 @@ export interface ChatRequest {
|
|||||||
max_tokens?: number
|
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(
|
export function streamChat(
|
||||||
request: ChatRequest,
|
request: ChatRequest,
|
||||||
handlers: {
|
handlers: {
|
||||||
|
|||||||
@@ -1,36 +1,78 @@
|
|||||||
import { beforeEach, expect, it, vi } from 'vitest'
|
import { beforeEach, expect, it, vi } from 'vitest'
|
||||||
import { createPinia, setActivePinia } from 'pinia'
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
import { useChatStore } from './chat'
|
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'
|
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(() => {
|
beforeEach(() => {
|
||||||
setActivePinia(createPinia())
|
setActivePinia(createPinia())
|
||||||
vi.mocked(streamChat).mockReset().mockReturnValue({ cancel: vi.fn() } as unknown as SseClient)
|
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()
|
const store = useChatStore()
|
||||||
store.selectedProviderId = 'real'
|
store.selectedProviderId = 'real'
|
||||||
store.selectedModel = 'configured-model'
|
store.selectedModel = 'configured-model'
|
||||||
await store.sendMessage('user input')
|
await store.sendMessage('user input')
|
||||||
const [request, handlers] = vi.mocked(streamChat).mock.calls[0]!
|
const [request, handlers] = vi.mocked(streamChat).mock.calls[0]!
|
||||||
expect(request.use_rag).toBe(true)
|
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(request.user_message_id).toBe(store.messages[0]?.message_id)
|
||||||
expect(store.messages[1]?.citations?.[0]?.content).toBe('real evidence')
|
expect(request.assistant_message_id).toBe(store.messages[1]?.message_id)
|
||||||
expect(request.messages).toEqual([{ role: 'user', content: 'user input' }])
|
expect(request.messages).toEqual([{ role: 'user', content: 'user input' }])
|
||||||
handlers.onEvent?.({ event: 'TextDelta', sequence: 0, timestamp: '', data: { text: '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' } })
|
||||||
expect(store.messages[1]?.content).toBe('real response')
|
handlers.onEvent?.({ event: 'TextDelta', sequence: 1, timestamp: '', data: { text: 'real response' } })
|
||||||
handlers.onDone?.()
|
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!
|
const id = store.activeConversationId!
|
||||||
store.createNewConversation()
|
await store.createNewConversation()
|
||||||
expect(store.messages).toEqual([])
|
expect(store.messages).toEqual([])
|
||||||
await store.setActiveConversation(id)
|
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()
|
const store = useChatStore()
|
||||||
await store.sendMessage('no provider')
|
await store.sendMessage('no provider')
|
||||||
expect(streamChat).not.toHaveBeenCalled()
|
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'
|
store.selectedModel = 'configured-model'
|
||||||
await store.sendMessage('first')
|
await store.sendMessage('first')
|
||||||
const old = vi.mocked(streamChat).mock.calls[0]![1]
|
const old = vi.mocked(streamChat).mock.calls[0]![1]
|
||||||
store.createNewConversation()
|
await store.createNewConversation()
|
||||||
await store.sendMessage('second')
|
await store.sendMessage('second')
|
||||||
old.onDone?.()
|
old.onDone?.()
|
||||||
expect(store.isStreaming).toBe(true)
|
expect(store.isStreaming).toBe(true)
|
||||||
expect(store.messages[0]?.content).toBe('second')
|
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
@@ -1,7 +1,13 @@
|
|||||||
|
import { computed, reactive, ref } from 'vue'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed, reactive } from 'vue'
|
import type { ChatMessage, Citation, Conversation } from '@/contracts'
|
||||||
import type { ChatMessage, Conversation } from '@/contracts'
|
import {
|
||||||
import { streamChat } from '@/services/chatService'
|
createConversation as createConversationApi,
|
||||||
|
listConversationMessages,
|
||||||
|
listConversations as listConversationsApi,
|
||||||
|
removeConversation,
|
||||||
|
streamChat,
|
||||||
|
} from '@/services/chatService'
|
||||||
import type { SseClient } from '@/services/sseClient'
|
import type { SseClient } from '@/services/sseClient'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
@@ -15,68 +21,155 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
const selectedSkillId = ref<string | null>(null)
|
const selectedSkillId = ref<string | null>(null)
|
||||||
const selectedProviderId = ref('')
|
const selectedProviderId = ref('')
|
||||||
const selectedModel = 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 sseClient: SseClient | null = null
|
||||||
let streamVersion = 0
|
let streamVersion = 0
|
||||||
|
const pendingCreates = new Map<string, Promise<void>>()
|
||||||
// User-created conversations live in this browser session; no fabricated history.
|
|
||||||
const history = reactive<Record<string, ChatMessage[]>>({})
|
|
||||||
|
|
||||||
const activeConversation = computed(() =>
|
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(() =>
|
const sortedConversations = computed(() =>
|
||||||
[...conversations.value].sort((a, b) => b.updated_at.localeCompare(a.updated_at))
|
[...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) {
|
async function setActiveConversation(id: string) {
|
||||||
stopGeneration()
|
stopGeneration()
|
||||||
|
const version = ++loadVersion
|
||||||
activeConversationId.value = id
|
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) {
|
async function sendMessage(text: string) {
|
||||||
if (!text.trim() || isStreaming.value || !selectedProviderId.value || !selectedModel.value) return
|
const content = text.trim()
|
||||||
const conversationId = activeConversationId.value || crypto.randomUUID()
|
if (!content || isStreaming.value || !selectedProviderId.value || !selectedModel.value) return
|
||||||
|
historyError.value = ''
|
||||||
if (!activeConversationId.value) {
|
let conversation = activeConversation.value
|
||||||
const newConv: Conversation = {
|
if (!conversation) {
|
||||||
conversation_id: conversationId,
|
conversation = addLocalConversation(content.slice(0, 30))
|
||||||
title: text.slice(0, 30),
|
try { await persistConversation(conversation) } catch { return }
|
||||||
created_at: new Date().toISOString(),
|
} else if (pendingCreates.has(conversation.conversation_id)) {
|
||||||
updated_at: new Date().toISOString(),
|
try { await pendingCreates.get(conversation.conversation_id) } catch { return }
|
||||||
message_count: 0,
|
|
||||||
}
|
|
||||||
conversations.value.unshift(newConv)
|
|
||||||
activeConversationId.value = conversationId
|
|
||||||
}
|
}
|
||||||
|
|
||||||
history[conversationId] = messages.value
|
const conversationId = conversation.conversation_id
|
||||||
const conversationMessages = messages.value
|
if (conversation.message_count === 0) conversation.title = content.slice(0, 30)
|
||||||
const userMsg: ChatMessage = {
|
const userMsg: ChatMessage = {
|
||||||
message_id: crypto.randomUUID(),
|
message_id: crypto.randomUUID(), conversation_id: conversationId, role: 'user', content,
|
||||||
conversation_id: conversationId,
|
|
||||||
role: 'user',
|
|
||||||
content: text,
|
|
||||||
created_at: new Date().toISOString(),
|
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 = ''
|
inputText.value = ''
|
||||||
isStreaming.value = true
|
isStreaming.value = true
|
||||||
const conversation = conversations.value.find(c => c.conversation_id === conversationId)
|
conversation.updated_at = new Date().toISOString()
|
||||||
if (conversation) { conversation.updated_at = new Date().toISOString(); conversation.message_count = messages.value.length }
|
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)
|
|
||||||
|
|
||||||
const version = ++streamVersion
|
const version = ++streamVersion
|
||||||
const argumentBuffers = new Map<string, string>()
|
const argumentBuffers = new Map<string, string>()
|
||||||
@@ -84,10 +177,13 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
provider_id: selectedProviderId.value,
|
provider_id: selectedProviderId.value,
|
||||||
model: selectedModel.value,
|
model: selectedModel.value,
|
||||||
conversation_id: conversationId,
|
conversation_id: conversationId,
|
||||||
|
user_message_id: userMsg.message_id,
|
||||||
|
assistant_message_id: aiMsg.message_id,
|
||||||
|
conversation_title: conversation.title,
|
||||||
use_rag: useRag.value,
|
use_rag: useRag.value,
|
||||||
messages: messages.value
|
messages: messages.value
|
||||||
.filter((message) => message.message_id !== aiMsg.message_id)
|
.filter(message => message.message_id !== aiMsg.message_id)
|
||||||
.map((message) => ({ role: message.role, content: message.content })),
|
.map(message => ({ role: message.role, content: message.content })),
|
||||||
}, {
|
}, {
|
||||||
onEvent(event) {
|
onEvent(event) {
|
||||||
if (version !== streamVersion) return
|
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 === 'ThinkingDelta') aiMsg.thinking = `${aiMsg.thinking ?? ''}${String(event.data.text ?? '')}`
|
||||||
if (event.event === 'ToolCallStart') {
|
if (event.event === 'ToolCallStart') {
|
||||||
aiMsg.tool_calls?.push({
|
aiMsg.tool_calls?.push({
|
||||||
tool_call_id: String(event.data.tool_call_id ?? ''),
|
tool_call_id: String(event.data.tool_call_id ?? ''), name: String(event.data.name ?? 'unknown'),
|
||||||
name: String(event.data.name ?? 'unknown'),
|
parameters: (event.data.arguments ?? {}) as Record<string, unknown>, status: 'running',
|
||||||
parameters: (event.data.arguments ?? {}) as Record<string, unknown>,
|
|
||||||
status: 'running',
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (event.event === 'ToolCallDelta') {
|
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') {
|
if (call && typeof event.data.arguments_delta === 'string') {
|
||||||
const buffer = (argumentBuffers.get(call.tool_call_id) ?? '') + event.data.arguments_delta
|
const buffer = (argumentBuffers.get(call.tool_call_id) ?? '') + event.data.arguments_delta
|
||||||
argumentBuffers.set(call.tool_call_id, buffer)
|
argumentBuffers.set(call.tool_call_id, buffer)
|
||||||
try { call.parameters = JSON.parse(buffer) } catch { /* incomplete JSON fragment */ }
|
try { call.parameters = JSON.parse(buffer) } catch { /* incomplete JSON fragment */ }
|
||||||
}
|
}
|
||||||
if (call && event.data.arguments && typeof event.data.arguments === 'object') {
|
if (call && event.data.arguments && typeof event.data.arguments === 'object') Object.assign(call.parameters, event.data.arguments)
|
||||||
Object.assign(call.parameters, event.data.arguments)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (event.event === 'ToolCallEnd') {
|
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 (call) call.status = 'completed'
|
||||||
}
|
}
|
||||||
if (event.event === 'Usage') {
|
if (event.event === 'Usage') {
|
||||||
@@ -139,11 +231,8 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
},
|
},
|
||||||
onDone() {
|
onDone() {
|
||||||
if (version !== streamVersion) return
|
if (version !== streamVersion) return
|
||||||
const conversation = conversations.value.find((item) => item.conversation_id === conversationId)
|
conversation!.message_count = messages.value.length
|
||||||
if (conversation) {
|
conversation!.updated_at = new Date().toISOString()
|
||||||
conversation.message_count = conversationMessages.length
|
|
||||||
conversation.updated_at = new Date().toISOString()
|
|
||||||
}
|
|
||||||
isStreaming.value = false
|
isStreaming.value = false
|
||||||
sseClient = null
|
sseClient = null
|
||||||
},
|
},
|
||||||
@@ -152,57 +241,30 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
|
|
||||||
function stopGeneration() {
|
function stopGeneration() {
|
||||||
streamVersion++
|
streamVersion++
|
||||||
if (sseClient) {
|
if (sseClient) { sseClient.cancel(); sseClient = null }
|
||||||
sseClient.cancel()
|
|
||||||
sseClient = null
|
|
||||||
}
|
|
||||||
isStreaming.value = false
|
isStreaming.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function createNewConversation() {
|
async function deleteConversation(id: string) {
|
||||||
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) {
|
|
||||||
if (activeConversationId.value === id) stopGeneration()
|
if (activeConversationId.value === id) stopGeneration()
|
||||||
delete history[id]
|
historyError.value = ''
|
||||||
const idx = conversations.value.findIndex((c) => c.conversation_id === id)
|
try {
|
||||||
if (idx > -1) {
|
if (pendingCreates.has(id)) await pendingCreates.get(id)
|
||||||
conversations.value.splice(idx, 1)
|
await removeConversation(id)
|
||||||
|
conversations.value = conversations.value.filter(item => item.conversation_id !== id)
|
||||||
if (activeConversationId.value === id) {
|
if (activeConversationId.value === id) {
|
||||||
activeConversationId.value = conversations.value[0]?.conversation_id || null
|
const next = sortedConversations.value[0]
|
||||||
messages.value = conversations.value[0] ? history[conversations.value[0].conversation_id] || [] : []
|
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 {
|
return {
|
||||||
conversations,
|
conversations, activeConversationId, activeConversation, sortedConversations, messages,
|
||||||
activeConversationId,
|
isStreaming, inputText, useRag, selectedSkillId, selectedProviderId, selectedModel, historyError,
|
||||||
activeConversation,
|
loadConversations, setActiveConversation, sendMessage, stopGeneration, createNewConversation, deleteConversation,
|
||||||
sortedConversations,
|
|
||||||
messages,
|
|
||||||
isStreaming,
|
|
||||||
inputText,
|
|
||||||
useRag,
|
|
||||||
selectedSkillId,
|
|
||||||
selectedProviderId,
|
|
||||||
selectedModel,
|
|
||||||
setActiveConversation,
|
|
||||||
sendMessage,
|
|
||||||
stopGeneration,
|
|
||||||
createNewConversation,
|
|
||||||
deleteConversation,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user