Make the governed RAG evidence path executable end to end
Some checks failed
verify / verify (push) Has been cancelled

Separate local parsing from model indexing, bind review decisions to immutable manifests, persist vectors behind active profiles, and expose retrieval, chat, evaluation, and document workflows through the React workbench.

Constraint: Live Bailian authentication currently fails for all three configured capabilities

Rejected: Direct upload-to-embedding flow | bypasses local review and manifest binding

Confidence: high

Scope-risk: broad

Directive: Keep private-data deployment blocked until authentication, RBAC, and separate database roles land

Tested: make verify; fresh and replay Docker document smoke; worker recovery smoke; frozen synthetic evaluation; migration 0003-0004 roundtrip

Not-tested: Successful live Bailian calls, OCR, real multi-user authorization
This commit is contained in:
2026-07-13 05:58:11 +08:00
parent 75592af33a
commit ecdb10c37a
111 changed files with 25457 additions and 152 deletions

View File

@@ -1,6 +1,19 @@
# 地质知识离线检索前端
# 地质知识 RAG 工作台前端
React + TypeScript 的 synthetic/offline RAG 演示工作台。前端只访问同源 `/api`,不读取、保存或显示模型 Key、百炼工作区地址和数据库连接信息。
React + TypeScript 的地质知识 RAG 工作台,覆盖离线演示、正式检索、证据问答和受控文档入库。前端只访问同源 `/api`,不读取、保存或显示模型 Key、百炼工作区地址和数据库连接信息。
## 文档入库工作台
访问 `/documents` 可验证完整的治理流程:
1. 浏览器计算文件 SHA-256以 UUID `Idempotency-Key` 创建上传声明;
2.`application/octet-stream` 写入隔离存储,完成后轮询本地解析作业;
3. 加载全部分页复核包核对页、块、Chunk、profile 与出域 manifest
4. 人工确认后以 `expected_revision` 和 manifest 提交批准,或选择明确原因拒绝;
5. 批准后轮询向量索引作业,直到完整性校验与激活完成。
支持 TXT、Markdown、DOCX 和 PDF浏览器侧限制 100 MiB。PDF 进入 `OCR_REQUIRED`
并不代表系统已经理解地图空间关系;页面不会把上传成功或解析成功显示为可检索。
## 运行环境与固定版本
@@ -28,19 +41,20 @@ Vite 将同源 `/api` 代理到本机后端。生产构建输出到 `dist/`。
## OpenAPI 类型
`src/api/schema.generated.ts` 来自 FastAPI 的真实 OpenAPI 文档。后端 API 契约变化后,启动后端并运行:
`src/api/schema.generated.ts` 来自 FastAPI 应用工厂的真实 OpenAPI 文档。默认离线导出,不需要启动 API、连接数据库或读取模型 Secret。后端契约变化后运行:
```bash
npm run generate:api
```
后端使用其他本机端口,可设置非敏感的 `OPENAPI_SCHEMA_URL`
需对照正在运行的其他本机端口,可显式设置非敏感的 `OPENAPI_SCHEMA_URL`
```bash
OPENAPI_SCHEMA_URL=http://127.0.0.1:8010/openapi.json npm run generate:api
```
生成脚本会确认 `/api/v1/demo/status``/api/v1/demo/search` 均存在后才覆盖类型文件。
生成脚本会确认 Demo、正式 Retrieval 与 Grounded Chat 契约均存在后才覆盖类型文件。
`npm run check:api` 会离线重新生成到内存并与提交文件逐字比较CI 用它阻止后端和前端类型漂移。
## 质量门禁

View File

