From c67e05c32c11178a4fa79c1276273f210f9e5ecf Mon Sep 17 00:00:00 2001 From: Jia-Ethan <271507930+Jia-Ethan@users.noreply.github.com> Date: Sat, 18 Apr 2026 17:07:54 +0800 Subject: [PATCH] feat(workbench): add project experience phase one --- .env.example | 23 +- .github/ISSUE_TEMPLATE/bug_report.yml | 35 ++ .github/ISSUE_TEMPLATE/feature_request.yml | 27 ++ .github/PULL_REQUEST_TEMPLATE.md | 36 ++ .github/workflows/ci.yml | 8 +- CHANGELOG.md | 16 + CONTRIBUTING.md | 54 +++ README-local.md | 16 +- README.md | 24 +- SECURITY.md | 33 ++ backend/app/config.py | 20 + backend/app/database.py | 44 +- backend/app/main.py | 30 +- backend/app/models.py | 16 + backend/app/security.py | 68 +++ backend/app/workbench.py | 248 ++++++++++- docker-compose.yml | 4 + docs/api.md | 118 +++++ ...raduate-export-implementation-record-v1.md | 2 +- docs/deploy-vercel.md | 7 +- docs/known-limitations-word-export.md | 4 +- docs/local-validation-word.md | 2 +- docs/product-mainline-word-v1.md | 8 +- docs/quality-checklist-compliance.md | 5 +- docs/quality-checklist-v2.md | 9 +- docs/workbench-v1.md | 24 +- scripts/export_compliance_fixture.py | 33 ++ tests/test_workbench.py | 81 ++++ web/src/app/api.ts | 95 ++++- web/src/styles/features.css | 195 ++++++++- web/src/workbench/AccessCodeGate.tsx | 46 ++ web/src/workbench/ModelStatusBadge.tsx | 18 + web/src/workbench/PrivacyConsentBanner.tsx | 20 + web/src/workbench/ProjectSettings.tsx | 104 +++++ web/src/workbench/ProjectWizard.tsx | 107 +++++ web/src/workbench/ProviderSettings.tsx | 94 ++++ web/src/workbench/WorkbenchApp.test.tsx | 153 +++++++ web/src/workbench/WorkbenchApp.tsx | 403 ++++++++++++------ web/src/workbench/WorkbenchEmptyState.tsx | 18 + 39 files changed, 2051 insertions(+), 197 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 backend/app/security.py create mode 100644 docs/api.md create mode 100644 scripts/export_compliance_fixture.py create mode 100644 web/src/workbench/AccessCodeGate.tsx create mode 100644 web/src/workbench/ModelStatusBadge.tsx create mode 100644 web/src/workbench/PrivacyConsentBanner.tsx create mode 100644 web/src/workbench/ProjectSettings.tsx create mode 100644 web/src/workbench/ProjectWizard.tsx create mode 100644 web/src/workbench/ProviderSettings.tsx create mode 100644 web/src/workbench/WorkbenchApp.test.tsx create mode 100644 web/src/workbench/WorkbenchEmptyState.tsx diff --git a/.env.example b/.env.example index f508dc7..ebad755 100644 --- a/.env.example +++ b/.env.example @@ -1,15 +1,22 @@ # ================================================ -# scnu-thesis-portal (merged with Story2Paper) +# scnu-thesis-portal # ================================================ -# SCNU Exporter (always required) -OUTPUT_DIR=./output +# SCNU Workbench / exporter +APP_ENV=development +MAX_DOCX_SIZE_BYTES=4194304 +SCNU_DATABASE_URL= +SCNU_STORAGE_DIR= +CORS_ALLOWED_ORIGINS=http://127.0.0.1:5173,http://localhost:5173 -# Story2Paper — LiteLLM (required for AI generation) -LITELLM_API_KEY=*** +# Optional private deployment gate. +SCNU_ACCESS_CODE= -# Story2Paper — CORS (comma-separated origins) -CORS_ORIGINS=https://scnu-thesis-portal.vercel.app,http://localhost:3000 +# Required for real deployments that save Provider API keys. +SCNU_SECRET_KEY= -# Story2Paper — SQLite paper store (optional, defaults to /data/papers.db) +# Legacy Story2Paper experiment settings. +OUTPUT_DIR=./output +LITELLM_API_KEY=*** +CORS_ORIGINS=https://scnu-thesis-portal.vercel.app,http://localhost:3000 PAPER_DB_PATH=/data/papers.db diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..9404479 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,35 @@ +name: Bug report +description: Report a reproducible problem in SC-TH. +title: "[Bug]: " +labels: ["bug"] +body: + - type: textarea + id: what-happened + attributes: + label: What happened? + description: Describe the behavior and what you expected instead. + validations: + required: true + - type: textarea + id: steps + attributes: + label: Reproduction steps + placeholder: "1. Open...\n2. Upload...\n3. Click..." + validations: + required: true + - type: textarea + id: verification + attributes: + label: Verification output + description: Paste relevant test/build/compliance output. Do not include thesis private content or API keys. + - type: dropdown + id: area + attributes: + label: Area + options: + - Quick export + - Workbench + - Provider settings + - DOCX compliance + - Deployment + - Documentation diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..c248c65 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,27 @@ +name: Feature request +description: Propose a focused SC-TH improvement. +title: "[Feature]: " +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What user workflow should this improve? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed behavior + description: Describe the smallest useful version. + validations: + required: true + - type: checkboxes + id: boundaries + attributes: + label: Boundaries + options: + - label: This does not require fabricating data, references, comments, or school rules. + - label: This keeps AI-generated正文 behind Proposal / Approval. + - label: This describes any privacy or remote Provider impact. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..18ceb14 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,36 @@ +## Summary + +- + +## User Scenarios + +- + +## Data / API Impact + +- [ ] No schema or API change +- [ ] Schema change +- [ ] API change +- [ ] Migration or compatibility handling included + +## Export / Privacy Impact + +- [ ] No export behavior change +- [ ] Export behavior changed +- [ ] No privacy or Provider behavior change +- [ ] Privacy or Provider behavior changed + +## Verification + +```bash +uv run pytest tests -q +npm run test:smoke --prefix web +npm run build --prefix web +uv run python scripts/build_web_public.py +uv run python scripts/export_compliance_fixture.py tmp/fixture-export.docx +uv run python scripts/check_docx_compliance.py tmp/fixture-export.docx +``` + +## Screenshots + +Add screenshots for UI changes. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 327a0ca..aa7ff4a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,4 +48,10 @@ jobs: run: npm run build --prefix web - name: Build public bundle - run: python3 scripts/build_web_public.py + run: uv run python scripts/build_web_public.py + + - name: Export compliance fixture + run: uv run python scripts/export_compliance_fixture.py tmp/ci-fixture-export.docx + + - name: Check DOCX compliance fixture + run: uv run python scripts/check_docx_compliance.py tmp/ci-fixture-export.docx diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..cd0db23 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +## Unreleased + +- Added Workbench project metadata for SCNU undergraduate thesis projects. +- Added project-level privacy mode and explicit remote Provider authorization. +- Added Access Code protection for private Workbench deployments. +- Added Provider config listing, verification, deletion, and server-side key sealing. +- Added Workbench project wizard, project settings, Provider settings, privacy banner, model status badge, and access-code gate. +- Added GitHub contribution, security, PR, issue, and compliance fixture CI documentation. + +## 0.2.0 + +- Established the standards-driven `.docx` export mainline. +- Added Workbench v1 project, file, version, Proposal, Agent event, and export-record skeleton. +- Added Provider metadata and SSRF guard skeleton. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4a49cfd --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,54 @@ +# Contributing + +SC-TH keeps the fast `.docx` export path stable while Workbench evolves in small phases. + +## Branches + +- Use `main` for released baseline. +- Use `codex/` for feature work. +- Keep each PR focused on one phase or one user-facing capability. + +## Commits + +Use: + +```text +(scope): +``` + +Allowed types: + +- `feat` +- `fix` +- `docs` +- `test` +- `refactor` +- `chore` +- `security` + +Examples: + +```text +feat(workbench): add project wizard and privacy consent +security(provider): verify base urls before saving provider configs +docs(api): document access code guard +``` + +## Required Checks + +Run before opening a PR: + +```bash +uv run pytest tests -q +npm run test:smoke --prefix web +npm run build --prefix web +uv run python scripts/build_web_public.py +uv run python scripts/export_compliance_fixture.py tmp/fixture-export.docx +uv run python scripts/check_docx_compliance.py tmp/fixture-export.docx +``` + +If UI changed, include a screenshot. If export changed, include the compliance script result. + +## Product Boundary + +This project is an AI-assisted thesis workbench and formatter. It must not present itself as a thesis ghostwriting system, fabricate data, fabricate references, or bypass user approval for AI-generated正文. diff --git a/README-local.md b/README-local.md index ef405d8..ffd5471 100644 --- a/README-local.md +++ b/README-local.md @@ -8,7 +8,7 @@ Workbench v1 本地骨架为: -`创建项目 → 上传材料 → 解析任务 → baseline version → Issue / Proposal → 用户确认 → 导出记录` +`项目向导 → 隐私模式 → 上传材料 → 解析任务 → baseline version → Issue / Proposal → 用户确认 → 导出记录` ## 依赖 @@ -69,13 +69,13 @@ docker compose up --build 生成前端类型: ```bash -python3 scripts/generate_frontend_types.py +uv run python scripts/generate_frontend_types.py ``` 构建前端并写入 `public/`: ```bash -python3 scripts/build_web_public.py +uv run python scripts/build_web_public.py ``` ## 本地验收 @@ -87,11 +87,19 @@ python3 scripts/build_web_public.py 3. 检查预检弹窗是否显示“缺失章节保留留白位”和“复杂元素需人工复核” 4. 检查正式封面已作为主线输出的一部分 5. 通过预检后导出 `.docx` -6. 运行 `python3 scripts/check_docx_compliance.py <导出文件>` +6. 运行 `uv run python scripts/check_docx_compliance.py <导出文件>` 7. 在 Word 中更新目录并抽查页眉页脚、页码和分页 8. 进入 `#/workbench` 新建项目 9. 上传 `.docx` 或文本文件并触发解析 10. 检查版本、Agent 事件、Proposal 队列与导出历史 11. 检查 Provider 配置不会向前端返回 API key,内网 base URL 默认被拦截 +12. 如需访问码保护,设置 `SCNU_ACCESS_CODE` 并确认未验证请求会返回 `ACCESS_CODE_REQUIRED` + +CI 使用的导出合规 fixture 可本地生成: + +```bash +uv run python scripts/export_compliance_fixture.py tmp/fixture-export.docx +uv run python scripts/check_docx_compliance.py tmp/fixture-export.docx +``` 更细的人工验收项见 `docs/local-validation-word.md`。 diff --git a/README.md b/README.md index e743231..b2df1aa 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ 面向华南师范大学本科毕业论文的规范驱动 Word 导出工具与 Agent Workbench 骨架。 +![CI](https://img.shields.io/badge/CI-pytest%20%2B%20vitest%20%2B%20build-blue) +![Template](https://img.shields.io/badge/template-SCNU%20undergraduate-green) +![Privacy](https://img.shields.io/badge/privacy-local%20first-6b7280) + **当前主线:** 1. **快速导出入口** — 上传 `.docx` 或粘贴论文文本,生成按华师规范组织的 Word 文档 2. **Workbench v1 骨架** — 项目空间、文件库、版本、导出记录、Issue Ledger、Proposal 队列和可追溯 Agent 事件 @@ -68,7 +72,8 @@ flowchart LR - Agent 事件骨架:解析任务、事件流、规则建议、用户确认 / 拒绝 / 暂存 - 多输入解析 registry:`.docx`、文本、PDF 本地粗解析、图片/OCR 占位、参考文献文件 - 导出 registry:`.docx`、Markdown、自检报告、PDF 降级占位 -- Provider 配置骨架:OpenAI、Gemini、DeepSeek、MiniMax、Ollama 元数据与 SSRF 防护 +- Provider 设置:OpenAI、Gemini、DeepSeek、MiniMax、Ollama 元数据、服务端密钥保存、验证状态与 SSRF 防护 +- 项目创建向导、项目设置、隐私模式提示、远程 Provider 授权与访问码保护 - 规范驱动的正式封面渲染 - 中文摘要 / 英文摘要 / 目录 / 正文 / 参考文献 / 附录 / 致谢固定生成 - Word TOC 字段、页眉、页脚、页码与分节控制 @@ -82,7 +87,7 @@ flowchart LR - 表格、图片、脚注、复杂浮动对象不作为阻塞项,但默认进入人工复核范围 - 参考文献只做有限格式整理,不补造作者、刊名、卷期等缺失元数据 - 当前只覆盖本科论文导出主线,不提供研究生模板入口 -- Workbench v1 当前是可运行 MVP 骨架,真实 OCR、Celery 队列、MinIO/S3 SDK 与真实 LLM 调用仍需后续接入 +- Workbench v1 当前是可运行 MVP 骨架,真实 OCR、Celery 队列、MinIO/S3 SDK、真实 LLM 调用与 Director Runtime 仍需后续接入 - PDF 导出当前保留 `.docx` 结果并记录转换降级,不承诺 PDF 高保真 ## 在线预览 @@ -138,11 +143,13 @@ VITE_API_BASE_URL=http://127.0.0.1:8000 npm run dev --prefix web docker compose up --build ``` +私有部署可复制 `.env.example`,设置 `SCNU_ACCESS_CODE` 保护 API,并设置 `SCNU_SECRET_KEY` 用于服务端封存 Provider API key。 + 本地构建: ```bash -python3 scripts/generate_frontend_types.py -python3 scripts/build_web_public.py +uv run python scripts/generate_frontend_types.py +uv run python scripts/build_web_public.py ``` ## 质量护栏 @@ -150,19 +157,24 @@ python3 scripts/build_web_public.py - `uv run pytest tests -q` - `npm run test:smoke --prefix web` - `npm run build --prefix web` -- `python3 scripts/build_web_public.py` -- `python3 scripts/check_docx_compliance.py ` +- `uv run python scripts/build_web_public.py` +- `uv run python scripts/export_compliance_fixture.py tmp/fixture-export.docx` +- `uv run python scripts/check_docx_compliance.py tmp/fixture-export.docx` ## 关键文档 - [主线说明](docs/product-mainline-word-v1.md) - [Workbench v1 说明](docs/workbench-v1.md) +- [API 说明](docs/api.md) - [规范映射表](docs/scnu-undergraduate-format-spec-map.md) - [合规清单](docs/quality-checklist-compliance.md) - [已知限制](docs/known-limitations-word-export.md) - [审计报告](docs/compliance/scnu-undergraduate-export-audit-report-v1.md) - [实施与验收记录](docs/compliance/scnu-undergraduate-export-implementation-record-v1.md) - [本地运行说明](README-local.md) +- [Changelog](CHANGELOG.md) +- [Contributing](CONTRIBUTING.md) +- [Security](SECURITY.md) ## 仓库结构 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..31d09ca --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,33 @@ +# Security + +## Supported Scope + +The current Workbench is designed for private or local deployments. It is not a multi-tenant SaaS permission system. + +## Access Code + +Set `SCNU_ACCESS_CODE` to protect API routes in private deployments. + +- `/api/health` +- `/api/access-code/status` +- `/api/access-code/verify` + +remain public so the app can verify access. Other `/api/*` routes require the access cookie when `SCNU_ACCESS_CODE` is set. + +## Provider Secrets + +Provider API keys are accepted only by the backend and are never returned to the frontend. Responses expose only `has_api_key`. + +Set `SCNU_SECRET_KEY` in real deployments. Development mode derives an explicitly insecure local key only so the app can run without extra setup. + +## SSRF Guard + +Custom Provider `base_url` values are validated before storage and verification. + +- Remote providers reject loopback and private addresses. +- Link-local, reserved, and multicast addresses are always rejected. +- Ollama can use local addresses only when `allow_local=true`. + +## Reporting + +Open a private issue or contact the maintainer if a bug could expose thesis content, Provider keys, local files, or internal network access. diff --git a/backend/app/config.py b/backend/app/config.py index 3615539..0bafa66 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +from hashlib import sha256 from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parents[2] @@ -56,3 +57,22 @@ def read_csv_env(name: str, default: list[str]) -> list[str]: "CORS_ALLOWED_ORIGINS", ["http://127.0.0.1:5173", "http://localhost:5173"] if APP_ENV != "production" else [], ) + + +ACCESS_CODE_COOKIE_NAME = "scnu_access_token" + + +def access_code() -> str: + return os.getenv("SCNU_ACCESS_CODE", "").strip() + + +def secret_key() -> str: + configured = os.getenv("SCNU_SECRET_KEY", "").strip() + if configured: + return configured + seed = f"insecure-local-dev-key:{PROJECT_ROOT}:{APP_ENV}" + return sha256(seed.encode("utf-8")).hexdigest() + + +def using_insecure_local_secret() -> bool: + return not bool(os.getenv("SCNU_SECRET_KEY", "").strip()) diff --git a/backend/app/database.py b/backend/app/database.py index e0e6f94..e14b0ab 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -4,7 +4,7 @@ from pathlib import Path from typing import Generator -from sqlalchemy import create_engine +from sqlalchemy import create_engine, inspect, text from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker from .config import OUTPUTS_DIR @@ -31,6 +31,48 @@ def init_db() -> None: from . import models as _models # noqa: F401 Base.metadata.create_all(bind=engine) + bootstrap_schema() + + +SCHEMA_BOOTSTRAP_COLUMNS = { + "thesis_projects": { + "school": {"sqlite": "VARCHAR(80) NOT NULL DEFAULT 'scnu'", "postgresql": "VARCHAR(80) NOT NULL DEFAULT 'scnu'"}, + "degree_level": {"sqlite": "VARCHAR(80) NOT NULL DEFAULT 'undergraduate'", "postgresql": "VARCHAR(80) NOT NULL DEFAULT 'undergraduate'"}, + "template_profile": {"sqlite": "VARCHAR(120) NOT NULL DEFAULT 'scnu-undergraduate'", "postgresql": "VARCHAR(120) NOT NULL DEFAULT 'scnu-undergraduate'"}, + "rule_set_id": {"sqlite": "VARCHAR(120) NOT NULL DEFAULT 'scnu-undergraduate-2025'", "postgresql": "VARCHAR(120) NOT NULL DEFAULT 'scnu-undergraduate-2025'"}, + "department": {"sqlite": "VARCHAR(200) NOT NULL DEFAULT ''", "postgresql": "VARCHAR(200) NOT NULL DEFAULT ''"}, + "major": {"sqlite": "VARCHAR(200) NOT NULL DEFAULT ''", "postgresql": "VARCHAR(200) NOT NULL DEFAULT ''"}, + "advisor": {"sqlite": "VARCHAR(120) NOT NULL DEFAULT ''", "postgresql": "VARCHAR(120) NOT NULL DEFAULT ''"}, + "student_name": {"sqlite": "VARCHAR(120) NOT NULL DEFAULT ''", "postgresql": "VARCHAR(120) NOT NULL DEFAULT ''"}, + "student_id": {"sqlite": "VARCHAR(120) NOT NULL DEFAULT ''", "postgresql": "VARCHAR(120) NOT NULL DEFAULT ''"}, + "writing_stage": {"sqlite": "VARCHAR(80) NOT NULL DEFAULT 'draft'", "postgresql": "VARCHAR(80) NOT NULL DEFAULT 'draft'"}, + "privacy_mode": {"sqlite": "VARCHAR(80) NOT NULL DEFAULT 'local_only'", "postgresql": "VARCHAR(80) NOT NULL DEFAULT 'local_only'"}, + "remote_provider_allowed": {"sqlite": "BOOLEAN NOT NULL DEFAULT 0", "postgresql": "BOOLEAN NOT NULL DEFAULT false"}, + }, + "provider_configs": { + "verification_status": {"sqlite": "VARCHAR(40) NOT NULL DEFAULT 'untested'", "postgresql": "VARCHAR(40) NOT NULL DEFAULT 'untested'"}, + "verification_message": {"sqlite": "TEXT NOT NULL DEFAULT ''", "postgresql": "TEXT NOT NULL DEFAULT ''"}, + "last_verified_at": {"sqlite": "DATETIME", "postgresql": "TIMESTAMP"}, + "deleted_at": {"sqlite": "DATETIME", "postgresql": "TIMESTAMP"}, + }, +} + + +def bootstrap_schema() -> None: + """Additive compatibility layer for local SQLite/Postgres installs before Alembic.""" + inspector = inspect(engine) + existing_tables = set(inspector.get_table_names()) + dialect = engine.dialect.name + with engine.begin() as connection: + for table, columns in SCHEMA_BOOTSTRAP_COLUMNS.items(): + if table not in existing_tables: + continue + existing_columns = {column["name"] for column in inspector.get_columns(table)} + for name, declarations in columns.items(): + if name in existing_columns: + continue + declaration = declarations.get(dialect) or declarations["sqlite"] + connection.execute(text(f"ALTER TABLE {table} ADD COLUMN {name} {declaration}")) def get_db() -> Generator[Session, None, None]: diff --git a/backend/app/main.py b/backend/app/main.py index f3577af..1ec0ced 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,16 +7,17 @@ from contextlib import asynccontextmanager from pathlib import Path -from fastapi import FastAPI, File, HTTPException, UploadFile +from fastapi import FastAPI, File, HTTPException, Request, UploadFile from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, JSONResponse, Response, StreamingResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel -from .config import ALLOWED_DOCX_CONTENT_TYPES, ALLOWED_DOCX_EXTENSIONS, APP_ENV, CORS_ALLOWED_ORIGINS, MAX_UPLOAD_SIZE_BYTES, TEMPLATE_NAME +from .config import ACCESS_CODE_COOKIE_NAME, ALLOWED_DOCX_CONTENT_TYPES, ALLOWED_DOCX_EXTENSIONS, APP_ENV, CORS_ALLOWED_ORIGINS, MAX_UPLOAD_SIZE_BYTES, TEMPLATE_NAME, access_code from .contracts import CapabilityFlags, CoverFields, HealthResponse, NormalizedThesis, PrecheckResponse, ServiceLimits, TextPrecheckRequest from .errors import AppError +from .security import verify_access_token from .services.export import export_docx from .services.parse import from_story2paper_json, normalize_text_input, parse_docx_file from .services.precheck import run_precheck @@ -58,6 +59,31 @@ async def lifespan(_app: FastAPI): ) +ACCESS_CODE_EXEMPT_PATHS = { + "/api/health", + "/api/access-code/status", + "/api/access-code/verify", +} + + +@app.middleware("http") +async def access_code_guard(request: Request, call_next): + if request.method == "OPTIONS" or not access_code(): + return await call_next(request) + if not request.url.path.startswith("/api/") or request.url.path in ACCESS_CODE_EXEMPT_PATHS: + return await call_next(request) + if verify_access_token(request.cookies.get(ACCESS_CODE_COOKIE_NAME)): + return await call_next(request) + return JSONResponse( + status_code=401, + content={ + "error_code": "ACCESS_CODE_REQUIRED", + "error_message": "需要访问码后才能使用 Workbench API。", + "details": {"access_code_required": True}, + }, + ) + + @app.exception_handler(AppError) async def handle_app_error(_request, exc: AppError): return JSONResponse( diff --git a/backend/app/models.py b/backend/app/models.py index 96bc64f..fc8cf6b 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -30,6 +30,18 @@ class ThesisProject(Base, TimestampMixin): id: Mapped[str] = mapped_column(String(64), primary_key=True) owner_id: Mapped[str | None] = mapped_column(String(64), ForeignKey("users.id"), nullable=True) title: Mapped[str] = mapped_column(String(300), default="未命名论文项目", nullable=False) + school: Mapped[str] = mapped_column(String(80), default="scnu", nullable=False) + degree_level: Mapped[str] = mapped_column(String(80), default="undergraduate", nullable=False) + template_profile: Mapped[str] = mapped_column(String(120), default="scnu-undergraduate", nullable=False) + rule_set_id: Mapped[str] = mapped_column(String(120), default="scnu-undergraduate-2025", nullable=False) + department: Mapped[str] = mapped_column(String(200), default="", nullable=False) + major: Mapped[str] = mapped_column(String(200), default="", nullable=False) + advisor: Mapped[str] = mapped_column(String(120), default="", nullable=False) + student_name: Mapped[str] = mapped_column(String(120), default="", nullable=False) + student_id: Mapped[str] = mapped_column(String(120), default="", nullable=False) + writing_stage: Mapped[str] = mapped_column(String(80), default="draft", nullable=False) + privacy_mode: Mapped[str] = mapped_column(String(80), default="local_only", nullable=False) + remote_provider_allowed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) status: Mapped[str] = mapped_column(String(40), default="active", nullable=False) current_version_id: Mapped[str | None] = mapped_column(String(64), nullable=True) deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) @@ -175,6 +187,10 @@ class ProviderConfig(Base, TimestampMixin): base_url: Mapped[str | None] = mapped_column(String(1000), nullable=True) encrypted_api_key: Mapped[str] = mapped_column(Text, default="", nullable=False) allow_local: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + verification_status: Mapped[str] = mapped_column(String(40), default="untested", nullable=False) + verification_message: Mapped[str] = mapped_column(Text, default="", nullable=False) + last_verified_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) class AuditLog(Base, TimestampMixin): diff --git a/backend/app/security.py b/backend/app/security.py new file mode 100644 index 0000000..f07764d --- /dev/null +++ b/backend/app/security.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import base64 +import hashlib +import hmac +import os + +from .config import access_code, secret_key + + +def access_token_for_current_code() -> str: + code = access_code() + if not code: + return "" + return hmac.new(secret_key().encode("utf-8"), f"access:{code}".encode("utf-8"), hashlib.sha256).hexdigest() + + +def verify_access_token(token: str | None) -> bool: + expected = access_token_for_current_code() + return bool(expected and token and hmac.compare_digest(token, expected)) + + +def verify_access_code(candidate: str) -> bool: + expected = access_code() + return bool(expected and hmac.compare_digest(candidate.strip(), expected)) + + +def seal_secret(value: str) -> str: + if not value: + return "" + nonce = os.urandom(16) + payload = value.encode("utf-8") + key = _secret_key_bytes() + stream = _keystream(key, nonce, len(payload)) + cipher = bytes(left ^ right for left, right in zip(payload, stream, strict=True)) + mac = hmac.new(key, nonce + cipher, hashlib.sha256).digest() + return "v1:" + base64.urlsafe_b64encode(nonce + mac + cipher).decode("ascii") + + +def open_secret(sealed: str) -> str: + if not sealed: + return "" + if not sealed.startswith("v1:"): + return "" + raw = base64.urlsafe_b64decode(sealed[3:].encode("ascii")) + nonce = raw[:16] + mac = raw[16:48] + cipher = raw[48:] + key = _secret_key_bytes() + expected = hmac.new(key, nonce + cipher, hashlib.sha256).digest() + if not hmac.compare_digest(mac, expected): + return "" + stream = _keystream(key, nonce, len(cipher)) + payload = bytes(left ^ right for left, right in zip(cipher, stream, strict=True)) + return payload.decode("utf-8") + + +def _secret_key_bytes() -> bytes: + return hashlib.sha256(secret_key().encode("utf-8")).digest() + + +def _keystream(key: bytes, nonce: bytes, size: int) -> bytes: + blocks: list[bytes] = [] + counter = 0 + while sum(len(block) for block in blocks) < size: + blocks.append(hmac.new(key, nonce + counter.to_bytes(8, "big"), hashlib.sha256).digest()) + counter += 1 + return b"".join(blocks)[:size] diff --git a/backend/app/workbench.py b/backend/app/workbench.py index c9c76f0..f22a0cf 100644 --- a/backend/app/workbench.py +++ b/backend/app/workbench.py @@ -1,6 +1,5 @@ from __future__ import annotations -import base64 import hashlib import ipaddress import json @@ -10,14 +9,17 @@ from pathlib import Path from typing import Any from urllib.parse import urlparse +from urllib.request import Request as UrlRequest +from urllib.request import urlopen -from fastapi import APIRouter, Depends, File, Form, UploadFile +from fastapi import APIRouter, Depends, File, Form, Request, UploadFile from fastapi.responses import Response, StreamingResponse from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.orm import Session from .contracts import CapabilityFlags, NormalizedThesis +from .config import ACCESS_CODE_COOKIE_NAME, APP_ENV, access_code, using_insecure_local_secret from .database import get_db from .errors import AppError from .models import ( @@ -36,6 +38,7 @@ ThesisVersion, ) from .parsers import parse_payload +from .security import access_token_for_current_code, seal_secret, verify_access_code, verify_access_token from .services.export_registry import export_thesis from .services.precheck import run_precheck from .storage import storage @@ -49,11 +52,43 @@ def new_id(prefix: str) -> str: class ProjectCreateRequest(BaseModel): title: str = "未命名论文项目" + department: str = "" + major: str = "" + advisor: str = "" + student_name: str = "" + student_id: str = "" + writing_stage: str = "draft" + privacy_mode: str = "local_only" + remote_provider_allowed: bool = False + + +class ProjectUpdateRequest(BaseModel): + title: str | None = None + department: str | None = None + major: str | None = None + advisor: str | None = None + student_name: str | None = None + student_id: str | None = None + writing_stage: str | None = None + privacy_mode: str | None = None + remote_provider_allowed: bool | None = None class ProjectResponse(BaseModel): id: str title: str + school: str + degree_level: str + template_profile: str + rule_set_id: str + department: str + major: str + advisor: str + student_name: str + student_id: str + writing_stage: str + privacy_mode: str + remote_provider_allowed: bool status: str current_version_id: str | None = None created_at: datetime @@ -138,6 +173,24 @@ class ProviderConfigRequest(BaseModel): allow_local: bool = False +class ProviderConfigResponse(BaseModel): + id: str + provider: str + model: str + base_url: str | None + allow_local: bool + has_api_key: bool + verification_status: str + verification_message: str + last_verified_at: datetime | None = None + created_at: datetime + updated_at: datetime + + +class AccessCodeVerifyRequest(BaseModel): + access_code: str + + class SourceSearchRequest(BaseModel): query: str @@ -153,6 +206,18 @@ def project_to_response(project: ThesisProject) -> ProjectResponse: return ProjectResponse( id=project.id, title=project.title, + school=project.school, + degree_level=project.degree_level, + template_profile=project.template_profile, + rule_set_id=project.rule_set_id, + department=project.department, + major=project.major, + advisor=project.advisor, + student_name=project.student_name, + student_id=project.student_id, + writing_stage=project.writing_stage, + privacy_mode=project.privacy_mode, + remote_provider_allowed=project.remote_provider_allowed, status=project.status, current_version_id=project.current_version_id, created_at=project.created_at, @@ -160,6 +225,22 @@ def project_to_response(project: ThesisProject) -> ProjectResponse: ) +def provider_to_response(row: ProviderConfig) -> ProviderConfigResponse: + return ProviderConfigResponse( + id=row.id, + provider=row.provider, + model=row.model, + base_url=row.base_url, + allow_local=row.allow_local, + has_api_key=bool(row.encrypted_api_key), + verification_status=row.verification_status, + verification_message=row.verification_message, + last_verified_at=row.last_verified_at, + created_at=row.created_at, + updated_at=row.updated_at, + ) + + def require_project(db: Session, project_id: str) -> ThesisProject: project = db.get(ThesisProject, project_id) if not project or project.deleted_at is not None or project.status == "deleted": @@ -182,7 +263,19 @@ def latest_version(db: Session, project: ThesisProject) -> ThesisVersion: @router.post("/projects", response_model=ProjectResponse) def create_project(request: ProjectCreateRequest, db: Session = Depends(get_db)) -> ProjectResponse: - project = ThesisProject(id=new_id("proj"), title=request.title.strip() or "未命名论文项目") + privacy_mode = _normalize_privacy_mode(request.privacy_mode) + project = ThesisProject( + id=new_id("proj"), + title=request.title.strip() or "未命名论文项目", + department=request.department.strip(), + major=request.major.strip(), + advisor=request.advisor.strip(), + student_name=request.student_name.strip(), + student_id=request.student_id.strip(), + writing_stage=_normalize_writing_stage(request.writing_stage), + privacy_mode=privacy_mode, + remote_provider_allowed=bool(request.remote_provider_allowed and privacy_mode != "local_only"), + ) db.add(project) db.add(AuditLog(id=new_id("audit"), project_id=project.id, action="project.created", target_type="project", target_id=project.id)) db.commit() @@ -203,6 +296,29 @@ def get_project(project_id: str, db: Session = Depends(get_db)) -> ProjectRespon return project_to_response(require_project(db, project_id)) +@router.patch("/projects/{project_id}", response_model=ProjectResponse) +def update_project(project_id: str, request: ProjectUpdateRequest, db: Session = Depends(get_db)) -> ProjectResponse: + project = require_project(db, project_id) + updates = request.model_dump(exclude_unset=True) + text_fields = ["title", "department", "major", "advisor", "student_name", "student_id"] + for field in text_fields: + if field in updates and updates[field] is not None: + value = str(updates[field]).strip() + setattr(project, field, value or ("未命名论文项目" if field == "title" else "")) + if request.writing_stage is not None: + project.writing_stage = _normalize_writing_stage(request.writing_stage) + if request.privacy_mode is not None: + project.privacy_mode = _normalize_privacy_mode(request.privacy_mode) + if project.privacy_mode == "local_only": + project.remote_provider_allowed = False + if request.remote_provider_allowed is not None: + project.remote_provider_allowed = bool(request.remote_provider_allowed and project.privacy_mode != "local_only") + db.add(AuditLog(id=new_id("audit"), project_id=project.id, action="project.updated", target_type="project", target_id=project.id)) + db.commit() + db.refresh(project) + return project_to_response(project) + + @router.delete("/projects/{project_id}", response_model=ProjectResponse) def delete_project(project_id: str, db: Session = Depends(get_db)) -> ProjectResponse: project = require_project(db, project_id) @@ -471,24 +587,85 @@ def list_providers() -> dict: {"id": "ollama", "name": "Ollama", "remote": False}, ], "keys_exposed": False, + "secret_storage": "insecure-local-dev" if using_insecure_local_secret() else "configured", } -@router.post("/provider-configs") -def create_provider_config(request: ProviderConfigRequest, db: Session = Depends(get_db)) -> dict: - validate_base_url(request.base_url, allow_local=request.allow_local or request.provider == "ollama") +@router.get("/provider-configs", response_model=list[ProviderConfigResponse]) +def list_provider_configs(db: Session = Depends(get_db)) -> list[ProviderConfigResponse]: + rows = db.execute(select(ProviderConfig).where(ProviderConfig.deleted_at.is_(None)).order_by(ProviderConfig.created_at.desc())).scalars().all() + return [provider_to_response(row) for row in rows] + + +@router.post("/provider-configs", response_model=ProviderConfigResponse) +def create_provider_config(request: ProviderConfigRequest, db: Session = Depends(get_db)) -> ProviderConfigResponse: + provider_id = request.provider.strip().lower() + allow_local = bool(request.allow_local and provider_id == "ollama") + validate_base_url(request.base_url, allow_local=allow_local) row = ProviderConfig( id=new_id("prov"), - provider=request.provider, - model=request.model, - base_url=request.base_url, - encrypted_api_key=_seal_secret(request.api_key), - allow_local=request.allow_local, + provider=provider_id, + model=request.model.strip(), + base_url=request.base_url.strip() if request.base_url else None, + encrypted_api_key=seal_secret(request.api_key), + allow_local=allow_local, ) db.add(row) db.add(AuditLog(id=new_id("audit"), action="provider_config.created", target_type="provider_config", target_id=row.id)) db.commit() - return {"id": row.id, "provider": row.provider, "model": row.model, "base_url": row.base_url, "has_api_key": bool(request.api_key)} + db.refresh(row) + return provider_to_response(row) + + +@router.post("/provider-configs/{config_id}/verify", response_model=ProviderConfigResponse) +def verify_provider_config(config_id: str, db: Session = Depends(get_db)) -> ProviderConfigResponse: + row = db.get(ProviderConfig, config_id) + if not row or row.deleted_at is not None: + raise AppError("PROVIDER_CONFIG_NOT_FOUND", "Provider 配置不存在或已删除。", status_code=404) + result = _verify_provider_metadata(row) + row.verification_status = result["status"] + row.verification_message = result["message"] + row.last_verified_at = datetime.now(UTC).replace(tzinfo=None) + db.add(AuditLog(id=new_id("audit"), action="provider_config.verified", target_type="provider_config", target_id=row.id, metadata_json={"status": row.verification_status})) + db.commit() + db.refresh(row) + return provider_to_response(row) + + +@router.delete("/provider-configs/{config_id}", response_model=ProviderConfigResponse) +def delete_provider_config(config_id: str, db: Session = Depends(get_db)) -> ProviderConfigResponse: + row = db.get(ProviderConfig, config_id) + if not row or row.deleted_at is not None: + raise AppError("PROVIDER_CONFIG_NOT_FOUND", "Provider 配置不存在或已删除。", status_code=404) + row.deleted_at = datetime.now(UTC).replace(tzinfo=None) + db.add(AuditLog(id=new_id("audit"), action="provider_config.deleted", target_type="provider_config", target_id=row.id)) + db.commit() + db.refresh(row) + return provider_to_response(row) + + +@router.get("/access-code/status") +def access_code_status(request: Request) -> dict: + required = bool(access_code()) + return {"required": required, "verified": (not required) or verify_access_token(request.cookies.get(ACCESS_CODE_COOKIE_NAME))} + + +@router.post("/access-code/verify") +def verify_access_code_route(request: AccessCodeVerifyRequest) -> Response: + if not access_code(): + return Response(content=json.dumps({"required": False, "verified": True}), media_type="application/json") + if not verify_access_code(request.access_code): + raise AppError("ACCESS_CODE_INVALID", "访问码不正确。", status_code=401) + response = Response(content=json.dumps({"required": True, "verified": True}), media_type="application/json") + response.set_cookie( + ACCESS_CODE_COOKIE_NAME, + access_token_for_current_code(), + httponly=True, + secure=APP_ENV == "production", + samesite="lax", + max_age=60 * 60 * 24 * 30, + ) + return response @router.post("/source-guardian/search") @@ -633,11 +810,42 @@ def _media_type_for_export(row: ExportRecord) -> str: return "application/vnd.openxmlformats-officedocument.wordprocessingml.document" -def _seal_secret(value: str) -> str: - if not value: - return "" - digest = hashlib.sha256(value.encode("utf-8")).digest() - return base64.urlsafe_b64encode(digest).decode("ascii") +def _normalize_writing_stage(value: str) -> str: + allowed = {"topic", "proposal", "draft", "revision", "final_check"} + normalized = (value or "draft").strip() + return normalized if normalized in allowed else "draft" + + +def _normalize_privacy_mode(value: str) -> str: + allowed = {"local_only", "remote_allowed"} + normalized = (value or "local_only").strip() + return normalized if normalized in allowed else "local_only" + + +def _verify_provider_metadata(row: ProviderConfig) -> dict[str, str]: + try: + validate_base_url(row.base_url, allow_local=row.allow_local) + except AppError as exc: + return {"status": "failed", "message": exc.message} + if not row.model.strip(): + return {"status": "failed", "message": "请先填写模型名称。"} + if row.provider != "ollama" and not row.encrypted_api_key: + return {"status": "failed", "message": "远程 Provider 需要服务端 API key。"} + if row.provider == "ollama" and row.base_url: + return _probe_ollama(row.base_url) + return {"status": "verified", "message": "Provider 元数据有效。未进行远程模型调用。"} + + +def _probe_ollama(base_url: str) -> dict[str, str]: + endpoint = base_url.rstrip("/") + "/api/tags" + try: + request = UrlRequest(endpoint, method="GET") + with urlopen(request, timeout=1.5) as response: + if 200 <= response.status < 500: + return {"status": "verified", "message": "Ollama 本地服务可访问。"} + except Exception as exc: + return {"status": "failed", "message": f"Ollama 本地探测失败:{exc}"} + return {"status": "failed", "message": "Ollama 本地服务返回异常状态。"} def validate_base_url(base_url: str | None, *, allow_local: bool) -> None: @@ -652,7 +860,7 @@ def validate_base_url(base_url: str | None, *, allow_local: bool) -> None: raise AppError("INVALID_PROVIDER_BASE_URL", "Provider base_url 无法解析。", status_code=400) from exc for info in infos: ip = ipaddress.ip_address(info[4][0]) - if allow_local: - continue - if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_multicast: + if ip.is_link_local or ip.is_reserved or ip.is_multicast: + raise AppError("PROVIDER_BASE_URL_BLOCKED", "Provider base_url 指向高风险地址,已被 SSRF 防护拦截。", status_code=400) + if not allow_local and (ip.is_private or ip.is_loopback): raise AppError("PROVIDER_BASE_URL_BLOCKED", "Provider base_url 指向内网或本机地址,已被 SSRF 防护拦截。", status_code=400) diff --git a/docker-compose.yml b/docker-compose.yml index 0e6a316..443dc11 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,6 +35,8 @@ services: APP_ENV: production SCNU_DATABASE_URL: postgresql+psycopg://scnu:scnu-local@postgres:5432/scnu_thesis SCNU_STORAGE_DIR: /data/storage + SCNU_ACCESS_CODE: ${SCNU_ACCESS_CODE:-} + SCNU_SECRET_KEY: ${SCNU_SECRET_KEY:-} CORS_ALLOWED_ORIGINS: http://localhost:5173 volumes: - app-storage:/data/storage @@ -54,6 +56,8 @@ services: APP_ENV: production SCNU_DATABASE_URL: postgresql+psycopg://scnu:scnu-local@postgres:5432/scnu_thesis SCNU_STORAGE_DIR: /data/storage + SCNU_ACCESS_CODE: ${SCNU_ACCESS_CODE:-} + SCNU_SECRET_KEY: ${SCNU_SECRET_KEY:-} volumes: - app-storage:/data/storage depends_on: diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..58c4f93 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,118 @@ +# API Reference + +This document covers the Workbench-facing API surface. All endpoints are under `/api`. + +## Access Code + +When `SCNU_ACCESS_CODE` is set, all `/api/*` routes require the access cookie except: + +- `GET /api/health` +- `GET /api/access-code/status` +- `POST /api/access-code/verify` + +### `GET /api/access-code/status` + +Returns: + +```json +{ + "required": true, + "verified": false +} +``` + +### `POST /api/access-code/verify` + +Request: + +```json +{ + "access_code": "..." +} +``` + +On success, the backend sets an HttpOnly cookie and returns `verified=true`. + +## Projects + +### `POST /api/projects` + +Creates an SCNU undergraduate project. The server defaults are: + +- `school=scnu` +- `degree_level=undergraduate` +- `template_profile=scnu-undergraduate` +- `rule_set_id=scnu-undergraduate-2025` +- `privacy_mode=local_only` +- `remote_provider_allowed=false` + +Request fields: + +- `title` +- `department` +- `major` +- `advisor` +- `student_name` +- `student_id` +- `writing_stage`: `topic`, `proposal`, `draft`, `revision`, `final_check` +- `privacy_mode`: `local_only`, `remote_allowed` +- `remote_provider_allowed` + +### `PATCH /api/projects/{id}` + +Updates project metadata and privacy settings. If `privacy_mode=local_only`, the server forces `remote_provider_allowed=false`. + +## Provider Configs + +### `GET /api/providers` + +Returns Provider metadata only. API keys are never included. + +### `GET /api/provider-configs` + +Returns saved Provider configs with: + +- `provider` +- `model` +- `base_url` +- `allow_local` +- `has_api_key` +- `verification_status` +- `verification_message` + +Raw API keys are never returned. + +### `POST /api/provider-configs` + +Stores a Provider config and seals `api_key` server-side. + +Remote Providers reject private, loopback, link-local, reserved, and multicast `base_url` targets. Ollama can use local loopback/private addresses only when `allow_local=true`; link-local, reserved, and multicast targets remain blocked. + +### `POST /api/provider-configs/{id}/verify` + +Runs metadata verification only. It does not call a remote LLM and does not send论文正文. + +- Remote Providers require a model and saved API key. +- Ollama may perform a short local `/api/tags` probe when a base URL is configured. + +### `DELETE /api/provider-configs/{id}` + +Soft-deletes the Provider config. + +## Existing Workbench APIs + +- `POST /api/projects/{id}/files` +- `GET /api/projects/{id}/files` +- `POST /api/projects/{id}/parse-jobs` +- `GET /api/jobs/{id}` +- `GET /api/jobs/{id}/events` +- `GET /api/jobs/{id}/events/stream` +- `GET /api/projects/{id}/versions` +- `GET /api/projects/{id}/issues` +- `GET /api/projects/{id}/proposals` +- `POST /api/proposals/{id}/accept` +- `POST /api/proposals/{id}/reject` +- `POST /api/proposals/{id}/stash` +- `POST /api/projects/{id}/exports` +- `GET /api/projects/{id}/exports` +- `GET /api/exports/{id}/download` diff --git a/docs/compliance/scnu-undergraduate-export-implementation-record-v1.md b/docs/compliance/scnu-undergraduate-export-implementation-record-v1.md index 14c3dca..ae74f47 100644 --- a/docs/compliance/scnu-undergraduate-export-implementation-record-v1.md +++ b/docs/compliance/scnu-undergraduate-export-implementation-record-v1.md @@ -29,7 +29,7 @@ - `uv run pytest tests -q` 通过 - `npm run test:smoke --prefix web` 通过 - `npm run build --prefix web` 通过 - - `python3 scripts/build_web_public.py` 通过 + - `uv run python scripts/build_web_public.py` 通过 - 合规脚本: - `sample-export.docx`:`PASS` - `missing-export.docx`:`PASS` diff --git a/docs/deploy-vercel.md b/docs/deploy-vercel.md index fedf317..f8fe6bb 100644 --- a/docs/deploy-vercel.md +++ b/docs/deploy-vercel.md @@ -4,7 +4,7 @@ ## Vercel 构建链 -- `buildCommand`:`npm ci --prefix web && python3 scripts/build_web_public.py` +- `buildCommand`:`npm ci --prefix web && uv run python scripts/build_web_public.py` - `outputDirectory`:`public` - API 入口:`/index` @@ -22,7 +22,7 @@ 1. 首页输入与预检可用 2. `.docx` 上传和文本输入都可走通 3. 可导出 `.docx` -4. 下载结果通过 `scripts/check_docx_compliance.py` +4. 下载结果通过 `uv run python scripts/check_docx_compliance.py` 5. 抽查正式封面、目录、页眉页脚和页码 ## Workbench 自托管 @@ -46,6 +46,8 @@ docker compose up --build - `SCNU_DATABASE_URL`:默认本地 SQLite;Compose 使用 Postgres - `SCNU_STORAGE_DIR`:默认 `outputs/storage` +- `SCNU_ACCESS_CODE`:启用私有部署访问码保护 +- `SCNU_SECRET_KEY`:用于服务端封存 Provider key - `VITE_API_BASE_URL`:前端访问 API 的地址 - `CORS_ALLOWED_ORIGINS`:前端源白名单 @@ -59,3 +61,4 @@ docker compose up --build 6. 删除项目后,导出文件下载地址返回不可访问 7. Provider 配置接口不返回 API key 8. 非 Ollama Provider 的内网 base URL 被拒绝 +9. 配置 `SCNU_ACCESS_CODE` 后,未验证请求被拒绝 diff --git a/docs/known-limitations-word-export.md b/docs/known-limitations-word-export.md index 3da0b2e..34670ba 100644 --- a/docs/known-limitations-word-export.md +++ b/docs/known-limitations-word-export.md @@ -10,6 +10,7 @@ - Workbench 项目、文件、版本、导出记录的本地持久化 - 未确认 Proposal 不会改变当前导出版本 - Provider 配置不向前端返回密钥,自定义 base URL 有 SSRF 防护 +- 项目级隐私模式、远程 Provider 授权提示和访问码保护 ## 仍需人工复核 @@ -38,7 +39,8 @@ - MinIO/S3 SDK 尚未替换本地文件系统 adapter - PDF 转换目前降级保留 `.docx` 产物 - 真实 LLM Provider 调用尚未接入 Agent Runtime -- Alembic 迁移脚本尚未建立,当前用 SQLAlchemy metadata 初始化 +- Access code 是单机/私有部署保护,不是多用户权限系统 +- Alembic 迁移脚本尚未建立,当前用 SQLAlchemy metadata 初始化和幂等 schema bootstrap ## 结论 diff --git a/docs/local-validation-word.md b/docs/local-validation-word.md index 6ccd155..a6f71e3 100644 --- a/docs/local-validation-word.md +++ b/docs/local-validation-word.md @@ -6,7 +6,7 @@ 2. 检查上传 `.docx` 与粘贴文本入口都可用 3. 触发预检,确认弹窗展示“缺失章节留白”和“复杂元素需人工复核” 4. 通过预检后导出 `.docx` -5. 运行 `python3 scripts/check_docx_compliance.py <导出文件>` +5. 运行 `uv run python scripts/check_docx_compliance.py <导出文件>` 6. 用 Word 打开并更新目录 ## 重点抽查 diff --git a/docs/product-mainline-word-v1.md b/docs/product-mainline-word-v1.md index 547dc19..97b0139 100644 --- a/docs/product-mainline-word-v1.md +++ b/docs/product-mainline-word-v1.md @@ -3,7 +3,7 @@ 当前产品保留两条入口: - 快速入口:`规范驱动的章节映射 + Word 模板渲染` -- Workbench v1:`项目空间 + 文件库 + 版本 + 建议队列 + 可追溯导出` +- Workbench v1:`项目向导 + 隐私模式 + 文件库 + 版本 + 建议队列 + 可追溯导出` 它不再把导出结果定义为“正文审查稿”,而是把 `.docx` 结果作为华南师范大学本科毕业论文送审稿基线。智能辅助只生成候选建议;未确认内容不得改变当前导出版本。 @@ -28,7 +28,7 @@ ## Workbench 路径 1. 打开 `#/workbench` -2. 创建论文项目 +2. 通过项目向导创建论文项目并选择写作阶段、隐私模式 3. 上传 `.docx`、PDF、文本、图片/OCR 占位或参考文献文件 4. 后端保存 `ProjectFile` 并写入本地对象存储 5. parser registry 解析材料并生成 `NormalizedThesis v2` @@ -69,6 +69,6 @@ - 数据层:SQLAlchemy 模型覆盖项目、文件、来源、版本、blocks、issues、proposals、approvals、agent runs/events、exports、provider configs、audit logs - 存储层:默认 SQLite + 本地文件系统,Docker Compose 预留 Postgres、Redis、MinIO -- 安全层:Provider key 不返回前端;自定义 base URL 默认拦截内网、本机和 link-local 地址;Ollama 需显式允许本地访问 +- 安全层:Provider key 不返回前端;访问码可保护私有部署;自定义 base URL 默认拦截内网、本机和 link-local 地址;Ollama 需显式允许本地访问 - 事件层:支持 REST 事件列表和 SSE 事件流端点 -- UI 层:三栏 Workbench 壳,覆盖文件区、文档预览、版本历史、Agent 面板、建议队列和导出历史 +- UI 层:三栏 Workbench 壳,覆盖项目向导、项目设置、Provider 设置、隐私提示、文件区、文档预览、版本历史、Agent 面板、建议队列和导出历史 diff --git a/docs/quality-checklist-compliance.md b/docs/quality-checklist-compliance.md index 691004f..5429395 100644 --- a/docs/quality-checklist-compliance.md +++ b/docs/quality-checklist-compliance.md @@ -30,8 +30,9 @@ - `uv run pytest tests -q` - `npm run test:smoke --prefix web` - `npm run build --prefix web` -- `python3 scripts/build_web_public.py` -- `python3 scripts/check_docx_compliance.py ` +- `uv run python scripts/build_web_public.py` +- `uv run python scripts/export_compliance_fixture.py tmp/fixture-export.docx` +- `uv run python scripts/check_docx_compliance.py tmp/fixture-export.docx` ## 人工复核 diff --git a/docs/quality-checklist-v2.md b/docs/quality-checklist-v2.md index 7641265..5c90a81 100644 --- a/docs/quality-checklist-v2.md +++ b/docs/quality-checklist-v2.md @@ -13,6 +13,8 @@ ## Workbench - 可创建项目并上传文件 +- 项目创建向导默认本地优先,并显示远程 Provider 授权提示 +- 项目设置可修改写作阶段、隐私模式和远程授权 - 文件记录包含类型、hash、storage key、parser、source label - 解析任务生成 baseline version、Issue Ledger、Agent events - Proposal 默认 pending,接受前不影响当前导出版本 @@ -32,6 +34,8 @@ ## 安全 - Provider key 不返回前端 +- Provider 设置页只显示 `has_api_key`、模型、base URL 和验证状态 +- 配置 `SCNU_ACCESS_CODE` 时,未验证请求必须被 API 拒绝 - 自定义 base URL 默认拒绝内网、本机、link-local 和保留地址 - Ollama 本地地址必须显式允许 - 日志和 Agent event 不应存储原始正文全文作为调试输出 @@ -41,5 +45,6 @@ - `uv run pytest tests -q` - `npm run test:smoke --prefix web` - `npm run build --prefix web` -- `python3 scripts/build_web_public.py` -- `python3 scripts/check_docx_compliance.py ` +- `uv run python scripts/build_web_public.py` +- `uv run python scripts/export_compliance_fixture.py tmp/fixture-export.docx` +- `uv run python scripts/check_docx_compliance.py tmp/fixture-export.docx` diff --git a/docs/workbench-v1.md b/docs/workbench-v1.md index 85b21a0..dd2a6f5 100644 --- a/docs/workbench-v1.md +++ b/docs/workbench-v1.md @@ -6,7 +6,7 @@ Workbench v1 是当前仓库的可运行 MVP 骨架,用来把论文材料放 ## 当前能力 -- 项目空间:创建、查询、删除论文项目 +- 项目空间:创建向导、查询、设置、删除论文项目 - 文件库:上传 `.docx`、PDF、文本、图片/OCR 占位、参考文献文件 - 本地对象存储:默认写入 `outputs/storage` - 数据层:默认 SQLite,Docker Compose 预留 Postgres @@ -16,7 +16,9 @@ Workbench v1 是当前仓库的可运行 MVP 骨架,用来把论文材料放 - Proposal 队列:接受、拒绝、暂存 - Agent events:支持事件列表和 SSE 事件流 - 导出记录:`.docx`、Markdown、自检报告,PDF 当前降级记录 -- Provider 配置:OpenAI、Gemini、DeepSeek、MiniMax、Ollama 元数据,服务端密钥不返回前端 +- Provider 配置:OpenAI、Gemini、DeepSeek、MiniMax、Ollama 元数据,服务端密钥保存、验证状态与前端脱敏 +- 隐私模式:默认本地优先,远程 Provider 需要项目级显式授权 +- 访问码保护:私有部署可通过 `SCNU_ACCESS_CODE` 保护 `/api/*` ## 本地运行 @@ -46,6 +48,7 @@ docker compose up --build - `POST /api/projects` - `GET /api/projects` - `GET /api/projects/{id}` +- `PATCH /api/projects/{id}` - `DELETE /api/projects/{id}` - `POST /api/projects/{id}/files` - `GET /api/projects/{id}/files` @@ -62,7 +65,12 @@ docker compose up --build - `POST /api/projects/{id}/exports` - `GET /api/projects/{id}/exports` - `GET /api/providers` +- `GET /api/provider-configs` - `POST /api/provider-configs` +- `POST /api/provider-configs/{id}/verify` +- `DELETE /api/provider-configs/{id}` +- `GET /api/access-code/status` +- `POST /api/access-code/verify` - `POST /api/source-guardian/search` - `POST /api/source-guardian/confirm` @@ -72,9 +80,10 @@ docker compose up --build - Story2Paper 实验正文不会直接写入默认导出版本 - 参考文献只整理已有文本,不补造作者、刊名、卷期、DOI - 未确认联网来源不影响 Compliance Auditor 结论 -- API key 不返回前端;当前实现只保存密钥摘要占位 +- API key 不返回前端;当前实现服务端封存密钥,前端只看到 `has_api_key` - 自定义 Provider `base_url` 默认拦截内网、本机、link-local、保留和组播地址 -- Ollama 本地地址必须通过 `allow_local` 显式允许 +- Link-local、reserved、multicast 地址始终拦截;Ollama 本地地址必须通过 `allow_local` 显式允许 +- 设置 `SCNU_SECRET_KEY` 后使用部署密钥封存 Provider key;未设置时仅使用开发环境 insecure local key ## 当前限制 @@ -84,7 +93,7 @@ docker compose up --build - 图片 OCR 是占位入口,需后续接本地或远程 OCR Provider - PDF 导出当前保留 `.docx` 并记录转换降级 - 真实 LLM Provider 调用尚未接入 Agent Runtime -- Alembic 迁移脚本尚未建立 +- Alembic 迁移脚本尚未建立;当前仅有幂等 schema bootstrap 补齐新增列 ## 验收命令 @@ -92,6 +101,7 @@ docker compose up --build uv run pytest tests -q npm run test:smoke --prefix web npm run build --prefix web -python3 scripts/build_web_public.py -python3 scripts/check_docx_compliance.py +uv run python scripts/build_web_public.py +uv run python scripts/export_compliance_fixture.py tmp/fixture-export.docx +uv run python scripts/check_docx_compliance.py tmp/fixture-export.docx ``` diff --git a/scripts/export_compliance_fixture.py b/scripts/export_compliance_fixture.py new file mode 100644 index 0000000..06eba86 --- /dev/null +++ b/scripts/export_compliance_fixture.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import argparse +from pathlib import Path + +from backend.app.contracts import CapabilityFlags +from backend.app.services.export import export_docx +from backend.app.services.parse import normalize_text_input + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +DEFAULT_SOURCE = PROJECT_ROOT / "examples" / "compliance" / "sample-text-basic.md" + + +def build_fixture(output_path: Path, source_path: Path = DEFAULT_SOURCE) -> Path: + thesis = normalize_text_input(source_path.read_text(encoding="utf-8"), CapabilityFlags(docx_export=True, profile="undergraduate")) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_bytes(export_docx(thesis)) + return output_path + + +def main() -> int: + parser = argparse.ArgumentParser(description="Export a SCNU DOCX fixture for compliance CI.") + parser.add_argument("output_path", type=Path) + parser.add_argument("--source", type=Path, default=DEFAULT_SOURCE) + args = parser.parse_args() + path = build_fixture(args.output_path, args.source) + print(path) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_workbench.py b/tests/test_workbench.py index 94f360d..2aa46c2 100644 --- a/tests/test_workbench.py +++ b/tests/test_workbench.py @@ -70,6 +70,42 @@ def test_project_file_parse_proposal_and_export_flow(): assert export.json()["filename"].endswith(".md") +def test_project_defaults_and_patch_metadata(): + with TestClient(app) as client: + project_response = client.post( + "/api/projects", + json={ + "title": "阶段一项目", + "department": "软件学院", + "major": "软件工程", + "advisor": "李老师", + "student_name": "张三", + "student_id": "2020123456", + }, + ) + assert project_response.status_code == 200 + project = project_response.json() + assert project["school"] == "scnu" + assert project["degree_level"] == "undergraduate" + assert project["template_profile"] == "scnu-undergraduate" + assert project["privacy_mode"] == "local_only" + assert project["remote_provider_allowed"] is False + + patched = client.patch( + f"/api/projects/{project['id']}", + json={"writing_stage": "revision", "privacy_mode": "remote_allowed", "remote_provider_allowed": True}, + ) + assert patched.status_code == 200 + payload = patched.json() + assert payload["writing_stage"] == "revision" + assert payload["privacy_mode"] == "remote_allowed" + assert payload["remote_provider_allowed"] is True + + local_only = client.patch(f"/api/projects/{project['id']}", json={"privacy_mode": "local_only", "remote_provider_allowed": True}) + assert local_only.status_code == 200 + assert local_only.json()["remote_provider_allowed"] is False + + def test_project_delete_removes_export_access(): with TestClient(app) as client: project = client.post("/api/projects", json={"title": "删除测试"}).json() @@ -103,6 +139,51 @@ def test_provider_config_redacts_key_and_blocks_private_base_url(): assert allowed.status_code == 200 assert "secret" not in allowed.text assert allowed.json()["has_api_key"] is True + assert allowed.json()["allow_local"] is True + + configs = client.get("/api/provider-configs") + assert configs.status_code == 200 + assert all("secret" not in str(item) for item in configs.json()) + + verify = client.post(f"/api/provider-configs/{allowed.json()['id']}/verify") + assert verify.status_code == 200 + assert verify.json()["verification_status"] in {"verified", "failed"} + + deleted = client.delete(f"/api/provider-configs/{allowed.json()['id']}") + assert deleted.status_code == 200 + assert client.get("/api/provider-configs").json() == [] + + +def test_provider_config_remote_verify_requires_key(): + with TestClient(app) as client: + created = client.post("/api/provider-configs", json={"provider": "openai", "model": "gpt-test", "api_key": ""}) + assert created.status_code == 200 + verify = client.post(f"/api/provider-configs/{created.json()['id']}/verify") + + assert verify.status_code == 200 + assert verify.json()["verification_status"] == "failed" + assert "API key" in verify.json()["verification_message"] + + +def test_access_code_guard_requires_and_accepts_code(monkeypatch): + monkeypatch.setenv("SCNU_ACCESS_CODE", "phase-one") + with TestClient(app) as client: + status = client.get("/api/access-code/status") + assert status.status_code == 200 + assert status.json() == {"required": True, "verified": False} + + blocked = client.get("/api/projects") + assert blocked.status_code == 401 + + wrong = client.post("/api/access-code/verify", json={"access_code": "wrong"}) + assert wrong.status_code == 401 + + verified = client.post("/api/access-code/verify", json={"access_code": "phase-one"}) + assert verified.status_code == 200 + assert verified.json()["verified"] is True + + assert client.get("/api/access-code/status").json() == {"required": True, "verified": True} + assert client.get("/api/projects").status_code == 200 def test_source_guardian_unconfirmed_search_does_not_affect_auditor(): diff --git a/web/src/app/api.ts b/web/src/app/api.ts index ea3e4c2..f857e31 100644 --- a/web/src/app/api.ts +++ b/web/src/app/api.ts @@ -173,12 +173,36 @@ export function precheckFromStory2Paper(schemaData: object, cover: import("../ge export interface ThesisProject { id: string; title: string; + school: string; + degree_level: string; + template_profile: string; + rule_set_id: string; + department: string; + major: string; + advisor: string; + student_name: string; + student_id: string; + writing_stage: string; + privacy_mode: string; + remote_provider_allowed: boolean; status: string; current_version_id: string | null; created_at: string; updated_at: string; } +export interface ProjectDraft { + title: string; + department?: string; + major?: string; + advisor?: string; + student_name?: string; + student_id?: string; + writing_stage?: string; + privacy_mode?: string; + remote_provider_allowed?: boolean; +} + export interface ProjectFileRecord { id: string; project_id: string; @@ -240,11 +264,32 @@ export interface ExportRecord { created_at: string; } -export function createProject(title: string) { +export interface ProviderOption { + id: string; + name: string; + remote: boolean; +} + +export interface ProviderConfigRecord { + id: string; + provider: string; + model: string; + base_url: string | null; + allow_local: boolean; + has_api_key: boolean; + verification_status: string; + verification_message: string; + last_verified_at: string | null; + created_at: string; + updated_at: string; +} + +export function createProject(input: string | ProjectDraft) { + const payload = typeof input === "string" ? { title: input } : input; return jsonRequest("/api/projects", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ title }), + body: JSON.stringify(payload), }); } @@ -252,6 +297,14 @@ export function listProjects() { return jsonRequest("/api/projects"); } +export function updateProject(projectId: string, patch: Partial) { + return jsonRequest(`/api/projects/${projectId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + }); +} + export function uploadProjectFile(projectId: string, file: File, fileType = "docx") { const form = new FormData(); form.append("file", file); @@ -306,7 +359,43 @@ export function listProjectExports(projectId: string) { } export function getProviders() { - return jsonRequest<{ providers: Array<{ id: string; name: string; remote: boolean }>; keys_exposed: boolean }>("/api/providers"); + return jsonRequest<{ providers: ProviderOption[]; keys_exposed: boolean; secret_storage: string }>("/api/providers"); +} + +export function listProviderConfigs() { + return jsonRequest("/api/provider-configs"); +} + +export function saveProviderConfig(payload: { provider: string; model: string; base_url?: string; api_key?: string; allow_local?: boolean }) { + return jsonRequest("/api/provider-configs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); +} + +export function verifyProviderConfig(configId: string) { + return jsonRequest(`/api/provider-configs/${configId}/verify`, { + method: "POST", + }); +} + +export function deleteProviderConfig(configId: string) { + return jsonRequest(`/api/provider-configs/${configId}`, { + method: "DELETE", + }); +} + +export function getAccessCodeStatus() { + return jsonRequest<{ required: boolean; verified: boolean }>("/api/access-code/status"); +} + +export function verifyAccessCode(accessCode: string) { + return jsonRequest<{ required: boolean; verified: boolean }>("/api/access-code/verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ access_code: accessCode }), + }); } export function exportDownloadUrl(exportId: string) { diff --git a/web/src/styles/features.css b/web/src/styles/features.css index de955e9..6feb58f 100644 --- a/web/src/styles/features.css +++ b/web/src/styles/features.css @@ -160,6 +160,42 @@ color: #171b22; } +.workbench-gate { + min-height: 100vh; + display: grid; + place-items: center; + padding: 24px; + background: #f7f8fb; +} + +.workbench-gate-card, +.workbench-empty-state { + width: min(100%, 760px); + border: 1px solid #d9dee8; + border-radius: 8px; + background: #fff; + padding: 24px; + box-shadow: 0 20px 48px rgba(83, 99, 122, 0.1); +} + +.workbench-gate-card { + display: grid; + gap: 14px; +} + +.workbench-empty-state { + margin: 24px auto; + display: grid; + gap: 20px; +} + +.workbench-empty-state .eyebrow { + margin: 0 0 6px; + color: #65758a; + font-size: 13px; + text-transform: uppercase; +} + .workbench-topbar { display: flex; justify-content: space-between; @@ -191,7 +227,8 @@ } .workbench-status span, -.proposal-status { +.proposal-status, +.model-status { border: 1px solid #cfd7e4; border-radius: 8px; padding: 6px 9px; @@ -199,6 +236,22 @@ font-size: 13px; } +.model-status-local { + background: #f2f8f3; + border-color: #bfdbc8; +} + +.model-status-ready { + background: #eef8ff; + border-color: #bad8ee; +} + +.model-status-warn, +.workbench-message.is-warning { + background: #fff8e8; + border-color: #ead8a8; +} + .workbench-grid { display: grid; grid-template-columns: minmax(220px, 280px) minmax(0, 1fr) minmax(280px, 360px); @@ -232,7 +285,11 @@ .workbench-panel button, .workbench-document button, .workbench-upload, -.workbench-list a { +.workbench-list a, +.project-wizard button, +.project-settings button, +.provider-settings button, +.workbench-gate-card button { border: 1px solid #cfd7e4; border-radius: 8px; background: #f8fafc; @@ -241,6 +298,22 @@ text-decoration: none; } +.project-wizard button, +.project-settings button, +.provider-settings button, +.workbench-gate-card button { + justify-self: start; +} + +.workbench-panel button:disabled, +.workbench-document button:disabled, +.project-wizard button:disabled, +.project-settings button:disabled, +.provider-settings button:disabled, +.workbench-gate-card button:disabled { + opacity: 0.55; +} + .workbench-list, .workbench-timeline, .workbench-events, @@ -262,6 +335,11 @@ gap: 3px; } +.workbench-list button span { + color: #65758a; + font-size: 12px; +} + .workbench-list button.is-active { border-color: #5f7fa6; background: #edf4fb; @@ -286,11 +364,39 @@ } .workbench-message { + border: 1px solid #d6e4f3; border-radius: 8px; background: #edf4fb; padding: 10px 12px; } +.workbench-message.is-error { + border-color: rgba(220, 170, 174, 0.44); + background: rgba(255, 250, 251, 0.86); + color: #9a5f64; +} + +.privacy-banner { + display: grid; + gap: 4px; + border: 1px solid #cfe0d4; + border-radius: 8px; + background: #f3faf5; + padding: 12px; + margin-bottom: 14px; +} + +.privacy-banner-remote { + border-color: #d9c996; + background: #fff8e8; +} + +.privacy-banner p { + margin: 0; + color: #65758a; + font-size: 13px; +} + .workbench-preview, .workbench-blocks section, .workbench-proposals article, @@ -318,6 +424,87 @@ color: #65758a; } +.project-wizard, +.project-settings, +.provider-settings, +.provider-settings form, +.provider-config-list { + display: grid; + gap: 12px; +} + +.project-settings, +.provider-settings { + border-top: 1px solid #e2e7ef; + margin-top: 18px; + padding-top: 16px; +} + +.workbench-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.project-wizard label, +.project-settings label, +.provider-settings label, +.workbench-gate-card label { + display: grid; + gap: 5px; + color: #2d3748; + font-size: 13px; +} + +.project-wizard input, +.project-wizard select, +.project-settings input, +.project-settings select, +.provider-settings input, +.provider-settings select, +.workbench-gate-card input { + min-width: 0; + border: 1px solid #cfd7e4; + border-radius: 8px; + padding: 8px 10px; + background: #fff; + color: #171b22; +} + +.privacy-choice { + display: grid; + gap: 8px; + border: 1px solid #e2e7ef; + border-radius: 8px; + padding: 10px; +} + +.privacy-choice legend { + color: #65758a; + font-size: 13px; +} + +.privacy-choice label, +.checkbox-line { + display: flex; + align-items: center; + gap: 8px; +} + +.provider-config-list article { + display: grid; + gap: 6px; + border: 1px solid #e2e7ef; + border-radius: 8px; + padding: 12px; +} + +.provider-config-list article div { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + @media (max-width: 980px) { .workbench-grid { grid-template-columns: 1fr; @@ -327,4 +514,8 @@ .workbench-document { min-height: auto; } + + .workbench-form-grid { + grid-template-columns: 1fr; + } } diff --git a/web/src/workbench/AccessCodeGate.tsx b/web/src/workbench/AccessCodeGate.tsx new file mode 100644 index 0000000..ab72271 --- /dev/null +++ b/web/src/workbench/AccessCodeGate.tsx @@ -0,0 +1,46 @@ +import { useState, type FormEvent } from "react"; +import { ApiError, verifyAccessCode } from "../app/api"; + +type AccessCodeGateProps = { + onVerified: () => void; +}; + +export function AccessCodeGate({ onVerified }: AccessCodeGateProps) { + const [accessCode, setAccessCode] = useState(""); + const [message, setMessage] = useState(null); + const [busy, setBusy] = useState(false); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + setBusy(true); + setMessage(null); + try { + const result = await verifyAccessCode(accessCode); + if (result.verified) onVerified(); + } catch (error) { + setMessage(error instanceof ApiError ? error.message : "访问码验证失败。"); + } finally { + setBusy(false); + } + } + + return ( +
+
void handleSubmit(event)}> + + 返回快速导出 + +

