Make the offline RAG chain observable without widening credential access
Some checks failed
verify / verify (push) Has been cancelled

Expose the verified synthetic retrieval path through a typed React client and a non-root Nginx edge while keeping database credentials and data-network reachability out of the browser tier. A fixed-origin gateway preserves request boundaries and now closes upstream streams even when downstream disconnects before body iteration. The deployment ADR and runbooks record the four-network topology and its accepted Web edge-egress risk.

Constraint: The previously exposed Bailian key must be revoked and no live provider credential may enter Git, images, logs, or the browser.
Rejected: Connect Web directly to the data network | expands lateral reach to PostgreSQL.
Rejected: Publish the database-aware API on the edge network | gives a credential-bearing process a public default route.
Rejected: Buffer streaming responses in either proxy | prevents incremental chat delivery in the future.
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Do not mark Stage 1 or Stage 2 complete until the rotated-key live smoke and remaining stage gates pass.
Tested: make verify; 65 backend tests; 14 frontend tests; Docker image build; container health and isolation probes; real HTTP demo/status/search/docs/OpenAPI checks.
Not-tested: Live Bailian models, real document ingestion, business chat SSE generation, and final browser screenshot automation because the browser skill runtime was unavailable.
This commit is contained in:
2026-07-12 17:38:57 +08:00
parent 2fa27ae71c
commit c3bad0f186
60 changed files with 9062 additions and 82 deletions

View File

@@ -1 +0,0 @@

View File

@@ -0,0 +1,65 @@
const DEFAULT_HEADERS = {
Accept: "application/json",
} as const;
export type ApiErrorKind = "validation" | "unavailable" | "network" | "http";
export class ApiError extends Error {
readonly kind: ApiErrorKind;
readonly status: number | null;
constructor(kind: ApiErrorKind, message: string, status: number | null = null) {
super(message);
this.name = "ApiError";
this.kind = kind;
this.status = status;
}
}
function safeErrorMessage(response: Response): string {
if (response.status === 422) {
return "查询条件超出允许范围";
}
if (response.status === 503) {
return "本地数据库暂不可用";
}
return `请求未成功HTTP ${response.status}`;
}
export async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
let response: Response;
try {
response = await fetch(path, {
...init,
headers: {
...DEFAULT_HEADERS,
...init?.headers,
},
});
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
throw error;
}
throw new ApiError("network", "无法连接本地 API 服务");
}
if (!response.ok) {
const kind: ApiErrorKind =
response.status === 422 ? "validation" : response.status === 503 ? "unavailable" : "http";
throw new ApiError(kind, safeErrorMessage(response), response.status);
}
try {
return (await response.json()) as T;
} catch {
throw new ApiError("http", "本地 API 返回了无法解析的数据", response.status);
}
}
export function getApiErrorMessage(error: unknown): string {
if (error instanceof ApiError) {
return error.message;
}
return "发生了未预期的本地错误,请稍后重试";
}

View File

