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

@@ -13,7 +13,10 @@ jobs:
with:
version: "0.11.3"
enable-cache: true
- name: Install locked backend dependencies
run: make backend-sync
- name: Verify design, secrets, backend, and schema contracts
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Verify design, secrets, backend, frontend, and schema contracts
run: make verify-ci

View File

@@ -1,6 +1,7 @@
.PHONY: setup-hooks check-secrets docs-check links-check verify-design backend-sync \
backend-format backend-lint backend-type backend-test backend-check compose-check \
verify-ci verify
frontend-sync frontend-format frontend-lint frontend-type frontend-test \
frontend-build frontend-check verify-ci verify
setup-hooks:
git config core.hooksPath .githooks
@@ -16,6 +17,7 @@ docs-check:
test -f docs/03-implementation-plan.md
test -f docs/04-project-todo.md
test -f docs/05-stage1-runbook.md
test -f docs/06-frontend-demo-runbook.md
links-check:
python3 scripts/check_markdown_links.py
@@ -41,12 +43,32 @@ backend-test:
backend-check: backend-sync backend-format backend-lint backend-type backend-test
frontend-sync:
cd frontend && npm ci
frontend-format:
cd frontend && npm run format:check
frontend-lint:
cd frontend && npm run lint
frontend-type:
cd frontend && npm run typecheck
frontend-test:
cd frontend && npm run test
frontend-build:
cd frontend && npm run build
frontend-check: frontend-sync frontend-format frontend-lint frontend-type frontend-test frontend-build
compose-check:
docker compose --env-file .env.example config --quiet
# CI does not assume its runner exposes a Docker daemon. Schema and Compose
# security contracts still run in backend-test; local verification also asks the
# Docker CLI to render the real Compose model.
verify-ci: verify-design backend-check
verify-ci: verify-design backend-check frontend-check
verify: verify-ci compose-check

View File

@@ -2,7 +2,7 @@
本仓库用于“基于 RAG 的地质找矿知识问答系统构建与应用”毕业设计。系统计划采用 Python/FastAPI 后端、React/TypeScript 前端、PostgreSQL + pgvector 向量存储,以及阿里云百炼的 `text-embedding-v4``qwen3-rerank``deepseek-v4-flash`
当前处于 Stage 1“模型与数据库 PoC”设计基线离线 pgvector/适配器/seed 子闭环可运行真实百炼 smoke 仍待轮换后的新 KeyReact 产品功能尚未开始。完整方案、状态和评测方法见 [docs/README.md](docs/README.md),仓库不会把 PoC 伪装成已经完成的产品。
当前处于 Stage 1“模型与数据库 PoC”设计基线离线 pgvector/适配器/seed、FastAPI 和 React/Nginx 可视化检索演示均可运行真实百炼 smoke 仍待轮换后的新 Key正式知识库、文档入库和生成问答尚未实现。完整方案、状态和评测方法见 [docs/README.md](docs/README.md),仓库不会把离线演示伪装成已经完成的产品。
## 目录
@@ -23,7 +23,7 @@
- 百炼 API Key 只通过 Docker Secret、环境变量或云密钥管理服务注入。
- 真实 `.env``secrets/`、原始受限资料和私有评测数据一律禁止提交。
- 前端不得接触模型密钥;所有模型请求只能由后端发起。
- 每次提交前运行 `make verify`当前设计阶段它执行文档、链接、diff 和密钥检查;代码落地后将扩展为后端/前端 lint、类型、测试和镜像检查。首次克隆后运行 `make setup-hooks` 启用提交前密钥检查。
- 每次提交前运行 `make verify`。它统一执行文档、链接、diff、密钥、后端前端格式、类型、测试、构建和 Compose 配置检查。首次克隆后运行 `make setup-hooks` 启用提交前密钥检查。
- 若密钥曾出现在聊天、日志、终端历史或提交中,立即在百炼控制台重置;仅从 Git 历史删除文件并不能让密钥恢复安全。
## 阶段交付规则
@@ -44,19 +44,19 @@
- [实施计划与验收](docs/03-implementation-plan.md)
- [全生命周期 TODO 与进度](docs/04-project-todo.md)
- [Stage 1 PoC 运行手册](docs/05-stage1-runbook.md)
- [React 离线检索演示运行手册](docs/06-frontend-demo-runbook.md)
- [架构决策记录](docs/adr/README.md)
## 当前可运行入口
```bash
make setup-hooks
make backend-sync
bash scripts/init-local-secrets.sh
docker compose up -d --build gateway
docker compose up -d --build web
docker compose --profile tools run --rm seed-demo-offline
curl http://127.0.0.1:8000/health/ready
curl http://127.0.0.1:8000/api/v1/demo/status
make verify
```
这组命令不需要百炼 Key只使用 20 条虚构数据验证 pgvector 写入、检索、重排和幂等。`gateway` 会依次启动数据库、迁移内部 API;启动后可在 <http://127.0.0.1:8000/docs> 查看 FastAPI Swagger,并通过 `POST /api/v1/demo/search` 体验只读离线检索。真实模型运行必须先轮换聊天中已暴露的旧 Key再按 [Stage 1 运行手册](docs/05-stage1-runbook.md) 操作。
这组命令不需要百炼 Key只使用 20 条虚构数据验证 pgvector 写入、检索、重排、引用编号和同源 Web 交互。`web` 会依次启动数据库、迁移内部 API 和 gateway启动后访问 <http://127.0.0.1:8000/> 使用 React 演示,或访问 <http://127.0.0.1:8000/docs> 查看 FastAPI Swagger。真实模型运行必须先轮换聊天中已暴露的旧 Key再按 [Stage 1 运行手册](docs/05-stage1-runbook.md) 操作。

View File

@@ -5,7 +5,8 @@ from contextlib import asynccontextmanager
import httpx
from fastapi import FastAPI, Request, Response, status
from fastapi.responses import JSONResponse
from fastapi.responses import JSONResponse, StreamingResponse
from starlette.types import Receive, Scope, Send
UPSTREAM_ORIGIN = httpx.URL("http://api:8000")
MAX_REQUEST_BODY_BYTES = 1024 * 1024
@@ -53,6 +54,27 @@ RESPONSE_HEADER_ALLOWLIST = frozenset(
)
class _UpstreamStreamingResponse(StreamingResponse):
"""Close the upstream response even if downstream headers cannot be sent."""
def __init__(
self,
*,
upstream_response: httpx.Response,
content: AsyncIterator[bytes],
status_code: int,
headers: Mapping[str, str],
) -> None:
self._upstream_response = upstream_response
super().__init__(content=content, status_code=status_code, headers=headers)
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
try:
await super().__call__(scope, receive, send)
finally:
await self._upstream_response.aclose()
def _allowlisted_headers(headers: Mapping[str, str], allowlist: frozenset[str]) -> dict[str, str]:
return {name: value for name, value in headers.items() if name.lower() in allowlist}
@@ -139,15 +161,23 @@ def create_gateway_app(transport: httpx.AsyncBaseTransport | None = None) -> Fas
content=body,
)
try:
upstream_response = await upstream_client.send(upstream_request)
upstream_response = await upstream_client.send(upstream_request, stream=True)
except httpx.RequestError:
return JSONResponse(
status_code=status.HTTP_502_BAD_GATEWAY,
content={"detail": "upstream unavailable"},
)
return Response(
content=upstream_response.content,
async def response_body() -> AsyncIterator[bytes]:
if upstream_response.is_stream_consumed:
yield upstream_response.content
else:
async for chunk in upstream_response.aiter_raw():
yield chunk
return _UpstreamStreamingResponse(
upstream_response=upstream_response,
content=response_body(),
status_code=upstream_response.status_code,
headers=_allowlisted_headers(
upstream_response.headers,

View File

@@ -18,6 +18,8 @@ from app.persistence.job_queue_sql import ( # noqa: E402
COMPOSE = (ROOT / "compose.yaml").read_text(encoding="utf-8")
DOCKERFILE = (BACKEND / "Dockerfile").read_text(encoding="utf-8")
FRONTEND_DOCKERFILE = (ROOT / "frontend/Dockerfile").read_text(encoding="utf-8")
NGINX = (ROOT / "frontend/nginx.conf").read_text(encoding="utf-8")
DEMO_API = (BACKEND / "app/api/v1/demo.py").read_text(encoding="utf-8")
BOOTSTRAP = (ROOT / "ops/postgres/init/10-bootstrap-rag.sh").read_text(encoding="utf-8")
MIGRATION = (BACKEND / "migrations/versions/0001_initial_schema.py").read_text(encoding="utf-8")
@@ -35,6 +37,7 @@ def test_compose_isolates_database_credentials_and_networks() -> None:
migrate = _service_block("migrate")
api = _service_block("api")
gateway = _service_block("gateway")
web = _service_block("web")
provider_smoke = _service_block("provider-smoke")
seed_demo = _service_block("seed-demo")
seed_demo_offline = _service_block("seed-demo-offline")
@@ -59,15 +62,29 @@ def test_compose_isolates_database_credentials_and_networks() -> None:
assert "no-new-privileges:true" in api
assert "cap_drop:" in api and " - ALL" in api
assert '"127.0.0.1:8000:8000"' in gateway
assert " - edge" in gateway
assert '"127.0.0.1:8000:8000"' not in gateway
assert " - ingress" in gateway
assert " - data" in gateway
assert " - edge" not in gateway
assert " - egress" not in gateway
assert "secrets:" not in gateway
assert "POSTGRES_" not in gateway
assert "BAILIAN_" not in gateway
assert "read_only: true" in gateway
assert "no-new-privileges:true" in gateway
assert '"127.0.0.1:8000:8080"' in web
assert " - edge" in web
assert " - ingress" in web
assert " - data" not in web
assert " - egress" not in web
assert "secrets:" not in web
assert "POSTGRES_" not in web
assert "BAILIAN_" not in web
assert "read_only: true" in web
assert "no-new-privileges:true" in web
assert len(re.findall(r"(?m)^ ports:$", COMPOSE)) == 1
assert "bailian_api_key" in provider_smoke
assert "postgres_" not in provider_smoke
@@ -83,11 +100,15 @@ def test_compose_isolates_database_credentials_and_networks() -> None:
assert "./data/samples/public:/demo:ro" in seed_demo_offline
assert re.search(r"(?ms)^ data:\n.*?^ internal: true$", COMPOSE)
assert re.search(r"(?ms)^ ingress:\n.*?^ internal: true$", COMPOSE)
assert re.search(r"(?m)^ edge:$", COMPOSE)
assert re.search(r"(?m)^ egress:$", COMPOSE)
assert "USER 10001:10001" in DOCKERFILE
assert 'CMD ["uvicorn", "app.main:app"' in DOCKERFILE
assert "USER 101:101" in FRONTEND_DOCKERFILE
assert "proxy_buffering off" in NGINX
assert "server gateway:8000" in NGINX
def test_demo_queries_pin_governed_offline_identity() -> None:

View File

@@ -1,16 +1,38 @@
import json
from collections.abc import AsyncIterator, Callable
from contextlib import asynccontextmanager
from typing import cast
import httpx
import pytest
from fastapi import FastAPI
from starlette.requests import ClientDisconnect
from starlette.types import Message, Scope
from app.gateway import MAX_REQUEST_BODY_BYTES, create_gateway_app
type Handler = Callable[[httpx.Request], httpx.Response]
class _TwoEventStream(httpx.AsyncByteStream):
async def __aiter__(self) -> AsyncIterator[bytes]:
yield b"data: first\n\n"
yield b"data: second\n\n"
class _TrackedStream(httpx.AsyncByteStream):
def __init__(self) -> None:
self.closed = False
self.started = False
async def __aiter__(self) -> AsyncIterator[bytes]:
self.started = True
yield b"data: never-delivered\n\n"
async def aclose(self) -> None:
self.closed = True
@asynccontextmanager
async def _gateway_client(handler: Handler) -> AsyncIterator[httpx.AsyncClient]:
gateway = create_gateway_app(httpx.MockTransport(handler))
@@ -161,3 +183,105 @@ async def test_sensitive_upstream_response_headers_are_not_forwarded() -> None:
assert "set-cookie" not in response.headers
assert "server" not in response.headers
assert "x-internal-token" not in response.headers
@pytest.mark.asyncio
async def test_sse_events_are_forwarded_as_separate_asgi_body_chunks() -> None:
def handler(_: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
headers={"content-type": "text/event-stream"},
stream=_TwoEventStream(),
)
gateway = create_gateway_app(httpx.MockTransport(handler))
sent: list[Message] = []
request_delivered = False
async def receive() -> Message:
nonlocal request_delivered
if not request_delivered:
request_delivered = True
return {"type": "http.request", "body": b"", "more_body": False}
return {"type": "http.disconnect"}
async def send(message: Message) -> None:
sent.append(message)
scope = cast(
Scope,
{
"type": "http",
"asgi": {"version": "3.0", "spec_version": "2.4"},
"http_version": "1.1",
"method": "GET",
"scheme": "http",
"path": "/api/v1/chat/stream",
"raw_path": b"/api/v1/chat/stream",
"query_string": b"",
"headers": [(b"accept", b"text/event-stream")],
"client": ("127.0.0.1", 12345),
"server": ("gateway", 80),
},
)
async with gateway.router.lifespan_context(gateway):
await gateway(scope, receive, send)
body_messages = [message for message in sent if message["type"] == "http.response.body"]
assert [message["body"] for message in body_messages] == [
b"data: first\n\n",
b"data: second\n\n",
b"",
]
assert [message["more_body"] for message in body_messages] == [True, True, False]
@pytest.mark.asyncio
async def test_upstream_stream_closes_when_downstream_disconnects_before_body_iteration() -> None:
upstream_stream = _TrackedStream()
def handler(_: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
headers={"content-type": "text/event-stream"},
stream=upstream_stream,
)
gateway = create_gateway_app(httpx.MockTransport(handler))
request_delivered = False
async def receive() -> Message:
nonlocal request_delivered
if not request_delivered:
request_delivered = True
return {"type": "http.request", "body": b"", "more_body": False}
return {"type": "http.disconnect"}
async def disconnect_on_response_start(message: Message) -> None:
if message["type"] == "http.response.start":
raise OSError("downstream disconnected")
scope = cast(
Scope,
{
"type": "http",
"asgi": {"version": "3.0", "spec_version": "2.4"},
"http_version": "1.1",
"method": "GET",
"scheme": "http",
"path": "/api/v1/chat/stream",
"raw_path": b"/api/v1/chat/stream",
"query_string": b"",
"headers": [(b"accept", b"text/event-stream")],
"client": ("127.0.0.1", 12345),
"server": ("gateway", 80),
},
)
async with gateway.router.lifespan_context(gateway):
with pytest.raises(ClientDisconnect):
await gateway(scope, receive, disconnect_on_response_start)
assert upstream_stream.closed is True
assert upstream_stream.started is False

View File

@@ -124,10 +124,8 @@ services:
depends_on:
api:
condition: service_healthy
ports:
- "127.0.0.1:8000:8000"
networks:
- edge
- ingress
- data
healthcheck:
test:
@@ -151,6 +149,39 @@ services:
- ALL
restart: unless-stopped
web:
build:
context: ./frontend
depends_on:
gateway:
condition: service_healthy
ports:
- "127.0.0.1:8000:8080"
networks:
- edge
- ingress
healthcheck:
test:
- CMD
- wget
- --quiet
- --output-document=-
- http://127.0.0.1:8080/nginx-health
interval: 10s
timeout: 3s
retries: 5
start_period: 5s
init: true
user: "101:101"
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=16m,uid=101,gid=101,mode=1770
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
restart: unless-stopped
provider-smoke:
build:
context: ./backend
@@ -213,6 +244,9 @@ volumes:
networks:
edge:
driver: bridge
ingress:
driver: bridge
internal: true
data:
driver: bridge
internal: true

View File

@@ -132,7 +132,8 @@ deepseek-v4-flash
```mermaid
flowchart LR
U["浏览器用户"] -->|"HTTPS / SSE"| W["React + Nginx"]
W -->|"/api/v1"| A["FastAPI API"]
W -->|"同源 /api"| G["无 Secret 固定上游 Gateway"]
G -->|"internal data"| A["FastAPI API"]
A --> D[("PostgreSQL + pgvector")]
A --> F[("文件卷 / OSS 适配器")]
A --> E["text-embedding-v4"]
@@ -150,6 +151,7 @@ flowchart LR
| 组件 | 职责 | 不承担 |
|---|---|---|
| `web` | React 静态页面、Nginx 代理、SSE 透传 | 模型凭证、业务权限判断 |
| `gateway` | 固定 API 上游、请求大小/头边界、脱敏错误和流式转发 | 数据库凭证、模型凭证、业务权限判断 |
| `api` | 认证、知识库、检索、问答、评测 API | 长时间 OCR/批量入库 |
| `worker` | 解析、OCR、分块、向量化、索引、评测批任务 | 对外开放端口 |
| `db` | 元数据、向量、任务、会话、评测事实来源 | 原始大文件长期存储 |

View File

@@ -29,9 +29,10 @@ docker compose up -d --build
|---|---|---|---|
| `db` | 固定版本 pgvector 镜像 | 元数据、向量、任务、会话、评测 | 仅内部网络 |
| `migrate` | 与后端同镜像 | 运行 Alembic一次成功退出 | 无 |
| `api` | `backend/Dockerfile` | FastAPI、检索、问答、管理 API | 仅由 Web 代理 |
| `api` | `backend/Dockerfile` | FastAPI、检索、问答、管理 API | 仅 internal data 网络 |
| `gateway` | 与后端同镜像 | 固定上游、请求边界、脱敏错误和流式转发 | 仅 internal ingress/data 网络 |
| `worker` | 同一后端镜像 | 解析、向量化、评测后台任务 | 无 |
| `web` | `frontend/Dockerfile` | Nginx 静态资源和反向代理 | 宿主机 8080/HTTPS |
| `web` | `frontend/Dockerfile` | React 静态资源、Nginx 同源入口和 SSE 代理 | `127.0.0.1:8000`;生产部署 HTTPS |
| `ocr-worker` | 可选 profile | PaddleOCR 重型任务 | 无 |
| `prometheus/grafana` | 可选 profile | 本地观测 | 默认不对公网 |
@@ -116,26 +117,31 @@ services:
api:
build: ./backend
command: ["python", "-m", "app.main"]
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
depends_on:
migrate:
condition: service_completed_successfully
environment:
<<: [*runtime-config, *rag-config]
APP_SECRET_KEY_FILE: /run/secrets/app_secret_key
environment: *runtime-config
secrets:
- postgres_app_password
- app_secret_key
- bailian_api_key
volumes:
- uploads:/data/uploads
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/v1/health/ready', timeout=2)"]
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/ready', timeout=2)"]
interval: 10s
timeout: 3s
retries: 12
start_period: 10s
networks: [edge, data, egress]
networks: [data]
restart: unless-stopped
gateway:
build: ./backend
command: ["uvicorn", "app.gateway:app", "--host", "0.0.0.0", "--port", "8000"]
depends_on:
api:
condition: service_healthy
networks: [ingress, data]
restart: unless-stopped
worker:
@@ -183,11 +189,11 @@ services:
web:
build: ./frontend
depends_on:
api:
gateway:
condition: service_healthy
ports:
- "8080:80"
networks: [edge]
- "127.0.0.1:8000:8080"
networks: [edge, ingress]
restart: unless-stopped
volumes:
@@ -197,6 +203,9 @@ volumes:
networks:
edge:
driver: bridge
ingress:
driver: bridge
internal: true
data:
driver: bridge
internal: true
@@ -210,15 +219,13 @@ secrets:
file: ./secrets/postgres_migrator_password
postgres_app_password:
file: ./secrets/postgres_app_password
app_secret_key:
file: ./secrets/app_secret_key
bailian_api_key:
file: ./secrets/bailian_api_key
```
最终实现固定镜像 digest示意版本只说明可行结构。`migrate` 只有一个实例执行API 和 Worker 不在启动时并发自动迁移。首次初始化脚本只在 `db` 容器内使用 bootstrap 凭据创建 `vector` 扩展、无超级用户权限的 migrator/app 登录角色和专属 schemamigrator 拥有 schema/DDLapp 仅获运行期所需的 DML/sequence 权限及默认权限API/Worker 永远不挂载 bootstrap 或 migrator Secret。最后一个初始化脚本只有在全部 SQL 事务和授权成功后,才在 `PGDATA` 内用同文件系统 rename 原子写入 `.rag-bootstrap-complete`healthcheck 同时检查 `pg_isready` 与该哨兵,防止临时初始化 server 提前放行迁移。备份再使用独立只读角色。Compose 的 `.env` 仅做变量插值,不会自动注入容器,因此可配置的非敏感项和 `*_FILE` 路径通过 YAML anchor 在 `environment` 中完整声明。后端从各自的 `POSTGRES_PASSWORD_FILE` 读取密码后在内存中组装 DSN不把明文密码放进 `.env``DATABASE_URL`
网络分为层:`web + api` 位于 `edge``api + worker + migrate + db` 位于 `data`只有 `api + worker` 位于 `egress``data` 设置 `internal: true`,所以 Web 无法横向连接数据库;普通 bridge 本身不构成出站域名白名单,生产机仍需主机防火墙/出口代理放行百炼和对象存储域名。
网络分为层:`web` 位于普通 `edge` 和 internal `ingress``gateway` 位于 `ingress + data`API、迁移、Worker 和数据库位于 internal `data`只有明确需要云调用的一次性工具或未来 Worker 才能加入命名的 `egress`。因此浏览器入口不能横向连接数据库,数据库感知 API 也没有公网默认出口。只有无 Secret 的 Web 容器发布回环端口gateway 固定上游并继续执行请求边界和脱敏错误契约。详细理由和被否决方案见 [ADR-0004](adr/0004-secretless-web-ingress.md)。必须注意:普通 `edge` bridge 为 Web 提供宿主机端口发布时,也给 Web 留有技术上的默认公网出口;当前接受这一点的前提是 Web 无 Secret、无数据网络、无挂载且代理上游固定。若部署基线要求入口容器也完全禁网必须在宿主机防火墙/出口代理层阻断,不能把“未加入名为 egress 的网络”误写成“没有外网出口”。普通 bridge 本身不构成域名白名单,正式联网 Worker 仍需主机防火墙出口代理放行百炼和对象存储域名。
### 2.3 后台任务为何不用 Redis

View File

@@ -7,7 +7,7 @@
| 已完成阶段 | Stage 0仓库、安全和设计基线 |
| 整体完成度 | 约 12%,合理区间 10%15% |
| 设计完成度 | 设计基线已完成;实现中发现新约束时继续通过 ADR 和文档修订维护 |
| 业务代码完成度 | 约 10%Stage 1 的 Docker API、离线检索数据库 PoC 已验收真实百炼 smoke 仍待新 Key,前端产品功能尚未实现 |
| 业务代码完成度 | 约 18%Stage 1 的 Docker API、离线检索数据库 PoC,以及 React 离线演示、Nginx 单入口和四网络隔离前置均已验收真实百炼 smoke 仍待轮换后的新 Key |
| 当前预计剩余工期 | 约 913 周,含 300 题正式标注、盲测、论文和答辩缓冲 |
| 进度权威来源 | 本文的阶段状态、验收证据和已推送提交 |
@@ -50,7 +50,7 @@
| Stage 9 答辩、发布与归档 | 3% | `TODO` | 0% |
| **合计** | **100%** | — | **12%** |
Stage 1 已完成可离线验收的数据库、适配器契约、synthetic seed只读 API 子闭环,阶段内部约完成 90%;但真实三模型 smoke 仍依赖轮换后的新 Key因此 Stage 1 不提前计入整体里程碑。本文复选框表示“已通过验收”,不表示“文件是否已经出现”。当前可诚实表述为:**设计基线完成,整体里程碑约 12%Stage 1 离线后端可运行,真实百炼验证尚未完成。**
Stage 1 已完成可离线验收的数据库、适配器契约、synthetic seed只读 API 和 Web 演示子闭环,安全离线子阶段内部约完成 95%;但真实三模型 smoke 仍依赖轮换后的新 Key因此 Stage 1 不提前计入整体里程碑。Stage 2 中不依赖云密钥的 React、Nginx、网络隔离和质量门禁前置已经完成但 Worker 租约、完整产品工作流等必选项尚未完成Stage 2 仍为 `TODO`本文复选框表示“已通过验收”,不表示“文件是否已经出现”。当前可诚实表述为:**设计基线完成,整体里程碑约 12%业务代码约 18%Stage 1 安全离线链路可运行,真实百炼验证尚未完成。**
## 2. 阶段依赖与关键路径
@@ -70,7 +70,7 @@ Stage 0 DONE
| 阶段 | 必须依赖 | 可提前并行的工作 | 不得提前做的工作 |
|---|---|---|---|
| Stage 1 | Stage 0、已轮换 Secret、受控联网 | 虚构数据和 fake server 契约测试 | 使用真实地质资料,或使用已暴露密钥 |
| Stage 2 | Stage 1 三模型和迁移 PoC | 前端静态布局、OpenAPI 生成脚本 | 绕过任务租约直接做不可恢复长任务 |
| Stage 2 | Stage 1 三模型和迁移 PoC | React 离线演示、OpenAPI 类型生成、Nginx 单入口和网络隔离前置 | 绕过任务租约直接做不可恢复长任务 |
| Stage 3 | Stage 2 服务骨架 | 合法语料清单、标注规范准备 | 未通过 `CLOUD_APPROVED` 的内容出域 |
| Stage 4 | Stage 3 可追溯切片与向量 | 100 组三元组完善 | 用测试集调相似度、Top K 或阈值 |
| Stage 5 | Stage 4 稳定候选和引用 ID | UI 组件、Prompt fake 测试 | 让前端直接接触模型 Key |
@@ -165,7 +165,7 @@ Stage 0 DONE
- [x] `docker compose run --rm seed-demo-offline` 完成 20 条虚构数据写入、检索和重排;真实模式待新 Key。
- [x] `api + gateway` 镜像构建并达到 healthylive/ready/meta/demo status/demo search 均通过真实 HTTP 验收。
- [x] 第二次运行 seed 后数据库计数与第一次相同,均为 chunks/vectors/searchable = 20/20/20。
- [x] 63 项测试证明模型契约、健康检查、固定上游 gateway、离线 demo、数据治理和失败路径正确。
- [x] 65后端测试证明模型契约、健康检查、固定上游 gateway、SSE 代理分块与早断连释放契约、离线 demo、数据治理和失败路径正确。
- [x] `make verify`、Secret 扫描、固定镜像构建和 `git diff --check` 通过。
### 2026-07-12 已验证运行证据
@@ -177,6 +177,10 @@ Stage 0 DONE
- API 容器对公网 TCP 探测返回 `ENETUNREACH (101)`gateway 的 mounts 为空且不含数据库或百炼业务环境变量。
- PostgreSQL 重启后计数仍为 20/20/20。
- app 尝试修改已 `CLOUD_APPROVED``cloud_text` 被审批不可变触发器拒绝;尝试建表被权限拒绝。
- React + TypeScript strict 离线演示已实现工作台与系统状态页14 项前端测试、ESLint、类型检查和生产构建通过。
- Nginx 成为唯一回环入口Compose 仅发布 `127.0.0.1:8000``web -> gateway -> api` 同源链路、Swagger、OpenAPI、健康检查和 synthetic search 均通过真实 HTTP 验收。
- `edge / ingress / data / egress` 四网络边界已落地Web 不可解析数据库API 与 gateway 无公网 TCP 出口Web 与 gateway 均不持有 Secret。
- gateway 与 Nginx 已关闭代理缓冲,自动测试证明两个 SSE 事件保持为独立下游分块,且响应头阶段断连也会释放上游流;真实聊天生成与取消传播仍属于 Stage 5尚未实现。
- 唯一未验收项是真实 `text-embedding-v4` / `qwen3-rerank` / `deepseek-v4-flash` smoke 与真实模式 seed。
### 提交/推送节点
@@ -202,6 +206,7 @@ Stage 0 DONE
- **状态:** `TODO`
- **预计工期:** 34 个工作日
- **依赖:** Stage 1 `DONE`
- **已完成前置:** 不依赖云密钥的 React 离线演示、OpenAPI 类型、Nginx 单入口、四网络隔离和当前质量门禁已验收;这不代表 Stage 2 已启动或完成。
### 具体实现任务
@@ -211,26 +216,26 @@ Stage 0 DONE
- [ ] 建立 PostgreSQL `background_jobs`、租约、lease token、fencing 和重试状态机。
- [ ] 建立 Worker maintenance loop、advisory-lock reaper 和强杀恢复测试。
- [ ] 建立 `/health/live``/health/ready` 和管理员模型能力探测端点。
- [ ] 建立 React + TypeScript strict、路由、TanStack Query 和 OpenAPI 客户端生成。
- [ ] 建立 Nginx仅发布 Web 端口;API、Worker、DB 只在内部网络
- [x] 建立 React + TypeScript strict、路由、TanStack Query 和 OpenAPI 客户端生成。
- [x] 建立 Nginx仅发布 Web 端口;当前 API、DB 只在内部网络Worker 落地后必须沿用同一边界
- [ ] 建立完整 Compose`web/api/worker/db/migrate`OCR profile 默认关闭。
- [ ] 建立 Ruff、类型、pytest、ESLint、TypeScript、Vitest 和镜像检查。
- [ ] 建立 Gitea CI 的 fake provider 测试;外部 PR 不运行真实 Key 测试。
- [x] 建立当前范围的 Ruff、类型、pytest、ESLint、TypeScript、Vitest 和镜像检查Stage 2 新模块必须继续纳入门禁
- [x] 建立 Gitea CI 的 fake provider 测试;外部 PR 不运行真实 Key 测试。
### 验收证据
- [ ] 空白机器仅按 README 执行即可启动完整骨架。
- [ ] migration 只执行一次,失败时 API readiness 不通过。
- [ ] `docker compose ps` 只暴露 Nginx/Web 端口
- [x] 当前 `docker compose ps` 只暴露 Nginx/Web 回环端口;加入 Worker 后须重新验收
- [ ] Docker 重启后数据库数据保留。
- [ ] 强杀 Worker 后验证可重领、耗尽进入 `FAILED`、旧 token 不能提交。
- [ ] 后端/前端 lint、类型、单元和集成测试均通过
- [x] 当前后端 65 项、前端 14 项测试及两端 lint、类型和构建门禁均通过Stage 2 新功能仍需增量验证
### 提交/推送节点
- [ ] `S2-A`FastAPI、持久化、健康端点和错误契约。
- [ ] `S2-B`任务租约、reaper、fencing 和恢复测试。
- [ ] `S2-C`React、Nginx、完整 Compose、CI 和文档。
- [ ] `S2-C`React、Nginx、完整 Compose、CI 和文档;其中 React/Nginx/CI 前置已完成,缺 Worker 与完整 Compose 验收,节点不得提前关闭
- [ ] 每个节点验证后独立提交并推送Stage 2 完成时在本文更新状态。
### 风险门禁
@@ -555,13 +560,13 @@ Stage 0 DONE
## 15. 下一步执行顺序
当前只推进 Stage 1建议按以下顺序开始
当前主线仍是关闭 Stage 1 的真实供应商门禁;已完成的 React/Nginx 工作仅作为 Stage 2 安全前置保留。建议按以下顺序继续
1. 轮换并受控注入新百炼 Key确认真实端点不写入仓库
2. 创建最小 Python 包、数据库初始化、Alembic 和 `db + migrate` Compose
3. fake server 实现并验证三模型适配器契约
4. 运行受控 live smoke记录 provider profile 和能力指纹
5. 用 20 条虚构数据完成写入、检索、重排和幂等闭环
6. 扩展 `make verify`,保存验收证据,完成 S1-A/S1-B/S1-C 提交与推送
1. 在百炼控制台撤销聊天中暴露的旧 Key创建新 Key并仅通过未提交的 Docker Secret 注入
2. 分别运行 `text-embedding-v4``qwen3-rerank``deepseek-v4-flash` 受控 live smoke记录脱敏 provider profile、能力指纹、维度、流式协议和错误映射
3.现有 20 条 synthetic 数据运行真实 Embedding/Rerank/Chat 链路和幂等 seed真实地质资料仍不得出域
4. 关闭 S1-B 并把 Stage 1 标记为 `DONE` 后,再实现 Stage 2 的 Worker 租约、fencing、reaper、恢复测试和完整 Compose
5. Stage 3 实现数字 PDF/DOCX/TXT/Markdown 的隔离解析、页码/章节恢复、审批状态机、切分、向量化写库和可追溯版本激活
6. Stage 4 完成真实向量召回、重排与验证集评测Stage 5 再实现 grounded chat SSE、取消传播、引用解析和聊天 UI不把当前代理分块测试误称为聊天功能完成
在 Stage 1 验收之前,不并行展开真实语料入库、完整 UI 或正式验证集调参
在 Stage 1 验收之前,不展开真实语料云端入库或正式验证集调参;前端可继续做不接触 Secret、不会绕过阶段门禁的离线可用性修正

View File

@@ -21,7 +21,7 @@ bash scripts/init-local-secrets.sh
离线模式使用确定性的 1024 维 feature-hash 向量和词法重排器,只验证数据治理、批处理、迁移、向量写入、权限过滤、检索、重排和幂等性,不把它当成真实模型质量。
```bash
docker compose up -d --build gateway
docker compose up -d --build web
docker compose --profile tools run --rm seed-demo-offline
docker compose --profile tools run --rm seed-demo-offline
```
@@ -46,7 +46,7 @@ Seed 的实际流程是:
### 2.1 查看后端运行效果
内部 API 只连接 `internal` data 网络,只挂载 app 数据库 Secret不连接外网也不挂载百炼 Key。无 Secret、无数据库凭证的 gateway 同时连接 ingressdata 网络,并且仅发布到本机回环地址 `127.0.0.1:8000`。两个容器都以数值非 root 用户运行,根文件系统只读,并移除 Linux capabilities。
内部 API 只连接 `internal` data 网络,只挂载 app 数据库 Secret不连接外网也不挂载百炼 Key。无 Secret、无数据库凭证的 gateway 连接 internal ingress/dataReact/Nginx Web 连接 edge/ingress并且是唯一发布到本机回环地址 `127.0.0.1:8000` 的容器。三个运行容器均使用非 root 用户、只读根文件系统并移除 Linux capabilities。
```bash
curl http://127.0.0.1:8000/health/live

View File

@@ -0,0 +1,91 @@
# React 离线检索演示运行手册
本手册用于启动 React + Nginx + gateway + FastAPI + PostgreSQL/pgvector 的同源离线演示。当前页面只展示合成数据的检索和重排结果,不调用百炼,不生成大模型答案,也不构成地质勘查结论。
## 1. 一键启动
首次运行先创建本地数据库 Secret
```bash
make setup-hooks
bash scripts/init-local-secrets.sh
docker compose up -d --build web
docker compose --profile tools run --rm seed-demo-offline
```
`web` 的依赖链会依次等待 `db -> migrate -> api -> gateway`。离线 seed 完成后刷新页面,即可看到 20/20/20 的演示数据状态。
访问入口:
- React 演示:<http://127.0.0.1:8000/>
- FastAPI Swagger<http://127.0.0.1:8000/docs>
- 数据库 readiness<http://127.0.0.1:8000/health/ready>
- synthetic 状态:<http://127.0.0.1:8000/api/v1/demo/status>
## 2. 推荐演示流程
1. 确认页面顶部持续显示“合成数据、离线模式、不调用云端模型”。
2. 确认切片、向量和可检索计数均为 20。
3. 选择示例问题或输入“花岗斑岩 铜矿化”,设置返回 3 条。
4. 提交检索,查看合成标题、证据摘录、页码、演示排序分和不透明 citation ID。
5. 说明 citation ID 尚不能打开真实报告页,当前没有 LLM 生成答案。
6. 缩窄浏览器到移动宽度,验证表单和来源卡片仍可完整阅读。
## 3. 本地前端开发
先让 Docker Web/API 链保持运行,再启动 Vite
```bash
cd frontend
npm ci
npm run dev
```
Vite 只监听 `127.0.0.1`,并把 `/api``/health``/openapi.json` 代理到本机 Docker Web 入口。前端 API 基址保持同源,构建产物中不存在数据库地址或百炼凭证。
后端 OpenAPI 改变后重新生成类型:
```bash
cd frontend
npm run generate:api
npm run typecheck
```
生成脚本会检查 demo status/search 路径是否存在,然后覆盖 `src/api/schema.generated.ts`;生成结果必须随 API 契约变更一起提交。
## 4. 质量门禁
```bash
make verify
docker compose --env-file .env.example ps -a
```
统一门禁包括:
- Secret、Markdown 链接和 Git diff 检查;
- Ruff、严格 mypy 和 pytest
- Prettier、ESLint、严格 TypeScript、Vitest 和 Vite production build
- Compose 配置与网络/Secret 合同测试。
容器级验收还应确认:
- 只有 `web` 发布 `127.0.0.1:8000`
- `web``gateway` 没有 Secret
- `api` 只连接 internal data 网络,公网 socket 探测失败;
- `gateway` 公网 socket 探测失败Web 因普通 edge 网络可技术性出网,但无 Secret、无数据网络和挂载此残余风险记录于 ADR-0004
- `/api`、健康检查和 Swagger 经 Web 同源代理可用;
- SSE 响应在 gateway 与 Nginx 均不被整体缓冲。
## 5. 停止与清理
停止容器但保留 PostgreSQL 数据:
```bash
docker compose down
```
不要在需要保留验收数据时使用 `docker compose down -v`。该命令会删除 PostgreSQL volume。
## 6. 当前边界
已实现的是可运行的离线检索演示尚未实现登录、真实知识库、文件上传、OCR、生成答案、可点击原文页、会话、反馈、正式评测面板和真实百炼模型探测。只有轮换后的新 Key 完成三模型 smoke 后Stage 1 才能标记为完成Stage 2 也不能因页面可见而提前标记为完成。

View File

@@ -5,7 +5,9 @@
- Stage 0“仓库、安全和设计基线”已完成并推送。
- Stage 1“模型与数据库 PoC”正在进行。
- 当前整体完成度约 12%,合理区间为 10%15%。
- 0003 的设计基线已完成;业务代码完成度约 8%Stage 1 离线数据库/适配器/seed 子闭环已验收,真实百炼 smoke 与前端产品功能尚未完成
- 0003 的设计基线已完成;业务代码完成度约 18%Stage 1 安全离线子阶段约 95%
- 后端离线链路、20 条 synthetic 数据、React 离线演示、Nginx 单入口和四网络隔离均已验收;真实百炼三模型 smoke 仍待旧 Key 撤销并轮换。
- Stage 2 仍为 `TODO`当前只完成了不依赖云密钥的前置Worker 租约、完整 Compose 和产品工作流尚未完成。
- 后续状态、依赖、验收证据、工期和提交节点以 [项目全生命周期 TODO](04-project-todo.md) 为准。
## 文档列表
@@ -18,8 +20,9 @@
| [03-implementation-plan.md](03-implementation-plan.md) | 阶段计划、完成定义、测试与验收清单 | 设计基线完成 |
| [04-project-todo.md](04-project-todo.md) | 从当前状态到最终答辩、发布和归档的任务看板 | Stage 1 进行中 |
| [05-stage1-runbook.md](05-stage1-runbook.md) | Secret 初始化、离线数据库/seed、真实三模型 smoke 和排障命令 | Stage 1 运行手册 |
| [06-frontend-demo-runbook.md](06-frontend-demo-runbook.md) | React/Nginx 离线检索演示、同源 API、开发与验收命令 | 离线前端运行手册 |
| [adr/](adr/) | 不易逆转的架构决策记录 | 持续维护 |
文档中的工作空间地址统一使用 `<workspace-id>` 占位符;真实地址和凭证均属于部署配置,不写入仓库。
建议首次阅读按 00 → 01 → 02 → 03 → 04 的顺序;日常执行查看 04运行 PoC 查看 05。
建议首次阅读按 00 → 01 → 02 → 03 → 04 的顺序;日常执行查看 04数据库/模型 PoC 查看 05React 演示查看 06

View File

@@ -0,0 +1,48 @@
# ADR-0004使用无 Secret 的分层 Web 入口
- **状态:** accepted
- **日期:** 2026-07-12
## 背景
系统需要同时满足三个约束:浏览器只能访问一个本机 Web 端口;数据库感知 API 不能获得公网出口React/Nginx 不能因反向代理而加入数据库所在网络。仅把 API 同时接入普通 `edge` 和内部 `data` 网络会获得公网出口;仅把 API 接入 `internal` 网络时Docker Desktop 又不会可靠发布宿主机端口。
## 决策
采用四层单机拓扑:
```text
browser
-> web (Nginx + React; edge + ingress)
-> gateway (fixed-origin HTTP proxy; ingress + data)
-> api (FastAPI; data only)
-> PostgreSQL/pgvector (data only)
```
- `edge` 是普通 bridge只连接无 Secret 的 `web`,用于宿主机回环端口发布。
- `ingress``internal` 网络,只连接 `web``gateway`
- `data``internal` 网络,只连接 `gateway``api`、迁移和数据库等内部组件。
- 命名的 `egress` 只供显式云模型工具使用;`web``gateway``api` 均不连接该网络。普通 `edge` bridge 仍会给 Web 一个技术上的默认公网出口,不能把网络名称当作防火墙。
- 只有 `web` 发布 `127.0.0.1:8000`gateway、API 和数据库不发布端口。
- `web``gateway` 不挂载数据库或百炼 Secretgateway 固定上游为 `http://api:8000`执行请求体上限、头白名单、SSRF 防护和脱敏 502 映射。
- API 只挂载 app 数据库 Secret以非 root、只读根文件系统运行并通过真实 socket 探测证明无公网出口。
## 取舍
多一层 gateway 会增加一个容器和一次本机网络转发,但当前查询耗时主要来自数据库和模型,额外开销相对可忽略。保留应用层 gateway 可以让固定上游、请求大小和错误脱敏形成可单测契约Nginx 专注静态资源、SPA fallback、安全响应头和 SSE 代理。
在当前 Docker Desktop 上,`edge` 必须保持普通 bridge 才能可靠发布宿主机回环端口;实测 Web 可以从该网络建立公网 TCP 连接。当前接受这一残余风险,因为 Web 无 Secret、无数据网络、无挂载且 Nginx 只代理固定 gateway。需要入口容器零出网的部署必须额外使用主机防火墙或受控出口策略重新验收后再收紧本 ADR。
## 被否决方案
1. **API 直接加入 edge 并发布端口:** 实测仍可访问公网,不满足数据库感知进程无 egress 的要求。
2. **web 直接加入 data 并代理 API** web 会获得到数据库网络的横向连通性,扩大入口层受攻陷后的范围。
3. **只发布 internal 网络中的 API 端口:** 在当前 Docker Desktop 上没有形成可访问的宿主机端口。
4. **只使用 Nginx、删除 gateway** 可以减少一跳,但会丢失已经测试的固定上游、请求头白名单和脱敏错误契约;本阶段优先保持安全边界可验证。
## 验证要求
- Compose 合同测试锁定网络成员、唯一发布端口和 Secret 挂载。
- API 与 gateway 容器公网 TCP 探测必须失败Web 的 edge 出口能力必须被显式记录web/gateway 镜像历史和环境不得出现 Key、数据库密码或内部 DSN。
- 浏览器从 Web 入口访问 SPA、`/api`、健康检查与 SSE 时保持同源。
- 每次改变该拓扑都必须更新本 ADR、部署文档并重新执行容器级验收。

View File

@@ -7,3 +7,4 @@ ADR 用于记录会长期影响系统的技术决策。状态使用 `proposed`
- [0001-use-pgvector.md](0001-use-pgvector.md):选择 PostgreSQL + pgvector 作为第一版向量存储。
- [0002-separate-bailian-protocols.md](0002-separate-bailian-protocols.md):分离百炼 Chat/Embedding 与 Rerank 协议适配器。
- [0003-text-first-scope.md](0003-text-first-scope.md):第一版采用文本优先边界,不宣称地质图空间理解。
- [0004-secretless-web-ingress.md](0004-secretless-web-ingress.md):用无 Secret 的 Nginx Web 与固定上游 gateway 隔离浏览器、API 和数据库网络。

11
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
.dockerignore
.env
.env.*
.git
.gitignore
.npmrc
Dockerfile
coverage
dist
node_modules
npm-debug.log*

5
frontend/.prettierignore Normal file
View File

@@ -0,0 +1,5 @@
dist
coverage
node_modules
src/api/schema.generated.ts
package-lock.json

View File

@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2
}

25
frontend/Dockerfile Normal file
View 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;"]

View File

@@ -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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

52
frontend/package.json Normal file
View 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"
}
}

View 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}`);

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" />

View 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
View File

@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}

View 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
View 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"],
},
},
});