SCNU Thesis Agent Workbench

+

该部署已启用访问码保护。验证后再进入项目、文件和 Provider 设置。

+ + {message ?

{message}

: null} + +
+
+ ); +} diff --git a/web/src/workbench/ModelStatusBadge.tsx b/web/src/workbench/ModelStatusBadge.tsx new file mode 100644 index 0000000..b1eee04 --- /dev/null +++ b/web/src/workbench/ModelStatusBadge.tsx @@ -0,0 +1,18 @@ +import type { ProviderConfigRecord, ThesisProject } from "../app/api"; + +type ModelStatusBadgeProps = { + project: ThesisProject | null; + configs: ProviderConfigRecord[]; +}; + +export function ModelStatusBadge({ project, configs }: ModelStatusBadgeProps) { + const remoteAllowed = project?.remote_provider_allowed ?? false; + const verifiedConfigs = configs.filter((item) => item.verification_status === "verified"); + if (!remoteAllowed) { + return 本地模式; + } + if (verifiedConfigs.length > 0) { + return 远程已授权 · {verifiedConfigs[0].provider}; + } + return 远程已授权 · Provider 待验证; +} diff --git a/web/src/workbench/PrivacyConsentBanner.tsx b/web/src/workbench/PrivacyConsentBanner.tsx new file mode 100644 index 0000000..32dd1ce --- /dev/null +++ b/web/src/workbench/PrivacyConsentBanner.tsx @@ -0,0 +1,20 @@ +import type { ThesisProject } from "../app/api"; + +type PrivacyConsentBannerProps = { + project: ThesisProject | null; +}; + +export function PrivacyConsentBanner({ project }: PrivacyConsentBannerProps) { + if (!project) return null; + const remoteAllowed = project.remote_provider_allowed && project.privacy_mode === "remote_allowed"; + return ( +
+ {remoteAllowed ? "远程模型已授权" : "本地优先模式"} +

+ {remoteAllowed + ? "该项目允许把经用户确认的任务内容发送到已配置 Provider。密钥仍只保存在服务端,前端不会读取。" + : "默认不会把真实论文内容发送到远程模型。需要远程协作时,请先在项目设置中显式授权。"} +

+
+ ); +} diff --git a/web/src/workbench/ProjectSettings.tsx b/web/src/workbench/ProjectSettings.tsx new file mode 100644 index 0000000..3153ef9 --- /dev/null +++ b/web/src/workbench/ProjectSettings.tsx @@ -0,0 +1,104 @@ +import { useEffect, useState, type FormEvent } from "react"; +import type { ProjectDraft, ThesisProject } from "../app/api"; + +type ProjectSettingsProps = { + project: ThesisProject | null; + busy?: boolean; + onSave: (patch: Partial) => Promise; +}; + +export function ProjectSettings({ project, busy = false, onSave }: ProjectSettingsProps) { + const [draft, setDraft] = useState>({}); + + useEffect(() => { + if (!project) return; + setDraft({ + title: project.title, + department: project.department, + major: project.major, + advisor: project.advisor, + student_name: project.student_name, + student_id: project.student_id, + writing_stage: project.writing_stage, + privacy_mode: project.privacy_mode, + remote_provider_allowed: project.remote_provider_allowed, + }); + }, [project]); + + if (!project) return null; + + function update(key: K, value: ProjectDraft[K]) { + setDraft((current) => { + const next = { ...current, [key]: value }; + if (key === "privacy_mode" && value === "local_only") { + next.remote_provider_allowed = false; + } + return next; + }); + } + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + await onSave({ + ...draft, + remote_provider_allowed: draft.privacy_mode === "remote_allowed" && !!draft.remote_provider_allowed, + }); + } + + return ( +
void handleSubmit(event)}> +

项目设置

+ +
+ + + + +
+
+ 远程模型授权 + + + +
+ +
+ ); +} diff --git a/web/src/workbench/ProjectWizard.tsx b/web/src/workbench/ProjectWizard.tsx new file mode 100644 index 0000000..66a3972 --- /dev/null +++ b/web/src/workbench/ProjectWizard.tsx @@ -0,0 +1,107 @@ +import { useState, type FormEvent } from "react"; +import type { ProjectDraft } from "../app/api"; + +type ProjectWizardProps = { + onCreate: (draft: ProjectDraft) => Promise; + busy?: boolean; +}; + +const DEFAULT_DRAFT: ProjectDraft = { + title: "", + department: "", + major: "", + advisor: "", + student_name: "", + student_id: "", + writing_stage: "draft", + privacy_mode: "local_only", + remote_provider_allowed: false, +}; + +export function ProjectWizard({ onCreate, busy = false }: ProjectWizardProps) { + const [draft, setDraft] = useState(DEFAULT_DRAFT); + + function update(key: K, value: ProjectDraft[K]) { + setDraft((current) => { + const next = { ...current, [key]: value }; + if (key === "privacy_mode" && value === "local_only") { + next.remote_provider_allowed = false; + } + return next; + }); + } + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + await onCreate({ + ...draft, + title: draft.title.trim() || "未命名论文项目", + remote_provider_allowed: draft.privacy_mode === "remote_allowed" && !!draft.remote_provider_allowed, + }); + setDraft(DEFAULT_DRAFT); + } + + return ( +
void handleSubmit(event)}> + +
+ + + + + + +
+
+ 隐私模式 + + + +
+ +
+ ); +} diff --git a/web/src/workbench/ProviderSettings.tsx b/web/src/workbench/ProviderSettings.tsx new file mode 100644 index 0000000..f6f1dac --- /dev/null +++ b/web/src/workbench/ProviderSettings.tsx @@ -0,0 +1,94 @@ +import { useState, type FormEvent } from "react"; +import type { ProviderConfigRecord, ProviderOption } from "../app/api"; + +type ProviderSettingsProps = { + providers: ProviderOption[]; + configs: ProviderConfigRecord[]; + remoteAllowed: boolean; + busy?: boolean; + onSave: (payload: { provider: string; model: string; base_url?: string; api_key?: string; allow_local?: boolean }) => Promise; + onVerify: (configId: string) => Promise; + onDelete: (configId: string) => Promise; +}; + +export function ProviderSettings({ providers, configs, remoteAllowed, busy = false, onSave, onVerify, onDelete }: ProviderSettingsProps) { + const [provider, setProvider] = useState("ollama"); + const [model, setModel] = useState(""); + const [baseUrl, setBaseUrl] = useState(""); + const [apiKey, setApiKey] = useState(""); + const [allowLocal, setAllowLocal] = useState(true); + const selected = providers.find((item) => item.id === provider); + const isRemote = selected?.remote ?? false; + const disabledByPrivacy = isRemote && !remoteAllowed; + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + await onSave({ + provider, + model, + base_url: baseUrl || undefined, + api_key: apiKey || undefined, + allow_local: provider === "ollama" && allowLocal, + }); + setApiKey(""); + } + + return ( +
+

Provider 设置

+
void handleSubmit(event)}> +
+ + + + +
+ + {disabledByPrivacy ?

当前项目未授权远程模型,请先在项目设置中开启。

: null} + +
+ +
+ {configs.map((config) => ( +
+ {config.verification_status} + {config.provider} · {config.model} +

{config.base_url || "默认 Provider 地址"} · {config.has_api_key ? "已保存 API key" : "未保存 API key"}

+ {config.verification_message ? {config.verification_message} : null} +
+ + +
+
+ ))} +
+
+ ); +} diff --git a/web/src/workbench/WorkbenchApp.test.tsx b/web/src/workbench/WorkbenchApp.test.tsx new file mode 100644 index 0000000..429e35f --- /dev/null +++ b/web/src/workbench/WorkbenchApp.test.tsx @@ -0,0 +1,153 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { WorkbenchApp } from "./WorkbenchApp"; +import type { ProviderConfigRecord, ThesisProject } from "../app/api"; +import { jsonResponse } from "../test/fixtures"; + +function project(overrides: Partial = {}): ThesisProject { + return { + id: "proj_1", + title: "测试论文", + school: "scnu", + degree_level: "undergraduate", + template_profile: "scnu-undergraduate", + rule_set_id: "scnu-undergraduate-2025", + department: "软件学院", + major: "软件工程", + advisor: "李老师", + student_name: "张三", + student_id: "2020123456", + writing_stage: "draft", + privacy_mode: "local_only", + remote_provider_allowed: false, + status: "active", + current_version_id: null, + created_at: "2026-04-18T00:00:00", + updated_at: "2026-04-18T00:00:00", + ...overrides, + }; +} + +function providerConfig(overrides: Partial = {}): ProviderConfigRecord { + return { + id: "prov_1", + provider: "openai", + model: "gpt-test", + base_url: null, + allow_local: false, + has_api_key: true, + verification_status: "untested", + verification_message: "", + last_verified_at: null, + created_at: "2026-04-18T00:00:00", + updated_at: "2026-04-18T00:00:00", + ...overrides, + }; +} + +function mockWorkbenchFetch(options: { projects?: ThesisProject[]; configs?: ProviderConfigRecord[]; accessRequired?: boolean } = {}) { + let projects = options.projects ?? []; + let configs = options.configs ?? []; + let accessVerified = !options.accessRequired; + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const method = init?.method ?? "GET"; + if (url.endsWith("/api/access-code/status")) return jsonResponse({ required: !!options.accessRequired, verified: accessVerified }); + if (url.endsWith("/api/access-code/verify")) { + accessVerified = true; + return jsonResponse({ required: true, verified: true }); + } + if (url.endsWith("/api/projects") && method === "GET") return jsonResponse(projects); + if (url.endsWith("/api/projects") && method === "POST") { + const body = JSON.parse(String(init?.body || "{}")); + const created = project({ ...body, id: "proj_created", title: body.title || "未命名论文项目" }); + projects = [created, ...projects]; + return jsonResponse(created); + } + if (url.includes("/api/projects/proj_1") && method === "PATCH") { + const body = JSON.parse(String(init?.body || "{}")); + projects = projects.map((item) => (item.id === "proj_1" ? { ...item, ...body } : item)); + return jsonResponse(projects[0]); + } + if (url.endsWith("/api/providers")) { + return jsonResponse({ + providers: [ + { id: "ollama", name: "Ollama", remote: false }, + { id: "openai", name: "OpenAI", remote: true }, + ], + keys_exposed: false, + secret_storage: "insecure-local-dev", + }); + } + if (url.endsWith("/api/provider-configs") && method === "GET") return jsonResponse(configs); + if (url.endsWith("/api/provider-configs") && method === "POST") { + const body = JSON.parse(String(init?.body || "{}")); + configs = [providerConfig({ provider: body.provider, model: body.model, base_url: body.base_url ?? null })]; + return jsonResponse(configs[0]); + } + if (url.includes("/files") || url.includes("/versions") || url.includes("/proposals") || url.includes("/exports")) return jsonResponse([]); + return jsonResponse({}); + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +describe("WorkbenchApp", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("shows the empty state and creates a project from the wizard", async () => { + const fetchMock = mockWorkbenchFetch(); + render(); + + expect(await screen.findByText("先建立一个可追溯的论文项目")).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText("论文题目"), { target: { value: "阶段一论文" } }); + fireEvent.click(screen.getByRole("button", { name: "创建项目" })); + + await screen.findByText("项目已创建,可以上传论文材料。"); + const createCall = fetchMock.mock.calls.find(([input, init]) => String(input).endsWith("/api/projects") && init?.method === "POST"); + expect(createCall).toBeTruthy(); + const body = JSON.parse(String((createCall?.[1] as RequestInit).body)); + expect(body.privacy_mode).toBe("local_only"); + expect(body.remote_provider_allowed).toBe(false); + }); + + it("updates privacy consent before remote model use", async () => { + mockWorkbenchFetch({ projects: [project()] }); + render(); + + expect(await screen.findByText("本地优先模式")).toBeInTheDocument(); + fireEvent.click(screen.getByLabelText("允许远程 Provider")); + fireEvent.click(screen.getByLabelText("确认远程处理提示")); + fireEvent.click(screen.getByRole("button", { name: "保存项目设置" })); + + expect(await screen.findByText("远程模型已授权")).toBeInTheDocument(); + }); + + it("saves provider settings without rendering the raw API key", async () => { + mockWorkbenchFetch({ projects: [project({ privacy_mode: "remote_allowed", remote_provider_allowed: true })] }); + render(); + + await screen.findByText("Provider 设置"); + fireEvent.change(screen.getByLabelText("Provider"), { target: { value: "openai" } }); + fireEvent.change(screen.getByLabelText("模型"), { target: { value: "gpt-test" } }); + fireEvent.change(screen.getByLabelText("API key"), { target: { value: "super-secret" } }); + fireEvent.click(screen.getByRole("button", { name: "保存 Provider" })); + + expect(await screen.findByText(/已保存 API key/)).toBeInTheDocument(); + expect(screen.queryByText("super-secret")).not.toBeInTheDocument(); + }); + + it("blocks the workbench behind the access code gate", async () => { + mockWorkbenchFetch({ accessRequired: true }); + render(); + + expect(await screen.findByText("该部署已启用访问码保护。验证后再进入项目、文件和 Provider 设置。")).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText("访问码"), { target: { value: "phase-one" } }); + fireEvent.click(screen.getByRole("button", { name: "进入 Workbench" })); + + await waitFor(() => expect(screen.queryByText("该部署已启用访问码保护。验证后再进入项目、文件和 Provider 设置。")).not.toBeInTheDocument()); + expect(await screen.findByText("先建立一个可追溯的论文项目")).toBeInTheDocument(); + }); +}); diff --git a/web/src/workbench/WorkbenchApp.tsx b/web/src/workbench/WorkbenchApp.tsx index 4409352..b1a1e61 100644 --- a/web/src/workbench/WorkbenchApp.tsx +++ b/web/src/workbench/WorkbenchApp.tsx @@ -5,21 +5,37 @@ import { createProject, createProjectExport, decideProposal, + deleteProviderConfig, exportDownloadUrl, + getAccessCodeStatus, getJobEvents, getProviders, listProjectExports, listProjectFiles, listProjects, + listProviderConfigs, listProposals, listVersions, + saveProviderConfig, + updateProject, uploadProjectFile, + verifyProviderConfig, type ExportRecord, + type ProjectDraft, type ProjectFileRecord, type ProposalRecord, + type ProviderConfigRecord, + type ProviderOption, type ThesisProject, type ThesisVersionRecord, } from "../app/api"; +import { AccessCodeGate } from "./AccessCodeGate"; +import { ModelStatusBadge } from "./ModelStatusBadge"; +import { PrivacyConsentBanner } from "./PrivacyConsentBanner"; +import { ProjectSettings } from "./ProjectSettings"; +import { ProjectWizard } from "./ProjectWizard"; +import { ProviderSettings } from "./ProviderSettings"; +import { WorkbenchEmptyState } from "./WorkbenchEmptyState"; type EventRecord = { id: string; type: string; payload: Record; created_at: string }; @@ -31,30 +47,60 @@ export function WorkbenchApp() { const [proposals, setProposals] = useState([]); const [exportsList, setExportsList] = useState([]); const [events, setEvents] = useState([]); - const [providers, setProviders] = useState>([]); + const [providers, setProviders] = useState([]); + const [providerConfigs, setProviderConfigs] = useState([]); + const [needsAccessCode, setNeedsAccessCode] = useState(false); + const [booting, setBooting] = useState(true); + const [showWizard, setShowWizard] = useState(false); const [busy, setBusy] = useState(false); const [message, setMessage] = useState(null); const currentVersion = useMemo(() => versions[0] ?? null, [versions]); + const remoteAllowed = !!activeProject?.remote_provider_allowed && activeProject.privacy_mode === "remote_allowed"; useEffect(() => { - void boot(); + void bootWithAccessCheck(); }, []); - async function boot() { + async function bootWithAccessCheck() { + setBooting(true); try { - const [projectRows, providerRows] = await Promise.all([listProjects(), getProviders()]); - setProjects(projectRows); - setProviders(providerRows.providers); - if (projectRows[0]) { - setActiveProject(projectRows[0]); - await refreshProject(projectRows[0].id); + const access = await getAccessCodeStatus(); + if (access.required && !access.verified) { + setNeedsAccessCode(true); + return; } + setNeedsAccessCode(false); + await boot(); } catch (error) { setMessage(readError(error)); + } finally { + setBooting(false); + } + } + + async function boot() { + const [projectRows, providerRows, configRows] = await Promise.all([listProjects(), getProviders(), listProviderConfigs()]); + setProjects(projectRows); + setProviders(providerRows.providers); + setProviderConfigs(configRows); + const nextActive = projectRows[0] ?? null; + setActiveProject(nextActive); + if (nextActive) { + await refreshProject(nextActive.id); + } else { + clearProjectScopedState(); } } + function clearProjectScopedState() { + setFiles([]); + setVersions([]); + setProposals([]); + setExportsList([]); + setEvents([]); + } + async function refreshProject(projectId: string) { const [fileRows, versionRows, proposalRows, exportRows] = await Promise.all([ listProjectFiles(projectId), @@ -68,14 +114,32 @@ export function WorkbenchApp() { setExportsList(exportRows); } - async function handleCreateProject() { + async function handleCreateProject(draft: ProjectDraft) { setBusy(true); setMessage(null); try { - const project = await createProject("SCNU Thesis Workbench 项目"); + const project = await createProject(draft); setProjects((items) => [project, ...items]); setActiveProject(project); + setShowWizard(false); await refreshProject(project.id); + setMessage("项目已创建,可以上传论文材料。"); + } catch (error) { + setMessage(readError(error)); + } finally { + setBusy(false); + } + } + + async function handleSaveProject(patch: Partial) { + if (!activeProject) return; + setBusy(true); + setMessage(null); + try { + const project = await updateProject(activeProject.id, patch); + setActiveProject(project); + setProjects((items) => items.map((item) => (item.id === project.id ? project : item))); + setMessage("项目设置已保存。"); } catch (error) { setMessage(readError(error)); } finally { @@ -131,6 +195,54 @@ export function WorkbenchApp() { } } + async function handleSaveProvider(payload: { provider: string; model: string; base_url?: string; api_key?: string; allow_local?: boolean }) { + setBusy(true); + setMessage(null); + try { + await saveProviderConfig(payload); + setProviderConfigs(await listProviderConfigs()); + setMessage("Provider 配置已保存,密钥不会返回前端。"); + } catch (error) { + setMessage(readError(error)); + } finally { + setBusy(false); + } + } + + async function handleVerifyProvider(configId: string) { + setBusy(true); + setMessage(null); + try { + await verifyProviderConfig(configId); + setProviderConfigs(await listProviderConfigs()); + } catch (error) { + setMessage(readError(error)); + } finally { + setBusy(false); + } + } + + async function handleDeleteProvider(configId: string) { + setBusy(true); + setMessage(null); + try { + await deleteProviderConfig(configId); + setProviderConfigs(await listProviderConfigs()); + } catch (error) { + setMessage(readError(error)); + } finally { + setBusy(false); + } + } + + if (needsAccessCode) { + return void bootWithAccessCheck()} />; + } + + if (booting) { + return

正在载入 Workbench。

; + } + return (
@@ -142,135 +254,155 @@ export function WorkbenchApp() {
{currentVersion ? `当前版本 ${currentVersion.label}` : "暂无版本"} - {providers.some((item) => item.id === "ollama") ? "本地模型可配置" : "模型待配置"} +
-
- + -
-
-

文档预览与版本

-
- - - - +

文件库

+
+ {files.map((file) => ( +
+ {file.filename} + {file.type} · {Math.round(file.size / 1024)} KB +
+ ))}
-
- {message &&

{message}

} - {currentVersion ? ( -
-

{currentVersion.thesis.cover.title || "未命名论文"}

-

{currentVersion.thesis.abstract_cn.content || "中文摘要待补充。"}

-
- {currentVersion.thesis.body_sections.map((section) => ( -
-

{section.title}

-

{section.content || "该正文块暂无用户确认内容。"}

-
- ))} -
-
- ) : ( -

新建项目并上传 `.docx`、PDF、图片、参考文献或任务书后,这里会显示结构树和当前版本。

- )} -

版本历史

-
- {versions.map((version) => ( -
- {version.label} - {new Date(version.created_at).toLocaleString()} -
- ))} -
-
+ - -
+ ) : ( +

上传 `.docx`、PDF、图片、参考文献或任务书后,这里会显示结构树和当前版本。

+ )} +

版本历史

+
+ {versions.map((version) => ( +
+ {version.label} + {new Date(version.created_at).toLocaleString()} +
+ ))} +
+ + + + + )}
); } @@ -296,6 +428,17 @@ function eventLabel(type: string) { return labels[type] ?? type; } +function stageLabel(stage: string) { + const labels: Record = { + topic: "选题", + proposal: "开题", + draft: "初稿", + revision: "修改", + final_check: "定稿自检", + }; + return labels[stage] ?? "初稿"; +} + function readError(error: unknown) { if (error instanceof ApiError) return error.message; if (error instanceof Error) return error.message; diff --git a/web/src/workbench/WorkbenchEmptyState.tsx b/web/src/workbench/WorkbenchEmptyState.tsx new file mode 100644 index 0000000..dbb46f1 --- /dev/null +++ b/web/src/workbench/WorkbenchEmptyState.tsx @@ -0,0 +1,18 @@ +import type { ReactNode } from "react"; + +type WorkbenchEmptyStateProps = { + children: ReactNode; +}; + +export function WorkbenchEmptyState({ children }: WorkbenchEmptyStateProps) { + return ( +
+
+

Project workspace

+

先建立一个可追溯的论文项目

+

项目会保存材料、版本、建议队列、导出历史和审计记录。AI 候选内容不会直接写入当前版本。

+
+ {children} +
+ ); +}