@@ -0,0 +1,406 @@
/**
* This file was auto-generated by openapi-typescript.
* Do not make direct changes to the file.
*/
export interface paths {
readonly "/api/v1/demo/search": {
readonly parameters: {
readonly query?: never;
readonly header?: never;
readonly path?: never;
readonly cookie?: never;
};
readonly get?: never;
readonly put?: never;
/** Demo Search */
readonly post: operations["demo_search_api_v1_demo_search_post"];
readonly delete?: never;
readonly options?: never;
readonly head?: never;
readonly patch?: never;
readonly trace?: never;
};
readonly "/api/v1/demo/status": {
readonly parameters: {
readonly query?: never;
readonly header?: never;
readonly path?: never;
readonly cookie?: never;
};
/** Demo Status */
readonly get: operations["demo_status_api_v1_demo_status_get"];
readonly put?: never;
readonly post?: never;
readonly delete?: never;
readonly options?: never;
readonly head?: never;
readonly patch?: never;
readonly trace?: never;
};
readonly "/api/v1/health/live": {
readonly parameters: {
readonly query?: never;
readonly header?: never;
readonly path?: never;
readonly cookie?: never;
};
/** Live */
readonly get: operations["live_api_v1_health_live_get"];
readonly put?: never;
readonly post?: never;
readonly delete?: never;
readonly options?: never;
readonly head?: never;
readonly patch?: never;
readonly trace?: never;
};
readonly "/api/v1/health/ready": {
readonly parameters: {
readonly query?: never;
readonly header?: never;
readonly path?: never;
readonly cookie?: never;
};
/** Ready */
readonly get: operations["ready_api_v1_health_ready_get"];
readonly put?: never;
readonly post?: never;
readonly delete?: never;
readonly options?: never;
readonly head?: never;
readonly patch?: never;
readonly trace?: never;
};
readonly "/api/v1/meta": {
readonly parameters: {
readonly query?: never;
readonly header?: never;
readonly path?: never;
readonly cookie?: never;
};
/** Meta */
readonly get: operations["meta_api_v1_meta_get"];
readonly put?: never;
readonly post?: never;
readonly delete?: never;
readonly options?: never;
readonly head?: never;
readonly patch?: never;
readonly trace?: never;
};
readonly "/health/live": {
readonly parameters: {
readonly query?: never;
readonly header?: never;
readonly path?: never;
readonly cookie?: never;
};
/** Live */
readonly get: operations["live_health_live_get"];
readonly put?: never;
readonly post?: never;
readonly delete?: never;
readonly options?: never;
readonly head?: never;
readonly patch?: never;
readonly trace?: never;
};
readonly "/health/ready": {
readonly parameters: {
readonly query?: never;
readonly header?: never;
readonly path?: never;
readonly cookie?: never;
};
/** Ready */
readonly get: operations["ready_health_ready_get"];
readonly put?: never;
readonly post?: never;
readonly delete?: never;
readonly options?: never;
readonly head?: never;
readonly patch?: never;
readonly trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
/**
* DemoCounts
* @description Public aggregate counts for the synthetic dataset.
*/
readonly DemoCounts: {
/** Chunks */
readonly chunks: number;
/** Searchable */
readonly searchable: number;
/** Vectors */
readonly vectors: number;
};
/**
* DemoSearchItem
* @description Public synthetic result; internal identifiers and hashes are excluded.
*/
readonly DemoSearchItem: {
/** Citation Id */
readonly citation_id: string;
/** Page Label */
readonly page_label: string;
/** Score */
readonly score: number;
/** Snippet */
readonly snippet: string;
/** Title */
readonly title: string;
};
/**
* DemoSearchRequest
* @description Bounded request accepted by the offline demo search.
*/
readonly DemoSearchRequest: {
/** Query */
readonly query: string;
/**
* Top K
* @default 5
*/
readonly top_k: number;
};
/**
* DemoSearchResponse
* @description Offline retrieval response with an explicit empty-dataset state.
*/
readonly DemoSearchResponse: {
/**
* Dataset
* @default synthetic-demo
* @constant
*/
readonly dataset: "synthetic-demo";
/** Results */
readonly results: readonly components["schemas"]["DemoSearchItem"][];
/**
* Status
* @enum {string}
*/
readonly status: "ok" | "empty_dataset";
};
/**
* DemoStatusResponse
* @description Safe readiness summary without database or approval internals.
*/
readonly DemoStatusResponse: {
readonly counts: components["schemas"]["DemoCounts"];
/**
* Dataset
* @default synthetic-demo
* @constant
*/
readonly dataset: "synthetic-demo";
/**
* Status
* @enum {string}
*/
readonly status: "ready" | "empty_dataset" | "incomplete_dataset";
};
readonly HealthPayload: {
readonly [key: string]: string | {
readonly [key: string]: string;
};
};
/** HTTPValidationError */
readonly HTTPValidationError: {
/** Detail */
readonly detail?: readonly components["schemas"]["ValidationError"][];
};
/** ValidationError */
readonly ValidationError: {
/** Context */
readonly ctx?: Record<string, never>;
/** Input */
readonly input?: unknown;
/** Location */
readonly loc: readonly (string | number)[];
/** Message */
readonly msg: string;
/** Error Type */
readonly type: string;
};
};
responses: never;
parameters: never;
requestBodies: never;
headers: never;
pathItems: never;
}
export type $defs = Record<string, never>;
export interface operations {
readonly demo_search_api_v1_demo_search_post: {
readonly parameters: {
readonly query?: never;
readonly header?: never;
readonly path?: never;
readonly cookie?: never;
};
readonly requestBody: {
readonly content: {
readonly "application/json": components["schemas"]["DemoSearchRequest"];
};
};
readonly responses: {
/** @description Successful Response */
readonly 200: {
headers: {
readonly [name: string]: unknown;
};
content: {
readonly "application/json": components["schemas"]["DemoSearchResponse"];
};
};
/** @description Validation Error */
readonly 422: {
headers: {
readonly [name: string]: unknown;
};
content: {
readonly "application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
readonly demo_status_api_v1_demo_status_get: {
readonly parameters: {
readonly query?: never;
readonly header?: never;
readonly path?: never;
readonly cookie?: never;
};
readonly requestBody?: never;
readonly responses: {
/** @description Successful Response */
readonly 200: {
headers: {
readonly [name: string]: unknown;
};
content: {
readonly "application/json": components["schemas"]["DemoStatusResponse"];
};
};
};
};
readonly live_api_v1_health_live_get: {
readonly parameters: {
readonly query?: never;
readonly header?: never;
readonly path?: never;
readonly cookie?: never;
};
readonly requestBody?: never;
readonly responses: {
/** @description Successful Response */
readonly 200: {
headers: {
readonly [name: string]: unknown;
};
content: {
readonly "application/json": {
readonly [key: string]: string;
};
};
};
};
};
readonly ready_api_v1_health_ready_get: {
readonly parameters: {
readonly query?: never;
readonly header?: never;
readonly path?: never;
readonly cookie?: never;
};
readonly requestBody?: never;
readonly responses: {
/** @description Successful Response */
readonly 200: {
headers: {
readonly [name: string]: unknown;
};
content: {
readonly "application/json": components["schemas"]["HealthPayload"];
};
};
};
};
readonly meta_api_v1_meta_get: {
readonly parameters: {
readonly query?: never;
readonly header?: never;
readonly path?: never;
readonly cookie?: never;
};
readonly requestBody?: never;
readonly responses: {
/** @description Successful Response */
readonly 200: {
headers: {
readonly [name: string]: unknown;
};
content: {
readonly "application/json": {
readonly [key: string]: unknown;
};
};
};
};
};
readonly live_health_live_get: {
readonly parameters: {
readonly query?: never;
readonly header?: never;
readonly path?: never;
readonly cookie?: never;
};
readonly requestBody?: never;
readonly responses: {
/** @description Successful Response */
readonly 200: {
headers: {
readonly [name: string]: unknown;
};
content: {
readonly "application/json": {
readonly [key: string]: string;
};
};
};
};
};
readonly ready_health_ready_get: {
readonly parameters: {
readonly query?: never;
readonly header?: never;
readonly path?: never;
readonly cookie?: never;
};
readonly requestBody?: never;
readonly responses: {
/** @description Successful Response */
readonly 200: {
headers: {
readonly [name: string]: unknown;
};
content: {
readonly "application/json": components["schemas"]["HealthPayload"];
};
};
/** @description Database unavailable */
readonly 503: {
headers: {
readonly [name: string]: unknown;
};
content?: never;
};
};
};
}

16
frontend/src/app/App.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import { RouterProvider } from "react-router-dom";
import { createAppQueryClient } from "./queryClient";
import { router } from "./router";
export function App() {
const [queryClient] = useState(createAppQueryClient);
return (
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
);
}

View File

@@ -0,0 +1,15 @@
import { QueryClient } from "@tanstack/react-query";
export function createAppQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: false,
},
mutations: {
retry: false,
},
},
});
}

View File

@@ -0,0 +1,18 @@
import { createBrowserRouter } from "react-router-dom";
import { AppShell } from "../components/AppShell";
import { NotFoundPage } from "../pages/NotFoundPage";
import { SystemPage } from "../pages/SystemPage";
import { WorkbenchPage } from "../pages/WorkbenchPage";
export const router = createBrowserRouter([
{
path: "/",
element: <AppShell />,
children: [
{ index: true, element: <WorkbenchPage /> },
{ path: "system", element: <SystemPage /> },
{ path: "*", element: <NotFoundPage /> },
],
},
]);

