Compare commits

...

10 Commits

Author SHA1 Message Date
gqt 49e8d5da2a 1 2026-07-13 16:58:27 +08:00
gqt 01b5a29b62 feat: require explicit runtime configuration 2026-07-13 14:49:56 +08:00
gqt 2361d62867 test: verify real kbqa workflow 2026-07-13 14:26:30 +08:00
gqt 52184aa00e fix: honor dashscope embedding batch limit 2026-07-13 12:16:09 +08:00
gqt 6e3f4b8d04 feat: add streaming chat and evidence workspace 2026-07-13 12:00:01 +08:00
gqt 52e14986d4 feat: add document management workspace 2026-07-13 11:54:54 +08:00
gqt 2c220a0b5f feat: add responsive react workbench 2026-07-13 11:50:32 +08:00
gqt 84e6f1f51a feat: stream dual-agent answers over sse 2026-07-13 11:43:23 +08:00
gqt 321cf13c16 feat: add langchain dual-agent workflow 2026-07-13 11:39:51 +08:00
gqt 5a18c24d62 feat: add persistent chat sessions 2026-07-13 11:37:44 +08:00
87 changed files with 8007 additions and 40 deletions
+43 -1
View File
@@ -1,21 +1,63 @@
# 所有变量均为必填项;请在项目根目录复制本文件为 .env 后逐项填写。
# ===== 应用 =====
# 服务显示名称;仅用于 FastAPI 文档标题。
KBQA_APP_NAME=KBQA
# 是否启用调试模式。生产或日常本地使用请保持 false。
KBQA_DEBUG=false
# ===== DashScope 模型 =====
# DashScope 兼容模式 API Key。必须保密,禁止提交到 Git。
KBQA_DASHSCOPE_API_KEY=
# DashScope OpenAI-compatible API 地址;使用专属实例时填写实例地址。
KBQA_DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
# 生成回答及 Agent 工具调用的聊天模型。
KBQA_CHAT_MODEL=qwen3.5-flash
# 文档分块向量化模型。
KBQA_EMBEDDING_MODEL=text-embedding-v4
# Embedding 向量维度,必须与已有 Milvus Lite 数据库一致。
KBQA_EMBEDDING_DIM=1024
# ===== 本地持久化 =====
# SQLite 异步连接串;相对路径以“项目根目录”为基准。
KBQA_DATABASE_URL=sqlite+aiosqlite:///./data/kbqa.sqlite3
# Milvus Lite 文件路径;相对路径以“项目根目录”为基准。
KBQA_MILVUS_URI=./data/milvus/kbqa.db
# 上传原文件副本目录;相对路径以“项目根目录”为基准。
KBQA_RAW_DATA_DIR=./data/raw
# ===== 文档切分与检索 =====
# 单个文本分块的最大字符数。
KBQA_CHUNK_SIZE=500
# 相邻分块重叠字符数,必须小于 KBQA_CHUNK_SIZE。
KBQA_CHUNK_OVERLAP=50
# 每次向量检索返回的候选数量。
KBQA_RETRIEVAL_TOP_K=5
# Research Agent 一轮中允许的最大检索次数。
KBQA_MAX_RESEARCH_SEARCHES=3
# 回答中最多展示的引用来源数。
KBQA_MAX_SOURCES=8
# 每个会话发送给模型的最大历史消息数。
KBQA_HISTORY_MESSAGE_LIMIT=20
# 单个上传文件大小上限,单位 MiB。
KBQA_MAX_UPLOAD_MIB=20
# 单次提问的最大字符数。
KBQA_MAX_QUERY_LENGTH=10000
# 允许前端跨域访问的地址,必须是合法 JSON 数组。
KBQA_CORS_ORIGINS=["http://localhost:5173","http://127.0.0.1:5173"]
# ===== 真实模型测试 =====
# 仅设为 1 时才会执行真实 Chat / Embedding 测试。
KBQA_RUN_LIVE_TESTS=0
KBQA_LIVE_TEST_FILE=
# 真实测试使用的知识文件路径;相对路径以项目根目录为基准。
KBQA_LIVE_TEST_FILE=./test.txt
# ===== LangSmith 追踪 =====
# 是否向 LangSmith 写入 Agent、工具与模型追踪。
LANGSMITH_TRACING=true
# LangSmith 服务地址;官方服务通常为 https://api.smith.langchain.com。
LANGSMITH_ENDPOINT=https://api.smith.langchain.com
# LangSmith API Key。必须保密,禁止提交到 Git。
LANGSMITH_API_KEY=
# LangSmith 项目名称,用于归类本系统的追踪记录。
LANGSMITH_PROJECT=kbqa-local
+2
View File
@@ -28,3 +28,5 @@ coverage/
.DS_Store
.idea/
.vscode/
*.txt
+18 -4
View File
@@ -12,22 +12,36 @@
## 本地配置
复制 `.env.example``.env` 并填写本地密钥。`.env``1.md``test.txt`
均被 Git 忽略。LangSmith 会接收 Agent 输入、工具结果和运行轨迹。
`.env` 是唯一运行配置来源,所有变量均为必填项;程序不提供运行参数默认值。复制
`.env.example``.env`,阅读每项中文注释并填写真实值。必须从**项目根目录**执行下列
命令,否则相对路径和 `.env` 文件位置会改变。`.env``1.md``test.txt` 均被 Git 忽略。
LangSmith 会接收 Agent 输入、工具结果和运行轨迹。
首次运行前建立本地数据目录:
```bash
mkdir -p data/raw data/milvus
```
## 后端
```bash
cd "$(git rev-parse --show-toplevel)"
uv sync --project backend --all-groups
mkdir -p data/raw data/milvus
uv run --project backend alembic -c backend/alembic.ini upgrade head
uv run --project backend uvicorn kbqa.main:app --app-dir backend/src --port 8000
uv run --project backend uvicorn kbqa.main:app --app-dir backend/src --host 127.0.0.1 --port 8000
```
`--port` 必须与 `web/vite.config.ts``/api` 代理目标的端口一致。仓库默认示例为
`8000`;若你本地将代理改为 `8003`,后端也必须使用 `--port 8003`
## 前端
```bash
cd "$(git rev-parse --show-toplevel)"
npm --prefix web install
npm --prefix web run dev
npm --prefix web run dev -- --host 127.0.0.1
```
## 普通验证
+1
View File
@@ -0,0 +1 @@
"""LangChain 1.x dual-agent workflow."""
+17
View File
@@ -0,0 +1,17 @@
from dataclasses import dataclass
from kbqa.rag.retriever import KnowledgeRetriever
@dataclass(frozen=True, slots=True)
class ResearchContext:
retriever: KnowledgeRetriever
request_id: str
@dataclass(frozen=True, slots=True)
class ChatRunContext:
retriever: KnowledgeRetriever
request_id: str
session_id: str
assistant_message_id: str
+52
View File
@@ -0,0 +1,52 @@
from dataclasses import dataclass
from typing import Any
from langchain.agents import create_agent
from langchain.agents.middleware import ModelCallLimitMiddleware, ToolCallLimitMiddleware
from kbqa.agents.context import ChatRunContext, ResearchContext
from kbqa.agents.llm import create_chat_model
from kbqa.agents.middleware import RequireResearchMiddleware
from kbqa.agents.prompts import MAIN_PROMPT, RESEARCH_PROMPT
from kbqa.agents.tools import create_research_tool, search_knowledge_base
from kbqa.config import Settings
@dataclass(frozen=True, slots=True)
class AgentBundle:
main_agent: Any
research_agent: Any
def create_agent_bundle(settings: Settings) -> AgentBundle:
model = create_chat_model(settings)
research_agent = create_agent(
model=model,
tools=[search_knowledge_base],
system_prompt=RESEARCH_PROMPT,
context_schema=ResearchContext,
middleware=[
RequireResearchMiddleware(tool_name="search_knowledge_base"),
ModelCallLimitMiddleware(run_limit=4, exit_behavior="error"),
ToolCallLimitMiddleware(
tool_name="search_knowledge_base",
run_limit=settings.max_research_searches,
exit_behavior="continue",
),
],
name="research-agent",
)
research_tool = create_research_tool(research_agent)
main_agent = create_agent(
model=model,
tools=[research_tool],
system_prompt=MAIN_PROMPT,
context_schema=ChatRunContext,
middleware=[
RequireResearchMiddleware(),
ModelCallLimitMiddleware(run_limit=3, exit_behavior="error"),
ToolCallLimitMiddleware(tool_name="research", run_limit=1, exit_behavior="error"),
],
name="main-agent",
)
return AgentBundle(main_agent=main_agent, research_agent=research_agent)
+17
View File
@@ -0,0 +1,17 @@
from langchain_openai import ChatOpenAI
from kbqa.config import Settings, validate_live_settings
def create_chat_model(settings: Settings) -> ChatOpenAI:
validate_live_settings(settings)
return ChatOpenAI(
api_key=settings.dashscope_api_key,
base_url=settings.dashscope_base_url,
model=settings.chat_model,
extra_body={"enable_thinking": False},
temperature=0.2,
streaming=True,
timeout=120.0,
max_retries=2,
)
+27
View File
@@ -0,0 +1,27 @@
from collections.abc import Awaitable, Callable
from langchain.agents.middleware import AgentMiddleware, ModelRequest, ModelResponse
from langchain.messages import HumanMessage, ToolMessage
class RequireResearchMiddleware(AgentMiddleware):
def __init__(self, tool_name: str = "research") -> None:
self.tool_name = tool_name
@staticmethod
def _has_current_tool(messages: list, tool_name: str) -> bool:
for message in reversed(messages):
if isinstance(message, ToolMessage) and message.name == tool_name:
return True
if isinstance(message, HumanMessage):
return False
return False
async def awrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
) -> ModelResponse:
has_tool_result = self._has_current_tool(request.messages, self.tool_name)
tool_choice = "none" if has_tool_result else "required"
return await handler(request.override(tool_choice=tool_choice))
+18
View File
@@ -0,0 +1,18 @@
RESEARCH_PROMPT = """你是知识库研究员,只能使用 search_knowledge_base 工具返回的资料。
规则:
1. 根据问题设计检索词;首次证据不足时可改写检索词,最多检索三次。
2. 不得使用模型自身知识补充事实,不得伪造文档或引用。
3. 汇总检索资料中的一致结论、差异和缺口;你不直接面向最终用户回答。
4. 没有可靠资料时明确写出“未检索到足够证据”。
"""
MAIN_PROMPT = """你是严格知识库问答助手。你必须先调用 research 工具,再回答用户。
规则:
1. 只能依据 research 返回的编号证据回答,不使用模型自身知识补充事实。
2. 每个知识性结论后使用 [S1]、[S2] 形式标注真实来源。
3. 只允许使用 research 明确提供的来源标签;不得编造标签。
4. 回答清晰、直接,区分确定事实与资料中的限制。
5. 如果资料不足,明确说明知识库中没有足够资料。
"""
+116
View File
@@ -0,0 +1,116 @@
import json
from collections.abc import Sequence
from typing import Any
from langchain.messages import ToolMessage
from langchain.tools import ToolRuntime, tool
from langgraph.config import get_stream_writer
from kbqa.agents.context import ChatRunContext, ResearchContext
from kbqa.rag.types import SourceCandidate
def serialize_source(source: SourceCandidate) -> dict[str, Any]:
return {
"chunk_id": source.chunk_id,
"document_id": source.document_id,
"filename": source.filename,
"order_index": source.order_index,
"page_number": source.page_number,
"content": source.content,
"score": source.score,
}
@tool(response_format="content_and_artifact")
async def search_knowledge_base(
query: str,
runtime: ToolRuntime[ResearchContext],
) -> tuple[str, list[dict[str, Any]]]:
"""搜索本地知识库并返回与问题相关的文档片段。
Args:
query: 简洁、独立、适合语义检索的查询语句。
"""
sources = await runtime.context.retriever.retrieve(query, top_k=5)
if not sources:
return "未检索到相关知识库片段。", []
artifact = [serialize_source(source) for source in sources]
content = "\n\n".join(
f"检索片段 {index + 1}{source.filename},第 {source.order_index + 1} 段):\n"
f"{source.content}"
for index, source in enumerate(sources)
)
return content, artifact
def _message_text(content: Any) -> str:
if isinstance(content, str):
return content
if isinstance(content, Sequence):
texts: list[str] = []
for block in content:
if isinstance(block, str):
texts.append(block)
elif isinstance(block, dict) and block.get("type") == "text":
texts.append(str(block.get("text", "")))
return "".join(texts)
return str(content)
def create_research_tool(research_agent):
@tool(response_format="content_and_artifact")
async def research(
question: str,
runtime: ToolRuntime[ChatRunContext],
) -> tuple[str, list[dict[str, Any]]]:
"""调用研究 Agent 检索知识库并整理可引用证据。
Args:
question: 结合会话语境改写后的独立知识问题。
"""
result = await research_agent.ainvoke(
{"messages": [{"role": "user", "content": question}]},
context=ResearchContext(
retriever=runtime.context.retriever,
request_id=runtime.context.request_id,
),
config={
"recursion_limit": 24,
"tags": ["research-agent"],
"metadata": {"request_id": runtime.context.request_id},
},
)
by_chunk: dict[str, dict[str, Any]] = {}
for message in result["messages"]:
if not isinstance(message, ToolMessage) or message.name != "search_knowledge_base":
continue
for source in message.artifact or []:
chunk_id = str(source["chunk_id"])
current = by_chunk.get(chunk_id)
if current is None or float(source["score"]) > float(current["score"]):
by_chunk[chunk_id] = dict(source)
sources = sorted(by_chunk.values(), key=lambda item: float(item["score"]), reverse=True)[:8]
writer = get_stream_writer()
if not sources:
writer({"kind": "no_evidence"})
return "知识库中没有足够资料。", []
labeled = []
evidence_blocks: list[str] = []
for index, source in enumerate(sources, start=1):
labeled_source = {**source, "label": f"S{index}"}
labeled.append(labeled_source)
evidence_blocks.append(
f"[S{index}] {source['filename']}{int(source['order_index']) + 1}\n"
f"{source['content']}"
)
writer({"kind": "sources", "sources": labeled})
summary = _message_text(result["messages"][-1].content)
tool_content = f"研究员摘要:\n{summary}\n\n可引用证据:\n" + "\n\n".join(evidence_blocks)
return tool_content, labeled
return research
def artifact_json(artifact: list[dict[str, Any]]) -> str:
return json.dumps(artifact, ensure_ascii=False, separators=(",", ":"))
+3
View File
@@ -1,6 +1,9 @@
from fastapi import APIRouter
from kbqa.chat.routes import chat_router, router as sessions_router
from kbqa.documents.routes import router as documents_router
api_router = APIRouter(prefix="/api/v1")
api_router.include_router(documents_router)
api_router.include_router(sessions_router)
api_router.include_router(chat_router)
+35 -6
View File
@@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from kbqa.chat.models import ChatSession, Message, MessageSource
from kbqa.database import utc_now
from kbqa.documents.models import Chunk, Document
@dataclass(frozen=True, slots=True)
@@ -16,6 +17,19 @@ class MessageSourceInput:
score: float
@dataclass(frozen=True, slots=True)
class MessageSourceView:
message_id: str
document_id: str
chunk_id: str
citation_label: str
score: float
filename: str
order_index: int
page_number: int | None
content: str
class ChatRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
@@ -67,17 +81,32 @@ class ChatRepository:
)
return list(rows), int(total or 0)
async def sources_for_messages(self, message_ids: list[str]) -> dict[str, list[MessageSource]]:
async def sources_for_messages(
self, message_ids: list[str]
) -> dict[str, list[MessageSourceView]]:
if not message_ids:
return {}
sources = await self.session.scalars(
select(MessageSource)
rows = await self.session.execute(
select(MessageSource, Chunk, Document)
.join(Chunk, Chunk.id == MessageSource.chunk_id)
.join(Document, Document.id == MessageSource.document_id)
.where(MessageSource.message_id.in_(message_ids))
.order_by(MessageSource.citation_label)
)
result: dict[str, list[MessageSource]] = {}
for source in sources:
result.setdefault(source.message_id, []).append(source)
result: dict[str, list[MessageSourceView]] = {}
for source, chunk, document in rows:
view = MessageSourceView(
message_id=source.message_id,
document_id=source.document_id,
chunk_id=source.chunk_id,
citation_label=source.citation_label,
score=source.score,
filename=document.filename,
order_index=chunk.order_index,
page_number=chunk.page_number,
content=chunk.content,
)
result.setdefault(source.message_id, []).append(view)
return result
async def begin_turn(self, session_id: str, query: str) -> tuple[Message, Message]:
+120
View File
@@ -0,0 +1,120 @@
from typing import Annotated
from fastapi import APIRouter, Depends, Query, Request, Response, status
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from kbqa.api.errors import AppError
from kbqa.chat.repository import ChatRepository
from kbqa.chat.schemas import (
MessagePage,
MessageResponse,
MessageSourceResponse,
ChatStreamRequest,
SessionCreate,
SessionPage,
SessionResponse,
)
from kbqa.database import get_session
router = APIRouter(prefix="/sessions", tags=["sessions"])
chat_router = APIRouter(prefix="/chat", tags=["chat"])
SessionDependency = Annotated[AsyncSession, Depends(get_session)]
@router.post("", response_model=SessionResponse, status_code=status.HTTP_201_CREATED)
async def create_session(payload: SessionCreate, session: SessionDependency) -> SessionResponse:
created = await ChatRepository(session).create_session(payload.title)
return SessionResponse.model_validate(created)
@router.get("", response_model=SessionPage)
async def list_sessions(
session: SessionDependency,
offset: Annotated[int, Query(ge=0)] = 0,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
) -> SessionPage:
items, total = await ChatRepository(session).page_sessions(offset, limit)
return SessionPage(
items=[SessionResponse.model_validate(item) for item in items],
total=total,
offset=offset,
limit=limit,
)
@router.get("/{session_id}/messages", response_model=MessagePage)
async def list_messages(
session_id: str,
session: SessionDependency,
offset: Annotated[int, Query(ge=0)] = 0,
limit: Annotated[int, Query(ge=1, le=100)] = 50,
) -> MessagePage:
repository = ChatRepository(session)
if await repository.get_session(session_id) is None:
raise AppError("SESSION_NOT_FOUND", "会话不存在", 404)
messages, total = await repository.page_messages(session_id, offset, limit)
sources_by_message = await repository.sources_for_messages([message.id for message in messages])
responses: list[MessageResponse] = []
for message in messages:
sources = sources_by_message.get(message.id, [])
source_labels = {source.citation_label for source in sources}
deleted = sorted(repository.citation_labels(message.content) - source_labels)
responses.append(
MessageResponse(
id=message.id,
session_id=message.session_id,
role=message.role,
content=message.content,
status=message.status,
error_code=message.error_code,
order_index=message.order_index,
created_at=message.created_at,
sources=[
MessageSourceResponse(
label=source.citation_label,
document_id=source.document_id,
chunk_id=source.chunk_id,
filename=source.filename,
order_index=source.order_index,
page_number=source.page_number,
content=source.content,
score=source.score,
)
for source in sources
],
deleted_source_labels=deleted,
)
)
return MessagePage(items=responses, total=total, offset=offset, limit=limit)
@router.delete("/{session_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_session(session_id: str, session: SessionDependency) -> Response:
if not await ChatRepository(session).delete_session(session_id):
raise AppError("SESSION_NOT_FOUND", "会话不存在", 404)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@chat_router.post("/stream")
async def stream_chat(
payload: ChatStreamRequest,
request: Request,
session: SessionDependency,
) -> StreamingResponse:
if await ChatRepository(session).get_session(payload.session_id) is None:
raise AppError("SESSION_NOT_FOUND", "会话不存在", 404)
query = payload.query.strip()
if not query:
raise AppError("VALIDATION_ERROR", "问题不能为空", 422)
service = request.app.state.chat_service
service.reserve(payload.session_id)
return StreamingResponse(
service.stream(payload.session_id, query, request.state.request_id),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"X-Request-ID": request.state.request_id,
},
)
+51
View File
@@ -0,0 +1,51 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from kbqa.documents.schemas import Page
class SessionCreate(BaseModel):
title: str | None = Field(default=None, max_length=200)
class ChatStreamRequest(BaseModel):
session_id: str
query: str = Field(min_length=1, max_length=10_000)
class SessionResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
title: str | None
created_at: datetime
updated_at: datetime
class MessageSourceResponse(BaseModel):
label: str
document_id: str
chunk_id: str
filename: str
order_index: int
page_number: int | None
content: str
score: float
class MessageResponse(BaseModel):
id: str
session_id: str
role: str
content: str
status: str
error_code: str | None
order_index: int
created_at: datetime
sources: list[MessageSourceResponse] = Field(default_factory=list)
deleted_source_labels: list[str] = Field(default_factory=list)
SessionPage = Page[SessionResponse]
MessagePage = Page[MessageResponse]
+220
View File
@@ -0,0 +1,220 @@
import asyncio
import logging
import re
from collections.abc import AsyncIterator, Sequence
from contextlib import suppress
from typing import Any
from langchain.messages import AIMessage, AIMessageChunk, HumanMessage
from sqlalchemy.ext.asyncio import async_sessionmaker
from kbqa.agents.context import ChatRunContext
from kbqa.agents.factory import AgentBundle, create_agent_bundle
from kbqa.api.errors import AppError
from kbqa.chat.repository import ChatRepository, MessageSourceInput
from kbqa.chat.sse import encode_event, encode_ping
from kbqa.config import Settings
from kbqa.rag.retriever import KnowledgeRetriever
INSUFFICIENT_ANSWER = "知识库中没有足够资料回答这个问题。"
logger = logging.getLogger(__name__)
class ChatService:
def __init__(
self,
settings: Settings,
session_factory: async_sessionmaker,
retriever: KnowledgeRetriever,
) -> None:
self.settings = settings
self.session_factory = session_factory
self.retriever = retriever
self._active_sessions: set[str] = set()
self._agents: AgentBundle | None = None
def reserve(self, session_id: str) -> None:
if session_id in self._active_sessions:
raise AppError("SESSION_BUSY", "当前会话正在生成回答", 409)
self._active_sessions.add(session_id)
def _get_agents(self) -> AgentBundle:
if self._agents is None:
self._agents = create_agent_bundle(self.settings)
return self._agents
async def stream(
self,
session_id: str,
query: str,
request_id: str,
) -> AsyncIterator[bytes]:
assistant_id: str | None = None
graph_stream = None
try:
async with self.session_factory() as session:
repository = ChatRepository(session)
if await repository.get_session(session_id) is None:
yield encode_event(
"error",
{
"code": "SESSION_NOT_FOUND",
"message": "会话不存在",
"retryable": False,
},
)
return
_, assistant = await repository.begin_turn(session_id, query)
assistant_id = assistant.id
history = await repository.load_recent_messages(
session_id, self.settings.history_message_limit
)
messages = [
HumanMessage(content=message.content)
if message.role == "user"
else AIMessage(content=message.content)
for message in history
if message.id != assistant_id
]
sources: list[dict[str, Any]] = []
answer_parts: list[str] = []
answering = False
no_evidence = False
yield encode_event("status", {"phase": "researching"})
graph_stream = self._get_agents().main_agent.astream(
{"messages": messages},
context=ChatRunContext(
retriever=self.retriever,
request_id=request_id,
session_id=session_id,
assistant_message_id=assistant_id,
),
config={
"recursion_limit": 24,
"tags": ["main-agent"],
"metadata": {
"request_id": request_id,
"session_id": session_id,
"assistant_message_id": assistant_id,
},
},
stream_mode=["messages", "custom", "updates"],
version="v2",
)
iterator = graph_stream.__aiter__()
pending = asyncio.create_task(anext(iterator))
while True:
done, _ = await asyncio.wait({pending}, timeout=15.0)
if not done:
yield encode_ping()
continue
try:
chunk = pending.result()
except StopAsyncIteration:
break
pending = asyncio.create_task(anext(iterator))
chunk_type = chunk.get("type")
data = chunk.get("data")
if chunk_type == "custom" and isinstance(data, dict):
if data.get("kind") == "no_evidence":
no_evidence = True
pending.cancel()
with suppress(asyncio.CancelledError):
await pending
await graph_stream.aclose()
graph_stream = None
break
if data.get("kind") == "sources":
sources = list(data.get("sources", []))
yield encode_event("sources", {"sources": sources})
yield encode_event("status", {"phase": "answering"})
answering = True
continue
if chunk_type != "messages" or not answering:
continue
token, _metadata = data
if not isinstance(token, AIMessageChunk):
continue
text = self._message_text(token.content)
if text:
answer_parts.append(text)
yield encode_event("token", {"content": text})
if no_evidence:
answer = INSUFFICIENT_ANSWER
yield encode_event("status", {"phase": "answering"})
yield encode_event("token", {"content": answer})
else:
answer = "".join(answer_parts).strip()
self._validate_citations(answer, sources)
source_inputs = [
MessageSourceInput(
document_id=str(source["document_id"]),
chunk_id=str(source["chunk_id"]),
citation_label=str(source["label"]),
score=float(source["score"]),
)
for source in sources
]
async with self.session_factory() as session:
await ChatRepository(session).complete_answer(assistant_id, answer, source_inputs)
yield encode_event("done", {"message_id": assistant_id})
except asyncio.CancelledError:
if assistant_id is not None:
await self._mark_failed(assistant_id, "CLIENT_DISCONNECTED")
raise
except AppError as exc:
if assistant_id is not None:
await self._mark_failed(assistant_id, exc.code)
yield encode_event(
"error",
{"code": exc.code, "message": exc.message, "retryable": exc.retryable},
)
except Exception:
logger.exception("Chat stream failed request_id=%s", request_id)
if assistant_id is not None:
await self._mark_failed(assistant_id, "MODEL_UPSTREAM_ERROR")
yield encode_event(
"error",
{
"code": "MODEL_UPSTREAM_ERROR",
"message": "模型调用失败,请稍后重试",
"retryable": True,
},
)
finally:
if graph_stream is not None:
await graph_stream.aclose()
self._active_sessions.discard(session_id)
async def _mark_failed(self, message_id: str, error_code: str) -> None:
async with self.session_factory() as session:
await ChatRepository(session).fail_answer(message_id, error_code)
@staticmethod
def _message_text(content: Any) -> str:
if isinstance(content, str):
return content
if isinstance(content, Sequence):
parts: list[str] = []
for block in content:
if isinstance(block, str):
parts.append(block)
elif isinstance(block, dict) and block.get("type") == "text":
parts.append(str(block.get("text", "")))
return "".join(parts)
return ""
@staticmethod
def _validate_citations(answer: str, sources: list[dict[str, Any]]) -> None:
if not answer:
raise AppError("EMPTY_MODEL_ANSWER", "模型没有返回有效回答", 502, retryable=True)
allowed = {str(source["label"]) for source in sources}
used = set(re.findall(r"\[(S\d+)]", answer))
if sources and not used:
raise AppError("MISSING_CITATION", "模型回答缺少知识库引用", 502, retryable=True)
if not used.issubset(allowed):
raise AppError("INVALID_CITATION", "模型回答包含无效引用", 502, retryable=True)
+11
View File
@@ -0,0 +1,11 @@
import json
from collections.abc import Mapping
def encode_event(event: str, data: Mapping[str, object]) -> bytes:
payload = json.dumps(data, ensure_ascii=False, separators=(",", ":"))
return f"event: {event}\ndata: {payload}\n\n".encode()
def encode_ping() -> bytes:
return b": ping\n\n"
+27 -25
View File
@@ -9,37 +9,39 @@ class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_prefix="KBQA_",
extra="ignore",
extra="forbid",
)
app_name: str = "KBQA"
debug: bool = False
app_name: str
debug: bool
dashscope_api_key: str = ""
dashscope_base_url: str = "https://dashscope.aliyuncs.com/compatible-mode/v1"
chat_model: str = "qwen3.5-flash"
embedding_model: str = "text-embedding-v4"
embedding_dim: int = 1024
dashscope_api_key: str
dashscope_base_url: str
chat_model: str
embedding_model: str
embedding_dim: int
database_url: str = "sqlite+aiosqlite:///./data/kbqa.sqlite3"
milvus_uri: str = "./data/milvus/kbqa.db"
raw_data_dir: Path = Path("./data/raw")
database_url: str
milvus_uri: str
raw_data_dir: Path
chunk_size: int = Field(default=500, ge=1)
chunk_overlap: int = Field(default=50, ge=0)
retrieval_top_k: int = Field(default=5, ge=1, le=100)
max_research_searches: int = Field(default=3, ge=1, le=10)
max_sources: int = Field(default=8, ge=1, le=50)
history_message_limit: int = Field(default=20, ge=1, le=200)
max_upload_mib: int = Field(default=20, ge=1, le=1024)
max_query_length: int = Field(default=10_000, ge=1, le=100_000)
cors_origins: list[str] = [
"http://localhost:5173",
"http://127.0.0.1:5173",
]
chunk_size: int = Field(ge=1)
chunk_overlap: int = Field(ge=0)
retrieval_top_k: int = Field(ge=1, le=100)
max_research_searches: int = Field(ge=1, le=10)
max_sources: int = Field(ge=1, le=50)
history_message_limit: int = Field(ge=1, le=200)
max_upload_mib: int = Field(ge=1, le=1024)
max_query_length: int = Field(ge=1, le=100_000)
cors_origins: list[str]
run_live_tests: bool = False
live_test_file: Path | None = None
run_live_tests: bool
live_test_file: Path
langsmith_tracing: bool = Field(validation_alias="LANGSMITH_TRACING")
langsmith_endpoint: str = Field(validation_alias="LANGSMITH_ENDPOINT")
langsmith_api_key: str = Field(validation_alias="LANGSMITH_API_KEY")
langsmith_project: str = Field(validation_alias="LANGSMITH_PROJECT")
@field_validator("chunk_overlap")
@classmethod
+1 -1
View File
@@ -119,7 +119,7 @@ class IndexWorker:
await self._mark_failed(document_id, "INDEXING_FAILED", "文档索引失败,请重试")
async def _embed_and_upsert(self, document_id: str, chunks: list[PreparedChunk]) -> bool:
batch_size = 20
batch_size = 10
for start in range(0, len(chunks), batch_size):
batch = chunks[start : start + batch_size]
vectors = await self.embeddings.embed_documents([chunk.content for chunk in batch])
+3
View File
@@ -7,6 +7,7 @@ from fastapi import FastAPI
from pymilvus import MilvusClient
from kbqa.config import get_settings
from kbqa.chat.service import ChatService
from kbqa.database import Database
from kbqa.documents.service import DocumentService
from kbqa.indexing.worker import IndexWorker
@@ -53,6 +54,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
vector_store,
vector_mutation_lock,
)
chat_service = ChatService(settings, database.session_factory, retriever)
app.state.settings = settings
app.state.database = database
app.state.milvus_client = milvus_client
@@ -61,6 +63,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app.state.retriever = retriever
app.state.index_worker = worker
app.state.document_service = document_service
app.state.chat_service = chat_service
await worker.start()
await document_service.recover_deletions()
try:
+7 -1
View File
@@ -1,8 +1,12 @@
import logging
from langchain_openai import OpenAIEmbeddings
from kbqa.api.errors import AppError
from kbqa.config import Settings, validate_live_settings
logger = logging.getLogger(__name__)
class EmbeddingProvider:
def __init__(self, settings: Settings) -> None:
@@ -17,7 +21,7 @@ class EmbeddingProvider:
base_url=self.settings.dashscope_base_url,
model=self.settings.embedding_model,
dimensions=self.settings.embedding_dim,
chunk_size=20,
chunk_size=10,
max_retries=2,
timeout=60.0,
check_embedding_ctx_length=False,
@@ -40,6 +44,7 @@ class EmbeddingProvider:
except AppError:
raise
except Exception as exc:
logger.exception("Embedding document request failed error_type=%s", type(exc).__name__)
raise AppError(
"EMBEDDING_UPSTREAM_ERROR",
"文档向量化失败,请稍后重试",
@@ -54,6 +59,7 @@ class EmbeddingProvider:
except AppError:
raise
except Exception as exc:
logger.exception("Embedding query request failed error_type=%s", type(exc).__name__)
raise AppError(
"EMBEDDING_UPSTREAM_ERROR",
"查询向量化失败,请稍后重试",
+4 -2
View File
@@ -14,6 +14,7 @@ class MilvusVectorStore:
def ensure_collection(self) -> None:
if self.client.has_collection(self.collection_name):
self.client.load_collection(self.collection_name)
return
schema = self.client.create_schema(auto_id=False, enable_dynamic_field=False)
schema.add_field(
@@ -44,6 +45,7 @@ class MilvusVectorStore:
schema=schema,
index_params=index_params,
)
self.client.load_collection(self.collection_name)
def upsert(self, chunks: list[PreparedChunk], vectors: list[list[float]]) -> None:
if len(chunks) != len(vectors):
@@ -67,7 +69,7 @@ class MilvusVectorStore:
data=[vector],
anns_field="vector",
limit=limit,
output_fields=["document_id", "order_index"],
output_fields=["chunk_id", "document_id", "order_index"],
search_params={"metric_type": "COSINE"},
)
hits: list[VectorHit] = []
@@ -75,7 +77,7 @@ class MilvusVectorStore:
entity = item.get("entity", {})
hits.append(
VectorHit(
chunk_id=str(item["id"]),
chunk_id=str(entity["chunk_id"]),
document_id=str(entity["document_id"]),
order_index=int(entity["order_index"]),
score=float(item["distance"]),
+22
View File
@@ -0,0 +1,22 @@
from collections.abc import AsyncIterator
import pytest_asyncio
from sqlalchemy import event
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from kbqa.database import Base
import kbqa.models # noqa: F401
@pytest_asyncio.fixture
async def sqlite_engine(tmp_path) -> AsyncIterator[AsyncEngine]:
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'test.sqlite3'}")
@event.listens_for(engine.sync_engine, "connect")
def enable_foreign_keys(connection, _record) -> None:
connection.execute("PRAGMA foreign_keys=ON")
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
+5
View File
@@ -0,0 +1,5 @@
# 星槐项目资料
星槐项目的内部代号是 **ORCHID-731**。项目负责人是林岚,首次评审日期为 2026 年 6 月 18 日。
系统采用严格知识库回答方式:没有资料时必须说明证据不足。
+89
View File
@@ -0,0 +1,89 @@
%PDF-1.3
%“Œ‹ž ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R /F2 3 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /STSong-Light /DescendantFonts [ <<
/BaseFont /STSong-Light /CIDSystemInfo <<
/Ordering (GB1) /Registry (Adobe) /Supplement 0
>> /DW 1000 /FontDescriptor <<
/Ascent 752 /CapHeight 737 /Descent -271 /Flags 6 /FontBBox [ -25 -254 1000 880 ] /FontName /STSongStd-Light
/ItalicAngle 0 /Leading 148 /MaxWidth 1000 /MissingWidth 500 /StemH 91 /StemV 58
/Type /FontDescriptor /XHeight 553
>> /Subtype /CIDFontType0 /Type /Font
/W [ 1 [ 207 270 342 467 462 797 710 239 374 ] 10 [ 374 423 605 238 375 238 334 462 ] 18 26 462 27 28 238
29 31 605 32 [ 344 748 684 560 695 739 563 511 729 793
318 312 666 526 896 758 772 544 772 628
465 607 753 711 972 647 620 607 374 333
374 606 500 239 417 503 427 529 415 264
444 518 241 230 495 228 793 527 524 ] 81 [ 524 504 338 336 277 517 450 652 466 452
407 370 258 370 605 ] ]
>> ] /Encoding /UniGB-UCS2-H /Name /F2 /Subtype /Type0 /Type /Font
>>
endobj
4 0 obj
<<
/Contents 8 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 7 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
5 0 obj
<<
/PageMode /UseNone /Pages 7 0 R /Type /Catalog
>>
endobj
6 0 obj
<<
/Author (anonymous) /CreationDate (D:20260713120326+08'00') /Creator (anonymous) /Keywords () /ModDate (D:20260713120326+08'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (unspecified) /Title (Northwind Archive) /Trapped /False
>>
endobj
7 0 obj
<<
/Count 1 /Kids [ 4 0 R ] /Type /Pages
>>
endobj
8 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 324
>>
stream
GarnP9hPRS%))C:jJ[ZI5%\uh*fCe"=f%[YTn5r';V54D,5BneL:V6(1@QXO-1AMYTKOdWp&ME-P;rXuj:+p&#KVsFQUdmu5dBtI6^RKJM'40ugn2hs//:/o$`[%R[G?92TM2)sdJeC&2NBC5D]rI.HVr1P1&pVfEH""-4SS_L`pgS'>,@IBXHk>9g.W1m_4bk94'XkO"$-R?>/f)A4K1mL<"AQ'-t^<GF#3K#=m^U.:Tl`Q9DD:d&,>.Q?/6nV[TF=DbJ_lha*>qb8)_]&^pQdj,t'O0XuO?P<E4k*DW#$(Cl<8rh3=q\rB:3Nhp7=2eG~>endstream
endobj
xref
0 9
0000000000 65535 f
0000000061 00000 n
0000000102 00000 n
0000000209 00000 n
0000001142 00000 n
0000001345 00000 n
0000001413 00000 n
0000001683 00000 n
0000001742 00000 n
trailer
<<
/ID
[<2d05274fa2bda2ab799503a41bce39e6><2d05274fa2bda2ab799503a41bce39e6>]
% ReportLab generated PDF document -- digest (opensource)
/Info 6 0 R
/Root 5 0 R
/Size 9
>>
startxref
2156
%%EOF
+2
View File
@@ -0,0 +1,2 @@
青屿实验的校准周期是每 14 天一次。校准负责人是周禾。
如果温度超过 32 摄氏度,应暂停实验并记录环境状态。
@@ -0,0 +1,38 @@
from collections.abc import AsyncIterator
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
from kbqa.api.errors import register_exception_handlers
from kbqa.api.middleware import RequestIDMiddleware
from kbqa.chat.routes import router
from kbqa.database import get_session
async def test_session_api_persists_and_deletes(sqlite_engine: AsyncEngine) -> None:
factory = async_sessionmaker(sqlite_engine, expire_on_commit=False)
app = FastAPI()
app.add_middleware(RequestIDMiddleware)
register_exception_handlers(app)
app.include_router(router, prefix="/api/v1")
async def session_override() -> AsyncIterator[AsyncSession]:
async with factory() as session:
yield session
app.dependency_overrides[get_session] = session_override
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
created = await client.post("/api/v1/sessions", json={"title": "持久化测试"})
assert created.status_code == 201
session_id = created.json()["id"]
listed = await client.get("/api/v1/sessions")
assert listed.json()["items"][0]["id"] == session_id
messages = await client.get(f"/api/v1/sessions/{session_id}/messages")
assert messages.json()["items"] == []
deleted = await client.delete(f"/api/v1/sessions/{session_id}")
assert deleted.status_code == 204
missing = await client.get(f"/api/v1/sessions/{session_id}/messages")
assert missing.status_code == 404
@@ -0,0 +1,56 @@
from sqlalchemy import event, select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from kbqa.chat.models import ChatSession, Message
from kbqa.database import Base
from kbqa.documents.models import Document, IndexJob
import kbqa.models # noqa: F401
async def test_sqlite_cascades_document_and_session(tmp_path) -> None:
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'test.db'}")
@event.listens_for(engine.sync_engine, "connect")
def enable_foreign_keys(connection, _record) -> None:
connection.execute("PRAGMA foreign_keys=ON")
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
factory = async_sessionmaker(engine, expire_on_commit=False)
async with factory() as session:
document = Document(
id="doc",
filename="a.txt",
stored_path="a",
file_type="txt",
mime_type="text/plain",
file_size=1,
sha256="0" * 64,
status="pending",
chunk_count=0,
)
session.add_all(
[
document,
IndexJob(id="job", document_id="doc", status="queued", attempt_count=0),
ChatSession(id="session"),
]
)
await session.commit()
session.add(
Message(
id="message",
session_id="session",
role="user",
content="q",
status="completed",
order_index=0,
)
)
await session.commit()
await session.delete(document)
await session.delete(await session.get(ChatSession, "session"))
await session.commit()
assert await session.scalar(select(IndexJob).where(IndexJob.id == "job")) is None
assert await session.scalar(select(Message).where(Message.id == "message")) is None
await engine.dispose()
+233
View File
@@ -0,0 +1,233 @@
import json
import os
from pathlib import Path
import socket
import sqlite3
import subprocess
import time
import httpx
import pytest
from pymilvus import MilvusClient
pytestmark = pytest.mark.live
ROOT = Path(__file__).resolve().parents[3]
FIXTURES = ROOT / "backend" / "tests" / "fixtures"
FIXTURE_PATHS = tuple(
FIXTURES / name for name in ("knowledge.md", "knowledge.txt", "knowledge.pdf")
)
def _port() -> int:
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])
def _start_server(port: int, env: dict[str, str]) -> subprocess.Popen[str]:
process = subprocess.Popen(
[
str(ROOT / "backend" / ".venv" / "bin" / "python"),
"-m",
"uvicorn",
"kbqa.main:app",
"--app-dir",
"backend/src",
"--host",
"127.0.0.1",
"--port",
str(port),
],
cwd=ROOT,
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.STDOUT,
text=True,
)
base_url = f"http://127.0.0.1:{port}"
for _ in range(120):
if process.poll() is not None:
raise RuntimeError("live Uvicorn exited during startup")
try:
if httpx.get(f"{base_url}/health", timeout=1).status_code == 200:
return process
except httpx.HTTPError:
pass
time.sleep(0.25)
process.terminate()
raise TimeoutError("live Uvicorn did not become healthy")
def _stop_server(process: subprocess.Popen[str]) -> None:
process.terminate()
try:
process.wait(timeout=15)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=5)
def _stream(client: httpx.Client, session_id: str, query: str) -> list[tuple[str, dict]]:
events: list[tuple[str, dict]] = []
with client.stream(
"POST", "/api/v1/chat/stream", json={"session_id": session_id, "query": query}, timeout=180
) as response:
response.raise_for_status()
event_name = ""
for line in response.iter_lines():
if line.startswith("event: "):
event_name = line.removeprefix("event: ")
elif line.startswith("data: "):
events.append((event_name, json.loads(line.removeprefix("data: "))))
return events
def _answer(events: list[tuple[str, dict]]) -> str:
return "".join(data["content"] for name, data in events if name == "token")
def _assert_flow(events: list[tuple[str, dict]]) -> None:
stages = [
f"status:{data['phase']}" if name == "status" else name
for name, data in events
if name != "ping"
]
expected = ["status:researching", "sources", "status:answering", "token", "done"]
positions = [stages.index(stage) for stage in expected]
assert positions == sorted(positions), stages
def test_real_model_pipeline_survives_restart_and_cleans_up(tmp_path) -> None:
if os.environ.get("KBQA_RUN_LIVE_TESTS") != "1":
pytest.skip("set KBQA_RUN_LIVE_TESTS=1 to call real models")
database = tmp_path / "kbqa.sqlite3"
milvus = tmp_path / "milvus.db"
raw = tmp_path / "raw"
env = os.environ.copy()
env.update(
{
"KBQA_DATABASE_URL": f"sqlite+aiosqlite:///{database}",
"KBQA_MILVUS_URI": str(milvus),
"KBQA_RAW_DATA_DIR": str(raw),
"KBQA_RUN_LIVE_TESTS": "1",
}
)
subprocess.run(
[
str(ROOT / "backend" / ".venv" / "bin" / "alembic"),
"-c",
"backend/alembic.ini",
"upgrade",
"head",
],
cwd=ROOT,
env=env,
check=True,
capture_output=True,
text=True,
)
port = _port()
process = _start_server(port, env)
base_url = f"http://127.0.0.1:{port}"
document_ids: list[str] = []
try:
with httpx.Client(base_url=base_url, timeout=30) as client:
for path in FIXTURE_PATHS:
with path.open("rb") as file_handle:
response = client.post(
"/api/v1/documents", files={"file": (path.name, file_handle)}
)
response.raise_for_status()
document_ids.append(response.json()["id"])
deadline = time.monotonic() + 120
while time.monotonic() < deadline:
documents = client.get("/api/v1/documents", params={"limit": 100}).json()["items"]
if all(item["status"] == "indexed" for item in documents):
break
assert not any(item["status"] == "failed" for item in documents), documents
time.sleep(0.5)
else:
pytest.fail("documents did not finish indexing")
assert len(documents) == 3
assert all(item["chunk_count"] > 0 for item in documents)
session = client.post("/api/v1/sessions", json={"title": "真实模型测试"}).json()
session_id = session["id"]
first = _stream(client, session_id, "星槐项目的内部代号和负责人分别是什么?")
_assert_flow(first)
first_answer = _answer(first)
assert "ORCHID-731" in first_answer
assert "林岚" in first_answer
first_sources = [
source for name, data in first if name == "sources" for source in data["sources"]
]
assert first_sources
assert {source["filename"] for source in first_sources} <= {
path.name for path in FIXTURE_PATHS
}
chunks_by_document: dict[str, set[str]] = {}
for source in first_sources:
document_id = source["document_id"]
if document_id not in chunks_by_document:
page = client.get(
f"/api/v1/documents/{document_id}/chunks", params={"limit": 100}
).json()
chunks_by_document[document_id] = {item["id"] for item in page["items"]}
assert source["chunk_id"] in chunks_by_document[document_id]
follow_up = _stream(client, session_id, "她负责的项目首次评审日期是什么?")
_assert_flow(follow_up)
follow_up_answer = _answer(follow_up)
assert "2026" in follow_up_answer and "18" in follow_up_answer
absent = _stream(client, session_id, "星槐项目部署在哪个月球基地?")
_assert_flow(absent)
assert any(marker in _answer(absent) for marker in ("不足", "没有", "未检索"))
history = client.get(
f"/api/v1/sessions/{session_id}/messages", params={"limit": 100}
).json()
assert history["total"] == 6
assert all(item["status"] == "completed" for item in history["items"])
assert history["items"][1]["sources"]
finally:
_stop_server(process)
process = _start_server(port, env)
try:
with httpx.Client(base_url=base_url, timeout=30) as client:
assert client.get("/api/v1/documents").json()["total"] == 3
history = client.get(
f"/api/v1/sessions/{session_id}/messages", params={"limit": 100}
).json()
assert history["total"] == 6
after_restart = _stream(client, session_id, "青屿实验的校准周期是多久?")
_assert_flow(after_restart)
assert "14" in _answer(after_restart)
for document_id in document_ids:
assert client.delete(f"/api/v1/documents/{document_id}").status_code == 204
assert client.delete(f"/api/v1/sessions/{session_id}").status_code == 204
assert client.get("/api/v1/documents").json()["total"] == 0
assert client.get(f"/api/v1/sessions/{session_id}/messages").status_code == 404
finally:
_stop_server(process)
with sqlite3.connect(database) as connection:
for table in (
"documents",
"index_jobs",
"chunks",
"chat_sessions",
"messages",
"message_sources",
):
assert connection.execute(f"SELECT count(*) FROM {table}").fetchone()[0] == 0
vector_client = MilvusClient(str(milvus))
try:
vector_client.load_collection("kbqa_chunks")
assert int(vector_client.get_collection_stats("kbqa_chunks")["row_count"]) == 0
finally:
vector_client.close()
assert not list(raw.glob("*"))
@@ -0,0 +1,13 @@
from pathlib import Path
import pytest
from kbqa.api.errors import AppError
from kbqa.indexing.loaders import load_document
def test_text_loader_rejects_non_utf8(tmp_path: Path) -> None:
path = tmp_path / "bad.txt"
path.write_bytes(b"\xff\xfe")
with pytest.raises(AppError, match="UTF-8"):
load_document(path, "txt")
+12
View File
@@ -0,0 +1,12 @@
from kbqa.indexing.loaders import LoadedPage
from kbqa.indexing.splitter import split_pages
def test_split_pages_is_deterministic() -> None:
document_id = "12345678-1234-5678-1234-567812345678"
pages = [LoadedPage(content="知识库" * 300, page_number=1)]
first = split_pages(document_id, pages, 100, 10)
second = split_pages(document_id, pages, 100, 10)
assert [chunk.id for chunk in first] == [chunk.id for chunk in second]
assert first[0].page_number == 1
assert [chunk.order_index for chunk in first] == list(range(len(first)))
+8
View File
@@ -0,0 +1,8 @@
from kbqa.chat.sse import encode_event, encode_ping
def test_sse_encodes_single_line_utf8_json() -> None:
assert encode_event("token", {"content": "知识"}) == (
'event: token\ndata: {"content":"知识"}\n\n'.encode()
)
assert encode_ping() == b": ping\n\n"
+93
View File
@@ -0,0 +1,93 @@
# KBQA MVP 真实链路验收报告
- 验收时间:2026-07-13Asia/Shanghai
- 分支:`feature/kbqa-mvp`
- 模型:`qwen3.5-flash``text-embedding-v4`1024 维)
- 数据库:SQLite + Milvus Lite
- 运行约束:单 Uvicorn worker
## 版本
- Python 3.12.12
- LangChain 1.3.13(仅使用 1.x API
- FastAPI 0.139.0
- SQLAlchemy 2.0.51
- PyMilvus 2.6.16
- Node.js 24.17.0 / npm 11.13.0
## 自动验证
普通测试未调用 Chat/Embedding
```text
ruff format --check: 48 files already formatted
ruff check: All checks passed
pytest unit + integration: 5 passed
ESLint: passed
Vitest: 1 file / 3 tests passed(含任意 UTF-8 网络分片的增量 SSE)
Vite production build: passed
```
显式开启的真实模型黑盒测试:
```text
KBQA_RUN_LIVE_TESTS=1 uv run pytest tests/live/test_live_pipeline.py -v -s
1 passed in 69.77s
```
黑盒测试使用隔离的临时 SQLite、Milvus Lite 和原始文件目录,完成以下流程:上传真实
Markdown、UTF-8 TXT、带文本层 PDF;等待真实 Embedding 索引;三轮真实双 Agent 问答;
校验严格 SSE 子序列、答案语义、拒答边界、来源 chunk 可读取;重启 Uvicorn 后再次执行真实
检索;验证文档、会话和消息仍存在;删除数据;直接确认原始文件、SQLite 六张业务表及
Milvus 向量均清空。
## 用户真实文件验收
使用用户授权的 `test.txt`(6,158,012 字节)执行了额外的大文件验收:
- 后台真实 Embedding 完成 5,037 个片段的索引。
- 首轮问题经 Research Agent 检索后进入 Main AgentSSE 依次产生 `researching`
`sources``answering`、多个 `token``done`
- 同一会话追问“佩罗和奥克斯对伊南娜做了什么?”,准确召回第 1 章相关片段并生成
S3/S4/S5 引用。
- 多次停止并重启后端后,文档、5,037 个片段、会话和历史消息均可读取。
- 删除会话后,该会话及消息记录均为 0。
- 删除文档后,文档、索引任务、片段、消息来源记录及原始副本均为 0;Milvus
`row_count` 为 0。用户提供的源文件未被改动。
首个宽泛问题的向量召回偏离了开篇片段,因此系统严格返回了证据不足;更具体的多轮追问
召回正确。这属于可接受的模型/向量检索波动,测试只断言事件结构、来源真实性和证据边界,
不锁定模型措辞。
## 浏览器验收
使用 agent-browser 检查了文档状态、详情抽屉、历史会话恢复、可点击引用、桌面右栏及
390px 窄屏证据抽屉:
- [文档索引状态](screenshots/documents-indexing.png)
- [文档详情抽屉](screenshots/document-detail-drawer.png)
- [文档窄屏布局](screenshots/documents-narrow.png)
- [历史对话与桌面引用](screenshots/chat-history-citation.png)
- [对话窄屏布局](screenshots/chat-mobile.png)
- [窄屏可点击引用抽屉](screenshots/chat-mobile-citation.png)
- [浏览器上传与后台索引中](screenshots/browser-upload-indexing.png)
- [真实问答研究阶段](screenshots/browser-live-researching.png)
- [真实回答与引用](screenshots/browser-live-answer.png)
- [失败文档重试](screenshots/browser-retry.png)
- [不支持格式的页面错误](screenshots/browser-upload-error.png)
- [文档删除后的空状态](screenshots/browser-delete-empty.png)
- [会话删除后侧栏同步](screenshots/browser-session-deleted.png)
## 真实环境问题与修复
- DashScope 单次 Embedding 上限为 10,索引器已固定分批上限并验证大文件成功。
- `qwen3.5-flash` 默认思考模式不支持强制 `tool_choice`;按官方兼容接口关闭思考模式,
保留双 Agent 的必经工具约束。
- Milvus Lite 重启后集合需要重新 load,启动流程已补齐。
- 自定义主键搜索结果从 `entity.chunk_id` 读取,避免依赖不存在的默认 `id` 字段。
- Research Agent 完成一次真实检索后强制进入证据总结,避免模型重复调用工具直到图递归上限。
- 修复新会话首问完成回调的导航竞态,SSE 完成后按实际会话 ID 刷新历史与侧栏。
LangSmith tracing 已按本地 `.env` 开启并写入既有项目。代表性 Main Agent trace
[`019f5a18-28fb-7f80-b252-b436e2567e1d`](https://smith.langchain.com/o/3b9cd927-6db9-411f-b6af-19b38f36a387/projects/p/6b719950-c885-405f-addb-1bd86ac3cf83/r/019f5a18-28fb-7f80-b252-b436e2567e1d?trace_id=019f5a18-28fb-7f80-b252-b436e2567e1d&start_time=2026-07-13T06:09:20.635570)。
报告不包含任何密钥。
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+32
View File
@@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
+25
View File
@@ -0,0 +1,25 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2022,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
},
},
)
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+5174
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"test": "vitest",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.19",
"@radix-ui/react-slot": "^1.3.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.24.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.18.1",
"tailwind-merge": "^3.6.0",
"zustand": "^5.0.14"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4.3.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"eslint": "^10.7.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3",
"globals": "^17.7.0",
"jsdom": "^29.1.1",
"oxlint": "^1.71.0",
"tailwindcss": "^4.3.2",
"typescript": "~6.0.2",
"typescript-eslint": "^8.63.0",
"vite": "^8.1.1",
"vitest": "^4.1.10"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+58
View File
@@ -0,0 +1,58 @@
import * as Dialog from '@radix-ui/react-dialog'
import { BookOpenText, Menu, MessageSquareText, X } from 'lucide-react'
import { useState } from 'react'
import { NavLink, Outlet } from 'react-router-dom'
import { Button } from '../components/ui/button'
import { SessionList } from '../features/chat/SessionList'
import { cn } from '../lib/utils'
function Navigation({ onNavigate }: { onNavigate?: () => void }) {
return (
<>
<div className="brand-lockup">
<span className="brand-lockup__mark">K</span>
<span><strong>KNOWLEDGE</strong><small>DESK · </small></span>
</div>
<p className="nav-label"></p>
<nav className="primary-nav" aria-label="主导航">
<NavLink to="/chat" onClick={onNavigate} className={({ isActive }) => cn('nav-item', isActive && 'nav-item--active')}>
<MessageSquareText size={17} /><span></span>
</NavLink>
<NavLink to="/documents" onClick={onNavigate} className={({ isActive }) => cn('nav-item', isActive && 'nav-item--active')}>
<BookOpenText size={17} /><span></span>
</NavLink>
</nav>
<SessionList onNavigate={onNavigate} />
<div className="sidebar-note">
<span className="status-dot" />
<div><strong></strong><small></small></div>
</div>
<p className="sidebar-version">KBQA · LOCAL / 0.1</p>
</>
)
}
export function AppLayout() {
const [menuOpen, setMenuOpen] = useState(false)
return (
<div className="app-shell">
<aside className="sidebar"><Navigation /></aside>
<header className="mobile-header">
<span className="brand-lockup__mark">K</span><strong>KNOWLEDGE DESK</strong>
<Dialog.Root open={menuOpen} onOpenChange={setMenuOpen}>
<Dialog.Trigger asChild><Button variant="ghost" size="icon" aria-label="打开导航"><Menu /></Button></Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="dialog-overlay" />
<Dialog.Content className="mobile-menu">
<Dialog.Title className="sr-only"></Dialog.Title>
<Dialog.Close asChild><Button className="mobile-menu__close" variant="ghost" size="icon" aria-label="关闭导航"><X /></Button></Dialog.Close>
<Navigation onNavigate={() => setMenuOpen(false)} />
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
</header>
<main className="route-canvas"><Outlet /></main>
</div>
)
}
+15
View File
@@ -0,0 +1,15 @@
import { BookOpenText, MessageSquareText } from 'lucide-react'
import { useLocation } from 'react-router-dom'
export function PlaceholderPage() {
const documents = useLocation().pathname.startsWith('/documents')
const Icon = documents ? BookOpenText : MessageSquareText
return (
<section className="placeholder-page">
<div className="placeholder-page__mark"><Icon size={28} /></div>
<p className="eyebrow">KNOWLEDGE DESK</p>
<h1>{documents ? '文档工作区' : '知识库对话'}</h1>
<p>{documents ? '上传、索引与审阅你的资料。' : '基于可追溯证据展开研究。'}</p>
</section>
)
}
+19
View File
@@ -0,0 +1,19 @@
import { Navigate, createBrowserRouter } from 'react-router-dom'
import { AppLayout } from './AppLayout'
import { ChatPage } from '../features/chat/ChatPage'
import { DocumentsPage } from '../features/documents/DocumentsPage'
export const router = createBrowserRouter([
{
path: '/',
element: <AppLayout />,
children: [
{ index: true, element: <Navigate to="/chat" replace /> },
{ path: 'chat', element: <ChatPage /> },
{ path: 'chat/:sessionId', element: <ChatPage /> },
{ path: 'documents', element: <DocumentsPage /> },
{ path: 'documents/:documentId', element: <DocumentsPage /> },
],
},
])
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+7
View File
@@ -0,0 +1,7 @@
import type { HTMLAttributes } from 'react'
import { cn } from '../../lib/utils'
export function Badge({ className, ...props }: HTMLAttributes<HTMLSpanElement>) {
return <span className={cn('badge', className)} {...props} />
}
+30
View File
@@ -0,0 +1,30 @@
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import type { ButtonHTMLAttributes } from 'react'
import { cn } from '../../lib/utils'
const buttonVariants = cva('button', {
variants: {
variant: {
primary: 'button--primary',
secondary: 'button--secondary',
ghost: 'button--ghost',
danger: 'button--danger',
},
size: {
sm: 'button--sm',
md: 'button--md',
icon: 'button--icon',
},
},
defaultVariants: { variant: 'primary', size: 'md' },
})
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonVariants> & { asChild?: boolean }
export function Button({ className, variant, size, asChild, ...props }: ButtonProps) {
const Component = asChild ? Slot : 'button'
return <Component className={cn(buttonVariants({ variant, size }), className)} {...props} />
}
+22
View File
@@ -0,0 +1,22 @@
import { SendHorizontal, Square } from 'lucide-react'
import { useState } from 'react'
import { Button } from '../../components/ui/button'
type Props = { streaming: boolean; onSend: (query: string) => void; onCancel: () => void }
export function ChatComposer({ streaming, onSend, onCancel }: Props) {
const [query, setQuery] = useState('')
function submit() {
const value = query.trim()
if (!value || streaming) return
setQuery(''); onSend(value)
}
return (
<div className="chat-composer">
<textarea value={query} onChange={(event) => setQuery(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); submit() } }} placeholder="向知识库提问…" rows={2} disabled={streaming} />
{streaming ? <Button variant="secondary" size="icon" onClick={onCancel} aria-label="停止生成"><Square size={15} fill="currentColor" /></Button> : <Button size="icon" onClick={submit} disabled={!query.trim()} aria-label="发送问题"><SendHorizontal size={17} /></Button>}
<small>Enter · Shift + Enter </small>
</div>
)
}
+78
View File
@@ -0,0 +1,78 @@
import * as Dialog from '@radix-ui/react-dialog'
import { PanelRightOpen, Radar, Sparkles } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Button } from '../../components/ui/button'
import type { Message, Source } from '../../lib/types'
import { announceSessionChange, createSession, listMessages } from './api'
import { ChatComposer } from './ChatComposer'
import { EvidencePanel } from './EvidencePanel'
import { MessageList } from './MessageList'
import { useChatStreamStore } from './store'
import { useChatStream } from './useChatStream'
export function ChatPage() {
const { sessionId } = useParams()
const navigate = useNavigate()
const [messages, setMessages] = useState<Message[]>([])
const [evidenceOpen, setEvidenceOpen] = useState(false)
const currentSessionId = useRef(sessionId)
const stream = useChatStreamStore()
useEffect(() => { currentSessionId.current = sessionId }, [sessionId])
const refreshMessages = useCallback(async () => {
if (!sessionId) {
if (currentSessionId.current === sessionId) setMessages([])
return
}
const target = sessionId
const page = await listMessages(target)
if (currentSessionId.current !== target) return
setMessages(page.items); announceSessionChange()
}, [sessionId])
useEffect(() => {
if (!sessionId) return
const target = sessionId
let cancelled = false
void listMessages(target).then((page) => {
if (!cancelled && currentSessionId.current === target) setMessages(page.items)
})
return () => { cancelled = true }
}, [sessionId])
const { send, cancel } = useChatStream(async () => { await refreshMessages(); useChatStreamStore.getState().reset() })
async function handleSend(query: string) {
let target = sessionId
if (!target) {
const session = await createSession(); target = session.id; navigate(`/chat/${target}`); announceSessionChange()
}
await send(target, query)
const page = await listMessages(target)
if (currentSessionId.current !== target) return
setMessages(page.items)
announceSessionChange()
}
function chooseSource(source: Source, sources: Source[] = stream.sources) {
stream.setSources(sources)
stream.selectSource(source)
setEvidenceOpen(true)
}
const streaming = stream.phase === 'researching' || stream.phase === 'answering'
return (
<section className="chat-workspace">
<main className="chat-main">
<header className="chat-header"><div><p className="eyebrow">KNOWLEDGE CONVERSATION</p><h1>{sessionId ? '知识库对话' : '开始新研究'}</h1></div><Button className="evidence-trigger" variant="secondary" size="sm" onClick={() => setEvidenceOpen(true)}><PanelRightOpen size={15} /></Button></header>
<div className="conversation-scroll"><MessageList messages={messages} onSource={chooseSource} />{stream.activeQuery && <div className="streaming-turn"><article className="message message--user"><div className="message-body"><p>{stream.activeQuery}</p></div></article><article className="message message--assistant"><div className="message-avatar"><Sparkles size={15} /></div><div className="message-body">{stream.phase === 'researching' && <p className="research-state"><Radar className="pulse" size={16} /> Agent </p>}{stream.text && <p className="answer-copy">{stream.text}<span className="typing-cursor" /></p>}{stream.error && <p className="failed-message">{stream.error}</p>}</div></article></div>}</div>
<ChatComposer streaming={streaming} onSend={(query) => void handleSend(query)} onCancel={cancel} />
</main>
<div className="evidence-desktop"><EvidencePanel sources={stream.sources} selected={stream.selectedSource} onSelect={stream.selectSource} /></div>
<Dialog.Root open={evidenceOpen} onOpenChange={setEvidenceOpen}><Dialog.Portal><Dialog.Overlay className="dialog-overlay" /><Dialog.Content className="evidence-drawer"><Dialog.Title className="sr-only"></Dialog.Title><EvidencePanel sources={stream.sources} selected={stream.selectedSource} onSelect={stream.selectSource} onClose={() => setEvidenceOpen(false)} /></Dialog.Content></Dialog.Portal></Dialog.Root>
</section>
)
}
+16
View File
@@ -0,0 +1,16 @@
import { BookOpenCheck, FileText, X } from 'lucide-react'
import { Button } from '../../components/ui/button'
import type { Source } from '../../lib/types'
import { cn } from '../../lib/utils'
type Props = { sources: Source[]; selected: Source | null; onSelect: (source: Source) => void; onClose?: () => void }
export function EvidencePanel({ sources, selected, onSelect, onClose }: Props) {
return (
<aside className="evidence-panel">
<header className="evidence-header"><div><p className="eyebrow">EVIDENCE</p><h2></h2></div><span>{sources.length} </span>{onClose && <Button variant="ghost" size="icon" onClick={onClose}><X size={17} /></Button>}</header>
{sources.length === 0 ? <div className="evidence-empty"><BookOpenCheck size={23} /><strong></strong><span></span></div> : <><div className="source-list">{sources.map((source) => <button key={source.chunk_id} className={cn('source-card', selected?.chunk_id === source.chunk_id && 'source-card--active')} onClick={() => onSelect(source)}><FileText size={14} /><span><strong>{source.label} · {source.filename}</strong><small> {source.order_index + 1} · {(source.score * 100).toFixed(0)}%</small></span></button>)}</div>{selected && <article className="source-detail"><header><strong>{selected.label} · </strong><span>{selected.page_number ? `${selected.page_number}` : `${selected.order_index + 1}`}</span></header><p>{selected.content}</p></article>}</>}
</aside>
)
}
+28
View File
@@ -0,0 +1,28 @@
import { Bot, CircleAlert, UserRound } from 'lucide-react'
import type { Message, Source } from '../../lib/types'
function AnswerContent({ content, sources, onSource }: { content: string; sources: Source[]; onSource: (source: Source, sources: Source[]) => void }) {
const lookup = new Map(sources.map((source) => [source.label, source]))
return <p className="answer-copy">{content.split(/(\[S\d+])/g).map((part, index) => {
const label = part.match(/^\[(S\d+)]$/)?.[1]
const source = label ? lookup.get(label) : undefined
if (!label) return <span key={index}>{part}</span>
if (!source) return <span key={index} className="citation citation--deleted"></span>
return <button key={index} className="citation" onClick={() => onSource(source, sources)}>{label}</button>
})}</p>
}
type Props = { messages: Message[]; onSource: (source: Source, sources: Source[]) => void }
export function MessageList({ messages, onSource }: Props) {
if (messages.length === 0) return <div className="chat-empty"><div><Bot size={28} /></div><p className="eyebrow">STRICT KNOWLEDGE MODE</p><h2></h2><span> Agent Agent </span></div>
return (
<div className="message-list">
{messages.map((message) => <article key={message.id} className={`message message--${message.role}`}>
<div className="message-avatar">{message.role === 'user' ? <UserRound size={15} /> : <Bot size={15} />}</div>
<div className="message-body"><header><strong>{message.role === 'user' ? '你' : '知识库助手'}</strong><time>{new Date(message.created_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}</time></header>{message.status === 'failed' ? <p className="failed-message"><CircleAlert size={14} /></p> : message.role === 'assistant' ? <AnswerContent content={message.content} sources={message.sources} onSource={onSource} /> : <p>{message.content}</p>}</div>
</article>)}
</div>
)
}
+52
View File
@@ -0,0 +1,52 @@
import { MessageSquarePlus, Trash2 } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { Button } from '../../components/ui/button'
import type { Session } from '../../lib/types'
import { cn } from '../../lib/utils'
import { createSession, deleteSession, listSessions } from './api'
export function SessionList({ onNavigate }: { onNavigate?: () => void }) {
const [sessions, setSessions] = useState<Session[]>([])
const navigate = useNavigate()
const { sessionId } = useParams()
const isChat = useLocation().pathname.startsWith('/chat')
const refresh = useCallback(async () => {
if (!isChat) return
const page = await listSessions()
setSessions(page.items)
}, [isChat])
useEffect(() => {
if (!isChat) return
void listSessions().then((page) => setSessions(page.items))
const listener = () => void refresh()
window.addEventListener('kbqa:sessions-changed', listener)
return () => window.removeEventListener('kbqa:sessions-changed', listener)
}, [isChat, refresh])
if (!isChat) return null
async function addSession() {
const session = await createSession()
await refresh(); navigate(`/chat/${session.id}`); onNavigate?.()
}
async function removeSession(id: string) {
if (!window.confirm('确定删除这个会话及全部历史吗?')) return
await deleteSession(id); await refresh()
if (sessionId === id) navigate('/chat')
}
return (
<section className="session-rail">
<Button className="new-session" size="sm" onClick={() => void addSession()}><MessageSquarePlus size={15} /></Button>
<p className="nav-label"></p>
<div className="session-list">
{sessions.map((session) => <div key={session.id} className={cn('session-item', session.id === sessionId && 'session-item--active')}><button onClick={() => { navigate(`/chat/${session.id}`); onNavigate?.() }}><strong>{session.title || '新会话'}</strong><span>{new Date(session.updated_at).toLocaleDateString('zh-CN')}</span></button><Button variant="ghost" size="icon" title="删除会话" onClick={() => void removeSession(session.id)}><Trash2 size={13} /></Button></div>)}
{sessions.length === 0 && <p className="session-empty"></p>}
</div>
</section>
)
}
+25
View File
@@ -0,0 +1,25 @@
import { apiRequest } from '../../lib/api'
import type { Message, Page, Session } from '../../lib/types'
export function createSession(title?: string) {
return apiRequest<Session>('/api/v1/sessions', {
method: 'POST',
body: JSON.stringify({ title: title || null }),
})
}
export function listSessions() {
return apiRequest<Page<Session>>('/api/v1/sessions?limit=100')
}
export function listMessages(sessionId: string) {
return apiRequest<Page<Message>>(`/api/v1/sessions/${sessionId}/messages?limit=100`)
}
export function deleteSession(sessionId: string) {
return apiRequest<void>(`/api/v1/sessions/${sessionId}`, { method: 'DELETE' })
}
export function announceSessionChange() {
window.dispatchEvent(new Event('kbqa:sessions-changed'))
}
+41
View File
@@ -0,0 +1,41 @@
import { create } from 'zustand'
import type { Source } from '../../lib/types'
type Phase = 'idle' | 'researching' | 'answering' | 'error'
type ChatStreamState = {
phase: Phase
activeQuery: string
text: string
sources: Source[]
selectedSource: Source | null
error: string | null
start: (query: string) => void
setPhase: (phase: Phase) => void
appendText: (content: string) => void
setSources: (sources: Source[]) => void
selectSource: (source: Source | null) => void
fail: (message: string) => void
reset: () => void
}
const initial = {
phase: 'idle' as Phase,
activeQuery: '',
text: '',
sources: [] as Source[],
selectedSource: null as Source | null,
error: null as string | null,
}
export const useChatStreamStore = create<ChatStreamState>((set) => ({
...initial,
start: (query) => set({ ...initial, phase: 'researching', activeQuery: query }),
setPhase: (phase) => set({ phase }),
appendText: (content) => set((state) => ({ text: state.text + content })),
setSources: (sources) => set({ sources, selectedSource: sources[0] ?? null }),
selectSource: (selectedSource) => set({ selectedSource }),
fail: (error) => set({ phase: 'error', error }),
reset: () => set(initial),
}))
+39
View File
@@ -0,0 +1,39 @@
import { useCallback, useEffect, useRef } from 'react'
import { streamSSE } from '../../lib/sse'
import { useChatStreamStore } from './store'
export function useChatStream(onDone: () => Promise<void>) {
const controllerRef = useRef<AbortController | null>(null)
const store = useChatStreamStore()
const abort = useCallback(() => controllerRef.current?.abort(), [])
const cancel = useCallback(() => {
controllerRef.current?.abort()
useChatStreamStore.getState().fail('已停止生成,可重新发送问题。')
}, [])
const send = useCallback(async (sessionId: string, query: string) => {
controllerRef.current?.abort()
const controller = new AbortController()
controllerRef.current = controller
store.start(query)
try {
await streamSSE('/api/v1/chat/stream', { session_id: sessionId, query }, (event) => {
if (event.event === 'status') store.setPhase(event.data.phase)
if (event.event === 'sources') store.setSources(event.data.sources)
if (event.event === 'token') store.appendText(event.data.content)
if (event.event === 'error') store.fail(event.data.message)
}, controller.signal)
if (!controller.signal.aborted && useChatStreamStore.getState().phase !== 'error') await onDone()
} catch (reason) {
if (controller.signal.aborted) return
store.fail(reason instanceof Error ? reason.message : '流式回答失败')
} finally {
if (controllerRef.current === controller) controllerRef.current = null
}
}, [onDone, store])
useEffect(() => abort, [abort])
return { send, cancel }
}
@@ -0,0 +1,45 @@
import { ChevronLeft, ChevronRight, FileSearch, X } from 'lucide-react'
import { useEffect, useState } from 'react'
import { Button } from '../../components/ui/button'
import type { Chunk, Document } from '../../lib/types'
import { getDocument, listChunks } from './api'
type Props = { documentId?: string; onClose?: () => void }
export function DocumentDetail({ documentId, onClose }: Props) {
const [document, setDocument] = useState<Document | null>(null)
const [chunks, setChunks] = useState<Chunk[]>([])
const [offset, setOffset] = useState(0)
const [total, setTotal] = useState(0)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!documentId) return
let active = true
void Promise.all([getDocument(documentId), listChunks(documentId, offset, 10)])
.then(([nextDocument, page]) => {
if (!active) return
setDocument(nextDocument); setChunks(page.items); setTotal(page.total); setError(null)
})
.catch((reason) => { if (active) setError(reason instanceof Error ? reason.message : '详情加载失败') })
return () => { active = false }
}, [documentId, offset])
if (!documentId) return <aside className="detail-panel detail-panel--empty"><FileSearch size={24} /><strong></strong><span></span></aside>
return (
<aside className="detail-panel">
<header className="detail-panel__header">
<div><p className="eyebrow">DOCUMENT DETAIL</p><h2>{document?.filename ?? '加载中…'}</h2></div>
{onClose && <Button variant="ghost" size="icon" onClick={onClose} aria-label="关闭详情"><X size={17} /></Button>}
</header>
{error && <p className="panel-error">{error}</p>}
{document && <dl className="document-meta"><div><dt></dt><dd>{document.status}</dd></div><div><dt></dt><dd>{document.chunk_count}</dd></div><div><dt></dt><dd>{document.file_type.toUpperCase()}</dd></div></dl>}
<div className="chunk-list">
{chunks.map((chunk) => <article key={chunk.id} className="chunk-card"><div><span>#{chunk.order_index + 1}</span>{chunk.page_number && <span> {chunk.page_number} </span>}</div><p>{chunk.content}</p></article>)}
{document?.status !== 'indexed' && chunks.length === 0 && <p className="muted-copy"></p>}
</div>
{total > 10 && <footer className="detail-pagination"><Button variant="ghost" size="icon" disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - 10))}><ChevronLeft /></Button><span>{offset + 1}{Math.min(offset + 10, total)} / {total}</span><Button variant="ghost" size="icon" disabled={offset + 10 >= total} onClick={() => setOffset(offset + 10)}><ChevronRight /></Button></footer>}
</aside>
)
}
@@ -0,0 +1,50 @@
import { FileText, MoreHorizontal, RefreshCw, Trash2 } from 'lucide-react'
import { Badge } from '../../components/ui/badge'
import { Button } from '../../components/ui/button'
import type { Document } from '../../lib/types'
import { cn } from '../../lib/utils'
const STATUS_LABEL = {
pending: '等待索引', indexing: '索引中', indexed: '已索引', failed: '失败', deleting: '删除中',
} as const
function formatSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 ** 2).toFixed(1)} MB`
}
type Props = {
documents: Document[]
selectedId?: string
onSelect: (id: string) => void
onRetry: (id: string) => void
onDelete: (id: string) => void
}
export function DocumentList({ documents, selectedId, onSelect, onRetry, onDelete }: Props) {
if (documents.length === 0) {
return <div className="list-empty"><FileText size={24} /><strong></strong><span></span></div>
}
return (
<div className="document-list">
{documents.map((document) => (
<article key={document.id} className={cn('document-row', selectedId === document.id && 'document-row--selected')} onClick={() => onSelect(document.id)}>
<div className="file-glyph">{document.file_type.toUpperCase()}</div>
<div className="document-row__body">
<strong title={document.filename}>{document.filename}</strong>
<span>{formatSize(document.file_size)} · {document.chunk_count ? `${document.chunk_count} 个分块` : '尚无分块'}</span>
{document.error_message && <small>{document.error_message}</small>}
</div>
<Badge className={`status status--${document.status}`}><span />{STATUS_LABEL[document.status]}</Badge>
<div className="row-actions" onClick={(event) => event.stopPropagation()}>
{document.status === 'failed' && <Button variant="ghost" size="icon" title="重试" onClick={() => onRetry(document.id)}><RefreshCw size={15} /></Button>}
<Button variant="ghost" size="icon" title="删除" disabled={document.status === 'deleting'} onClick={() => onDelete(document.id)}><Trash2 size={15} /></Button>
</div>
<MoreHorizontal className="row-cue" size={16} />
</article>
))}
</div>
)
}
@@ -0,0 +1,71 @@
import * as Dialog from '@radix-ui/react-dialog'
import { Archive, FileCheck2, Filter, LoaderCircle, PanelRightOpen, TriangleAlert } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Button } from '../../components/ui/button'
import type { DocumentStatus } from '../../lib/types'
import { cn } from '../../lib/utils'
import { deleteDocument, retryDocument } from './api'
import { DocumentDetail } from './DocumentDetail'
import { DocumentList } from './DocumentList'
import { UploadDocument } from './UploadDocument'
import { useDocuments } from './useDocuments'
const FILTERS: Array<{ value?: DocumentStatus; label: string }> = [
{ label: '全部资料' },
{ value: 'indexed', label: '已索引' },
{ value: 'indexing', label: '处理中' },
{ value: 'failed', label: '失败' },
]
export function DocumentsPage() {
const { documentId } = useParams()
const navigate = useNavigate()
const [filter, setFilter] = useState<DocumentStatus | undefined>()
const [detailOpen, setDetailOpen] = useState(false)
const { documents, loading, error, refresh } = useDocuments(filter)
const counts = useMemo(() => ({
indexed: documents.filter((item) => item.status === 'indexed').length,
active: documents.filter((item) => ['pending', 'indexing'].includes(item.status)).length,
failed: documents.filter((item) => item.status === 'failed').length,
}), [documents])
function selectDocument(id: string) { navigate(`/documents/${id}`); setDetailOpen(true) }
async function retry(id: string) { await retryDocument(id); await refresh() }
async function remove(id: string) {
const document = documents.find((item) => item.id === id)
if (!window.confirm(`确定删除“${document?.filename ?? '此文档'}”及全部索引吗?`)) return
await deleteDocument(id)
if (documentId === id) navigate('/documents')
await refresh()
}
return (
<section className="documents-workspace">
<main className="documents-main">
<header className="page-header">
<div><p className="eyebrow">DOCUMENT LIBRARY</p><h1></h1><p> Agent </p></div>
<UploadDocument onUploaded={(id) => { selectDocument(id); void refresh() }} />
</header>
<div className="metric-strip">
<div><Archive size={17} /><span><strong>{documents.length}</strong> </span></div>
<div><FileCheck2 size={17} /><span><strong>{counts.indexed}</strong> </span></div>
<div><LoaderCircle size={17} /><span><strong>{counts.active}</strong> </span></div>
<div><TriangleAlert size={17} /><span><strong>{counts.failed}</strong> </span></div>
</div>
<div className="document-toolbar">
<div className="filter-tabs"><Filter size={14} />{FILTERS.map((item) => <button key={item.label} className={cn(filter === item.value && 'is-active')} onClick={() => setFilter(item.value)}>{item.label}</button>)}</div>
{documentId && <Button className="detail-trigger" variant="secondary" size="sm" onClick={() => setDetailOpen(true)}><PanelRightOpen size={15} /></Button>}
</div>
{error && <p className="panel-error">{error}</p>}
{loading ? <div className="loading-state"><LoaderCircle className="spin" /></div> : <DocumentList documents={documents} selectedId={documentId} onSelect={selectDocument} onRetry={(id) => void retry(id)} onDelete={(id) => void remove(id)} />}
</main>
<div className="documents-detail-desktop"><DocumentDetail key={documentId ?? 'empty'} documentId={documentId} /></div>
<Dialog.Root open={detailOpen} onOpenChange={setDetailOpen}>
<Dialog.Portal><Dialog.Overlay className="dialog-overlay" /><Dialog.Content className="evidence-drawer"><Dialog.Title className="sr-only"></Dialog.Title><DocumentDetail key={`drawer-${documentId ?? 'empty'}`} documentId={documentId} onClose={() => setDetailOpen(false)} /></Dialog.Content></Dialog.Portal>
</Dialog.Root>
</section>
)
}
@@ -0,0 +1,39 @@
import { FileUp, LoaderCircle } from 'lucide-react'
import { useRef, useState } from 'react'
import { Button } from '../../components/ui/button'
import { uploadDocument } from './api'
type Props = { onUploaded: (documentId: string) => void }
export function UploadDocument({ onUploaded }: Props) {
const inputRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleFile(file?: File) {
if (!file) return
setUploading(true)
setError(null)
try {
const document = await uploadDocument(file)
onUploaded(document.id)
} catch (reason) {
setError(reason instanceof Error ? reason.message : '上传失败')
} finally {
setUploading(false)
if (inputRef.current) inputRef.current.value = ''
}
}
return (
<div className="upload-control">
<input ref={inputRef} className="sr-only" type="file" accept=".pdf,.md,.markdown,.txt" onChange={(event) => void handleFile(event.target.files?.[0])} />
<Button onClick={() => inputRef.current?.click()} disabled={uploading}>
{uploading ? <LoaderCircle className="spin" size={16} /> : <FileUp size={16} />}
{uploading ? '正在上传' : '上传文档'}
</Button>
{error && <p className="inline-error" role="alert">{error}</p>}
</div>
)
}
+32
View File
@@ -0,0 +1,32 @@
import { apiRequest } from '../../lib/api'
import type { Chunk, Document, DocumentStatus, Page } from '../../lib/types'
export function listDocuments(status?: DocumentStatus) {
const params = new URLSearchParams({ limit: '100' })
if (status) params.set('status', status)
return apiRequest<Page<Document>>(`/api/v1/documents?${params}`)
}
export function getDocument(documentId: string) {
return apiRequest<Document>(`/api/v1/documents/${documentId}`)
}
export function listChunks(documentId: string, offset = 0, limit = 20) {
return apiRequest<Page<Chunk>>(
`/api/v1/documents/${documentId}/chunks?offset=${offset}&limit=${limit}`,
)
}
export function uploadDocument(file: File) {
const form = new FormData()
form.append('file', file)
return apiRequest<Document>('/api/v1/documents', { method: 'POST', body: form })
}
export function retryDocument(documentId: string) {
return apiRequest<Document>(`/api/v1/documents/${documentId}/retry`, { method: 'POST' })
}
export function deleteDocument(documentId: string) {
return apiRequest<void>(`/api/v1/documents/${documentId}`, { method: 'DELETE' })
}
@@ -0,0 +1,52 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { Document, DocumentStatus } from '../../lib/types'
import { listDocuments } from './api'
const ACTIVE = new Set<DocumentStatus>(['pending', 'indexing', 'deleting'])
export function useDocuments(filter?: DocumentStatus) {
const [documents, setDocuments] = useState<Document[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const mounted = useRef(true)
const refresh = useCallback(async () => {
try {
const page = await listDocuments(filter)
if (mounted.current) {
setDocuments(page.items)
setError(null)
}
} catch (reason) {
if (mounted.current) setError(reason instanceof Error ? reason.message : '文档加载失败')
} finally {
if (mounted.current) setLoading(false)
}
}, [filter])
useEffect(() => {
mounted.current = true
listDocuments(filter)
.then((page) => {
if (!mounted.current) return
setDocuments(page.items)
setError(null)
})
.catch((reason) => {
if (mounted.current) setError(reason instanceof Error ? reason.message : '文档加载失败')
})
.finally(() => {
if (mounted.current) setLoading(false)
})
return () => { mounted.current = false }
}, [filter])
useEffect(() => {
if (!documents.some((document) => ACTIVE.has(document.status))) return
const timer = window.setInterval(() => void refresh(), 2_000)
return () => window.clearInterval(timer)
}, [documents, refresh])
return { documents, loading, error, refresh }
}
+260
View File
@@ -0,0 +1,260 @@
@import "tailwindcss";
:root {
font-family: "Avenir Next", "PingFang SC", sans-serif;
color: #25352f;
background: #e9eeeb;
font-synthesis: none;
text-rendering: optimizeLegibility;
--ink: #20342d;
--forest: #1f332c;
--forest-2: #2b473e;
--moss: #70897e;
--paper: #f8f7f2;
--paper-2: #efeee8;
--line: #d5dcd8;
--ember: #d6784d;
--ember-dark: #a95434;
--danger: #a4433c;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: #e9eeeb; }
button, input, textarea { font: inherit; }
button, a { -webkit-tap-highlight-color: transparent; }
a { color: inherit; text-decoration: none; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
.eyebrow { margin: 0; font-size: .68rem; font-weight: 700; letter-spacing: .18em; text-transform: uppercase; color: var(--ember-dark); }
.app-shell { display: grid; grid-template-columns: 236px minmax(0, 1fr); min-height: 100vh; }
.sidebar { position: sticky; top: 0; height: 100vh; padding: 22px 16px 18px; color: #edf3ef; background: var(--forest); display: flex; flex-direction: column; overflow: hidden; }
.sidebar::after { content: ""; position: absolute; inset: auto -80px -100px auto; width: 220px; height: 220px; border: 1px solid rgba(255,255,255,.09); border-radius: 50%; box-shadow: 0 0 0 34px rgba(255,255,255,.025), 0 0 0 68px rgba(255,255,255,.018); pointer-events: none; }
.brand-lockup { display: flex; align-items: center; gap: 11px; padding: 0 4px 22px; border-bottom: 1px solid rgba(255,255,255,.1); }
.brand-lockup__mark { display: inline-grid; place-items: center; width: 32px; height: 32px; flex: none; color: white; background: var(--ember); border-radius: 9px 9px 3px 9px; font-family: Georgia, serif; font-size: 1.05rem; box-shadow: 0 7px 18px rgba(0,0,0,.16); }
.brand-lockup strong { display: block; font-size: .74rem; letter-spacing: .13em; }
.brand-lockup small { display: block; margin-top: 2px; color: #99ada5; font-size: .58rem; letter-spacing: .06em; }
.nav-label { margin: 26px 8px 10px; color: #82988f; font-size: .62rem; font-weight: 700; letter-spacing: .16em; text-transform: uppercase; }
.primary-nav { display: grid; gap: 6px; }
.nav-item { display: flex; align-items: center; gap: 10px; padding: 10px 11px; border-radius: 8px; color: #bdcbc6; font-size: .85rem; transition: color .18s ease, background .18s ease, transform .18s ease; }
.nav-item:hover { color: white; background: rgba(255,255,255,.06); transform: translateX(2px); }
.nav-item--active { color: white; background: var(--forest-2); box-shadow: inset 3px 0 var(--ember); }
.sidebar-note { display: flex; gap: 9px; margin-top: auto; padding: 12px 10px; border: 1px solid rgba(255,255,255,.1); border-radius: 9px; background: rgba(0,0,0,.08); position: relative; z-index: 1; }
.status-dot { width: 7px; height: 7px; margin-top: 4px; flex: none; border-radius: 50%; background: #8ccba8; box-shadow: 0 0 0 4px rgba(140,203,168,.12); }
.sidebar-note strong, .sidebar-note small { display: block; }
.sidebar-note strong { font-size: .68rem; }
.sidebar-note small { margin-top: 3px; color: #91a69e; font-size: .59rem; line-height: 1.45; }
.sidebar-version { position: relative; z-index: 1; margin: 14px 8px 0; color: #627b71; font-size: .56rem; letter-spacing: .12em; }
.route-canvas { min-width: 0; min-height: 100vh; background: radial-gradient(circle at 90% 5%, rgba(214,120,77,.08), transparent 22%), var(--paper); }
.mobile-header { display: none; }
.placeholder-page { min-height: 100vh; display: grid; place-content: center; justify-items: center; padding: 40px; text-align: center; animation: page-enter .45s ease both; }
.placeholder-page__mark { display: grid; place-items: center; width: 58px; height: 58px; margin-bottom: 24px; color: var(--ember); border: 1px solid #d8c6bb; border-radius: 50%; background: rgba(255,255,255,.62); box-shadow: 0 15px 45px rgba(41,57,51,.08); }
.placeholder-page h1 { margin: 10px 0 8px; font-family: "Songti SC", Georgia, serif; font-size: clamp(2rem, 4vw, 3.8rem); font-weight: 600; letter-spacing: -.05em; }
.placeholder-page > p:last-child { margin: 0; color: #77847f; }
.button { display: inline-flex; align-items: center; justify-content: center; gap: 7px; border: 0; border-radius: 8px; cursor: pointer; font-weight: 650; transition: transform .15s ease, box-shadow .15s ease, background .15s ease; }
.button:focus-visible { outline: 3px solid rgba(214,120,77,.3); outline-offset: 2px; }
.button:disabled { opacity: .52; cursor: not-allowed; }
.button:not(:disabled):active { transform: translateY(1px); }
.button--md { min-height: 38px; padding: 0 14px; font-size: .8rem; }
.button--sm { min-height: 31px; padding: 0 10px; font-size: .72rem; }
.button--icon { width: 38px; height: 38px; padding: 0; }
.button--primary { color: white; background: var(--ember); box-shadow: 0 7px 18px rgba(166,79,48,.19); }
.button--primary:hover { background: #c9673f; }
.button--secondary { color: var(--ink); background: white; border: 1px solid var(--line); }
.button--ghost { color: inherit; background: transparent; }
.button--ghost:hover { background: rgba(72,94,85,.08); }
.button--danger { color: white; background: var(--danger); }
.badge { display: inline-flex; align-items: center; gap: 5px; min-height: 23px; padding: 0 8px; border-radius: 999px; background: var(--paper-2); color: #5b6b65; font-size: .66rem; font-weight: 700; }
.dialog-overlay { position: fixed; inset: 0; z-index: 50; background: rgba(13,24,20,.48); backdrop-filter: blur(4px); animation: fade-in .2s ease; }
.mobile-menu { position: fixed; inset: 0 auto 0 0; z-index: 51; width: min(310px, 88vw); padding: 22px 16px; color: #edf3ef; background: var(--forest); box-shadow: 24px 0 80px rgba(0,0,0,.3); animation: slide-in .25s ease; }
.mobile-menu__close { position: absolute; top: 12px; right: 10px; z-index: 2; }
.documents-workspace { display: grid; grid-template-columns: minmax(520px, 1fr) 340px; min-height: 100vh; }
.documents-main { min-width: 0; padding: 34px clamp(24px, 4vw, 58px); overflow: hidden; }
.page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; }
.page-header h1 { margin: 7px 0 6px; font-family: "Songti SC", Georgia, serif; font-size: clamp(2rem, 3vw, 3rem); font-weight: 600; letter-spacing: -.045em; }
.page-header p:not(.eyebrow) { margin: 0; color: #7b8782; font-size: .84rem; }
.upload-control { display: flex; align-items: center; gap: 10px; }
.inline-error { position: absolute; margin-top: 68px; max-width: 260px; color: var(--danger); font-size: .7rem; }
.metric-strip { margin: 30px 0 24px; padding: 13px 18px; display: grid; grid-template-columns: repeat(4, 1fr); border: 1px solid var(--line); border-radius: 11px; background: rgba(255,255,255,.6); box-shadow: 0 12px 40px rgba(38,54,48,.04); }
.metric-strip > div { display: flex; align-items: center; gap: 9px; min-width: 0; padding: 5px 14px; color: #75817d; border-right: 1px solid var(--line); }
.metric-strip > div:first-child { padding-left: 0; }
.metric-strip > div:last-child { border: 0; }
.metric-strip svg { flex: none; color: var(--ember); }
.metric-strip span { font-size: .7rem; white-space: nowrap; }
.metric-strip strong { margin-right: 3px; color: var(--ink); font-size: 1rem; }
.document-toolbar { min-height: 42px; margin-bottom: 12px; display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.filter-tabs { display: flex; align-items: center; gap: 3px; padding: 3px; border-radius: 8px; color: #77847f; background: #eaece8; }
.filter-tabs > svg { margin: 0 5px 0 4px; }
.filter-tabs button { min-height: 29px; padding: 0 10px; color: inherit; border: 0; border-radius: 6px; background: transparent; cursor: pointer; font-size: .68rem; }
.filter-tabs button.is-active { color: var(--ink); background: white; box-shadow: 0 2px 9px rgba(35,51,45,.08); }
.detail-trigger { display: none; }
.document-list { display: grid; gap: 7px; }
.document-row { position: relative; min-height: 74px; padding: 12px 45px 12px 12px; display: grid; grid-template-columns: 42px minmax(140px, 1fr) auto auto; align-items: center; gap: 12px; border: 1px solid transparent; border-radius: 10px; cursor: pointer; transition: border .16s ease, background .16s ease, transform .16s ease; }
.document-row:hover { background: rgba(255,255,255,.7); transform: translateY(-1px); }
.document-row--selected { border-color: #c9d4ce; background: white; box-shadow: 0 8px 28px rgba(40,56,50,.07); }
.file-glyph { width: 42px; height: 45px; display: grid; place-items: center; color: #9a583a; border: 1px solid #e4c9b9; border-radius: 7px 7px 3px 7px; background: #fff5ef; font-size: .57rem; font-weight: 800; letter-spacing: .05em; }
.document-row__body { min-width: 0; }
.document-row__body strong, .document-row__body span, .document-row__body small { display: block; }
.document-row__body strong { overflow: hidden; color: #263a33; font-size: .82rem; text-overflow: ellipsis; white-space: nowrap; }
.document-row__body span { margin-top: 5px; color: #83908b; font-size: .66rem; }
.document-row__body small { margin-top: 4px; overflow: hidden; color: var(--danger); font-size: .62rem; text-overflow: ellipsis; white-space: nowrap; }
.status > span { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.status--indexed { color: #38775e; background: #e5f1eb; }
.status--indexing, .status--pending { color: #9b653f; background: #f8eadf; }
.status--failed { color: #a4433c; background: #f8e5e3; }
.status--deleting { color: #697671; }
.row-actions { display: flex; align-items: center; }
.row-actions .button { color: #74817c; }
.row-cue { position: absolute; right: 14px; color: #a8b1ad; }
.list-empty, .loading-state { min-height: 310px; display: grid; place-content: center; justify-items: center; gap: 8px; color: #8a9691; text-align: center; }
.list-empty svg { color: var(--ember); }
.list-empty strong { color: var(--ink); font-family: "Songti SC", Georgia, serif; font-size: 1.05rem; }
.list-empty span { max-width: 280px; font-size: .74rem; }
.loading-state { grid-auto-flow: column; font-size: .76rem; }
.spin { animation: spin .9s linear infinite; }
.panel-error { padding: 10px 12px; color: var(--danger); border: 1px solid #e8c6c2; border-radius: 7px; background: #fff0ee; font-size: .72rem; }
.documents-detail-desktop { min-width: 0; border-left: 1px solid var(--line); background: #f0f3f1; }
.detail-panel { position: sticky; top: 0; height: 100vh; padding: 28px 22px 18px; display: flex; flex-direction: column; overflow: hidden; }
.detail-panel--empty { align-items: center; justify-content: center; gap: 7px; color: #84908b; text-align: center; }
.detail-panel--empty svg { margin-bottom: 5px; color: var(--ember); }
.detail-panel--empty strong { color: var(--ink); font-family: "Songti SC", Georgia, serif; }
.detail-panel--empty span { max-width: 230px; font-size: .7rem; line-height: 1.6; }
.detail-panel__header { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
.detail-panel__header h2 { margin: 7px 0 0; overflow: hidden; font-family: "Songti SC", Georgia, serif; font-size: 1.25rem; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.document-meta { margin: 20px 0 16px; padding: 10px 0; display: grid; grid-template-columns: repeat(3, 1fr); border-block: 1px solid var(--line); }
.document-meta div { padding: 0 8px; border-right: 1px solid var(--line); }
.document-meta div:first-child { padding-left: 0; }
.document-meta div:last-child { border: 0; }
.document-meta dt { color: #8a9591; font-size: .58rem; text-transform: uppercase; }
.document-meta dd { margin: 4px 0 0; color: var(--ink); font-size: .7rem; font-weight: 700; }
.chunk-list { min-height: 0; flex: 1; display: grid; align-content: start; gap: 9px; overflow-y: auto; scrollbar-width: thin; }
.chunk-card { padding: 11px 12px; border: 1px solid #d9dfdc; border-radius: 8px; background: rgba(255,255,255,.72); }
.chunk-card > div { display: flex; gap: 7px; color: var(--ember-dark); font-size: .58rem; font-weight: 800; letter-spacing: .04em; }
.chunk-card p { margin: 7px 0 0; display: -webkit-box; overflow: hidden; color: #50605a; font-family: "Songti SC", Georgia, serif; font-size: .72rem; line-height: 1.7; -webkit-box-orient: vertical; -webkit-line-clamp: 5; }
.muted-copy { color: #8a9691; font-size: .72rem; line-height: 1.7; }
.detail-pagination { padding-top: 9px; display: flex; align-items: center; justify-content: center; gap: 10px; color: #84908b; border-top: 1px solid var(--line); font-size: .65rem; }
.evidence-drawer { position: fixed; inset: 0 0 0 auto; z-index: 51; width: min(390px, 92vw); background: #f0f3f1; box-shadow: -24px 0 80px rgba(0,0,0,.2); animation: drawer-in .25s ease; }
.session-rail { min-height: 0; margin-top: 14px; display: flex; flex: 1; flex-direction: column; }
.new-session { width: 100%; justify-content: flex-start; }
.session-rail .nav-label { margin-top: 18px; }
.session-list { min-height: 0; display: grid; align-content: start; gap: 4px; overflow-y: auto; scrollbar-width: thin; }
.session-item { display: grid; grid-template-columns: minmax(0, 1fr) 30px; align-items: center; border-radius: 7px; color: #aebdb7; }
.session-item > button:first-child { min-width: 0; padding: 8px 7px; color: inherit; border: 0; background: transparent; text-align: left; cursor: pointer; }
.session-item strong, .session-item span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.session-item strong { font-size: .68rem; font-weight: 650; }
.session-item span { margin-top: 3px; color: #748b82; font-size: .56rem; }
.session-item .button { width: 28px; height: 28px; opacity: 0; color: #91a49d; }
.session-item:hover { background: rgba(255,255,255,.05); }
.session-item:hover .button { opacity: 1; }
.session-item--active { color: white; background: var(--forest-2); box-shadow: inset 3px 0 var(--ember); }
.session-empty { margin: 8px; color: #70867d; font-size: .62rem; }
.chat-workspace { display: grid; grid-template-columns: minmax(500px, 1fr) 330px; min-height: 100vh; }
.chat-main { min-width: 0; height: 100vh; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; background: rgba(250,250,247,.82); }
.chat-header { min-height: 78px; padding: 20px clamp(20px, 3vw, 38px); display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); background: rgba(255,255,255,.62); backdrop-filter: blur(12px); }
.chat-header h1 { margin: 5px 0 0; font-family: "Songti SC", Georgia, serif; font-size: 1.35rem; font-weight: 600; }
.evidence-trigger { display: none; }
.conversation-scroll { min-height: 0; padding: 26px clamp(20px, 4vw, 54px); overflow-y: auto; scroll-behavior: smooth; }
.chat-empty { min-height: 100%; display: grid; place-content: center; justify-items: center; text-align: center; }
.chat-empty > div { width: 57px; height: 57px; margin-bottom: 19px; display: grid; place-items: center; color: var(--ember); border: 1px solid #ddc9bd; border-radius: 50%; background: white; box-shadow: 0 16px 45px rgba(39,55,49,.08); }
.chat-empty h2 { margin: 8px 0 7px; font-family: "Songti SC", Georgia, serif; font-size: 1.45rem; font-weight: 600; }
.chat-empty > span { max-width: 360px; color: #7e8a85; font-size: .75rem; line-height: 1.7; }
.message-list, .streaming-turn { display: grid; gap: 21px; }
.streaming-turn { margin-top: 21px; }
.message { display: grid; grid-template-columns: 28px minmax(0, 1fr); gap: 10px; align-items: start; animation: page-enter .25s ease both; }
.message--user { margin-left: clamp(20px, 15%, 110px); grid-template-columns: minmax(0, 1fr); }
.message--user .message-body { justify-self: end; max-width: 82%; padding: 10px 13px; border-radius: 12px 12px 3px 12px; background: #e2ebe7; }
.message--user .message-avatar { display: none; }
.message-avatar { width: 28px; height: 28px; display: grid; place-items: center; color: white; border-radius: 50%; background: var(--ember); box-shadow: 0 5px 14px rgba(172,86,53,.18); }
.message-body { min-width: 0; color: #374943; }
.message-body header { margin: 2px 0 7px; display: flex; align-items: baseline; gap: 8px; }
.message-body header strong { font-size: .72rem; }
.message-body header time { color: #929c98; font-size: .58rem; }
.message-body p { margin: 0; white-space: pre-wrap; font-size: .82rem; line-height: 1.82; }
.answer-copy { font-family: "Songti SC", Georgia, serif; }
.citation { display: inline-flex; margin: 0 2px; padding: 1px 5px; color: #a75435; border: 1px solid #dfc1af; border-radius: 4px; background: #fff5ef; cursor: pointer; font-family: "Avenir Next", sans-serif; font-size: .62rem; font-weight: 800; vertical-align: .08em; }
.citation--deleted { color: #8d9692; border-color: #d9dddb; background: #f0f1ee; cursor: default; }
.failed-message { display: flex; align-items: center; gap: 7px; color: var(--danger); }
.research-state { display: flex; align-items: center; gap: 8px; color: #73827c; font-family: "Avenir Next", sans-serif; font-size: .72rem !important; }
.typing-cursor { display: inline-block; width: 2px; height: 1em; margin-left: 3px; background: var(--ember); animation: blink .8s step-end infinite; vertical-align: -.12em; }
.pulse { color: var(--ember); animation: pulse 1.2s ease-in-out infinite; }
.chat-composer { position: relative; margin: 0 clamp(20px, 4vw, 54px) 20px; padding: 10px 10px 24px 13px; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 9px; border: 1px solid #cdd7d2; border-radius: 11px; background: white; box-shadow: 0 10px 35px rgba(37,54,47,.08); }
.chat-composer textarea { width: 100%; min-height: 45px; max-height: 140px; padding: 5px 0; resize: none; color: var(--ink); border: 0; outline: 0; background: transparent; font-size: .8rem; line-height: 1.55; }
.chat-composer textarea::placeholder { color: #9aa4a0; }
.chat-composer small { position: absolute; bottom: 7px; left: 13px; color: #a0aaa6; font-size: .54rem; }
.evidence-desktop { min-width: 0; border-left: 1px solid var(--line); background: #f0f3f1; }
.evidence-panel { height: 100vh; padding: 22px 17px 16px; display: flex; flex-direction: column; overflow: hidden; }
.evidence-header { min-height: 47px; display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: start; gap: 7px; }
.evidence-header h2 { margin: 5px 0 0; font-family: "Songti SC", Georgia, serif; font-size: 1.15rem; font-weight: 600; }
.evidence-header > span { margin-top: 7px; color: #82908a; font-size: .62rem; }
.evidence-empty { flex: 1; display: grid; place-content: center; justify-items: center; gap: 7px; color: #85918c; text-align: center; }
.evidence-empty svg { margin-bottom: 4px; color: var(--ember); }
.evidence-empty strong { color: var(--ink); font-family: "Songti SC", Georgia, serif; }
.evidence-empty span { max-width: 220px; font-size: .68rem; line-height: 1.6; }
.source-list { max-height: 42%; margin: 16px 0 11px; display: grid; align-content: start; gap: 7px; overflow-y: auto; }
.source-card { width: 100%; padding: 10px; display: grid; grid-template-columns: 18px minmax(0, 1fr); gap: 7px; color: #52625c; border: 1px solid #d8dfdb; border-radius: 8px; background: rgba(255,255,255,.64); cursor: pointer; text-align: left; }
.source-card > svg { margin-top: 1px; color: var(--ember); }
.source-card strong, .source-card small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.source-card strong { color: #354a42; font-size: .68rem; }
.source-card small { margin-top: 4px; color: #89948f; font-size: .58rem; }
.source-card--active { border-color: #d2a78e; background: white; box-shadow: inset 3px 0 var(--ember), 0 6px 20px rgba(41,58,51,.05); }
.source-detail { min-height: 0; flex: 1; padding: 14px; overflow-y: auto; color: #e7efeb; border-radius: 10px; background: var(--forest); }
.source-detail header { display: flex; justify-content: space-between; gap: 10px; }
.source-detail header strong { font-size: .68rem; }
.source-detail header span { color: #9fb1aa; font-size: .57rem; }
.source-detail p { margin: 12px 0 0; white-space: pre-wrap; color: #d8e2de; font-family: "Songti SC", Georgia, serif; font-size: .72rem; line-height: 1.85; }
@keyframes blink { 50% { opacity: 0; } }
@keyframes pulse { 50% { opacity: .45; transform: scale(.92); } }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes drawer-in { from { transform: translateX(100%); } }
@keyframes page-enter { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
@keyframes fade-in { from { opacity: 0; } }
@keyframes slide-in { from { transform: translateX(-100%); } }
@media (max-width: 767px) {
.app-shell { display: block; padding-top: 58px; }
.sidebar { display: none; }
.mobile-header { position: fixed; inset: 0 0 auto; z-index: 40; height: 58px; padding: 0 14px; display: flex; align-items: center; gap: 9px; color: #edf3ef; background: var(--forest); box-shadow: 0 4px 20px rgba(25,45,37,.18); }
.mobile-header .brand-lockup__mark { width: 29px; height: 29px; }
.mobile-header strong { font-size: .7rem; letter-spacing: .1em; }
.mobile-header > :last-child { margin-left: auto; }
.route-canvas { min-height: calc(100vh - 58px); }
}
@media (max-width: 1099px) {
.documents-workspace { display: block; }
.documents-detail-desktop { display: none; }
.detail-trigger { display: inline-flex; }
.chat-workspace { display: block; }
.evidence-desktop { display: none; }
.evidence-trigger { display: inline-flex; }
}
@media (max-width: 640px) {
.documents-main { padding: 24px 16px; }
.page-header { display: grid; }
.metric-strip { grid-template-columns: 1fr 1fr; gap: 7px; }
.metric-strip > div { border: 0; padding: 5px; }
.document-toolbar { align-items: flex-start; }
.filter-tabs { max-width: 100%; overflow-x: auto; }
.document-row { grid-template-columns: 40px minmax(0, 1fr); padding-right: 38px; }
.document-row .status { grid-column: 2; justify-self: start; }
.row-actions { grid-column: 2; }
.chat-main { height: calc(100vh - 58px); }
.chat-header { padding: 14px 16px; }
.conversation-scroll { padding: 20px 16px; }
.chat-composer { margin: 0 12px 12px; }
.message--user { margin-left: 16px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
}
+41
View File
@@ -0,0 +1,41 @@
import type { ApiErrorBody } from './types'
export class ApiError extends Error {
readonly code: string
readonly requestId: string
readonly retryable: boolean
readonly status: number
constructor(
message: string,
code: string,
requestId: string,
retryable = false,
status = 0,
) {
super(message)
this.name = 'ApiError'
this.code = code
this.requestId = requestId
this.retryable = retryable
this.status = status
}
}
export async function apiRequest<T>(path: string, init?: RequestInit): Promise<T> {
const headers = new Headers(init?.headers)
if (init?.body && !(init.body instanceof FormData)) headers.set('Content-Type', 'application/json')
const response = await fetch(path, { ...init, headers })
if (!response.ok) {
const body = (await response.json().catch(() => null)) as ApiErrorBody | null
throw new ApiError(
body?.error.message ?? `请求失败(${response.status}`,
body?.error.code ?? 'HTTP_ERROR',
body?.error.request_id ?? response.headers.get('X-Request-ID') ?? '',
body?.error.retryable ?? false,
response.status,
)
}
if (response.status === 204) return undefined as T
return (await response.json()) as T
}
+47
View File
@@ -0,0 +1,47 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { parseSSEBlock, streamSSE } from './sse'
afterEach(() => vi.restoreAllMocks())
describe('parseSSEBlock', () => {
it('parses Chinese token events with CRLF framing', () => {
expect(parseSSEBlock('event: token\r\ndata: {"content":"知识"}')).toEqual({
event: 'token',
data: { content: '知识' },
})
})
it('ignores heartbeat comments', () => {
expect(parseSSEBlock(': ping')).toBeNull()
})
})
describe('streamSSE', () => {
it('incrementally decodes arbitrary UTF-8 chunks, CRLF, multiple events, and a tail event', async () => {
const bytes = new TextEncoder().encode(
'event: status\r\ndata: {"phase":"researching"}\r\n\r\n' +
'event: token\ndata: {"content":"知识"}\n\n' +
'event: done\ndata: {"message_id":"m1"}',
)
const chunks = [bytes.slice(0, 7), bytes.slice(7, 58), bytes.slice(58, 61), bytes.slice(61)]
const body = new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) controller.enqueue(chunk)
controller.close()
},
})
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(body))
const controller = new AbortController()
const events: unknown[] = []
await streamSSE('/stream', { query: 'q' }, (event) => events.push(event), controller.signal)
expect(events).toEqual([
{ event: 'status', data: { phase: 'researching' } },
{ event: 'token', data: { content: '知识' } },
{ event: 'done', data: { message_id: 'm1' } },
])
expect(fetchMock).toHaveBeenCalledWith('/stream', expect.objectContaining({ signal: controller.signal }))
})
})
+60
View File
@@ -0,0 +1,60 @@
import { ApiError } from './api'
import type { SSEEvent } from './types'
export function parseSSEBlock(block: string): SSEEvent | null {
let eventName = ''
const dataLines: string[] = []
for (const line of block.split(/\r?\n/)) {
if (!line || line.startsWith(':')) continue
if (line.startsWith('event:')) eventName = line.slice(6).trim()
if (line.startsWith('data:')) dataLines.push(line.slice(5).trimStart())
}
if (!eventName || dataLines.length === 0) return null
return { event: eventName, data: JSON.parse(dataLines.join('\n')) } as SSEEvent
}
export async function streamSSE(
path: string,
body: unknown,
onEvent: (event: SSEEvent) => void,
signal: AbortSignal,
): Promise<void> {
const response = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal,
})
if (!response.ok) {
const payload = await response.json().catch(() => null)
throw new ApiError(
payload?.error?.message ?? `请求失败(${response.status}`,
payload?.error?.code ?? 'HTTP_ERROR',
payload?.error?.request_id ?? response.headers.get('X-Request-ID') ?? '',
payload?.error?.retryable ?? false,
response.status,
)
}
if (!response.body) throw new ApiError('浏览器未收到流式响应', 'EMPTY_STREAM', '', true)
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { value, done } = await reader.read()
buffer += decoder.decode(value, { stream: !done }).replace(/\r\n/g, '\n')
let boundary = buffer.indexOf('\n\n')
while (boundary >= 0) {
const block = buffer.slice(0, boundary)
buffer = buffer.slice(boundary + 2)
const event = parseSSEBlock(block)
if (event) onEvent(event)
boundary = buffer.indexOf('\n\n')
}
if (done) break
}
if (buffer.trim()) {
const event = parseSSEBlock(buffer)
if (event) onEvent(event)
}
}
+80
View File
@@ -0,0 +1,80 @@
export type Page<T> = {
items: T[]
total: number
offset: number
limit: number
}
export type DocumentStatus = 'pending' | 'indexing' | 'indexed' | 'failed' | 'deleting'
export type Document = {
id: string
filename: string
file_type: 'pdf' | 'md' | 'txt'
mime_type: string
file_size: number
status: DocumentStatus
chunk_count: number
error_code: string | null
error_message: string | null
created_at: string
updated_at: string
}
export type Chunk = {
id: string
document_id: string
order_index: number
content: string
page_number: number | null
char_count: number
created_at: string
}
export type Session = {
id: string
title: string | null
created_at: string
updated_at: string
}
export type Source = {
label: string
document_id: string
chunk_id: string
filename: string
order_index: number
page_number: number | null
content: string
score: number
}
export type Message = {
id: string
session_id: string
role: 'user' | 'assistant'
content: string
status: 'generating' | 'completed' | 'failed'
error_code: string | null
order_index: number
created_at: string
sources: Source[]
deleted_source_labels: string[]
}
export type ApiErrorBody = {
error: {
code: string
message: string
request_id: string
retryable?: boolean
details?: unknown
}
}
export type SSEEvent =
| { event: 'status'; data: { phase: 'researching' | 'answering' } }
| { event: 'sources'; data: { sources: Source[] } }
| { event: 'token'; data: { content: string } }
| { event: 'done'; data: { message_id: string } }
| { event: 'error'; data: { code: string; message: string; retryable: boolean } }
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+11
View File
@@ -0,0 +1,11 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { RouterProvider } from 'react-router-dom'
import './index.css'
import { router } from './app/router'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
)
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
proxy: {
'/api': 'http://127.0.0.1:8003',
'/health': 'http://127.0.0.1:8003',
},
},
})