@@ -59,7 +59,7 @@ http {
root /usr/share/nginx/html;
index index.html;
client_max_body_size 1m;
client_max_body_size 100m;
add_header Content-Security-Policy $content_security_policy always;
add_header Cross-Origin-Opener-Policy "same-origin" always;

View File

@@ -12,13 +12,14 @@
"build": "tsc -b && vite build",
"preview": "vite preview --host 127.0.0.1",
"generate:api": "node scripts/generate-openapi.mjs",
"check:api": "node scripts/check-openapi.mjs",
"format": "prettier --write .",
"format:check": "prettier --check .",
"lint": "eslint . --max-warnings 0",
"typecheck": "tsc -b --pretty false",
"test": "vitest run",
"test:watch": "vitest",
"verify": "npm run format:check && npm run lint && npm run typecheck && npm run test && npm run build"
"verify": "npm run format:check && npm run check:api && npm run lint && npm run typecheck && npm run test && npm run build"
},
"dependencies": {
"@tanstack/react-query": "5.101.2",

View File

@@ -0,0 +1,16 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { loadOpenApiSchema, renderOpenApiTypes } from "./openapi-contract.mjs";
const outputPath = resolve("src/api/schema.generated.ts");
const [expected, actual] = await Promise.all([
loadOpenApiSchema().then(renderOpenApiTypes),
readFile(outputPath, "utf8"),
]);
if (actual !== expected) {
throw new Error("Generated OpenAPI types are stale; run npm run generate:api");
}
console.log("Generated OpenAPI types match the offline FastAPI contract");

View File

@@ -1,41 +1,10 @@
import { writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import openapiTS, { astToString, COMMENT_HEADER } from "openapi-typescript";
import { loadOpenApiSchema, renderOpenApiTypes } from "./openapi-contract.mjs";
const schemaUrl = process.env.OPENAPI_SCHEMA_URL ?? "http://127.0.0.1:8000/openapi.json";
const outputPath = resolve("src/api/schema.generated.ts");
const requiredPaths = ["/api/v1/demo/status", "/api/v1/demo/search"];
const schema = await loadOpenApiSchema();
await writeFile(outputPath, await renderOpenApiTypes(schema), "utf8");
const response = await fetch(schemaUrl, {
headers: { Accept: "application/json" },
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
throw new Error(`OpenAPI schema request failed with HTTP ${response.status}`);
}
const schema = await response.json();
if (typeof schema !== "object" || schema === null || !("paths" in schema)) {
throw new Error("OpenAPI response does not contain a paths object");
}
const paths = schema.paths;
if (typeof paths !== "object" || paths === null) {
throw new Error("OpenAPI paths value is invalid");
}
for (const path of requiredPaths) {
if (!(path in paths)) {
throw new Error(`Required API path is missing: ${path}`);
}
}
const ast = await openapiTS(schema, {
alphabetize: true,
immutable: true,
});
await writeFile(outputPath, `${COMMENT_HEADER}${astToString(ast)}`, "utf8");
console.log(`Generated ${outputPath} from ${schemaUrl}`);
console.log(`Generated ${outputPath} from the offline FastAPI application contract`);

View File

@@ -0,0 +1,68 @@
import { spawnSync } from "node:child_process";
import { resolve } from "node:path";
import openapiTS, { astToString, COMMENT_HEADER } from "openapi-typescript";
const requiredPaths = [
"/api/v1/demo/status",
"/api/v1/demo/search",
"/api/v1/retrieval/search",
"/api/v1/chat/completions",
"/api/v1/document-uploads",
"/api/v1/document-uploads/{upload_id}/content",
"/api/v1/document-uploads/{upload_id}/complete",
"/api/v1/documents",
"/api/v1/documents/{document_id}",
"/api/v1/documents/{document_id}/review-bundle",
"/api/v1/documents/{document_id}/review-decisions",
"/api/v1/document-jobs/{job_id}",
];
async function schemaFromUrl(schemaUrl) {
const response = await fetch(schemaUrl, {
headers: { Accept: "application/json" },
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) throw new Error(`OpenAPI schema request failed with HTTP ${response.status}`);
return response.json();
}
function offlineSchema() {
const backendDirectory = resolve("..", "backend");
const python = process.env.BACKEND_PYTHON ?? resolve(backendDirectory, ".venv/bin/python");
const result = spawnSync(python, ["-m", "app.tools.export_openapi"], {
cwd: backendDirectory,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0 || result.error || !result.stdout) {
throw new Error("Offline OpenAPI export failed; run make backend-sync first");
}
try {
return JSON.parse(result.stdout);
} catch {
throw new Error("Offline OpenAPI export returned invalid JSON");
}
}
export async function loadOpenApiSchema() {
const schema = process.env.OPENAPI_SCHEMA_URL
? await schemaFromUrl(process.env.OPENAPI_SCHEMA_URL)
: offlineSchema();
if (typeof schema !== "object" || schema === null || !("paths" in schema)) {
throw new Error("OpenAPI response does not contain a paths object");
}
const paths = schema.paths;
if (typeof paths !== "object" || paths === null) {
throw new Error("OpenAPI paths value is invalid");
}
for (const path of requiredPaths) {
if (!(path in paths)) throw new Error(`Required API path is missing: ${path}`);
}
return schema;
}
export async function renderOpenApiTypes(schema) {
const ast = await openapiTS(schema, { alphabetize: true, immutable: true });
return `${COMMENT_HEADER}${astToString(ast)}`;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,10 @@
import { createBrowserRouter } from "react-router-dom";
import { AppShell } from "../components/AppShell";
import { ChatPage } from "../pages/ChatPage";
import { DocumentsPage } from "../pages/DocumentsPage";
import { NotFoundPage } from "../pages/NotFoundPage";
import { RetrievalPage } from "../pages/RetrievalPage";
import { SystemPage } from "../pages/SystemPage";
import { WorkbenchPage } from "../pages/WorkbenchPage";
@@ -11,6 +14,9 @@ export const router = createBrowserRouter([
element: <AppShell />,
children: [
{ index: true, element: <WorkbenchPage /> },
{ path: "retrieval", element: <RetrievalPage /> },
{ path: "chat", element: <ChatPage /> },
{ path: "documents", element: <DocumentsPage /> },
{ path: "system", element: <SystemPage /> },
{ path: "*", element: <NotFoundPage /> },
],

View File

@@ -3,7 +3,10 @@ import { NavLink, Outlet } from "react-router-dom";
import { Icon } from "./Icon";
const navItems = [
{ to: "/", label: "检索工作台", icon: "search" as const, end: true },
{ to: "/", label: "离线演示", icon: "search" as const, end: true },
{ to: "/retrieval", label: "正式检索", icon: "vector" as const, end: false },
{ to: "/chat", label: "证据问答", icon: "layers" as const, end: false },
{ to: "/documents", label: "文档入库", icon: "document" as const, end: false },
{ to: "/system", label: "系统说明", icon: "settings" as const, end: false },
] as const;
@@ -78,7 +81,7 @@ export function AppShell() {
</main>
<footer className="footer">
<span> · </span>
<span>使</span>
<span>使 · </span>
</footer>
</div>
</div>

View File

@@ -7,6 +7,7 @@ export type IconName =
| "compass"
| "copy"
| "database"
| "document"
| "layers"
| "search"
| "settings"
@@ -59,6 +60,14 @@ function iconPath(name: IconName): ReactNode {
<path d="M4 11v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6" />
</>
);
case "document":
return (
<>
<path d="M6 2h8l4 4v16H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Z" />
<path d="M14 2v5h5" />
<path d="M8 12h8M8 16h8" />
</>
);
case "layers":
return (
<>

View File

@@ -0,0 +1,82 @@
import type { ChatCompletionRequest, ChatStreamEvent } from "./types";
import { ChatStreamError, consumeChatSse } from "./sse";
const CHAT_COMPLETION_PATH = "/api/v1/chat/completions";
function safeHttpError(status: number): ChatStreamError {
if (status === 403) {
return new ChatStreamError(
"forbidden",
"当前身份无权访问该知识库。请恢复合成知识库或联系管理员授权。",
status,
);
}
if (status === 409) {
return new ChatStreamError(
"not_ready",
"知识库尚未完成审批、索引和 Active Profile 激活。",
status,
);
}
if (status === 422) {
return new ChatStreamError("validation", "问题或运行参数未通过服务端校验。", status);
}
if (status === 503) {
return new ChatStreamError(
"unavailable",
"数据库、模型网关或生成服务暂不可用。请稍后原样重试。",
status,
);
}
return new ChatStreamError("http", `问答请求未成功HTTP ${status})。`, status);
}
function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === "AbortError";
}
export async function streamGroundedChat(
request: ChatCompletionRequest,
options: {
signal: AbortSignal;
onEvent: (event: ChatStreamEvent) => void;
},
): Promise<void> {
let response: Response;
try {
response = await fetch(CHAT_COMPLETION_PATH, {
method: "POST",
headers: {
Accept: "text/event-stream",
"Content-Type": "application/json",
},
body: JSON.stringify(request),
signal: options.signal,
});
} catch (error) {
if (isAbortError(error) || options.signal.aborted) {
throw new ChatStreamError("aborted", "回答已停止。");
}
throw new ChatStreamError("network", "无法连接 Grounded Chat API。请检查 Docker 服务。");
}
if (!response.ok) {
// Do not surface untrusted Problem detail/provider bodies. Status is enough
// to select a stable, user-actionable message.
try {
await response.body?.cancel();
} catch {
// A failed cancellation must not replace the sanitized HTTP error.
}
throw safeHttpError(response.status);
}
try {
await consumeChatSse(response, request.knowledge_base_id, options.onEvent);
} catch (error) {
if (isAbortError(error) || options.signal.aborted) {
throw new ChatStreamError("aborted", "回答已停止。");
}
throw error;
}
}

View File

@@ -0,0 +1,176 @@
import { useId, useMemo, useRef, useState } from "react";
import { Icon } from "../../../components/Icon";
import {
CHAT_MAX_TOKENS_LIMIT,
CHAT_QUESTION_MAX_LENGTH,
CHAT_REQUEST_LIMIT_MAX,
DEFAULT_CHAT_INPUT,
type ChatCompletionRequest,
type ChatFormInput,
validateChatInput,
} from "../types";
interface ChatComposerProps {
isRunning: boolean;
onStart: (request: ChatCompletionRequest) => void;
onStop: () => void;
}
export function ChatComposer({ isRunning, onStart, onStop }: ChatComposerProps) {
const [input, setInput] = useState<ChatFormInput>(DEFAULT_CHAT_INPUT);
const [submitted, setSubmitted] = useState(false);
const formRef = useRef<HTMLFormElement>(null);
const errorId = useId();
const validation = useMemo(() => validateChatInput(input), [input]);
const showValidation = submitted && validation.request === null;
function updateInput<K extends keyof ChatFormInput>(key: K, value: ChatFormInput[K]) {
setInput((current) => ({ ...current, [key]: value }));
if (submitted) setSubmitted(false);
}
function submit() {
setSubmitted(true);
if (isRunning || validation.request === null) return;
onStart(validation.request);
}
return (
<section aria-labelledby="chat-composer-heading" className="chat-composer-panel">
<div className="section-heading">
<div>
<span className="eyebrow">GROUNDED QUESTION</span>
<h2 id="chat-composer-heading"></h2>
</div>
<span className="section-heading__meta">POST · SSE</span>
</div>
<form
aria-busy={isRunning}
className="chat-composer"
onSubmit={(event) => {
event.preventDefault();
submit();
}}
ref={formRef}
>
<label className="field-label" htmlFor="chat-knowledge-base">
ID
</label>
<input
className="text-input text-input--mono"
disabled={isRunning}
id="chat-knowledge-base"
onChange={(event) => updateInput("knowledgeBaseId", event.target.value)}
spellCheck={false}
type="text"
value={input.knowledgeBaseId}
/>
<p className="field-hint">使访 scope </p>
<label className="field-label chat-composer__question-label" htmlFor="chat-question">
</label>
<div className={`query-box${showValidation ? " query-box--error" : ""}`}>
<textarea
aria-describedby={showValidation ? errorId : undefined}
aria-invalid={showValidation}
disabled={isRunning}
id="chat-question"
maxLength={CHAT_QUESTION_MAX_LENGTH}
onChange={(event) => updateInput("question", event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault();
formRef.current?.requestSubmit();
}
}}
rows={5}
value={input.question}
/>
<span
className={`character-count${input.question.length >= 450 ? " character-count--warning" : ""}`}
>
{input.question.length}/{CHAT_QUESTION_MAX_LENGTH}
</span>
</div>
<p className="field-hint">Enter Shift + Enter 使</p>
<details className="chat-advanced-settings">
<summary></summary>
<div className="chat-parameter-grid">
<label htmlFor="chat-vector-top-k">
<span>Vector Top K</span>
<input
disabled={isRunning}
id="chat-vector-top-k"
max={CHAT_REQUEST_LIMIT_MAX}
min={1}
onChange={(event) => updateInput("vectorTopK", event.target.value)}
type="number"
value={input.vectorTopK}
/>
</label>
<label htmlFor="chat-rerank-top-n">
<span>Rerank Top N</span>
<input
disabled={isRunning}
id="chat-rerank-top-n"
max={CHAT_REQUEST_LIMIT_MAX}
min={1}
onChange={(event) => updateInput("rerankTopN", event.target.value)}
type="number"
value={input.rerankTopN}
/>
</label>
<label htmlFor="chat-max-tokens">
<span> Token </span>
<input
disabled={isRunning}
id="chat-max-tokens"
max={CHAT_MAX_TOKENS_LIMIT}
min={1}
onChange={(event) => updateInput("maxTokens", event.target.value)}
type="number"
value={input.maxTokens}
/>
</label>
</div>
</details>
{showValidation ? (
<p className="field-error chat-composer__error" id={errorId} role="alert">
{validation.message}
</p>
) : null}
<div className="chat-composer__actions">
<button
className="tertiary-button"
disabled={isRunning}
onClick={() => {
setInput(DEFAULT_CHAT_INPUT);
setSubmitted(false);
}}
type="button"
>
</button>
{isRunning ? (
<button className="stop-button" onClick={onStop} type="button">
<span aria-hidden="true" />
</button>
) : (
<button className="primary-button" type="submit">
<Icon name="layers" size={19} />
<Icon name="arrow" size={17} />
</button>
)}
</div>
</form>
</section>
);
}

View File

@@ -0,0 +1,252 @@
import { Icon } from "../../../components/Icon";
import type { ChatEvidence, ChatPhase, ChatRunState } from "../types";
interface ChatConversationProps {
isRunning: boolean;
onRetry: () => void;
state: ChatRunState;
}
const PHASE_LABELS: Record<ChatPhase, string> = {
idle: "等待问题",
retrieving: "正在检索",
generating: "正在生成",
complete: "回答完成",
refused: "证据不足 · 已拒答",
retrieval_only: "仅检索证据模式",
error: "问答未完成",
stopped: "已停止",
};
function formatScore(value: number | null): string {
return value === null ? "—" : value.toFixed(4);
}
function SourceCard({ evidence }: { evidence: ChatEvidence }) {
return (
<article aria-labelledby={`chat-source-${evidence.citation_id}`} className="chat-source-card">
<div className="chat-source-card__label">[{evidence.label}]</div>
<div className="chat-source-card__body">
<div className="chat-source-card__header">
<div>
<span>GROUNDED SOURCE</span>
<h4 id={`chat-source-${evidence.citation_id}`}>{evidence.source_name}</h4>
</div>
<div className="chat-source-card__score">
<span>Vector #{evidence.vector_rank}</span>
<strong>{formatScore(evidence.rerank_score)}</strong>
</div>
</div>
<p>{evidence.snippet}</p>
<dl>
<div>
<dt></dt>
<dd>
{evidence.section_path.length > 0 ? evidence.section_path.join(" / ") : "章节未知"}
</dd>
</div>
<div>
<dt></dt>
<dd>{evidence.page_label}</dd>
</div>
<div>
<dt>Citation ID</dt>
<dd>
<code>{evidence.citation_id}</code>
</dd>
</div>
</dl>
</div>
</article>
);
}
function RunMetadata({ state }: { state: ChatRunState }) {
if (state.meta === null) return null;
const timings = state.retrieval?.timings;
return (
<div className="chat-run-metadata">
<div>
<span>Generation</span>
<strong>
{state.meta.generation_mode === "synthetic_extractive" ? "合成抽取式" : "百炼 Grounded"}
</strong>
</div>
<div>
<span>Profile</span>
<strong>{state.meta.profile.synthetic ? "Synthetic" : "Live"}</strong>
<small>{state.meta.profile.model}</small>
</div>
<div>
<span>Retrieval</span>
<strong>{timings === undefined ? "—" : `${timings.total_ms.toFixed(1)} ms`}</strong>
<small>{state.retrieval?.rerank_status ?? "等待中"}</small>
</div>
<div>
<span>Trace ID</span>
<code>{state.meta.trace_id}</code>
</div>
</div>
);
}
function phaseDescription(state: ChatRunState): string {
if (state.phase === "retrieving")
return "正在生成 Query Vector 并检索当前授权范围内的已批准证据。";
if (state.phase === "generating") return "检索已完成,正在生成并校验带来源标签的回答。";
if (state.phase === "refused") return "未找到足以支持回答的证据,系统没有生成地质结论。";
if (state.phase === "retrieval_only")
return "模型答案未通过引用约束,当前展示后端抽取式证据回答。";
if (state.phase === "stopped") return "请求已由你主动取消,尚未完成的流内容不会继续展示。";
return "";
}
export function ChatConversation({ isRunning, onRetry, state }: ChatConversationProps) {
const sources = state.citations.length > 0 ? state.citations : [];
const degraded = state.retrieval?.rerank_status === "degraded";
const request = state.request;
return (
<section
aria-busy={isRunning}
aria-labelledby="chat-conversation-heading"
className="chat-conversation"
>
<div className="section-heading section-heading--results">
<div>
<span className="eyebrow">ANSWER STREAM</span>
<h2 id="chat-conversation-heading"></h2>
</div>
<span className={`chat-phase chat-phase--${state.phase}`}>{PHASE_LABELS[state.phase]}</span>
</div>
<span aria-atomic="true" aria-live="polite" className="visually-hidden">
{PHASE_LABELS[state.phase]}
</span>
{request === null ? (
<div className="chat-empty-state">
<span className="chat-empty-state__icon">
<Icon name="layers" size={28} />
</span>
<h3></h3>
<p> Citation Label </p>
</div>
) : (
<div className="chat-thread">
<article className="chat-message chat-message--user">
<span className="chat-message__role"></span>
<p>{request.question}</p>
</article>
<article className="chat-message chat-message--assistant">
<div className="chat-message__heading">
<span className="chat-message__role"></span>
<span>{PHASE_LABELS[state.phase]}</span>
</div>
{state.phase === "retrieving" ||
(state.phase === "generating" && state.answer.length === 0) ? (
<div className="chat-progress" role="status">
<span className="button-spinner" />
<p>{phaseDescription(state)}</p>
</div>
) : null}
{state.answer.length > 0 ? (
<p className="chat-answer" data-testid="chat-answer">
{state.answer}
{state.phase === "generating" ? (
<span aria-hidden="true" className="chat-answer__cursor" />
) : null}
</p>
) : null}
{state.phase === "refused" ||
state.phase === "retrieval_only" ||
state.phase === "stopped" ? (
<p className={`chat-mode-note chat-mode-note--${state.phase}`}>
{phaseDescription(state)}
</p>
) : null}
{state.phase === "error" ? (
<div className="chat-error" role="alert">
<Icon name="alert" size={21} />
<div>
<strong>
{state.streamError === null ? "回答流未能安全完成" : "生成模型未能完成回答"}
</strong>
<p>{state.errorMessage}</p>
</div>
</div>
) : null}
</article>
{degraded ? (
<div className="chat-degraded" role="status">
<Icon name="alert" size={18} />
<div>
<strong></strong>
<span>
Rerank Vector Rank
</span>
</div>
</div>
) : null}
<RunMetadata state={state} />
{state.usage ? (
<div className="chat-usage" aria-label="模型用量">
<span>
Model <strong>{state.usage.model}</strong>
</span>
<span>
Input <strong>{state.usage.input_tokens ?? "—"}</strong>
</span>
<span>
Output <strong>{state.usage.output_tokens ?? "—"}</strong>
</span>
<span>
Total <strong>{state.usage.total_tokens ?? "—"}</strong>
</span>
</div>
) : null}
{sources.length > 0 ? (
<section aria-labelledby="chat-sources-heading" className="chat-sources">
<div className="chat-sources__heading">
<div>
<span className="eyebrow">CITATIONS</span>
<h3 id="chat-sources-heading">
{state.streamError ? "已保留的检索证据" : "回答引用来源"}
</h3>
</div>
<span>{sources.length} </span>
</div>
<div className="chat-source-list">
{sources.map((evidence) => (
<SourceCard evidence={evidence} key={evidence.citation_id} />
))}
</div>
</section>
) : null}
{(state.phase === "error" || state.phase === "stopped") && state.request ? (
<div className="chat-retry-row">
<button
className="secondary-button"
disabled={isRunning}
onClick={onRetry}
type="button"
>
</button>
</div>
) : null}
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,112 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { streamGroundedChat } from "../api";
import { ChatStreamError } from "../sse";
import {
INITIAL_CHAT_STATE,
type ChatCompletionRequest,
type ChatRunState,
type ChatStreamEvent,
} from "../types";
function stateForEvent(current: ChatRunState, event: ChatStreamEvent): ChatRunState {
switch (event.name) {
case "meta":
return { ...current, meta: event };
case "retrieval":
return { ...current, phase: "generating", retrieval: event };
case "delta":
return { ...current, phase: "generating", answer: current.answer + event.text };
case "citations":
return { ...current, citations: event.citations };
case "usage":
return { ...current, usage: event };
case "done":
return {
...current,
phase:
event.answer_mode === "grounded"
? "complete"
: event.answer_mode === "refused"
? "refused"
: "retrieval_only",
done: event,
};
case "error":
return {
...current,
phase: "error",
citations: current.retrieval?.evidence ?? [],
streamError: event,
errorMessage: event.retryable
? "生成模型暂不可用,已保留本次检索证据,可以稍后重试。"
: "生成过程未能安全完成,已切换为仅检索证据模式。",
};
}
}
export function useGroundedChat() {
const [state, setState] = useState<ChatRunState>(INITIAL_CHAT_STATE);
const controllerRef = useRef<AbortController | null>(null);
const runIdRef = useRef(0);
const stop = useCallback(() => {
controllerRef.current?.abort();
}, []);
const start = useCallback(async (request: ChatCompletionRequest) => {
controllerRef.current?.abort();
const controller = new AbortController();
controllerRef.current = controller;
const runId = ++runIdRef.current;
setState({ ...INITIAL_CHAT_STATE, phase: "retrieving", request });
try {
await streamGroundedChat(request, {
signal: controller.signal,
onEvent: (event) => {
if (runId === runIdRef.current) setState((current) => stateForEvent(current, event));
},
});
} catch (error) {
if (runId !== runIdRef.current) return;
if (error instanceof ChatStreamError && error.kind === "aborted") {
setState((current) => ({
...current,
phase: "stopped",
errorMessage: null,
}));
} else {
setState((current) => ({
...current,
phase: "error",
answer:
error instanceof ChatStreamError && error.kind === "invalid_stream"
? ""
: current.answer,
citations:
error instanceof ChatStreamError && error.kind === "invalid_stream"
? []
: current.citations.length > 0
? current.citations
: (current.retrieval?.evidence ?? []),
errorMessage:
error instanceof ChatStreamError
? error.message
: "回答流发生未预期错误,请重新发起问题。",
}));
}
} finally {
if (runId === runIdRef.current) controllerRef.current = null;
}
}, []);
useEffect(() => () => controllerRef.current?.abort(), []);
return {
state,
isRunning: state.phase === "retrieving" || state.phase === "generating",
start,
stop,
};
}

View File

@@ -0,0 +1,555 @@
import type {
ChatCitationsEvent,
ChatDeltaEvent,
ChatDoneEvent,
ChatErrorEvent,
ChatEvidence,
ChatMetaEvent,
ChatRetrievalEvent,
ChatStreamEvent,
ChatTimings,
ChatUsageEvent,
} from "./types";
export type ChatStreamErrorKind =
| "aborted"
| "forbidden"
| "not_ready"
| "unavailable"
| "validation"
| "network"
| "invalid_stream"
| "http";
export class ChatStreamError extends Error {
readonly kind: ChatStreamErrorKind;
readonly status: number | null;
constructor(kind: ChatStreamErrorKind, message: string, status: number | null = null) {
super(message);
this.name = "ChatStreamError";
this.kind = kind;
this.status = status;
}
}
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const PROFILE_HASH_PATTERN = /^[0-9a-f]{64}$/;
const LABEL_PATTERN = /^S[1-9]\d*$/;
const EVENT_NAMES = new Set(["meta", "retrieval", "delta", "citations", "usage", "done", "error"]);
type JsonObject = Record<string, unknown>;
type StreamStage =
| "expect_meta"
| "expect_retrieval"
| "expect_delta_or_error"
| "accepting_delta"
| "expect_usage"
| "expect_done"
| "terminal";
function invalidStream(): never {
throw new ChatStreamError(
"invalid_stream",
"回答流不完整或格式无效。已停止展示后续内容,请重新发起问题。",
);
}
function isObject(value: unknown): value is JsonObject {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function hasExactKeys(value: JsonObject, keys: readonly string[]): boolean {
const actual = Object.keys(value).sort();
const expected = [...keys].sort();
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
}
function isFiniteNumber(
value: unknown,
minimum: number,
maximum = Number.POSITIVE_INFINITY,
): value is number {
return (
typeof value === "number" && Number.isFinite(value) && value >= minimum && value <= maximum
);
}
function isPositiveInteger(value: unknown): value is number {
return Number.isInteger(value) && typeof value === "number" && value >= 1;
}
function isNullableNonnegativeInteger(value: unknown): value is number | null {
return value === null || (Number.isInteger(value) && typeof value === "number" && value >= 0);
}
function isNullableString(value: unknown): value is string | null {
return value === null || typeof value === "string";
}
function parseProfile(value: unknown) {
if (
!isObject(value) ||
!hasExactKeys(value, ["profile_hash", "model", "dimension", "synthetic"]) ||
typeof value.profile_hash !== "string" ||
!PROFILE_HASH_PATTERN.test(value.profile_hash) ||
typeof value.model !== "string" ||
value.model.length === 0 ||
value.dimension !== 1024 ||
typeof value.synthetic !== "boolean"
) {
invalidStream();
}
return {
profile_hash: value.profile_hash,
model: value.model,
dimension: 1024 as const,
synthetic: value.synthetic,
};
}
function parseEvidence(value: unknown): ChatEvidence {
const keys = [
"label",
"rank",
"vector_rank",
"citation_id",
"document_id",
"source_name",
"snippet",
"section_path",
"page_start",
"page_end",
"page_label",
"vector_score",
"rerank_score",
] as const;
if (!isObject(value) || !hasExactKeys(value, keys)) invalidStream();
if (
typeof value.label !== "string" ||
!LABEL_PATTERN.test(value.label) ||
!isPositiveInteger(value.rank) ||
value.label !== `S${value.rank}` ||
!isPositiveInteger(value.vector_rank) ||
typeof value.citation_id !== "string" ||
!UUID_PATTERN.test(value.citation_id) ||
typeof value.document_id !== "string" ||
!UUID_PATTERN.test(value.document_id) ||
typeof value.source_name !== "string" ||
value.source_name.length < 1 ||
value.source_name.length > 240 ||
typeof value.snippet !== "string" ||
value.snippet.length < 1 ||
value.snippet.length > 1_200 ||
!Array.isArray(value.section_path) ||
value.section_path.some((part) => typeof part !== "string") ||
(value.page_start !== null && !isPositiveInteger(value.page_start)) ||
(value.page_end !== null && !isPositiveInteger(value.page_end)) ||
(value.page_start === null) !== (value.page_end === null) ||
(typeof value.page_start === "number" &&
typeof value.page_end === "number" &&
value.page_end < value.page_start) ||
typeof value.page_label !== "string" ||
value.page_label.length === 0 ||
!isFiniteNumber(value.vector_score, -1, 1) ||
(value.rerank_score !== null && !isFiniteNumber(value.rerank_score, 0, 1))
) {
invalidStream();
}
return {
label: value.label,
rank: value.rank,
vector_rank: value.vector_rank,
citation_id: value.citation_id,
document_id: value.document_id,
source_name: value.source_name,
snippet: value.snippet,
section_path: value.section_path,
page_start: value.page_start,
page_end: value.page_end,
page_label: value.page_label,
vector_score: value.vector_score,
rerank_score: value.rerank_score,
};
}
function parseTimings(value: unknown): ChatTimings {
if (
!isObject(value) ||
!hasExactKeys(value, ["embedding_ms", "database_ms", "rerank_ms", "total_ms"]) ||
!isFiniteNumber(value.embedding_ms, 0) ||
!isFiniteNumber(value.database_ms, 0) ||
!isFiniteNumber(value.rerank_ms, 0) ||
!isFiniteNumber(value.total_ms, 0)
) {
invalidStream();
}
return {
embedding_ms: value.embedding_ms,
database_ms: value.database_ms,
rerank_ms: value.rerank_ms,
total_ms: value.total_ms,
};
}
function parseMeta(value: JsonObject): ChatMetaEvent {
if (
!hasExactKeys(value, ["seq", "trace_id", "knowledge_base_id", "profile", "generation_mode"]) ||
!isPositiveInteger(value.seq) ||
typeof value.trace_id !== "string" ||
value.trace_id.length === 0 ||
typeof value.knowledge_base_id !== "string" ||
!UUID_PATTERN.test(value.knowledge_base_id) ||
(value.generation_mode !== "synthetic_extractive" && value.generation_mode !== "cloud_grounded")
) {
invalidStream();
}
const profile = parseProfile(value.profile);
if (
(profile.synthetic && value.generation_mode !== "synthetic_extractive") ||
(!profile.synthetic && value.generation_mode !== "cloud_grounded")
) {
invalidStream();
}
return {
name: "meta",
seq: value.seq,
trace_id: value.trace_id,
knowledge_base_id: value.knowledge_base_id,
profile,
generation_mode: value.generation_mode,
};
}
function parseRetrieval(value: JsonObject): ChatRetrievalEvent {
if (
!hasExactKeys(value, [
"seq",
"status",
"rerank_status",
"degradation_reason",
"evidence",
"timings",
]) ||
!isPositiveInteger(value.seq) ||
(value.status !== "ok" && value.status !== "empty") ||
!["applied", "degraded", "skipped_empty"].includes(String(value.rerank_status)) ||
(value.degradation_reason !== null && value.degradation_reason !== "rerank_unavailable") ||
!Array.isArray(value.evidence)
) {
invalidStream();
}
const evidence = value.evidence.map(parseEvidence);
if (new Set(evidence.map((item) => item.citation_id)).size !== evidence.length) invalidStream();
if (evidence.some((item, index) => item.rank !== index + 1)) invalidStream();
if ((value.status === "empty") !== (evidence.length === 0)) invalidStream();
if ((value.status === "empty") !== (value.rerank_status === "skipped_empty")) invalidStream();
if ((value.rerank_status === "degraded") !== (value.degradation_reason !== null)) invalidStream();
return {
name: "retrieval",
seq: value.seq,
status: value.status,
rerank_status: value.rerank_status as "applied" | "degraded" | "skipped_empty",
degradation_reason: value.degradation_reason,
evidence,
timings: parseTimings(value.timings),
};
}
function parseDelta(value: JsonObject): ChatDeltaEvent {
if (
!hasExactKeys(value, ["seq", "text"]) ||
!isPositiveInteger(value.seq) ||
typeof value.text !== "string"
) {
invalidStream();
}
return { name: "delta", seq: value.seq, text: value.text };
}
function parseCitations(value: JsonObject): ChatCitationsEvent {
if (
!hasExactKeys(value, ["seq", "citations"]) ||
!isPositiveInteger(value.seq) ||
!Array.isArray(value.citations)
) {
invalidStream();
}
return { name: "citations", seq: value.seq, citations: value.citations.map(parseEvidence) };
}
function parseUsage(value: JsonObject): ChatUsageEvent {
if (
!hasExactKeys(value, [
"seq",
"model",
"request_id",
"input_tokens",
"output_tokens",
"total_tokens",
]) ||
!isPositiveInteger(value.seq) ||
typeof value.model !== "string" ||
value.model.length === 0 ||
!isNullableString(value.request_id) ||
!isNullableNonnegativeInteger(value.input_tokens) ||
!isNullableNonnegativeInteger(value.output_tokens) ||
!isNullableNonnegativeInteger(value.total_tokens)
) {
invalidStream();
}
return {
name: "usage",
seq: value.seq,
model: value.model,
request_id: value.request_id,
input_tokens: value.input_tokens,
output_tokens: value.output_tokens,
total_tokens: value.total_tokens,
};
}
function parseDone(value: JsonObject): ChatDoneEvent {
if (
!hasExactKeys(value, ["seq", "status", "answer_mode", "finish_reason"]) ||
!isPositiveInteger(value.seq) ||
value.status !== "complete" ||
!["grounded", "refused", "retrieval_only"].includes(String(value.answer_mode)) ||
!isNullableString(value.finish_reason)
) {
invalidStream();
}
return {
name: "done",
seq: value.seq,
status: "complete",
answer_mode: value.answer_mode as "grounded" | "refused" | "retrieval_only",
finish_reason: value.finish_reason,
};
}
function parseError(value: JsonObject): ChatErrorEvent {
if (
!hasExactKeys(value, ["seq", "status", "code", "title", "retryable", "answer_mode"]) ||
!isPositiveInteger(value.seq) ||
value.status !== "error" ||
(value.code !== "CHAT_PROVIDER_UNAVAILABLE" && value.code !== "CHAT_GENERATION_FAILED") ||
typeof value.title !== "string" ||
value.title.length === 0 ||
typeof value.retryable !== "boolean" ||
value.answer_mode !== "retrieval_only"
) {
invalidStream();
}
return {
name: "error",
seq: value.seq,
status: "error",
code: value.code,
title: value.title,
retryable: value.retryable,
answer_mode: "retrieval_only",
};
}
function parseEvent(name: string, data: string): ChatStreamEvent {
if (!EVENT_NAMES.has(name)) invalidStream();
let value: unknown;
try {
value = JSON.parse(data);
} catch {
invalidStream();
}
if (!isObject(value)) invalidStream();
switch (name) {
case "meta":
return parseMeta(value);
case "retrieval":
return parseRetrieval(value);
case "delta":
return parseDelta(value);
case "citations":
return parseCitations(value);
case "usage":
return parseUsage(value);
case "done":
return parseDone(value);
case "error":
return parseError(value);
default:
return invalidStream();
}
}
function evidenceMatches(left: ChatEvidence, right: ChatEvidence): boolean {
return JSON.stringify(left) === JSON.stringify(right);
}
export class ChatEventSequence {
private stage: StreamStage = "expect_meta";
private expectedSeq = 1;
private expectedKnowledgeBaseId: string;
private retrievalEvidence: readonly ChatEvidence[] = [];
private citations: readonly ChatEvidence[] = [];
private generatedCharacters = 0;
private generatedText = "";
constructor(expectedKnowledgeBaseId: string) {
this.expectedKnowledgeBaseId = expectedKnowledgeBaseId;
}
accept(event: ChatStreamEvent): void {
if (this.stage === "terminal" || event.seq !== this.expectedSeq) invalidStream();
this.expectedSeq += 1;
if (event.name === "meta") {
if (
this.stage !== "expect_meta" ||
event.knowledge_base_id !== this.expectedKnowledgeBaseId
) {
invalidStream();
}
this.stage = "expect_retrieval";
return;
}
if (event.name === "retrieval") {
if (this.stage !== "expect_retrieval") invalidStream();
this.retrievalEvidence = event.evidence;
this.stage = "expect_delta_or_error";
return;
}
if (event.name === "delta") {
if (this.stage !== "expect_delta_or_error" && this.stage !== "accepting_delta")
invalidStream();
this.generatedCharacters += event.text.length;
if (this.generatedCharacters > 64_000) invalidStream();
this.generatedText += event.text;
this.stage = "accepting_delta";
return;
}
if (event.name === "citations") {
if (this.stage !== "accepting_delta") invalidStream();
const evidenceByLabel = new Map(this.retrievalEvidence.map((item) => [item.label, item]));
const labels = new Set<string>();
for (const citation of event.citations) {
const retrieved = evidenceByLabel.get(citation.label);
if (
retrieved === undefined ||
labels.has(citation.label) ||
!evidenceMatches(citation, retrieved)
) {
invalidStream();
}
labels.add(citation.label);
}
const referencedLabels = [
...new Set(
[...this.generatedText.matchAll(/\[S([1-9]\d*)\]/g)].map((match) => `S${match[1]}`),
),
];
if (
referencedLabels.length !== event.citations.length ||
referencedLabels.some((label, index) => event.citations[index]?.label !== label)
) {
invalidStream();
}
this.citations = event.citations;
this.stage = "expect_usage";
return;
}
if (event.name === "usage") {
if (this.stage !== "expect_usage") invalidStream();
this.stage = "expect_done";
return;
}
if (event.name === "done") {
if (this.stage !== "expect_done") invalidStream();
if (event.answer_mode === "grounded" && this.citations.length === 0) invalidStream();
if (event.answer_mode === "refused" && this.citations.length !== 0) invalidStream();
this.stage = "terminal";
return;
}
if (event.name === "error") {
if (this.stage !== "expect_delta_or_error") invalidStream();
this.stage = "terminal";
}
}
finish(): void {
if (this.stage !== "terminal") invalidStream();
}
}
function parseSseBlock(block: string): ChatStreamEvent {
let eventName: string | null = null;
const dataLines: string[] = [];
for (const rawLine of block.split("\n")) {
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
if (line.length === 0 || line.startsWith(":")) continue;
const separator = line.indexOf(":");
const field = separator === -1 ? line : line.slice(0, separator);
const rawValue = separator === -1 ? "" : line.slice(separator + 1);
const value = rawValue.startsWith(" ") ? rawValue.slice(1) : rawValue;
if (field === "event") {
if (eventName !== null || value.length === 0) invalidStream();
eventName = value;
} else if (field === "data") {
dataLines.push(value);
} else {
invalidStream();
}
}
if (eventName === null || dataLines.length === 0) invalidStream();
return parseEvent(eventName, dataLines.join("\n"));
}
function nextBlock(buffer: string): { block: string; rest: string } | null {
const match = /\r?\n\r?\n/.exec(buffer);
if (match?.index === undefined) return null;
return {
block: buffer.slice(0, match.index),
rest: buffer.slice(match.index + match[0].length),
};
}
export async function consumeChatSse(
response: Response,
expectedKnowledgeBaseId: string,
onEvent: (event: ChatStreamEvent) => void,
): Promise<void> {
if (!response.headers.get("content-type")?.toLowerCase().startsWith("text/event-stream")) {
invalidStream();
}
if (response.body === null) invalidStream();
const reader = response.body.getReader();
const decoder = new TextDecoder();
const sequence = new ChatEventSequence(expectedKnowledgeBaseId);
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let extracted = nextBlock(buffer);
while (extracted !== null) {
buffer = extracted.rest;
if (extracted.block.trim().length > 0) {
const event = parseSseBlock(extracted.block);
sequence.accept(event);
onEvent(event);
}
extracted = nextBlock(buffer);
}
}
buffer += decoder.decode();
if (buffer.trim().length > 0) {
const event = parseSseBlock(buffer);
sequence.accept(event);
onEvent(event);
}
sequence.finish();
} finally {
reader.releaseLock();
}
}

View File

@@ -0,0 +1,207 @@
import type { components } from "../../api/schema.generated";
import { DEFAULT_RETRIEVAL_QUERY, SYNTHETIC_KNOWLEDGE_BASE_ID } from "../retrieval/types";
export type ChatCompletionRequest = components["schemas"]["ChatCompletionRequest"];
export const DEFAULT_CHAT_QUESTION = DEFAULT_RETRIEVAL_QUERY;
export const DEFAULT_CHAT_KNOWLEDGE_BASE_ID = SYNTHETIC_KNOWLEDGE_BASE_ID;
export const CHAT_QUESTION_MAX_LENGTH = 500;
export const CHAT_MAX_TOKENS_LIMIT = 2_048;
export const CHAT_REQUEST_LIMIT_MAX = 10_000;
export interface ChatProfile {
profile_hash: string;
model: string;
dimension: 1024;
synthetic: boolean;
}
export interface ChatEvidence {
label: string;
rank: number;
vector_rank: number;
citation_id: string;
document_id: string;
source_name: string;
snippet: string;
section_path: readonly string[];
page_start: number | null;
page_end: number | null;
page_label: string;
vector_score: number;
rerank_score: number | null;
}
export interface ChatTimings {
embedding_ms: number;
database_ms: number;
rerank_ms: number;
total_ms: number;
}
export interface ChatMetaEvent {
name: "meta";
seq: number;
trace_id: string;
knowledge_base_id: string;
profile: ChatProfile;
generation_mode: "synthetic_extractive" | "cloud_grounded";
}
export interface ChatRetrievalEvent {
name: "retrieval";
seq: number;
status: "ok" | "empty";
rerank_status: "applied" | "degraded" | "skipped_empty";
degradation_reason: "rerank_unavailable" | null;
evidence: readonly ChatEvidence[];
timings: ChatTimings;
}
export interface ChatDeltaEvent {
name: "delta";
seq: number;
text: string;
}
export interface ChatCitationsEvent {
name: "citations";
seq: number;
citations: readonly ChatEvidence[];
}
export interface ChatUsageEvent {
name: "usage";
seq: number;
model: string;
request_id: string | null;
input_tokens: number | null;
output_tokens: number | null;
total_tokens: number | null;
}
export interface ChatDoneEvent {
name: "done";
seq: number;
status: "complete";
answer_mode: "grounded" | "refused" | "retrieval_only";
finish_reason: string | null;
}
export interface ChatErrorEvent {
name: "error";
seq: number;
status: "error";
code: "CHAT_PROVIDER_UNAVAILABLE" | "CHAT_GENERATION_FAILED";
title: string;
retryable: boolean;
answer_mode: "retrieval_only";
}
export type ChatStreamEvent =
| ChatMetaEvent
| ChatRetrievalEvent
| ChatDeltaEvent
| ChatCitationsEvent
| ChatUsageEvent
| ChatDoneEvent
| ChatErrorEvent;
export type ChatPhase =
| "idle"
| "retrieving"
| "generating"
| "complete"
| "refused"
| "retrieval_only"
| "error"
| "stopped";
export interface ChatRunState {
phase: ChatPhase;
request: ChatCompletionRequest | null;
answer: string;
meta: ChatMetaEvent | null;
retrieval: ChatRetrievalEvent | null;
citations: readonly ChatEvidence[];
usage: ChatUsageEvent | null;
done: ChatDoneEvent | null;
streamError: ChatErrorEvent | null;
errorMessage: string | null;
}
export const INITIAL_CHAT_STATE: ChatRunState = {
phase: "idle",
request: null,
answer: "",
meta: null,
retrieval: null,
citations: [],
usage: null,
done: null,
streamError: null,
errorMessage: null,
};
export interface ChatFormInput {
knowledgeBaseId: string;
question: string;
vectorTopK: string;
rerankTopN: string;
maxTokens: string;
}
export const DEFAULT_CHAT_INPUT: ChatFormInput = {
knowledgeBaseId: DEFAULT_CHAT_KNOWLEDGE_BASE_ID,
question: DEFAULT_CHAT_QUESTION,
vectorTopK: "50",
rerankTopN: "10",
maxTokens: "1024",
};
export interface ChatFormValidation {
request: ChatCompletionRequest | null;
message: string | null;
}
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
function parseInteger(value: string, maximum: number): number | null {
if (!/^\d+$/.test(value)) return null;
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed >= 1 && parsed <= maximum ? parsed : null;
}
export function validateChatInput(input: ChatFormInput): ChatFormValidation {
const knowledgeBaseId = input.knowledgeBaseId.trim();
const question = input.question.replace(/\s+/g, " ").trim();
const vectorTopK = parseInteger(input.vectorTopK, CHAT_REQUEST_LIMIT_MAX);
const rerankTopN = parseInteger(input.rerankTopN, CHAT_REQUEST_LIMIT_MAX);
const maxTokens = parseInteger(input.maxTokens, CHAT_MAX_TOKENS_LIMIT);
if (!UUID_PATTERN.test(knowledgeBaseId)) {
return { request: null, message: "请输入有效的知识库 UUID" };
}
if (question.length === 0) {
return { request: null, message: "请输入要提问的地质问题" };
}
if (input.question.length > CHAT_QUESTION_MAX_LENGTH) {
return { request: null, message: `问题不能超过 ${CHAT_QUESTION_MAX_LENGTH} 个字符` };
}
if (vectorTopK === null || rerankTopN === null) {
return { request: null, message: "检索参数必须是 110000 的整数" };
}
if (maxTokens === null) {
return { request: null, message: `回答 Token 上限必须是 1${CHAT_MAX_TOKENS_LIMIT}` };
}
return {
request: {
knowledge_base_id: knowledgeBaseId,
question,
vector_top_k: vectorTopK,
rerank_top_n: rerankTopN,
max_tokens: maxTokens,
},
message: null,
};
}

View File

@@ -1 +0,0 @@

View File

@@ -0,0 +1,115 @@
import { describe, expect, it, vi } from "vitest";
import { getCompleteReviewBundle, listDocuments } from "./api";
import type { ReviewBundle } from "./types";
const bundle: ReviewBundle = {
document: {
id: "70000000-0000-0000-0000-000000000001",
filename: "sample.md",
mime_type: "text/markdown",
raw_sha256: "a".repeat(64),
status: "PROCESSING",
active_version_id: null,
created_at: "2026-07-13T00:00:00Z",
updated_at: "2026-07-13T00:00:00Z",
},
version: {
id: "71000000-0000-0000-0000-000000000001",
review_state: "LOCAL_PARSED_PENDING_CLOUD_REVIEW",
review_revision: 2,
status: "PROCESSING",
parser_profile_hash: "b".repeat(64),
chunk_profile_hash: "c".repeat(64),
cloud_policy_id: "policy",
outbound_manifest_sha256: "d".repeat(64),
expected_chunk_count: 2,
error_code: null,
created_at: "2026-07-13T00:00:00Z",
completed_at: null,
},
pages: [],
blocks: [],
chunks: [
{
ordinal: 0,
display_text: "one",
cloud_text: "one",
cloud_text_sha256: "e".repeat(64),
embedding_text_sha256: "e".repeat(64),
token_count: 1,
page_start: null,
page_end: null,
section_path: [],
approval_status: "PENDING",
index_status: "PENDING",
},
],
next_ordinal: 0,
};
function response(value: unknown): Response {
return new Response(JSON.stringify(value), {
headers: { "Content-Type": "application/json" },
});
}
describe("document review API", () => {
it("loads the complete governed document directory", async () => {
const first = bundle.document;
const second = { ...first, id: "70000000-0000-0000-0000-000000000002" };
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValueOnce(response({ items: [first], next_cursor: first.id }))
.mockResolvedValueOnce(response({ items: [second], next_cursor: null })),
);
const result = await listDocuments();
expect(result.items.map((item) => item.id)).toEqual([first.id, second.id]);
expect(vi.mocked(fetch).mock.calls[1]?.[0]).toContain(`cursor=${first.id}`);
});
it("loads every page while preserving one reviewed version", async () => {
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValueOnce(response(bundle))
.mockResolvedValueOnce(
response({
...bundle,
chunks: [{ ...bundle.chunks[0]!, ordinal: 1, cloud_text: "two" }],
next_ordinal: null,
}),
),
);
const result = await getCompleteReviewBundle(bundle.document.id);
expect(result.chunks.map((chunk) => chunk.cloud_text)).toEqual(["one", "two"]);
expect(vi.mocked(fetch).mock.calls[1]?.[0]).toContain("after_ordinal=0");
});
it("fails closed if revision changes between pages", async () => {
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValueOnce(response(bundle))
.mockResolvedValueOnce(
response({
...bundle,
version: { ...bundle.version!, review_revision: 3 },
next_ordinal: null,
}),
),
);
await expect(getCompleteReviewBundle(bundle.document.id)).rejects.toEqual(
expect.objectContaining({ message: "复核内容在加载期间发生变化,请重新加载" }),
);
});
});

View File

@@ -0,0 +1,176 @@
import { ApiError } from "../../api/client";
import type {
CompleteUploadResponse,
DocumentJob,
DocumentListResponse,
DocumentUpload,
ReviewBundle,
ReviewDecisionRequest,
ReviewDecisionResponse,
} from "./types";
interface UploadDeclaration {
filename: string;
declared_mime_type: string;
expected_size: number;
expected_sha256: string;
}
const JSON_HEADERS = { Accept: "application/json", "Content-Type": "application/json" } as const;
function optionalSignal(signal: AbortSignal | undefined): { signal?: AbortSignal } {
return signal === undefined ? {} : { signal };
}
function safeDocumentError(status: number): string {
if (status === 409) return "当前状态已变化,请刷新后重试";
if (status === 412) return "审核内容已有新版本,请重新加载并复核";
if (status === 413) return "文件超过服务端允许的大小";
if (status === 415) return "文件类型不受支持";
if (status === 422) return "文件声明、内容或审核参数不符合约束";
if (status === 503) return "文档服务暂不可用,请稍后重试";
return `文档请求未成功HTTP ${status}`;
}
async function documentFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
let response: Response;
try {
response = await fetch(path, init);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") throw error;
throw new ApiError("network", "无法连接本地文档服务");
}
if (!response.ok) {
throw new ApiError(
response.status === 422 ? "validation" : response.status === 503 ? "unavailable" : "http",
safeDocumentError(response.status),
response.status,
);
}
try {
return (await response.json()) as T;
} catch {
throw new ApiError("http", "文档服务返回了无法解析的数据", response.status);
}
}
export function createDocumentUpload(
declaration: UploadDeclaration,
idempotencyKey: string,
signal: AbortSignal,
): Promise<DocumentUpload> {
return documentFetch("/api/v1/document-uploads", {
method: "POST",
headers: { ...JSON_HEADERS, "Idempotency-Key": idempotencyKey },
body: JSON.stringify(declaration),
signal,
});
}
export function putDocumentContent(
uploadId: string,
file: File,
signal: AbortSignal,
): Promise<DocumentUpload> {
return documentFetch(`/api/v1/document-uploads/${encodeURIComponent(uploadId)}/content`, {
method: "PUT",
headers: { Accept: "application/json", "Content-Type": "application/octet-stream" },
body: file,
signal,
});
}
export function completeDocumentUpload(
uploadId: string,
signal: AbortSignal,
): Promise<CompleteUploadResponse> {
return documentFetch(`/api/v1/document-uploads/${encodeURIComponent(uploadId)}/complete`, {
method: "POST",
headers: { Accept: "application/json" },
signal,
});
}
export function getDocumentJob(jobId: string, signal?: AbortSignal): Promise<DocumentJob> {
return documentFetch(`/api/v1/document-jobs/${encodeURIComponent(jobId)}`, {
headers: { Accept: "application/json" },
...optionalSignal(signal),
});
}
export async function listDocuments(signal?: AbortSignal): Promise<DocumentListResponse> {
let page = await documentFetch<DocumentListResponse>("/api/v1/documents?limit=100", {
headers: { Accept: "application/json" },
...optionalSignal(signal),
});
const items = [...page.items];
const seenCursors = new Set<string>();
let requests = 1;
while (page.next_cursor !== null) {
if (requests >= 100 || seenCursors.has(page.next_cursor)) {
throw new ApiError("http", "文档目录分页超过安全上限");
}
seenCursors.add(page.next_cursor);
page = await documentFetch<DocumentListResponse>(
`/api/v1/documents?limit=100&cursor=${encodeURIComponent(page.next_cursor)}`,
{ headers: { Accept: "application/json" }, ...optionalSignal(signal) },
);
items.push(...page.items);
requests += 1;
}
return { items, next_cursor: null };
}
function assertSameReviewVersion(previous: ReviewBundle, next: ReviewBundle): void {
if (
previous.document.id !== next.document.id ||
previous.version?.id !== next.version?.id ||
previous.version?.review_revision !== next.version?.review_revision
) {
throw new ApiError("http", "复核内容在加载期间发生变化,请重新加载");
}
}
export async function getCompleteReviewBundle(
documentId: string,
signal?: AbortSignal,
): Promise<ReviewBundle> {
const basePath = `/api/v1/documents/${encodeURIComponent(documentId)}/review-bundle`;
let bundle = await documentFetch<ReviewBundle>(`${basePath}?after_ordinal=-1&limit=100`, {
headers: { Accept: "application/json" },
...optionalSignal(signal),
});
let cursor = bundle.next_ordinal;
let requests = 1;
while (cursor !== null) {
if (requests >= 100) throw new ApiError("http", "复核内容分页超过安全上限");
const page = await documentFetch<ReviewBundle>(
`${basePath}?after_ordinal=${encodeURIComponent(String(cursor))}&limit=100`,
{ headers: { Accept: "application/json" }, ...optionalSignal(signal) },
);
assertSameReviewVersion(bundle, page);
bundle = {
...bundle,
pages: [...bundle.pages, ...page.pages],
blocks: [...bundle.blocks, ...page.blocks],
chunks: [...bundle.chunks, ...page.chunks],
next_ordinal: page.next_ordinal,
};
cursor = page.next_ordinal;
requests += 1;
}
return bundle;
}
export function createReviewDecision(
documentId: string,
request: ReviewDecisionRequest,
signal?: AbortSignal,
): Promise<ReviewDecisionResponse> {
return documentFetch(`/api/v1/documents/${encodeURIComponent(documentId)}/review-decisions`, {
method: "POST",
headers: JSON_HEADERS,
body: JSON.stringify(request),
...optionalSignal(signal),
});
}

View File

@@ -0,0 +1,93 @@
import { Icon } from "../../../components/Icon";
import { getApiErrorMessage } from "../../../api/client";
import type { DocumentSummary } from "../types";
interface DocumentLibraryProps {
documents: DocumentSummary[] | undefined;
error: unknown;
isLoading: boolean;
isRefreshing: boolean;
selectedId: string | null;
onRefresh: () => void;
onSelect: (id: string) => void;
}
function documentState(document: DocumentSummary): string {
if (document.active_version_id !== null) return "已激活检索";
if (document.status === "REJECTED") return "已拒绝出域";
return document.status;
}
export function DocumentLibrary({
documents,
error,
isLoading,
isRefreshing,
selectedId,
onRefresh,
onSelect,
}: DocumentLibraryProps) {
return (
<section className="document-panel document-library" aria-labelledby="document-library-title">
<div className="section-heading">
<div>
<span className="eyebrow">GOVERNED LIBRARY</span>
<h2 id="document-library-title"></h2>
</div>
<button
className="tertiary-button"
disabled={isRefreshing}
onClick={onRefresh}
type="button"
>
{isRefreshing ? "刷新中" : "刷新"}
</button>
</div>
{isLoading && (
<div className="document-library-state" role="status">
<span className="button-spinner button-spinner--forest" aria-hidden="true" />
</div>
)}
{error !== null && (
<div className="document-library-state document-library-state--error" role="alert">
<Icon name="alert" size={20} />
<span>{getApiErrorMessage(error)}</span>
</div>
)}
{!isLoading && error === null && documents?.length === 0 && (
<div className="document-library-state">
<Icon name="database" size={22} />
<span></span>
</div>
)}
{documents !== undefined && documents.length > 0 && (
<div className="document-list" role="list">
{documents.map((document) => (
<div key={document.id} role="listitem">
<button
aria-pressed={selectedId === document.id}
className={`document-list__item${
selectedId === document.id ? " document-list__item--active" : ""
}`}
onClick={() => onSelect(document.id)}
type="button"
>
<span className="document-list__icon">
<Icon name="document" size={18} />
</span>
<span className="document-list__copy">
<strong>{document.filename}</strong>
<small>{document.mime_type}</small>
<code>{document.raw_sha256.slice(0, 16)}</code>
</span>
<span className="document-list__state">{documentState(document)}</span>
</button>
</div>
))}
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,300 @@
import { useState } from "react";
import { getApiErrorMessage } from "../../../api/client";
import { Icon } from "../../../components/Icon";
import type { RejectionReason, ReviewBundle } from "../types";
interface DocumentReviewPanelProps {
bundle: ReviewBundle | undefined;
error: unknown;
isLoading: boolean;
isDecisionPending: boolean;
decisionError: unknown;
onApprove: () => void;
onReject: (reason: RejectionReason) => void;
onRetry: () => void;
}
const REJECTION_REASONS: { value: RejectionReason; label: string }[] = [
{ value: "RIGHTS_NOT_VERIFIED", label: "资料权利或授权未核实" },
{ value: "CONTENT_QUALITY_REJECTED", label: "内容质量不满足入库要求" },
{ value: "CLOUD_PROCESSING_REJECTED", label: "不允许提交云端处理" },
];
function reviewStateLabel(state: string): string {
const labels: Record<string, string> = {
LOCAL_PARSED_PENDING_CLOUD_REVIEW: "等待人工复核",
CLOUD_APPROVED: "已批准出域",
REJECTED: "已拒绝出域",
OCR_REQUIRED: "需要可靠 OCR",
};
return labels[state] ?? state;
}
export function DocumentReviewPanel({
bundle,
error,
isLoading,
isDecisionPending,
decisionError,
onApprove,
onReject,
onRetry,
}: DocumentReviewPanelProps) {
const [confirmed, setConfirmed] = useState(false);
const [rejectionReason, setRejectionReason] = useState<RejectionReason>("RIGHTS_NOT_VERIFIED");
if (isLoading) {
return (
<section className="document-panel document-review-panel" aria-live="polite">
<div className="document-review-state">
<span className="button-spinner button-spinner--forest" aria-hidden="true" />
<h2></h2>
<p> revision </p>
</div>
</section>
);
}
if (error !== null) {
return (
<section className="document-panel document-review-panel">
<div className="document-review-state document-review-state--error" role="alert">
<Icon name="alert" size={24} />
<h2></h2>
<p>{getApiErrorMessage(error)}</p>
<button className="secondary-button" onClick={onRetry} type="button">
</button>
</div>
</section>
);
}
if (bundle === undefined) {
return (
<section className="document-panel document-review-panel">
<div className="document-review-state">
<Icon name="document" size={28} />
<h2></h2>
<p></p>
</div>
</section>
);
}
const version = bundle.version;
if (version === null) {
return (
<section className="document-panel document-review-panel">
<div className="document-review-state">
<Icon name="layers" size={28} />
<h2>{bundle.document.filename}</h2>
<p> Worker </p>
</div>
</section>
);
}
const expectedChunksMatch = version.expected_chunk_count === bundle.chunks.length;
const reviewable =
version.review_state === "LOCAL_PARSED_PENDING_CLOUD_REVIEW" &&
version.status === "PROCESSING" &&
version.outbound_manifest_sha256 !== null &&
bundle.chunks.length > 0 &&
expectedChunksMatch;
return (
<section
className="document-panel document-review-panel"
aria-labelledby="document-review-title"
>
<div className="document-review-header">
<div>
<span className="eyebrow">MANIFEST-BOUND REVIEW</span>
<h2 id="document-review-title">{bundle.document.filename}</h2>
<span className="document-review-state-badge">
{reviewStateLabel(version.review_state)}
</span>
</div>
<dl>
<div>
<dt>Revision</dt>
<dd>{version.review_revision}</dd>
</div>
<div>
<dt></dt>
<dd>{version.status}</dd>
</div>
<div>
<dt> / / Chunk</dt>
<dd>
{bundle.pages.length} / {bundle.blocks.length} / {bundle.chunks.length}
</dd>
</div>
</dl>
</div>
<div className="document-manifest">
<div>
<span> SHA-256</span>
<code>{version.outbound_manifest_sha256 ?? "尚未生成"}</code>
</div>
<div>
<span>Parser / Chunk Profile</span>
<code>{version.parser_profile_hash}</code>
<code>{version.chunk_profile_hash}</code>
</div>
<div>
<span>Cloud Policy</span>
<code>{version.cloud_policy_id}</code>
</div>
</div>
{!expectedChunksMatch && (
<div className="document-review-warning" role="alert">
<Icon name="alert" size={18} />
<span>
{version.expected_chunk_count} Chunk {bundle.chunks.length}
</span>
</div>
)}
<div className="document-review-content">
<section aria-labelledby="review-pages-title">
<div className="document-subheading">
<h3 id="review-pages-title"></h3>
<span>{bundle.pages.length}</span>
</div>
{bundle.pages.length === 0 ? (
<p className="document-empty-copy">PDF OCR</p>
) : (
<div className="document-evidence-list">
{bundle.pages.map((page) => (
<details key={page.id}>
<summary>
{page.page_number ?? page.ordinal + 1} · {page.line_start}-
{page.line_end}
</summary>
<p>{page.text}</p>
<code>{page.text_sha256}</code>
</details>
))}
</div>
)}
<div className="document-subheading document-subheading--blocks">
<h3></h3>
<span>{bundle.blocks.length}</span>
</div>
{bundle.blocks.length === 0 ? (
<p className="document-empty-copy"></p>
) : (
<div className="document-evidence-list">
{bundle.blocks.map((block) => (
<details key={block.id}>
<summary>
{block.kind} · {block.section_path.join(" / ") || "未标注章节"} ·
{block.line_start}-{block.line_end}
</summary>
<p>{block.text}</p>
<code>{block.anchor_id}</code>
</details>
))}
</div>
)}
</section>
<section aria-labelledby="review-chunks-title">
<div className="document-subheading">
<h3 id="review-chunks-title"> Chunk</h3>
<span>{bundle.chunks.length}</span>
</div>
{bundle.chunks.length === 0 ? (
<p className="document-empty-copy"> Chunk</p>
) : (
<div className="document-chunk-list">
{bundle.chunks.map((chunk) => (
<article key={`${chunk.ordinal}:${chunk.cloud_text_sha256}`}>
<header>
<strong>Chunk {chunk.ordinal + 1}</strong>
<span>{chunk.token_count} tokens</span>
</header>
<p>{chunk.cloud_text}</p>
<footer>
<span>
{chunk.section_path.length === 0
? "未标注章节"
: chunk.section_path.join(" / ")}
</span>
<code>{chunk.cloud_text_sha256.slice(0, 18)}</code>
</footer>
</article>
))}
</div>
)}
</section>
</div>
<div className="document-human-gate">
<div className="document-human-gate__notice">
<Icon name="shield" size={20} />
<div>
<strong></strong>
<span>
revision
manifest
</span>
</div>
</div>
<label className="document-confirmation">
<input
checked={confirmed}
disabled={!reviewable || isDecisionPending}
onChange={(event) => setConfirmed(event.target.checked)}
type="checkbox"
/>
</label>
<div className="document-decision-actions">
<button
className="primary-button"
disabled={!reviewable || !confirmed || isDecisionPending}
onClick={onApprove}
type="button"
>
{isDecisionPending && <span className="button-spinner" aria-hidden="true" />}
</button>
<div className="document-reject-control">
<label htmlFor="rejection-reason"></label>
<select
disabled={!reviewable || isDecisionPending}
id="rejection-reason"
onChange={(event) => setRejectionReason(event.target.value as RejectionReason)}
value={rejectionReason}
>
{REJECTION_REASONS.map((reason) => (
<option key={reason.value} value={reason.value}>
{reason.label}
</option>
))}
</select>
<button
className="tertiary-button document-reject-button"
disabled={!reviewable || isDecisionPending}
onClick={() => onReject(rejectionReason)}
type="button"
>
</button>
</div>
</div>
{decisionError !== null && (
<p className="field-error document-decision-error" role="alert">
{getApiErrorMessage(decisionError)}
</p>
)}
</div>
</section>
);
}

View File

@@ -0,0 +1,162 @@
import { useRef, useState } from "react";
import { Icon } from "../../../components/Icon";
import { DOCUMENT_ACCEPT, formatBytes, validateDocumentFile } from "../file";
import type { DocumentJob, UploadPhase, UploadWorkflowState } from "../types";
interface DocumentUploadPanelProps {
workflow: UploadWorkflowState;
job: DocumentJob | undefined;
onUpload: (file: File) => void;
onCancel: () => void;
}
const ACTIVE_PHASES: UploadPhase[] = [
"hashing",
"declaring",
"uploading",
"completing",
"parsing",
"indexing",
];
const STEPS = [
{ phases: ["hashing", "declaring", "uploading", "completing"], label: "校验并隔离上传" },
{ phases: ["parsing"], label: "本地安全解析" },
{ phases: ["review"], label: "人工复核出域清单" },
{ phases: ["indexing", "indexed"], label: "向量化与激活" },
] as const;
function stepState(phase: UploadPhase, index: number): "pending" | "active" | "done" {
if (phase === "indexed") return "done";
const activeIndex = STEPS.findIndex((step) => (step.phases as readonly string[]).includes(phase));
if (activeIndex === -1) return "pending";
if (index < activeIndex) return "done";
return index === activeIndex ? "active" : "pending";
}
export function DocumentUploadPanel({
workflow,
job,
onUpload,
onCancel,
}: DocumentUploadPanelProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [file, setFile] = useState<File | null>(null);
const [selectionError, setSelectionError] = useState<string | null>(null);
const busy = ACTIVE_PHASES.includes(workflow.phase);
function chooseFile(next: File | undefined) {
if (next === undefined) return;
const error = validateDocumentFile(next);
setSelectionError(error);
setFile(error === null ? next : null);
}
return (
<section
className="document-panel document-upload-panel"
aria-labelledby="document-upload-title"
>
<div className="section-heading">
<div>
<span className="eyebrow">INGESTION GATE</span>
<h2 id="document-upload-title"></h2>
</div>
<span className="section-heading__meta"> 100 MiB</span>
</div>
<div
className={`document-dropzone${selectionError === null ? "" : " document-dropzone--error"}`}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault();
if (!busy) chooseFile(event.dataTransfer.files[0]);
}}
>
<Icon name="document" size={27} />
<strong>{file === null ? "选择或拖入地质资料" : file.name}</strong>
<span>
{file === null
? "支持 TXT、Markdown、DOCX、PDF"
: `${formatBytes(file.size)} · 等待本地校验`}
</span>
<button
className="secondary-button document-file-button"
disabled={busy}
onClick={() => inputRef.current?.click()}
type="button"
>
</button>
<input
ref={inputRef}
accept={DOCUMENT_ACCEPT}
aria-label="选择待上传文档"
className="visually-hidden"
disabled={busy}
onChange={(event) => chooseFile(event.target.files?.[0])}
type="file"
/>
</div>
{selectionError !== null && (
<p className="field-error" role="alert">
{selectionError}
</p>
)}
<div className="document-workflow" aria-label="文档处理阶段">
{STEPS.map((step, index) => {
const state = stepState(workflow.phase, index);
return (
<div
className={`document-workflow__step document-workflow__step--${state}`}
key={step.label}
>
<span aria-hidden="true">{state === "done" ? "✓" : index + 1}</span>
<small>{step.label}</small>
</div>
);
})}
</div>
<div className="document-live-status" aria-live="polite">
<div>
<strong>{workflow.filename ?? "尚未启动上传"}</strong>
<span>{workflow.message ?? "文件只在浏览器计算摘要,模型密钥不会进入页面。"}</span>
</div>
{job !== undefined && (
<div className="document-job-progress">
<span>{job.stage}</span>
<strong>{job.progress}%</strong>
</div>
)}
</div>
<div className="document-upload-actions">
<button
className="primary-button"
disabled={file === null || busy}
onClick={() => {
if (file !== null) onUpload(file);
}}
type="button"
>
{busy && <span className="button-spinner" aria-hidden="true" />}
{busy ? "处理中" : "校验并上传"}
</button>
{busy && (
<button className="tertiary-button" onClick={onCancel} type="button">
</button>
)}
</div>
<p className="disabled-note">
<Icon name="shield" size={15} />
PDF OCR_REQUIRED
</p>
</section>
);
}

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { declaredMimeType, sha256File, validateDocumentFile } from "./file";
describe("document file validation", () => {
it("computes the browser SHA-256 over exact file bytes", async () => {
const file = new File(["abc"], "sample.md", { type: "text/markdown" });
expect(await sha256File(file)).toBe(
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
);
});
it.each([
["report.txt", "text/plain"],
["report.md", "text/markdown"],
["report.markdown", "text/markdown"],
["report.pdf", "application/pdf"],
["report.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
])("maps %s to the server declaration %s", (filename, expected) => {
expect(declaredMimeType(filename)).toBe(expected);
});
it("rejects empty and unsupported files before hashing", () => {
expect(validateDocumentFile(new File([], "empty.md"))).toBe("不能上传空文件");
expect(validateDocumentFile(new File(["x"], "archive.zip"))).toBe(
"仅支持 TXT、Markdown、DOCX 和 PDF 文件",
);
});
it("rejects unsafe or secret-looking filenames", () => {
expect(validateDocumentFile(new File(["x"], " report.md"))).toBe(
"文件名包含不安全内容,请重命名后再上传",
);
expect(validateDocumentFile(new File(["x"], "sk-1234567890123456.md"))).toBe(
"文件名包含不安全内容,请重命名后再上传",
);
});
});

View File

@@ -0,0 +1,47 @@
const ACCEPTED_FILE_TYPES = {
".txt": "text/plain",
".md": "text/markdown",
".markdown": "text/markdown",
".pdf": "application/pdf",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
} as const;
export const DOCUMENT_ACCEPT = Object.keys(ACCEPTED_FILE_TYPES).join(",");
export const MAX_DOCUMENT_BYTES = 100 * 1024 * 1024;
export function declaredMimeType(filename: string): string | null {
const normalized = filename.toLowerCase();
const extension = Object.keys(ACCEPTED_FILE_TYPES).find((candidate) =>
normalized.endsWith(candidate),
) as keyof typeof ACCEPTED_FILE_TYPES | undefined;
return extension === undefined ? null : ACCEPTED_FILE_TYPES[extension];
}
export function validateDocumentFile(file: File): string | null {
if (
file.name !== file.name.trim() ||
file.name.includes("\0") ||
file.name.includes("/") ||
file.name.includes("\\") ||
/(?:sk-[A-Za-z0-9_-]{16,}|Bearer\s+[A-Za-z0-9._~+/-]{16,})/i.test(file.name)
) {
return "文件名包含不安全内容,请重命名后再上传";
}
if (declaredMimeType(file.name) === null) {
return "仅支持 TXT、Markdown、DOCX 和 PDF 文件";
}
if (file.size === 0) return "不能上传空文件";
if (file.size > MAX_DOCUMENT_BYTES) return "文件不能超过 100 MiB";
return null;
}
export async function sha256File(file: File): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", await file.arrayBuffer());
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MiB`;
}

View File

@@ -0,0 +1,291 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react";
import { getApiErrorMessage } from "../../../api/client";
import {
completeDocumentUpload,
createDocumentUpload,
createReviewDecision,
getCompleteReviewBundle,
getDocumentJob,
listDocuments,
putDocumentContent,
} from "../api";
import { declaredMimeType, sha256File, validateDocumentFile } from "../file";
import type { RejectionReason, ReviewDecisionRequest, UploadWorkflowState } from "../types";
const INITIAL_WORKFLOW: UploadWorkflowState = {
phase: "idle",
filename: null,
message: null,
};
type JobKind = "parse" | "index";
interface TrackedJob {
id: string;
kind: JobKind;
}
function isAbort(error: unknown): boolean {
return error instanceof DOMException && error.name === "AbortError";
}
export function useDocumentsWorkspace() {
const queryClient = useQueryClient();
const [workflow, setWorkflow] = useState<UploadWorkflowState>(INITIAL_WORKFLOW);
const [selectedDocumentId, setSelectedDocumentId] = useState<string | null>(null);
const [trackedJob, setTrackedJob] = useState<TrackedJob | null>(null);
const uploadController = useRef<AbortController | null>(null);
const handledTerminal = useRef<string | null>(null);
const documentsQuery = useQuery({
queryKey: ["documents"],
queryFn: ({ signal }) => listDocuments(signal),
});
const effectiveSelectedDocumentId =
selectedDocumentId ?? documentsQuery.data?.items[0]?.id ?? null;
const reviewQuery = useQuery({
queryKey: ["document-review", effectiveSelectedDocumentId],
queryFn: ({ signal }) => {
if (effectiveSelectedDocumentId === null) throw new Error("document id is required");
return getCompleteReviewBundle(effectiveSelectedDocumentId, signal);
},
enabled: effectiveSelectedDocumentId !== null,
});
const jobQuery = useQuery({
queryKey: ["document-job", trackedJob?.id],
queryFn: ({ signal }) => {
if (trackedJob === null) throw new Error("job id is required");
return getDocumentJob(trackedJob.id, signal);
},
enabled: trackedJob !== null,
refetchInterval: (query) => {
const status = query.state.data?.status;
return status === "QUEUED" || status === "RUNNING" || status === undefined ? 1_000 : false;
},
});
useEffect(() => {
const job = jobQuery.data;
if (job === undefined || trackedJob === null) return;
const terminalKey = `${job.id}:${job.status}`;
if (job.status !== "SUCCEEDED" && job.status !== "FAILED" && job.status !== "CANCELLED") return;
if (handledTerminal.current === terminalKey) return;
handledTerminal.current = terminalKey;
const timeout = window.setTimeout(() => {
if (job.status === "SUCCEEDED") {
if (trackedJob.kind === "parse" && job.stage === "PARSE_REJECTED") {
setWorkflow((current) => ({
...current,
phase: "error",
message: `本地解析被安全策略拒绝${
job.last_error_code === null ? "" : `${job.last_error_code}`
}`,
}));
void queryClient.invalidateQueries({ queryKey: ["documents"] });
return;
}
setWorkflow((current) => ({
...current,
phase: trackedJob.kind === "parse" ? "review" : "indexed",
message:
trackedJob.kind === "parse"
? job.stage === "OCR_REQUIRED"
? "本地检查完成,但该 PDF 需要可靠 OCR 后才能复核"
: "本地解析完成,必须人工复核后才能出域向量化"
: "向量索引完成,文档已通过完整性校验",
}));
void queryClient.invalidateQueries({ queryKey: ["documents"] });
void queryClient.invalidateQueries({
queryKey: ["document-review", effectiveSelectedDocumentId],
});
return;
}
setWorkflow((current) => ({
...current,
phase: "error",
message:
job.status === "CANCELLED"
? "后台作业已取消"
: `后台作业失败${job.last_error_code === null ? "" : `${job.last_error_code}`}`,
}));
}, 0);
return () => window.clearTimeout(timeout);
}, [effectiveSelectedDocumentId, jobQuery.data, queryClient, trackedJob]);
useEffect(() => {
if (jobQuery.error === null || trackedJob === null) return;
const errorKey = `${trackedJob.id}:request-error`;
if (handledTerminal.current === errorKey) return;
handledTerminal.current = errorKey;
const timeout = window.setTimeout(() => {
setWorkflow((current) => ({
...current,
phase: "error",
message: getApiErrorMessage(jobQuery.error),
}));
}, 0);
return () => window.clearTimeout(timeout);
}, [jobQuery.error, trackedJob]);
const startUpload = useCallback(
async (file: File) => {
const validationError = validateDocumentFile(file);
if (validationError !== null) {
setWorkflow({ phase: "error", filename: file.name, message: validationError });
return;
}
const mimeType = declaredMimeType(file.name);
if (mimeType === null) return;
uploadController.current?.abort();
const controller = new AbortController();
uploadController.current = controller;
setTrackedJob(null);
handledTerminal.current = null;
try {
setWorkflow({ phase: "hashing", filename: file.name, message: "正在本地计算 SHA-256" });
const sha256 = await sha256File(file);
if (controller.signal.aborted) throw new DOMException("cancelled", "AbortError");
setWorkflow({ phase: "declaring", filename: file.name, message: "正在创建幂等上传声明" });
const upload = await createDocumentUpload(
{
filename: file.name,
declared_mime_type: mimeType,
expected_size: file.size,
expected_sha256: sha256,
},
crypto.randomUUID(),
controller.signal,
);
setWorkflow({ phase: "uploading", filename: file.name, message: "正在写入隔离存储" });
await putDocumentContent(upload.id, file, controller.signal);
setWorkflow({ phase: "completing", filename: file.name, message: "正在提交本地解析作业" });
const completed = await completeDocumentUpload(upload.id, controller.signal);
setSelectedDocumentId(completed.document.id);
setTrackedJob({ id: completed.job.id, kind: "parse" });
handledTerminal.current = null;
setWorkflow({ phase: "parsing", filename: file.name, message: "本地 Worker 正在安全解析" });
void queryClient.invalidateQueries({ queryKey: ["documents"] });
} catch (error) {
if (isAbort(error)) {
setWorkflow({
phase: "cancelled",
filename: file.name,
message: "已停止当前页面的上传流程",
});
} else {
setWorkflow({ phase: "error", filename: file.name, message: getApiErrorMessage(error) });
}
} finally {
if (uploadController.current === controller) uploadController.current = null;
}
},
[queryClient],
);
const decisionMutation = useMutation({
mutationFn: ({ documentId, request }: { documentId: string; request: ReviewDecisionRequest }) =>
createReviewDecision(documentId, request),
onSuccess: (result) => {
handledTerminal.current = null;
if (result.job !== null) {
setTrackedJob({ id: result.job.id, kind: "index" });
setWorkflow((current) => ({
...current,
phase: "indexing",
message: "审核已锁定,模型 Worker 正在生成并校验向量索引",
}));
} else {
setTrackedJob(null);
setWorkflow((current) => ({
...current,
phase: "review",
message: "文档已拒绝出域,本地原始资料仍保持隔离",
}));
}
void queryClient.invalidateQueries({ queryKey: ["documents"] });
void queryClient.invalidateQueries({ queryKey: ["document-review", result.document_id] });
},
});
const approve = useCallback(() => {
const bundle = reviewQuery.data;
const version = bundle?.version;
if (bundle === undefined || !version?.outbound_manifest_sha256) {
return;
}
decisionMutation.mutate({
documentId: bundle.document.id,
request: {
decision: "APPROVE",
reason_code: "SYNTHETIC_REVIEW_APPROVED",
expected_revision: version.review_revision,
outbound_manifest_sha256: version.outbound_manifest_sha256,
},
});
}, [decisionMutation, reviewQuery.data]);
const reject = useCallback(
(reason: RejectionReason) => {
const bundle = reviewQuery.data;
if (bundle?.version === null || bundle?.version === undefined) return;
decisionMutation.mutate({
documentId: bundle.document.id,
request: {
decision: "REJECT",
reason_code: reason,
expected_revision: bundle.version.review_revision,
outbound_manifest_sha256: null,
},
});
},
[decisionMutation, reviewQuery.data],
);
const cancel = useCallback(() => {
uploadController.current?.abort();
if (trackedJob !== null) {
setTrackedJob(null);
setWorkflow((current) => ({
...current,
phase: "cancelled",
message: "已停止本页轮询;已提交的后台作业不会被页面强制终止",
}));
}
}, [trackedJob]);
const refresh = useCallback(() => {
void queryClient.invalidateQueries({ queryKey: ["documents"] });
if (effectiveSelectedDocumentId !== null) {
void queryClient.invalidateQueries({
queryKey: ["document-review", effectiveSelectedDocumentId],
});
}
}, [effectiveSelectedDocumentId, queryClient]);
return {
workflow,
documentsQuery,
reviewQuery,
jobQuery,
selectedDocumentId: effectiveSelectedDocumentId,
selectDocument: setSelectedDocumentId,
startUpload,
cancel,
refresh,
approve,
reject,
decisionMutation,
};
}

View File

@@ -0,0 +1,160 @@
export type UploadStatus = "CREATED" | "STORED" | "COMPLETED";
export type JobStatus = "QUEUED" | "RUNNING" | "SUCCEEDED" | "FAILED" | "CANCELLED";
export type ReviewDecision = "APPROVE" | "REJECT";
export interface DocumentUpload {
id: string;
filename: string;
declared_mime_type: string;
expected_size: number;
expected_sha256: string;
actual_size: number | null;
actual_sha256: string | null;
status: UploadStatus;
document_id: string | null;
parse_job_id: string | null;
created_at: string;
updated_at: string;
completed_at: string | null;
replayed: boolean;
}
export interface DocumentJob {
id: string;
job_type: string;
stage: string;
status: JobStatus;
progress: number;
attempt: number;
max_attempts: number;
last_error_code: string | null;
created_at: string;
updated_at: string;
finished_at: string | null;
}
export interface DocumentSummary {
id: string;
filename: string;
mime_type: string;
raw_sha256: string;
status: string;
active_version_id: string | null;
created_at: string;
updated_at: string;
}
export interface DocumentListResponse {
items: DocumentSummary[];
next_cursor: string | null;
}
export interface CompleteUploadResponse {
upload: DocumentUpload;
document: DocumentSummary;
job: DocumentJob;
}
export interface ReviewVersion {
id: string;
review_state: string;
review_revision: number;
status: string;
parser_profile_hash: string;
chunk_profile_hash: string;
cloud_policy_id: string;
outbound_manifest_sha256: string | null;
expected_chunk_count: number | null;
error_code: string | null;
created_at: string;
completed_at: string | null;
}
export interface ReviewPage {
id: string;
ordinal: number;
page_number: number | null;
text: string;
text_sha256: string;
line_start: number;
line_end: number;
}
export interface ReviewBlock {
id: string;
ordinal: number;
kind: string;
text: string;
text_sha256: string;
section_path: string[];
anchor_id: string;
char_start: number;
char_end: number;
line_start: number;
line_end: number;
page_start: number | null;
page_end: number | null;
}
export interface ReviewChunk {
ordinal: number;
display_text: string;
cloud_text: string;
cloud_text_sha256: string;
embedding_text_sha256: string;
token_count: number;
page_start: number | null;
page_end: number | null;
section_path: string[];
approval_status: string;
index_status: string;
}
export interface ReviewBundle {
document: DocumentSummary;
version: ReviewVersion | null;
pages: ReviewPage[];
blocks: ReviewBlock[];
chunks: ReviewChunk[];
next_ordinal: number | null;
}
export type RejectionReason =
"RIGHTS_NOT_VERIFIED" | "CONTENT_QUALITY_REJECTED" | "CLOUD_PROCESSING_REJECTED";
export interface ReviewDecisionRequest {
decision: ReviewDecision;
reason_code: "SYNTHETIC_REVIEW_APPROVED" | RejectionReason;
expected_revision: number;
outbound_manifest_sha256: string | null;
}
export interface ReviewDecisionResponse {
document_id: string;
document_version_id: string;
decision: ReviewDecision;
review_state: "CLOUD_APPROVED" | "REJECTED";
review_revision: number;
outbound_manifest_sha256: string | null;
embedding_profile_hash: string | null;
job: DocumentJob | null;
}
export type UploadPhase =
| "idle"
| "hashing"
| "declaring"
| "uploading"
| "completing"
| "parsing"
| "review"
| "indexing"
| "indexed"
| "cancelled"
| "error";
export interface UploadWorkflowState {
phase: UploadPhase;
filename: string | null;
message: string | null;
}

View File

@@ -0,0 +1,12 @@
import { requestJson } from "../../api/client";
import type { RetrievalSearchRequest, RetrievalSearchResponse } from "./types";
const RETRIEVAL_SEARCH_PATH = "/api/v1/retrieval/search";
export function searchRetrieval(request: RetrievalSearchRequest): Promise<RetrievalSearchResponse> {
return requestJson<RetrievalSearchResponse>(RETRIEVAL_SEARCH_PATH, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
});
}

View File

@@ -0,0 +1,170 @@
import { useId, useMemo, useRef, useState } from "react";
import { Icon } from "../../../components/Icon";
import {
DEFAULT_RETRIEVAL_QUERY,
QUERY_MAX_LENGTH,
REQUESTED_LIMIT_MAX,
SYNTHETIC_KNOWLEDGE_BASE_ID,
type RetrievalFormInput,
type RetrievalSearchRequest,
validateRetrievalInput,
} from "../types";
interface RetrievalFormProps {
isSearching: boolean;
onSearch: (request: RetrievalSearchRequest) => void;
}
const DEFAULT_INPUT: RetrievalFormInput = {
knowledgeBaseId: SYNTHETIC_KNOWLEDGE_BASE_ID,
query: DEFAULT_RETRIEVAL_QUERY,
vectorTopK: "50",
rerankTopN: "10",
};
export function RetrievalForm({ isSearching, onSearch }: RetrievalFormProps) {
const [input, setInput] = useState<RetrievalFormInput>(DEFAULT_INPUT);
const [submitted, setSubmitted] = useState(false);
const formRef = useRef<HTMLFormElement>(null);
const hintId = useId();
const errorId = useId();
const validation = useMemo(() => validateRetrievalInput(input), [input]);
const showValidation = submitted && !validation.valid;
function updateInput<K extends keyof RetrievalFormInput>(key: K, value: RetrievalFormInput[K]) {
setInput((current) => ({ ...current, [key]: value }));
if (submitted) setSubmitted(false);
}
function submit() {
setSubmitted(true);
if (isSearching || validation.request === null) return;
onSearch(validation.request);
}
return (
<section aria-labelledby="formal-retrieval-form-heading" className="retrieval-form-panel">
<div className="section-heading">
<div>
<span className="eyebrow">AUTHORIZED RETRIEVAL</span>
<h2 id="formal-retrieval-form-heading"></h2>
</div>
<span className="section-heading__meta">Dense Rerank</span>
</div>
<form
aria-busy={isSearching}
className="retrieval-form"
onSubmit={(event) => {
event.preventDefault();
submit();
}}
ref={formRef}
>
<label className="field-label" htmlFor="retrieval-knowledge-base">
ID
</label>
<input
aria-describedby={hintId}
className="text-input text-input--mono"
disabled={isSearching}
id="retrieval-knowledge-base"
onChange={(event) => updateInput("knowledgeBaseId", event.target.value)}
spellCheck={false}
type="text"
value={input.knowledgeBaseId}
/>
<p className="field-hint" id={hintId}>
访 scope
</p>
<label className="field-label retrieval-form__query-label" htmlFor="retrieval-query">
</label>
<div className={`query-box${showValidation ? " query-box--error" : ""}`}>
<textarea
aria-describedby={showValidation ? errorId : undefined}
aria-invalid={showValidation}
disabled={isSearching}
id="retrieval-query"
maxLength={QUERY_MAX_LENGTH}
onChange={(event) => updateInput("query", event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault();
formRef.current?.requestSubmit();
}
}}
rows={5}
value={input.query}
/>
<span
className={`character-count${input.query.length >= 450 ? " character-count--warning" : ""}`}
>
{input.query.length}/{QUERY_MAX_LENGTH}
</span>
</div>
<fieldset className="retrieval-parameter-fieldset">
<legend></legend>
<div className="retrieval-parameter-grid">
<label htmlFor="retrieval-vector-top-k">
<span></span>
<small>Vector Top K · 50</small>
<input
disabled={isSearching}
id="retrieval-vector-top-k"
inputMode="numeric"
max={REQUESTED_LIMIT_MAX}
min={1}
onChange={(event) => updateInput("vectorTopK", event.target.value)}
type="number"
value={input.vectorTopK}
/>
</label>
<label htmlFor="retrieval-rerank-top-n">
<span></span>
<small>Rerank Top N · 10</small>
<input
disabled={isSearching}
id="retrieval-rerank-top-n"
inputMode="numeric"
max={REQUESTED_LIMIT_MAX}
min={1}
onChange={(event) => updateInput("rerankTopN", event.target.value)}
type="number"
value={input.rerankTopN}
/>
</label>
</div>
</fieldset>
{showValidation ? (
<p className="field-error retrieval-form__error" id={errorId} role="alert">
{validation.message}
</p>
) : null}
<div className="retrieval-form__actions">
<button
className="tertiary-button"
disabled={isSearching}
onClick={() => {
setInput(DEFAULT_INPUT);
setSubmitted(false);
}}
type="button"
>
</button>
<button className="primary-button" disabled={isSearching} type="submit">
{isSearching ? <span className="button-spinner" /> : <Icon name="vector" size={19} />}
{isSearching ? "正在检索" : "运行正式检索"}
{!isSearching ? <Icon name="arrow" size={17} /> : null}
</button>
</div>
</form>
</section>
);
}

View File

@@ -0,0 +1,324 @@
import { ApiError, getApiErrorMessage } from "../../../api/client";
import { Icon } from "../../../components/Icon";
import type { RetrievalHit, RetrievalSearchRequest, RetrievalSearchResponse } from "../types";
interface RetrievalResultsProps {
data: RetrievalSearchResponse | undefined;
error: unknown;
isIdle: boolean;
isPending: boolean;
onRetry: () => void;
request: RetrievalSearchRequest | undefined;
}
function errorContent(error: unknown): { heading: string; detail: string } {
if (error instanceof ApiError && error.status === 403) {
return {
heading: "当前身份无权检索该知识库",
detail: "知识库和访问范围由服务端授权。请恢复合成知识库,或联系管理员配置访问权限。",
};
}
if (error instanceof ApiError && error.status === 409) {
return {
heading: "知识库尚未具备可检索 Profile",
detail: "请先完成文档审批、向量写入和 Active Embedding Profile 激活。",
};
}
if (error instanceof ApiError && error.status === 503) {
return {
heading: "检索服务暂不可用",
detail: "数据库、模型网关或 Embedding 服务暂不可用。已保留本次请求,可稍后原样重试。",
};
}
if (error instanceof ApiError && error.kind === "validation") {
return {
heading: "检索参数未通过服务端校验",
detail: "请检查知识库 ID、问题长度和 Top K 参数后重新提交。",
};
}
if (error instanceof ApiError && error.kind === "network") {
return {
heading: "无法连接正式检索 API",
detail: "请确认 Docker 服务已启动,并检查同源 /api 代理是否健康。",
};
}
return { heading: "本次正式检索未完成", detail: getApiErrorMessage(error) };
}
function formatMilliseconds(value: number): string {
if (value < 1) return `${value.toFixed(2)} ms`;
return `${value.toFixed(1)} ms`;
}
function formatScore(value: number | null | undefined): string {
return value === null || value === undefined ? "—" : value.toFixed(4);
}
function ResultCard({ hit }: { hit: RetrievalHit }) {
return (
<article aria-labelledby={`retrieval-source-${hit.citation_id}`} className="retrieval-hit-card">
<div className="retrieval-hit-card__rank">
<span></span>
<strong>#{hit.rank}</strong>
</div>
<div className="retrieval-hit-card__content">
<div className="retrieval-hit-card__header">
<div>
<span className="retrieval-hit-card__type">AUTHORIZED SOURCE</span>
<h3 id={`retrieval-source-${hit.citation_id}`}>{hit.source_name}</h3>
</div>
<div className="retrieval-score-pair">
<span aria-label={`向量初召回第 ${hit.vector_rank}`}>
Vector #{hit.vector_rank} <strong>{formatScore(hit.vector_score)}</strong>
</span>
<span aria-label={`重排分数 ${formatScore(hit.rerank_score)}`}>
Rerank <strong>{formatScore(hit.rerank_score)}</strong>
</span>
</div>
</div>
<p className="retrieval-hit-card__snippet">{hit.snippet}</p>
<dl className="retrieval-source-metadata">
<div>
<dt></dt>
<dd>{hit.section_path.length > 0 ? hit.section_path.join(" / ") : "章节未知"}</dd>
</div>
<div>
<dt></dt>
<dd>{hit.page_label}</dd>
</div>
<div>
<dt></dt>
<dd>
<code>{hit.citation_id}</code>
</dd>
</div>
</dl>
<details className="retrieval-hit-card__details">
<summary></summary>
<code>{hit.document_id}</code>
</details>
</div>
</article>
);
}
function RunSummary({
data,
request,
}: {
data: RetrievalSearchResponse;
request: RetrievalSearchRequest;
}) {
const timingItems = [
["Embedding", data.timings.embedding_ms],
["SQL / pgvector", data.timings.database_ms],
["Rerank", data.timings.rerank_ms],
["Total", data.timings.total_ms],
] as const;
return (
<div className="retrieval-run-summary">
<div className="retrieval-config-grid">
<div className="retrieval-config-card">
<span></span>
<strong>{data.parameters.vector_top_k}</strong>
<small>
{request.vector_top_k} {data.parameters.vector_top_k}
</small>
</div>
<div className="retrieval-config-card">
<span></span>
<strong>{data.parameters.rerank_top_n}</strong>
<small>
{request.rerank_top_n} {data.parameters.rerank_top_n}
</small>
</div>
<div className="retrieval-config-card retrieval-config-card--profile">
<span>Active Profile</span>
<strong>{data.profile.synthetic ? "合成 / 本地" : "真实 / 百炼"}</strong>
<small>
{data.profile.model} · {data.profile.dimension}D
</small>
</div>
<div className="retrieval-config-card">
<span></span>
<strong>{data.access_scope_count}</strong>
<small></small>
</div>
</div>
<div className="retrieval-timing-grid" aria-label="检索耗时">
{timingItems.map(([label, value]) => (
<div key={label}>
<span>{label}</span>
<strong>{formatMilliseconds(value)}</strong>
</div>
))}
</div>
<dl className="retrieval-trace">
<div>
<dt>Trace ID</dt>
<dd>
<code>{data.trace_id}</code>
</dd>
</div>
<div>
<dt>Profile Hash</dt>
<dd>
<code>{data.profile.profile_hash}</code>
</dd>
</div>
<div>
<dt>Embedding Request</dt>
<dd>
<code>{data.embedding_request_id ?? "local / unavailable"}</code>
</dd>
</div>
<div>
<dt>Rerank Request</dt>
<dd>
<code>{data.rerank_request_id ?? "degraded / local"}</code>
</dd>
</div>
</dl>
</div>
);
}
export function RetrievalResults({
data,
error,
isIdle,
isPending,
onRetry,
request,
}: RetrievalResultsProps) {
const failure = error === null || error === undefined ? null : errorContent(error);
const isEmpty = data?.status === "empty" || (data?.status === "ok" && data.results.length === 0);
return (
<section
aria-busy={isPending}
aria-labelledby="formal-results-heading"
className="retrieval-results"
>
<div className="section-heading section-heading--results">
<div>
<span className="eyebrow">RUN EVIDENCE</span>
<h2 id="formal-results-heading"></h2>
</div>
{data?.status === "ok" ? (
<span className={`run-status run-status--${data.rerank_status}`}>
{data.rerank_status === "applied"
? "重排完成"
: data.rerank_status === "degraded"
? "向量降级"
: "无候选"}
</span>
) : null}
</div>
<span className="visually-hidden" aria-live="polite">
{isPending
? "正在执行正式检索"
: failure
? failure.heading
: isEmpty
? "检索完成,当前授权范围内没有命中证据"
: data?.status === "ok"
? `检索完成,返回 ${data.results.length} 条证据`
: ""}
</span>
{isIdle ? (
<div className="retrieval-state retrieval-state--idle">
<span className="retrieval-state__icon">
<Icon name="vector" size={28} />
</span>
<h3></h3>
<p>
使 Active Embedding Profile Query Vector SQL
</p>
</div>
) : null}
{isPending ? (
<div className="retrieval-loading" role="status">
<div className="retrieval-loading__steps" aria-hidden="true">
<span>Query Embedding</span>
<span>Filtered pgvector</span>
<span>Rerank</span>
</div>
{[0, 1, 2].map((item) => (
<div className="skeleton-card" key={item}>
<span className="skeleton-line skeleton-line--short" />
<span className="skeleton-line skeleton-line--title" />
<span className="skeleton-line" />
<span className="skeleton-line skeleton-line--medium" />
</div>
))}
</div>
) : null}
{failure ? (
<div className="retrieval-state retrieval-state--error" role="alert">
<span className="retrieval-state__icon">
<Icon name="alert" size={25} />
</span>
<h3>{failure.heading}</h3>
<p>{failure.detail}</p>
<button className="secondary-button" onClick={onRetry} type="button">
</button>
</div>
) : null}
{data && request && isEmpty ? (
<div className="retrieval-state retrieval-state--empty">
<span className="retrieval-state__icon">
<Icon name="search" size={25} />
</span>
<h3></h3>
<p>
</p>
<RunSummary data={data} request={request} />
</div>
) : null}
{data?.status === "ok" && data.results.length > 0 && request ? (
<div className="retrieval-result-stack">
{data.rerank_status === "degraded" ? (
<div className="retrieval-degraded-notice" role="status">
<Icon name="alert" size={18} />
<div>
<strong></strong>
<span> Vector Rank Rerank </span>
</div>
</div>
) : null}
<div className="retrieval-query-summary">
<span></span>
<p>{request.query}</p>
</div>
<RunSummary data={data} request={request} />
<div className="retrieval-hit-list" aria-label="检索证据列表">
{data.results.map((hit) => (
<ResultCard hit={hit} key={hit.citation_id} />
))}
</div>
<p className="score-disclaimer">
Vector Rerank
</p>
</div>
) : null}
</section>
);
}

View File

@@ -0,0 +1,7 @@
import { useMutation } from "@tanstack/react-query";
import { searchRetrieval } from "../api";
export function useRetrievalSearch() {
return useMutation({ mutationFn: searchRetrieval });
}

View File

@@ -0,0 +1,71 @@
import type { components } from "../../api/schema.generated";
export type RetrievalHit = components["schemas"]["RetrievalHitResponse"];
export type RetrievalSearchRequest = components["schemas"]["RetrievalSearchRequest"];
export type RetrievalSearchResponse = components["schemas"]["RetrievalSearchResponse"];
export const SYNTHETIC_KNOWLEDGE_BASE_ID = "3acd3785-970b-55f7-a669-9eb4695e27eb";
export const DEFAULT_RETRIEVAL_QUERY = "花岗斑岩铜矿化有哪些典型蚀变特征?";
export const QUERY_MAX_LENGTH = 500;
export const REQUESTED_LIMIT_MAX = 10_000;
export interface RetrievalFormInput {
knowledgeBaseId: string;
query: string;
vectorTopK: string;
rerankTopN: string;
}
export interface RetrievalValidation {
valid: boolean;
message: string | null;
request: RetrievalSearchRequest | null;
}
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
function parseLimit(value: string): number | null {
if (!/^\d+$/.test(value)) return null;
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > REQUESTED_LIMIT_MAX) return null;
return parsed;
}
export function validateRetrievalInput(input: RetrievalFormInput): RetrievalValidation {
const knowledgeBaseId = input.knowledgeBaseId.trim();
const query = input.query.replace(/\s+/g, " ").trim();
const vectorTopK = parseLimit(input.vectorTopK);
const rerankTopN = parseLimit(input.rerankTopN);
if (!UUID_PATTERN.test(knowledgeBaseId)) {
return { valid: false, message: "请输入有效的知识库 UUID", request: null };
}
if (query.length === 0) {
return { valid: false, message: "请输入要检索的地质问题", request: null };
}
if (input.query.length > QUERY_MAX_LENGTH) {
return {
valid: false,
message: `问题不能超过 ${QUERY_MAX_LENGTH} 个字符`,
request: null,
};
}
if (vectorTopK === null || rerankTopN === null) {
return {
valid: false,
message: `请求参数必须是 1${REQUESTED_LIMIT_MAX} 的整数,后端仍会按安全上限限幅`,
request: null,
};
}
return {
valid: true,
message: null,
request: {
knowledge_base_id: knowledgeBaseId,
query,
vector_top_k: vectorTopK,
rerank_top_n: rerankTopN,
},
};
}

View File

@@ -0,0 +1,424 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createAppQueryClient } from "../app/queryClient";
import {
DEFAULT_CHAT_KNOWLEDGE_BASE_ID,
DEFAULT_CHAT_QUESTION,
type ChatEvidence,
} from "../features/chat/types";
import { ChatPage } from "./ChatPage";
const TRACE_ID = "50000000-0000-0000-0000-000000000001";
const evidence: ChatEvidence = {
label: "S1",
rank: 1,
vector_rank: 2,
citation_id: "60000000-0000-0000-0000-000000000001",
document_id: "70000000-0000-0000-0000-000000000001",
source_name: "<img src=x onerror=alert(1)>青岚铜矿报告.pdf",
snippet: "<script>alert('evidence')</script> 已批准的斑岩铜矿蚀变证据。",
section_path: ["区域地质", "蚀变分带"],
page_start: 8,
page_end: 9,
page_label: "第 8-9 页",
vector_score: 0.81,
rerank_score: 0.94,
};
const meta = {
seq: 1,
trace_id: TRACE_ID,
knowledge_base_id: DEFAULT_CHAT_KNOWLEDGE_BASE_ID,
profile: {
profile_hash: "b".repeat(64),
model: "fake-feature-hash-v1",
dimension: 1024,
synthetic: true,
},
generation_mode: "synthetic_extractive",
} as const;
const timings = {
embedding_ms: 1,
database_ms: 2,
rerank_ms: 3,
total_ms: 6,
};
function block(name: string, payload: object): string {
return `event: ${name}\ndata: ${JSON.stringify(payload)}\n\n`;
}
function successStream({ degraded = false }: { degraded?: boolean } = {}): string {
return [
block("meta", meta),
block("retrieval", {
seq: 2,
status: "ok",
rerank_status: degraded ? "degraded" : "applied",
degradation_reason: degraded ? "rerank_unavailable" : null,
evidence: [evidence],
timings,
}),
block("delta", { seq: 3, text: "<script>alert('answer')</script> " }),
block("delta", { seq: 4, text: "斑岩铜矿蚀变证据 [S1]。" }),
block("citations", { seq: 5, citations: [evidence] }),
block("usage", {
seq: 6,
model: "synthetic-grounded-extractive-v1",
request_id: null,
input_tokens: null,
output_tokens: null,
total_tokens: null,
}),
block("done", {
seq: 7,
status: "complete",
answer_mode: "grounded",
finish_reason: "synthetic_extractive",
}),
].join("");
}
function refusedStream(): string {
return [
block("meta", meta),
block("retrieval", {
seq: 2,
status: "empty",
rerank_status: "skipped_empty",
degradation_reason: null,
evidence: [],
timings,
}),
block("delta", { seq: 3, text: "未检索到足以支持回答的已批准证据,本次拒绝生成结论。" }),
block("citations", { seq: 4, citations: [] }),
block("usage", {
seq: 5,
model: "none",
request_id: null,
input_tokens: null,
output_tokens: null,
total_tokens: null,
}),
block("done", {
seq: 6,
status: "complete",
answer_mode: "refused",
finish_reason: "insufficient_evidence",
}),
].join("");
}
function providerErrorStream(): string {
return [
block("meta", {
...meta,
profile: {
...meta.profile,
model: "text-embedding-v4",
synthetic: false,
},
generation_mode: "cloud_grounded",
}),
block("retrieval", {
seq: 2,
status: "ok",
rerank_status: "applied",
degradation_reason: null,
evidence: [evidence],
timings,
}),
block("error", {
seq: 3,
status: "error",
code: "CHAT_PROVIDER_UNAVAILABLE",
title: "Grounded answer provider unavailable",
retryable: true,
answer_mode: "retrieval_only",
}),
].join("");
}
function sseResponse(body: string): Response {
return new Response(body, {
status: 200,
headers: { "Content-Type": "text/event-stream; charset=utf-8" },
});
}
function jsonResponse(body: unknown, status: number): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/problem+json" },
});
}
function renderChat() {
return render(
<QueryClientProvider client={createAppQueryClient()}>
<ChatPage />
</QueryClientProvider>,
);
}
function submittedBody(): unknown {
const call = vi.mocked(fetch).mock.calls[0];
if (call === undefined || typeof call[1]?.body !== "string") {
throw new Error("missing grounded chat request body");
}
return JSON.parse(call[1].body);
}
describe("ChatPage", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
it("streams a grounded answer, validates citations, and renders malicious markup as text", async () => {
vi.mocked(fetch).mockResolvedValue(sseResponse(successStream()));
const user = userEvent.setup();
renderChat();
expect(screen.getByLabelText("知识库 ID")).toHaveValue(DEFAULT_CHAT_KNOWLEDGE_BASE_ID);
expect(screen.getByLabelText("地质问题")).toHaveValue(DEFAULT_CHAT_QUESTION);
await user.click(screen.getByRole("button", { name: "开始证据问答" }));
expect(await screen.findAllByText("回答完成")).toHaveLength(3);
expect(screen.getByTestId("chat-answer")).toHaveTextContent(
"<script>alert('answer')</script> 斑岩铜矿蚀变证据 [S1]。",
);
expect(
screen.getByRole("heading", { name: "<img src=x onerror=alert(1)>青岚铜矿报告.pdf" }),
).toBeInTheDocument();
expect(screen.getByText("区域地质 / 蚀变分带")).toBeInTheDocument();
expect(screen.getByText("第 8-9 页")).toBeInTheDocument();
expect(screen.getByText(evidence.citation_id)).toBeInTheDocument();
expect(screen.getByText(TRACE_ID)).toBeInTheDocument();
expect(screen.getByText("Synthetic")).toBeInTheDocument();
expect(document.querySelector("script")).toBeNull();
expect(screen.queryByRole("img")).not.toBeInTheDocument();
expect(submittedBody()).toEqual({
knowledge_base_id: DEFAULT_CHAT_KNOWLEDGE_BASE_ID,
question: DEFAULT_CHAT_QUESTION,
vector_top_k: 50,
rerank_top_n: 10,
max_tokens: 1024,
});
});
it("renders validated delta events incrementally before the terminal event", async () => {
const encoder = new TextEncoder();
let streamController: ReadableStreamDefaultController<Uint8Array> | undefined;
const body = new ReadableStream<Uint8Array>({
start(controller) {
streamController = controller;
controller.enqueue(
encoder.encode(
block("meta", meta) +
block("retrieval", {
seq: 2,
status: "ok",
rerank_status: "applied",
degradation_reason: null,
evidence: [evidence],
timings,
}) +
block("delta", { seq: 3, text: "第一段证据 [S1]" }),
),
);
},
});
vi.mocked(fetch).mockResolvedValue(
new Response(body, { headers: { "Content-Type": "text/event-stream" } }),
);
const user = userEvent.setup();
renderChat();
await user.click(screen.getByRole("button", { name: "开始证据问答" }));
expect(await screen.findByTestId("chat-answer")).toHaveTextContent("第一段证据 [S1]");
expect(screen.getAllByText("正在生成")).not.toHaveLength(0);
act(() => {
streamController?.enqueue(
encoder.encode(
block("delta", { seq: 4, text: ",第二段。" }) +
block("citations", { seq: 5, citations: [evidence] }) +
block("usage", {
seq: 6,
model: "synthetic-grounded-extractive-v1",
request_id: null,
input_tokens: null,
output_tokens: null,
total_tokens: null,
}) +
block("done", {
seq: 7,
status: "complete",
answer_mode: "grounded",
finish_reason: "synthetic_extractive",
}),
),
);
streamController?.close();
});
expect(await screen.findAllByText("回答完成")).toHaveLength(3);
expect(screen.getByTestId("chat-answer")).toHaveTextContent("第一段证据 [S1],第二段。");
});
it("shows refused when retrieval has no approved evidence", async () => {
vi.mocked(fetch).mockResolvedValue(sseResponse(refusedStream()));
const user = userEvent.setup();
renderChat();
await user.click(screen.getByRole("button", { name: "开始证据问答" }));
expect(await screen.findAllByText("证据不足 · 已拒答")).toHaveLength(3);
expect(screen.getByTestId("chat-answer")).toHaveTextContent("本次拒绝生成结论");
expect(screen.getByText(/未找到足以支持回答的证据/)).toBeInTheDocument();
expect(screen.queryByRole("heading", { name: /回答引用来源/ })).not.toBeInTheDocument();
});
it("shows retrieval_only when the server safely falls back from an uncited model answer", async () => {
const stream = successStream().replace(
'"answer_mode":"grounded"',
'"answer_mode":"retrieval_only"',
);
vi.mocked(fetch).mockResolvedValue(sseResponse(stream));
const user = userEvent.setup();
renderChat();
await user.click(screen.getByRole("button", { name: "开始证据问答" }));
expect(await screen.findAllByText("仅检索证据模式")).not.toHaveLength(0);
expect(screen.getByText(/模型答案未通过引用约束/)).toBeInTheDocument();
expect(screen.getByTestId("chat-answer")).toHaveTextContent("[S1]");
expect(screen.getByRole("heading", { name: "回答引用来源" })).toBeInTheDocument();
});
it("keeps retrieval evidence when the provider emits a sanitized terminal error", async () => {
vi.mocked(fetch).mockResolvedValue(sseResponse(providerErrorStream()));
const user = userEvent.setup();
renderChat();
await user.click(screen.getByRole("button", { name: "开始证据问答" }));
expect(await screen.findByText("生成模型未能完成回答")).toBeInTheDocument();
expect(screen.getByText(/已保留本次检索证据/)).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "已保留的检索证据" })).toBeInTheDocument();
expect(
screen.getByRole("heading", { name: "<img src=x onerror=alert(1)>青岚铜矿报告.pdf" }),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "原样重试问题" })).toBeEnabled();
});
it("aborts the active POST stream and reports a stopped state", async () => {
let observedSignal: AbortSignal | undefined;
vi.mocked(fetch).mockImplementation(
(_input, init) =>
new Promise<Response>((_resolve, reject) => {
observedSignal = init?.signal ?? undefined;
observedSignal?.addEventListener("abort", () => {
reject(new DOMException("aborted", "AbortError"));
});
}),
);
const user = userEvent.setup();
renderChat();
await user.click(screen.getByRole("button", { name: "开始证据问答" }));
expect(await screen.findByText(/正在生成 Query Vector/)).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "停止回答" }));
expect(observedSignal?.aborted).toBe(true);
expect(await screen.findByText(/请求已由你主动取消/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "原样重试问题" })).toBeEnabled();
});
it("makes rerank degradation explicit while still completing the grounded answer", async () => {
vi.mocked(fetch).mockResolvedValue(sseResponse(successStream({ degraded: true })));
const user = userEvent.setup();
renderChat();
await user.click(screen.getByRole("button", { name: "开始证据问答" }));
expect(await screen.findByText("检索已降级")).toBeInTheDocument();
expect(screen.getByText(/不会伪装为已完成重排/)).toBeInTheDocument();
expect(screen.getAllByText("回答完成")).toHaveLength(3);
});
it.each([
[
"跳号",
block("meta", meta) +
block("retrieval", {
seq: 4,
status: "empty",
rerank_status: "skipped_empty",
degradation_reason: null,
evidence: [],
timings,
}),
],
[
"断流",
block("meta", meta) +
block("retrieval", {
seq: 2,
status: "ok",
rerank_status: "applied",
degradation_reason: null,
evidence: [evidence],
timings,
}) +
block("delta", { seq: 3, text: "不完整回答 [S1]" }),
],
])("fails closed for an invalid SSE %s", async (_caseName, body) => {
vi.mocked(fetch).mockResolvedValue(sseResponse(body));
const user = userEvent.setup();
renderChat();
await user.click(screen.getByRole("button", { name: "开始证据问答" }));
expect(await screen.findByText("回答流未能安全完成")).toBeInTheDocument();
expect(screen.getByText(/回答流不完整或格式无效/)).toBeInTheDocument();
expect(screen.queryByText("不完整回答 [S1]")).not.toBeInTheDocument();
expect(screen.queryByText(evidence.citation_id)).not.toBeInTheDocument();
});
it("handles pre-stream Problem JSON with a sanitized friendly message", async () => {
vi.mocked(fetch).mockResolvedValue(
jsonResponse(
{ detail: "password=do-not-leak sk-private provider-body", code: "PRIVATE" },
403,
),
);
const user = userEvent.setup();
renderChat();
await user.click(screen.getByRole("button", { name: "开始证据问答" }));
expect(await screen.findByText("回答流未能安全完成")).toBeInTheDocument();
expect(screen.getByText(/当前身份无权访问该知识库/)).toBeInTheDocument();
expect(screen.queryByText(/do-not-leak/)).not.toBeInTheDocument();
expect(screen.queryByText(/sk-private/)).not.toBeInTheDocument();
});
it("validates whitespace-only questions before opening a stream", async () => {
const user = userEvent.setup();
renderChat();
const question = screen.getByLabelText("地质问题");
await user.clear(question);
await user.type(question, " ");
await user.click(screen.getByRole("button", { name: "开始证据问答" }));
expect(screen.getByText("请输入要提问的地质问题")).toBeInTheDocument();
await waitFor(() => expect(vi.mocked(fetch)).not.toHaveBeenCalled());
});
});

View File

@@ -0,0 +1,57 @@
import { ChatComposer } from "../features/chat/components/ChatComposer";
import { ChatConversation } from "../features/chat/components/ChatConversation";
import { useGroundedChat } from "../features/chat/hooks/useGroundedChat";
import type { ChatCompletionRequest } from "../features/chat/types";
export function ChatPage() {
const chat = useGroundedChat();
function start(request: ChatCompletionRequest) {
void chat.start(request);
}
return (
<div className="page-stack chat-page">
<header className="hero chat-hero">
<div className="hero__copy">
<div className="mode-badge">
<span className="mode-badge__dot" />
Grounded Chat · SSE ·
</div>
<span className="eyebrow">EVIDENCE-GROUNDED ANSWERING</span>
<h1></h1>
<p className="hero__lead">
Citation Label
</p>
</div>
<div className="hero__seal" aria-hidden="true">
<div className="hero__seal-ring">
<span>RAG</span>
<small>GROUNDED</small>
</div>
<div className="hero__coordinates">SSE · [S1] · TRACE</div>
</div>
</header>
<div className="safety-notice" role="note">
<strong></strong>
<span>
HTML
</span>
</div>
<div className="chat-workspace">
<ChatComposer isRunning={chat.isRunning} onStart={start} onStop={chat.stop} />
<ChatConversation
isRunning={chat.isRunning}
onRetry={() => {
if (chat.state.request) void chat.start(chat.state.request);
}}
state={chat.state}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,466 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createAppQueryClient } from "../app/queryClient";
import type {
CompleteUploadResponse,
DocumentJob,
DocumentListResponse,
DocumentSummary,
DocumentUpload,
ReviewBundle,
ReviewDecisionResponse,
} from "../features/documents/types";
import { DocumentsPage } from "./DocumentsPage";
const FILE_HASH = "a".repeat(64);
const MANIFEST_HASH = "b".repeat(64);
const DOCUMENT_ID = "70000000-0000-0000-0000-000000000001";
const VERSION_ID = "71000000-0000-0000-0000-000000000001";
const UPLOAD_ID = "72000000-0000-0000-0000-000000000001";
const PARSE_JOB_ID = "73000000-0000-0000-0000-000000000001";
const INDEX_JOB_ID = "74000000-0000-0000-0000-000000000001";
const NOW = "2026-07-13T08:00:00Z";
vi.mock(import("../features/documents/file"), async (importOriginal) => {
const actual = await importOriginal();
return { ...actual, sha256File: vi.fn(() => Promise.resolve(FILE_HASH)) };
});
const document: DocumentSummary = {
id: DOCUMENT_ID,
filename: "<img src=x onerror=alert(1)>青岚合成资料.md",
mime_type: "text/markdown",
raw_sha256: FILE_HASH,
status: "PROCESSING",
active_version_id: null,
created_at: NOW,
updated_at: NOW,
};
const upload: DocumentUpload = {
id: UPLOAD_ID,
filename: "青岚合成资料.md",
declared_mime_type: "text/markdown",
expected_size: 12,
expected_sha256: FILE_HASH,
actual_size: null,
actual_sha256: null,
status: "CREATED",
document_id: null,
parse_job_id: null,
created_at: NOW,
updated_at: NOW,
completed_at: null,
replayed: false,
};
function job(id: string, jobType: string, status: DocumentJob["status"]): DocumentJob {
return {
id,
job_type: jobType,
stage:
status === "SUCCEEDED" && jobType === "PARSE_DOCUMENT"
? "LOCAL_PARSED_PENDING_CLOUD_REVIEW"
: status === "SUCCEEDED"
? "READY"
: "PENDING",
status,
progress: status === "SUCCEEDED" ? 100 : 0,
attempt: 1,
max_attempts: 3,
last_error_code: null,
created_at: NOW,
updated_at: NOW,
finished_at: status === "SUCCEEDED" ? NOW : null,
};
}
const reviewBundle: ReviewBundle = {
document,
version: {
id: VERSION_ID,
review_state: "LOCAL_PARSED_PENDING_CLOUD_REVIEW",
review_revision: 4,
status: "PROCESSING",
parser_profile_hash: "c".repeat(64),
chunk_profile_hash: "d".repeat(64),
cloud_policy_id: "synthetic-public-only-v1",
outbound_manifest_sha256: MANIFEST_HASH,
expected_chunk_count: 1,
error_code: null,
created_at: NOW,
completed_at: null,
},
pages: [
{
id: "75000000-0000-0000-0000-000000000001",
ordinal: 0,
page_number: 1,
text: "<script>alert('page')</script> 本地解析证据",
text_sha256: "e".repeat(64),
line_start: 1,
line_end: 3,
},
],
blocks: [
{
id: "76000000-0000-0000-0000-000000000001",
ordinal: 0,
kind: "paragraph",
text: "<iframe>block</iframe> 结构化矿化块",
text_sha256: "8".repeat(64),
section_path: ["矿化特征"],
anchor_id: "line:1-3",
char_start: 0,
char_end: 18,
line_start: 1,
line_end: 3,
page_start: 1,
page_end: 1,
},
],
chunks: [
{
ordinal: 0,
display_text: "展示文本",
cloud_text: "<img src=x onerror=alert(1)> 拟出域蚀变证据",
cloud_text_sha256: "f".repeat(64),
embedding_text_sha256: "f".repeat(64),
token_count: 18,
page_start: 1,
page_end: 1,
section_path: ["区域地质", "蚀变分带"],
approval_status: "PENDING",
index_status: "PENDING",
},
],
next_ordinal: null,
};
function response(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
function pathOf(input: RequestInfo | URL): string {
if (typeof input === "string") return input;
return input instanceof URL ? input.href : input.url;
}
function jsonBody(init: RequestInit | undefined): unknown {
if (typeof init?.body !== "string") throw new Error("expected a JSON request body");
return JSON.parse(init.body);
}
function renderDocuments() {
return render(
<QueryClientProvider client={createAppQueryClient()}>
<DocumentsPage />
</QueryClientProvider>,
);
}
function installReviewFetch(decision?: ReviewDecisionResponse | { status: number }) {
vi.mocked(fetch).mockImplementation((input, init) => {
const path = pathOf(input);
if (path.startsWith("/api/v1/documents?")) {
return Promise.resolve(response({ items: [document], next_cursor: null }));
}
if (path.includes("/review-bundle")) return Promise.resolve(response(reviewBundle));
if (path.includes("/review-decisions")) {
if (decision !== undefined && "status" in decision) {
return Promise.resolve(response({ detail: "private provider error" }, decision.status));
}
return Promise.resolve(
response(
decision ?? {
document_id: DOCUMENT_ID,
document_version_id: VERSION_ID,
decision: "APPROVE",
review_state: "CLOUD_APPROVED",
review_revision: 5,
outbound_manifest_sha256: MANIFEST_HASH,
embedding_profile_hash: "9".repeat(64),
job: job(INDEX_JOB_ID, "EMBED_DOCUMENT", "SUCCEEDED"),
},
202,
),
);
}
if (path.includes("/document-jobs/")) {
return Promise.resolve(response(job(INDEX_JOB_ID, "EMBED_DOCUMENT", "SUCCEEDED")));
}
throw new Error(`unexpected request ${path} ${String(init?.method)}`);
});
}
describe("DocumentsPage", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
it("renders an accessible empty library and explicit governance boundary", async () => {
vi.mocked(fetch).mockResolvedValue(
response({ items: [], next_cursor: null } satisfies DocumentListResponse),
);
renderDocuments();
expect(screen.getByRole("heading", { name: "地质资料入库与复核" })).toBeInTheDocument();
expect(screen.getByLabelText("选择待上传文档")).toHaveAttribute(
"accept",
".txt,.md,.markdown,.pdf,.docx",
);
expect(await screen.findByText(/暂无文档/)).toBeInTheDocument();
expect(screen.getByText(/上传成功不等于可检索/)).toBeInTheDocument();
expect(screen.getByText(/PDF 当前可能进入 OCR_REQUIRED/)).toBeInTheDocument();
});
it("hashes, declares, streams, completes, and observes the parse job", async () => {
const completed: CompleteUploadResponse = {
upload: {
...upload,
status: "COMPLETED",
document_id: DOCUMENT_ID,
parse_job_id: PARSE_JOB_ID,
completed_at: NOW,
},
document,
job: job(PARSE_JOB_ID, "PARSE_DOCUMENT", "QUEUED"),
};
vi.mocked(fetch).mockImplementation((input, init) => {
const path = pathOf(input);
if (path.startsWith("/api/v1/documents?")) {
return Promise.resolve(response({ items: [], next_cursor: null }));
}
if (path === "/api/v1/document-uploads") return Promise.resolve(response(upload, 201));
if (path.endsWith(`/document-uploads/${UPLOAD_ID}/content`)) {
return Promise.resolve(
response({ ...upload, status: "STORED", actual_size: 12, actual_sha256: FILE_HASH }),
);
}
if (path.endsWith(`/document-uploads/${UPLOAD_ID}/complete`)) {
return Promise.resolve(response(completed, 202));
}
if (path.endsWith(`/document-jobs/${PARSE_JOB_ID}`)) {
return Promise.resolve(response(job(PARSE_JOB_ID, "PARSE_DOCUMENT", "SUCCEEDED")));
}
if (path.includes("/review-bundle")) return Promise.resolve(response(reviewBundle));
throw new Error(`unexpected request ${path} ${String(init?.method)}`);
});
const user = userEvent.setup();
renderDocuments();
const file = new File(["hello geology"], "青岚合成资料.md", { type: "text/markdown" });
await user.upload(screen.getByLabelText("选择待上传文档"), file);
await user.click(screen.getByRole("button", { name: "校验并上传" }));
expect(await screen.findByText(/本地解析完成,必须人工复核/)).toBeInTheDocument();
const calls = vi.mocked(fetch).mock.calls;
const declaration = calls.find(([input]) => pathOf(input) === "/api/v1/document-uploads");
expect(declaration?.[1]?.headers).toMatchObject({
"Content-Type": "application/json",
"Idempotency-Key": expect.stringMatching(/^[0-9a-f-]{36}$/),
});
expect(jsonBody(declaration?.[1])).toEqual({
filename: "青岚合成资料.md",
declared_mime_type: "text/markdown",
expected_size: 13,
expected_sha256: FILE_HASH,
});
const content = calls.find(([input]) => pathOf(input).endsWith(`/${UPLOAD_ID}/content`));
expect(content?.[1]?.headers).toMatchObject({ "Content-Type": "application/octet-stream" });
expect(content?.[1]?.body).toBe(file);
});
it("shows a deterministic parse rejection as an error instead of a successful review", async () => {
const rejected = {
...job(PARSE_JOB_ID, "PARSE_DOCUMENT", "SUCCEEDED"),
stage: "PARSE_REJECTED",
last_error_code: "DOCX_PATH_TRAVERSAL",
};
vi.mocked(fetch).mockImplementation((input) => {
const path = pathOf(input);
if (path.startsWith("/api/v1/documents?")) {
return Promise.resolve(response({ items: [], next_cursor: null }));
}
if (path === "/api/v1/document-uploads") return Promise.resolve(response(upload, 201));
if (path.endsWith(`/document-uploads/${UPLOAD_ID}/content`)) {
return Promise.resolve(response({ ...upload, status: "STORED" }));
}
if (path.endsWith(`/document-uploads/${UPLOAD_ID}/complete`)) {
return Promise.resolve(
response(
{
upload: { ...upload, status: "COMPLETED" },
document,
job: job(PARSE_JOB_ID, "PARSE_DOCUMENT", "QUEUED"),
},
202,
),
);
}
if (path.endsWith(`/document-jobs/${PARSE_JOB_ID}`)) {
return Promise.resolve(response(rejected));
}
throw new Error(`unexpected request ${path}`);
});
const user = userEvent.setup();
renderDocuments();
await user.upload(
screen.getByLabelText("选择待上传文档"),
new File(["hello geology"], "青岚合成资料.md", { type: "text/markdown" }),
);
await user.click(screen.getByRole("button", { name: "校验并上传" }));
expect(
await screen.findByText(/本地解析被安全策略拒绝DOCX_PATH_TRAVERSAL/),
).toBeInTheDocument();
expect(screen.queryByText(/本地解析完成,必须人工复核/)).not.toBeInTheDocument();
});
it("requires human confirmation and binds approval to revision and manifest", async () => {
installReviewFetch();
const user = userEvent.setup();
renderDocuments();
expect(await screen.findByRole("heading", { name: document.filename })).toBeInTheDocument();
expect(screen.getByText("<script>alert('page')</script> 本地解析证据")).toBeInTheDocument();
expect(screen.getByText("<iframe>block</iframe> 结构化矿化块")).toBeInTheDocument();
expect(screen.getByText("<img src=x onerror=alert(1)> 拟出域蚀变证据")).toBeInTheDocument();
expect(window.document.querySelector("script")).toBeNull();
expect(screen.queryByRole("img")).not.toBeInTheDocument();
const approve = screen.getByRole("button", { name: "批准并开始向量化" });
expect(approve).toBeDisabled();
await user.click(screen.getByRole("checkbox", { name: /我已逐项核对/ }));
expect(approve).toBeEnabled();
await user.click(approve);
expect(await screen.findByText("向量索引完成,文档已通过完整性校验")).toBeInTheDocument();
const call = vi
.mocked(fetch)
.mock.calls.find(([input]) => pathOf(input).includes("/review-decisions"));
expect(jsonBody(call?.[1])).toEqual({
decision: "APPROVE",
reason_code: "SYNTHETIC_REVIEW_APPROVED",
expected_revision: 4,
outbound_manifest_sha256: MANIFEST_HASH,
});
});
it("rejects without exporting a manifest and preserves the selected reason", async () => {
installReviewFetch({
document_id: DOCUMENT_ID,
document_version_id: VERSION_ID,
decision: "REJECT",
review_state: "REJECTED",
review_revision: 5,
outbound_manifest_sha256: null,
embedding_profile_hash: null,
job: null,
});
const user = userEvent.setup();
renderDocuments();
await screen.findByRole("heading", { name: document.filename });
await user.selectOptions(screen.getByLabelText("拒绝原因"), "CLOUD_PROCESSING_REJECTED");
await user.click(screen.getByRole("button", { name: "拒绝出域" }));
expect(await screen.findByText(/文档已拒绝出域/)).toBeInTheDocument();
const call = vi
.mocked(fetch)
.mock.calls.find(([input]) => pathOf(input).includes("/review-decisions"));
expect(jsonBody(call?.[1])).toEqual({
decision: "REJECT",
reason_code: "CLOUD_PROCESSING_REJECTED",
expected_revision: 4,
outbound_manifest_sha256: null,
});
});
it("surfaces stale-review conflicts without exposing the provider body", async () => {
installReviewFetch({ status: 412 });
const user = userEvent.setup();
renderDocuments();
await screen.findByRole("heading", { name: document.filename });
await user.click(screen.getByRole("checkbox", { name: /我已逐项核对/ }));
await user.click(screen.getByRole("button", { name: "批准并开始向量化" }));
expect(await screen.findByText("审核内容已有新版本,请重新加载并复核")).toBeInTheDocument();
expect(screen.queryByText(/private provider error/)).not.toBeInTheDocument();
});
it("blocks approval when the expected chunk count is incomplete", async () => {
vi.mocked(fetch).mockImplementation((input) => {
const path = pathOf(input);
if (path.startsWith("/api/v1/documents?")) {
return Promise.resolve(response({ items: [document], next_cursor: null }));
}
if (path.includes("/review-bundle")) {
return Promise.resolve(
response({
...reviewBundle,
version: { ...reviewBundle.version!, expected_chunk_count: 2 },
}),
);
}
throw new Error(`unexpected request ${path}`);
});
renderDocuments();
expect(await screen.findByText(/期望 2 个 Chunk/)).toBeInTheDocument();
expect(screen.getByRole("checkbox", { name: /我已逐项核对/ })).toBeDisabled();
expect(screen.getByRole("button", { name: "批准并开始向量化" })).toBeDisabled();
});
it("rejects unsupported file extensions before any upload request", async () => {
vi.mocked(fetch).mockResolvedValue(response({ items: [], next_cursor: null }));
const user = userEvent.setup({ applyAccept: false });
renderDocuments();
await user.upload(
screen.getByLabelText("选择待上传文档"),
new File(["secret"], "archive.exe", { type: "application/octet-stream" }),
);
expect(screen.getByRole("alert")).toHaveTextContent("仅支持 TXT、Markdown、DOCX 和 PDF 文件");
expect(screen.getByRole("button", { name: "校验并上传" })).toBeDisabled();
await waitFor(() => expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1));
});
it("aborts an in-flight upload request and explains the server-side boundary", async () => {
vi.mocked(fetch).mockImplementation((input, init) => {
const path = pathOf(input);
if (path.startsWith("/api/v1/documents?")) {
return Promise.resolve(response({ items: [], next_cursor: null }));
}
if (path === "/api/v1/document-uploads") {
return new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => {
reject(new DOMException("cancelled", "AbortError"));
});
});
}
throw new Error(`unexpected request ${path}`);
});
const user = userEvent.setup();
renderDocuments();
await user.upload(
screen.getByLabelText("选择待上传文档"),
new File(["safe"], "safe.md", { type: "text/markdown" }),
);
await user.click(screen.getByRole("button", { name: "校验并上传" }));
await screen.findByText("正在创建幂等上传声明");
await user.click(screen.getByRole("button", { name: "停止本页流程" }));
expect(await screen.findByText("已停止当前页面的上传流程")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,71 @@
import { DocumentLibrary } from "../features/documents/components/DocumentLibrary";
import { DocumentReviewPanel } from "../features/documents/components/DocumentReviewPanel";
import { DocumentUploadPanel } from "../features/documents/components/DocumentUploadPanel";
import { useDocumentsWorkspace } from "../features/documents/hooks/useDocumentsWorkspace";
export function DocumentsPage() {
const workspace = useDocumentsWorkspace();
return (
<div className="page-stack documents-page">
<header className="hero documents-hero">
<div className="hero__copy">
<div className="mode-badge">
<span className="mode-badge__dot" />
Local-first · Human Review · Manifest Bound
</div>
<span className="eyebrow">GOVERNED KNOWLEDGE INGESTION</span>
<h1></h1>
<p className="hero__lead">
</p>
</div>
<div className="hero__seal" aria-hidden="true">
<div className="hero__seal-ring">
<span>DOC</span>
<small>REVIEW</small>
</div>
<div className="hero__coordinates">SHA-256 · LEASE · ACL</div>
</div>
</header>
<div className="safety-notice" role="note">
<strong></strong>
<span>
manifest
</span>
</div>
<div className="documents-top-grid">
<DocumentUploadPanel
job={workspace.jobQuery.data}
onCancel={workspace.cancel}
onUpload={(file) => void workspace.startUpload(file)}
workflow={workspace.workflow}
/>
<DocumentLibrary
documents={workspace.documentsQuery.data?.items}
error={workspace.documentsQuery.error}
isLoading={workspace.documentsQuery.isLoading}
isRefreshing={workspace.documentsQuery.isFetching}
onRefresh={workspace.refresh}
onSelect={workspace.selectDocument}
selectedId={workspace.selectedDocumentId}
/>
</div>
<DocumentReviewPanel
bundle={workspace.reviewQuery.data}
decisionError={workspace.decisionMutation.error}
error={workspace.reviewQuery.error}
isDecisionPending={workspace.decisionMutation.isPending}
isLoading={workspace.reviewQuery.isLoading && workspace.selectedDocumentId !== null}
key={`${workspace.reviewQuery.data?.version?.id ?? "none"}:${workspace.reviewQuery.data?.version?.review_revision ?? 0}`}
onApprove={workspace.approve}
onReject={workspace.reject}
onRetry={() => void workspace.reviewQuery.refetch()}
/>
</div>
);
}

View File

@@ -0,0 +1,248 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createAppQueryClient } from "../app/queryClient";
import {
DEFAULT_RETRIEVAL_QUERY,
SYNTHETIC_KNOWLEDGE_BASE_ID,
type RetrievalHit,
type RetrievalSearchResponse,
} from "../features/retrieval/types";
import { RetrievalPage } from "./RetrievalPage";
const successfulHit: RetrievalHit = {
rank: 1,
vector_rank: 3,
citation_id: "60000000-0000-0000-0000-000000000001",
document_id: "70000000-0000-0000-0000-000000000001",
source_name: "青岚示范区斑岩铜矿报告.pdf",
snippet: "<img src=x onerror=alert(1)> 已批准的合成蚀变分带证据。",
section_path: ["区域地质", "蚀变分带"],
page_start: 3,
page_end: 4,
page_label: "第 3-4 页",
vector_score: 0.812345,
rerank_score: 0.934567,
};
const successfulResponse: RetrievalSearchResponse = {
status: "ok",
trace_id: "50000000-0000-0000-0000-000000000001",
knowledge_base_id: SYNTHETIC_KNOWLEDGE_BASE_ID,
access_scope_count: 1,
profile: {
profile_hash: "a".repeat(64),
model: "fake-feature-hash-v1",
dimension: 1024,
synthetic: true,
},
parameters: { vector_top_k: 50, rerank_top_n: 10 },
rerank_status: "applied",
degradation_reason: null,
embedding_request_id: null,
rerank_request_id: "rerank-safe-request",
embedding_model: "fake-feature-hash-v1",
rerank_model: "fake-lexical-rerank-v1",
timings: {
embedding_ms: 2.25,
database_ms: 5.5,
rerank_ms: 3.75,
total_ms: 11.5,
},
results: [successfulHit],
};
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
function renderRetrieval() {
return render(
<QueryClientProvider client={createAppQueryClient()}>
<RetrievalPage />
</QueryClientProvider>,
);
}
function requestBody(init: RequestInit | undefined): unknown {
const body = init?.body;
if (typeof body !== "string") throw new Error("retrieval request body must be JSON text");
return JSON.parse(body);
}
describe("RetrievalPage", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
it("starts with the synthetic knowledge base, example question, and bounded defaults", () => {
renderRetrieval();
expect(screen.getByLabelText("知识库 ID")).toHaveValue(SYNTHETIC_KNOWLEDGE_BASE_ID);
expect(screen.getByLabelText("地质问题")).toHaveValue(DEFAULT_RETRIEVAL_QUERY);
expect(screen.getByLabelText(/向量候选/)).toHaveValue(50);
expect(screen.getByLabelText(/重排返回/)).toHaveValue(10);
expect(screen.getByText(/页面不能提交 scope/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "运行正式检索" })).toBeEnabled();
});
it("shows effective parameters, profile, ranks, timings, trace, and plain-text evidence", async () => {
vi.mocked(fetch).mockResolvedValue(jsonResponse(successfulResponse));
const user = userEvent.setup();
renderRetrieval();
const vectorInput = screen.getByLabelText(/向量候选/);
const rerankInput = screen.getByLabelText(/重排返回/);
await user.clear(vectorInput);
await user.type(vectorInput, "999");
await user.clear(rerankInput);
await user.type(rerankInput, "999");
await user.click(screen.getByRole("button", { name: "运行正式检索" }));
expect(
await screen.findByRole("heading", { name: "青岚示范区斑岩铜矿报告.pdf" }),
).toBeInTheDocument();
expect(screen.getByText("请求 999 → 生效 50")).toBeInTheDocument();
expect(screen.getByText("请求 999 → 生效 10")).toBeInTheDocument();
expect(screen.getByText("合成 / 本地")).toBeInTheDocument();
expect(screen.getByText("fake-feature-hash-v1 · 1024D")).toBeInTheDocument();
expect(screen.getByText("Vector #3")).toBeInTheDocument();
expect(screen.getByText("0.8123")).toBeInTheDocument();
expect(screen.getByText("0.9346")).toBeInTheDocument();
expect(screen.getByText("区域地质 / 蚀变分带")).toBeInTheDocument();
expect(screen.getByText("第 3-4 页")).toBeInTheDocument();
expect(screen.getByText("60000000-0000-0000-0000-000000000001")).toBeInTheDocument();
expect(screen.getByText("50000000-0000-0000-0000-000000000001")).toBeInTheDocument();
expect(screen.getByText("11.5 ms")).toBeInTheDocument();
expect(
screen.getByText("<img src=x onerror=alert(1)> 已批准的合成蚀变分带证据。"),
).toBeInTheDocument();
expect(screen.queryByRole("img")).not.toBeInTheDocument();
expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1);
const call = vi.mocked(fetch).mock.calls[0];
if (call === undefined) throw new Error("missing retrieval request");
expect(requestBody(call[1])).toEqual({
knowledge_base_id: SYNTHETIC_KNOWLEDGE_BASE_ID,
query: DEFAULT_RETRIEVAL_QUERY,
vector_top_k: 999,
rerank_top_n: 999,
});
});
it("announces loading while the three-stage request is pending", async () => {
let resolveResponse: ((response: Response) => void) | undefined;
vi.mocked(fetch).mockImplementation(
() =>
new Promise<Response>((resolve) => {
resolveResponse = resolve;
}),
);
const user = userEvent.setup();
renderRetrieval();
await user.click(screen.getByRole("button", { name: "运行正式检索" }));
expect(screen.getByRole("button", { name: "正在检索" })).toBeDisabled();
expect(screen.getByText("Query Embedding")).toBeInTheDocument();
expect(screen.getByText("Filtered pgvector")).toBeInTheDocument();
expect(screen.getByText("Rerank")).toBeInTheDocument();
resolveResponse?.(jsonResponse(successfulResponse));
expect(
await screen.findByRole("heading", { name: "青岚示范区斑岩铜矿报告.pdf" }),
).toBeInTheDocument();
});
it("renders an explicit empty state with the executed run metadata", async () => {
vi.mocked(fetch).mockResolvedValue(
jsonResponse({
...successfulResponse,
status: "empty",
rerank_status: "skipped_empty",
rerank_request_id: null,
rerank_model: null,
results: [],
} satisfies RetrievalSearchResponse),
);
const user = userEvent.setup();
renderRetrieval();
await user.click(screen.getByRole("button", { name: "运行正式检索" }));
expect(
await screen.findByRole("heading", { name: "当前授权范围内没有命中证据" }),
).toBeInTheDocument();
expect(screen.getByText("合成 / 本地")).toBeInTheDocument();
expect(screen.getByText("50000000-0000-0000-0000-000000000001")).toBeInTheDocument();
});
it("makes rerank degradation visible without hiding vector evidence", async () => {
vi.mocked(fetch).mockResolvedValue(
jsonResponse({
...successfulResponse,
rerank_status: "degraded",
degradation_reason: "rerank_unavailable",
rerank_request_id: null,
rerank_model: null,
timings: { ...successfulResponse.timings, rerank_ms: 0 },
results: [{ ...successfulHit, vector_rank: 1, rerank_score: null }],
} satisfies RetrievalSearchResponse),
);
const user = userEvent.setup();
renderRetrieval();
await user.click(screen.getByRole("button", { name: "运行正式检索" }));
expect(await screen.findByText("重排服务不可用,已保留向量召回结果")).toBeInTheDocument();
expect(screen.getByText(/不能视为已完成重排/)).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "青岚示范区斑岩铜矿报告.pdf" })).toBeInTheDocument();
expect(screen.getByText("向量降级")).toBeInTheDocument();
});
it.each([
[403, "当前身份无权检索该知识库", /服务端授权/],
[503, "检索服务暂不可用", /数据库、模型网关或 Embedding 服务/],
] as const)(
"explains HTTP %s without leaking provider details",
async (status, heading, detail) => {
vi.mocked(fetch).mockResolvedValue(
jsonResponse(
{
detail: "password=do-not-leak sk-secret provider-private-body",
trace_id: "private-trace",
},
status,
),
);
const user = userEvent.setup();
renderRetrieval();
await user.click(screen.getByRole("button", { name: "运行正式检索" }));
expect(await screen.findByRole("heading", { name: heading })).toBeInTheDocument();
expect(screen.getByText(detail)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "原样重试" })).toBeEnabled();
expect(screen.queryByText(/do-not-leak/)).not.toBeInTheDocument();
expect(screen.queryByText(/sk-secret/)).not.toBeInTheDocument();
},
);
it("keeps invalid whitespace input on the client and sends no request", async () => {
const user = userEvent.setup();
renderRetrieval();
const query = screen.getByLabelText("地质问题");
await user.clear(query);
await user.type(query, " ");
await user.click(screen.getByRole("button", { name: "运行正式检索" }));
expect(screen.getByText("请输入要检索的地质问题")).toBeInTheDocument();
await waitFor(() => expect(vi.mocked(fetch)).not.toHaveBeenCalled());
});
});

View File

@@ -0,0 +1,60 @@
import { RetrievalForm } from "../features/retrieval/components/RetrievalForm";
import { RetrievalResults } from "../features/retrieval/components/RetrievalResults";
import { useRetrievalSearch } from "../features/retrieval/hooks/useRetrievalSearch";
import type { RetrievalSearchRequest } from "../features/retrieval/types";
export function RetrievalPage() {
const searchMutation = useRetrievalSearch();
function handleSearch(request: RetrievalSearchRequest) {
searchMutation.mutate(request);
}
return (
<div className="page-stack retrieval-page">
<header className="hero retrieval-hero">
<div className="hero__copy">
<div className="mode-badge">
<span className="mode-badge__dot" />
API · Active Profile · SQL
</div>
<span className="eyebrow">PROFILE-AWARE RETRIEVAL LAB</span>
<h1></h1>
<p className="hero__lead">
qwen3-rerank
Profile
</p>
</div>
<div className="hero__seal" aria-hidden="true">
<div className="hero__seal-ring">
<span>ANN</span>
<small>RERANK</small>
</div>
<div className="hero__coordinates">ACL · 1024D · TRACE</div>
</div>
</header>
<div className="safety-notice" role="note">
<strong></strong>
<span>
访 CLOUD_APPROVEDREADYActive Version
</span>
</div>
<div className="retrieval-workspace">
<RetrievalForm isSearching={searchMutation.isPending} onSearch={handleSearch} />
<RetrievalResults
data={searchMutation.data}
error={searchMutation.error}
isIdle={searchMutation.isIdle}
isPending={searchMutation.isPending}
onRetry={() => {
if (searchMutation.variables) searchMutation.mutate(searchMutation.variables);
}}
request={searchMutation.variables}
/>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff