Make the offline RAG chain observable without widening credential access
Some checks failed
verify / verify (push) Has been cancelled
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:
11
frontend/.dockerignore
Normal file
11
frontend/.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
||||
.dockerignore
|
||||
.env
|
||||
.env.*
|
||||
.git
|
||||
.gitignore
|
||||
.npmrc
|
||||
Dockerfile
|
||||
coverage
|
||||
dist
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
5
frontend/.prettierignore
Normal file
5
frontend/.prettierignore
Normal file
@@ -0,0 +1,5 @@
|
||||
dist
|
||||
coverage
|
||||
node_modules
|
||||
src/api/schema.generated.ts
|
||||
package-lock.json
|
||||
7
frontend/.prettierrc.json
Normal file
7
frontend/.prettierrc.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2
|
||||
}
|
||||
25
frontend/Dockerfile
Normal file
25
frontend/Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
||||
# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e
|
||||
FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS build
|
||||
|
||||
ENV CI=true
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginxinc/nginx-unprivileged:1.29-alpine@sha256:0c79d56aee561a1d81c63f00eee5fb5fe29279560cdc55e91425133104c7fbe6 AS runtime
|
||||
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
COPY --chown=101:101 --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
USER 101:101
|
||||
EXPOSE 8080
|
||||
|
||||
# The configuration is immutable and does not use envsubst templates, so skip
|
||||
# the base entrypoint's mutation probes against the read-only filesystem.
|
||||
ENTRYPOINT ["nginx"]
|
||||
CMD ["-g", "daemon off;"]
|
||||
@@ -1,23 +1,57 @@
|
||||
# Frontend
|
||||
# 地质知识离线检索前端
|
||||
|
||||
React + TypeScript 前端代码位于本目录。构建产物由 Nginx 提供,并由同一入口反向代理 `/api` 与 SSE。
|
||||
React + TypeScript 的 synthetic/offline RAG 演示工作台。前端只访问同源 `/api`,不读取、保存或显示模型 Key、百炼工作区地址和数据库连接信息。
|
||||
|
||||
规划边界:
|
||||
## 运行环境与固定版本
|
||||
|
||||
```text
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── api/ 类型安全的后端客户端
|
||||
│ ├── components/ 通用展示组件
|
||||
│ ├── features/ chat、documents、retrieval、evaluation
|
||||
│ ├── hooks/
|
||||
│ ├── pages/
|
||||
│ ├── routes/
|
||||
│ ├── styles/
|
||||
│ └── types/
|
||||
└── tests/
|
||||
├── unit/
|
||||
└── e2e/
|
||||
- Node.js `>=22.12.0`,本地验证版本 `22.22.3`
|
||||
- npm `>=10.9.0`,本地验证版本 `10.9.8`
|
||||
- React `19.2.7`
|
||||
- React Router `7.18.1`
|
||||
- TanStack Query `5.101.2`
|
||||
- Vite `8.1.4`
|
||||
- TypeScript `5.9.3`(`openapi-typescript 7.13.0` 当前要求 TypeScript 5.x)
|
||||
- Vitest `4.1.10`
|
||||
|
||||
精确依赖和完整性哈希记录在 `package-lock.json`,不要绕过 lockfile 安装。
|
||||
|
||||
## 本地开发
|
||||
|
||||
先确保后端监听 `127.0.0.1:8000`,再运行:
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
前端只展示后端返回的脱敏模型状态,不提供读取、编辑或回显真实模型密钥的页面。
|
||||
Vite 将同源 `/api` 代理到本机后端。生产构建输出到 `dist/`。
|
||||
|
||||
## OpenAPI 类型
|
||||
|
||||
`src/api/schema.generated.ts` 来自 FastAPI 的真实 OpenAPI 文档。后端 API 契约变化后,启动后端并运行:
|
||||
|
||||
```bash
|
||||
npm run generate:api
|
||||
```
|
||||
|
||||
如后端使用其他本机端口,可设置非敏感的 `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` 均存在后才覆盖类型文件。
|
||||
|
||||
## 质量门禁
|
||||
|
||||
```bash
|
||||
npm run format:check
|
||||
npm run lint
|
||||
npm run typecheck
|
||||
npm run test
|
||||
npm run build
|
||||
```
|
||||
|
||||
也可使用 `npm run verify` 顺序执行全部门禁。
|
||||
|
||||
当前界面明确区分:索引 ready、空数据、不完整数据、422 输入边界、503 数据库不可用和网络错误。只有 ready 状态允许检索;结果中的“演示排序分”不是地质结论置信度。
|
||||
|
||||
57
frontend/eslint.config.js
Normal file
57
frontend/eslint.config.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import js from "@eslint/js";
|
||||
import prettierConfig from "eslint-config-prettier";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import globals from "globals";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist", "coverage", "src/api/schema.generated.ts"] },
|
||||
{
|
||||
files: ["**/*.{js,mjs}"],
|
||||
...js.configs.recommended,
|
||||
languageOptions: { globals: globals.node },
|
||||
},
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2023,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
...reactRefresh.configs.vite.rules,
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"error",
|
||||
{ prefer: "type-imports", fixStyle: "inline-type-imports" },
|
||||
],
|
||||
"@typescript-eslint/no-confusing-void-expression": "off",
|
||||
"@typescript-eslint/no-misused-promises": [
|
||||
"error",
|
||||
{ checksVoidReturn: { attributes: false } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/*.{test,spec}.{ts,tsx}", "src/test/**/*.ts"],
|
||||
languageOptions: { globals: globals.node },
|
||||
rules: {
|
||||
"@typescript-eslint/no-unsafe-assignment": "off",
|
||||
"@typescript-eslint/no-unsafe-argument": "off",
|
||||
},
|
||||
},
|
||||
prettierConfig,
|
||||
);
|
||||
14
frontend/index.html
Normal file
14
frontend/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="基于合成地质资料的离线 RAG 检索演示工作台" />
|
||||
<meta name="theme-color" content="#0b2e2b" />
|
||||
<title>地知寻脉 · 地质 RAG 工作台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
114
frontend/nginx.conf
Normal file
114
frontend/nginx.conf
Normal file
@@ -0,0 +1,114 @@
|
||||
pid /tmp/nginx.pid;
|
||||
|
||||
worker_processes auto;
|
||||
|
||||
error_log /dev/stderr notice;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
access_log /dev/stdout;
|
||||
|
||||
client_body_temp_path /tmp/client_temp;
|
||||
proxy_temp_path /tmp/proxy_temp;
|
||||
fastcgi_temp_path /tmp/fastcgi_temp;
|
||||
uwsgi_temp_path /tmp/uwsgi_temp;
|
||||
scgi_temp_path /tmp/scgi_temp;
|
||||
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
keepalive_timeout 65s;
|
||||
server_tokens off;
|
||||
|
||||
gzip on;
|
||||
gzip_comp_level 5;
|
||||
gzip_min_length 1024;
|
||||
gzip_proxied any;
|
||||
gzip_types
|
||||
application/javascript
|
||||
application/json
|
||||
application/xml
|
||||
image/svg+xml
|
||||
text/css
|
||||
text/plain;
|
||||
|
||||
# FastAPI's development Swagger page currently references versioned assets
|
||||
# from jsDelivr and uses an inline bootstrap script. Keep that exception
|
||||
# scoped to /docs; the React application retains the strict self-only policy.
|
||||
map $uri $content_security_policy {
|
||||
~^/docs(?:/|$) "default-src 'self'; base-uri 'self'; connect-src 'self'; font-src 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data: https://fastapi.tiangolo.com; object-src 'none'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net";
|
||||
default "default-src 'self'; base-uri 'self'; connect-src 'self'; font-src 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'";
|
||||
}
|
||||
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
|
||||
upstream gateway_upstream {
|
||||
zone gateway_upstream 64k;
|
||||
server gateway:8000 resolve;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 8080;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
client_max_body_size 1m;
|
||||
|
||||
add_header Content-Security-Policy $content_security_policy always;
|
||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||
add_header Cross-Origin-Resource-Policy "same-origin" always;
|
||||
add_header Permissions-Policy "camera=(), geolocation=(), microphone=()" always;
|
||||
add_header Referrer-Policy "no-referrer" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header X-Permitted-Cross-Domain-Policies "none" always;
|
||||
|
||||
location = /nginx-health {
|
||||
access_log off;
|
||||
default_type text/plain;
|
||||
return 200 "ok\n";
|
||||
}
|
||||
|
||||
location ~ /\.(?!well-known(?:/|$)) {
|
||||
deny all;
|
||||
}
|
||||
|
||||
# Keep all browser API traffic same-origin. Buffering is disabled so
|
||||
# event-stream responses can be delivered incrementally end to end.
|
||||
location ~ ^/(?:api(?:/|$)|health(?:/|$)|docs(?:/|$)|openapi\.json$) {
|
||||
proxy_pass http://gateway_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_request_buffering off;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
|
||||
location ^~ /assets/ {
|
||||
expires 1y;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location = /index.html {
|
||||
expires -1;
|
||||
try_files /index.html =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
}
|
||||
4474
frontend/package-lock.json
generated
Normal file
4474
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
52
frontend/package.json
Normal file
52
frontend/package.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "geological-rag-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.12.0",
|
||||
"npm": ">=10.9.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview --host 127.0.0.1",
|
||||
"generate:api": "node scripts/generate-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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "5.101.2",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7",
|
||||
"react-router-dom": "7.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "10.0.1",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
"@testing-library/react": "16.3.2",
|
||||
"@testing-library/user-event": "14.6.1",
|
||||
"@types/node": "26.1.1",
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "6.0.3",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"eslint": "10.7.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-plugin-react-hooks": "7.1.1",
|
||||
"eslint-plugin-react-refresh": "0.5.3",
|
||||
"globals": "17.7.0",
|
||||
"jsdom": "29.1.1",
|
||||
"openapi-typescript": "7.13.0",
|
||||
"prettier": "3.9.5",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.63.0",
|
||||
"vite": "8.1.4",
|
||||
"vitest": "4.1.10"
|
||||
}
|
||||
}
|
||||
41
frontend/scripts/generate-openapi.mjs
Normal file
41
frontend/scripts/generate-openapi.mjs
Normal file
@@ -0,0 +1,41 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import openapiTS, { astToString, COMMENT_HEADER } from "openapi-typescript";
|
||||
|
||||
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 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}`);
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
65
frontend/src/api/client.ts
Normal file
65
frontend/src/api/client.ts
Normal 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 "发生了未预期的本地错误,请稍后重试";
|
||||
}
|
||||
406
frontend/src/api/schema.generated.ts
Normal file
406
frontend/src/api/schema.generated.ts
Normal 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
16
frontend/src/app/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
frontend/src/app/queryClient.ts
Normal file
15
frontend/src/app/queryClient.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
18
frontend/src/app/router.tsx
Normal file
18
frontend/src/app/router.tsx
Normal 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 /> },
|
||||
],
|
||||
},
|
||||
]);
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
86
frontend/src/components/AppShell.tsx
Normal file
86
frontend/src/components/AppShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
frontend/src/components/Icon.tsx
Normal file
118
frontend/src/components/Icon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
21
frontend/src/features/demo/api.ts
Normal file
21
frontend/src/features/demo/api.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
137
frontend/src/features/demo/components/DemoStatusPanel.tsx
Normal file
137
frontend/src/features/demo/components/DemoStatusPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
153
frontend/src/features/demo/components/SearchForm.tsx
Normal file
153
frontend/src/features/demo/components/SearchForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
194
frontend/src/features/demo/components/SearchResults.tsx
Normal file
194
frontend/src/features/demo/components/SearchResults.tsx
Normal 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 "查询条件超出边界,请控制在 1–500 个字符,并选择 1–10 条来源。";
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
6
frontend/src/features/demo/exampleQuestions.ts
Normal file
6
frontend/src/features/demo/exampleQuestions.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const EXAMPLE_QUESTIONS = [
|
||||
"花岗斑岩铜矿化有哪些典型蚀变特征?",
|
||||
"北岭地区金异常与哪些构造条件有关?",
|
||||
"矽卡岩型铁矿的找矿标志有哪些?",
|
||||
"断裂交汇部位为何有利于热液成矿?",
|
||||
] as const;
|
||||
7
frontend/src/features/demo/hooks/useDemoSearch.ts
Normal file
7
frontend/src/features/demo/hooks/useDemoSearch.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
import { searchDemo } from "../api";
|
||||
|
||||
export function useDemoSearch() {
|
||||
return useMutation({ mutationFn: searchDemo });
|
||||
}
|
||||
15
frontend/src/features/demo/hooks/useDemoStatus.ts
Normal file
15
frontend/src/features/demo/hooks/useDemoStatus.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
29
frontend/src/features/demo/types.test.ts
Normal file
29
frontend/src/features/demo/types.test.ts
Normal 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: "返回条数必须在 1–10 之间" },
|
||||
{ query: "铜矿", topK: 11, expected: "返回条数必须在 1–10 之间" },
|
||||
{ query: "铜矿", topK: 1.5, expected: "返回条数必须在 1–10 之间" },
|
||||
])("rejects the input boundary %#", ({ expected, query, topK }) => {
|
||||
const result = validateSearchInput({ query, topK });
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.message).toBe(expected);
|
||||
});
|
||||
});
|
||||
45
frontend/src/features/demo/types.ts
Normal file
45
frontend/src/features/demo/types.ts
Normal 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
17
frontend/src/main.tsx
Normal 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>,
|
||||
);
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
17
frontend/src/pages/NotFoundPage.tsx
Normal file
17
frontend/src/pages/NotFoundPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
148
frontend/src/pages/SystemPage.tsx
Normal file
148
frontend/src/pages/SystemPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
197
frontend/src/pages/WorkbenchPage.test.tsx
Normal file
197
frontend/src/pages/WorkbenchPage.test.tsx
Normal 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));
|
||||
});
|
||||
});
|
||||
84
frontend/src/pages/WorkbenchPage.tsx
Normal file
84
frontend/src/pages/WorkbenchPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
1837
frontend/src/styles/global.css
Normal file
1837
frontend/src/styles/global.css
Normal file
File diff suppressed because it is too large
Load Diff
21
frontend/src/test/renderWorkbench.tsx
Normal file
21
frontend/src/test/renderWorkbench.tsx
Normal 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" },
|
||||
});
|
||||
}
|
||||
21
frontend/src/test/setup.ts
Normal file
21
frontend/src/test/setup.ts
Normal 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
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
29
frontend/tsconfig.app.json
Normal file
29
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noImplicitReturns": true,
|
||||
"types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
4
frontend/tsconfig.json
Normal file
4
frontend/tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
16
frontend/tsconfig.node.json
Normal file
16
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noEmit": true,
|
||||
"types": ["node"],
|
||||
"allowImportingTsExtensions": true
|
||||
},
|
||||
"include": ["vite.config.ts", "eslint.config.js"]
|
||||
}
|
||||
37
frontend/vite.config.ts
Normal file
37
frontend/vite.config.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
proxy: Object.fromEntries(
|
||||
["/api", "/health", "/docs", "/openapi.json"].map((path) => [
|
||||
path,
|
||||
{
|
||||
target: "http://127.0.0.1:8000",
|
||||
changeOrigin: false,
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
preview: {
|
||||
port: 4173,
|
||||
strictPort: true,
|
||||
},
|
||||
build: {
|
||||
sourcemap: false,
|
||||
target: "es2022",
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
setupFiles: "./src/test/setup.ts",
|
||||
css: true,
|
||||
restoreMocks: true,
|
||||
clearMocks: true,
|
||||
coverage: {
|
||||
reporter: ["text", "html"],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user