View File

@@ -1 +0,0 @@

View File

@@ -0,0 +1,86 @@
import { NavLink, Outlet } from "react-router-dom";
import { Icon } from "./Icon";
const navItems = [
{ to: "/", label: "检索工作台", icon: "search" as const, end: true },
{ to: "/system", label: "系统说明", icon: "settings" as const, end: false },
] as const;
export function AppShell() {
return (
<div className="app-shell">
<a className="skip-link" href="#main-content">
</a>
<aside className="sidebar" aria-label="主导航">
<div className="brand">
<div className="brand__mark" aria-hidden="true">
<span className="brand__ridge brand__ridge--back" />
<span className="brand__ridge brand__ridge--front" />
</div>
<div>
<strong></strong>
<span>Geological RAG</span>
</div>
</div>
<nav className="nav-list">
{navItems.map((item) => (
<NavLink
className={({ isActive }) => `nav-link${isActive ? " nav-link--active" : ""}`}
end={item.end}
key={item.to}
to={item.to}
>
<Icon name={item.icon} size={19} />
<span>{item.label}</span>
</NavLink>
))}
</nav>
<div className="sidebar__note">
<Icon name="shield" size={18} />
<div>
<strong></strong>
<span></span>
</div>
</div>
</aside>
<div className="app-main">
<header className="mobile-header">
<div className="brand brand--mobile">
<div className="brand__mark" aria-hidden="true">
<span className="brand__ridge brand__ridge--back" />
<span className="brand__ridge brand__ridge--front" />
</div>
<strong></strong>
</div>
<nav className="mobile-nav" aria-label="移动端主导航">
{navItems.map((item) => (
<NavLink
aria-label={item.label}
className={({ isActive }) =>
`mobile-nav__link${isActive ? " mobile-nav__link--active" : ""}`
}
end={item.end}
key={item.to}
to={item.to}
>
<Icon name={item.icon} size={20} />
</NavLink>
))}
</nav>
</header>
<main className="page" id="main-content" tabIndex={-1}>
<Outlet />
</main>
<footer className="footer">
<span> · </span>
<span>使</span>
</footer>
</div>
</div>
);
}

View File

@@ -0,0 +1,118 @@
import type { ReactNode } from "react";
export type IconName =
| "alert"
| "arrow"
| "check"
| "compass"
| "copy"
| "database"
| "layers"
| "search"
| "settings"
| "shield"
| "vector";
interface IconProps {
name: IconName;
size?: number;
}
function iconPath(name: IconName): ReactNode {
switch (name) {
case "alert":
return (
<>
<path d="M12 9v4" />
<path d="M12 17h.01" />
<path d="M10.3 3.6 2.4 18a2 2 0 0 0 1.8 3h15.6a2 2 0 0 0 1.8-3L13.7 3.6a2 2 0 0 0-3.4 0Z" />
</>
);
case "arrow":
return (
<>
<path d="M5 12h14" />
<path d="m13 6 6 6-6 6" />
</>
);
case "check":
return <path d="m5 12 4 4L19 6" />;
case "compass":
return (
<>
<circle cx="12" cy="12" r="9" />
<path d="m15.5 8.5-2 5-5 2 2-5 5-2Z" />
</>
);
case "copy":
return (
<>
<rect width="12" height="12" x="9" y="9" rx="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</>
);
case "database":
return (
<>
<ellipse cx="12" cy="5" rx="8" ry="3" />
<path d="M4 5v6c0 1.7 3.6 3 8 3s8-1.3 8-3V5" />
<path d="M4 11v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6" />
</>
);
case "layers":
return (
<>
<path d="m12 2 9 5-9 5-9-5 9-5Z" />
<path d="m3 12 9 5 9-5" />
<path d="m3 17 9 5 9-5" />
</>
);
case "search":
return (
<>
<circle cx="11" cy="11" r="7" />
<path d="m20 20-4-4" />
</>
);
case "settings":
return (
<>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H2.8v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1a1.7 1.7 0 0 0 1.9.3A1.7 1.7 0 0 0 10 3V2.8h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z" />
</>
);
case "shield":
return (
<>
<path d="M20 13c0 5-3.5 7.5-8 9-4.5-1.5-8-4-8-9V5l8-3 8 3v8Z" />
<path d="m9 12 2 2 4-4" />
</>
);
case "vector":
return (
<>
<circle cx="5" cy="6" r="2" />
<circle cx="19" cy="6" r="2" />
<circle cx="12" cy="18" r="2" />
<path d="m7 7 4 9M17 7l-4 9M7 6h10" />
</>
);
}
}
export function Icon({ name, size = 20 }: IconProps) {
return (
<svg
aria-hidden="true"
className="icon"
fill="none"
height={size}
viewBox="0 0 24 24"
width={size}
>
<g stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8">
{iconPath(name)}
</g>
</svg>
);
}

View File

@@ -1 +0,0 @@

View File

@@ -0,0 +1,21 @@
import { requestJson } from "../../api/client";
import type { DemoSearchRequest, DemoSearchResponse, DemoStatusResponse } from "./types";
const DEMO_API_BASE = "/api/v1/demo";
export function getDemoStatus({
signal,
}: { signal?: AbortSignal } = {}): Promise<DemoStatusResponse> {
return requestJson<DemoStatusResponse>(
`${DEMO_API_BASE}/status`,
signal === undefined ? {} : { signal },
);
}
export function searchDemo(request: DemoSearchRequest): Promise<DemoSearchResponse> {
return requestJson<DemoSearchResponse>(`${DEMO_API_BASE}/search`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
});
}

View File

