Compare commits
10 Commits
c0523d0d95
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 49e8d5da2a | |||
| 01b5a29b62 | |||
| 2361d62867 | |||
| 52184aa00e | |||
| 6e3f4b8d04 | |||
| 52e14986d4 | |||
| 2c220a0b5f | |||
| 84e6f1f51a | |||
| 321cf13c16 | |||
| 5a18c24d62 |
@@ -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
|
||||
|
||||
@@ -28,3 +28,5 @@ coverage/
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
*.txt
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
## 普通验证
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""LangChain 1.x dual-agent workflow."""
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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))
|
||||
@@ -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. 如果资料不足,明确说明知识库中没有足够资料。
|
||||
"""
|
||||
@@ -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=(",", ":"))
|
||||
@@ -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)
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
"查询向量化失败,请稍后重试",
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,5 @@
|
||||
# 星槐项目资料
|
||||
|
||||
星槐项目的内部代号是 **ORCHID-731**。项目负责人是林岚,首次评审日期为 2026 年 6 月 18 日。
|
||||
|
||||
系统采用严格知识库回答方式:没有资料时必须说明证据不足。
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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)))
|
||||
@@ -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"
|
||||
@@ -0,0 +1,93 @@
|
||||
# KBQA MVP 真实链路验收报告
|
||||
|
||||
- 验收时间:2026-07-13(Asia/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 Agent,SSE 依次产生 `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)。
|
||||
报告不包含任何密钥。
|
||||
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 75 KiB |
@@ -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?
|
||||
@@ -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 }]
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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 }],
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 9.3 KiB |
@@ -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 |
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 /> },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
After Width: | Height: | Size: 13 KiB |
@@ -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 |
|
After Width: | Height: | Size: 8.5 KiB |
@@ -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} />
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'))
|
||||
}
|
||||
@@ -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),
|
||||
}))
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }))
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 } }
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
})
|
||||