test: verify real kbqa workflow

This commit is contained in:
gqt
2026-07-13 14:23:06 +08:00
parent 52184aa00e
commit 2361d62867
32 changed files with 656 additions and 12 deletions
+1
View File
@@ -0,0 +1 @@
*.pdf binary
+1
View File
@@ -26,6 +26,7 @@ def create_agent_bundle(settings: Settings) -> AgentBundle:
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",
+1
View File
@@ -9,6 +9,7 @@ def create_chat_model(settings: Settings) -> 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,
+7 -3
View File
@@ -5,10 +5,13 @@ 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_research(messages: list) -> bool:
def _has_current_tool(messages: list, tool_name: str) -> bool:
for message in reversed(messages):
if isinstance(message, ToolMessage) and message.name == "research":
if isinstance(message, ToolMessage) and message.name == tool_name:
return True
if isinstance(message, HumanMessage):
return False
@@ -19,5 +22,6 @@ class RequireResearchMiddleware(AgentMiddleware):
request: ModelRequest,
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
) -> ModelResponse:
tool_choice = "none" if self._has_current_research(request.messages) else "required"
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))
+4 -2
View File
@@ -14,6 +14,7 @@ class MilvusVectorStore:
def ensure_collection(self) -> None:
if self.client.has_collection(self.collection_name):
self.client.load_collection(self.collection_name)
return
schema = self.client.create_schema(auto_id=False, enable_dynamic_field=False)
schema.add_field(
@@ -44,6 +45,7 @@ class MilvusVectorStore:
schema=schema,
index_params=index_params,
)
self.client.load_collection(self.collection_name)
def upsert(self, chunks: list[PreparedChunk], vectors: list[list[float]]) -> None:
if len(chunks) != len(vectors):
@@ -67,7 +69,7 @@ class MilvusVectorStore:
data=[vector],
anns_field="vector",
limit=limit,
output_fields=["document_id", "order_index"],
output_fields=["chunk_id", "document_id", "order_index"],
search_params={"metric_type": "COSINE"},
)
hits: list[VectorHit] = []
@@ -75,7 +77,7 @@ class MilvusVectorStore:
entity = item.get("entity", {})
hits.append(
VectorHit(
chunk_id=str(item["id"]),
chunk_id=str(entity["chunk_id"]),
document_id=str(entity["document_id"]),
order_index=int(entity["order_index"]),
score=float(item["distance"]),
+22
View File
@@ -0,0 +1,22 @@
from collections.abc import AsyncIterator
import pytest_asyncio
from sqlalchemy import event
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from kbqa.database import Base
import kbqa.models # noqa: F401
@pytest_asyncio.fixture
async def sqlite_engine(tmp_path) -> AsyncIterator[AsyncEngine]:
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'test.sqlite3'}")
@event.listens_for(engine.sync_engine, "connect")
def enable_foreign_keys(connection, _record) -> None:
connection.execute("PRAGMA foreign_keys=ON")
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
+5
View File
@@ -0,0 +1,5 @@
# 星槐项目资料
星槐项目的内部代号是 **ORCHID-731**。项目负责人是林岚,首次评审日期为 2026 年 6 月 18 日。
系统采用严格知识库回答方式:没有资料时必须说明证据不足。
+89
View File
@@ -0,0 +1,89 @@
%PDF-1.3
%“Œ‹ž ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R /F2 3 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /STSong-Light /DescendantFonts [ <<
/BaseFont /STSong-Light /CIDSystemInfo <<
/Ordering (GB1) /Registry (Adobe) /Supplement 0
>> /DW 1000 /FontDescriptor <<
/Ascent 752 /CapHeight 737 /Descent -271 /Flags 6 /FontBBox [ -25 -254 1000 880 ] /FontName /STSongStd-Light
/ItalicAngle 0 /Leading 148 /MaxWidth 1000 /MissingWidth 500 /StemH 91 /StemV 58
/Type /FontDescriptor /XHeight 553
>> /Subtype /CIDFontType0 /Type /Font
/W [ 1 [ 207 270 342 467 462 797 710 239 374 ] 10 [ 374 423 605 238 375 238 334 462 ] 18 26 462 27 28 238
29 31 605 32 [ 344 748 684 560 695 739 563 511 729 793
318 312 666 526 896 758 772 544 772 628
465 607 753 711 972 647 620 607 374 333
374 606 500 239 417 503 427 529 415 264
444 518 241 230 495 228 793 527 524 ] 81 [ 524 504 338 336 277 517 450 652 466 452
407 370 258 370 605 ] ]
>> ] /Encoding /UniGB-UCS2-H /Name /F2 /Subtype /Type0 /Type /Font
>>
endobj
4 0 obj
<<
/Contents 8 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 7 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
5 0 obj
<<
/PageMode /UseNone /Pages 7 0 R /Type /Catalog
>>
endobj
6 0 obj
<<
/Author (anonymous) /CreationDate (D:20260713120326+08'00') /Creator (anonymous) /Keywords () /ModDate (D:20260713120326+08'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (unspecified) /Title (Northwind Archive) /Trapped /False
>>
endobj
7 0 obj
<<
/Count 1 /Kids [ 4 0 R ] /Type /Pages
>>
endobj
8 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 324
>>
stream
GarnP9hPRS%))C:jJ[ZI5%\uh*fCe"=f%[YTn5r';V54D,5BneL:V6(1@QXO-1AMYTKOdWp&ME-P;rXuj:+p&#KVsFQUdmu5dBtI6^RKJM'40ugn2hs//:/o$`[%R[G?92TM2)sdJeC&2NBC5D]rI.HVr1P1&pVfEH""-4SS_L`pgS'>,@IBXHk>9g.W1m_4bk94'XkO"$-R?>/f)A4K1mL<"AQ'-t^<GF#3K#=m^U.:Tl`Q9DD:d&,>.Q?/6nV[TF=DbJ_lha*>qb8)_]&^pQdj,t'O0XuO?P<E4k*DW#$(Cl<8rh3=q\rB:3Nhp7=2eG~>endstream
endobj
xref
0 9
0000000000 65535 f
0000000061 00000 n
0000000102 00000 n
0000000209 00000 n
0000001142 00000 n
0000001345 00000 n
0000001413 00000 n
0000001683 00000 n
0000001742 00000 n
trailer
<<
/ID
[<2d05274fa2bda2ab799503a41bce39e6><2d05274fa2bda2ab799503a41bce39e6>]
% ReportLab generated PDF document -- digest (opensource)
/Info 6 0 R
/Root 5 0 R
/Size 9
>>
startxref
2156
%%EOF
+2
View File
@@ -0,0 +1,2 @@
青屿实验的校准周期是每 14 天一次。校准负责人是周禾。
如果温度超过 32 摄氏度,应暂停实验并记录环境状态。
@@ -0,0 +1,38 @@
from collections.abc import AsyncIterator
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
from kbqa.api.errors import register_exception_handlers
from kbqa.api.middleware import RequestIDMiddleware
from kbqa.chat.routes import router
from kbqa.database import get_session
async def test_session_api_persists_and_deletes(sqlite_engine: AsyncEngine) -> None:
factory = async_sessionmaker(sqlite_engine, expire_on_commit=False)
app = FastAPI()
app.add_middleware(RequestIDMiddleware)
register_exception_handlers(app)
app.include_router(router, prefix="/api/v1")
async def session_override() -> AsyncIterator[AsyncSession]:
async with factory() as session:
yield session
app.dependency_overrides[get_session] = session_override
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
created = await client.post("/api/v1/sessions", json={"title": "持久化测试"})
assert created.status_code == 201
session_id = created.json()["id"]
listed = await client.get("/api/v1/sessions")
assert listed.json()["items"][0]["id"] == session_id
messages = await client.get(f"/api/v1/sessions/{session_id}/messages")
assert messages.json()["items"] == []
deleted = await client.delete(f"/api/v1/sessions/{session_id}")
assert deleted.status_code == 204
missing = await client.get(f"/api/v1/sessions/{session_id}/messages")
assert missing.status_code == 404
@@ -0,0 +1,56 @@
from sqlalchemy import event, select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from kbqa.chat.models import ChatSession, Message
from kbqa.database import Base
from kbqa.documents.models import Document, IndexJob
import kbqa.models # noqa: F401
async def test_sqlite_cascades_document_and_session(tmp_path) -> None:
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'test.db'}")
@event.listens_for(engine.sync_engine, "connect")
def enable_foreign_keys(connection, _record) -> None:
connection.execute("PRAGMA foreign_keys=ON")
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
factory = async_sessionmaker(engine, expire_on_commit=False)
async with factory() as session:
document = Document(
id="doc",
filename="a.txt",
stored_path="a",
file_type="txt",
mime_type="text/plain",
file_size=1,
sha256="0" * 64,
status="pending",
chunk_count=0,
)
session.add_all(
[
document,
IndexJob(id="job", document_id="doc", status="queued", attempt_count=0),
ChatSession(id="session"),
]
)
await session.commit()
session.add(
Message(
id="message",
session_id="session",
role="user",
content="q",
status="completed",
order_index=0,
)
)
await session.commit()
await session.delete(document)
await session.delete(await session.get(ChatSession, "session"))
await session.commit()
assert await session.scalar(select(IndexJob).where(IndexJob.id == "job")) is None
assert await session.scalar(select(Message).where(Message.id == "message")) is None
await engine.dispose()
+233
View File
@@ -0,0 +1,233 @@
import json
import os
from pathlib import Path
import socket
import sqlite3
import subprocess
import time
import httpx
import pytest
from pymilvus import MilvusClient
pytestmark = pytest.mark.live
ROOT = Path(__file__).resolve().parents[3]
FIXTURES = ROOT / "backend" / "tests" / "fixtures"
FIXTURE_PATHS = tuple(
FIXTURES / name for name in ("knowledge.md", "knowledge.txt", "knowledge.pdf")
)
def _port() -> int:
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])
def _start_server(port: int, env: dict[str, str]) -> subprocess.Popen[str]:
process = subprocess.Popen(
[
str(ROOT / "backend" / ".venv" / "bin" / "python"),
"-m",
"uvicorn",
"kbqa.main:app",
"--app-dir",
"backend/src",
"--host",
"127.0.0.1",
"--port",
str(port),
],
cwd=ROOT,
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.STDOUT,
text=True,
)
base_url = f"http://127.0.0.1:{port}"
for _ in range(120):
if process.poll() is not None:
raise RuntimeError("live Uvicorn exited during startup")
try:
if httpx.get(f"{base_url}/health", timeout=1).status_code == 200:
return process
except httpx.HTTPError:
pass
time.sleep(0.25)
process.terminate()
raise TimeoutError("live Uvicorn did not become healthy")
def _stop_server(process: subprocess.Popen[str]) -> None:
process.terminate()
try:
process.wait(timeout=15)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=5)
def _stream(client: httpx.Client, session_id: str, query: str) -> list[tuple[str, dict]]:
events: list[tuple[str, dict]] = []
with client.stream(
"POST", "/api/v1/chat/stream", json={"session_id": session_id, "query": query}, timeout=180
) as response:
response.raise_for_status()
event_name = ""
for line in response.iter_lines():
if line.startswith("event: "):
event_name = line.removeprefix("event: ")
elif line.startswith("data: "):
events.append((event_name, json.loads(line.removeprefix("data: "))))
return events
def _answer(events: list[tuple[str, dict]]) -> str:
return "".join(data["content"] for name, data in events if name == "token")
def _assert_flow(events: list[tuple[str, dict]]) -> None:
stages = [
f"status:{data['phase']}" if name == "status" else name
for name, data in events
if name != "ping"
]
expected = ["status:researching", "sources", "status:answering", "token", "done"]
positions = [stages.index(stage) for stage in expected]
assert positions == sorted(positions), stages
def test_real_model_pipeline_survives_restart_and_cleans_up(tmp_path) -> None:
if os.environ.get("KBQA_RUN_LIVE_TESTS") != "1":
pytest.skip("set KBQA_RUN_LIVE_TESTS=1 to call real models")
database = tmp_path / "kbqa.sqlite3"
milvus = tmp_path / "milvus.db"
raw = tmp_path / "raw"
env = os.environ.copy()
env.update(
{
"KBQA_DATABASE_URL": f"sqlite+aiosqlite:///{database}",
"KBQA_MILVUS_URI": str(milvus),
"KBQA_RAW_DATA_DIR": str(raw),
"KBQA_RUN_LIVE_TESTS": "1",
}
)
subprocess.run(
[
str(ROOT / "backend" / ".venv" / "bin" / "alembic"),
"-c",
"backend/alembic.ini",
"upgrade",
"head",
],
cwd=ROOT,
env=env,
check=True,
capture_output=True,
text=True,
)
port = _port()
process = _start_server(port, env)
base_url = f"http://127.0.0.1:{port}"
document_ids: list[str] = []
try:
with httpx.Client(base_url=base_url, timeout=30) as client:
for path in FIXTURE_PATHS:
with path.open("rb") as file_handle:
response = client.post(
"/api/v1/documents", files={"file": (path.name, file_handle)}
)
response.raise_for_status()
document_ids.append(response.json()["id"])
deadline = time.monotonic() + 120
while time.monotonic() < deadline:
documents = client.get("/api/v1/documents", params={"limit": 100}).json()["items"]
if all(item["status"] == "indexed" for item in documents):
break
assert not any(item["status"] == "failed" for item in documents), documents
time.sleep(0.5)
else:
pytest.fail("documents did not finish indexing")
assert len(documents) == 3
assert all(item["chunk_count"] > 0 for item in documents)
session = client.post("/api/v1/sessions", json={"title": "真实模型测试"}).json()
session_id = session["id"]
first = _stream(client, session_id, "星槐项目的内部代号和负责人分别是什么?")
_assert_flow(first)
first_answer = _answer(first)
assert "ORCHID-731" in first_answer
assert "林岚" in first_answer
first_sources = [
source for name, data in first if name == "sources" for source in data["sources"]
]
assert first_sources
assert {source["filename"] for source in first_sources} <= {
path.name for path in FIXTURE_PATHS
}
chunks_by_document: dict[str, set[str]] = {}
for source in first_sources:
document_id = source["document_id"]
if document_id not in chunks_by_document:
page = client.get(
f"/api/v1/documents/{document_id}/chunks", params={"limit": 100}
).json()
chunks_by_document[document_id] = {item["id"] for item in page["items"]}
assert source["chunk_id"] in chunks_by_document[document_id]
follow_up = _stream(client, session_id, "她负责的项目首次评审日期是什么?")
_assert_flow(follow_up)
follow_up_answer = _answer(follow_up)
assert "2026" in follow_up_answer and "18" in follow_up_answer
absent = _stream(client, session_id, "星槐项目部署在哪个月球基地?")
_assert_flow(absent)
assert any(marker in _answer(absent) for marker in ("不足", "没有", "未检索"))
history = client.get(
f"/api/v1/sessions/{session_id}/messages", params={"limit": 100}
).json()
assert history["total"] == 6
assert all(item["status"] == "completed" for item in history["items"])
assert history["items"][1]["sources"]
finally:
_stop_server(process)
process = _start_server(port, env)
try:
with httpx.Client(base_url=base_url, timeout=30) as client:
assert client.get("/api/v1/documents").json()["total"] == 3
history = client.get(
f"/api/v1/sessions/{session_id}/messages", params={"limit": 100}
).json()
assert history["total"] == 6
after_restart = _stream(client, session_id, "青屿实验的校准周期是多久?")
_assert_flow(after_restart)
assert "14" in _answer(after_restart)
for document_id in document_ids:
assert client.delete(f"/api/v1/documents/{document_id}").status_code == 204
assert client.delete(f"/api/v1/sessions/{session_id}").status_code == 204
assert client.get("/api/v1/documents").json()["total"] == 0
assert client.get(f"/api/v1/sessions/{session_id}/messages").status_code == 404
finally:
_stop_server(process)
with sqlite3.connect(database) as connection:
for table in (
"documents",
"index_jobs",
"chunks",
"chat_sessions",
"messages",
"message_sources",
):
assert connection.execute(f"SELECT count(*) FROM {table}").fetchone()[0] == 0
vector_client = MilvusClient(str(milvus))
try:
vector_client.load_collection("kbqa_chunks")
assert int(vector_client.get_collection_stats("kbqa_chunks")["row_count"]) == 0
finally:
vector_client.close()
assert not list(raw.glob("*"))
@@ -0,0 +1,13 @@
from pathlib import Path
import pytest
from kbqa.api.errors import AppError
from kbqa.indexing.loaders import load_document
def test_text_loader_rejects_non_utf8(tmp_path: Path) -> None:
path = tmp_path / "bad.txt"
path.write_bytes(b"\xff\xfe")
with pytest.raises(AppError, match="UTF-8"):
load_document(path, "txt")
+12
View File
@@ -0,0 +1,12 @@
from kbqa.indexing.loaders import LoadedPage
from kbqa.indexing.splitter import split_pages
def test_split_pages_is_deterministic() -> None:
document_id = "12345678-1234-5678-1234-567812345678"
pages = [LoadedPage(content="知识库" * 300, page_number=1)]
first = split_pages(document_id, pages, 100, 10)
second = split_pages(document_id, pages, 100, 10)
assert [chunk.id for chunk in first] == [chunk.id for chunk in second]
assert first[0].page_number == 1
assert [chunk.order_index for chunk in first] == list(range(len(first)))
+8
View File
@@ -0,0 +1,8 @@
from kbqa.chat.sse import encode_event, encode_ping
def test_sse_encodes_single_line_utf8_json() -> None:
assert encode_event("token", {"content": "知识"}) == (
'event: token\ndata: {"content":"知识"}\n\n'.encode()
)
assert encode_ping() == b": ping\n\n"
+93
View File
@@ -0,0 +1,93 @@
# KBQA MVP 真实链路验收报告
- 验收时间:2026-07-13Asia/Shanghai
- 分支:`feature/kbqa-mvp`
- 模型:`qwen3.5-flash``text-embedding-v4`1024 维)
- 数据库:SQLite + Milvus Lite
- 运行约束:单 Uvicorn worker
## 版本
- Python 3.12.12
- LangChain 1.3.13(仅使用 1.x API
- FastAPI 0.139.0
- SQLAlchemy 2.0.51
- PyMilvus 2.6.16
- Node.js 24.17.0 / npm 11.13.0
## 自动验证
普通测试未调用 Chat/Embedding
```text
ruff format --check: 48 files already formatted
ruff check: All checks passed
pytest unit + integration: 5 passed
ESLint: passed
Vitest: 1 file / 3 tests passed(含任意 UTF-8 网络分片的增量 SSE)
Vite production build: passed
```
显式开启的真实模型黑盒测试:
```text
KBQA_RUN_LIVE_TESTS=1 uv run pytest tests/live/test_live_pipeline.py -v -s
1 passed in 69.77s
```
黑盒测试使用隔离的临时 SQLite、Milvus Lite 和原始文件目录,完成以下流程:上传真实
Markdown、UTF-8 TXT、带文本层 PDF;等待真实 Embedding 索引;三轮真实双 Agent 问答;
校验严格 SSE 子序列、答案语义、拒答边界、来源 chunk 可读取;重启 Uvicorn 后再次执行真实
检索;验证文档、会话和消息仍存在;删除数据;直接确认原始文件、SQLite 六张业务表及
Milvus 向量均清空。
## 用户真实文件验收
使用用户授权的 `test.txt`(6,158,012 字节)执行了额外的大文件验收:
- 后台真实 Embedding 完成 5,037 个片段的索引。
- 首轮问题经 Research Agent 检索后进入 Main AgentSSE 依次产生 `researching`
`sources``answering`、多个 `token``done`
- 同一会话追问“佩罗和奥克斯对伊南娜做了什么?”,准确召回第 1 章相关片段并生成
S3/S4/S5 引用。
- 多次停止并重启后端后,文档、5,037 个片段、会话和历史消息均可读取。
- 删除会话后,该会话及消息记录均为 0。
- 删除文档后,文档、索引任务、片段、消息来源记录及原始副本均为 0;Milvus
`row_count` 为 0。用户提供的源文件未被改动。
首个宽泛问题的向量召回偏离了开篇片段,因此系统严格返回了证据不足;更具体的多轮追问
召回正确。这属于可接受的模型/向量检索波动,测试只断言事件结构、来源真实性和证据边界,
不锁定模型措辞。
## 浏览器验收
使用 agent-browser 检查了文档状态、详情抽屉、历史会话恢复、可点击引用、桌面右栏及
390px 窄屏证据抽屉:
- [文档索引状态](screenshots/documents-indexing.png)
- [文档详情抽屉](screenshots/document-detail-drawer.png)
- [文档窄屏布局](screenshots/documents-narrow.png)
- [历史对话与桌面引用](screenshots/chat-history-citation.png)
- [对话窄屏布局](screenshots/chat-mobile.png)
- [窄屏可点击引用抽屉](screenshots/chat-mobile-citation.png)
- [浏览器上传与后台索引中](screenshots/browser-upload-indexing.png)
- [真实问答研究阶段](screenshots/browser-live-researching.png)
- [真实回答与引用](screenshots/browser-live-answer.png)
- [失败文档重试](screenshots/browser-retry.png)
- [不支持格式的页面错误](screenshots/browser-upload-error.png)
- [文档删除后的空状态](screenshots/browser-delete-empty.png)
- [会话删除后侧栏同步](screenshots/browser-session-deleted.png)
## 真实环境问题与修复
- DashScope 单次 Embedding 上限为 10,索引器已固定分批上限并验证大文件成功。
- `qwen3.5-flash` 默认思考模式不支持强制 `tool_choice`;按官方兼容接口关闭思考模式,
保留双 Agent 的必经工具约束。
- Milvus Lite 重启后集合需要重新 load,启动流程已补齐。
- 自定义主键搜索结果从 `entity.chunk_id` 读取,避免依赖不存在的默认 `id` 字段。
- Research Agent 完成一次真实检索后强制进入证据总结,避免模型重复调用工具直到图递归上限。
- 修复新会话首问完成回调的导航竞态,SSE 完成后按实际会话 ID 刷新历史与侧栏。
LangSmith tracing 已按本地 `.env` 开启并写入既有项目。代表性 Main Agent trace
[`019f5a18-28fb-7f80-b252-b436e2567e1d`](https://smith.langchain.com/o/3b9cd927-6db9-411f-b6af-19b38f36a387/projects/p/6b719950-c885-405f-addb-1bd86ac3cf83/r/019f5a18-28fb-7f80-b252-b436e2567e1d?trace_id=019f5a18-28fb-7f80-b252-b436e2567e1d&start_time=2026-07-13T06:09:20.635570)。
报告不包含任何密钥。
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

+21 -4
View File
@@ -1,6 +1,6 @@
import * as Dialog from '@radix-ui/react-dialog'
import { PanelRightOpen, Radar, Sparkles } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Button } from '../../components/ui/button'
@@ -17,17 +17,30 @@ export function ChatPage() {
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) { setMessages([]); return }
const page = await listMessages(sessionId)
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
void listMessages(sessionId).then((page) => setMessages(page.items))
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() })
@@ -38,6 +51,10 @@ export function ChatPage() {
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) {
+47
View File
@@ -0,0 +1,47 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { parseSSEBlock, streamSSE } from './sse'
afterEach(() => vi.restoreAllMocks())
describe('parseSSEBlock', () => {
it('parses Chinese token events with CRLF framing', () => {
expect(parseSSEBlock('event: token\r\ndata: {"content":"知识"}')).toEqual({
event: 'token',
data: { content: '知识' },
})
})
it('ignores heartbeat comments', () => {
expect(parseSSEBlock(': ping')).toBeNull()
})
})
describe('streamSSE', () => {
it('incrementally decodes arbitrary UTF-8 chunks, CRLF, multiple events, and a tail event', async () => {
const bytes = new TextEncoder().encode(
'event: status\r\ndata: {"phase":"researching"}\r\n\r\n' +
'event: token\ndata: {"content":"知识"}\n\n' +
'event: done\ndata: {"message_id":"m1"}',
)
const chunks = [bytes.slice(0, 7), bytes.slice(7, 58), bytes.slice(58, 61), bytes.slice(61)]
const body = new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) controller.enqueue(chunk)
controller.close()
},
})
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(body))
const controller = new AbortController()
const events: unknown[] = []
await streamSSE('/stream', { query: 'q' }, (event) => events.push(event), controller.signal)
expect(events).toEqual([
{ event: 'status', data: { phase: 'researching' } },
{ event: 'token', data: { content: '知识' } },
{ event: 'done', data: { message_id: 'm1' } },
])
expect(fetchMock).toHaveBeenCalledWith('/stream', expect.objectContaining({ signal: controller.signal }))
})
})
+3 -3
View File
@@ -1,7 +1,7 @@
import { ApiError } from './api'
import type { SSEEvent } from './types'
function parseBlock(block: string): SSEEvent | null {
export function parseSSEBlock(block: string): SSEEvent | null {
let eventName = ''
const dataLines: string[] = []
for (const line of block.split(/\r?\n/)) {
@@ -47,14 +47,14 @@ export async function streamSSE(
while (boundary >= 0) {
const block = buffer.slice(0, boundary)
buffer = buffer.slice(boundary + 2)
const event = parseBlock(block)
const event = parseSSEBlock(block)
if (event) onEvent(event)
boundary = buffer.indexOf('\n\n')
}
if (done) break
}
if (buffer.trim()) {
const event = parseBlock(buffer)
const event = parseSSEBlock(buffer)
if (event) onEvent(event)
}
}