@@ -0,0 +1,137 @@
import { ApiError } from "../../../api/client";
import { Icon, type IconName } from "../../../components/Icon";
import type { DemoStatusResponse } from "../types";
interface DemoStatusPanelProps {
data: DemoStatusResponse | undefined;
error: unknown;
isFetching: boolean;
isPending: boolean;
onRetry: () => void;
}
interface StateCopy {
icon: IconName;
label: string;
description: string;
tone: "ready" | "warning" | "danger" | "neutral";
}
function statusCopy(
data: DemoStatusResponse | undefined,
error: unknown,
isPending: boolean,
): StateCopy {
if (isPending) {
return {
icon: "database",
label: "正在检查离线索引",
description: "正在读取本地 synthetic 数据集的聚合状态。",
tone: "neutral",
};
}
if (error instanceof ApiError && error.kind === "unavailable") {
return {
icon: "alert",
label: "数据库暂不可用",
description: "本地 API 已响应,但数据库尚未就绪。检索已暂停,请稍后重试。",
tone: "danger",
};
}
if (error) {
return {
icon: "alert",
label: "无法连接本地服务",
description: "请确认本地 Docker 服务已启动。页面不会改用云端模型。",
tone: "danger",
};
}
if (data?.status === "ready") {
return {
icon: "check",
label: "离线索引已就绪",
description: `${data.counts.searchable} 条 synthetic 切片已完成向量写入并允许检索。`,
tone: "ready",
};
}
if (data?.status === "empty_dataset") {
return {
icon: "database",
label: "合成数据尚未写入",
description: "数据库连接正常,但当前没有可检索切片。完成离线初始化后再试。",
tone: "warning",
};
}
if (data?.status === "incomplete_dataset") {
return {
icon: "alert",
label: "离线索引不完整",
description: "切片、向量或可检索记录数量不一致。为避免误导,检索已暂停。",
tone: "warning",
};
}
return {
icon: "database",
label: "等待离线状态",
description: "正在等待本地服务返回数据集状态。",
tone: "neutral",
};
}
const metrics = [
{ key: "chunks", label: "知识切片", icon: "layers" as const },
{ key: "vectors", label: "向量记录", icon: "vector" as const },
{ key: "searchable", label: "可检索", icon: "search" as const },
] as const;
export function DemoStatusPanel({
data,
error,
isFetching,
isPending,
onRetry,
}: DemoStatusPanelProps) {
const state = statusCopy(data, error, isPending);
const canRetry =
!isPending && (Boolean(error) || (data !== undefined && data.status !== "ready"));
return (
<section className="status-overview" aria-labelledby="index-status-heading">
<div
aria-live="polite"
className={`status-callout status-callout--${state.tone}`}
role={state.tone === "danger" ? "alert" : "status"}
>
<span className="status-callout__icon">
<Icon name={state.icon} size={20} />
</span>
<div>
<div className="status-callout__title-row">
<h2 id="index-status-heading">{state.label}</h2>
{isFetching && !isPending ? <span className="status-sync"></span> : null}
</div>
<p>{state.description}</p>
{canRetry ? (
<button className="status-retry" disabled={isFetching} onClick={onRetry} type="button">
{isFetching ? "正在重新检查" : "重新检查状态"}
</button>
) : null}
</div>
</div>
<div className="metric-grid" aria-label="离线数据计数">
{metrics.map((metric) => (
<article className="metric-card" key={metric.key}>
<div className="metric-card__icon">
<Icon name={metric.icon} size={19} />
</div>
<div>
<strong>{data?.counts[metric.key] ?? "—"}</strong>
<span>{metric.label}</span>
</div>
</article>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,153 @@
import { useId, useMemo, useRef, useState } from "react";
import { Icon } from "../../../components/Icon";
import { EXAMPLE_QUESTIONS } from "../exampleQuestions";
import {
QUERY_MAX_LENGTH,
TOP_K_MAX,
TOP_K_MIN,
type SearchInput,
validateSearchInput,
} from "../types";
interface SearchFormProps {
disabled: boolean;
isSearching: boolean;
onSearch: (input: SearchInput) => void;
}
export function SearchForm({ disabled, isSearching, onSearch }: SearchFormProps) {
const [query, setQuery] = useState("");
const [topK, setTopK] = useState(5);
const [submitted, setSubmitted] = useState(false);
const formRef = useRef<HTMLFormElement>(null);
const hintId = useId();
const errorId = useId();
const validation = useMemo(() => validateSearchInput({ query, topK }), [query, topK]);
const showValidation = submitted && !validation.valid && !disabled;
function submit() {
setSubmitted(true);
if (disabled || isSearching || !validation.valid) {
return;
}
onSearch({ query: validation.normalizedQuery, topK });
}
return (
<section className="search-panel" aria-labelledby="search-heading">
<div className="section-heading">
<div>
<span className="eyebrow">RETRIEVAL LAB</span>
<h2 id="search-heading"></h2>
</div>
<span className="section-heading__meta"> + </span>
</div>
<form
aria-busy={isSearching}
className="search-form"
onSubmit={(event) => {
event.preventDefault();
submit();
}}
ref={formRef}
>
<label className="field-label" htmlFor="geology-query">
</label>
<div className={`query-box${showValidation ? " query-box--error" : ""}`}>
<textarea
aria-describedby={`${hintId}${showValidation ? ` ${errorId}` : ""}`}
aria-invalid={showValidation}
disabled={disabled || isSearching}
id="geology-query"
maxLength={QUERY_MAX_LENGTH}
onChange={(event) => {
setQuery(event.target.value);
if (submitted) setSubmitted(false);
}}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault();
formRef.current?.requestSubmit();
}
}}
placeholder="例如:花岗斑岩铜矿化有哪些典型蚀变特征?"
rows={4}
value={query}
/>
<span
className={`character-count${query.length >= 450 ? " character-count--warning" : ""}`}
>
{query.length}/{QUERY_MAX_LENGTH}
</span>
</div>
<p className="field-hint" id={hintId}>
Enter Shift + Enter
</p>
{showValidation ? (
<p className="field-error" id={errorId} role="alert">
{validation.message}
</p>
) : null}
<div className="search-controls">
<label className="top-k-control" htmlFor="top-k">
<span>
<small>Top K</small>
</span>
<select
disabled={disabled || isSearching}
id="top-k"
onChange={(event) => setTopK(Number(event.target.value))}
value={topK}
>
{Array.from(
{ length: TOP_K_MAX - TOP_K_MIN + 1 },
(_, index) => index + TOP_K_MIN,
).map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
</label>
<button className="primary-button" disabled={disabled || isSearching} type="submit">
{isSearching ? <span className="button-spinner" /> : <Icon name="search" size={19} />}
{isSearching ? "正在检索" : "开始检索"}
{!isSearching ? <Icon name="arrow" size={17} /> : null}
</button>
</div>
{disabled ? (
<p className="disabled-note" role="status">
<Icon name="alert" size={17} />
线 ready
</p>
) : null}
</form>
<div className="examples" aria-labelledby="examples-heading">
<p id="examples-heading"> · </p>
<div className="example-list">
{EXAMPLE_QUESTIONS.map((question) => (
<button
className="example-chip"
disabled={disabled || isSearching}
key={question}
onClick={() => {
setQuery(question);
setSubmitted(false);
document.querySelector<HTMLTextAreaElement>("#geology-query")?.focus();
}}
type="button"
>
{question}
</button>
))}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,194 @@
import { useState } from "react";
import { ApiError, getApiErrorMessage } from "../../../api/client";
import { Icon } from "../../../components/Icon";
import type { DemoSearchItem, DemoSearchResponse } from "../types";
interface SearchResultsProps {
data: DemoSearchResponse | undefined;
error: unknown;
isIdle: boolean;
isPending: boolean;
onRetry: () => void;
query: string | undefined;
}
function errorGuidance(error: unknown): string {
if (error instanceof ApiError && error.kind === "validation") {
return "查询条件超出边界,请控制在 1500 个字符,并选择 110 条来源。";
}
if (error instanceof ApiError && error.kind === "unavailable") {
return "本地数据库暂不可用。请等待容器恢复健康后重新检索。";
}
if (error instanceof ApiError && error.kind === "network") {
return "无法连接本地 API。请确认 Docker 服务和同源 /api 代理均已启动。";
}
return getApiErrorMessage(error);
}
function SourceCard({ item, rank }: { item: DemoSearchItem; rank: number }) {
const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle");
async function copyCitation() {
try {
await navigator.clipboard.writeText(item.citation_id);
setCopyState("copied");
window.setTimeout(() => setCopyState("idle"), 1800);
} catch {
setCopyState("failed");
}
}
return (
<article className="source-card">
<div className="source-card__rank" aria-label={`结果 ${rank}`}>
{String(rank).padStart(2, "0")}
</div>
<div className="source-card__body">
<div className="source-card__header">
<div>
<span className="source-card__type"></span>
<h3>{item.title}</h3>
</div>
<div className="score" title="用于离线演示排序,不表示地质结论置信度">
<span></span>
<strong>{item.score.toFixed(4)}</strong>
</div>
</div>
<p className="source-card__snippet">{item.snippet}</p>
<div className="source-card__footer">
<div className="source-meta">
<span>{item.page_label}</span>
<span className="source-meta__divider" aria-hidden="true" />
<code>{item.citation_id}</code>
</div>
<button className="copy-button" onClick={copyCitation} type="button">
<Icon
name={copyState === "copied" ? "check" : copyState === "failed" ? "alert" : "copy"}
size={16}
/>
{copyState === "copied"
? "已复制"
: copyState === "failed"
? "复制失败"
: "复制引用编号"}
</button>
</div>
<p className="source-card__limitation"></p>
<span className="visually-hidden" aria-live="polite">
{copyState === "copied"
? `引用编号 ${item.citation_id} 已复制`
: copyState === "failed"
? "复制失败,请手动选择引用编号"
: ""}
</span>
</div>
</article>
);
}
export function SearchResults({
data,
error,
isIdle,
isPending,
onRetry,
query,
}: SearchResultsProps) {
return (
<section aria-busy={isPending} aria-labelledby="results-heading" className="results-panel">
<div className="section-heading section-heading--results">
<div>
<span className="eyebrow">SOURCE IDENTIFIERS</span>
<h2 id="results-heading"></h2>
</div>
{data?.status === "ok" ? (
<span className="results-count"> {data.results.length} </span>
) : null}
</div>
<span className="visually-hidden" aria-live="polite">
{isPending
? "正在检索合成地质资料"
: data?.status === "empty_dataset"
? "离线演示数据尚未准备好,当前没有可展示结果"
: data?.status === "ok"
? `检索完成,返回 ${data.results.length} 条合成资料`
: ""}
</span>
{isIdle ? (
<div className="result-state result-state--idle">
<div className="result-state__illustration" aria-hidden="true">
<span className="strata strata--one" />
<span className="strata strata--two" />
<span className="strata strata--three" />
<Icon name="compass" size={31} />
</div>
<h3></h3>
<p> 1024 线</p>
</div>
) : null}
{isPending ? (
<div className="result-skeleton">
{[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}
{error ? (
<div className="result-state result-state--error" role="alert">
<span className="result-state__icon">
<Icon name="alert" size={24} />
</span>
<h3></h3>
<p>{errorGuidance(error)}</p>
<button className="secondary-button" onClick={onRetry} type="button">
</button>
</div>
) : null}
{data?.status === "empty_dataset" ? (
<div className="result-state result-state--empty">
<span className="result-state__icon">
<Icon name="database" size={24} />
</span>
<h3>线</h3>
<p> synthetic seed </p>
</div>
) : null}
{data?.status === "ok" && data.results.length === 0 ? (
<div className="result-state result-state--empty">
<span className="result-state__icon">
<Icon name="search" size={24} />
</span>
<h3></h3>
<p></p>
</div>
) : null}
{data?.status === "ok" && data.results.length > 0 ? (
<div className="source-list">
<div className="query-summary">
<span></span>
<p>{query}</p>
</div>
{data.results.map((item, index) => (
<SourceCard item={item} key={item.citation_id} rank={index + 1} />
))}
<p className="score-disclaimer">
线
</p>
</div>
) : null}
</section>
);
}

View File

@@ -0,0 +1,6 @@
export const EXAMPLE_QUESTIONS = [
"花岗斑岩铜矿化有哪些典型蚀变特征?",
"北岭地区金异常与哪些构造条件有关?",
"矽卡岩型铁矿的找矿标志有哪些?",
"断裂交汇部位为何有利于热液成矿?",
] as const;

View File

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

View File

@@ -0,0 +1,15 @@
import { useQuery } from "@tanstack/react-query";
import { getDemoStatus } from "../api";
export const demoStatusQueryKey = ["demo", "status"] as const;
export function useDemoStatus() {
return useQuery({
queryKey: demoStatusQueryKey,
queryFn: ({ signal }) => getDemoStatus({ signal }),
refetchInterval: 30_000,
retry: 1,
staleTime: 15_000,
});
}

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { QUERY_MAX_LENGTH, validateSearchInput } from "./types";
describe("validateSearchInput", () => {
it("normalizes valid whitespace without changing the configured top-k", () => {
expect(validateSearchInput({ query: " 花岗斑岩\n 铜矿化 ", topK: 5 })).toEqual({
valid: true,
message: null,
normalizedQuery: "花岗斑岩 铜矿化",
});
});
it.each([
{ query: " ", topK: 5, expected: "请输入要检索的地质问题" },
{
query: "x".repeat(QUERY_MAX_LENGTH + 1),
topK: 5,
expected: `问题不能超过 ${QUERY_MAX_LENGTH} 个字符`,
},
{ query: "铜矿", topK: 0, expected: "返回条数必须在 110 之间" },
{ query: "铜矿", topK: 11, expected: "返回条数必须在 110 之间" },
{ query: "铜矿", topK: 1.5, expected: "返回条数必须在 110 之间" },
])("rejects the input boundary %#", ({ expected, query, topK }) => {
const result = validateSearchInput({ query, topK });
expect(result.valid).toBe(false);
expect(result.message).toBe(expected);
});
});

View File

@@ -0,0 +1,45 @@
import type { components } from "../../api/schema.generated";
export type DemoCounts = components["schemas"]["DemoCounts"];
export type DemoSearchItem = components["schemas"]["DemoSearchItem"];
export type DemoSearchRequest = components["schemas"]["DemoSearchRequest"];
export type DemoSearchResponse = components["schemas"]["DemoSearchResponse"];
export type DemoStatusResponse = components["schemas"]["DemoStatusResponse"];
export const QUERY_MAX_LENGTH = 500;
export const TOP_K_MIN = 1;
export const TOP_K_MAX = 10;
export interface SearchInput {
query: string;
topK: number;
}
export interface SearchValidation {
valid: boolean;
message: string | null;
normalizedQuery: string;
}
export function validateSearchInput({ query, topK }: SearchInput): SearchValidation {
const normalizedQuery = query.replace(/\s+/g, " ").trim();
if (normalizedQuery.length === 0) {
return { valid: false, message: "请输入要检索的地质问题", normalizedQuery };
}
if (query.length > QUERY_MAX_LENGTH) {
return {
valid: false,
message: `问题不能超过 ${QUERY_MAX_LENGTH} 个字符`,
normalizedQuery,
};
}
if (!Number.isInteger(topK) || topK < TOP_K_MIN || topK > TOP_K_MAX) {
return {
valid: false,
message: `返回条数必须在 ${TOP_K_MIN}${TOP_K_MAX} 之间`,
normalizedQuery,
};
}
return { valid: true, message: null, normalizedQuery };
}

17
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,17 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./app/App";
import "./styles/global.css";
const root = document.querySelector<HTMLDivElement>("#root");
if (!root) {
throw new Error("Application root element is missing");
}
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@@ -1 +0,0 @@

View File

@@ -0,0 +1,17 @@
import { Link } from "react-router-dom";
import { Icon } from "../components/Icon";
export function NotFoundPage() {
return (
<div className="not-found">
<span>404</span>
<h1></h1>
<p>线</p>
<Link className="primary-button" to="/">
<Icon name="arrow" size={17} />
</Link>
</div>
);
}

View File

@@ -0,0 +1,148 @@
import { Icon, type IconName } from "../components/Icon";
interface ModelCard {
name: string;
role: string;
state: "离线替身已运行" | "规划接入 · 未验证";
description: string;
icon: IconName;
}
const models: readonly ModelCard[] = [
{
name: "fake-feature-hash-v1",
role: "当前 Embedding",
state: "离线替身已运行",
description: "确定性的 1024 维特征哈希,仅用于 Docker 离线链路与回归测试。",
icon: "vector",
},
{
name: "text-embedding-v4",
role: "规划 Embedding",
state: "规划接入 · 未验证",
description: "计划通过阿里云百炼兼容接口生成正式 1024 维向量。",
icon: "vector",
},
{
name: "qwen3-rerank",
role: "规划 Rerank",
state: "规划接入 · 未验证",
description: "计划对向量召回候选进行二次语义排序。",
icon: "layers",
},
{
name: "deepseek-v4-flash",
role: "规划 Generation",
state: "规划接入 · 未验证",
description: "计划在来源约束下生成带 citation 的回答;当前页面不调用。",
icon: "compass",
},
] as const;
const pipeline = [
{ index: "01", title: "Synthetic 数据", detail: "20 条公开合成地质记录" },
{ index: "02", title: "离线向量化", detail: "fake-feature-hash-v1 · 1024D" },
{ index: "03", title: "pgvector 召回", detail: "限定知识库、访问域与审批状态" },
{ index: "04", title: "模拟重排", detail: "返回带稳定引用编号的纯文本来源" },
] as const;
export function SystemPage() {
return (
<div className="page-stack system-page">
<header className="system-hero">
<div>
<span className="eyebrow">SYSTEM TRANSPARENCY</span>
<h1></h1>
<p>
线
</p>
</div>
<div className="verification-stamp">
<Icon name="alert" size={21} />
<div>
<strong></strong>
<span> Key smoke test</span>
</div>
</div>
</header>
<section className="system-section" aria-labelledby="pipeline-heading">
<div className="section-heading">
<div>
<span className="eyebrow">VERIFIED PATH</span>
<h2 id="pipeline-heading">线</h2>
</div>
<span className="verified-label">
<Icon name="check" size={16} /> 线 ·
</span>
</div>
<ol className="pipeline-list">
{pipeline.map((step) => (
<li key={step.index}>
<span className="pipeline-list__index">{step.index}</span>
<div>
<strong>{step.title}</strong>
<p>{step.detail}</p>
</div>
</li>
))}
</ol>
</section>
<section className="system-section" aria-labelledby="models-heading">
<div className="section-heading">
<div>
<span className="eyebrow">MODEL REGISTRY</span>
<h2 id="models-heading"></h2>
</div>
</div>
<div className="model-grid">
{models.map((model) => {
const isVerified = model.state === "离线替身已运行";
return (
<article className="model-card" key={model.name}>
<div className="model-card__top">
<span className="model-card__icon">
<Icon name={model.icon} size={20} />
</span>
<span
className={`model-state${isVerified ? " model-state--verified" : " model-state--planned"}`}
>
{model.state}
</span>
</div>
<span className="model-card__role">{model.role}</span>
<h3>{model.name}</h3>
<p>{model.description}</p>
</article>
);
})}
</div>
</section>
<section className="boundary-grid" aria-label="安全与功能边界">
<article className="boundary-card">
<Icon name="shield" size={22} />
<div>
<h2></h2>
<p> `/api` Key</p>
</div>
</article>
<article className="boundary-card">
<Icon name="database" size={22} />
<div>
<h2></h2>
<p> synthetic </p>
</div>
</article>
<article className="boundary-card">
<Icon name="compass" size={22} />
<div>
<h2></h2>
<p> OCR </p>
</div>
</article>
</section>
</div>
);
}

View File

@@ -0,0 +1,197 @@
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { jsonResponse, renderWorkbench } from "../test/renderWorkbench";
const readyStatus = {
status: "ready",
dataset: "synthetic-demo",
counts: { chunks: 20, vectors: 20, searchable: 20 },
} as const;
const sourceResponse = {
status: "ok",
dataset: "synthetic-demo",
results: [
{
title: "合成资料|青岚示范区斑岩铜矿蚀变分带",
snippet: "合成记录显示花岗斑岩附近发育钾化、绢英岩化与青磐岩化分带。",
page_label: "第 3-4 页",
score: 0.923456,
citation_id: "demo-a1b2c3d4e5f60708",
},
],
} as const;
function requestPath(input: string | URL | Request): string {
if (input instanceof Request) return input.url;
return String(input);
}
describe("WorkbenchPage", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
it("shows the explicit offline mode, ready status, and aggregate counts", async () => {
vi.mocked(fetch).mockResolvedValue(jsonResponse(readyStatus));
renderWorkbench();
expect(screen.getByText("合成数据 · 离线模式 · 不调用云端模型")).toBeInTheDocument();
expect(screen.getByText(/不构成地质勘查结论、矿权判断或投资建议/)).toBeInTheDocument();
expect(await screen.findByRole("heading", { name: "离线索引已就绪" })).toBeInTheDocument();
expect(screen.getAllByText("20")).toHaveLength(3);
expect(screen.getByRole("button", { name: "开始检索" })).toBeEnabled();
});
it.each([
["empty_dataset", { chunks: 0, vectors: 0, searchable: 0 }, "合成数据尚未写入"],
["incomplete_dataset", { chunks: 20, vectors: 19, searchable: 18 }, "离线索引不完整"],
] as const)(
"keeps search disabled for %s and offers a status retry",
async (status, counts, label) => {
vi.mocked(fetch).mockResolvedValue(
jsonResponse({ status, dataset: "synthetic-demo", counts }),
);
renderWorkbench();
expect(await screen.findByRole("heading", { name: label })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "开始检索" })).toBeDisabled();
expect(screen.getByRole("button", { name: "重新检查状态" })).toBeEnabled();
},
);
it("fills an example without sending, then renders a traceable successful result", async () => {
vi.mocked(fetch).mockImplementation((input) => {
const path = requestPath(input);
if (path.endsWith("/status")) return Promise.resolve(jsonResponse(readyStatus));
if (path.endsWith("/search")) return Promise.resolve(jsonResponse(sourceResponse));
return Promise.resolve(jsonResponse({}, 404));
});
const user = userEvent.setup();
renderWorkbench();
await screen.findByRole("heading", { name: "离线索引已就绪" });
await user.click(screen.getByRole("button", { name: "花岗斑岩铜矿化有哪些典型蚀变特征?" }));
expect(screen.getByLabelText("地质问题")).toHaveValue("花岗斑岩铜矿化有哪些典型蚀变特征?");
expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1);
await user.selectOptions(screen.getByLabelText(/返回来源数/), "3");
await user.click(screen.getByRole("button", { name: "开始检索" }));
expect(
await screen.findByRole("heading", {
name: "合成资料|青岚示范区斑岩铜矿蚀变分带",
}),
).toBeInTheDocument();
expect(screen.getByText("第 3-4 页")).toBeInTheDocument();
expect(screen.getByText("demo-a1b2c3d4e5f60708")).toBeInTheDocument();
expect(screen.getByText("0.9235")).toBeInTheDocument();
expect(screen.getByText(/暂不能打开原始页/)).toBeInTheDocument();
expect(screen.getByText(/不是矿产存在概率或答案置信度/)).toBeInTheDocument();
const searchCall = vi
.mocked(fetch)
.mock.calls.find(([input]) => requestPath(input).endsWith("/search"));
expect(searchCall).toBeDefined();
const searchBody = searchCall?.[1]?.body;
expect(typeof searchBody).toBe("string");
if (typeof searchBody !== "string") throw new Error("search body must be JSON text");
expect(JSON.parse(searchBody)).toEqual({
query: "花岗斑岩铜矿化有哪些典型蚀变特征?",
top_k: 3,
});
});
it("explains when search finds the ready dataset has no seeded records", async () => {
vi.mocked(fetch).mockImplementation((input) => {
const path = requestPath(input);
if (path.endsWith("/status")) return Promise.resolve(jsonResponse(readyStatus));
if (path.endsWith("/search")) {
return Promise.resolve(
jsonResponse({ status: "empty_dataset", dataset: "synthetic-demo", results: [] }),
);
}
return Promise.resolve(jsonResponse({}, 404));
});
const user = userEvent.setup();
renderWorkbench();
await screen.findByRole("heading", { name: "离线索引已就绪" });
await user.type(screen.getByLabelText("地质问题"), "寻找斑岩铜矿蚀变线索");
await user.click(screen.getByRole("button", { name: "开始检索" }));
expect(
await screen.findByRole("heading", { name: "离线演示数据尚未准备好" }),
).toBeInTheDocument();
expect(screen.getByText(/完成 synthetic seed 后重新检查数据状态/)).toBeInTheDocument();
});
it("distinguishes a 503 and retries the same query", async () => {
let searchAttempts = 0;
vi.mocked(fetch).mockImplementation((input) => {
const path = requestPath(input);
if (path.endsWith("/status")) return Promise.resolve(jsonResponse(readyStatus));
if (path.endsWith("/search")) {
searchAttempts += 1;
return Promise.resolve(
searchAttempts === 1
? jsonResponse({ detail: "database unavailable" }, 503)
: jsonResponse(sourceResponse),
);
}
return Promise.resolve(jsonResponse({}, 404));
});
const user = userEvent.setup();
renderWorkbench();
await screen.findByRole("heading", { name: "离线索引已就绪" });
await user.type(screen.getByLabelText("地质问题"), "铜矿蚀变特征");
await user.click(screen.getByRole("button", { name: "开始检索" }));
expect(await screen.findByText(/本地数据库暂不可用/)).toBeInTheDocument();
expect(screen.getByLabelText("地质问题")).toHaveValue("铜矿蚀变特征");
await user.click(screen.getByRole("button", { name: "重新检索" }));
expect(
await screen.findByRole("heading", {
name: "合成资料|青岚示范区斑岩铜矿蚀变分带",
}),
).toBeInTheDocument();
expect(searchAttempts).toBe(2);
});
it("shows a bounded 422 message and does not present it as a server outage", async () => {
vi.mocked(fetch).mockImplementation((input) => {
const path = requestPath(input);
if (path.endsWith("/status")) return Promise.resolve(jsonResponse(readyStatus));
return Promise.resolve(jsonResponse({ detail: [] }, 422));
});
const user = userEvent.setup();
renderWorkbench();
await screen.findByRole("heading", { name: "离线索引已就绪" });
await user.type(screen.getByLabelText("地质问题"), "铜矿");
await user.click(screen.getByRole("button", { name: "开始检索" }));
expect(await screen.findByText(/查询条件超出边界/)).toBeInTheDocument();
expect(screen.queryByText(/数据库暂不可用/)).not.toBeInTheDocument();
});
it("keeps whitespace-only input on the client and never calls search", async () => {
vi.mocked(fetch).mockResolvedValue(jsonResponse(readyStatus));
const user = userEvent.setup();
renderWorkbench();
await screen.findByRole("heading", { name: "离线索引已就绪" });
await user.type(screen.getByLabelText("地质问题"), " ");
await user.click(screen.getByRole("button", { name: "开始检索" }));
expect(screen.getByText("请输入要检索的地质问题")).toBeInTheDocument();
await waitFor(() => expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1));
});
});

View File

@@ -0,0 +1,84 @@
import { useEffect } from "react";
import { DemoStatusPanel } from "../features/demo/components/DemoStatusPanel";
import { SearchForm } from "../features/demo/components/SearchForm";
import { SearchResults } from "../features/demo/components/SearchResults";
import { useDemoSearch } from "../features/demo/hooks/useDemoSearch";
import { useDemoStatus } from "../features/demo/hooks/useDemoStatus";
import type { SearchInput } from "../features/demo/types";
export function WorkbenchPage() {
const statusQuery = useDemoStatus();
const searchMutation = useDemoSearch();
const { isIdle: isSearchIdle, reset: resetSearch } = searchMutation;
const isReady = statusQuery.data?.status === "ready" && !statusQuery.error;
useEffect(() => {
if (!isReady && !isSearchIdle) {
resetSearch();
}
}, [isReady, isSearchIdle, resetSearch]);
function handleSearch(input: SearchInput) {
searchMutation.mutate({ query: input.query, top_k: input.topK });
}
return (
<div className="page-stack">
<header className="hero">
<div className="hero__copy">
<div className="mode-badge">
<span className="mode-badge__dot" />
· 线 ·
</div>
<span className="eyebrow">GEOLOGICAL KNOWLEDGE RETRIEVAL</span>
<h1>线</h1>
<p className="hero__lead">
synthetic
</p>
</div>
<div className="hero__seal" aria-hidden="true">
<div className="hero__seal-ring">
<span>RAG</span>
<small>OFFLINE</small>
</div>
<div className="hero__coordinates">1024D · PGVECTOR</div>
</div>
</header>
<div className="safety-notice" role="note">
<strong>使</strong>
<span></span>
</div>
<DemoStatusPanel
data={statusQuery.data}
error={statusQuery.error}
isFetching={statusQuery.isFetching}
isPending={statusQuery.isPending}
onRetry={() => void statusQuery.refetch()}
/>
<div className="workspace-grid">
<SearchForm
disabled={!isReady}
isSearching={searchMutation.isPending}
onSearch={handleSearch}
/>
<SearchResults
data={isReady ? searchMutation.data : undefined}
error={isReady ? searchMutation.error : undefined}
isIdle={!isReady || searchMutation.isIdle}
isPending={isReady && searchMutation.isPending}
onRetry={() => {
if (searchMutation.variables) {
searchMutation.mutate(searchMutation.variables);
}
}}
query={searchMutation.variables?.query}
/>
</div>
</div>
);
}

View File

@@ -1 +0,0 @@

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { render } from "@testing-library/react";
import { createAppQueryClient } from "../app/queryClient";
import { WorkbenchPage } from "../pages/WorkbenchPage";
export function renderWorkbench() {
const queryClient = createAppQueryClient();
return render(
<QueryClientProvider client={queryClient}>
<WorkbenchPage />
</QueryClientProvider>,
);
}
export function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}

View File

@@ -0,0 +1,21 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach, vi } from "vitest";
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />