Compare commits
110 Commits
72d7111581
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64732b0d1b | ||
|
|
1afeb2cdb5 | ||
|
|
9bc0e1f2e6 | ||
|
|
40cc3fc9e3 | ||
|
|
b46afd4f8c | ||
|
|
b810a3fa3c | ||
|
|
9aa0cddeaf | ||
|
|
5f28491ad6 | ||
|
|
4a334f79cc | ||
|
|
54d3a532af | ||
|
|
ac40f48e05 | ||
|
|
f4bea7f26d | ||
|
|
355a2d11cd | ||
|
|
45025c36bf | ||
|
|
5674158707 | ||
|
|
67e30a6863 | ||
|
|
d3e9ae972f | ||
|
|
83ba47ab8f | ||
|
|
fd6551b0ba | ||
|
|
b9f9134748 | ||
|
|
57b3183564 | ||
|
|
dbece64d4d | ||
|
|
3c87b01379 | ||
|
|
e9c5502c2d | ||
|
|
9f6cbb5598 | ||
|
|
59d7c75737 | ||
|
|
ceae24740a | ||
|
|
61a6931bb1 | ||
|
|
5317d601dd | ||
|
|
222cc48f5a | ||
|
|
1185069572 | ||
|
|
5af45f5034 | ||
|
|
f6e88d7438 | ||
|
|
5ba3eabda3 | ||
|
|
33a9b7656c | ||
|
|
59f2f60a7e | ||
|
|
ddf7fa061a | ||
|
|
dce882ca91 | ||
|
|
7c4b3c95be | ||
|
|
c675a74f1d | ||
|
|
1a188e6e5b | ||
|
|
9da938d39b | ||
|
|
31067abbed | ||
|
|
c34dbf5d2d | ||
|
|
2145d33cc7 | ||
|
|
a039a1e5d9 | ||
|
|
05f49d0b7f | ||
|
|
64145422eb | ||
|
|
16f2eae2d3 | ||
|
|
9cf4915d49 | ||
|
|
d80d41a08d | ||
|
|
125908ed83 | ||
|
|
47d5558e4a | ||
|
|
1bda024141 | ||
|
|
f160a7dac1 | ||
|
|
6a739cad5e | ||
|
|
ec91c8fdf7 | ||
|
|
c9ffada503 | ||
|
|
832ab7dfa1 | ||
|
|
9439b97804 | ||
|
|
32451a9bd0 | ||
|
|
9ee7c7b000 | ||
|
|
49e3cb6dc4 | ||
|
|
bf3dabea82 | ||
|
|
0892ff6cda | ||
|
|
1392830e57 | ||
|
|
c596a8d342 | ||
|
|
c22f6b41d1 | ||
|
|
a703a90c0c | ||
|
|
ea9a9d9953 | ||
|
|
8e29b33a11 | ||
|
|
4b1088a179 | ||
|
|
9b32743606 | ||
|
|
6b7391d1b7 | ||
|
|
6fd637596b | ||
|
|
4a09f273d9 | ||
|
|
331a07abc4 | ||
|
|
dd6d2fce2b | ||
|
|
e08b01ac58 | ||
|
|
d6b53d08b2 | ||
|
|
6e2b478ca9 | ||
|
|
5c02792e5f | ||
|
|
5665cc9a16 | ||
|
|
f4c2c4e8ec | ||
|
|
baa41d370b | ||
|
|
e1a24a2c2c | ||
|
|
26f10d023d | ||
|
|
25d48b7aa7 | ||
|
|
5dd336b011 | ||
|
|
ca8fbc89a1 | ||
|
|
ed0a679322 | ||
|
|
8a2b1b09dc | ||
|
|
51fe9f44fe | ||
|
|
afeac51422 | ||
|
|
cb8fddfc3b | ||
|
|
c69e613c97 | ||
|
|
e30c933c76 | ||
|
|
1652ad9d20 | ||
|
|
fb5caa3d89 | ||
|
|
542c2abac9 | ||
|
|
6ed9c89430 | ||
|
|
4977c760d8 | ||
|
|
3dd7d2861f | ||
|
|
351fbc99e8 | ||
|
|
44b0b8a7f0 | ||
|
|
04f4785292 | ||
|
|
9195cf36f3 | ||
|
|
2f0b84191a | ||
|
|
8389572cbb | ||
|
|
e703d0a65c |
@@ -6,6 +6,7 @@ DATABASE_URL_SYNC=postgresql+psycopg://writer:writer@localhost:5432/writer
|
||||
APP_ENV=dev
|
||||
LOG_JSON=false
|
||||
# 凭据加密密钥(Fernet, urlsafe-base64 32B)— **必填**:API 启动即校验,缺失/非法则拒绝启动。
|
||||
# 下方为开发占位 key(可直接用于本地);生产请换新值:
|
||||
# ⚠️ 下方为**不可用占位符**,必须替换:`cp .env.example .env` 后用下方命令生成真实 key 填入。
|
||||
# 切勿把真实 key 提交进版本库;任何曾经泄露过的 key 必须立即轮换(重新加密已存凭据)。
|
||||
# uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
CREDENTIAL_ENC_KEY=cnMpG6QQxJejuDLHTe_S-nq2snoKgXCqWFfsctEHB-4=
|
||||
CREDENTIAL_ENC_KEY=replace-me-with-generated-fernet-key
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -19,3 +19,5 @@ apps/web/lib/api/openapi.json
|
||||
# os
|
||||
.DS_Store
|
||||
.gstack/
|
||||
# coverage artifact(不入库;覆盖率跑生成,误提交由 CR 批 c596a8d 撤销)
|
||||
.coverage
|
||||
|
||||
@@ -481,7 +481,6 @@ class LlmRequest(BaseModel):
|
||||
input: str | list[Block] # 易变内容(断点之后)
|
||||
stream: bool = False
|
||||
output_schema: type[BaseModel] | None = None # 需结构化输出时(Pydantic 模型/instructor)
|
||||
thinking: bool = False # 是否启用思考/推理(有则启用)
|
||||
max_tokens: int | None = None
|
||||
scope: "Scope" # {project_id; user_id 原型可固定}
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ Aligns `ARCHITECTURE.md §9.3`. This is how every failure gets diagnosed.
|
||||
|
||||
- **环境变量**:`uv` 装到 `~/.local/bin`,命令前 `export PATH="$HOME/.local/bin:$PATH"`。
|
||||
- **依赖安装**:`uv sync`(后端,仓库根);`pnpm install`(前端,`cd apps/web`)。
|
||||
- **本地起服务**:`docker compose up`(pg + api + web)。仅起库:`docker compose up -d pg`。裸跑 API:`uv run uvicorn ww_api.main:app --reload`。
|
||||
- **本地起服务(宿主端口 +1000 防冲突:web 4000 / api 9000 / pg 6432;容器内仍 3000/8000/5432)**:`docker compose up`(pg + api + web,端口已 +1000)。仅起库:`docker compose up -d pg`(宿主 6432)。裸跑:API `uv run uvicorn ww_api.main:app --port 9000 --reload`(读根 `.env`→pg 6432 / CORS 放行 4000);前端 `cd apps/web && pnpm dev`(`next dev -p 4000`,读 `apps/web/.env.local`→`NEXT_PUBLIC_API_BASE=http://localhost:9000`)。`.env`/`.env.local` 为本机文件(gitignored)。
|
||||
- **迁移**:`uv run alembic upgrade head`;改模型后 `uv run alembic revision --autogenerate -m "..."`;漂移校验 `uv run alembic check`(需 pg 在跑)。
|
||||
- **后端门禁**:`uv run ruff check .` · `uv run ruff format .` · `uv run mypy packages apps` · `uv run pytest -q`。覆盖率门禁:`uv run pytest -q --cov --cov-report=term-missing --cov-fail-under=80`。单测:`uv run pytest path::test -q`(单测不带 `--cov`,避免 fail-under 误报)。
|
||||
- **前端门禁**(`cd apps/web`):`pnpm lint` · `pnpm typecheck` · `pnpm test`(vitest)· `pnpm build`。覆盖率门禁:`pnpm test:coverage`(阈值 80%,范围 `lib/**`)。
|
||||
|
||||
106
PROGRESS.md
106
PROGRESS.md
@@ -8,7 +8,21 @@
|
||||
|
||||
---
|
||||
|
||||
## 当前阶段:QA-CR · 全项目 Code Review 整改批
|
||||
## ✅ 已交付:AI 对话(聊天记录)持久化(AC-1..AC-4 全绿)· develop 上新功能
|
||||
|
||||
> **背景**:[2026-07-09] 用户要求把作者↔AI 每轮往复(refine/rewrite/clarify/continue/工具箱生成器)**落库为聊天记录**,每章一个「AI 对话」抽屉展示。多 agent(4 学科起草→4 视角交叉评审→合稿)产出实施计划 `docs/design/ai-chat-history-plan.md`。**决策全 A**:clarify buffer-until-concluded / v1 只记 5 类交互 / append-only 不删。
|
||||
> **契约与不变量纪律**:@db 建表(契约 C2 扩)→@backend 端点(C3 扩)→@frontend gen:api+接线→@qa E2E,逐级等上游契约「稳定」。守 #1/#3/#8:聊天记录是**旁路留痕**(同 `usage_ledger` 物种),前端**显式 append**、生成端点**保持只读不变**、**绝不**喂 `assemble`/prompt(计划 §8 有围栏 + code-review 守卫)。
|
||||
|
||||
| 任务 | 状态 | 负责 | 依赖 | 备注 |
|
||||
|---|---|---|---|---|
|
||||
| AC-1 `ai_messages` 表 + 迁移 | ✅ @db 2026-07-09 | @db | — | 计划 §2 canonical DDL;迁移 `ceabd3c2c00d`(down=`f6a7b8c9d0e1`);C2 扩已「稳定」,AC-2 可动工 |
|
||||
| AC-2 repo + append/list 端点 + schema + 契约 | ✅ @backend 2026-07-09 | @backend | AC-1 | `c675a74`;`SqlAiMessageRepo`(批 append seq/thread_id + newest-first union/分页) + `POST/GET /projects/{id}/ai-messages` + schemas;C3 扩已「稳定」→ AC-3 可动工(须 `pnpm gen:api`) |
|
||||
| AC-3 gen:api + AiConversationDrawer + useAiConversation + 接线 5 处交互 | ✅ @frontend 2026-07-09 | @frontend | AC-2 | `dce882c`/`ddf7fa0`/`59f2f60`/`33a9b76`;gen:api + 纯视图模型 + hook + 抽屉 + 五类交互留痕(clarify buffer-until-concluded);vitest 706 passed / cov 95% / build OK |
|
||||
| AC-4 E2E + #1/#6 围栏守卫 | ✅ @qa 2026-07-09 | @qa | AC-3 | 计划 §6/§8;`tests/test_ai_chat_history_e2e.py`(6 用例,真 pg 零 LLM)+ `tests/test_ai_messages_not_in_generation_path.py`(AST 围栏,能变红);pytest **1002 passed**(+10)。**AI 对话(聊天记录)AC-1..AC-4 全交付** |
|
||||
|
||||
---
|
||||
|
||||
## 已完成阶段:QA-CR · 全项目 Code Review 整改批(全 ✅)
|
||||
|
||||
> **背景**:[2026-06-26] 对全仓做了一次**七维度 Code Review**(后端 Python 规范 / LangGraph 最佳实践 / 后端 FastAPI 架构 / 前端编程规范 / 前端架构 / 应用安全 / 依赖时效),6 个专科 reviewer 并行 + 依赖自查。绿门禁下仍揪出 **2 CRITICAL + 13 HIGH + 一批 MEDIUM/LOW**——再次证伪「测试过=没问题」。架构本身成熟、不变量纪律强(provider 中立 / HITL accept 单写 / checkpoint 仅控制流 / SecretStr 全覆盖);**确认无虞**:SSRF(`base_url` 仅来自硬编码 `_PROVIDER_BASE_URLS`,无用户 URL 入网)/ SQL 注入(全 ORM 参数化)/ XSS(无 `dangerouslySetInnerHTML`/`eval`)/ 错误信封不泄栈密钥 / Kimi 用 RFC 8628 device flow 无 CSRF 面。
|
||||
> **整改 DoD**:CRITICAL + HIGH 全部 ✅ 且各自补回归测试;MEDIUM 择批清理;门禁全绿(后端 ruff/format/mypy/pytest + alembic 无漂移;前端 lint/tsc/vitest/build)。**守 §Definition of Done**:每项 TDD 先红后绿、完成即小步 commit。
|
||||
@@ -17,29 +31,62 @@
|
||||
|
||||
| 任务 | 状态 | 负责 | 依赖 | 备注 |
|
||||
|---|---|---|---|---|
|
||||
| CR-C1 🔴 升级 Next.js 修 RCE(CVE-2025-66478)+ React 补丁 + 轮换密钥 | ⬜ | @frontend | — | `apps/web/package.json` next **15.1.3→15.1.9**(或 15.5.7/16.x),react/react-dom 19.0.0→补丁版(上游 CVE-2025-55182);App Router + RSC 应用正受影响。`pnpm install` 重锁 + `pnpm build` 验;**升级后轮换全部应用密钥**(联动 CR-H8)。Next15 LTS 2026-10-21 EOL,规划 16.x 迁移 |
|
||||
| CR-C2 🔴 多章链设 `recursion_limit` | ⬜ | @backend | — | `services/chain_runner.py:210` 默认 25 supersteps、每章 4 节点→超 ~6 章即 `GraphRecursionError` 整 job 失败,「批量产一卷(K章)」在目标规模不可用。`config` 加 `recursion_limit=(last-start+1)*4+10`;补超默认上限的 mock-gateway 回归测试 |
|
||||
| CR-H1 🟠 Kimi OAuth 轮询不占 DB 会话 | ⬜ | @backend | — | `services/job_runner.py:75`+`routers/kimi_oauth.py:85` 轮询全程(最坏 >200s)占连接池连接、饿死其他请求;轮询循环无需 DB,仅最终 `upsert_oauth_credential` 开短会话 |
|
||||
| CR-H2 🟠 修 `httpx.AsyncClient` 泄漏 | ⬜ | @backend | — | `routers/kimi_oauth.py:73` `Depends(_default_http_client)` 非 generator 依赖、永不 `aclose()`;改 `async def _http_client(): async with httpx.AsyncClient(...) as http: yield http` |
|
||||
| CR-H3 🟠 Anthropic 结构化输出记真实 usage | ⬜ | @llm | — | `adapters/anthropic.py:161` 硬编码 `usage=0`→`usage_ledger` 全记 0 成本、腐蚀计费(违 ARCH §4.8);用 `create_with_completion` 取原始响应 usage |
|
||||
| CR-H4 🟠 `request_id` 贯通网关日志 | ⬜ | @llm | — | `gateway.py:183` 所有 `llm_call`/`llm_provider_failed` 无 request_id;`LlmRequest` 加 `request_id` 字段并从 API 层透传(违 ARCH §9.3 端到端追踪不变量) |
|
||||
| CR-H5 🟠 `thinking` 字段:实现或删除 | ⬜ | @llm | — | `types.py:41` 声明但所有 adapter 忽略,`thinking=True` 静默无效、无报错;按 YAGNI 删除或实现转发 + 测试 |
|
||||
| CR-H6 🟠 流式路径加 per-provider 重试 | ⬜ | @llm | — | `gateway.py:268` 流式仅 1 次尝试,首 token 前一次瞬时 429 烧光 fallback 链(与非流式 `_complete_with_retry` 不对称);用 `_retrying()` 包裹首 token 前尝试或显式文档化 |
|
||||
| CR-H7 🟠 `TOOLBOX` 注册表不可变 | ⬜ | @backend | — | `packages/skills/ww_skills/toolbox_registry.py:70` 裸 dict 可变;仿同库 `SPECS` 用 `Final[Mapping]=MappingProxyType(...)`(违不可变不变量) |
|
||||
| CR-H8 🟠 `.env.example` 移除可用 Fernet key | ⬜ | @devops | — | 已确认提交真实可用 key(注释「可直接用于本地」),换非功能占位符 + 突出轮换提示;联动 CR-C1 轮换 |
|
||||
| CR-H9 🟠 API 文本输入加 `max_length` | ⬜ | @backend | — | `schemas/{providers,generation,projects}.py` `api_key`/`brief`/`final_text`/`segment` 仅 `min_length=1`(DoS/成本失控);加上限(api_key≤512、brief≤1e4、final_text≤2e5)+ ASGI 请求体大小限 |
|
||||
| CR-H10 🟠 前端 `errorCode` 去重 | ⬜ | @frontend | — | `lib/style/{useRefine,useStyleLearn}.ts` 各自重复定义,统一 import 自 `lib/generation/cards.ts:132`(DRY 漂移隐患) |
|
||||
| CR-H11 🟠 Drawer 关闭还原焦点 | ⬜ | @frontend | — | `components/Drawer.tsx:41` 关闭后焦点掉到 body(违 WCAG 2.4.3);传 `triggerRef`、关闭时 `.focus()` |
|
||||
| CR-H12 🟠 RefineView 取消在途 refine | ⬜ | @frontend | — | `components/style/RefineView.tsx:33` 无 AbortController,段切换时旧响应可覆盖新响应;`useRefine` 暴露 abort 在 effect cleanup 调用,去 blanket exhaustive-deps disable |
|
||||
| CR-M1 🟡 后端架构整改批 | ⬜ | @backend | — | `outline.py:162` volume 下推 DB 过滤;`generation.py:326`/`toolbox.py:431` N+1 INSERT→`bulk_create`;`jobs.py:28` 裸 ORM→repo;`provider_deps.py:91` 去私有 `_client`(加 `probe_connection()`);列表端点加分页。⚠️`assemble.py:155` 8 串行查询:**AsyncSession 不支持同一 session 并发,`gather` 须每查询独立 session**,不可照搬 |
|
||||
| CR-M2 🟡 网关/adapters 整改批 | ⬜ | @llm | — | `gateway.py:90` `is_open()` 副作用违 CQS;`pricing.py` 未知 model 静默记 0 → 加 warning;adapter `str(exc)` 入异常消息可能带 vendor 响应体→只留类名+status |
|
||||
| CR-M3 🟡 LangGraph 链健壮性 | ⬜ | @llm | — | `chain_runner.py:124` accept commit 后 `run_overdue_scan` 非幂等且失败无法 resume→部分进度空洞(幂等化或纳入事务);`chain_runner.py:159` 改用公开 `get_state().next` 替私有 `__interrupt__`;`chain/nodes.py:213` 链内 4 审串行→并行/复用 review 子图 |
|
||||
| CR-M4 🟡 前端 key/a11y/cleanup 批 | ⬜ | @frontend | — | 可编辑列表去 index-key(`CharacterGenerator.tsx:135`/`GeneratorRunner.tsx:178`/`ForeshadowCard.tsx:68`);SSE `KNOWN_EVENTS` 补 `stop`(核对 `memory/contracts.md`);ARIA 补全(AppShell nav label / NavDrawer aria-controls / ForeshadowCard `<dl>` 结构);ProjectWizard 步骤焦点;`ReviewReport.tsx:301` setTimeout 卸载清理 |
|
||||
| CR-M5 🟡 前端 TS 边界类型收紧 | ⬜ | @frontend | — | `lib/api/server.ts:34` `parseJsonBody` 加最小结构校验;`useAccept.ts:60` `ApiErrorEnvelope` 从生成 schema 派生;`applyFix.ts` `hasApplicableFix` 改类型谓词去 `as string` |
|
||||
| CR-L1 🟢 LOW 清理批 | ⬜ | @backend·@llm·@frontend | — | `chain.py:256` 静默回退加 warning;SSE 端点补 OpenAPI `text/event-stream` 声明;冗余 `as`/`.valueOf()` cast 清理;测试脚本 `print`→logging |
|
||||
| CR-D1 🟡 抬高后端依赖下界(可选升级评估) | ⬜ | @devops | — | `pyproject.toml` 下界过旧(`langgraph>=0.2.40`/`anthropic>=0.34`),抬到接近 lockfile 实锁版本保可复现;后端实锁版本均当前。可选评估 vitest 3.x / tailwind 4.x |
|
||||
| CR-C1 🔴 升级 Next.js 修 RCE + React 补丁 | ✅ | @frontend | — | `f4c2c4e` next 15.1.3→**15.5.20**(registry backport 线,避 16.x 大迁移)、react/react-dom 19.0.0→**19.2.7**、eslint-config-next/@types 同步;`pnpm install` 重锁 + `pnpm build` 绿;`pnpm audit` next/react/react-dom 无 high/critical;新 `lib/security/dependencyFloors.ts` 版本下界守卫。**⚠️ 运维待办(用户执行)**:CVE 号无法对真库核验(升级后 `pnpm audit`/GitHub advisory 复核)+ 升级后轮换全部应用密钥 |
|
||||
| CR-C2 🔴 多章链设 `recursion_limit` | ✅ | @backend | — | `ed0a679` **前提证伪**:langgraph 1.2.5 默认上限=10007(非 25)、count≤50→~200 步,**无实活崩溃**;仍加显式**顶层** `recursion_limit=count*4+10`(防御硬化);测断言 config 键(行为「跑长链完成」测会 vacuous) |
|
||||
| CR-H1 🟠 Kimi OAuth 轮询不占 DB 会话 | ✅ | @backend | — | `ca8fbc8` 轮询移出会话:`_poll_for_token`(无会话)+`_run_kimi_oauth_poll`,仅最终 persist 走 run_job 短会话;`job_runner` 未动(与 style_learn 共享) |
|
||||
| CR-H2 🟠 修 `httpx.AsyncClient` 泄漏 | ✅ | @backend | — | `5dd336b`+`26f10d0` 请求级依赖改 `_http_client` 生成器(`async with`→`aclose`);两处测 override 跟随改,堵住 E2E 误触真网 |
|
||||
| CR-H3 🟠 Anthropic 结构化输出记真实 usage | ✅ | @llm | — | `cb8fddf` 改 `create_with_completion` 取 `raw.usage`,弃硬编码 0(修计费腐蚀) |
|
||||
| CR-H4 🟠 `request_id` 贯通网关日志 | ✅ | @llm | — | `51fe9f4`+`e08b01a` LlmRequest 加可选 `request_id` + **条件透传**(仅非 None,否则覆盖 contextvars 反退化 sync/SSE 追踪);`llm_call`+两处 `llm_provider_failed` 全锁测。契约 C1-扩 |
|
||||
| CR-H5 🟠 `thinking` 字段:实现或删除 | ✅ | @llm | — | `afeac51` 删死字段 `LlmRequest.thinking`(YAGNI);保 `Capabilities.thinking`(异名活字段);回写 ARCH/contracts |
|
||||
| CR-H6 🟠 流式路径加 per-provider 重试 | ✅ | @llm | — | `8a2b1b0` `_stream_with_retry` 对称 run:仅首 token 前重试,mid-stream 失败照抛(不变量 §4.5) |
|
||||
| CR-H7 🟠 `TOOLBOX` 注册表不可变 | ✅ | @backend | — | `25d48b7` `Final[Mapping]=MappingProxyType(_TOOLBOX_BY_KEY)`(仿 SPECS) |
|
||||
| CR-H8 🟠 `.env.example` 移除可用 Fernet key | ✅ | @devops | — | `baa41d3` 换不可用占位符(Fernet 拒绝→照抄即启动失败)+ 轮换提示。**⚠️ 真 key 仍在 git 历史,曾用该 key 的环境须轮换(用户)** |
|
||||
| CR-H9 🟠 API 文本输入加 `max_length` | ✅ | @backend | — | `e1a24a2`+`d6b53d0` 全请求文本字段加上限(api_key≤512/brief≤1e4/final_text≤2e5/segment≤2e4/directive≤2e4/instruction≤2e3…)+ **2 MiB body 中间件 413 兜底** + `PAYLOAD_TOO_LARGE`。契约 C3;`gen:api` 无漂移 |
|
||||
| CR-H10 🟠 前端 `errorCode` 去重 | ✅ | @frontend | — | `5665cc9` `lib/style/{useRefine,useStyleLearn}` 统一 import 自 `generation/cards` |
|
||||
| CR-H11 🟠 Drawer 关闭还原焦点 | ✅ | @frontend | — | `6e2b478` 行为本分支早已修;提取 `lib/a11y/useRestoreFocus` + renderHook 回归测试 + DRY(Drawer/ContextDrawer 共用) |
|
||||
| CR-H12 🟠 RefineView 取消在途 refine | ✅ | @frontend | — | `5c02792` useRefine 暴露 `abort`(setState-free)+ AbortController + aborted 守卫弃迟到响应;RefineView cleanup 调用 + 真 deps |
|
||||
| CR-M1 🟡 后端架构整改批 | ✅ 大部 | @backend | — | `c596a8d` outline volume 下推 DB WHERE(Protocol/Sql/fake 加 keyword `volume`);`bf3dabe` 角色/世界观入库 N+1→`create_many`(世界观 add_all;角色一次 IN 查+一次 flush 保 upsert 幂等/批内去重;单条 create 薄封装);`1392830` jobs 读端点裸 ORM→JobRepo;`0892ff6` projects/templates 列表加分页(`ww_api.pagination` limit∈[1,200]/offset≥0,越界 422,下推 LIMIT/OFFSET)。**item#4 provider_deps `_client`→已由 @llm 交付**(`832ab7d` `OpenAICompatAdapter.probe_connection()` 公开探测 + provider_deps 改调,删私有 `_client` 反手 + `type:ignore`/`noqa:SLF001`;isinstance 收窄兜非 OpenAI 兼容)。**item#6 assemble 并行→SKIP**:8 查询共享同一注入 AsyncSession,`gather` 会腐蚀(ledger 警告属实);安全化须把 session 工厂穿透 assemble + 8 repo 各独立 session + 并发正确性测,对单用户几 ms 收益不值该回归风险 |
|
||||
| CR-M2 🟡 网关/adapters 整改批 | ✅ 大部 | @llm | — | `32451a9` pricing 未知 model 发 `pricing_unknown_model` warning(成本仍 0、照常记账);`9439b97` 三 adapter 瞬时错误消息经 `provider_error_summary(exc)` 只留类名+status(不泄 vendor 响应体)。**item `is_open()` CQS→SKIP**:冷却过读时半开清态是**故意的惰性转移**(唯一转半开点),拆 CQS 需两处调用方各先发命令、漏发则熔断永不复位(更差),风格分不值动熔断语义 |
|
||||
| CR-M3 🟡 LangGraph 链健壮性 | ✅ 大部 | @llm | — | `ec91c8f` runner 改用公开 `graph.aget_state(config).interrupts` 判暂停(弃私有 `__interrupt__`;SpyGraph 测同步补 aget_state 透传);`6a739ca` review 节点五审 `asyncio.gather` 并行扇出(复用主图 add-only 记账并发范式 T3.8,collect 仍一次落库;并发峰值测 + 五审落齐测)。**item `run_overdue_scan` 幂等→SKIP**:`scan_overdue` 本已幂等(单条 `UPDATE...RETURNING` + `status NOT IN [CLOSED,OVERDUE]` + commit-only-if-changed,原子无部分空洞),且下一章扫描 current+1 自愈漏标;再动会触碰验收事务边界,单用户零收益 |
|
||||
| CR-M4 🟡 前端 key/a11y/cleanup 批 | ✅ | @frontend | — | `47d5558` ARIA(NavDrawer `aria-controls` + Drawer `id` + ForeshadowCard 语义化 `<dl>/<dt>/<dd>`)+ `125908e` ReviewReport `flashMark` setTimeout 卸载清理(ref 存句柄)。**核实为无需改/已具备**:index-key(三处均 replace-all/append-only 静态展示、无天然稳定 id,index 安全;卡名可编辑作 key 更糟)、`stop`(经查**非**服务端 SSE 帧、是客户端 AbortController 动作,加则处理不存在事件)、AppShell nav label(`<nav aria-label>` 已有)、ProjectWizard 步骤焦点(`stepHeadingRef`+首帧守卫 useEffect 已实现) |
|
||||
| CR-M5 🟡 前端 TS 边界类型收紧 | ✅ | @frontend | — | `d80d41a` `ApiErrorEnvelope` 改由生成 schema(`components.schemas.ErrorEnvelope`)派生防漂移 + `9cf4915` `hasApplicableFix` 改对象参类型谓词、消除 `applyConflictFix` 两处 `as string` + `16f2eae` `parseJsonBody`(结构校验 HEAD 已具备,补非对象/非 JSON 守卫分支测试) |
|
||||
| CR-L1 🟢 LOW 清理批 | ✅ | @backend·@llm·@frontend | — | **@backend**:`49e3cb6` SSE draft/rewrite/review 补 OpenAPI `text/event-stream` 声明(共享 `_SSE_RESPONSE`);`print`→logging **无需改**(owned 目录唯一 `print(` 是 `export_openapi.py` CLI stdout,前端 gen:api 管道消费,改则断管道)。**@llm**:`c9ffada` 续写链前一章无已验收终稿发 `chain_prior_accepted_missing` warning。**@frontend**:`6414542` 去冗余 `as`/`.valueOf()` cast(`stream/sse.ts` idx.valueOf、`jobs/job.ts` `dict[error] as string`;保留 load-bearing 的 CSS-var 键/变量键强转) |
|
||||
| CR-D1 🟡 抬高后端依赖下界(可选升级评估) | ✅ | @devops | — | 8 个 pyproject 后端运行时下界抬到 lockfile 主次版本(`langgraph>=1.2`/`anthropic>=0.111`/`openai>=2.43`/`pydantic>=2.13`/`fastapi>=0.137`/`cryptography>=49` 等,每条 ≤ 实锁);`uv.lock` **纯元数据刷新**(仅 `requires-dist` specifier,零 pinned 版本变动);`uv lock --check` 绿、pytest **971**、ruff 干净。root dev 工具下界 + 前端 vitest/tailwind 升级评估未纳入(超范围) |
|
||||
|
||||
> **[2026-07-08] CRITICAL + HIGH 全部 ✅ 落地**(14 项 + 4 项低危收尾),多 agent 并行开发(@llm/@backend/@devops/@frontend 四簇串行整合,各 TDD 红→绿、逐项补回归测试)。**门禁全绿**:后端 ruff/format 干净 · mypy **229** · alembic **无漂移** · pytest **934**;前端 lint/tsc 干净 · vitest **669** · build OK · coverage **95.41%**。**14 项对抗式复核 0 problem**(skeptic 逐条实证 test 非 vacuous)。落地含 3 处前提证伪(C2 非实活崩溃/H4 sync-SSE 已由 contextvars 贯通/H11 行为本分支已修)。
|
||||
>
|
||||
> **[2026-07-08] MEDIUM/LOW(CR-M1..M5 / L1 / D1)亦全部清理 ✅**(四簇串行整合):明确取舍——高价值低风险项落地、风险>价值或前提已陈旧的子项**有据 SKIP/NOOP**(assemble 并行 gather / breaker is_open CQS / overdue-scan 已幂等 / index-key 安全 / `stop` 非服务端帧 / 已具备的 nav-label、wizard 焦点)。**门禁全绿**:后端 mypy **232** · alembic 无漂移 · pytest **971**;前端 vitest **681** · build · coverage **95.45%**。附带修复:一 agent 误提交的非源文件(`.coverage`/`AGENTS.md`/`example_skills`)已撤销还原为未跟踪(`1bda024`)。
|
||||
>
|
||||
> **维度小结**:后端 FastAPI 架构 0C/2H/7M·Python 规范 0C/4H/6M·LangGraph 1C/2H/5M·前端 React 0C/2H/9M·前端 TS 0C/1H/8L·安全 0C/2H/4M·依赖 1C。后端依赖全部当前大版本线;前端唯一关键缺口=Next RCE。
|
||||
>
|
||||
> **[2026-07-08] CR-M1 + CR-L1 后端片 ✅ 落地**(@backend,6 提交 `c596a8d`/`bf3dabe`/`1392830`/`0892ff6`/`49e3cb6`,各行为改动先写红测再实现)。CR-M1 4/6 完成(volume 下推 · N+1→create_many · jobs→JobRepo · 列表分页),item#4(去 `_client`)与 item#6(assemble 并行)SKIP(理由见 CR-M1 行);CR-L1 后端片完成(SSE OpenAPI;print 属误报)。**门禁全绿**:后端 ruff/format 干净 · mypy **230** · pytest **960 passed**(较基线 955 +5 新测:volume 下推 1 + 分页 2 + 批量入库 2)· 无模型改动故免 alembic。新增 5 测无回归,既有 upsert 幂等/冲突 gate + m5 真 pg E2E 全绿。
|
||||
>
|
||||
> **[请求 → @llm]** CR-M1 item#4:`apps/api/ww_api/services/provider_deps.py:91` 需去掉对 adapter 私有 `_client` 的探测(`adapter._client.models.list()`)。owning 类 `OpenAICompatAdapter`/`ProviderAdapter`(`packages/llm_gateway/ww_llm_gateway/adapters/`)在 @llm 域——请在 adapter 上加一个公开 `async probe_connection()`(内部最小请求验 Key,如 `models.list()`),@backend 改调该公开方法即可删私有属性访问 + `type: ignore`/`noqa: SLF001`。行为保持不变。
|
||||
|
||||
---
|
||||
|
||||
## 进行中:写作工作台重构(writing-first)· 分支 `feat/writing-first-workspace`
|
||||
|
||||
> **背景**:[2026-07-07] 用户对写作体验提 8 点反馈,产品主张升级为 **writing-first 工作台**——进来即写、所有工具/AI 动作从编辑器内按需调用并回填正文、元数据由内容渐进生成。多 agent 工作流(扎根代码→Web+GitHub 调研→三方设计→四镜头对抗评审→定稿)产出 DEV_PLAN(`scratchpad/writing-first-workspace-DEVPLAN.md`,**ephemeral 未入库**;灵感/取舍见 `memory/selectable-write-prompts.md`)。
|
||||
> **已拍板决策**:进来即写=**延迟落库**(首次击键才建占位项目 + 列表过滤空稿);可选提示词 MVP=**纯前端** directive→volatile(不改 `DraftStreamRequest` 契约、不动 `write_craft.md` 字节 → 金标准零回归);预设 MVP=先只上内置;续写=多候选卡点选插入。
|
||||
> **纪律**:全程 TDD 小步提交,回填全守 HITL(AI 绝不 auto-replace,点选/接受才落草稿);`/refine`、`/rewrite` 只读不写库(不变量 #3)。**门禁全绿**:前端 vitest **678** / lint / tsc / build / coverage **95.36%**;后端 ruff / mypy **229** / pytest **955**(含 WFW-9 clarify E2E)/ alembic 无漂移。**WFW-0~9 全部交付**(WFW-5/6/7 多 agent 并行 → `3dd7d28`;WFW-8 整章重写跨栈 → `6ed9c89`+`542c2ab`;**WFW-9 三段全交付** M1 `1652ad9`+`e30c933` / M2 `4a09f27`..`8e29b33` / M3 `ea9a9d9`+`a703a90`,契约 C-Rewrite+C-Clarify 见 `memory/contracts.md`)。**未 push、未开 PR**(等用户示意)。
|
||||
|
||||
| 任务 | 状态 | 负责 | 依赖 | 反馈# | 备注 |
|
||||
|---|---|---|---|---|---|
|
||||
| WFW-0 进来即写(延迟落库) | ✅ | @frontend | — | #5 | `8389572` `/write`(DraftComposer) + `lib/start/useDeferredDraft`(懒建占位项目+种首章草稿,双重幂等,空进空出不落库) + 落地页 CTA「直接开始写」主/「先立项」辅 |
|
||||
| WFW-1 润色 / 再沟通 | ✅ | @frontend | — | #1#2 | `2f0b841` `useRefine`(同步/refine,版本栈,再沟通=上一版产出+新意见再回炉) + `refineApply`(接受回填按内容重锚,原文变则提示重选) + RefinePanel 内联卡 + Editor 上报选区 |
|
||||
| WFW-2 续写(多候选) | ✅ | @frontend | WFW-0 | #2 | `9195cf3` `useContinue`(continue 生成器 with_prior_chapter 读前文,多候选累加) + ContinuePanel 候选卡点选插入章末 + flush 后读最新草稿 |
|
||||
| WFW-3 内联工具箱 + 金手指露出 | ✅ | @frontend | — | #4#8 | `04f4785` `useToolboxTools`(客户端拉描述符) + InlineToolbox(列非 legacy 生成器含金手指) + `GeneratorRunner` 加 `onInsertText`(文本产物插入正文) |
|
||||
| WFW-4 AI 写作三槽(文风/剧情/自定义) | ✅ | @frontend | — | #3 | `44b0b8a` `directive.ts` 加 `PLOT_PRESETS` + `composeDirective` 合并;DirectivePanel 拆文风/剧情两组 chips + 自定义。纯前端零契约,`write_craft.md` 字节不变 |
|
||||
| WFW-5 内容感知书名 | ✅ | @frontend | — | #6 | `3dd7d28` 纯前端零后端 MVP:`buildManuscriptExcerpt`(首尾摘录+硬上界) + GeneratorRunner「用当前正文」按钮把摘录填 brief,book-title 据正文反推。**后续更佳版**:后端富化 book-title.md + `with_manuscript` 策略(未做) |
|
||||
| WFW-6 侧栏 IA 收敛(ContextDrawer) | ✅ | @frontend | — | #7 | `3dd7d28` 加法式 ContextDrawer 右侧速查抽屉 + 底栏「速查」按钮;设定库/大纲**完整只读速查**,伏笔/规则/文风**摘要+跳全宽页**(后续可补真实数据)。`CONTEXT_DRAWER_ENABLED` 一键回滚、旧侧栏/全宽页路由全保留 |
|
||||
| WFW-7 首次 AI 写作前极轻 genre 采集 + 无大纲降级 | ✅ | @frontend | WFW-0 | — | `3dd7d28` 无题材首次写章前弹 GenrePicker(临时并入本次 directive 不落库) + chapters 空时「尚无大纲,将按设定自由生成」提示,杜绝无题材 slop |
|
||||
| WFW-8 整章级「再沟通/重写」(对话式迭代) | ✅ | @backend·@llm·@frontend | — | 新#1 | `6ed9c89`(后端)+`542c2ab`(前端)。后端 SSE `POST .../rewrite`(`rewrite.md` 教条 + `rewrite_node` + `RewriteStreamRequest`,复用 assemble 记忆注入,只读不写库、工具非写节点、守 #3/#7/#9);前端 `useChapterRewrite`(版本栈)+`ChapterRewritePanel`+底栏「整章重写」:给意见→流式重写整章→多轮迭代→接受某版才替换正文(HITL)。契约 C-Rewrite。用户反馈「整章不满意要跟 AI 交流」 |
|
||||
| WFW-9 M1 AI 反问澄清(refine 侧,路线A 两阶段) | ✅ | @backend·@llm·@frontend | — | 新#2 | `1652ad9`(后端)+`e30c933`(前端)。多agent并行(后端整片‖ChoiceChips)+主线集成。非流式预检 `POST .../refine/clarify`→`ClarifyDecision`(结构化,analyst,只读,失败回退放行)+clarify_refine spec/教条(SPECS#24);既有 refine 未改。前端 `useClarify`+`ChoiceChips`+`clarify.ts`(foldClarifications/门控)+RefinePanel 门控接线:意见<10字或点「帮我理清方向」→AI反问选项→折答案进instruction→走既有refine。契约 C-Clarify。**M1 refine 侧完成;M2/M3 见下两行** |
|
||||
| WFW-9 M2 AI 反问澄清(rewrite 侧,整章重写路径) | ✅ | @backend·@llm·@frontend | — | 新#2 | `4a09f27`+`6fd6375`(后端)+`9b32743`+`4b1088a`+`8e29b33`(前端),多 agent 串行整合。新非流式预检 `POST .../rewrite/clarify`→`ClarifyDecision`(复用 M1 schema/`run_clarify`)+新 `clarify_rewrite` spec/教条(章级,SPECS 24→25,prompt_hashes +1)+`RewriteClarifyRequest{feedback,prior_draft?}`(仅取有界 3000 字摘录喂 analyst,不整章入 LLM);只读不写库、失败放行。前端 `useRewriteClarify`+`toVM/PROCEED` 提取入共享 `clarify.ts`(DRY)+`ChapterRewritePanel` 门控(镜像 RefinePanel):意见<10字或「帮我理清方向」→反问→折进 feedback→走既有 rewrite SSE。契约 C-Clarify 扩;gen:api 纳入新端点 |
|
||||
| WFW-9 M3 澄清 UX 打磨 + E2E | ✅ | @frontend·@qa | — | — | `ea9a9d9`(a11y):refine/rewrite 两面板 AI 反问块加 `role=status` 向读屏动态播报(WCAG 4.1.3 Status Messages)。`a703a90`(E2E):`tests/test_wfw9_clarify_e2e.py` 7 用例真 pg+mock 网关零 token——refine/rewrite 含糊→反问 / 清晰→放行 / 失败开路(parsed=None→200 need_clarification=false,不 500) / 只读不写业务表但记 usage_ledger / 404。无产品 bug。**深度视觉/交互 QA(真起前端浏览)为人工验收事项** |
|
||||
| WFW-9 M2 AI 反问澄清(rewrite 侧,路线A 两阶段) | ✅ 后端 | @llm·@backend | WFW-8·WFW-9 M1 | 新#2 | `4a09f27`(llm)+`6fd6375`(后端)。镜像 M1:非流式预检 `POST .../rewrite/clarify`←`RewriteClarifyRequest{feedback(必给),prior_draft(可选)}`→`ClarifyDecision`(复用同一 schema,analyst,只读,失败回退放行)+`clarify_rewrite_spec`/教条 `clarify_rewrite.md`(segment→整章,SPECS#25);既有 `.../rewrite` SSE 未改。端点只喂 prior_draft 开头 3000 字摘录(不整章入 LLM)、日志脱敏。契约 C-Clarify(扩)。门禁绿:后端 ruff/format·mypy229·pytest948·alembic 无漂移。**前端仍欠 `pnpm gen:api`+M2 UI 接线(仿 RefinePanel 门控 ChapterRewritePanel) + M3 UX/E2E 待办** |
|
||||
| WFW-9 E2E(两阶段 AI 反问澄清,refine+rewrite) | ✅ | @qa | WFW-9 M1·M2 | 新#2 | `tests/test_wfw9_clarify_e2e.py` 7 用例真 pg + mock 网关(provider/adapter 层,同 M2/T6 范式)零 token:refine 含糊→反问1问 / refine 明确→放行、rewrite 含糊→反问 / rewrite 明确→放行、失败开放(网关产非 ClarifyDecision→仍200 need_clarification=false 不 500 且仍记账)、只读(seed chapter 字节一致+无 review/digest 行)但 usage_ledger 真落 analyst 一行、404 信封。响应绝不回灌原文摘录。无产品 bug。门禁绿:ruff/format·mypy 本文件净·pytest 955 passed |
|
||||
|
||||
---
|
||||
|
||||
@@ -161,6 +208,20 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移(无漂移,users st
|
||||
|
||||
> 格式:`- [YYYY-MM-DD] @skill 完成/进展 Txx — 一句话结果 + 影响的契约/文件`
|
||||
|
||||
- [2026-07-10] @frontend — **✅ 写作台 UX 改造 P2-1..P2-3 全落地 → P0/P1/P2 整套 IA 改造完成**(`docs/design/ux-exploration.md` §4/§5,P2-4 按计划延后不做)。**P2-1 左栏分组**:`lib/nav/items.ts` 新增纯函数 `navSections(projectId?)`——把 10 个平铺项目项归为 **写作 / 资料库(大纲·设定库·伏笔·文风)/ 审阅(审稿·多章链)/ 更多(工具箱·规则·技能库)** 四组(全局组 作品库/模板库/设置 无标题恒在顶部),`NavItems.tsx` 复用现有「本作」小标题样式逐组渲染小标题(桌面侧栏/移动抽屉共用,链接/路由/激活键均不变,纯重排+加标题);工具箱在原分组清单缺列,收进「更多」以不丢入口(记 decisions)。**P2-2 目录归位(可折叠列)**:新增 `lib/workbench/useChapterListCollapse.ts`(`localStorage` 键 `ww.workbench_chapter_list_collapsed`,SSR 首帧恒展开避免 hydration 不一致、挂载后恢复、toggle 即时写回),`ChapterList` 收起时降为一条带「展开目录」按钮的窄轨(`2.75rem`)、展开时目录标题旁加「收起目录」按钮,`Workbench` 三栏栅格据折叠态在 `xl:grid-cols-[2.75rem_1fr_18rem]`↔`[12rem_1fr_18rem]` 间切换(章节列表/当前章高亮/切章导航全保留;窄屏抽屉不受影响、不带折叠按钮)。**P2-3 放置规则**:`memory/decisions.md` 落一条四象限唯一落点规则——导航→左栏、AI 动作→中央输入条+「或让 AI:」行、章节状态→底栏、参考/透明度→右栏「本章参考」,防三重复回潮。**P2-4 统一对话面板按计划延后**(§3/§4-P2-4/§6 明列为「先观察的大赌注、非开局」,本波不实现)。**TDD**:`navSections` +6 断言(分组标签/成员/全项恰好一次覆盖)、`useChapterListCollapse` +3(默认展开/toggle 持久化/挂载恢复)均 RED→GREEN。**门禁全绿**:lint/typecheck 干净 · vitest **716 passed**(710 基线 +6 新)· `test:coverage` 通过(`lib/**` 95.43% ≥ 80)· `build` OK。仅动 `apps/web/` + `memory/decisions.md` + `PROGRESS.md`。
|
||||
- [2026-07-10] @frontend — **✅ 写作台 UX 改造 P1-1..P1-5 全落地**(`docs/design/ux-exploration.md` §4,接 P0 新布局)。**P1-1 底栏瘦身 + act-on-chapter/导航分家**:把生成动词(续写/润色选段/整章重写/工具箱)从底栏搬进中央输入条正下方常驻的**「或让 AI:」次级动作行**(新 `AiVerbsRow`/`VerbButton`,读作「同一个 AI 的其它用法」,生成中禁用而非隐藏,触发沿用既有 `openContinue/openRefine/openRewrite/openToolbox`——仅搬家不改行为);删底栏纯导航「大纲」「伏笔」(左栏已有,连带下线 `BookOpen/Library` import);**唯一强调的「审稿」** 作为写→审→验收前进 CTA 保留(outline);`写本章`「发送键」上移到 `DirectivePanel` 表头**常驻可见**(折叠时也在,streaming 切「停」),底栏遂只剩 字数/生成中 · 保存 · 里程碑播报 · streaming 时的「停」(sticky 底栏够得着)+ 审稿 CTA + 速查/对话记录抽屉入口。**P1-2 统一「采用」动词族**:`ConversationThread.ACCEPT_LABEL`→采用(替换整章)/采用(加到章末)/采用(替换这段);`RefinePanel`「接受回填」+`ContinuePanel`「插入正文」+`ChapterRewritePanel`「接受最新版」同步;弃「回填」(含 Workbench 三处 toast 与两处面板描述)。**P1-3 右栏去术语(不降级,守 #6 透明度)**:`ChapterAssistant`「本章助手」→本章参考、「本章注入(透明)」→AI 写这章会参考、「四审」→写完后检查(白话描述,**删重复的「去审稿」快捷** + 去 `next/link` import)、空/加载/错误态与「已排除」及 pin/排除 aria 全部注入→参考白话;实体透明内容本身未动;移动抽屉 label 同改本章参考。**P1-4 四动词可见范围提示**(非 hover-only):写本章=整章·从头写(表头按钮内)、续写=接着往下写、整章重写=整章·基于现有重写、润色选段=选中段·打磨(「或让 AI:」行内 2xs 副标签)。**P1-5 题材去弹窗**:删中途弹出的 `genrePromptOpen`/`onPickGenre`,改为项目无题材时正文上方**常驻内联题材条**(复用 `GenrePicker`,`onPick=setChosenGenre` 不自动开写)→ 首次「写本章」顺滑流式不被打断;no-slop 守卫保留(未选题材点写本章给 toast 引导而非静默生成)。守 §3:五交互未并框、写本章仍流式进正文、右栏透明度未删。**纯表现层,无新增 `lib/**` 逻辑**(`useGenreGate`/`directive` 未动 → 无新 vitest)。**门禁全绿**:lint/typecheck 干净 · vitest **710 passed**(基线持平)· `test:coverage` 通过(`lib/**` 95.47% ≥ 80)· `build` OK。仅动 `apps/web/`。**决策**:速查/对话记录(两抽屉,flag 均 true)非导航亦非那 4 个生成动词,留在底栏作辅助入口(保全接线,符「底栏 essentially 只剩状态 + 审稿」)。**P2(左栏分组/目录归位/放置规则)待后续。**
|
||||
- [2026-07-10] @frontend — **✅ 写作台 UX 改造 P0-1..P0-5 全落地**(`docs/design/ux-exploration.md` §4,回答用户原话「我怎么和 AI 对话」)。**P0-1** 底栏「AI 对话」→「对话记录」(只读日志正名,让「对话」一词归还真输入框):`AiConversationDrawer` 标题/`aria-label`/关闭 aria/空态(→「这里留存你和 AI 的往复记录」)+ 底栏按钮文案/title 同改。**P0-2** 把折叠 `<details>`「本章写作指令[可选]」原地提升为正文正上方**常驻输入条**(`Workbench.tsx` 内联 `DirectivePanel`):标题「告诉 AI 这章想怎么写」、去「可选」徽标、空章默认展开(`defaultOpen={initialText 空}`,受控 `useState` 取代原生 details)、「AI 写作诉求」→「你的写作要求」、textarea 邀请式 placeholder,**发送键即「写本章」**(内联 primary 写本章 / streaming 时切「停」,复用既有 `onWrite`/`stop` + `composeDirective` + 文风/剧情 chips + 流式打字机,**未改成聊天气泡**)。**P0-3** 删顶部「AI 工具条」:`AppShell` 去 `AiToolbar` 渲染 + `--chrome` 恒 4rem(原 7rem,省 48px),`git rm` 死代码 `components/AiToolbar.tsx`+`AiToolbarMoreMenu.tsx`+`lib/nav/ai-tools.ts`+`ai-tools.test.ts`(全仓仅 AppShell 用,无其它 importer)。**P0-4** 底栏「润色选段」常驻可点(去 `disabled`/hover-only title),无选区点击弹 toast「先在正文里选中一段再润色」(`openRefine` 守卫 + `canRefine` 从 `Toolbar` props 下线,仅留 Workbench 内做面板渲染守卫)。**P0-5** 输入条旁常驻一行 HITL 微文案「AI 给草稿,你决定采不采用,原文永远你说了算」+ 可再发现的「?这是什么」弹层(`HitlHelp`,非一次性 coach,Esc/点击外部关闭)。守 §3:五种交互未并框、写本章未改聊天、右栏透明度未删、未用 Sparkles 作 AI 标记、润色未藏到有选区才现。**纯表现层改造,无新增 `lib/**` 逻辑**(`directive.ts` 未动 → 无新 vitest;删 `ai-tools.test.ts` 使基线 715→710)。**门禁全绿**:lint/typecheck 干净 · vitest **710 passed** · `test:coverage` 通过(`lib/**` 95.47% ≥ 80)· `build` OK。仅动 `apps/web/`。**P1(底栏瘦身/统一采用词/右栏去术语)+ P2(左栏分组/目录归位/放置规则)待后续。**
|
||||
- [2026-07-10] @frontend — **✅ AC-3 后续:润色/整章重写面板内联对话气泡流**(用户反馈:不想每次开「AI 对话」抽屉才看得到对话,要在对话时就地内联可见)。抽出复用组件 `components/workbench/ConversationThread.tsx`(把抽屉内联的 `Bubble`/`readOptions`/`ACCEPT_LABEL` 提取为共享气泡列表,抽屉与内联面板共用 → 外观一致 DRY);新纯助手 `lib/workbench/inlineConversation.ts`(`mergeInlineThreads` 把已落库留痕〔本章 + 指定 kinds〕与「进行中」实时气泡按 id 去重合并、复用 `groupByThread` 定序;`toPendingView` 合成未落库气泡〔`meta._pending/_streaming` UI 标记〕;`isPendingView/isStreamingView/readSegment`)+ **RED→GREEN 9 单测**。RefinePanel/ChapterRewritePanel 内联渲染 `role=log` 滚动气泡流(本章 refine/rewrite + 其 clarify 往复 **∪** 本轮进行中气泡:流式 token 逐字进气泡、缓冲的 clarify Q&A 亦实时可见),输入框置底像聊天;**pending→persisted 无重影**(进行中气泡结束即清、已落库经 `mergeById` 去重);**fail-soft**(历史加载失败仍显示本轮实时气泡,不阻塞回炉/重写);气泡「再接受/回填」复用既有 DRAFT-only HITL 回调(drift-safe,正文仍只在验收才变);clarify gating/abort/停/接受悉数保留。**抽屉保留原样**(底栏仍可开,跨章/项目级全量历史浏览)。**门禁全绿**:lint/typecheck 干净 · vitest **715 passed**(706 基线 + 9)· `test:coverage` 通过(`lib/**` 95.48% ≥ 80)· `build` OK。仅动 `apps/web/`。
|
||||
- [2026-07-09] @qa — **✅ AC-4 E2E + #1/#6 真源围栏守卫|AI 对话(聊天记录)AC-1..AC-4 全交付**(计划 §6/§8)。**E2E**(`tests/test_ai_chat_history_e2e.py`,真 pg、**零 LLM 无网关**——纯 CRUD 侧记录,镜像 `test_t6_toolbox_e2e.py` 的 `e2e_sm` 生命周期)覆盖 §6 全 6 用例:① 缓冲多轮线程一批落库(作者含糊→AI 反问〔选项进 meta〕→作者答→AI 定稿,同 `thread_id`/`seq` 0..3)→ GET newest-first(`seq`/`role`/`thread_id`/`meta{options,version_no,segment}` 往复保真、全文 content 未截断);② 作用域分区(章级 ∪ 项目级 `chapter_no IS NULL`——`?chapter_no=N` 两者都返回、缺省仅项目级、项目级现于任一章抽屉);③ 跨多批 newest-first + `limit/offset` 分页 + `kind` 过滤;④ **append 只写 `ai_messages`**(UsageLedger/Chapter/ChapterReview/WorldEntity/Job 逐表零变化、`usage_ledger` 恒 0 证无网关)、GET 只读不建行;⑤ 未知项目 POST/GET 皆 404、非法 role/kind·空 messages·超上限 content/meta 五类 422 且失败零落库;⑥ clarify-abandon 代理(服务端无部分写路径,只持久化被显式 POST 的已定线程)。**围栏**(`tests/test_ai_messages_not_in_generation_path.py`,AST 静态守卫,**能变红**):memory/orchestrator 两目录 23 文件无一 import/引用 `AiMessage*`/`ai_message_repo`(Name/Attribute/Import 全扫)+ `MemoryRepos` 捆绑与 `assemble` 形参都不含它 + 植入违规自证探测器非空——把 ai_message repo 接进 `assemble()` 会直接失败(已用真 assemble.py 源注入验证变红)。守 #1/#6:聊天记录是 append-only 旁路侧记录(同 `usage_ledger` 物种),绝不喂 prompt/记忆注入。**门禁全绿**:ruff/format 干净 · mypy **238 文件** · **`alembic check` 无漂移** · pytest **1002 passed**(992 基线 + 10:E2E 6 + 围栏 4)。gotcha 已登记(ai_messages 真源围栏 = code-review BLOCKER)。仅动 `tests/` + `memory/gotchas.md` + `PROGRESS.md`,**未碰任何产品代码**。
|
||||
- [2026-07-09] @frontend — **✅ AC-3 gen:api + AI 对话 hook/抽屉 + 五类交互留痕**(计划 §4,`dce882c`/`ddf7fa0`/`59f2f60`/`33a9b76`)。**gen:api**(`dce882c`):`POST/GET /projects/{id}/ai-messages` + `AiMessage*` 4 schema 入 `schema.d.ts`(openapi.json 为 gitignore 构建产物),`lib/api/types.ts` re-export 码生成名。**核心**(`ddf7fa0`):`lib/workbench/aiConversation.ts` 纯视图模型(`kindLabel/kindBadge`、`bubbleSide`、`groupByThread` 按 `(created_at,seq)` 组内+线程定序〔**绝不单靠 created_at**——同批共享 created_at 靠 repo 的 seq 消歧〕、`partitionByScope`〔本章 ∪ 项目级〕、`acceptActionFor`〔rewrite→替换整章 / continue·generator→插入正文 / refine→回填选段 / clarify→无〕)+ 11 单测;`lib/workbench/useAiConversation.ts` Workbench 级单实例(懒加载去重 + `mergeById` 防并发覆盖 + 乐观插入/正位对账/失败回滚,**fail-soft 绝不 throw 进生成/HITL**〔守 #3 意图〕;`silent` 直投供无抽屉的工具箱页)+ 9 renderHook 测试;`components/workbench/AiConversationDrawer.tsx` 克隆 ContextDrawer 的 a11y(`role=dialog/log`、`aria-live` additions、Esc、focus trap、还原焦点、prefers-reduced-motion),本章对话 + 项目级生成两段气泡、clarify 只读选项芯片、ai 气泡挂 DRAFT-only 再接受。**hook 返回产物**(`59f2f60`):四生成 hook 加返回值(`RefineVersion|null`/`string|null`/`RewriteVersion|null`/`GeneratorArtifact|null`,向后兼容,补成功用例断言)。**五处接线**(`33a9b76`):RefinePanel/ChapterRewritePanel 结束后 append `[author(意见/答复), ai(产出)]`,**clarify buffer-until-concluded**——澄清 Q&A(原含糊意见→AI 反问〔选项进 meta〕→作者答复)缓冲,随最终产出批一起落库同 `thread_id`,**纯放弃不记**;refine ai 行 meta 存 `version_no`+原选段(每版重锚);ContinuePanel ai-only(续写无作者输入)meta `candidate_index`;GeneratorRunner append `[author(字段摘要,meta{tool_key,input_fields}), ai(渲染预览,meta{tool_key,output_kind})]`(空产物不记,`lib/toolbox/aiSummary.ts` 纯助手+5 单测),InlineToolbox 挂本章号、ToolboxPage silent 项目级(`chapter_no=null`);Workbench 挂 hook 单实例 + 抽屉 + 底栏「AI 对话」入口(`AI_CONVERSATION_ENABLED` 灰度)+ 三个再接受回调(成功关抽屉+toast、refine 漂移不盲替换)。**留痕永不改「是否/何时写正文」**——正文仍只在验收事务落库(守 #3)。**门禁全绿**:lint/typecheck 干净 · vitest **706 passed**(681 基线 + 25:aiConversation 11 + useAiConversation 9 + aiSummary 5)· `test:coverage` 通过(`lib/**` 95.44% ≥ 80,新纯模块/hook 全测)· `build` OK。→ 剩 **AC-4 @qa** E2E(计划 §6)+ P4 #1/#6 围栏。
|
||||
- [2026-07-09] @backend — **✅ AC-2 repo + append/list 端点 + schema + 契约**(计划 §3,`c675a74`)。repo `packages/core/ww_core/domain/ai_message_repo.py`(`AiMessageRepo`/`SqlAiMessageRepo`/`AiMessageView`/`AiTurnRow`,经 `ww_core.domain` 导出):`append`(一批多条 bubble → 批内 `seq`=enumerate 0 基位置、同 `thread_id`、meta 新 dict 不原地 mutate、`add_all`+一次 flush+refresh,**只 flush 不 commit**) + `list_for_chapter`(`created_at DESC, seq DESC` newest-first;`chapter_no` 非 None → 本章 ∪ 项目级 `IS NULL`,None → 仅项目级;可选 `kind`;`limit/offset`)。schemas `apps/api/ww_api/schemas/ai_messages.py`:批级 append 信封 `AiMessageAppendRequest{thread_id,chapter_no(ge=1)|null,kind,tool_key,messages}` + `AiMessageInput{role,content(min1,max200_000),meta(序列化≤50_000)}` + `AiMessageView` + `AiMessageListResponse{messages}`;`Literal` 为 kind/role 枚举权威(边界 422)。端点 `apps/api/ww_api/routers/ai_messages.py`(prefix `/projects`,tag `ai-messages`,已注册 main):**`POST /projects/{id}/ai-messages`**(201,项目 404,校验 422,append 只 flush + 端点 `commit()`) + **`GET /projects/{id}/ai-messages?chapter_no=&kind=`+`PageDep`**(200,newest-first,只读)。**这是显式记录写入**——五个生成端点逐字节不变、只读(守 #3);`ai_messages` 旁路侧记录,`assemble()`/agent 永不读(守 #1/#6)。注入缝 `get_ai_message_repo`。**TDD**:repo 真 pg 单测 9 passed(seq 0..n-1 / 同 thread_id / `created_at DESC,seq DESC` / union 含 NULL / 分页 / flush-only 跨 session 不可见);端点集成 12 passed(round-trip / 本章∪项目级 / kind 过滤 / 404×2 / 422×5〔非法 role/kind、空 messages、空 content、超上限 content、超上限 meta〕 / commit 边界 / 响应 key 集合不泄漏)。**门禁全绿**:ruff/format 干净 · mypy **238 文件** · **`alembic check` 无漂移**(本任务不加模型)· `export_openapi` 成功(新端点+4 schema 已入表面)· pytest **992 passed**(971 基线 + 21)· 新模块 cov router 100%/schemas 100%/repo 97%(余 Protocol `...` 桩),全局 89%。契约 **C3 扩(AC-2)已登记「稳定」** → **@frontend AC-3 须 `cd apps/web && pnpm gen:api`** 后消费码生成名。
|
||||
- [2026-07-09] @db — **✅ AC-1 `ai_messages` 表 + 迁移**(计划 §2 canonical DDL)。`packages/db/ww_db/models.py` 加 `AiMessage(UuidPk, CreatedAt, Base)`(补 `Uuid` 导入):`project_id`(FK projects CASCADE, NOT NULL, indexed)/`chapter_no`(Integer nullable=项目级)/`thread_id`(Uuid NOT NULL)/`seq`(Integer NOT NULL, repo 批内 0 基定序)/`kind`(Text 无 CHECK)/`tool_key`(Text nullable)/`role`(Text + `ck_ai_messages_role` CHECK author|ai)/`content`(Text NOT NULL 全文)/`meta`(JSONB NOT NULL server_default `'{}'`)/`id`+`created_at`(mixin);append-only 无 updated_at。复合索引 `ix_ai_messages_project_chapter=(project_id,chapter_no,created_at DESC)` 手写 `sa.text` DESC + FK 单列索引。迁移 `ceabd3c2c00d`(down=`f6a7b8c9d0e1`,**autogenerate 仅捕获 ai_messages 无杂散漂移**,DESC 表达式归一为 `sa.text`)。**T0.2 MVP schema 后第一张新业务表**——对话历史是 18 表都无的能力、正交于 review/job/手稿,属真新增(非重复建表);是**旁路侧记录非手稿真源**,`assemble()`/agent 永不读(守 #1/#3/#6,围栏留 AC-4/P4 收口)。**门禁全绿**:ruff/format 干净 · mypy **233 文件** · `alembic upgrade head` + **`alembic check` 无漂移** + **downgrade→upgrade 往复干净** · pytest **971 passed**(基线不变,无代码用新表)。契约 **C2 扩(AC-1)已登记「稳定」** → AC-2 @backend 可动工。
|
||||
- [2026-07-08] @devops — **✅ QA-CR CR-D1 抬高后端依赖下界至 lockfile 主次版本(保可复现)**。8 个 pyproject(`apps/api` + `packages/{core,agents,config,db,llm_gateway,shared,skills}`)的后端运行时依赖下界从过旧值抬到实锁 MAJOR.MINOR:`langgraph>=1.2`/`langgraph-checkpoint-postgres>=3.1`/`openai>=2.43`/`anthropic>=0.111`/`google-genai>=2.9`/`instructor>=1.15`/`tenacity>=9.1`/`pydantic>=2.13`/`pydantic-settings>=2.14`/`structlog>=26.1`/`asyncpg>=0.31`/`alembic>=1.18`/`fastapi>=0.137`/`uvicorn>=0.49`/`cryptography>=49`(每条 ≤ lockfile 实锁,同小版本线的 sqlalchemy/psycopg 留原样)。`uv.lock` 变动为**纯元数据刷新**——仅各 workspace 成员 `[package.metadata].requires-dist` 的 specifier 字符串随下界更新,**零 pinned `version=` 变动、零 re-resolve、零第三方版本 bump**。root dev 工具下界(pytest/ruff/mypy/httpx)+ 前端 vitest 3.x/tailwind 4.x 升级评估**明确不纳入**(非运行时可复现关键,超范围)。**门禁**:`uv lock --check` 绿(元数据刷新后一致)· `uv run ruff check .` 干净 · pytest **971 passed**(与基线一致,无回归)。无源码/测试改动。
|
||||
- [2026-07-08] @llm — **✅ QA-CR MEDIUM/LOW 网关+链整改批(CR-M2/M3/L1@llm/M1.4,TDD 逐项提交)**。**完成 6**:`32451a9` pricing 未知 model 发 `pricing_unknown_model` warning(成本仍 0);`9439b97` 三 adapter 瞬时错误消息经 `provider_error_summary(exc)` 只留类名+status(不泄 vendor 响应体);`832ab7d` `OpenAICompatAdapter.probe_connection()` 公开探测 + provider_deps 弃私有 `_client` 反手(CR-M1.4);`c9ffada` 续写链前一章无已验收终稿发 `chain_prior_accepted_missing` warning(CR-L1);`ec91c8f` 链 runner 改用公开 `graph.aget_state(config).interrupts` 判暂停(弃私有 `__interrupt__`);`6a739ca` review 节点五审 `asyncio.gather` 并行扇出(复用主图 add-only 记账并发范式 T3.8,collect 一次落库)。**SKIP 2**:CR-M2 `is_open()` CQS(惰性半开转移是唯一转半开点、拆分漏发命令会致熔断永不复位,风险>收益);CR-M3 `run_overdue_scan` 幂等(`scan_overdue` 本已是原子 `UPDATE...RETURNING`+`NOT IN[CLOSED,OVERDUE]`+commit-only-if-changed,且下一章 current+1 扫描自愈漏标,再动会触碰验收事务边界)。**门禁全绿**:后端 ruff/format 干净 · mypy **232 文件** · pytest **971 passed**(+11 新测:pricing 2 / 错误脱敏 4 / probe 2 / 续写缺前文告警 2 / 并行审 1)· 无 DDL/无迁移。守不变量 #1/#3/#4(熔断/验收事务/add-only 记账 sink 未动)。
|
||||
- [2026-07-08] @llm/@backend/@devops/@frontend — **✅ QA-CR CRITICAL + HIGH 整改批全部落地**(14 项 + 4 低危收尾)。多 agent 工作流:先 14 个只读 agent 逐项**扎根真实代码**产精确修复+测试规格(纠出 3 处台账前提漂移),再按 owner 四簇**串行整合**入单工作树(避免并发写树 + repo 级 `ruff format .` 竞态),各 TDD 红→绿、逐项补回归测试、逐 fix 小步提交,末尾 14 个 skeptic **对抗式复核**(实证每条 test 非 vacuous,0 problem)。**关键落地/纠偏**:C1 next 15.1.3→**15.5.20**+react **19.2.7**(`pnpm audit` 清、`dependencyFloors.ts` 下界守卫,**CVE 号无法对真库核验+密钥轮换为用户运维事项**);C2 **前提证伪**(langgraph 默认上限 10007 非 25,无实活崩溃)仍加显式顶层 `recursion_limit`;H4 request_id **条件透传**(否则覆盖 contextvars 反退化);H2 修请求级 httpx 泄漏时**揪出 E2E 会误触真网**(override 跟随修);H9 全文本字段上限+2 MiB body 中间件 413 兜底(契约 C3,`gen:api` 无漂移);H8 `.env.example` 换不可用占位符(**真 key 仍在 git 历史,曾用环境须轮换**)。**门禁全绿**:后端 mypy 229/pytest **934**/alembic 无漂移;前端 vitest **669**/build/coverage 95.41%。契约 C1-扩(LlmRequest.request_id)+ C3(H9 caps)。**MEDIUM/LOW 择批后清理。** 提交 `cb8fddf`→`dd6d2fc`(19 提交)。
|
||||
|
||||
- [2026-07-08] @backend — **✅ QA-CR 后端整改 5 项(TDD,逐 fix 提交)**。**CR-C2**:`chain_runner.run_chain_job` 给 `graph.ainvoke` 传显式 top-level `recursion_limit=count*NODES_PER_CHAPTER+HEADROOM`(防御硬化,命名常量)。**CR-H1**:Kimi OAuth 设备轮询移出 DB 会话——新增 `_poll_for_token`(纯 HTTP)/`_make_persist_work`/`_run_kimi_oauth_poll`,仅最终持久化经 `run_job` 开短会话(防连接池饥饿;删 `_make_poll_work`)。**CR-H2**:请求级 httpx 客户端改**生成器依赖** `_http_client`(`async with ... yield`,请求结束 aclose 修泄漏);`_default_http_client` 保留供后台裸调;E2E + 端点测试 override 键跟随改 `_http_client`。**CR-H7**:冻结 `TOOLBOX` 为只读 `MappingProxyType`(`_TOOLBOX_BY_KEY` 私有底 dict + `Final[Mapping]`,仿 SPECS)。**CR-H9**:用户请求字符串字段补 `max_length`(providers/generation/projects/style/foreshadow/rules/templates,命名常量无魔数,样本列表长度+每条双上界)+ 新错误码 `ErrorCode.PAYLOAD_TOO_LARGE`(413) + 应用级 `body_size_limit_middleware`(2 MiB,按 Content-Length 拦截自建 413 信封),`main.py` 注册。**门禁全绿**:ruff/format 干净 · mypy **229 文件**(0 错)· pytest **928**(+23:recursion_limit 1 / poll-no-session 1 / http-gen 1 / toolbox-readonly 1 / input-size 19[17 参数化 + samples 1 + body413 1])· **无 DDL/无迁移**。记 contracts C3(CR-H9:新增 `maxLength` + `PAYLOAD_TOO_LARGE`)。→ **@frontend 须 `pnpm gen:api`**(请求字段现有 `maxLength`;本 cluster 未代跑)。守不变量 #3/#8/#9。
|
||||
|
||||
- [2026-07-06] @backend/@llm/@frontend — **✅ 灵感⑦ 群像按定位配比·缩减版 + §0 归属纠偏(develop 直提)**。角色群像生成支持「按定位配比」下发。**契约变更** `CharacterGenerateRequest` 加 `role_mix:dict[str,int]|None`——`model_validator(mode="after")` 定义与既有 `count` 的关系:在场时校验(非空、每项≥1、总和≤`MAX_GENERATE_COUNT`=12)并把 `count` 归一为各值之和(构造期归一化,忽略客户端 count);缺省退回单一 `role`+`count`。**§0 归属纠偏**:`build_character_gen_context`+`run_character_gen`(`ww_core/orchestrator/generation_node.py`)确为 **@llm 域**——加末位默认 `role_mix=None` 关键字参,在场时注入「角色定位配比」块(保插入序、确定性、无时间戳,守 #6/#7 assemble 纯函数;覆盖单一 role 行);`routers/generation.py` 穿 `role_mix=body.role_mix`。`character-gen.md` 加「按配比严格分配 role、各定位之和=总数」纪律 → **regen `prompt_hashes.json`**(仅 character-gen 哈希变,**不进 SPECS→不 bump 计数**)。**前端** `cards.ts` 加 `sanitizeRoleMix`(去空/去重/clamp≥1/按 12 截断)+`roleMixTotal`+`buildCharacterGenerateRequest` 第 4 参 roleMix;`useCharacterGen` `CharacterGenInput.roleMix` 穿参;新 `RoleMixEditor.tsx` + `CharacterGenerator` SegmentedControl 单一/配比双模。**TDD**:schema 校验 7 例(总和≤12/空 dict/非正值拒绝)+ context 注入 2 例 + run 穿参 1 例 + 前端 cards 构造/clamp 5 例 + hook renderHook 1 例。**门禁全绿**:后端 ruff/format 干净 · mypy **222** · pytest **858**(+11)· 无迁移(未动模型,未跑 alembic);前端 gen:api/lint/typecheck 干净 · vitest **543**(+6)· coverage 95%(cards.ts 100%)· build OK。记 contracts C3。守不变量 #2/#6/#7/#9。**无 DB 迁移**。
|
||||
|
||||
- [2026-07-06] @llm/@db/@backend/@frontend — **✅ 灵感③ 人物塑造 advisory 审(develop 直提)**。写→审链新增第五审「人物塑造」:**advisory 单维——仅建议、不阻断验收**(不进 conflicts、不碰 `assert_conflicts_resolved`、**不入 `REVIEW_RESERVED_NAMES`**,D2);只做人物塑造、不做氛围(氛围噪声大折进 style/pace)。**新 SPEC** `characterization_spec`(`tier="analyst"` 不写 model,`reads=("characters",)` 以人物卡 motive/appearance 为客观锚点,`writes=()` 只读,`prompts/characterization.md` **显式忽略文本长度与辞藻华丽度**、引用逐字片段+置信度对抗 LLM-judge 冗长/辞藻偏见)+ schema `CharacterizationReview{issues:[…character/aspect/where/quote/diagnosis/suggestion/confidence]}`(全字段默认值守解析韧性)+ 注册 `SCHEMA_CATALOG["characterization"]`;**计数三处 22→23**(`specs.py` assert / `test_prompt_loader.py` len / `schema_catalog.py` docstring)+ **regen `prompt_hashes.json`**(仅加一条,无其余漂移)。**DB** `chapter_reviews` 加 1 列 `characterization`(**JSONB nullable**,迁移 `f6a7b8c9d0e1`,nullable 免 backfill)。**契约变更** `ReviewHistoryItem`+`ReviewView` 加 `characterization`;接线链 `graph.py REVIEW_SPECS` 末位追加 + `chain/nodes.py ReviewRecordRepo` + `collect.py {CHARACTERIZATION/extract_characterization/ReviewRecorder/collect_reviews}` + `review_repo.py {Protocol/SqlReviewRepo/_to_view}` + `sse.py {EVENT_CHARACTERIZATION/characterization_event/_section_result_events}`(normalize 按 sorted 键自动纳入,字典序排最前不打乱既有四审断言)。**前端** `lib/review/{sse.ts,history.ts,useReviewStream.ts}` + 新 `CharacterizationPanel.tsx`(advisory 只展示、**无裁决 UI**、按置信度降序 + 低置信度折叠控注意力预算)+ `ReviewReport.tsx` 挂面板 + 双 seed。**门禁全绿**:后端 ruff/format 干净 · mypy **221** · pytest **848**(+核心 char 审 12 + agents spec 11 + api accept 2 等)· alembic 无漂移;前端 gen:api/lint/typecheck 干净 · vitest **536**(+10 含 `test_characterization_does_not_block_accept` 对应前端 reducer/normalize/parse)· coverage 95% · build OK。记 contracts C3+C4。守不变量 #2/#3/#9。依赖 ⑧(motive/appearance 已入 CharacterCard 契约,作客观锚点)。
|
||||
@@ -245,3 +306,4 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移(无漂移,users st
|
||||
- [2026-06-28] @backend/@frontend — **UI Phase G 后端依赖补齐**:`ProjectResponse` 增加 `updated_at` / `pending_review_count`,`chapters` 加 `updated_at` 迁移 `8c1d2e3f4a5b`;`SqlProjectRepo` 聚合项目自身、章节、审稿时间作为最近编辑,并按草稿是否晚于最近 accepted/review 统计待审稿章数。前端 `pnpm gen:api` 后项目库默认按最近编辑排序,新增“待审稿”筛选,作品卡显示最近编辑与待审稿徽标。契约记 `memory/contracts.md` C3 扩,UI 计划记 `docs/design/ui-improvement-plan.md` 0.16。
|
||||
- [2026-06-29] @frontend/@qa — **UI review follow-up 修复完成**:补 `lib/review/chapterNav.ts` 使新增章导航测试可运行;`ReviewSectionPanel` 改为本地 open 状态,避免用户折叠后重渲染强制弹开;`ReviewReport.hasReport` 纳入 clean review;新增 `tests/test_project_metadata_e2e.py` 用真实 DB 覆盖项目最近编辑/待审稿统计口径。验证:前端 lint/typecheck/vitest **490 passed**/build;后端 ruff/format/mypy、pytest **763 passed**;浏览器抽样 review/settings 无 console error/横向溢出。
|
||||
- [2026-07-06] @frontend — **✅ 灵感⑥ 关系图谱(缩减版)+ D4 完成**(纯前端,无后端契约/迁移/gen:api)。新增 `lib/characters/graphLayout.ts`(纯逻辑:`roleStyle` role→分组配色关键词映射、`buildGraphNodes`/`buildGraphEdges`(kind→标签,`DEFAULT_RELATION_LABEL` 命名常量)、`countDanglingRelations`、`layoutGraph`(@dagrejs/dagre 一次性确定性初始坐标、中心→左上、不可变)、`buildRelationshipGraph` 端到端)——**仅用现有 `CharacterCardView.relations={name,kind,note}`**:边按 name-join、kind 作中文标签、节点按 role 上色,无 closeness/阵营(数据不存在,不硬造);悬空边(引用未入库角色)跳过并计数。新增 `components/characters/RelationshipGraph.tsx`(`'use client'`,React Flow @xyflow/react;CodexPage 经 `dynamic(ssr:false)` 挂载于人物 tab,显式容器高度 h-[28rem] + `import '@xyflow/react/dist/style.css'` + prefers-reduced-motion 关 fitView 动画 + 纸感 CSS 变量配色 + 悬空计数提示)。依赖:`@xyflow/react@^12.11.2`+`@dagrejs/dagre@^3.0.0`(见 contracts.md 2026-07-06)。TDD:graphLayout **+22 测**(role 映射/节点去空重名/kind 标签+默认/悬空跳过/自指跳过/无向去重/边 id 唯一/悬空计数/布局确定性+不可变/端到端);组件走人工/E2E。前端门禁全绿:lint 干净 · typecheck 干净 · **vitest 565 passed** · **coverage lib 95.4%(graphLayout.ts 97.6%)** · **build OK**(codex 路由 7.58 kB,React Flow 懒加载独立 chunk)。注:`pnpm build` 门禁与运行中的 `next dev`(3001) 共享 `.next` → 触发 dev 服务临时 500(stale chunk),非代码缺陷、dev watcher 下次重编自愈,未重启服务。
|
||||
- [2026-07-08] @frontend — **CR-M4/M5/L1 前端评审收尾(MEDIUM/LOW 打磨,仅 apps/web,无后端契约/gen:api)**。DONE:①a11y——`NavDrawer` 汉堡按钮补 `aria-controls`(新增 `Drawer` 可选 `id` prop 指向面板,默认 `nav-drawer`)、`ForeshadowCard` 的 `<dl>` 裸 `<div>` 行改真 `<dt>/<dd>` 结构(可视文案不变,importance 用 sr-only 标签);②`ReviewReport.flashMark` 的 `setTimeout` 句柄入 ref + 卸载 useEffect 清理(防定时器泄漏,提 `FLASH_DURATION_MS` 常量);③`useAccept` 手写 `ApiErrorEnvelope` 改由生成 schema 派生 `components["schemas"]["ErrorEnvelope"]`(端点专属 details 就地收窄,行为不变);④`applyFix.hasApplicableFix` 重写为对象入参**类型谓词** `patch is ApplicablePatch`,消除 `applyConflictFix` 里两处 `as string`(含收窄单测);⑤`server.parseJsonBody` 已具边界校验,补两条守卫分支测试(非对象体/非 JSON 体);⑥去冗余 cast:`stream/sse.ts` `idx.valueOf()`→`idx`、`jobs/job.ts` `dict["error"] as string`(typeof 守卫后 TS 自动收窄)。SKIP/NOOP(前提已过时/本就安全):`ProjectWizard` 换步聚焦标题已实现;`AppShell`/`LeftNav` 两处 `<nav>` 均已带 `aria-label`;`KNOWN_EVENTS` 加 `stop`——`stop` 是客户端 abort 动作非服务端 SSE 帧(对照 contracts.md 草稿 token/done/error、续审 section/conflict/foreshadow/pace/style/characterization/done/error 均无 stop),加入会为不存在的事件建处理;`CharacterGenerator`/`GeneratorRunner`/`ForeshadowCard.progress` 的 index-key——三处均为整表替换/追加式/静态展示列表(无逐项重排或删除,`CharacterCardItem`/`PreviewRow` 为无内部状态受控组件,选择/编辑均按 index 且随新预览整体重置),index 作 key 安全且无天然稳定 id,加合成 UUID 需重做 index 选择/编辑态属过度改造。门禁全绿:lint 干净 · typecheck 干净 · **vitest 681 passed**(+3)· **coverage lib 95.45%**(exit 0)· **build OK**。
|
||||
|
||||
15
README.md
15
README.md
@@ -10,7 +10,7 @@
|
||||
|
||||
- **引导式立项** — 一句灵感走向连载:选题材 / 基调 / 结局走向 / 叙事视角,AI 起草世界观、主角、金手指、总纲方案,一键建库。
|
||||
- **设定库(Codex)** — 结构化的单一真相源:
|
||||
- 角色卡(外貌 / 动机 / 目标 / 口癖 / 性格弧光 / 人物关系图)
|
||||
- 角色卡(外貌 / 动机 / 性格 / 背景 / 口癖 / 性格弧光 / 人物关系图)
|
||||
- 世界观(力量体系 / 势力 / 地理 / 词条)
|
||||
- **大纲 / 细纲** — AI 排分卷分章骨架,逐层细化到场景清单,并提示伏笔回收窗口。
|
||||
- **写章(SSE 流式)** — 注入设定 + 文风指纹,按 genre-aware 的网文写作教条(黄金三章、章末钩子、爽点密度)流式产出本章草稿。
|
||||
@@ -19,6 +19,7 @@
|
||||
- **创作工具箱(15 个生成器)** — 声明式 skill 框架,「加生成器 = 加一份声明」:脑洞 / 书名 / 简介 / 取名 / 金手指 / 词条 / 黄金开篇 / 细纲 / 续写 / 扩写 / 降 AI / 拆书,外加世界观 / 角色 / 大纲。
|
||||
- **模板库** — 复用与沉淀常用创作模板。
|
||||
- **多 Provider LLM 网关** — 路由 / 回退 / 熔断的自建薄网关;Agent 只声明能力 tier,网关按配置映射到 provider+model;支持 Kimi Code 订阅 OAuth(device flow)。
|
||||
- **沉浸式写作体验** — 纸感暖色 + 夜读双主题(`data-theme` 切换)、写作台专注模式(隐藏侧栏加宽正文)、`⌘K` 命令面板(跨页跳转 / 执行命令)、应用内帮助页 [`/help`](http://localhost:4000/help)(功能介绍 + 新手上手)。UI 对标编辑部式暖奶油设计语言,全程尊重 `prefers-reduced-motion` 与 WCAG AA。
|
||||
|
||||
---
|
||||
|
||||
@@ -43,6 +44,8 @@
|
||||
|
||||
前置:Python 3.12+ · Node 22 · [uv](https://docs.astral.sh/uv/) · pnpm · Docker
|
||||
|
||||
> **本地端口(宿主已 +1000,防与其他项目冲突)**:前端 **http://localhost:4000** · 后端 **http://localhost:9000**(API 文档 `/docs`)· Postgres **localhost:6432**。容器内部端口不变(3000 / 8000 / 5432),只改宿主发布端口;要改回默认各减 1000 即可。接线:前端 `apps/web/.env.local` 的 `NEXT_PUBLIC_API_BASE=http://localhost:9000` 指向后端;后端 `.env` 的 `CORS_ORIGINS` 放行 `http://localhost:4000`、`DATABASE_URL` 指向 `localhost:6432`。
|
||||
|
||||
```bash
|
||||
# 1. 克隆后,安装后端依赖(仓库根,uv workspace)
|
||||
uv sync
|
||||
@@ -59,17 +62,17 @@ cp .env.example .env
|
||||
docker compose up -d pg
|
||||
uv run alembic upgrade head
|
||||
|
||||
# 5. 起后端 API(默认 http://localhost:8000,文档 /docs)
|
||||
uv run uvicorn ww_api.main:app --reload
|
||||
# 5. 起后端 API(http://localhost:9000,文档 /docs)— 宿主端口 +1000
|
||||
uv run uvicorn ww_api.main:app --port 9000 --reload
|
||||
|
||||
# 6. 起前端(另开终端,默认 http://localhost:3000)
|
||||
# 6. 起前端(另开终端,http://localhost:4000)— pnpm dev 已内置 -p 4000
|
||||
cd apps/web && pnpm dev
|
||||
```
|
||||
|
||||
或一键起全套(pg + api + web):
|
||||
或一键起全套(pg + api + web,宿主端口均已 +1000):
|
||||
|
||||
```bash
|
||||
docker compose up
|
||||
docker compose up # web → :4000 · api → :9000 · pg → :6432
|
||||
```
|
||||
|
||||
> **LLM API Key 不放 `.env`**:在应用设置页 **`/settings/providers`** 录入,密文存于数据库凭据库(用 `CREDENTIAL_ENC_KEY` 加密)。`.env` 里只有那把加密主密钥本身。
|
||||
|
||||
@@ -12,4 +12,6 @@ EXPOSE 8000
|
||||
# 存活探测:镜像无 curl,用 stdlib urllib 打 /health(非 200/连接失败会抛异常,退出非零)。
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health').read()"]
|
||||
CMD ["uv", "run", "uvicorn", "ww_api.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
# --no-sync:用构建期已烤好的 --no-dev venv 直接起,禁止启动时重新同步(否则 `uv run` 会在
|
||||
# 运行时按默认组重装依赖、连 ruff/mypy 等 dev 工具都现场下载,慢且需运行时联网、每次启动重来)。
|
||||
CMD ["uv", "run", "--no-sync", "uvicorn", "ww_api.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
@@ -3,10 +3,10 @@ name = "ww-api"
|
||||
version = "0.0.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"fastapi>=0.111",
|
||||
"uvicorn[standard]>=0.30",
|
||||
"structlog>=24.1",
|
||||
"cryptography>=43",
|
||||
"fastapi>=0.137",
|
||||
"uvicorn[standard]>=0.49",
|
||||
"structlog>=26.1",
|
||||
"cryptography>=49",
|
||||
"ww-shared",
|
||||
"ww-config",
|
||||
"ww-db",
|
||||
|
||||
@@ -63,8 +63,13 @@ class FakeProjectRepo:
|
||||
self.rows[pid] = (owner_id, view)
|
||||
return view
|
||||
|
||||
async def list_for_owner(self, owner_id: uuid.UUID) -> list[ProjectView]:
|
||||
return [v for (o, v) in self.rows.values() if o == owner_id]
|
||||
async def list_for_owner(
|
||||
self, owner_id: uuid.UUID, *, limit: int | None = None, offset: int = 0
|
||||
) -> list[ProjectView]:
|
||||
views = [v for (o, v) in self.rows.values() if o == owner_id]
|
||||
if limit is None:
|
||||
return views
|
||||
return views[offset : offset + limit]
|
||||
|
||||
async def get(self, owner_id: uuid.UUID, project_id: uuid.UUID) -> ProjectView | None:
|
||||
entry = self.rows.get(project_id)
|
||||
@@ -358,8 +363,14 @@ class FakeOutlineReadRepo:
|
||||
async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None:
|
||||
return self.rows.get((project_id, chapter_no))
|
||||
|
||||
async def list_for_project(self, project_id: uuid.UUID) -> list[OutlineView]:
|
||||
views = [v for (p, _), v in self.rows.items() if p == project_id]
|
||||
async def list_for_project(
|
||||
self, project_id: uuid.UUID, *, volume: int | None = None
|
||||
) -> list[OutlineView]:
|
||||
views = [
|
||||
v
|
||||
for (p, _), v in self.rows.items()
|
||||
if p == project_id and (volume is None or v.volume == volume)
|
||||
]
|
||||
return sorted(views, key=lambda v: v.chapter_no)
|
||||
|
||||
|
||||
|
||||
325
apps/api/tests/test_ai_messages.py
Normal file
325
apps/api/tests/test_ai_messages.py
Normal file
@@ -0,0 +1,325 @@
|
||||
"""AC-2 POST/GET /projects/{id}/ai-messages 端点(内存替身,无 DB/无网络)。
|
||||
|
||||
docs/design/ai-chat-history-plan.md §3.3 / §6。覆盖:
|
||||
- POST 201 + 回显持久化视图(seq/id/created_at)+ 端点 commit 边界;
|
||||
- append→list 回环;项目级(chapter_no null) vs 本章 union;
|
||||
- 校验 → 422(非法 role、超上限 content、空 messages、超大 meta);
|
||||
- 未知项目 → 404;响应 key 集合不泄漏意外字段。
|
||||
|
||||
owner_id 全程 stub(单用户原型)。AI 一律不触达(纯 CRUD)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
from fakes_projects import FakeProjectRepo, FakeSession
|
||||
from ww_core.domain.ai_message_repo import AiMessageView, AiTurnRow
|
||||
from ww_core.domain.project_repo import ProjectView
|
||||
|
||||
# 期望的响应 bubble 字段集合(防泄漏意外字段)。
|
||||
_EXPECTED_KEYS = {
|
||||
"id",
|
||||
"project_id",
|
||||
"chapter_no",
|
||||
"thread_id",
|
||||
"seq",
|
||||
"kind",
|
||||
"tool_key",
|
||||
"role",
|
||||
"content",
|
||||
"meta",
|
||||
"created_at",
|
||||
}
|
||||
|
||||
|
||||
class _FakeAiMessageRepo:
|
||||
"""镜像 `SqlAiMessageRepo` 语义的内存版(批内 seq、newest-first union)。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.rows: list[AiMessageView] = []
|
||||
self._clock = 0
|
||||
|
||||
async def append(
|
||||
self,
|
||||
project_id: uuid.UUID,
|
||||
*,
|
||||
thread_id: uuid.UUID,
|
||||
chapter_no: int | None,
|
||||
kind: str,
|
||||
tool_key: str | None,
|
||||
rows: list[AiTurnRow],
|
||||
) -> list[AiMessageView]:
|
||||
self._clock += 1
|
||||
created = datetime(2026, 7, 9, tzinfo=UTC) + timedelta(seconds=self._clock)
|
||||
out: list[AiMessageView] = []
|
||||
for i, r in enumerate(rows):
|
||||
view = AiMessageView(
|
||||
id=uuid.uuid4(),
|
||||
project_id=project_id,
|
||||
chapter_no=chapter_no,
|
||||
thread_id=thread_id,
|
||||
seq=i,
|
||||
kind=kind,
|
||||
tool_key=tool_key,
|
||||
role=r.role,
|
||||
content=r.content,
|
||||
meta=dict(r.meta),
|
||||
created_at=created,
|
||||
)
|
||||
self.rows.append(view)
|
||||
out.append(view)
|
||||
return out
|
||||
|
||||
async def list_for_chapter(
|
||||
self,
|
||||
project_id: uuid.UUID,
|
||||
chapter_no: int | None,
|
||||
*,
|
||||
kind: str | None = None,
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> list[AiMessageView]:
|
||||
matched = [r for r in self.rows if r.project_id == project_id]
|
||||
if chapter_no is not None:
|
||||
matched = [r for r in matched if r.chapter_no in (chapter_no, None)]
|
||||
else:
|
||||
matched = [r for r in matched if r.chapter_no is None]
|
||||
if kind is not None:
|
||||
matched = [r for r in matched if r.kind == kind]
|
||||
matched.sort(key=lambda r: (r.created_at, r.seq), reverse=True)
|
||||
return matched[offset : offset + limit]
|
||||
|
||||
|
||||
def _make_client() -> tuple[httpx.AsyncClient, _FakeAiMessageRepo, FakeProjectRepo, FakeSession]:
|
||||
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
|
||||
from ww_api.main import create_app
|
||||
from ww_api.services.credentials import STUB_OWNER_ID
|
||||
from ww_api.services.project_deps import get_ai_message_repo, get_project_repo
|
||||
from ww_db import get_session
|
||||
|
||||
repo = _FakeAiMessageRepo()
|
||||
project_repo = FakeProjectRepo()
|
||||
session = FakeSession()
|
||||
|
||||
# 一个已存在的项目(owner = stub),供 404 对照——直接种入 fake 的行表(避开事件循环)。
|
||||
project_id = uuid.uuid4()
|
||||
project_repo.rows[project_id] = (STUB_OWNER_ID, ProjectView(id=project_id, title="AC-2"))
|
||||
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_ai_message_repo] = lambda: repo
|
||||
app.dependency_overrides[get_project_repo] = lambda: project_repo
|
||||
app.dependency_overrides[get_session] = lambda: session
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
client = httpx.AsyncClient(transport=transport, base_url="http://test")
|
||||
client.project_id = project_id # type: ignore[attr-defined]
|
||||
return client, repo, project_repo, session
|
||||
|
||||
|
||||
def _refine_batch(chapter_no: int | None = 5) -> dict[str, object]:
|
||||
return {
|
||||
"thread_id": str(uuid.uuid4()),
|
||||
"chapter_no": chapter_no,
|
||||
"kind": "refine",
|
||||
"messages": [
|
||||
{"role": "author", "content": "原选段"},
|
||||
{"role": "ai", "content": "重写段", "meta": {"version_no": 1, "segment": "原选段"}},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_returns_201_persisted_views_and_commits() -> None:
|
||||
client, repo, _pr, session = _make_client()
|
||||
pid = client.project_id # type: ignore[attr-defined]
|
||||
async with client:
|
||||
resp = await client.post(f"/projects/{pid}/ai-messages", json=_refine_batch(5))
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
msgs = body["messages"]
|
||||
assert [m["seq"] for m in msgs] == [0, 1]
|
||||
assert [m["role"] for m in msgs] == ["author", "ai"]
|
||||
assert msgs[1]["meta"] == {"version_no": 1, "segment": "原选段"}
|
||||
assert all(set(m.keys()) == _EXPECTED_KEYS for m in msgs)
|
||||
assert all(m["id"] and m["created_at"] for m in msgs)
|
||||
assert session.commits == 1
|
||||
assert len(repo.rows) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_then_list_roundtrip() -> None:
|
||||
client, _repo, _pr, _session = _make_client()
|
||||
pid = client.project_id # type: ignore[attr-defined]
|
||||
async with client:
|
||||
await client.post(f"/projects/{pid}/ai-messages", json=_refine_batch(5))
|
||||
resp = await client.get(f"/projects/{pid}/ai-messages", params={"chapter_no": 5})
|
||||
assert resp.status_code == 200
|
||||
msgs = resp.json()["messages"]
|
||||
# newest-first:批内 seq DESC → ai(seq1) 先于 author(seq0)。
|
||||
assert [m["content"] for m in msgs] == ["重写段", "原选段"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_chapter_union_includes_project_level() -> None:
|
||||
client, _repo, _pr, _session = _make_client()
|
||||
pid = client.project_id # type: ignore[attr-defined]
|
||||
async with client:
|
||||
await client.post(f"/projects/{pid}/ai-messages", json=_refine_batch(5))
|
||||
# 项目级工具箱生成(chapter_no 省略 → NULL)。
|
||||
await client.post(
|
||||
f"/projects/{pid}/ai-messages",
|
||||
json={
|
||||
"thread_id": str(uuid.uuid4()),
|
||||
"kind": "generator",
|
||||
"tool_key": "expand",
|
||||
"messages": [{"role": "ai", "content": "扩写预览"}],
|
||||
},
|
||||
)
|
||||
chapter_view = await client.get(f"/projects/{pid}/ai-messages", params={"chapter_no": 5})
|
||||
toolbox_view = await client.get(f"/projects/{pid}/ai-messages")
|
||||
chapter_contents = {m["content"] for m in chapter_view.json()["messages"]}
|
||||
toolbox_contents = {m["content"] for m in toolbox_view.json()["messages"]}
|
||||
assert chapter_contents == {"原选段", "重写段", "扩写预览"} # 本章 ∪ 项目级
|
||||
assert toolbox_contents == {"扩写预览"} # 仅项目级
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_kind_filter() -> None:
|
||||
client, _repo, _pr, _session = _make_client()
|
||||
pid = client.project_id # type: ignore[attr-defined]
|
||||
async with client:
|
||||
await client.post(f"/projects/{pid}/ai-messages", json=_refine_batch(2))
|
||||
await client.post(
|
||||
f"/projects/{pid}/ai-messages",
|
||||
json={
|
||||
"thread_id": str(uuid.uuid4()),
|
||||
"chapter_no": 2,
|
||||
"kind": "rewrite",
|
||||
"messages": [{"role": "ai", "content": "整章新版"}],
|
||||
},
|
||||
)
|
||||
resp = await client.get(
|
||||
f"/projects/{pid}/ai-messages", params={"chapter_no": 2, "kind": "rewrite"}
|
||||
)
|
||||
msgs = resp.json()["messages"]
|
||||
assert [m["kind"] for m in msgs] == ["rewrite"]
|
||||
assert [m["content"] for m in msgs] == ["整章新版"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_unknown_project_returns_404() -> None:
|
||||
client, repo, _pr, session = _make_client()
|
||||
async with client:
|
||||
resp = await client.post(f"/projects/{uuid.uuid4()}/ai-messages", json=_refine_batch(5))
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["error"]["code"] == "NOT_FOUND"
|
||||
assert session.commits == 0
|
||||
assert repo.rows == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_unknown_project_returns_404() -> None:
|
||||
client, _repo, _pr, _session = _make_client()
|
||||
async with client:
|
||||
resp = await client.get(f"/projects/{uuid.uuid4()}/ai-messages", params={"chapter_no": 1})
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["error"]["code"] == "NOT_FOUND"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_invalid_role_returns_422() -> None:
|
||||
client, _repo, _pr, session = _make_client()
|
||||
pid = client.project_id # type: ignore[attr-defined]
|
||||
async with client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/ai-messages",
|
||||
json={
|
||||
"thread_id": str(uuid.uuid4()),
|
||||
"chapter_no": 5,
|
||||
"kind": "refine",
|
||||
"messages": [{"role": "system", "content": "x"}],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert session.commits == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_invalid_kind_returns_422() -> None:
|
||||
client, _repo, _pr, _session = _make_client()
|
||||
pid = client.project_id # type: ignore[attr-defined]
|
||||
async with client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/ai-messages",
|
||||
json={
|
||||
"thread_id": str(uuid.uuid4()),
|
||||
"kind": "translate", # 非五类枚举 → 422
|
||||
"messages": [{"role": "ai", "content": "x"}],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_empty_messages_returns_422() -> None:
|
||||
client, _repo, _pr, _session = _make_client()
|
||||
pid = client.project_id # type: ignore[attr-defined]
|
||||
async with client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/ai-messages",
|
||||
json={"thread_id": str(uuid.uuid4()), "kind": "refine", "messages": []},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_empty_content_returns_422() -> None:
|
||||
client, _repo, _pr, _session = _make_client()
|
||||
pid = client.project_id # type: ignore[attr-defined]
|
||||
async with client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/ai-messages",
|
||||
json={
|
||||
"thread_id": str(uuid.uuid4()),
|
||||
"kind": "refine",
|
||||
"messages": [{"role": "ai", "content": ""}],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_content_over_cap_returns_422() -> None:
|
||||
client, _repo, _pr, _session = _make_client()
|
||||
pid = client.project_id # type: ignore[attr-defined]
|
||||
async with client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/ai-messages",
|
||||
json={
|
||||
"thread_id": str(uuid.uuid4()),
|
||||
"kind": "rewrite",
|
||||
"messages": [{"role": "ai", "content": "x" * 200_001}],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_oversize_meta_returns_422() -> None:
|
||||
client, _repo, _pr, _session = _make_client()
|
||||
pid = client.project_id # type: ignore[attr-defined]
|
||||
async with client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/ai-messages",
|
||||
json={
|
||||
"thread_id": str(uuid.uuid4()),
|
||||
"kind": "refine",
|
||||
"messages": [{"role": "ai", "content": "x", "meta": {"blob": "y" * 60_000}}],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
@@ -572,6 +572,66 @@ async def test_run_chain_job_no_conflict_completes_done(_patched_runner: None) -
|
||||
assert [a["chapter_no"] for a in fakes["accepted"]] == [1, 2]
|
||||
|
||||
|
||||
async def test_run_chain_job_sets_recursion_limit_scaled_to_count(
|
||||
_patched_runner: None, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""CR-C2:run_chain_job 给 graph.ainvoke 传显式 top-level recursion_limit(防御硬化)。
|
||||
|
||||
langgraph 默认上界已足够(无实时崩溃),但每链上界应显式 = count*NODES_PER_CHAPTER +
|
||||
HEADROOM。断言 config 键值 + 仍正常跑完(行为不变)。用 PROXY 包裹真 graph 记录 config
|
||||
(不 setattr 到可能冻结的 CompiledStateGraph 上)。
|
||||
"""
|
||||
from ww_core.orchestrator.chain import build_chain_graph as real_build
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
class _SpyGraph:
|
||||
def __init__(self, inner: Any) -> None:
|
||||
self._inner = inner
|
||||
|
||||
async def ainvoke(self, inp: Any, *, config: Any = None, **kw: Any) -> Any:
|
||||
captured["config"] = config
|
||||
return await self._inner.ainvoke(inp, config=config, **kw)
|
||||
|
||||
async def aget_state(self, config: Any, **kw: Any) -> Any:
|
||||
# runner 现经公开 aget_state 判 interrupt(CR-M3)——代理透传给真 graph。
|
||||
return await self._inner.aget_state(config, **kw)
|
||||
|
||||
def _spy_build(*args: Any, **kwargs: Any) -> Any:
|
||||
return _SpyGraph(real_build(*args, **kwargs))
|
||||
|
||||
monkeypatch.setattr("ww_api.services.chain_runner.build_chain_graph", _spy_build)
|
||||
|
||||
fakes = _chain_runner_fakes()
|
||||
gateway = _FakeChainGateway(conflicts=[])
|
||||
job_id = uuid.uuid4()
|
||||
saver = MemorySaver()
|
||||
|
||||
await run_chain_job(
|
||||
_session_factory,
|
||||
job_id,
|
||||
project_id=uuid.uuid4(),
|
||||
user_id=USER,
|
||||
chain_key="draft_volume",
|
||||
start_chapter_no=1,
|
||||
count=7,
|
||||
chain_gateway_builder=_async_returning(gateway),
|
||||
checkpointer_ctx=lambda: _memsaver_ctx(saver),
|
||||
accept_op=fakes["accept_op"],
|
||||
chapter_repo_factory=fakes["chapter_repo_factory"],
|
||||
review_repo_factory=fakes["review_repo_factory"],
|
||||
)
|
||||
|
||||
# (1) 显式上界 = count*NODES_PER_CHAPTER + HEADROOM = 7*4+10 = 38。
|
||||
assert captured["config"]["recursion_limit"] == 7 * 4 + 10 == 38
|
||||
# (2) recursion_limit 是 top-level 键(不在 configurable 下——langgraph 只认 top-level)。
|
||||
assert "recursion_limit" not in captured["config"]["configurable"]
|
||||
# (3) 仍正常跑完(行为不变)。
|
||||
rec = _RecordingJobRepo.state[job_id]
|
||||
assert rec["status"] == STATUS_DONE
|
||||
assert rec["result"]["written"] == list(range(1, 8))
|
||||
|
||||
|
||||
async def test_run_chain_job_conflict_sets_awaiting_then_resume_done(
|
||||
_patched_runner: None,
|
||||
) -> None:
|
||||
|
||||
213
apps/api/tests/test_chapters_list.py
Normal file
213
apps/api/tests/test_chapters_list.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""GET /projects/:id/chapters 列章端点 + 纯聚合逻辑测试(审稿选章枚举)。
|
||||
|
||||
分两层,与本仓「真 PG 行为交 tests/ E2E、单测保持确定性」纪律一致:
|
||||
- **纯聚合** `build_chapter_list`:喂造好的 chapter/review 行,断言 has_draft/accepted/
|
||||
reviewed_at、去重、按章号升序、空正文草稿不计(这是端点真正的业务逻辑,脱库直测)。
|
||||
- **端点集成**:FastAPI client + 覆盖 `get_chapter_lister` 为 Fake(同 test_projects.py 风格),
|
||||
断言 404、空项目 []、字段透传、升序;网关无关(本端点不触 LLM)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
from fakes_projects import FakeProjectRepo
|
||||
from ww_api.services.chapter_list import (
|
||||
ChapterRow,
|
||||
ReviewRow,
|
||||
build_chapter_list,
|
||||
)
|
||||
from ww_api.services.credentials import STUB_OWNER_ID
|
||||
from ww_core.domain.project_repo import ProjectView
|
||||
from ww_shared import ErrorCode
|
||||
|
||||
# ---- 纯聚合逻辑(build_chapter_list)----
|
||||
|
||||
|
||||
def _dt(day: int) -> datetime:
|
||||
return datetime(2026, 7, day, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_build_chapter_list_marks_draft_accepted_and_reviewed() -> None:
|
||||
# 三态各出现:草稿章(1) / 已验收章(2) / 已审章(3, 有 review 时间)。
|
||||
chapter_rows = [
|
||||
ChapterRow(chapter_no=1, status="draft", content="草稿正文"),
|
||||
ChapterRow(chapter_no=2, status="draft", content="待验收"),
|
||||
ChapterRow(chapter_no=2, status="accepted", content="终稿"),
|
||||
ChapterRow(chapter_no=3, status="draft", content="被审的草稿"),
|
||||
]
|
||||
review_rows = [ReviewRow(chapter_no=3, created_at=_dt(5))]
|
||||
|
||||
items = build_chapter_list(chapter_rows, review_rows)
|
||||
|
||||
by_no = {it.chapter_no: it for it in items}
|
||||
assert by_no[1].has_draft is True
|
||||
assert by_no[1].accepted is False
|
||||
assert by_no[1].reviewed_at is None
|
||||
assert by_no[2].accepted is True
|
||||
assert by_no[2].has_draft is True
|
||||
assert by_no[3].reviewed_at == _dt(5)
|
||||
|
||||
|
||||
def test_build_chapter_list_empty_returns_empty() -> None:
|
||||
assert build_chapter_list([], []) == []
|
||||
|
||||
|
||||
def test_build_chapter_list_sorted_ascending_and_deduped() -> None:
|
||||
# 乱序 + 同章多版本行 → 去重 + 章号升序。
|
||||
chapter_rows = [
|
||||
ChapterRow(chapter_no=3, status="draft", content="c3"),
|
||||
ChapterRow(chapter_no=1, status="draft", content="c1"),
|
||||
ChapterRow(chapter_no=1, status="accepted", content="c1a"),
|
||||
ChapterRow(chapter_no=2, status="accepted", content="c2"),
|
||||
]
|
||||
items = build_chapter_list(chapter_rows, [])
|
||||
assert [it.chapter_no for it in items] == [1, 2, 3]
|
||||
|
||||
|
||||
def test_build_chapter_list_reviewed_at_is_latest() -> None:
|
||||
# 同章多次审稿 → reviewed_at 取最近一次。
|
||||
chapter_rows = [ChapterRow(chapter_no=1, status="draft", content="x")]
|
||||
review_rows = [
|
||||
ReviewRow(chapter_no=1, created_at=_dt(3)),
|
||||
ReviewRow(chapter_no=1, created_at=_dt(9)),
|
||||
ReviewRow(chapter_no=1, created_at=_dt(6)),
|
||||
]
|
||||
items = build_chapter_list(chapter_rows, review_rows)
|
||||
assert items[0].reviewed_at == _dt(9)
|
||||
|
||||
|
||||
def test_build_chapter_list_empty_content_draft_not_marked() -> None:
|
||||
# 空/纯空白正文的草稿行不算「有草稿正文」(与 GET .../draft 语义一致)。
|
||||
chapter_rows = [
|
||||
ChapterRow(chapter_no=1, status="draft", content=" "),
|
||||
ChapterRow(chapter_no=1, status="draft", content=None),
|
||||
]
|
||||
items = build_chapter_list(chapter_rows, [])
|
||||
assert items[0].chapter_no == 1
|
||||
assert items[0].has_draft is False
|
||||
|
||||
|
||||
def test_build_chapter_list_review_only_chapter_appears() -> None:
|
||||
# 有审稿但无 chapters 行(作者以 body.draft 送审未存草稿)→ 该章仍枚举、标已审。
|
||||
items = build_chapter_list([], [ReviewRow(chapter_no=7, created_at=_dt(2))])
|
||||
assert len(items) == 1
|
||||
assert items[0].chapter_no == 7
|
||||
assert items[0].has_draft is False
|
||||
assert items[0].accepted is False
|
||||
assert items[0].reviewed_at == _dt(2)
|
||||
|
||||
|
||||
# ---- 端点集成(FastAPI client + Fake lister)----
|
||||
|
||||
|
||||
class _FakeChapterLister:
|
||||
"""实现 ChapterLister Protocol 的内存版:回放预置的列章结果。"""
|
||||
|
||||
def __init__(self, items_by_project: dict[uuid.UUID, list[object]] | None = None) -> None:
|
||||
self.items_by_project = items_by_project or {}
|
||||
|
||||
async def list_chapters(self, project_id: uuid.UUID) -> list[object]:
|
||||
return self.items_by_project.get(project_id, [])
|
||||
|
||||
|
||||
def _make_client(
|
||||
*,
|
||||
project_repo: FakeProjectRepo,
|
||||
lister: _FakeChapterLister,
|
||||
) -> httpx.AsyncClient:
|
||||
import os
|
||||
|
||||
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
|
||||
from ww_api.main import create_app
|
||||
from ww_api.services.project_deps import get_chapter_lister, get_project_repo
|
||||
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_project_repo] = lambda: project_repo
|
||||
app.dependency_overrides[get_chapter_lister] = lambda: lister
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
return httpx.AsyncClient(transport=transport, base_url="http://test")
|
||||
|
||||
|
||||
def _seed_project(project_repo: FakeProjectRepo) -> uuid.UUID:
|
||||
pid = uuid.uuid4()
|
||||
project_repo.rows[pid] = (STUB_OWNER_ID, ProjectView(id=pid, title="测试"))
|
||||
return pid
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_chapters_returns_items_with_fields() -> None:
|
||||
from ww_api.schemas.projects import ChapterListItem
|
||||
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = _seed_project(project_repo)
|
||||
lister = _FakeChapterLister(
|
||||
{
|
||||
pid: [
|
||||
ChapterListItem(chapter_no=1, has_draft=True, accepted=False, reviewed_at=None),
|
||||
ChapterListItem(chapter_no=2, has_draft=True, accepted=True, reviewed_at=_dt(4)),
|
||||
]
|
||||
}
|
||||
)
|
||||
client = _make_client(project_repo=project_repo, lister=lister)
|
||||
async with client:
|
||||
resp = await client.get(f"/projects/{pid}/chapters")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert isinstance(body, list)
|
||||
assert body[0] == {
|
||||
"chapter_no": 1,
|
||||
"has_draft": True,
|
||||
"accepted": False,
|
||||
"reviewed_at": None,
|
||||
}
|
||||
assert body[1]["chapter_no"] == 2
|
||||
assert body[1]["accepted"] is True
|
||||
assert body[1]["reviewed_at"].startswith("2026-07-04")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_chapters_empty_project_returns_empty_list() -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = _seed_project(project_repo)
|
||||
client = _make_client(project_repo=project_repo, lister=_FakeChapterLister())
|
||||
async with client:
|
||||
resp = await client.get(f"/projects/{pid}/chapters")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_chapters_unknown_project_404() -> None:
|
||||
client = _make_client(project_repo=FakeProjectRepo(), lister=_FakeChapterLister())
|
||||
async with client:
|
||||
resp = await client.get(f"/projects/{uuid.uuid4()}/chapters")
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_chapters_ascending_order() -> None:
|
||||
from ww_api.schemas.projects import ChapterListItem
|
||||
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = _seed_project(project_repo)
|
||||
lister = _FakeChapterLister(
|
||||
{
|
||||
pid: [
|
||||
ChapterListItem(chapter_no=1, has_draft=True, accepted=False, reviewed_at=None),
|
||||
ChapterListItem(chapter_no=2, has_draft=False, accepted=True, reviewed_at=None),
|
||||
ChapterListItem(chapter_no=5, has_draft=True, accepted=False, reviewed_at=None),
|
||||
]
|
||||
}
|
||||
)
|
||||
client = _make_client(project_repo=project_repo, lister=lister)
|
||||
async with client:
|
||||
resp = await client.get(f"/projects/{pid}/chapters")
|
||||
assert resp.status_code == 200
|
||||
assert [it["chapter_no"] for it in resp.json()] == [1, 2, 5]
|
||||
@@ -28,7 +28,7 @@ from ww_agents import (
|
||||
WorldEntityCard,
|
||||
WorldGenResult,
|
||||
)
|
||||
from ww_core.domain.character_repo import CharacterWriteView
|
||||
from ww_core.domain.character_repo import CharacterWriteFields, CharacterWriteView
|
||||
from ww_core.domain.project_repo import ProjectCreate
|
||||
from ww_core.domain.rule_repo import RuleListItemView
|
||||
from ww_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage
|
||||
@@ -120,6 +120,27 @@ class _FakeCharacterWriteRepo:
|
||||
)
|
||||
return CharacterWriteView(id=row_id, name=name, role=role)
|
||||
|
||||
async def create_many(
|
||||
self, project_id: uuid.UUID, cards: list[CharacterWriteFields]
|
||||
) -> list[CharacterWriteView]:
|
||||
# 镜像 Sql:逐卡 upsert(含批内重名去重),保序返回逐卡 View。
|
||||
return [
|
||||
await self.create(
|
||||
project_id,
|
||||
name=c.name,
|
||||
role=c.role,
|
||||
traits=list(c.traits),
|
||||
appearance=c.appearance,
|
||||
motive=c.motive,
|
||||
backstory=c.backstory,
|
||||
arc=c.arc,
|
||||
speech_tics=list(c.speech_tics),
|
||||
tags=list(c.tags),
|
||||
relations=[dict(r) for r in c.relations],
|
||||
)
|
||||
for c in cards
|
||||
]
|
||||
|
||||
|
||||
class _FakeRulesReadRepo:
|
||||
"""实现规则 repo 的读侧 `list_for_project`(带 id,供 list_rules 端点)。"""
|
||||
@@ -352,6 +373,39 @@ async def test_ingest_characters_no_conflict_writes_and_201() -> None:
|
||||
assert session.commits == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_multiple_characters_all_land_in_one_batch() -> None:
|
||||
# 批量入库:多张卡一次 create_many 全部落库,created 保序,单次 commit。
|
||||
repo = FakeProjectRepo()
|
||||
pid = await _seed_project(repo)
|
||||
gateway = _SchemaRoutingGateway({ContinuityReview: _no_conflicts()})
|
||||
char_repo = _FakeCharacterWriteRepo()
|
||||
app, session = _make_app(project_repo=repo, gateway=gateway, char_repo=char_repo)
|
||||
|
||||
def _card(name: str) -> dict[str, Any]:
|
||||
return {
|
||||
"name": name,
|
||||
"role": "配角",
|
||||
"traits": [],
|
||||
"backstory": "x",
|
||||
"arc": "y",
|
||||
"speech_tics": [],
|
||||
"tags": [],
|
||||
"relations": [],
|
||||
}
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/characters",
|
||||
json={"cards": [_card("甲"), _card("乙"), _card("丙")]},
|
||||
)
|
||||
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["created"] == ["甲", "乙", "丙"]
|
||||
assert [r["name"] for r in char_repo.rows] == ["甲", "乙", "丙"]
|
||||
assert session.commits == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_characters_with_conflict_blocks_409() -> None:
|
||||
repo = FakeProjectRepo()
|
||||
|
||||
@@ -38,8 +38,11 @@ class _OutlineRepo:
|
||||
async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None:
|
||||
return self._rows.get(chapter_no)
|
||||
|
||||
async def list_for_project(self, project_id: uuid.UUID) -> list[OutlineView]:
|
||||
return [self._rows[k] for k in sorted(self._rows)]
|
||||
async def list_for_project(
|
||||
self, project_id: uuid.UUID, *, volume: int | None = None
|
||||
) -> list[OutlineView]:
|
||||
rows = [self._rows[k] for k in sorted(self._rows)]
|
||||
return [v for v in rows if volume is None or v.volume == volume]
|
||||
|
||||
|
||||
class _CharRepo:
|
||||
|
||||
112
apps/api/tests/test_input_size_limits.py
Normal file
112
apps/api/tests/test_input_size_limits.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""CR-H9:用户请求文本字段 max_length 上界 + 应用级请求体大小中间件(防 DoS/成本)。
|
||||
|
||||
- 单元:各请求 schema 的字符串字段在 cap 处可构造、cap+1 触发 ValidationError(含样本列表长度
|
||||
与列表项两处上界——最大 DoS 面)。
|
||||
- 集成(无 pg):超大请求体经中间件直接 413(不入路由/DB)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
# create_app() 于 import ww_api.main 时构建,需 CREDENTIAL_ENC_KEY——先于任何 main 导入置好。
|
||||
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
|
||||
|
||||
from ww_api.schemas.foreshadow import ForeshadowRegisterRequest
|
||||
from ww_api.schemas.generation import CharacterGenerateRequest, WorldGenerateRequest
|
||||
from ww_api.schemas.projects import (
|
||||
AcceptRequest,
|
||||
DraftSaveRequest,
|
||||
DraftStreamRequest,
|
||||
ProjectCreateRequest,
|
||||
ReviewRequest,
|
||||
)
|
||||
from ww_api.schemas.providers import (
|
||||
ProviderCredentialInput,
|
||||
TierRoutingInput,
|
||||
)
|
||||
from ww_api.schemas.providers import (
|
||||
TestConnectionRequest as _TestConnectionRequest, # 别名避免 pytest 误采集 Test* 类
|
||||
)
|
||||
from ww_api.schemas.rules import RuleCreateRequest
|
||||
from ww_api.schemas.style import RefineRequest, StyleLearnRequest
|
||||
from ww_api.schemas.templates import TemplateCreateRequest
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _StrCase:
|
||||
"""一个字符串字段的上界用例:cap 处可构造、cap+1 触发 ValidationError。"""
|
||||
|
||||
schema: type[BaseModel]
|
||||
field_name: str
|
||||
cap: int
|
||||
case_id: str
|
||||
base: dict[str, Any] = field(default_factory=dict) # 该 schema 其余必填字段
|
||||
|
||||
|
||||
_STR_CASES: list[_StrCase] = [
|
||||
_StrCase(ProviderCredentialInput, "api_key", 512, "api_key<=512", {"provider": "p"}),
|
||||
_StrCase(ProviderCredentialInput, "provider", 64, "provider<=64", {"api_key": "k"}),
|
||||
_StrCase(TierRoutingInput, "model", 128, "model<=128", {"tier": "writer", "provider": "p"}),
|
||||
_StrCase(
|
||||
TierRoutingInput, "provider", 64, "routing-provider<=64", {"tier": "writer", "model": "m"}
|
||||
),
|
||||
_StrCase(_TestConnectionRequest, "provider", 64, "test-provider<=64"),
|
||||
_StrCase(WorldGenerateRequest, "brief", 10_000, "world-brief<=10000"),
|
||||
_StrCase(CharacterGenerateRequest, "brief", 10_000, "char-brief<=10000"),
|
||||
_StrCase(ProjectCreateRequest, "title", 200, "title<=200"),
|
||||
_StrCase(AcceptRequest, "final_text", 200_000, "final_text<=200000"),
|
||||
_StrCase(DraftSaveRequest, "text", 200_000, "draft-text<=200000"),
|
||||
_StrCase(ReviewRequest, "draft", 200_000, "review-draft<=200000"),
|
||||
_StrCase(RefineRequest, "segment", 20_000, "segment<=20000"),
|
||||
_StrCase(RefineRequest, "instruction", 2_000, "refine-instruction<=2000", {"segment": "s"}),
|
||||
_StrCase(DraftStreamRequest, "directive", 20_000, "directive<=20000"),
|
||||
_StrCase(ForeshadowRegisterRequest, "code", 100, "code<=100", {"title": "t"}),
|
||||
_StrCase(ForeshadowRegisterRequest, "title", 500, "foreshadow-title<=500", {"code": "c"}),
|
||||
_StrCase(RuleCreateRequest, "content", 10_000, "rule-content<=10000", {"level": "project"}),
|
||||
_StrCase(TemplateCreateRequest, "title", 200, "template-title<=200", {"body": "b"}),
|
||||
_StrCase(TemplateCreateRequest, "body", 20_000, "template-body<=20000", {"title": "t"}),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", _STR_CASES, ids=[c.case_id for c in _STR_CASES])
|
||||
def test_request_str_fields_reject_over_max_length(case: _StrCase) -> None:
|
||||
# Arrange / Act — 长度 == cap:构造成功。
|
||||
case.schema(**{**case.base, case.field_name: "x" * case.cap})
|
||||
# Assert — 长度 == cap+1:超界即 ValidationError。
|
||||
with pytest.raises(ValidationError):
|
||||
case.schema(**{**case.base, case.field_name: "x" * (case.cap + 1)})
|
||||
|
||||
|
||||
def test_style_learn_samples_item_and_list_caps() -> None:
|
||||
# 单条样本 == 20_000 字符 OK;20_001 → ValidationError(最大 DoS 面:每条正文都设上界)。
|
||||
StyleLearnRequest(samples=["x" * 20_000])
|
||||
with pytest.raises(ValidationError):
|
||||
StyleLearnRequest(samples=["x" * 20_001])
|
||||
# 列表 == 20 条 OK;21 条 → ValidationError(同时约束列表长度)。
|
||||
StyleLearnRequest(samples=["s"] * 20)
|
||||
with pytest.raises(ValidationError):
|
||||
StyleLearnRequest(samples=["s"] * 21)
|
||||
|
||||
|
||||
def test_oversized_request_body_rejected_413() -> None:
|
||||
"""超大请求体经 body-size 中间件直接 413(不入路由/DB)。
|
||||
|
||||
不以 context manager 进入 TestClient → 不跑 lifespan/seed → 不需 Postgres。中间件在
|
||||
路由前拦截,故请求根本不触达任何 DB。
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
from ww_api.main import create_app
|
||||
from ww_api.middleware import MAX_BODY_BYTES
|
||||
|
||||
client = TestClient(create_app())
|
||||
resp = client.post("/projects", content=b"x" * (MAX_BODY_BYTES + 1))
|
||||
|
||||
assert resp.status_code == 413
|
||||
assert resp.json()["error"]["code"] == "PAYLOAD_TOO_LARGE"
|
||||
@@ -15,6 +15,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
@@ -51,12 +53,65 @@ class _ScriptedHttp:
|
||||
return None
|
||||
|
||||
|
||||
class _TrackingSessionFactory:
|
||||
"""记录同一时刻在持会话数(open_count)+ 累计开启次数(total_opened)的 session 工厂替身。
|
||||
|
||||
CR-H1 断言用:轮询期间 open_count 应恒 0(无会话被长时间占用),持久化时 total_opened≥1。
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.open_count = 0
|
||||
self.total_opened = 0
|
||||
|
||||
def __call__(self) -> Any:
|
||||
from fakes_projects import FakeSession
|
||||
|
||||
@asynccontextmanager
|
||||
async def _cm() -> AsyncIterator[Any]:
|
||||
self.open_count += 1
|
||||
self.total_opened += 1
|
||||
try:
|
||||
yield FakeSession()
|
||||
finally:
|
||||
self.open_count -= 1
|
||||
|
||||
return _cm()
|
||||
|
||||
|
||||
class _ObservingHttp:
|
||||
"""观测型 scripted http:每次 POST 到 TOKEN_URL(轮询)时记录当下在持会话数。aclose 无操作。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
responses: list[_FakeResponse],
|
||||
*,
|
||||
factory: _TrackingSessionFactory,
|
||||
token_url: str,
|
||||
) -> None:
|
||||
self._responses = responses
|
||||
self._factory = factory
|
||||
self._token_url = token_url
|
||||
self.calls: list[tuple[str, dict[str, str]]] = []
|
||||
self.open_during_poll: list[int] = []
|
||||
|
||||
async def post(self, url: str, *, data: dict[str, str]) -> _FakeResponse:
|
||||
idx = len(self.calls)
|
||||
self.calls.append((url, data))
|
||||
if url == self._token_url: # 轮询 POST(区别于 device_authorization POST)。
|
||||
self.open_during_poll.append(self._factory.open_count)
|
||||
return self._responses[idx]
|
||||
|
||||
async def aclose(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _make_client(
|
||||
*,
|
||||
store: FakeCredentialStore,
|
||||
http: _ScriptedHttp,
|
||||
http: Any,
|
||||
enc_key: str,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
session_factory: Any = None,
|
||||
) -> TestClient:
|
||||
os.environ["CREDENTIAL_ENC_KEY"] = enc_key
|
||||
from ww_config import get_settings
|
||||
@@ -88,12 +143,15 @@ def _make_client(
|
||||
job_repo = FakeJobRepo()
|
||||
monkeypatch.setattr(job_runner, "SqlJobRepo", lambda _session: job_repo)
|
||||
|
||||
sf = session_factory if session_factory is not None else FakeSessionFactory()
|
||||
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_credential_store] = lambda: store
|
||||
app.dependency_overrides[get_job_repo] = lambda: job_repo
|
||||
app.dependency_overrides[get_session] = lambda: FakeSession()
|
||||
app.dependency_overrides[get_session_factory] = lambda: FakeSessionFactory()
|
||||
app.dependency_overrides[routers.kimi_oauth._default_http_client] = lambda: http
|
||||
app.dependency_overrides[get_session_factory] = lambda: sf
|
||||
# 请求级 http 现为生成器依赖 `_http_client`(CR-H2)——override 它注 fake(否则真生成器联网)。
|
||||
app.dependency_overrides[routers.kimi_oauth._http_client] = lambda: http
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@@ -163,6 +221,90 @@ def test_start_polls_through_authorization_pending(monkeypatch: pytest.MonkeyPat
|
||||
assert (STUB_OWNER_ID, "kimi-code") in store.oauth
|
||||
|
||||
|
||||
def test_poll_loop_does_not_hold_db_session(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""CR-H1:设备授权轮询期间**不得**占用 DB 会话(否则连接池被长时间空占→饥饿)。
|
||||
|
||||
轮询循环只做 HTTP(无 DB);仅最终持久化才开一个**短**会话。断言:轮询 POST 期间
|
||||
session_factory 未开会话(open_count==0),而持久化确开过短会话;凭据已落库、响应不泄 token。
|
||||
"""
|
||||
from ww_api.services.kimi_oauth import TOKEN_URL
|
||||
|
||||
enc_key = Fernet.generate_key().decode()
|
||||
store = FakeCredentialStore()
|
||||
factory = _TrackingSessionFactory()
|
||||
# device auth → poll #1 pending → poll #2 success(≥2 次轮询 POST)。
|
||||
http = _ObservingHttp(
|
||||
[
|
||||
_device_auth_response(),
|
||||
_FakeResponse(400, {"error": "authorization_pending"}),
|
||||
_token_response(),
|
||||
],
|
||||
factory=factory,
|
||||
token_url=TOKEN_URL,
|
||||
)
|
||||
client = _make_client(
|
||||
store=store,
|
||||
http=http,
|
||||
enc_key=enc_key,
|
||||
monkeypatch=monkeypatch,
|
||||
session_factory=factory,
|
||||
)
|
||||
|
||||
resp = client.post("/settings/providers/kimi-code/oauth/start")
|
||||
|
||||
assert resp.status_code == 202
|
||||
# 核心断言:轮询 POST 期间无 session_factory 会话在持。
|
||||
assert http.open_during_poll # 确有轮询 POST 发生
|
||||
assert all(c == 0 for c in http.open_during_poll)
|
||||
# 持久化时确开过一个短会话。
|
||||
assert factory.total_opened >= 1
|
||||
# 凭据已落库;响应绝不含 token。
|
||||
assert (STUB_OWNER_ID, "kimi-code") in store.oauth
|
||||
assert "secret-acc" not in resp.text
|
||||
assert "secret-ref" not in resp.text
|
||||
|
||||
|
||||
async def test_http_client_dependency_is_generator_and_closes_client(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""CR-H2:请求级 `_http_client` 是**生成器依赖**,请求结束时 aclose 客户端(不泄漏连接)。
|
||||
|
||||
驱动生成器一轮(enter→yield→exit),断言:是 async 生成器;yield 时未关闭;耗尽时恰好
|
||||
关闭一次。RED:修复前 `_http_client` 不存在(AttributeError)。
|
||||
"""
|
||||
import inspect
|
||||
|
||||
from ww_api.routers import kimi_oauth
|
||||
|
||||
closes = {"count": 0}
|
||||
|
||||
class _SpyClient:
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
pass
|
||||
|
||||
async def __aenter__(self) -> _SpyClient:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc: Any) -> None:
|
||||
closes["count"] += 1
|
||||
|
||||
async def aclose(self) -> None:
|
||||
closes["count"] += 1
|
||||
|
||||
monkeypatch.setattr("ww_api.routers.kimi_oauth.httpx.AsyncClient", _SpyClient)
|
||||
|
||||
# 是 async 生成器依赖(有 teardown),非普通函数(普通函数依赖无 teardown → 泄漏)。
|
||||
assert inspect.isasyncgenfunction(kimi_oauth._http_client)
|
||||
|
||||
gen = kimi_oauth._http_client()
|
||||
client = await gen.__anext__()
|
||||
assert client is not None
|
||||
assert closes["count"] == 0 # yield 时尚未关闭
|
||||
with pytest.raises(StopAsyncIteration):
|
||||
await gen.__anext__()
|
||||
assert closes["count"] == 1 # 请求结束恰好关闭一次
|
||||
|
||||
|
||||
def test_disconnect_clears_credential(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
enc_key = Fernet.generate_key().decode()
|
||||
store = FakeCredentialStore()
|
||||
|
||||
@@ -264,6 +264,25 @@ async def test_get_outline_with_volume_filters_to_that_volume() -> None:
|
||||
assert all(c["volume"] == 2 for c in vol2_chapters)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_outline_repo_volume_filter_pushed_into_query() -> None:
|
||||
# 卷过滤下推到 repo(DB WHERE),而非端点 Python 侧过滤:直接调 repo 断言只回该卷。
|
||||
repo = FakeOutlineReadRepo()
|
||||
pid = uuid.uuid4()
|
||||
repo.add_chapter(pid, volume=1, chapter_no=1, beats=["卷一·1"])
|
||||
repo.add_chapter(pid, volume=1, chapter_no=2, beats=["卷一·2"])
|
||||
repo.add_chapter(pid, volume=2, chapter_no=3, beats=["卷二·3"])
|
||||
|
||||
vol1 = await repo.list_for_project(pid, volume=1)
|
||||
vol2 = await repo.list_for_project(pid, volume=2)
|
||||
all_rows = await repo.list_for_project(pid)
|
||||
|
||||
assert [v.chapter_no for v in vol1] == [1, 2]
|
||||
assert all(v.volume == 1 for v in vol1)
|
||||
assert [v.chapter_no for v in vol2] == [3]
|
||||
assert [v.chapter_no for v in all_rows] == [1, 2, 3]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_outline_returns_empty_list_when_no_outline() -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
|
||||
@@ -10,7 +10,14 @@ import uuid
|
||||
import httpx
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
from fakes_projects import FakeChapterRepo, FakeProjectRepo, FakeWriterGateway
|
||||
from fakes_projects import (
|
||||
FakeChapterRepo,
|
||||
FakeProjectRepo,
|
||||
FakeReviewGateway,
|
||||
FakeSession,
|
||||
FakeWriterGateway,
|
||||
)
|
||||
from fastapi import FastAPI
|
||||
from ww_api.services.credentials import STUB_OWNER_ID
|
||||
from ww_core.domain.project_repo import ProjectView
|
||||
from ww_core.domain.repositories import (
|
||||
@@ -31,7 +38,9 @@ class _EmptyOutlineRepo:
|
||||
async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None:
|
||||
return None
|
||||
|
||||
async def list_for_project(self, project_id: uuid.UUID) -> list[OutlineView]:
|
||||
async def list_for_project(
|
||||
self, project_id: uuid.UUID, *, volume: int | None = None
|
||||
) -> list[OutlineView]:
|
||||
return []
|
||||
|
||||
|
||||
@@ -213,6 +222,33 @@ async def test_list_projects() -> None:
|
||||
assert all(p["pending_review_count"] == 0 for p in projects)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_projects_paginates_with_limit_and_offset() -> None:
|
||||
client, _, _, _ = _make_client()
|
||||
async with client:
|
||||
for t in ("甲", "乙", "丙"):
|
||||
await client.post("/projects", json={"title": t})
|
||||
page1 = await client.get("/projects?limit=2")
|
||||
page2 = await client.get("/projects?limit=2&offset=2")
|
||||
assert page1.status_code == 200
|
||||
assert [p["title"] for p in page1.json()["projects"]] == ["甲", "乙"]
|
||||
assert page2.status_code == 200
|
||||
assert [p["title"] for p in page2.json()["projects"]] == ["丙"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_projects_rejects_out_of_range_pagination() -> None:
|
||||
client, _, _, _ = _make_client()
|
||||
async with client:
|
||||
too_small = await client.get("/projects?limit=0")
|
||||
too_large = await client.get("/projects?limit=201")
|
||||
neg_offset = await client.get("/projects?offset=-1")
|
||||
# 边界越界 → FastAPI 422(limit∈[1,200]、offset≥0)。
|
||||
assert too_small.status_code == 422
|
||||
assert too_large.status_code == 422
|
||||
assert neg_offset.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_project_detail() -> None:
|
||||
client, _, _, _ = _make_client()
|
||||
@@ -361,3 +397,191 @@ async def test_get_draft_404_when_no_draft() -> None:
|
||||
resp = await client.get(f"/projects/{pid}/chapters/9/draft")
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
|
||||
|
||||
|
||||
# ---- POST /rewrite/clarify(整章重写预检澄清,WFW-9 M2)----
|
||||
|
||||
# 摘录上界外的哨兵:喂超长 prior_draft,断言超界正文不进网关 input(不喂全章、不泄漏)。
|
||||
_LEAK_SENTINEL = "泄漏哨兵段落禁止外泄"
|
||||
|
||||
|
||||
def _need_clarification_decision() -> object:
|
||||
from ww_agents import ClarifyDecision, ClarifyOption, ClarifyQuestion
|
||||
|
||||
return ClarifyDecision(
|
||||
need_clarification=True,
|
||||
questions=[
|
||||
ClarifyQuestion(
|
||||
question="你说本章节奏太慢,是想往哪个方向重写整章?",
|
||||
options=[
|
||||
ClarifyOption(label="砍支线压缩篇幅", value="删掉次要支线,压缩铺陈直入主线"),
|
||||
ClarifyOption(label="提前引爆冲突", value="把本章高潮冲突提前到前半段"),
|
||||
],
|
||||
allow_free_text=True,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _clear_rewrite_decision() -> object:
|
||||
from ww_agents import ClarifyDecision
|
||||
|
||||
return ClarifyDecision(
|
||||
need_clarification=False,
|
||||
verification="我会把本章高潮提前、删掉开头回忆插叙,直接进冲突,对吗?",
|
||||
)
|
||||
|
||||
|
||||
def _clarify_app(
|
||||
*,
|
||||
project_repo: FakeProjectRepo,
|
||||
session: FakeSession | None = None,
|
||||
clarify_gateway: object | None = None,
|
||||
) -> FastAPI:
|
||||
"""构造只挂 rewrite/clarify 端点依赖的 app(project_repo + session + clarify 网关)。"""
|
||||
import os
|
||||
|
||||
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
|
||||
from ww_api.main import create_app
|
||||
from ww_api.services.project_deps import get_clarify_gateway, get_project_repo
|
||||
from ww_db import get_session
|
||||
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_project_repo] = lambda: project_repo
|
||||
app.dependency_overrides[get_session] = lambda: session or FakeSession()
|
||||
if clarify_gateway is not None:
|
||||
app.dependency_overrides[get_clarify_gateway] = lambda: clarify_gateway
|
||||
return app
|
||||
|
||||
|
||||
def _clarify_client(app: FastAPI) -> httpx.AsyncClient:
|
||||
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
|
||||
return httpx.AsyncClient(transport=transport, base_url="http://test")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rewrite_clarify_vague_feedback_asks_one_question_and_commits() -> None:
|
||||
# (a) 含糊章级意见 → need_clarification True + 恰 1 问;analyst 档 + 末尾 commit 落账。
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = _seed_project(project_repo)
|
||||
session = FakeSession()
|
||||
gateway = FakeReviewGateway(parsed=_need_clarification_decision())
|
||||
app = _clarify_app(project_repo=project_repo, session=session, clarify_gateway=gateway)
|
||||
|
||||
async with _clarify_client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/chapters/3/rewrite/clarify",
|
||||
json={"feedback": "节奏太慢", "prior_draft": "本章开头……"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["need_clarification"] is True
|
||||
assert len(body["questions"]) == 1
|
||||
assert body["questions"][0]["options"][0]["label"] == "砍支线压缩篇幅"
|
||||
assert body["questions"][0]["allow_free_text"] is True
|
||||
# 不变量 #2:analyst 档;意见折进网关 input;末尾一次 commit(网关 usage_ledger 落库)。
|
||||
assert gateway.requests[0].tier == "analyst"
|
||||
assert "节奏太慢" in str(gateway.requests[0].input)
|
||||
assert session.commits == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rewrite_clarify_clear_feedback_proceeds_read_only() -> None:
|
||||
# (b) 明确意见 → need_clarification False + verification;只读、无回滚。
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = _seed_project(project_repo)
|
||||
session = FakeSession()
|
||||
gateway = FakeReviewGateway(parsed=_clear_rewrite_decision())
|
||||
app = _clarify_app(project_repo=project_repo, session=session, clarify_gateway=gateway)
|
||||
|
||||
async with _clarify_client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/chapters/3/rewrite/clarify",
|
||||
json={"feedback": "把本章高潮提前到前半段,删掉开头回忆插叙。"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["need_clarification"] is False
|
||||
assert body["questions"] == []
|
||||
assert body["verification"] is not None
|
||||
# 只读不写业务表:仅一次 commit 落网关 usage,无回滚(不变量 #3)。
|
||||
assert session.commits == 1
|
||||
assert session.rollbacks == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rewrite_clarify_unknown_project_404() -> None:
|
||||
# (c) 项目不存在 → 404(触网关前 fail-fast)。
|
||||
app = _clarify_app(
|
||||
project_repo=FakeProjectRepo(),
|
||||
clarify_gateway=FakeReviewGateway(parsed=_clear_rewrite_decision()),
|
||||
)
|
||||
async with _clarify_client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{uuid.uuid4()}/chapters/1/rewrite/clarify",
|
||||
json={"feedback": "节奏太慢"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rewrite_clarify_fails_open_on_wrong_parsed() -> None:
|
||||
# (d) 网关返回非 ClarifyDecision → 端点仍 200 + need_clarification False(韧性回退,非 500)。
|
||||
from ww_agents import WorldGenResult
|
||||
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = _seed_project(project_repo)
|
||||
session = FakeSession()
|
||||
gateway = FakeReviewGateway(parsed=WorldGenResult(entities=[]))
|
||||
app = _clarify_app(project_repo=project_repo, session=session, clarify_gateway=gateway)
|
||||
|
||||
async with _clarify_client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/chapters/3/rewrite/clarify",
|
||||
json={"feedback": "重写一遍"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["need_clarification"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rewrite_clarify_empty_feedback_422() -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = _seed_project(project_repo)
|
||||
app = _clarify_app(
|
||||
project_repo=project_repo,
|
||||
clarify_gateway=FakeReviewGateway(parsed=_clear_rewrite_decision()),
|
||||
)
|
||||
async with _clarify_client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/chapters/1/rewrite/clarify", json={"feedback": ""}
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rewrite_clarify_bounds_excerpt_and_never_leaks_manuscript() -> None:
|
||||
# 有界摘录:超长 prior_draft 的越界正文不进网关 input,响应也绝不回灌正文(脱敏)。
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = _seed_project(project_repo)
|
||||
session = FakeSession()
|
||||
gateway = FakeReviewGateway(parsed=_clear_rewrite_decision())
|
||||
app = _clarify_app(project_repo=project_repo, session=session, clarify_gateway=gateway)
|
||||
|
||||
# 哨兵放在远超摘录上界处(10k 填充 + 哨兵),确保被截断在摘录外。
|
||||
prior_draft = ("正" * 10_000) + _LEAK_SENTINEL
|
||||
|
||||
async with _clarify_client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/chapters/3/rewrite/clarify",
|
||||
json={"feedback": "重写本章", "prior_draft": prior_draft},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
# 越界正文既不进 LLM 输入,也不出现在响应体(正文只喂有界摘录、绝不回灌)。
|
||||
assert _LEAK_SENTINEL not in str(gateway.requests[0].input)
|
||||
assert _LEAK_SENTINEL not in resp.text
|
||||
|
||||
@@ -59,10 +59,12 @@ def _app_with_overrides(
|
||||
session_factory: FakeSessionFactory | None = None,
|
||||
extract_gateway: object | None = None,
|
||||
refine_gateway: object | None = None,
|
||||
clarify_gateway: object | None = None,
|
||||
) -> FastAPI:
|
||||
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
|
||||
from ww_api.main import create_app
|
||||
from ww_api.services.project_deps import (
|
||||
get_clarify_gateway,
|
||||
get_job_repo,
|
||||
get_project_repo,
|
||||
get_refine_gateway,
|
||||
@@ -85,6 +87,8 @@ def _app_with_overrides(
|
||||
app.dependency_overrides[get_style_extract_gateway] = lambda: extract_gateway
|
||||
if refine_gateway is not None:
|
||||
app.dependency_overrides[get_refine_gateway] = lambda: refine_gateway
|
||||
if clarify_gateway is not None:
|
||||
app.dependency_overrides[get_clarify_gateway] = lambda: clarify_gateway
|
||||
return app
|
||||
|
||||
|
||||
@@ -394,3 +398,134 @@ async def test_refine_without_credentials_503() -> None:
|
||||
assert resp.status_code == 503
|
||||
assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE
|
||||
assert session.commits == 0
|
||||
|
||||
|
||||
# ---- POST /refine/clarify(润色预检澄清)----
|
||||
|
||||
|
||||
def _need_clarification() -> object:
|
||||
from ww_agents import ClarifyDecision, ClarifyOption, ClarifyQuestion
|
||||
|
||||
return ClarifyDecision(
|
||||
need_clarification=True,
|
||||
questions=[
|
||||
ClarifyQuestion(
|
||||
question="你想让这段更有张力,是指哪种方向?",
|
||||
options=[
|
||||
ClarifyOption(label="加快节奏", value="拆短句、压缩铺陈"),
|
||||
ClarifyOption(label="加重冲突", value="强化人物对立"),
|
||||
],
|
||||
allow_free_text=True,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _clear_decision() -> object:
|
||||
from ww_agents import ClarifyDecision
|
||||
|
||||
return ClarifyDecision(
|
||||
need_clarification=False,
|
||||
verification="我会把被字句改成主动句,其余不动,对吗?",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clarify_returns_need_clarification_and_commits() -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = await _seed_project(project_repo)
|
||||
session = FakeSession()
|
||||
gateway = FakeReviewGateway(parsed=_need_clarification())
|
||||
app = _app_with_overrides(project_repo=project_repo, session=session, clarify_gateway=gateway)
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/chapters/1/refine/clarify",
|
||||
json={"segment": "原始段落。", "instruction": "更有张力"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["need_clarification"] is True
|
||||
assert len(body["questions"]) == 1
|
||||
assert body["questions"][0]["options"][0]["label"] == "加快节奏"
|
||||
assert body["questions"][0]["allow_free_text"] is True
|
||||
# analyst 档 + 末尾 commit(记账落库);指令折进输入文本。
|
||||
assert gateway.requests[0].tier == "analyst"
|
||||
assert session.commits == 1
|
||||
assert "更有张力" in str(gateway.requests[0].input)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clarify_returns_clear_decision_read_only() -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = await _seed_project(project_repo)
|
||||
session = FakeSession()
|
||||
gateway = FakeReviewGateway(parsed=_clear_decision())
|
||||
app = _app_with_overrides(project_repo=project_repo, session=session, clarify_gateway=gateway)
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/chapters/2/refine/clarify",
|
||||
json={"segment": "把这句的被字句改成主动句。", "instruction": ""},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["need_clarification"] is False
|
||||
assert body["questions"] == []
|
||||
assert body["verification"] is not None
|
||||
# 只读不写库:无写侧 repo 参与,仅一次 commit 落网关 usage(不变量 #3)。
|
||||
assert session.commits == 1
|
||||
assert session.rollbacks == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clarify_unknown_project_404() -> None:
|
||||
app = _app_with_overrides(
|
||||
project_repo=FakeProjectRepo(),
|
||||
clarify_gateway=FakeReviewGateway(parsed=_clear_decision()),
|
||||
)
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{uuid.uuid4()}/chapters/1/refine/clarify",
|
||||
json={"segment": "原段", "instruction": "x"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clarify_empty_segment_422() -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = await _seed_project(project_repo)
|
||||
app = _app_with_overrides(
|
||||
project_repo=project_repo, clarify_gateway=FakeReviewGateway(parsed=_clear_decision())
|
||||
)
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(f"/projects/{pid}/chapters/1/refine/clarify", json={"segment": ""})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clarify_without_credentials_503() -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = await _seed_project(project_repo)
|
||||
session = FakeSession()
|
||||
|
||||
async def _no_creds() -> object:
|
||||
raise AppError(ErrorCode.LLM_UNAVAILABLE, "未配置凭据", {"provider": "deepseek"})
|
||||
|
||||
app = _app_with_overrides(project_repo=project_repo, session=session)
|
||||
from ww_api.services.project_deps import get_clarify_gateway
|
||||
|
||||
app.dependency_overrides[get_clarify_gateway] = _no_creds
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/chapters/1/refine/clarify", json={"segment": "原段"}
|
||||
)
|
||||
|
||||
assert resp.status_code == 503
|
||||
assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE
|
||||
assert session.commits == 0
|
||||
|
||||
@@ -33,8 +33,13 @@ class _FakeTemplateRepo:
|
||||
self.rows[view.id] = view
|
||||
return view
|
||||
|
||||
async def list_for_owner(self, owner_id: uuid.UUID) -> list[TemplateView]:
|
||||
return list(self.rows.values())
|
||||
async def list_for_owner(
|
||||
self, owner_id: uuid.UUID, *, limit: int | None = None, offset: int = 0
|
||||
) -> list[TemplateView]:
|
||||
views = list(self.rows.values())
|
||||
if limit is None:
|
||||
return views
|
||||
return views[offset : offset + limit]
|
||||
|
||||
async def delete(self, owner_id: uuid.UUID, template_id: uuid.UUID) -> bool:
|
||||
if template_id in self.rows:
|
||||
|
||||
@@ -32,7 +32,7 @@ from ww_core.domain.outline_write_repo import OutlineWriteView
|
||||
from ww_core.domain.project_repo import ProjectCreate
|
||||
from ww_core.domain.repositories import OutlineView
|
||||
from ww_core.domain.rule_repo import RuleWriteView
|
||||
from ww_core.domain.world_entity_repo import WorldEntityWriteView
|
||||
from ww_core.domain.world_entity_repo import WorldEntityFields, WorldEntityWriteView
|
||||
from ww_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage
|
||||
from ww_shared import AppError, ErrorCode
|
||||
|
||||
@@ -75,6 +75,15 @@ class _FakeWorldWriteRepo:
|
||||
self.rows.append({"type": type, "name": name, "rules": list(rules)})
|
||||
return WorldEntityWriteView(id=uuid.uuid4(), type=type, name=name)
|
||||
|
||||
async def create_many(
|
||||
self, project_id: uuid.UUID, entities: list[WorldEntityFields]
|
||||
) -> list[WorldEntityWriteView]:
|
||||
# 镜像 Sql:批量插入,保序返回逐条 View。
|
||||
return [
|
||||
await self.create(project_id, type=e.type, name=e.name, rules=list(e.rules))
|
||||
for e in entities
|
||||
]
|
||||
|
||||
|
||||
class _FakeOutlineWriteRepo:
|
||||
def __init__(self) -> None:
|
||||
@@ -569,6 +578,30 @@ async def test_ingest_world_entities_no_conflict_writes_201() -> None:
|
||||
assert session.commits == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_multiple_world_entities_all_land_in_one_batch() -> None:
|
||||
# 批量入库:多条实体一次 create_many 全部落库,created 保序,单次 commit。
|
||||
repo = FakeProjectRepo()
|
||||
pid = await _seed_project(repo)
|
||||
gateway = _SchemaRoutingGateway({ContinuityReview: _no_conflicts()})
|
||||
app, session, world_repo, _ = _make_app(project_repo=repo, gateway=gateway)
|
||||
payload = {
|
||||
"world_entities": [
|
||||
{"type": "力量体系", "name": "吞噬系统", "rules": ["每日上限"]},
|
||||
{"type": "势力", "name": "天机阁", "rules": ["中立"]},
|
||||
{"type": "地点", "name": "无尽海", "rules": []},
|
||||
]
|
||||
}
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(f"/projects/{pid}/skills/golden-finger/ingest", json=payload)
|
||||
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["created"] == ["吞噬系统", "天机阁", "无尽海"]
|
||||
assert [r["name"] for r in world_repo.rows] == ["吞噬系统", "天机阁", "无尽海"]
|
||||
assert session.commits == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_world_entities_conflict_blocks_409() -> None:
|
||||
repo = FakeProjectRepo()
|
||||
|
||||
@@ -14,8 +14,13 @@ from ww_db import get_sessionmaker
|
||||
from ww_shared import AppError, ErrorBody, ErrorCode, ErrorEnvelope
|
||||
|
||||
from ww_api.logging_config import configure_logging, get_logger
|
||||
from ww_api.middleware import REQUEST_ID_HEADER, request_id_middleware
|
||||
from ww_api.middleware import (
|
||||
REQUEST_ID_HEADER,
|
||||
body_size_limit_middleware,
|
||||
request_id_middleware,
|
||||
)
|
||||
from ww_api.routers import (
|
||||
ai_messages,
|
||||
chain,
|
||||
foreshadow,
|
||||
generation,
|
||||
@@ -74,6 +79,8 @@ def create_app() -> FastAPI:
|
||||
allow_headers=["content-type", "authorization", REQUEST_ID_HEADER],
|
||||
)
|
||||
app.middleware("http")(request_id_middleware)
|
||||
# 请求体大小守卫(CR-H9):在路由前拦截超大 body,直接 413。
|
||||
app.middleware("http")(body_size_limit_middleware)
|
||||
|
||||
@app.exception_handler(AppError)
|
||||
async def _app_error_handler(request: Request, exc: AppError) -> JSONResponse:
|
||||
@@ -117,6 +124,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(rules.router)
|
||||
app.include_router(style.router)
|
||||
app.include_router(templates.router)
|
||||
app.include_router(ai_messages.router)
|
||||
app.include_router(generation.router)
|
||||
app.include_router(generation.skills_router)
|
||||
app.include_router(toolbox.router)
|
||||
|
||||
@@ -12,10 +12,14 @@ from collections.abc import Awaitable, Callable
|
||||
|
||||
import structlog
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
from starlette.responses import JSONResponse, Response
|
||||
from ww_shared import ErrorBody, ErrorCode, ErrorEnvelope
|
||||
|
||||
REQUEST_ID_HEADER = "x-request-id"
|
||||
|
||||
# 请求体大小上限(2 MiB):超过即 413,防超大 body 撑爆内存/成本(CR-H9)。
|
||||
MAX_BODY_BYTES = 2 * 1024 * 1024
|
||||
|
||||
# 放行安全字符集(字母数字 . _ -,1–128 位):覆盖 uuid4().hex、常见 trace id 与客户端
|
||||
# 自定义短 id;含空白/控制字符/换行/注入序列/超长的一律丢弃改生成新 uuid——防止把未经
|
||||
# 校验的客户端值原样写进日志/响应头(日志注入防护)。
|
||||
@@ -38,3 +42,29 @@ async def request_id_middleware(
|
||||
response = await call_next(request)
|
||||
response.headers[REQUEST_ID_HEADER] = request_id
|
||||
return response
|
||||
|
||||
|
||||
async def body_size_limit_middleware(
|
||||
request: Request, call_next: Callable[[Request], Awaitable[Response]]
|
||||
) -> Response:
|
||||
"""按 `Content-Length` 拦截超大请求体,超限直接 413(CR-H9)。
|
||||
|
||||
用户中间件跑在 FastAPI 的 AppError 处理器**之外**,故必须自建错误信封响应(不能靠抛
|
||||
AppError)。分块编码(无 `Content-Length`)无法在此拦截——单用户原型可接受的限制。
|
||||
"""
|
||||
content_length = request.headers.get("content-length")
|
||||
if content_length is not None:
|
||||
try:
|
||||
size = int(content_length)
|
||||
except ValueError:
|
||||
size = 0 # 非法头防御性视作 0,交由下游正常解析/报错。
|
||||
if size > MAX_BODY_BYTES:
|
||||
envelope = ErrorEnvelope(
|
||||
error=ErrorBody(
|
||||
code=ErrorCode.PAYLOAD_TOO_LARGE,
|
||||
message="请求体过大",
|
||||
request_id=_sanitize_request_id(request.headers.get(REQUEST_ID_HEADER)),
|
||||
)
|
||||
)
|
||||
return JSONResponse(status_code=413, content=envelope.model_dump())
|
||||
return await call_next(request)
|
||||
|
||||
37
apps/api/ww_api/pagination.py
Normal file
37
apps/api/ww_api/pagination.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""列表端点的分页参数(`limit`/`offset`,边界校验 + 合理默认)。
|
||||
|
||||
无界列表端点(owner 维度:projects / templates)原来一次返回全部行,行数随用户
|
||||
数据线性增长。这里提供一个可复用的 FastAPI 依赖 `get_page`,把 `limit`/`offset`
|
||||
在边界处校验(越界 → 422)后交端点。默认返回第一页(`offset=0` + `DEFAULT_PAGE_LIMIT`),
|
||||
不传参的老客户端行为不破坏;`limit` 有上限(`MAX_PAGE_LIMIT`)防一次性拉爆。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, Query
|
||||
|
||||
# 合理默认 + 上限(单用户原型:默认页足够覆盖常见数据量;上限防一次拉全表)。
|
||||
DEFAULT_PAGE_LIMIT = 50
|
||||
MAX_PAGE_LIMIT = 200
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Page:
|
||||
"""一页的边界(不可变):`limit` 条数上限、`offset` 起始偏移。"""
|
||||
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
def get_page(
|
||||
limit: Annotated[int, Query(ge=1, le=MAX_PAGE_LIMIT)] = DEFAULT_PAGE_LIMIT,
|
||||
offset: Annotated[int, Query(ge=0)] = 0,
|
||||
) -> Page:
|
||||
"""校验并封装分页参数(`limit∈[1,MAX]`、`offset≥0`;越界 FastAPI 返 422)。"""
|
||||
return Page(limit=limit, offset=offset)
|
||||
|
||||
|
||||
PageDep = Annotated[Page, Depends(get_page)]
|
||||
114
apps/api/ww_api/routers/ai_messages.py
Normal file
114
apps/api/ww_api/routers/ai_messages.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""AI 对话(聊天记录)端点(AC-2 / ai-chat-history-plan §3.3)。
|
||||
|
||||
作者↔AI 五类往复交换(refine/rewrite/clarify/continue/generator)落库,供每章「AI 对话」
|
||||
抽屉重放。**本端点是显式的记录写入**(前端在一轮交换结束后 POST 一批)——不是生成端点;
|
||||
五个生成端点保持只读、逐字节不变(守不变量 #3)。`ai_messages` 是侧记录,assemble()/agent
|
||||
永不读它(守 #1/#6)。
|
||||
|
||||
- POST /projects/{id}/ai-messages 一批 append(201;项目不存在 → 404)。
|
||||
- GET /projects/{id}/ai-messages 列出(newest-first;`chapter_no` 过滤含项目级 NULL;分页)。
|
||||
|
||||
提交边界:`AiMessageRepo.append` 只 flush,POST 端点写后 `commit()`(仿模板/规则写侧)。
|
||||
GET 只读不 commit。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_core.domain import AiMessageRepo, AiTurnRow, ProjectRepo
|
||||
from ww_core.domain.ai_message_repo import AiMessageView as AiMessageRepoView
|
||||
from ww_db import get_session
|
||||
from ww_shared import AppError, ErrorCode
|
||||
|
||||
from ww_api.logging_config import get_logger
|
||||
from ww_api.pagination import PageDep
|
||||
from ww_api.schemas.ai_messages import (
|
||||
AiKind,
|
||||
AiMessageAppendRequest,
|
||||
AiMessageListResponse,
|
||||
AiMessageView,
|
||||
)
|
||||
from ww_api.services.credentials import STUB_OWNER_ID
|
||||
from ww_api.services.project_deps import get_ai_message_repo, get_project_repo
|
||||
|
||||
log = get_logger("ww.api.ai_messages")
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["ai-messages"])
|
||||
|
||||
AiMessageRepoDep = Annotated[AiMessageRepo, Depends(get_ai_message_repo)]
|
||||
ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
|
||||
def _to_response_view(v: AiMessageRepoView) -> AiMessageView:
|
||||
"""repo 视图 → API 视图(同字段镜像;避免路由碰 repo 视图类型泄漏)。"""
|
||||
return AiMessageView(
|
||||
id=v.id,
|
||||
project_id=v.project_id,
|
||||
chapter_no=v.chapter_no,
|
||||
thread_id=v.thread_id,
|
||||
seq=v.seq,
|
||||
kind=v.kind,
|
||||
tool_key=v.tool_key,
|
||||
role=v.role,
|
||||
content=v.content,
|
||||
meta=v.meta,
|
||||
created_at=v.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{project_id}/ai-messages", status_code=201)
|
||||
async def append_ai_messages(
|
||||
project_id: uuid.UUID,
|
||||
body: AiMessageAppendRequest,
|
||||
request: Request,
|
||||
project_repo: ProjectRepoDep,
|
||||
repo: AiMessageRepoDep,
|
||||
session: SessionDep,
|
||||
) -> AiMessageListResponse:
|
||||
"""一批往复 bubble append(201)。项目不存在 → 404;非法 kind/role/空/超上限 → 422。"""
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
project = await project_repo.get(STUB_OWNER_ID, project_id)
|
||||
if project is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
|
||||
views = await repo.append(
|
||||
project_id,
|
||||
thread_id=body.thread_id,
|
||||
chapter_no=body.chapter_no,
|
||||
kind=body.kind,
|
||||
tool_key=body.tool_key,
|
||||
rows=[AiTurnRow(role=m.role, content=m.content, meta=m.meta) for m in body.messages],
|
||||
)
|
||||
await session.commit()
|
||||
log.info(
|
||||
"ai_messages_appended",
|
||||
project_id=str(project_id),
|
||||
request_id=request_id,
|
||||
thread_id=str(body.thread_id),
|
||||
kind=body.kind,
|
||||
count=len(views),
|
||||
)
|
||||
return AiMessageListResponse(messages=[_to_response_view(v) for v in views])
|
||||
|
||||
|
||||
@router.get("/{project_id}/ai-messages")
|
||||
async def list_ai_messages(
|
||||
project_id: uuid.UUID,
|
||||
project_repo: ProjectRepoDep,
|
||||
repo: AiMessageRepoDep,
|
||||
page: PageDep,
|
||||
chapter_no: Annotated[int | None, Query(ge=1)] = None,
|
||||
kind: AiKind | None = None,
|
||||
) -> AiMessageListResponse:
|
||||
"""列出 AI 对话(newest-first)。`chapter_no` → 本章 ∪ 项目级(NULL);缺省 → 仅项目级。"""
|
||||
project = await project_repo.get(STUB_OWNER_ID, project_id)
|
||||
if project is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
|
||||
views = await repo.list_for_chapter(
|
||||
project_id, chapter_no, kind=kind, limit=page.limit, offset=page.offset
|
||||
)
|
||||
return AiMessageListResponse(messages=[_to_response_view(v) for v in views])
|
||||
@@ -33,6 +33,7 @@ from ww_agents import (
|
||||
worldbuilder_spec,
|
||||
)
|
||||
from ww_core.domain import (
|
||||
CharacterWriteFields,
|
||||
CharacterWriteRepo,
|
||||
ProjectRepo,
|
||||
RuleWriteRepo,
|
||||
@@ -372,10 +373,8 @@ async def ingest_characters(
|
||||
rejected_tables=rejected,
|
||||
)
|
||||
|
||||
created: list[str] = []
|
||||
for card in allowed.get("characters", []):
|
||||
view = await char_repo.create(
|
||||
project_id,
|
||||
fields = [
|
||||
CharacterWriteFields(
|
||||
name=card.name,
|
||||
role=card.role,
|
||||
traits=list(card.traits),
|
||||
@@ -387,7 +386,10 @@ async def ingest_characters(
|
||||
tags=list(card.tags),
|
||||
relations=[r.model_dump() for r in card.relations],
|
||||
)
|
||||
created.append(view.name)
|
||||
for card in allowed.get("characters", [])
|
||||
]
|
||||
# 批量 upsert(一次 flush 落全部),保序返回逐卡 View。
|
||||
created = [view.name for view in await char_repo.create_many(project_id, fields)]
|
||||
|
||||
# 提交边界:网关 ledger(precheck)+ 角色写侧均只 flush → 端点末尾一次 commit。
|
||||
await session.commit()
|
||||
|
||||
@@ -6,13 +6,11 @@ import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_db import get_session
|
||||
from ww_db.models import Job
|
||||
from ww_core.domain import JobRepo
|
||||
from ww_shared import AppError, ErrorCode, ErrorEnvelope
|
||||
|
||||
from ww_api.schemas.jobs import JobResponse
|
||||
from ww_api.services.project_deps import get_job_repo
|
||||
|
||||
router = APIRouter(prefix="/jobs", tags=["jobs"])
|
||||
|
||||
@@ -23,9 +21,9 @@ router = APIRouter(prefix="/jobs", tags=["jobs"])
|
||||
)
|
||||
async def get_job(
|
||||
job_id: uuid.UUID,
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
job_repo: Annotated[JobRepo, Depends(get_job_repo)],
|
||||
) -> JobResponse:
|
||||
job = (await session.execute(select(Job).where(Job.id == job_id))).scalar_one_or_none()
|
||||
job = await job_repo.get(job_id)
|
||||
if job is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"job {job_id} not found")
|
||||
return JobResponse(
|
||||
|
||||
@@ -17,6 +17,8 @@ token 仅以 Fernet 密文存 `provider_credentials.oauth_enc`。
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Annotated, Any
|
||||
|
||||
import httpx
|
||||
@@ -47,6 +49,7 @@ from ww_api.services.kimi_oauth import (
|
||||
AuthorizationPending,
|
||||
DeviceAuth,
|
||||
SlowDown,
|
||||
TokenSet,
|
||||
decrypt_oauth_bundle,
|
||||
encrypt_oauth_bundle,
|
||||
poll_token,
|
||||
@@ -70,51 +73,102 @@ _JOB_KIND_KIMI_OAUTH = "kimi_oauth"
|
||||
_MAX_POLL_ATTEMPTS = 200
|
||||
|
||||
|
||||
#: 请求/后台 HTTP 客户端超时(秒)。
|
||||
_HTTP_TIMEOUT_SECONDS = 30.0
|
||||
|
||||
|
||||
def _default_http_client() -> AsyncHttpClient:
|
||||
return httpx.AsyncClient(timeout=30.0)
|
||||
"""裸工厂:现建一个 httpx 客户端(**后台轮询**直接调用,自行在 finally 里 aclose)。"""
|
||||
return httpx.AsyncClient(timeout=_HTTP_TIMEOUT_SECONDS)
|
||||
|
||||
|
||||
def _make_poll_work(device: DeviceAuth) -> Any:
|
||||
"""构造后台轮询工作闭包:循环 poll token 直到成功/过期/拒绝。
|
||||
async def _http_client() -> AsyncIterator[AsyncHttpClient]:
|
||||
"""请求级 httpx 客户端**生成器依赖**:请求结束时经 `async with` 自动 aclose(CR-H2)。
|
||||
|
||||
`work(session)` 自建 httpx 客户端 + 凭据 store(用 `run_job` 传入的独立 session)→
|
||||
按 `interval` 轮询 token → 成功则加密存 `oauth_enc` 并返回非密 job 结果 → 过期/拒绝则
|
||||
抛 AppError(`run_job` 置 job failed)。**token 绝不进 job 结果/日志**。
|
||||
普通函数依赖无 teardown,返回的 client 永不关闭 → 每次 `/start` 泄漏一个连接。故请求路径
|
||||
用此生成器依赖(后台路径仍用裸工厂 `_default_http_client`,自管生命周期)。
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT_SECONDS) as http:
|
||||
yield http
|
||||
|
||||
async def work(session: AsyncSession) -> dict[str, Any]:
|
||||
enc_key = get_settings().credential_enc_key.get_secret_value()
|
||||
store = SqlCredentialStore(session)
|
||||
|
||||
# SlowDown 退避每次增大的间隔量(秒)。
|
||||
_SLOW_DOWN_STEP_SECONDS = 5
|
||||
|
||||
|
||||
async def _poll_for_token(http: AsyncHttpClient, device: DeviceAuth) -> TokenSet:
|
||||
"""设备授权轮询循环(**纯 HTTP,无 DB 会话**,CR-H1):直到拿到 token 或超时。
|
||||
|
||||
`authorization_pending` → 继续;`slow_down` → 增大 interval;成功 → 返回 `TokenSet`;
|
||||
次数耗尽 → 抛 `AppError`(视作过期,由调用方经 `run_job` 置 job failed)。
|
||||
"""
|
||||
interval = max(1, device.interval)
|
||||
|
||||
http = _default_http_client()
|
||||
try:
|
||||
for _ in range(_MAX_POLL_ATTEMPTS):
|
||||
await asyncio.sleep(interval)
|
||||
try:
|
||||
token = await poll_token(http, device.device_code)
|
||||
return await poll_token(http, device.device_code)
|
||||
except AuthorizationPending:
|
||||
continue
|
||||
except SlowDown:
|
||||
interval += 5
|
||||
interval += _SLOW_DOWN_STEP_SECONDS
|
||||
continue
|
||||
# 成功:加密 token 包入库(auth_type=oauth)。明文 token 不进结果/日志。
|
||||
blob = encrypt_oauth_bundle(token, key=enc_key)
|
||||
await store.upsert_oauth_credential(STUB_OWNER_ID, KIMI_CODE_PROVIDER, blob)
|
||||
return {"connected": True, "provider": KIMI_CODE_PROVIDER}
|
||||
# 轮询次数耗尽(视作过期)。
|
||||
raise AppError(
|
||||
ErrorCode.LLM_UNAVAILABLE,
|
||||
"Kimi 设备授权轮询超时",
|
||||
{"provider": KIMI_CODE_PROVIDER},
|
||||
)
|
||||
|
||||
|
||||
def _make_persist_work(token: TokenSet) -> Any:
|
||||
"""构造**最终持久化**工作闭包:只做「加密 token → upsert oauth 凭据」的一次短事务。
|
||||
|
||||
`run_job` 用独立 session 调用此 work;轮询已在会话外完成,故此处会话极短。
|
||||
**明文 token 绝不进 job 结果/日志。**
|
||||
"""
|
||||
|
||||
async def work(session: AsyncSession) -> dict[str, Any]:
|
||||
enc_key = get_settings().credential_enc_key.get_secret_value()
|
||||
store = SqlCredentialStore(session)
|
||||
blob = encrypt_oauth_bundle(token, key=enc_key)
|
||||
await store.upsert_oauth_credential(STUB_OWNER_ID, KIMI_CODE_PROVIDER, blob)
|
||||
return {"connected": True, "provider": KIMI_CODE_PROVIDER}
|
||||
|
||||
return work
|
||||
|
||||
|
||||
def _raise_work(exc: Exception) -> Any:
|
||||
"""构造只把 `exc` 重抛的 work(轮询失败路径:交由 `run_job` 分类脱敏并置 job failed)。"""
|
||||
|
||||
async def work(_session: AsyncSession) -> dict[str, Any]:
|
||||
raise exc
|
||||
|
||||
return work
|
||||
|
||||
|
||||
async def _run_kimi_oauth_poll(
|
||||
session_factory: SessionFactory,
|
||||
job_id: uuid.UUID,
|
||||
device: DeviceAuth,
|
||||
*,
|
||||
request_id: str | None = None,
|
||||
) -> None:
|
||||
"""后台任务:**先在会话外**轮询 token,再经 `run_job` 开短会话持久化(CR-H1)。
|
||||
|
||||
轮询期间不占用任何 DB 会话(防连接池饥饿);成功 → 交由 `run_job` 落库并置 done;
|
||||
失败 → 把异常带进 `run_job`,走其 `_classify_job_error`/`_mark_failed` 脱敏置 failed。
|
||||
"""
|
||||
http = _default_http_client()
|
||||
try:
|
||||
token = await _poll_for_token(http, device)
|
||||
work = _make_persist_work(token)
|
||||
except Exception as exc: # noqa: BLE001 — 失败经 run_job 统一脱敏置 job failed,不在此处冒泡。
|
||||
work = _raise_work(exc)
|
||||
finally:
|
||||
# `httpx.AsyncClient` 有 `aclose`(最小 Protocol 无;测试 fake 也实现了它)。
|
||||
aclose = getattr(http, "aclose", None)
|
||||
if aclose is not None:
|
||||
await aclose()
|
||||
|
||||
return work
|
||||
await run_job(session_factory, job_id, work, request_id=request_id)
|
||||
|
||||
|
||||
@router.post("/start", status_code=202)
|
||||
@@ -125,7 +179,7 @@ async def start_oauth(
|
||||
job_repo: JobRepoDep,
|
||||
session: SessionDep,
|
||||
session_factory: SessionFactoryDep,
|
||||
http: Annotated[AsyncHttpClient, Depends(_default_http_client)],
|
||||
http: Annotated[AsyncHttpClient, Depends(_http_client)],
|
||||
) -> OAuthStartResponse:
|
||||
"""发起 device authorization:返回 202 + user_code/verification_uri,后台轮询 token。
|
||||
|
||||
@@ -139,11 +193,12 @@ async def start_oauth(
|
||||
job = await job_repo.create(None, _JOB_KIND_KIMI_OAUTH)
|
||||
await session.commit() # job 行需在 202 前持久化(供前端立即轮询)。
|
||||
|
||||
# 轮询移出 DB 会话(CR-H1):后台先在会话外轮询 token,仅最终持久化开一个短会话。
|
||||
background_tasks.add_task(
|
||||
run_job,
|
||||
_run_kimi_oauth_poll,
|
||||
session_factory,
|
||||
job.id,
|
||||
_make_poll_work(device),
|
||||
device,
|
||||
request_id=request_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -158,9 +158,7 @@ async def get_outline(
|
||||
if project is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
|
||||
|
||||
views = await outline_repo.list_for_project(project_id)
|
||||
if volume is not None:
|
||||
views = [v for v in views if v.volume == volume]
|
||||
views = await outline_repo.list_for_project(project_id, volume=volume)
|
||||
chapters = [
|
||||
OutlineChapterView(
|
||||
no=view.chapter_no,
|
||||
|
||||
@@ -20,6 +20,7 @@ from typing import Annotated, Any
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_agents import ClarifyDecision, clarify_rewrite_spec
|
||||
from ww_core.domain.chapter_repo import ChapterRepo
|
||||
from ww_core.domain.digest_repo import DigestAppendRepo
|
||||
from ww_core.domain.injection_repo import EntityRef, InjectionOverride, InjectionOverrideRepo
|
||||
@@ -36,13 +37,16 @@ from ww_core.orchestrator import (
|
||||
error_event,
|
||||
normalize_deltas,
|
||||
normalize_review,
|
||||
run_clarify,
|
||||
stream_chapter_draft,
|
||||
stream_chapter_rewrite,
|
||||
)
|
||||
from ww_db import get_session
|
||||
from ww_llm_gateway import Gateway
|
||||
from ww_shared import AppError, ErrorCode, ErrorEnvelope
|
||||
|
||||
from ww_api.logging_config import get_logger
|
||||
from ww_api.pagination import PageDep
|
||||
from ww_api.schemas.injection import (
|
||||
InjectionEntity,
|
||||
InjectionEntityRef,
|
||||
@@ -52,6 +56,7 @@ from ww_api.schemas.injection import (
|
||||
from ww_api.schemas.projects import (
|
||||
AcceptRequest,
|
||||
AcceptResponse,
|
||||
ChapterListItem,
|
||||
DraftResponse,
|
||||
DraftSaveRequest,
|
||||
DraftStreamRequest,
|
||||
@@ -63,17 +68,22 @@ from ww_api.schemas.projects import (
|
||||
ReviewHistoryItem,
|
||||
ReviewHistoryResponse,
|
||||
ReviewRequest,
|
||||
RewriteClarifyRequest,
|
||||
RewriteStreamRequest,
|
||||
)
|
||||
from ww_api.services.accept_service import (
|
||||
AcceptOutcome,
|
||||
assert_conflicts_resolved,
|
||||
run_accept_transaction,
|
||||
)
|
||||
from ww_api.services.chapter_list import ChapterLister
|
||||
from ww_api.services.credentials import STUB_OWNER_ID
|
||||
from ww_api.services.digest_extraction import extract_digest_facts
|
||||
from ww_api.services.foreshadow_scan import SessionFactory, run_overdue_scan
|
||||
from ww_api.services.project_deps import (
|
||||
get_chapter_lister,
|
||||
get_chapter_repo,
|
||||
get_clarify_gateway,
|
||||
get_digest_append_repo,
|
||||
get_digest_gateway,
|
||||
get_injection_repo,
|
||||
@@ -98,12 +108,21 @@ _ACCEPT_ERRORS: dict[int | str, dict[str, Any]] = {
|
||||
409: {"model": ErrorEnvelope, "description": "存在未裁决冲突"},
|
||||
503: {"model": ErrorEnvelope, "description": "LLM 不可用"},
|
||||
}
|
||||
# SSE 端点:如实声明 200 的 media type 为 text/event-stream(否则 OpenAPI 误标 json)。
|
||||
_SSE_RESPONSE: dict[int | str, dict[str, Any]] = {
|
||||
200: {
|
||||
"description": "SSE 事件流(`event: <name>\\ndata: <json>`,见 memory/contracts.md)",
|
||||
"content": {"text/event-stream": {}},
|
||||
}
|
||||
}
|
||||
|
||||
ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
|
||||
ChapterRepoDep = Annotated[ChapterRepo, Depends(get_chapter_repo)]
|
||||
ChapterListerDep = Annotated[ChapterLister, Depends(get_chapter_lister)]
|
||||
GatewayDep = Annotated[Gateway, Depends(get_writer_gateway)]
|
||||
ReviewGatewayDep = Annotated[Gateway, Depends(get_review_gateway)]
|
||||
DigestGatewayDep = Annotated[Gateway, Depends(get_digest_gateway)]
|
||||
ClarifyGatewayDep = Annotated[Gateway, Depends(get_clarify_gateway)]
|
||||
MemoryReposDep = Annotated[MemoryRepos, Depends(get_memory_repos)]
|
||||
InjectionRepoDep = Annotated[InjectionOverrideRepo, Depends(get_injection_repo)]
|
||||
ReviewRepoDep = Annotated[ReviewRepo, Depends(get_review_repo)]
|
||||
@@ -137,8 +156,9 @@ async def create_project(body: ProjectCreateRequest, repo: ProjectRepoDep) -> Pr
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_projects(repo: ProjectRepoDep) -> ProjectListResponse:
|
||||
views = await repo.list_for_owner(STUB_OWNER_ID)
|
||||
async def list_projects(repo: ProjectRepoDep, page: PageDep) -> ProjectListResponse:
|
||||
"""作品列表(按 created_at 升序,分页)。`?limit=&offset=`,默认第一页。"""
|
||||
views = await repo.list_for_owner(STUB_OWNER_ID, limit=page.limit, offset=page.offset)
|
||||
return ProjectListResponse(projects=[_to_response(v) for v in views])
|
||||
|
||||
|
||||
@@ -150,6 +170,28 @@ async def get_project(project_id: uuid.UUID, repo: ProjectRepoDep) -> ProjectRes
|
||||
return _to_response(view)
|
||||
|
||||
|
||||
@router.get("/{project_id}/chapters", responses=_NOT_FOUND)
|
||||
async def list_chapters(
|
||||
project_id: uuid.UUID,
|
||||
project_repo: ProjectRepoDep,
|
||||
lister: ChapterListerDep,
|
||||
) -> list[ChapterListItem]:
|
||||
"""列出该项目「真实存在的章」+ 可审/已审标记(审稿页选章下拉枚举)。
|
||||
|
||||
来源 `chapters`(草稿/accepted)+ `chapter_reviews`,按章号去重升序。只读、不触 LLM。
|
||||
项目不存在 → 404(同其它端点,owner 走 project_repo stub 校验)。
|
||||
"""
|
||||
if await project_repo.get(STUB_OWNER_ID, project_id) is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"project {project_id} not found")
|
||||
items = await lister.list_chapters(project_id)
|
||||
log.info(
|
||||
"chapters_listed",
|
||||
project_id=str(project_id),
|
||||
chapter_count=len(items),
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
async def _injection_response(
|
||||
repos: MemoryRepos,
|
||||
project_id: uuid.UUID,
|
||||
@@ -247,7 +289,7 @@ def _encode_sse(event: SseEvent) -> str:
|
||||
return f"event: {event.event}\ndata: {payload}\n\n"
|
||||
|
||||
|
||||
@router.post("/{project_id}/chapters/{chapter_no}/draft")
|
||||
@router.post("/{project_id}/chapters/{chapter_no}/draft", responses=_SSE_RESPONSE)
|
||||
async def stream_draft(
|
||||
project_id: uuid.UUID,
|
||||
chapter_no: int,
|
||||
@@ -310,6 +352,138 @@ async def stream_draft(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{project_id}/chapters/{chapter_no}/rewrite", responses=_SSE_RESPONSE)
|
||||
async def stream_rewrite(
|
||||
project_id: uuid.UUID,
|
||||
chapter_no: int,
|
||||
request: Request,
|
||||
repos: MemoryReposDep,
|
||||
gateway: GatewayDep,
|
||||
injection_repo: InjectionRepoDep,
|
||||
project_repo: ProjectRepoDep,
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
body: RewriteStreamRequest,
|
||||
) -> StreamingResponse:
|
||||
"""整章再沟通/重写:组装记忆 + 当前草稿 + 作者意见 → 网关流 → 归一 SSE → event-stream。
|
||||
|
||||
HITL:新版停前端/草稿,接受才落库(不变量 #3);rewrite 是工具非写节点,不破坏章
|
||||
纯函数性(不变量 #7)。项目不存在 → 404(触网关前 fail-fast,同 draft,QA C1)。
|
||||
"""
|
||||
if await project_repo.get(STUB_OWNER_ID, project_id) is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
override = await injection_repo.get(project_id, chapter_no)
|
||||
context = await assemble(repos, project_id, chapter_no, override=override)
|
||||
log.info(
|
||||
"rewrite_stream_start",
|
||||
project_id=str(project_id),
|
||||
chapter_no=chapter_no,
|
||||
request_id=request_id,
|
||||
stable_core_len=len(context.stable_core),
|
||||
volatile_len=len(context.volatile),
|
||||
prior_draft_len=len(body.prior_draft),
|
||||
feedback_len=len(body.feedback),
|
||||
)
|
||||
|
||||
deltas = stream_chapter_rewrite(
|
||||
gateway,
|
||||
stable_core=context.stable_core,
|
||||
volatile=context.volatile,
|
||||
prior_draft=body.prior_draft,
|
||||
feedback=body.feedback,
|
||||
user_id=STUB_OWNER_ID,
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
async def _frames() -> AsyncIterator[str]:
|
||||
async for event in normalize_deltas(deltas, request_id=request_id):
|
||||
yield _encode_sse(event)
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception: # noqa: BLE001 — 流已发完,commit 失败不能再改响应;至少记错误。
|
||||
log.error("sse_commit_failed", request_id=request_id, endpoint="rewrite")
|
||||
|
||||
return StreamingResponse(
|
||||
_frames(),
|
||||
media_type="text/event-stream",
|
||||
headers={"cache-control": "no-cache", "x-accel-buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
# 整章重写预检的草稿摘录上界:只喂 analyst 预检**开头一小段**供锚定,绝不整章入 LLM
|
||||
# (守成本/上下文,且预检只需判意见含糊度、不需读全章)。
|
||||
_REWRITE_CLARIFY_EXCERPT_MAX = 3000
|
||||
|
||||
|
||||
def _build_rewrite_clarify_input(feedback: str, prior_draft: str | None) -> str:
|
||||
"""组整章重写预检输入:作者章级意见 + 当前章草稿的有界摘录(分块标注,供教条区分)。
|
||||
|
||||
只取 `prior_draft` 开头 `_REWRITE_CLARIFY_EXCERPT_MAX` 字符作锚定摘录——预检不需读全章,
|
||||
避免把 200k 整章喂给 analyst 预检(成本/上下文/脱敏)。无草稿时只喂意见(退化,仍可判含糊)。
|
||||
"""
|
||||
parts = [f"【作者的整章修改意见】\n{feedback}"]
|
||||
if prior_draft:
|
||||
excerpt = prior_draft[:_REWRITE_CLARIFY_EXCERPT_MAX]
|
||||
parts.append(f"【当前章节草稿(摘录,仅供锚定)】\n{excerpt}")
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{project_id}/chapters/{chapter_no}/rewrite/clarify",
|
||||
responses={
|
||||
404: {"model": ErrorEnvelope, "description": "项目不存在"},
|
||||
503: {"model": ErrorEnvelope, "description": "LLM 不可用"},
|
||||
},
|
||||
)
|
||||
async def clarify_rewrite(
|
||||
project_id: uuid.UUID,
|
||||
chapter_no: int,
|
||||
body: RewriteClarifyRequest,
|
||||
request: Request,
|
||||
project_repo: ProjectRepoDep,
|
||||
gateway: ClarifyGatewayDep,
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> ClarifyDecision:
|
||||
"""整章重写预检澄清(WFW-9 M2 路线A 两阶段之「问题」阶段):非流式 JSON,只判不改。
|
||||
|
||||
镜像 refine 侧 clarify——只判「作者这条整章重写意见清不清楚」,含糊则反问 1 问 + 给选项,
|
||||
清楚则给确认语放行;**绝不改正文**(整章重写仍走既有 rewrite SSE 端点)。analyst 档
|
||||
(不变量 #2)。只取 `prior_draft` 有界摘录喂预检(不整章入 LLM)。
|
||||
|
||||
项目不存在 → 404(触网关前 fail-fast,同 rewrite/draft);无凭据 → 503(dep 解析阶段拦下)。
|
||||
**只读不写业务表**(不变量 #3);末尾 `commit()` 让网关 usage_ledger 落库(add-only,否则
|
||||
记账静默丢失,同 rewrite 纪律)。判别/校验失败在 `run_clarify` 内确定性回退
|
||||
`need_clarification=false`(不阻塞整章重写主链路)。
|
||||
"""
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
|
||||
if await project_repo.get(STUB_OWNER_ID, project_id) is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
|
||||
|
||||
context = _build_rewrite_clarify_input(body.feedback, body.prior_draft)
|
||||
decision = await run_clarify(
|
||||
clarify_rewrite_spec, # 不变量 #2:analyst 档,不传 model
|
||||
context=context,
|
||||
gateway=gateway,
|
||||
user_id=STUB_OWNER_ID,
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
# 提交边界:只读不写业务表,但网关 ledger add-only → 端点末尾 commit(否则记账丢失)。
|
||||
await session.commit()
|
||||
|
||||
log.info(
|
||||
"rewrite_clarify_done",
|
||||
project_id=str(project_id),
|
||||
chapter_no=chapter_no,
|
||||
request_id=request_id,
|
||||
feedback_len=len(body.feedback), # 脱敏:只记长度,不记正文(不变量/日志纪律)
|
||||
need_clarification=decision.need_clarification,
|
||||
question_count=len(decision.questions),
|
||||
)
|
||||
return decision
|
||||
|
||||
|
||||
@router.put("/{project_id}/chapters/{chapter_no}/draft")
|
||||
async def save_draft(
|
||||
project_id: uuid.UUID,
|
||||
@@ -391,7 +565,7 @@ async def _resolve_review_draft(
|
||||
return saved.content
|
||||
|
||||
|
||||
@router.post("/{project_id}/chapters/{chapter_no}/review")
|
||||
@router.post("/{project_id}/chapters/{chapter_no}/review", responses=_SSE_RESPONSE)
|
||||
async def review_chapter(
|
||||
project_id: uuid.UUID,
|
||||
chapter_no: int,
|
||||
|
||||
@@ -22,10 +22,10 @@ from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Request, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_agents import refiner_spec, style_extract_spec
|
||||
from ww_agents import ClarifyDecision, clarify_refine_spec, refiner_spec, style_extract_spec
|
||||
from ww_core.domain import JobRepo, ProjectRepo, StyleFingerprintWriteRepo
|
||||
from ww_core.domain.style_repo import SqlStyleFingerprintWriteRepo
|
||||
from ww_core.orchestrator import run_style_extraction
|
||||
from ww_core.orchestrator import run_clarify, run_style_extraction
|
||||
from ww_db import get_session
|
||||
from ww_llm_gateway import Gateway
|
||||
from ww_llm_gateway.types import Block, LlmRequest, Scope
|
||||
@@ -34,6 +34,7 @@ from ww_shared import AppError, ErrorCode, ErrorEnvelope
|
||||
from ww_api.logging_config import get_logger
|
||||
from ww_api.schemas.style import (
|
||||
DimensionEntry,
|
||||
RefineClarifyRequest,
|
||||
RefineRequest,
|
||||
RefineResponse,
|
||||
StyleFingerprintResponse,
|
||||
@@ -45,6 +46,7 @@ from ww_api.services.foreshadow_scan import SessionFactory
|
||||
from ww_api.services.job_runner import run_job
|
||||
from ww_api.services.project_deps import (
|
||||
build_gateway_for_tier,
|
||||
get_clarify_gateway,
|
||||
get_job_repo,
|
||||
get_project_repo,
|
||||
get_refine_gateway,
|
||||
@@ -62,6 +64,7 @@ JobRepoDep = Annotated[JobRepo, Depends(get_job_repo)]
|
||||
StyleWriteRepoDep = Annotated[StyleFingerprintWriteRepo, Depends(get_style_write_repo)]
|
||||
StyleExtractGatewayDep = Annotated[Gateway, Depends(get_style_extract_gateway)]
|
||||
RefineGatewayDep = Annotated[Gateway, Depends(get_refine_gateway)]
|
||||
ClarifyGatewayDep = Annotated[Gateway, Depends(get_clarify_gateway)]
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
SessionFactoryDep = Annotated[SessionFactory, Depends(get_session_factory)]
|
||||
|
||||
@@ -238,3 +241,59 @@ async def refine_segment(
|
||||
has_instruction=body.instruction is not None,
|
||||
)
|
||||
return RefineResponse(original=body.segment, refined=resp.text)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{project_id}/chapters/{chapter_no}/refine/clarify",
|
||||
responses={
|
||||
404: {"model": ErrorEnvelope, "description": "项目不存在"},
|
||||
503: {"model": ErrorEnvelope, "description": "LLM 不可用"},
|
||||
},
|
||||
)
|
||||
async def clarify_refine(
|
||||
project_id: uuid.UUID,
|
||||
chapter_no: int,
|
||||
body: RefineClarifyRequest,
|
||||
request: Request,
|
||||
project_repo: ProjectRepoDep,
|
||||
gateway: ClarifyGatewayDep,
|
||||
session: SessionDep,
|
||||
) -> ClarifyDecision:
|
||||
"""润色预检澄清(WFW-9 M1 路线A 两阶段之「问题」阶段):非流式 JSON,只判不改。
|
||||
|
||||
独立于既有 refine 端点——只判「作者这条再沟通意见清不清楚」,含糊则反问 1 问 + 给选项,
|
||||
清楚则给确认语放行;**绝不改正文**(正文仍走 refine 端点)。analyst 档(不变量 #2)。
|
||||
|
||||
项目不存在 → 404;无凭据 → 503(dep 解析阶段拦下)。**只读不写库**(不变量 #3);
|
||||
末尾 `commit()` 让网关 usage_ledger 落库(add-only,否则记账静默丢失,同 refine 纪律)。
|
||||
判别/校验失败在 `run_clarify` 内确定性回退 `need_clarification=false`(不阻塞润色)。
|
||||
"""
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
|
||||
project = await project_repo.get(STUB_OWNER_ID, project_id)
|
||||
if project is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
|
||||
|
||||
context = _build_refine_input(body.segment, body.instruction or None)
|
||||
decision = await run_clarify(
|
||||
clarify_refine_spec, # 不变量 #2:analyst 档,不传 model
|
||||
context=context,
|
||||
gateway=gateway,
|
||||
user_id=STUB_OWNER_ID,
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
# 提交边界:只读不写业务表,但网关 ledger add-only → 端点末尾 commit(否则记账丢失)。
|
||||
await session.commit()
|
||||
|
||||
log.info(
|
||||
"refine_clarify_done",
|
||||
project_id=str(project_id),
|
||||
chapter_no=chapter_no,
|
||||
request_id=request_id,
|
||||
segment_len=len(body.segment),
|
||||
instruction_len=len(body.instruction),
|
||||
need_clarification=decision.need_clarification,
|
||||
question_count=len(decision.questions),
|
||||
)
|
||||
return decision
|
||||
|
||||
@@ -23,6 +23,7 @@ from ww_db import get_session
|
||||
from ww_shared import AppError, ErrorCode
|
||||
|
||||
from ww_api.logging_config import get_logger
|
||||
from ww_api.pagination import PageDep
|
||||
from ww_api.schemas.templates import TemplateCreateRequest, TemplateResponse
|
||||
from ww_api.services.credentials import STUB_OWNER_ID
|
||||
from ww_api.services.project_deps import get_template_repo
|
||||
@@ -36,9 +37,9 @@ SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_templates(repo: TemplateRepoDep) -> list[TemplateResponse]:
|
||||
"""列出当前用户(stub)的模板。"""
|
||||
views = await repo.list_for_owner(STUB_OWNER_ID)
|
||||
async def list_templates(repo: TemplateRepoDep, page: PageDep) -> list[TemplateResponse]:
|
||||
"""列出当前用户(stub)的模板(分页;`?limit=&offset=`,默认第一页)。"""
|
||||
views = await repo.list_for_owner(STUB_OWNER_ID, limit=page.limit, offset=page.offset)
|
||||
return [
|
||||
TemplateResponse(
|
||||
id=v.id, title=v.title, body=v.body, category=v.category, tool_key=v.tool_key
|
||||
|
||||
@@ -25,7 +25,7 @@ from ww_core.domain.outline_write_repo import OutlineWriteRepo
|
||||
from ww_core.domain.project_repo import ProjectRepo
|
||||
from ww_core.domain.repositories import MemoryRepos, OutlineRepo
|
||||
from ww_core.domain.rule_repo import RuleWriteRepo
|
||||
from ww_core.domain.world_entity_repo import WorldEntityWriteRepo
|
||||
from ww_core.domain.world_entity_repo import WorldEntityFields, WorldEntityWriteRepo
|
||||
from ww_core.orchestrator import run_generator
|
||||
from ww_db import get_session
|
||||
from ww_llm_gateway import Gateway
|
||||
@@ -427,12 +427,12 @@ async def _ingest_world_entities(
|
||||
rejected_tables=rejected,
|
||||
)
|
||||
|
||||
created: list[str] = []
|
||||
for entity in allowed.get("world_entities", []):
|
||||
view = await world_write_repo.create(
|
||||
project_id, type=entity.type, name=entity.name, rules=list(entity.rules)
|
||||
)
|
||||
created.append(view.name)
|
||||
fields = [
|
||||
WorldEntityFields(type=entity.type, name=entity.name, rules=list(entity.rules))
|
||||
for entity in allowed.get("world_entities", [])
|
||||
]
|
||||
# 批量插入(一次 flush 落全部),保序返回逐条 View。
|
||||
created = [view.name for view in await world_write_repo.create_many(project_id, fields)]
|
||||
|
||||
await session.commit()
|
||||
log.info(
|
||||
|
||||
74
apps/api/ww_api/schemas/ai_messages.py
Normal file
74
apps/api/ww_api/schemas/ai_messages.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""AI 对话(聊天记录)端点的请求/响应 schema(AC-2 / ai-chat-history-plan §3.2)。
|
||||
|
||||
snake_case;前端经 OpenAPI 生成 TS 类型消费(改字段 → 前端 `pnpm gen:api`)。
|
||||
`Literal` 是 kind/role 的**枚举权威**(DB 列自由 Text,边界在此收窄):非法 kind/role → 422。
|
||||
批级字段(thread_id/chapter_no/kind/tool_key)提到请求顶层,`messages` 是本批各条 bubble。
|
||||
长文进 `content`(有手稿级上限),`meta` 有序列化上界兜底防绕过 content 上限。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, StringConstraints, field_validator
|
||||
|
||||
# kind/role 的枚举权威(DB 列自由 Text;加新 kind 改这里 + 零迁移)。
|
||||
AiKind = Literal["refine", "rewrite", "clarify", "continue", "generator"]
|
||||
AiRole = Literal["author", "ai"]
|
||||
|
||||
# content 上界 = 手稿上限(rewrite 整章文本级);命名常量非魔法数(CR-H9)。
|
||||
AI_MESSAGE_CONTENT_MAX = 200_000
|
||||
# meta 序列化上界兜底:防长文绕过 content 上限塞进 meta。
|
||||
_META_MAX_SERIALIZED = 50_000
|
||||
# 一次 append 的最大条数(一次交换的多轮 bubble)。
|
||||
_MAX_TURNS_PER_CALL = 50
|
||||
|
||||
|
||||
class AiMessageInput(BaseModel):
|
||||
"""一条待落库 bubble(角色 + 人读文本 + 结构化 meta)。"""
|
||||
|
||||
role: AiRole
|
||||
content: Annotated[str, StringConstraints(min_length=1, max_length=AI_MESSAGE_CONTENT_MAX)]
|
||||
meta: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@field_validator("meta")
|
||||
@classmethod
|
||||
def _bound_meta(cls, v: dict[str, Any]) -> dict[str, Any]:
|
||||
if len(json.dumps(v, ensure_ascii=False)) > _META_MAX_SERIALIZED:
|
||||
raise ValueError("meta too large; put long text in content")
|
||||
return v
|
||||
|
||||
|
||||
class AiMessageAppendRequest(BaseModel):
|
||||
"""POST /projects/{id}/ai-messages:一次交换的一批 bubble(批级字段提顶层)。"""
|
||||
|
||||
thread_id: uuid.UUID
|
||||
chapter_no: Annotated[int, Field(ge=1)] | None = None # NULL = 项目级(工具箱)
|
||||
kind: AiKind
|
||||
tool_key: str | None = None
|
||||
messages: Annotated[list[AiMessageInput], Field(min_length=1, max_length=_MAX_TURNS_PER_CALL)]
|
||||
|
||||
|
||||
class AiMessageView(BaseModel):
|
||||
"""聊天留痕视图(append/list 回显;snake_case,镜像 repo view)。"""
|
||||
|
||||
id: uuid.UUID
|
||||
project_id: uuid.UUID
|
||||
chapter_no: int | None
|
||||
thread_id: uuid.UUID
|
||||
seq: int
|
||||
kind: str
|
||||
tool_key: str | None
|
||||
role: str
|
||||
content: str
|
||||
meta: dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AiMessageListResponse(BaseModel):
|
||||
"""append/list 信封:`messages` 复数键(repo 约定,非 `items`)。"""
|
||||
|
||||
messages: list[AiMessageView]
|
||||
@@ -11,12 +11,18 @@ from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# 请求字符串字段长度上界(CR-H9:防超长入参 DoS/成本)。代号=短标识;标题=一句话。
|
||||
_CODE_MAX = 100
|
||||
_TITLE_MAX = 500
|
||||
|
||||
|
||||
class ForeshadowRegisterRequest(BaseModel):
|
||||
"""POST /projects/:id/foreshadow:作者显式登记一条伏笔(status 落 OPEN)。"""
|
||||
|
||||
code: str = Field(min_length=1, description="伏笔代号,`(project_id, code)` 唯一")
|
||||
title: str = Field(min_length=1, description="伏笔标题/一句话描述")
|
||||
code: str = Field(
|
||||
min_length=1, max_length=_CODE_MAX, description="伏笔代号,`(project_id, code)` 唯一"
|
||||
)
|
||||
title: str = Field(min_length=1, max_length=_TITLE_MAX, description="伏笔标题/一句话描述")
|
||||
planted_at: int | None = Field(default=None, description="埋设章号")
|
||||
content: str | None = Field(default=None, description="伏笔正文/线索")
|
||||
expected_close_from: int | None = Field(default=None, description="预期回收窗口起始章")
|
||||
|
||||
@@ -19,6 +19,9 @@ from ww_api.schemas.rules import RuleView
|
||||
# 批量生成数量上限(防一次性巨量调用;原型保守值)。
|
||||
MAX_GENERATE_COUNT = 12
|
||||
|
||||
# 一句话需求文本上界(CR-H9:防超长入参撑爆上下文/成本)。
|
||||
_BRIEF_MAX = 10_000
|
||||
|
||||
|
||||
# ---- 世界观生成(预览)----
|
||||
|
||||
@@ -26,7 +29,9 @@ MAX_GENERATE_COUNT = 12
|
||||
class WorldGenerateRequest(BaseModel):
|
||||
"""POST /projects/:id/world/generate:据需求生成世界观实体(预览)。"""
|
||||
|
||||
brief: str = Field(min_length=1, description="世界观生成需求(题材/设定方向/约束)")
|
||||
brief: str = Field(
|
||||
min_length=1, max_length=_BRIEF_MAX, description="世界观生成需求(题材/设定方向/约束)"
|
||||
)
|
||||
|
||||
|
||||
class WorldEntityCardView(BaseModel):
|
||||
@@ -54,7 +59,7 @@ class CharacterGenerateRequest(BaseModel):
|
||||
单一 `role` + `count` 语义。
|
||||
"""
|
||||
|
||||
brief: str = Field(min_length=1, description="角色生成需求")
|
||||
brief: str = Field(min_length=1, max_length=_BRIEF_MAX, description="角色生成需求")
|
||||
count: int = Field(
|
||||
default=1,
|
||||
ge=1,
|
||||
|
||||
@@ -11,11 +11,16 @@ from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# 请求文本长度上界(CR-H9:防超长入参 DoS/成本)。标题=标签级;章正文=整章级。
|
||||
_TITLE_MAX = 200
|
||||
_CHAPTER_TEXT_MAX = 200_000
|
||||
_DIRECTIVE_MAX = 20_000 # CR-H9 收尾:本章指令喂 LLM,加上界(正文级 body 中间件仍兜底)
|
||||
|
||||
|
||||
class ProjectCreateRequest(BaseModel):
|
||||
"""POST /projects:立项向导字段(owner_id 由后端补 stub,不入参)。"""
|
||||
|
||||
title: str = Field(min_length=1)
|
||||
title: str = Field(min_length=1, max_length=_TITLE_MAX)
|
||||
genre: str | None = None
|
||||
logline: str | None = None
|
||||
premise: str | None = None
|
||||
@@ -117,13 +122,44 @@ class DraftStreamRequest(BaseModel):
|
||||
后端只入 volatile(守缓存前缀不变量 #9)。无 body 时退化为纯按大纲生成(向后兼容)。
|
||||
"""
|
||||
|
||||
directive: str | None = None
|
||||
directive: str | None = Field(default=None, max_length=_DIRECTIVE_MAX)
|
||||
|
||||
|
||||
# 整章重写输入上界(守 DoS/成本,CR-H9 方向):意见短、整章草稿大(复用章正文上界,DRY)。
|
||||
_REWRITE_FEEDBACK_MAX = 4000
|
||||
_REWRITE_DRAFT_MAX = _CHAPTER_TEXT_MAX
|
||||
|
||||
|
||||
class RewriteStreamRequest(BaseModel):
|
||||
"""POST /projects/:id/chapters/:no/rewrite:整章再沟通/重写。
|
||||
|
||||
`feedback` = 作者对本章的修改意见;`prior_draft` = 当前整章草稿(前端传入,据此 + 记忆
|
||||
注入流式重写一版)。临时输入、不持久化、无迁移;只入 volatile(守缓存前缀不变量 #9)。
|
||||
新版停前端/草稿,接受才落库(HITL,不变量 #3)。
|
||||
"""
|
||||
|
||||
feedback: str = Field(min_length=1, max_length=_REWRITE_FEEDBACK_MAX)
|
||||
prior_draft: str = Field(min_length=1, max_length=_REWRITE_DRAFT_MAX)
|
||||
|
||||
|
||||
class RewriteClarifyRequest(BaseModel):
|
||||
"""整章重写预检澄清:章级意见 + 可选当前草稿(WFW-9 M2 路线A 两阶段之「问题」阶段)。
|
||||
|
||||
与 `RewriteStreamRequest` 独立——预检只判「作者这条章级意见清不清楚」,**不改正文**
|
||||
(整章重写仍走既有 rewrite SSE 端点)。`feedback` = 作者对整章的修改意见(触发反问的场景,
|
||||
可极短/含糊);`prior_draft` = 当前整章草稿(可选,仅供锚定;端点只取有界摘录喂 analyst 预检,
|
||||
不整章入 LLM)。带长度上界防超长入参。响应=`ClarifyDecision`(在 ww_agents,端点直接返回,
|
||||
供前端 gen:api 生成强类型客户端)。
|
||||
"""
|
||||
|
||||
feedback: str = Field(min_length=1, max_length=_REWRITE_FEEDBACK_MAX)
|
||||
prior_draft: str | None = Field(default=None, max_length=_REWRITE_DRAFT_MAX)
|
||||
|
||||
|
||||
class DraftSaveRequest(BaseModel):
|
||||
"""PUT /projects/:id/chapters/:no/draft:自动保存草稿正文。"""
|
||||
|
||||
text: str
|
||||
text: str = Field(max_length=_CHAPTER_TEXT_MAX)
|
||||
|
||||
|
||||
class DraftResponse(BaseModel):
|
||||
@@ -153,6 +189,23 @@ class DraftView(BaseModel):
|
||||
length: int
|
||||
|
||||
|
||||
# ---- 列章(审稿选章枚举)----
|
||||
|
||||
|
||||
class ChapterListItem(BaseModel):
|
||||
"""GET /projects/:id/chapters 单项:一个真实存在的章 + 可审/已审标记(snake_case)。
|
||||
|
||||
数据来源为 `chapters`(草稿/accepted 版本)与 `chapter_reviews`,按章号去重升序。
|
||||
`has_draft`=有非空草稿正文(可审);`accepted`=已有 accepted 版本;
|
||||
`reviewed_at`=最近一次审稿时间(无则 null)。
|
||||
"""
|
||||
|
||||
chapter_no: int
|
||||
has_draft: bool = Field(description="是否有非空草稿正文(可审)")
|
||||
accepted: bool = Field(description="是否已验收(存在 accepted 版本)")
|
||||
reviewed_at: datetime | None = Field(default=None, description="最近一次审稿时间;未审为 null")
|
||||
|
||||
|
||||
# ---- 审稿(T2.5)----
|
||||
|
||||
|
||||
@@ -162,7 +215,7 @@ class ReviewRequest(BaseModel):
|
||||
不传 `draft` 时端点回退到已保存的草稿(chapter_repo)。
|
||||
"""
|
||||
|
||||
draft: str | None = None
|
||||
draft: str | None = Field(default=None, max_length=_CHAPTER_TEXT_MAX)
|
||||
|
||||
|
||||
class ReviewConflictView(BaseModel):
|
||||
@@ -220,7 +273,9 @@ class ConflictDecision(BaseModel):
|
||||
class AcceptRequest(BaseModel):
|
||||
"""POST /projects/:id/chapters/:no/accept:裁决清单 + 可能改过的终稿。"""
|
||||
|
||||
final_text: str = Field(min_length=1, description="作者裁决/改稿后的最终验收文本")
|
||||
final_text: str = Field(
|
||||
min_length=1, max_length=_CHAPTER_TEXT_MAX, description="作者裁决/改稿后的最终验收文本"
|
||||
)
|
||||
decisions: list[ConflictDecision] = Field(
|
||||
default_factory=list, description="对最近审稿每个冲突的裁决(每冲突必有其一,R5)"
|
||||
)
|
||||
|
||||
@@ -8,6 +8,11 @@ from __future__ import annotations
|
||||
from pydantic import BaseModel, Field
|
||||
from ww_llm_gateway.types import Tier
|
||||
|
||||
# 请求字符串字段长度上界(CR-H9:防超长入参 DoS/成本)。
|
||||
_PROVIDER_MAX = 64
|
||||
_MODEL_MAX = 128
|
||||
_API_KEY_MAX = 512
|
||||
|
||||
|
||||
class ProviderView(BaseModel):
|
||||
"""已配置提供商(掩码视图)。"""
|
||||
@@ -35,8 +40,8 @@ class ProvidersResponse(BaseModel):
|
||||
class ProviderCredentialInput(BaseModel):
|
||||
"""单条提供商凭据写入。"""
|
||||
|
||||
provider: str = Field(min_length=1)
|
||||
api_key: str = Field(min_length=1) # 明文入站,加密入库,绝不回显
|
||||
provider: str = Field(min_length=1, max_length=_PROVIDER_MAX)
|
||||
api_key: str = Field(min_length=1, max_length=_API_KEY_MAX) # 明文入站,加密入库,绝不回显
|
||||
|
||||
|
||||
class TierRoutingInput(BaseModel):
|
||||
@@ -44,8 +49,8 @@ class TierRoutingInput(BaseModel):
|
||||
|
||||
# tier 限定已知档位 writer/analyst/light;未知档位 → 422(QA MEDIUM:原接受任意字符串)。
|
||||
tier: Tier
|
||||
provider: str = Field(min_length=1)
|
||||
model: str = Field(min_length=1)
|
||||
provider: str = Field(min_length=1, max_length=_PROVIDER_MAX)
|
||||
model: str = Field(min_length=1, max_length=_MODEL_MAX)
|
||||
fallback: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
@@ -59,7 +64,7 @@ class ProvidersUpsertRequest(BaseModel):
|
||||
class TestConnectionRequest(BaseModel):
|
||||
"""POST /test:最小探测请求。"""
|
||||
|
||||
provider: str = Field(min_length=1)
|
||||
provider: str = Field(min_length=1, max_length=_PROVIDER_MAX)
|
||||
|
||||
|
||||
class CapabilitiesView(BaseModel):
|
||||
|
||||
@@ -14,8 +14,13 @@ from pydantic import BaseModel, Field, StringConstraints
|
||||
|
||||
RuleLevel = Literal["global", "genre", "style", "project"]
|
||||
|
||||
# 规则正文长度上界(CR-H9:防超长入参 DoS/成本)。
|
||||
_RULE_CONTENT_MAX = 10_000
|
||||
|
||||
# 先 strip 再校验长度:纯空白内容(" ")strip 后为空 → 422(QA MEDIUM:此前被接受入库)。
|
||||
RuleContent = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
|
||||
RuleContent = Annotated[
|
||||
str, StringConstraints(strip_whitespace=True, min_length=1, max_length=_RULE_CONTENT_MAX)
|
||||
]
|
||||
|
||||
|
||||
class RuleCreateRequest(BaseModel):
|
||||
|
||||
@@ -7,15 +7,26 @@ snake_case(命名契约,见 memory/gotchas)。前端经 OpenAPI→TS 客
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Literal
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, StringConstraints
|
||||
|
||||
# 请求文本长度上界(CR-H9:防超长入参空跑 LLM/DoS)。选段/样本 20k 字符,样本列表 ≤20 条。
|
||||
_SEGMENT_MAX = 20_000
|
||||
_SAMPLE_MAX = 20_000
|
||||
_SAMPLES_LIST_MAX = 20
|
||||
# 预检澄清沿用相同选段上界(DRY);指令短。
|
||||
_MAX_CLARIFY_SEGMENT_LEN = _SEGMENT_MAX
|
||||
_MAX_CLARIFY_INSTRUCTION_LEN = 2000
|
||||
|
||||
|
||||
class StyleLearnRequest(BaseModel):
|
||||
"""学文风:上传样本正文(前端可读文件转文本)+ 模式(首学 / 更新)。"""
|
||||
|
||||
samples: list[str] = Field(min_length=1)
|
||||
# 同时约束**列表条数**与**每条样本长度**(最大 DoS 面:多条超长正文)。
|
||||
samples: list[Annotated[str, StringConstraints(max_length=_SAMPLE_MAX)]] = Field(
|
||||
min_length=1, max_length=_SAMPLES_LIST_MAX
|
||||
)
|
||||
mode: Literal["create", "update"] = "create"
|
||||
|
||||
|
||||
@@ -47,8 +58,9 @@ class StyleFingerprintResponse(BaseModel):
|
||||
class RefineRequest(BaseModel):
|
||||
"""回炉:重写选中段(可选改写指令)。"""
|
||||
|
||||
segment: str = Field(min_length=1)
|
||||
instruction: str | None = None
|
||||
segment: str = Field(min_length=1, max_length=_SEGMENT_MAX)
|
||||
# CR-H9 收尾:再沟通意见喂 LLM,加上界(与 clarify instruction 同口径 2000)。
|
||||
instruction: str | None = Field(default=None, max_length=_MAX_CLARIFY_INSTRUCTION_LEN)
|
||||
|
||||
|
||||
class RefineResponse(BaseModel):
|
||||
@@ -56,3 +68,15 @@ class RefineResponse(BaseModel):
|
||||
|
||||
original: str
|
||||
refined: str
|
||||
|
||||
|
||||
class RefineClarifyRequest(BaseModel):
|
||||
"""润色预检澄清:选段 + 再沟通意见(WFW-9 M1 路线A 两阶段之「问题」阶段)。
|
||||
|
||||
与 `RefineRequest` 独立——预检只判「意见清不清楚」,不改正文。`instruction` 为作者的
|
||||
再沟通意见(可空/极短,正是触发反问的场景);带长度上界防超长入参。响应=`ClarifyDecision`
|
||||
(在 ww_agents,端点直接返回,供前端 gen:api 生成强类型客户端)。
|
||||
"""
|
||||
|
||||
segment: str = Field(min_length=1, max_length=_MAX_CLARIFY_SEGMENT_LEN)
|
||||
instruction: str = Field(default="", max_length=_MAX_CLARIFY_INSTRUCTION_LEN)
|
||||
|
||||
@@ -12,15 +12,24 @@ from typing import Annotated
|
||||
|
||||
from pydantic import BaseModel, Field, StringConstraints
|
||||
|
||||
# 长度上界(CR-H9:防超长入参 DoS/成本)。标题=标签级;正文=段落级。
|
||||
_TITLE_MAX = 200
|
||||
_BODY_MAX = 20_000
|
||||
|
||||
# 先去首尾空白再校验长度:纯空白(" ")strip 后为空 → min_length=1 不满足 → 422。
|
||||
NonBlankStr = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
|
||||
NonBlankTitle = Annotated[
|
||||
str, StringConstraints(strip_whitespace=True, min_length=1, max_length=_TITLE_MAX)
|
||||
]
|
||||
NonBlankBody = Annotated[
|
||||
str, StringConstraints(strip_whitespace=True, min_length=1, max_length=_BODY_MAX)
|
||||
]
|
||||
|
||||
|
||||
class TemplateCreateRequest(BaseModel):
|
||||
"""POST /templates:新建一条提示词模板。"""
|
||||
|
||||
title: NonBlankStr = Field(description="模板标题(非空,纯空白 → 422)")
|
||||
body: NonBlankStr = Field(description="模板正文(非空,一键填入生成器的 brief/text)")
|
||||
title: NonBlankTitle = Field(description="模板标题(非空,纯空白 → 422)")
|
||||
body: NonBlankBody = Field(description="模板正文(非空,一键填入生成器的 brief/text)")
|
||||
category: str | None = Field(default=None, description="可选分类")
|
||||
tool_key: str | None = Field(default=None, description="可选关联生成器(NULL=通用)")
|
||||
|
||||
|
||||
@@ -50,6 +50,11 @@ log = structlog.get_logger(__name__)
|
||||
# 链 job 的 kind(jobs.kind 自由 Text 列,零迁移复用;§7)。
|
||||
JOB_KIND_CHAIN = "chain"
|
||||
|
||||
# 每章在链图里的超级步数(write→review→decide→accept),用于算 recursion_limit 上界。
|
||||
NODES_PER_CHAPTER = 4 # 每章 4 个超级步:write→review→decide→accept
|
||||
# START 边 + interrupt/resume 重入的余量(防御性冗余,非精确计数)。
|
||||
RECURSION_LIMIT_HEADROOM = 10 # START 边 + interrupt/resume 重入余量
|
||||
|
||||
|
||||
# 「按 session 建 digest 网关」缝:accept 节点在自建短事务里需 light 档网关跑终稿提炼。
|
||||
DigestGatewayBuilder = Callable[[Any], Awaitable[Gateway]]
|
||||
@@ -156,13 +161,13 @@ def _chain_result(state: dict[str, Any], *, awaiting_chapter: int | None) -> dic
|
||||
}
|
||||
|
||||
|
||||
def _extract_awaiting_chapter(final: dict[str, Any]) -> int | None:
|
||||
"""从图返回值判 interrupt:有 `__interrupt__` → 取暂停章号;否则 None(跑完)。
|
||||
def _extract_awaiting_chapter(interrupts: Sequence[Any]) -> int | None:
|
||||
"""从 pending interrupts 判暂停章号:空 → None(跑完);否则取首个 interrupt 载荷的章号。
|
||||
|
||||
interrupt 载荷 = `{"chapter_no": n}`(见链图 `_accept` 节点)。LangGraph 把它放在
|
||||
`final["__interrupt__"]`(Interrupt 对象列表)。容错读 `.value`/dict 两种形。
|
||||
interrupts 经**公开** `graph.aget_state(config).interrupts`(`StateSnapshot`)拿到,不再
|
||||
反手 ainvoke 返回值里的私有 `__interrupt__` 键。载荷 = `{"chapter_no": n}`(见链图 `_accept`
|
||||
节点)。容错读 `.value`/dict 两种形。
|
||||
"""
|
||||
interrupts = final.get("__interrupt__")
|
||||
if not interrupts:
|
||||
return None
|
||||
first = interrupts[0]
|
||||
@@ -207,7 +212,14 @@ async def run_chain_job(
|
||||
try:
|
||||
async with checkpointer_ctx() as checkpointer:
|
||||
await _set_running(session_factory, job_id)
|
||||
config: RunnableConfig = {"configurable": {"thread_id": str(job_id)}}
|
||||
# 显式每链 recursion_limit 上界(防御硬化,CR-C2):count 已被端点上限 MAX_CHAIN_COUNT
|
||||
# 夹住,故 count*每章步数+余量 是安全且贴合的上界。**必须放 config 顶层**——放到
|
||||
# `configurable` 下 langgraph 会静默忽略。单份 config 同时喂初始 run 与 resume 分支。
|
||||
recursion_limit = count * NODES_PER_CHAPTER + RECURSION_LIMIT_HEADROOM
|
||||
config: RunnableConfig = {
|
||||
"configurable": {"thread_id": str(job_id)},
|
||||
"recursion_limit": recursion_limit,
|
||||
}
|
||||
graph = build_chain_graph(
|
||||
chain_gateway_builder,
|
||||
session_factory=session_factory,
|
||||
@@ -232,9 +244,11 @@ async def run_chain_job(
|
||||
resume_value = [d.model_dump() for d in resume_decisions]
|
||||
raw_final = await graph.ainvoke(Command(resume=resume_value), config=config)
|
||||
|
||||
final: dict[str, Any] = dict(raw_final)
|
||||
awaiting_chapter = _extract_awaiting_chapter(final)
|
||||
result = _chain_result(final, awaiting_chapter=awaiting_chapter)
|
||||
# 经公开 `StateSnapshot.interrupts` 判 pending interrupt(不反手私有 `__interrupt__`);
|
||||
# checkpointer 仍在 `checkpointer_ctx()` 上下文内,aget_state 可读同一 thread 检查点。
|
||||
snapshot = await graph.aget_state(config)
|
||||
awaiting_chapter = _extract_awaiting_chapter(snapshot.interrupts)
|
||||
result = _chain_result(dict(raw_final), awaiting_chapter=awaiting_chapter)
|
||||
await _finish(session_factory, job_id, result, awaiting=awaiting_chapter is not None)
|
||||
log.info(
|
||||
"chain_job_settled",
|
||||
|
||||
109
apps/api/ww_api/services/chapter_list.py
Normal file
109
apps/api/ww_api/services/chapter_list.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""列章只读服务:枚举项目「真实存在的章」+ 可审/已审标记(审稿选章下拉用)。
|
||||
|
||||
数据来源:`chapters`(草稿/accepted 版本)+ `chapter_reviews`。核心业务逻辑是把这两张表
|
||||
的行**聚合**为「按章号去重、升序」的列章视图,抽成纯函数 `build_chapter_list` 以便脱库直测
|
||||
(真 PG 行为交 tests/ E2E,符合本仓单测确定性纪律)。`SqlChapterLister` 只负责取行 + 调聚合。
|
||||
|
||||
不变量:只读、不写库、不触 LLM;按 project_id 隔离(owner 校验在端点走 project_repo,同其它端点)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Protocol
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_core.domain.chapter_repo import ACCEPTED_STATUS, DRAFT_STATUS
|
||||
from ww_db.models import Chapter, ChapterReview
|
||||
|
||||
from ww_api.schemas.projects import ChapterListItem
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChapterRow:
|
||||
"""`chapters` 行的最小读投影(聚合所需字段)。"""
|
||||
|
||||
chapter_no: int
|
||||
status: str
|
||||
content: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReviewRow:
|
||||
"""`chapter_reviews` 行的最小读投影(章号 + 审稿时间)。"""
|
||||
|
||||
chapter_no: int
|
||||
created_at: datetime
|
||||
|
||||
|
||||
def build_chapter_list(
|
||||
chapter_rows: list[ChapterRow], review_rows: list[ReviewRow]
|
||||
) -> list[ChapterListItem]:
|
||||
"""把 chapters/reviews 行聚合为按章号去重、升序的列章视图(纯函数,无副作用)。
|
||||
|
||||
- `has_draft`:该章存在 status='draft' 且正文非空白的行(与 GET .../draft「空草稿=无」一致)。
|
||||
- `accepted`:该章存在 status='accepted' 行。
|
||||
- `reviewed_at`:该章审稿行 created_at 的最大值(无审稿则 None)。
|
||||
- 章号来自两表并集(含仅审稿未存草稿的章),去重后升序。
|
||||
"""
|
||||
has_draft: dict[int, bool] = {}
|
||||
accepted: dict[int, bool] = {}
|
||||
reviewed_at: dict[int, datetime] = {}
|
||||
|
||||
for row in chapter_rows:
|
||||
if row.status == DRAFT_STATUS and row.content is not None and row.content.strip():
|
||||
has_draft[row.chapter_no] = True
|
||||
if row.status == ACCEPTED_STATUS:
|
||||
accepted[row.chapter_no] = True
|
||||
|
||||
for review in review_rows:
|
||||
current = reviewed_at.get(review.chapter_no)
|
||||
if current is None or review.created_at > current:
|
||||
reviewed_at[review.chapter_no] = review.created_at
|
||||
|
||||
chapter_nos = sorted({r.chapter_no for r in chapter_rows} | {r.chapter_no for r in review_rows})
|
||||
return [
|
||||
ChapterListItem(
|
||||
chapter_no=no,
|
||||
has_draft=has_draft.get(no, False),
|
||||
accepted=accepted.get(no, False),
|
||||
reviewed_at=reviewed_at.get(no),
|
||||
)
|
||||
for no in chapter_nos
|
||||
]
|
||||
|
||||
|
||||
class ChapterLister(Protocol):
|
||||
"""列章只读接口(按 project_id 隔离)——端点依赖此协议,测试可注入 fake。"""
|
||||
|
||||
async def list_chapters(self, project_id: uuid.UUID) -> list[ChapterListItem]: ...
|
||||
|
||||
|
||||
class SqlChapterLister:
|
||||
"""SQLAlchemy 实现:两条只读查询取行 → 交纯聚合。单项目章数有界,聚合放 Python 侧(KISS)。"""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._s = session
|
||||
|
||||
async def list_chapters(self, project_id: uuid.UUID) -> list[ChapterListItem]:
|
||||
chapter_result = await self._s.execute(
|
||||
select(Chapter.chapter_no, Chapter.status, Chapter.content).where(
|
||||
Chapter.project_id == project_id
|
||||
)
|
||||
)
|
||||
review_result = await self._s.execute(
|
||||
select(ChapterReview.chapter_no, ChapterReview.created_at).where(
|
||||
ChapterReview.project_id == project_id
|
||||
)
|
||||
)
|
||||
chapter_rows = [
|
||||
ChapterRow(chapter_no=r.chapter_no, status=r.status, content=r.content)
|
||||
for r in chapter_result.all()
|
||||
]
|
||||
review_rows = [
|
||||
ReviewRow(chapter_no=r.chapter_no, created_at=r.created_at) for r in review_result.all()
|
||||
]
|
||||
return build_chapter_list(chapter_rows, review_rows)
|
||||
@@ -18,6 +18,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_config import get_settings
|
||||
from ww_core.domain import ForeshadowLedgerRepo, SqlForeshadowLedgerRepo
|
||||
from ww_core.domain.ai_message_repo import AiMessageRepo, SqlAiMessageRepo
|
||||
from ww_core.domain.chapter_repo import ChapterRepo, SqlChapterRepo
|
||||
from ww_core.domain.character_repo import CharacterWriteRepo, SqlCharacterWriteRepo
|
||||
from ww_core.domain.digest_repo import DigestAppendRepo, SqlDigestAppendRepo
|
||||
@@ -52,6 +53,7 @@ from ww_api.security.credentials import (
|
||||
CredentialKeyError,
|
||||
decrypt_api_key,
|
||||
)
|
||||
from ww_api.services.chapter_list import ChapterLister, SqlChapterLister
|
||||
from ww_api.services.credentials import (
|
||||
AUTH_TYPE_OAUTH,
|
||||
STUB_OWNER_ID,
|
||||
@@ -98,6 +100,13 @@ def get_chapter_repo(
|
||||
return SqlChapterRepo(session)
|
||||
|
||||
|
||||
def get_chapter_lister(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> ChapterLister:
|
||||
"""列章只读服务(GET /projects/:id/chapters)。只读、不写库;测试注入 fake lister。"""
|
||||
return SqlChapterLister(session)
|
||||
|
||||
|
||||
def get_memory_repos(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> MemoryRepos:
|
||||
@@ -160,6 +169,16 @@ def get_rule_write_repo(
|
||||
return SqlRuleWriteRepo(session)
|
||||
|
||||
|
||||
def get_ai_message_repo(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> AiMessageRepo:
|
||||
"""AI 对话留痕 repo(POST/GET /projects/{id}/ai-messages;append 只 flush,端点提交)。
|
||||
|
||||
测试经 `app.dependency_overrides[get_ai_message_repo]` 注入内存 fake。
|
||||
"""
|
||||
return SqlAiMessageRepo(session)
|
||||
|
||||
|
||||
async def get_template_repo(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> TemplateRepo:
|
||||
@@ -519,6 +538,18 @@ async def get_refine_gateway(
|
||||
return await build_gateway_for_tier(session, store, "writer")
|
||||
|
||||
|
||||
async def get_clarify_gateway(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> Gateway:
|
||||
"""润色预检澄清(analyst 档位)的可注入网关缝。
|
||||
|
||||
`POST .../refine/clarify` 在 dep 解析阶段构建网关 → 无凭据时这里抛 `LLM_UNAVAILABLE`
|
||||
(503,进入端点前拦下)。测试经 override 注 mock(产 `ClarifyDecision`)。
|
||||
"""
|
||||
store = SqlCredentialStore(session)
|
||||
return await build_gateway_for_tier(session, store, "analyst")
|
||||
|
||||
|
||||
async def get_chain_gateway(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> Gateway:
|
||||
|
||||
@@ -14,6 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_config import get_settings
|
||||
from ww_db import get_session
|
||||
from ww_llm_gateway.adapters.base import Capabilities
|
||||
from ww_llm_gateway.adapters.openai_compat import OpenAICompatAdapter
|
||||
from ww_llm_gateway.factory import build_adapter
|
||||
from ww_shared import AppError, ErrorCode
|
||||
|
||||
@@ -85,10 +86,17 @@ class GatewayProviderProbe:
|
||||
|
||||
# 经网关工厂构造适配器(base_url 单点归网关 build_adapter,不在此本地构造 AsyncOpenAI)。
|
||||
adapter = build_adapter(provider, api_key=api_key, base_url=base_url)
|
||||
# `_PROVIDER_BASE_URLS` 里的 api_key 型 provider 均映射到 OpenAI 兼容适配器(含
|
||||
# kimi-code-key 子类);据此收窄类型,用公开 `probe_connection` 探测(不反手私有客户端)。
|
||||
if not isinstance(adapter, OpenAICompatAdapter):
|
||||
raise AppError(
|
||||
ErrorCode.VALIDATION,
|
||||
f"provider {provider} 不支持 api_key 连接测试",
|
||||
{"provider": provider},
|
||||
)
|
||||
try:
|
||||
# 最小探测:列模型即可验证 Key 有效(不消耗生成额度)。底层 AsyncOpenAI 由适配器持有
|
||||
# (已知 provider 为 OpenAI 兼容,见 _PROVIDER_BASE_URLS);读私有客户端属同仓既有约定。
|
||||
await adapter._client.models.list() # type: ignore[attr-defined] # noqa: SLF001
|
||||
# 最小探测:列模型即可验证 Key 有效(不消耗生成额度)。
|
||||
await adapter.probe_connection()
|
||||
except Exception as exc: # noqa: BLE001 — 任一失败都映射为 LLM 不可用
|
||||
raise AppError(
|
||||
ErrorCode.LLM_UNAVAILABLE,
|
||||
|
||||
@@ -5,12 +5,26 @@
|
||||
/* 纸感设计 token(UX_SPEC §2.1) */
|
||||
:root,
|
||||
[data-theme="paper"] {
|
||||
/* 面色阶:底 → 分隔带 → 内容卡(比底暗一步)→ 选中强调 → 浮起面板 */
|
||||
--color-bg: #f5f1e8;
|
||||
--color-surface-soft: #efe9dc;
|
||||
--color-surface-card: #ece4d3;
|
||||
--color-surface-strong: #e4d9c4;
|
||||
--color-panel: #fbf8f1;
|
||||
/* 文字色阶(5 级):标题 → 强调段 → 正文 → 次级 → 说明/细则 */
|
||||
--color-ink: #2b2620;
|
||||
--color-body-strong: #3a342b;
|
||||
--color-body: #4a4234;
|
||||
--color-ink-soft: #6b6356;
|
||||
/* muted-soft 达 WCAG AA 小字(≥4.5:1)于 bg/card/panel/soft 各面(P4-1)。 */
|
||||
--color-muted-soft: #6f6556;
|
||||
/* 边框:主边框 + 同带内更弱分隔 */
|
||||
--color-line: #e5ddcd;
|
||||
--color-line-soft: #ece4d5;
|
||||
/* 朱砂 coral:主色 + 按压变深 + 奶油化禁用 + 淡底 */
|
||||
--color-cinnabar: #a23b2e;
|
||||
--color-cinnabar-active: #8a2f24;
|
||||
--color-cinnabar-disabled: #d9cdbd;
|
||||
--color-cinnabar-wash: #a23b2e14;
|
||||
--color-conflict: #b5543a;
|
||||
--color-overdue: #c8893a;
|
||||
@@ -18,17 +32,42 @@
|
||||
--color-info: #4a5a6b;
|
||||
--color-conflict-mark: #b5543a26;
|
||||
--color-conflict-mark-strong: #b5543a8c;
|
||||
/* 暖深色 callout:仅用于英雄/CTA 时刻(对标 DESIGN.md cta-band-dark),非内容底。 */
|
||||
--color-callout: #201d18;
|
||||
--color-on-callout: #f2ede2;
|
||||
--color-on-callout-soft: #b8ae9c;
|
||||
--shadow-paper: #2b26200f;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* 动效 token(与主题无关,单处定义;夜读模式不覆盖) */
|
||||
:root {
|
||||
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
|
||||
--dur-fast: 120ms;
|
||||
--dur-base: 180ms;
|
||||
}
|
||||
|
||||
[data-theme="night"] {
|
||||
/* 面色阶:底 → 分隔带 → 浮起面板 → 内容卡(暗面向上抬)→ 选中强调 */
|
||||
--color-bg: #181614;
|
||||
--color-surface-soft: #1e1b17;
|
||||
--color-surface-card: #2a2620;
|
||||
--color-surface-strong: #332e26;
|
||||
--color-panel: #221f1b;
|
||||
/* 文字色阶(5 级) */
|
||||
--color-ink: #efe6d7;
|
||||
--color-body-strong: #ddd2c0;
|
||||
--color-body: #cdc0ab;
|
||||
--color-ink-soft: #b9ab96;
|
||||
/* muted-soft 达 AA 小字(≥4.5:1)于夜读 bg/card/soft 各面(P4-1)。 */
|
||||
--color-muted-soft: #9a8e7c;
|
||||
/* 边框 */
|
||||
--color-line: #3c352c;
|
||||
--color-line-soft: #2f2a23;
|
||||
/* 朱砂 coral */
|
||||
--color-cinnabar: #e07866;
|
||||
--color-cinnabar-active: #c85f4d;
|
||||
--color-cinnabar-disabled: #3c352c;
|
||||
--color-cinnabar-wash: #e0786620;
|
||||
--color-conflict: #ef8a72;
|
||||
--color-overdue: #d8a65d;
|
||||
@@ -36,6 +75,10 @@
|
||||
--color-info: #8ca8ca;
|
||||
--color-conflict-mark: #ef8a7230;
|
||||
--color-conflict-mark-strong: #ef8a7290;
|
||||
/* 夜读下 callout 为更深的凹陷暖井,仍与 bg 拉开层次。 */
|
||||
--color-callout: #100e0c;
|
||||
--color-on-callout: #efe6d7;
|
||||
--color-on-callout-soft: #a89d8a;
|
||||
--shadow-paper: #00000045;
|
||||
color-scheme: dark;
|
||||
}
|
||||
@@ -91,7 +134,111 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
/* 主题切换图标:显隐由 data-theme 决定(而非 JS 状态),两个图标始终在 DOM 里,
|
||||
服务端/客户端首帧结构一致——既避免水合不一致,又无首帧闪烁。 */
|
||||
.theme-icon-moon {
|
||||
display: inline-block;
|
||||
}
|
||||
.theme-icon-sun {
|
||||
display: none;
|
||||
}
|
||||
[data-theme="night"] .theme-icon-moon {
|
||||
display: none;
|
||||
}
|
||||
[data-theme="night"] .theme-icon-sun {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Toast 入场:淡入 + 轻微上移(尊重 prefers-reduced-motion,见下)。 */
|
||||
.toast-enter {
|
||||
animation: toast-in var(--dur-base) var(--ease-standard);
|
||||
}
|
||||
@keyframes toast-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* AI 工作中·三点波动(比 animate-pulse 更明显:竖向弹跳 + 亮度,供 ThinkingIndicator 用)。 */
|
||||
.ai-dot {
|
||||
animation: ai-dot 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes ai-dot {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.4;
|
||||
}
|
||||
40% {
|
||||
transform: translateY(-3px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* AI 流式/处理中·不确定进度条(细条来回扫,明确传达"正在工作")。 */
|
||||
.ai-stream-track {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--color-cinnabar-wash);
|
||||
}
|
||||
.ai-stream-track::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
left: -40%;
|
||||
width: 40%;
|
||||
border-radius: 9999px;
|
||||
background: var(--color-cinnabar);
|
||||
animation: ai-stream 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes ai-stream {
|
||||
0% {
|
||||
left: -40%;
|
||||
width: 35%;
|
||||
}
|
||||
50% {
|
||||
width: 55%;
|
||||
}
|
||||
100% {
|
||||
left: 100%;
|
||||
width: 35%;
|
||||
}
|
||||
}
|
||||
|
||||
/* AI 构思中·占位微光(正文区首 token 前的呼吸感,比骨架更"活")。 */
|
||||
.ai-breathe {
|
||||
animation: ai-breathe 1.8s ease-in-out infinite;
|
||||
}
|
||||
@keyframes ai-breathe {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ai-dot {
|
||||
animation: none;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.ai-stream-track::after {
|
||||
animation: none;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
opacity: 0.35;
|
||||
}
|
||||
.ai-breathe {
|
||||
animation: none;
|
||||
}
|
||||
.typewriter-cursor {
|
||||
animation: none;
|
||||
}
|
||||
@@ -102,4 +249,7 @@ body {
|
||||
animation: none;
|
||||
outline: 2px solid var(--color-conflict);
|
||||
}
|
||||
.toast-enter {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
298
apps/web/app/help/page.tsx
Normal file
298
apps/web/app/help/page.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
import Link from "next/link";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
BookMarked,
|
||||
BookOpen,
|
||||
Blocks,
|
||||
CheckCircle2,
|
||||
ClipboardCheck,
|
||||
FileText,
|
||||
Flag,
|
||||
LayoutGrid,
|
||||
ListTree,
|
||||
Moon,
|
||||
PanelLeftClose,
|
||||
PenLine,
|
||||
Route,
|
||||
Search,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Eyebrow } from "@/components/ui/Eyebrow";
|
||||
import { PageContainer } from "@/components/ui/PageContainer";
|
||||
import { buttonClass } from "@/lib/ui/variants";
|
||||
|
||||
// 帮助中心:功能介绍 + 新手上手(静态内容,Server Component)。
|
||||
|
||||
interface Step {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
const STEPS: Step[] = [
|
||||
{
|
||||
icon: PenLine,
|
||||
title: "立项,或直接开始写",
|
||||
body: "从一句灵感开始:「先立项」立好核心卖点与一句话故事,再到设定库、大纲把架构搭起来;或「直接开始写」落笔即成书,边写边补设定。",
|
||||
},
|
||||
{
|
||||
icon: Sparkles,
|
||||
title: "让 AI 写这章",
|
||||
body: "在写作台告诉 AI 这章想怎么写,它对着大纲给出草稿(流式出稿)。你可以续写接着写、润色选段、或整章重写——原文永远你说了算。",
|
||||
},
|
||||
{
|
||||
icon: ClipboardCheck,
|
||||
title: "四审,然后验收",
|
||||
body: "写完点「审稿」,AI 从一致性 / 伏笔 / 文风 / 节奏四个维度逐条检查、就地标注冲突。你裁决后验收,系统更新真相源与伏笔账本。",
|
||||
},
|
||||
];
|
||||
|
||||
interface Principle {
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
const PRINCIPLES: Principle[] = [
|
||||
{
|
||||
title: "把写小说当软件工程",
|
||||
body: "先立架构(世界观 + 人物 + 大纲),再对着规格逐章生成,每章写完即测,全程维护单一真相源。不让 AI 一口气瞎写十万字。",
|
||||
},
|
||||
{
|
||||
title: "单一真相源 · 记忆不衰减",
|
||||
body: "设定、状态、伏笔集中记账;AI 写每一章都按需读取相关记忆(确定性选择,非一次性塞满上下文),对抗长篇遗忘。",
|
||||
},
|
||||
{
|
||||
title: "你始终掌控",
|
||||
body: "AI 是灵感增幅器 + 质检员,不是全自动黑箱。四审只读、只给建议,任何写入都要你裁决采纳,不会静默覆盖你的原文。",
|
||||
},
|
||||
];
|
||||
|
||||
interface Feature {
|
||||
icon: LucideIcon;
|
||||
name: string;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
interface FeatureGroup {
|
||||
label: string;
|
||||
features: Feature[];
|
||||
}
|
||||
|
||||
const FEATURE_GROUPS: FeatureGroup[] = [
|
||||
{
|
||||
label: "写作",
|
||||
features: [
|
||||
{
|
||||
icon: PenLine,
|
||||
name: "写作台",
|
||||
desc: "对着大纲逐章生成、流式出稿;续写接着写、润色选段、整章重写,随时进入专注模式沉浸写作。",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "资料库",
|
||||
features: [
|
||||
{ icon: ListTree, name: "大纲", desc: "卷 → 章 → 节拍逐层拆解;可用「细纲生成器」把某章展开为场景,入库时并入该章节拍。" },
|
||||
{ icon: BookMarked, name: "设定库", desc: "世界观圣经 + 人物卡(动机、人物弧光,像接口约束行为)+ 关系图谱。" },
|
||||
{ icon: Flag, name: "伏笔", desc: "「埋设 → 回收」的显式账本,逾期未收进「已逾期」泳道并标记,不再漏坑。" },
|
||||
{ icon: FileText, name: "文风", desc: "抽取你的文风指纹并注入生成,抵抗机翻腔与风格漂移。" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "审阅",
|
||||
features: [
|
||||
{
|
||||
icon: ClipboardCheck,
|
||||
name: "审稿 · 四审",
|
||||
desc: "每章写完即审:一致性 / 伏笔 / 文风 / 节奏,冲突就地标注、逐条裁决后验收。",
|
||||
},
|
||||
{ icon: Route, name: "多章链", desc: "从指定章起循环「写章 → 四审 → 验收」批量推进,遇未决冲突暂停等你裁决再续跑。" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "工具与全局",
|
||||
features: [
|
||||
{ icon: Blocks, name: "工具箱", desc: "十多个生成器:脑洞 / 书名 / 简介 / 金手指 / 词条 / 黄金开篇 / 细纲 / 拆书等;部分可一键入库,部分为预览产出。" },
|
||||
{ icon: ShieldCheck, name: "规则", desc: "沉淀你的创作规则,分全局 / 题材 / 文风 / 本作四级,约束 AI 少犯同类错。" },
|
||||
{ icon: LayoutGrid, name: "模板库", desc: "可复用的提示词模板,在工具箱生成器里一键填入需求 / 原文。" },
|
||||
{ icon: Settings, name: "设置", desc: "写手 / 分析 / 轻量三档模型路由 + API Key / Kimi OAuth。" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface Tip {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
const TIPS: Tip[] = [
|
||||
{ icon: Search, title: "命令面板 ⌘K", body: "任意页面按 ⌘K 快速跳转页面或执行命令。" },
|
||||
{ icon: PanelLeftClose, title: "专注写作模式", body: "写作台底栏点「专注」,隐藏侧栏、加宽正文,沉浸写作。" },
|
||||
{ icon: Moon, title: "夜读模式", body: "顶栏月亮图标切换暖色夜读,长时间写作更护眼。" },
|
||||
{ icon: CheckCircle2, title: "原文永远你说了算", body: "AI 给的是草稿,采不采用由你定,绝不静默覆盖你的正文。" },
|
||||
];
|
||||
|
||||
interface Faq {
|
||||
q: string;
|
||||
a: string;
|
||||
}
|
||||
|
||||
const FAQS: Faq[] = [
|
||||
{
|
||||
q: "AI 会自动改我的正文吗?",
|
||||
a: "不会。四审是只读的、只给建议;任何写入都要你在验收环节裁决,冲突未解决会阻止验收。",
|
||||
},
|
||||
{
|
||||
q: "用的是哪个大模型?",
|
||||
a: "后端是多提供商网关,写手 / 分析 / 轻量三档可各自路由(Claude / DeepSeek / Kimi / GPT / Gemini / 通义 / GLM 等,可回退),在「设置 → 模型与提供商」里配。",
|
||||
},
|
||||
{
|
||||
q: "写到几十万字会不会记忆衰减?",
|
||||
a: "靠单一真相源 + 按需记忆注入:写每章只挑相关的设定、角色与最近若干章,而不是把全书塞进上下文。",
|
||||
},
|
||||
{
|
||||
q: "我的数据在哪里?",
|
||||
a: "本地 Postgres,单用户原型;设定/正文/审稿结果都以数据库为唯一真相源,记忆追加保留历史、不就地覆盖。",
|
||||
},
|
||||
];
|
||||
|
||||
function SectionTitle({ eyebrow, title }: { eyebrow: string; title: string }) {
|
||||
return (
|
||||
<div className="mb-5">
|
||||
<Eyebrow className="mb-1.5">{eyebrow}</Eyebrow>
|
||||
<h2 className="font-serif text-display-sm text-ink">{title}</h2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IconChip({ icon: Icon }: { icon: LucideIcon }) {
|
||||
return (
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-[var(--color-cinnabar-wash)] text-cinnabar">
|
||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HelpPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<PageContainer>
|
||||
{/* 英雄:暖深色编辑部条(对标 DESIGN.md cta-band-dark)。 */}
|
||||
<section className="mb-12 rounded-xl border border-line/50 bg-callout px-8 py-12 shadow-paper sm:px-10">
|
||||
<Eyebrow className="text-on-callout-soft">墨痕 · 使用指南</Eyebrow>
|
||||
<h1 className="mt-3 max-w-2xl font-serif text-display-md text-on-callout">
|
||||
把写小说,当软件工程来做
|
||||
</h1>
|
||||
<p className="mt-4 max-w-2xl text-body leading-7 text-on-callout-soft">
|
||||
立项 → 设定 → 大纲 → 写章 → 四审 → 验收。先立架构再对着规格生成,每章写完即测,全程维护单一真相源。下面三步带你上手,并逐一介绍每个功能。
|
||||
</p>
|
||||
<div className="mt-6 flex flex-wrap gap-2">
|
||||
<Link href="/write" className={buttonClass({ variant: "primary" })}>
|
||||
<PenLine className="h-4 w-4" aria-hidden="true" />
|
||||
直接开始写
|
||||
</Link>
|
||||
<Link href="/projects/new" className={buttonClass({ variant: "secondary" })}>
|
||||
先立项
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 新手三步 */}
|
||||
<section className="mb-14">
|
||||
<SectionTitle eyebrow="快速上手" title="三步写出第一章" />
|
||||
<ol className="grid gap-4 sm:grid-cols-3">
|
||||
{STEPS.map((step, i) => (
|
||||
<li key={step.title}>
|
||||
<Card tone="card" className="flex h-full flex-col gap-3 p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<IconChip icon={step.icon} />
|
||||
<span className="font-mono text-caption text-muted-soft">
|
||||
第 {i + 1} 步
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="font-serif text-title-md text-ink">{step.title}</h3>
|
||||
<p className="text-caption leading-6 text-ink-soft">{step.body}</p>
|
||||
</Card>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
{/* 核心理念 */}
|
||||
<section className="mb-14">
|
||||
<SectionTitle eyebrow="为什么这样设计" title="三个核心理念" />
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{PRINCIPLES.map((p) => (
|
||||
<Card key={p.title} tone="soft" flat className="p-5">
|
||||
<h3 className="font-serif text-title-md text-ink">{p.title}</h3>
|
||||
<p className="mt-2 text-caption leading-6 text-ink-soft">{p.body}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 功能一览 */}
|
||||
<section className="mb-14">
|
||||
<SectionTitle eyebrow="功能介绍" title="每个板块是做什么的" />
|
||||
<div className="space-y-8">
|
||||
{FEATURE_GROUPS.map((group) => (
|
||||
<div key={group.label}>
|
||||
<h3 className="mb-3 flex items-center gap-2 font-sans text-title-md text-ink">
|
||||
{group.label}
|
||||
<Badge variant="neutral">{group.features.length}</Badge>
|
||||
</h3>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{group.features.map((f) => (
|
||||
<Card key={f.name} className="flex gap-3 p-4">
|
||||
<IconChip icon={f.icon} />
|
||||
<div className="min-w-0">
|
||||
<p className="font-serif text-title-md text-ink">{f.name}</p>
|
||||
<p className="mt-1 text-caption leading-6 text-ink-soft">{f.desc}</p>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 小技巧 */}
|
||||
<section className="mb-14">
|
||||
<SectionTitle eyebrow="顺手小技巧" title="用得更顺" />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{TIPS.map((t) => (
|
||||
<div key={t.title} className="flex gap-3">
|
||||
<IconChip icon={t.icon} />
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-ink">{t.title}</p>
|
||||
<p className="mt-0.5 text-caption leading-6 text-ink-soft">{t.body}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 常见问题 */}
|
||||
<section>
|
||||
<SectionTitle eyebrow="常见问题" title="你可能想问" />
|
||||
<dl className="space-y-3">
|
||||
{FAQS.map((f) => (
|
||||
<Card key={f.q} as="div" className="p-5">
|
||||
<dt className="font-serif text-title-md text-ink">{f.q}</dt>
|
||||
<dd className="mt-2 text-caption leading-6 text-ink-soft">{f.a}</dd>
|
||||
</Card>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
</PageContainer>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,79 @@
|
||||
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { PageContainer } from "@/components/ui/PageContainer";
|
||||
import { Skeleton } from "@/components/ui/Skeleton";
|
||||
|
||||
// 全站加载骨架:与作品库结构近似(标题条 + 工具条 + 卡片网格),
|
||||
// motion-safe 脉冲,尊重 prefers-reduced-motion。
|
||||
export default function Loading() {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-bg px-6 py-16">
|
||||
<ThinkingIndicator label="加载中…" className="text-ink-soft" />
|
||||
<main className="min-h-screen bg-bg">
|
||||
<PageContainer>
|
||||
<div role="status" aria-label="加载中" className="space-y-8">
|
||||
<span className="sr-only">加载中…</span>
|
||||
<HeaderSkeleton />
|
||||
<ToolbarSkeleton />
|
||||
<GridSkeleton />
|
||||
</div>
|
||||
</PageContainer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// 标题区:大标题条 + 说明条 + 右侧两枚按钮位。
|
||||
function HeaderSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-72 max-w-full" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-9 w-28" />
|
||||
<Skeleton className="h-9 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 工具条:搜索框 + 三枚控件位。
|
||||
function ToolbarSkeleton() {
|
||||
return (
|
||||
<Card flat className="grid gap-3 p-3 md:grid-cols-[1fr_auto_auto_auto]">
|
||||
<Skeleton className="h-9 w-full" />
|
||||
<Skeleton className="h-9 w-28" />
|
||||
<Skeleton className="h-9 w-28" />
|
||||
<Skeleton className="h-9 w-20" />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 卡片网格:6 张与作品卡等高的占位卡。
|
||||
function GridSkeleton() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<CardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 单张作品卡骨架:标题 + 徽章 + 三行简介 + 底部时间。
|
||||
function CardSkeleton() {
|
||||
return (
|
||||
<Card flat className="flex h-full min-h-[176px] flex-col p-6">
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<Skeleton className="h-6 w-3/4" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-8 shrink-0" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
</div>
|
||||
<Skeleton className="mt-auto h-3 w-28" />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import Link from "next/link";
|
||||
import { BookOpen, Plus } from "lucide-react";
|
||||
import { BookOpen, PenLine, Plus } from "lucide-react";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { BackendDownNotice } from "@/components/BackendDownNotice";
|
||||
import { ProjectLibrary } from "@/components/projects/ProjectLibrary";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { PageContainer } from "@/components/ui/PageContainer";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { fetchProjects } from "@/lib/api/server";
|
||||
import type { ProjectResponse } from "@/lib/api/types";
|
||||
@@ -23,42 +23,63 @@ export default async function DashboardPage() {
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="mx-auto max-w-5xl px-6 py-10 sm:px-8">
|
||||
<PageContainer width="wide">
|
||||
<PageHeader
|
||||
title="我的作品"
|
||||
description="从一个灵感进入正文、设定、审稿与验收闭环。"
|
||||
actions={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
href="/projects/new"
|
||||
href="/write"
|
||||
className={buttonClass({ variant: "primary" })}
|
||||
>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
新建作品
|
||||
<PenLine className="h-4 w-4" aria-hidden="true" />
|
||||
直接开始写
|
||||
</Link>
|
||||
<Link
|
||||
href="/projects/new"
|
||||
className={buttonClass({ variant: "secondary" })}
|
||||
>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
先立项
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{loadError ? (
|
||||
<BackendDownNotice />
|
||||
) : projects.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={BookOpen}
|
||||
title="还没有作品"
|
||||
description="从一句灵感开始,后续的设定库、大纲、写章和审稿都会围绕这本书展开。"
|
||||
action={
|
||||
// 首次进入的暖深色编辑部英雄(对标 DESIGN.md cta-band-dark;仅零作品时出现,不干扰工作库)。
|
||||
<div className="rounded-xl border border-line/50 bg-callout px-8 py-14 text-center shadow-paper sm:px-12 sm:py-20">
|
||||
<BookOpen className="mx-auto h-9 w-9 text-cinnabar" aria-hidden="true" />
|
||||
<h2 className="mt-5 font-serif text-display-sm text-on-callout">
|
||||
从一句灵感,写成一本书
|
||||
</h2>
|
||||
<p className="mx-auto mt-3 max-w-md text-body leading-7 text-on-callout-soft">
|
||||
直接开始写,落笔即成书;或先立项,从一句灵感搭好设定、大纲再动笔。
|
||||
</p>
|
||||
<div className="mt-7 flex flex-wrap items-center justify-center gap-2">
|
||||
<Link
|
||||
href="/write"
|
||||
className={buttonClass({ variant: "primary", size: "lg" })}
|
||||
>
|
||||
<PenLine className="h-4 w-4" aria-hidden="true" />
|
||||
直接开始写
|
||||
</Link>
|
||||
<Link
|
||||
href="/projects/new"
|
||||
className={buttonClass({ variant: "primary" })}
|
||||
className={buttonClass({ variant: "secondary", size: "lg" })}
|
||||
>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
新建作品
|
||||
先立项
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ProjectLibrary projects={projects} />
|
||||
)}
|
||||
</div>
|
||||
</PageContainer>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,17 @@ import { notFound } from "next/navigation";
|
||||
|
||||
import { ReviewReport } from "@/components/review/ReviewReport";
|
||||
import {
|
||||
fetchChapters,
|
||||
fetchDraft,
|
||||
fetchOutline,
|
||||
fetchProject,
|
||||
fetchReviews,
|
||||
} from "@/lib/api/server";
|
||||
import type { ProjectResponse } from "@/lib/api/types";
|
||||
import {
|
||||
buildReviewChapterOptions,
|
||||
defaultReviewChapter,
|
||||
} from "@/lib/review/chapterNav";
|
||||
import { latestReview } from "@/lib/review/history";
|
||||
import type { ChapterEntry } from "@/lib/workbench/chapter";
|
||||
|
||||
@@ -16,14 +21,11 @@ interface PageProps {
|
||||
searchParams: Promise<{ chapter?: string }>;
|
||||
}
|
||||
|
||||
const DEFAULT_CHAPTER_NO = 1;
|
||||
|
||||
// 审稿报告页(UX §6.4)。Server Component 取项目 + 审稿留痕历史(新→旧);
|
||||
// ReviewReport(Client)承载 SSE 重审 / 裁决 / 验收交互。
|
||||
export default async function ReviewPage({ params, searchParams }: PageProps) {
|
||||
const { id } = await params;
|
||||
const { chapter } = await searchParams;
|
||||
const chapterNo = parsePositiveInt(chapter) ?? DEFAULT_CHAPTER_NO;
|
||||
|
||||
// 仅当项目确实取不到时才判 404;审稿/草稿/大纲等次级拉取的瞬时错误不应把整页变成「找不到」。
|
||||
let project: ProjectResponse;
|
||||
@@ -33,6 +35,11 @@ export default async function ReviewPage({ params, searchParams }: PageProps) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// 真实存在的章(含可审/已审标记,错误→[]):供选章枚举 + 决定默认章。
|
||||
const chapterList = await fetchChapters(id);
|
||||
// 无 ?chapter 时默认落到最近一个有草稿的章,而非硬编码第 1 章。
|
||||
const chapterNo = parsePositiveInt(chapter) ?? defaultReviewChapter(chapterList);
|
||||
|
||||
// 审稿留痕(GET .../reviews,失败→空历史)。
|
||||
let initialReview;
|
||||
try {
|
||||
@@ -57,6 +64,13 @@ export default async function ReviewPage({ params, searchParams }: PageProps) {
|
||||
title: c.beats?.[0],
|
||||
}));
|
||||
|
||||
// 选章下拉选项:以真实章为准(覆盖「写过但不在大纲」的章),大纲标题补短名,标注可审/已审。
|
||||
const chapterOptions = buildReviewChapterOptions(
|
||||
chapterList,
|
||||
chapters,
|
||||
chapterNo,
|
||||
);
|
||||
|
||||
return (
|
||||
// key=章号:客户端切章(?chapter=N 变化)时强制重挂载,用新章审稿/草稿刷新状态,
|
||||
// 避免 ReviewReport 内 useState 基线沿用上一章。
|
||||
@@ -67,6 +81,7 @@ export default async function ReviewPage({ params, searchParams }: PageProps) {
|
||||
initialReview={initialReview}
|
||||
initialDraft={initialDraft}
|
||||
chapters={chapters}
|
||||
chapterOptions={chapterOptions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
6
apps/web/app/write/page.tsx
Normal file
6
apps/web/app/write/page.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { DraftComposer } from "@/components/start/DraftComposer";
|
||||
|
||||
// 进来即写入口(无 project 前缀):直达空白编辑器,敲字后懒建项目并换入 /projects/[id]/write。
|
||||
export default function WriteDraftPage() {
|
||||
return <DraftComposer />;
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import {
|
||||
BookOpen,
|
||||
ClipboardCheck,
|
||||
ListTree,
|
||||
PenLine,
|
||||
Sparkles,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { AiToolbarMoreMenu } from "@/components/AiToolbarMoreMenu";
|
||||
import {
|
||||
aiToolItems,
|
||||
primaryAiToolItems,
|
||||
secondaryAiToolItems,
|
||||
} from "@/lib/nav/ai-tools";
|
||||
import type { ActiveNav } from "@/lib/nav/items";
|
||||
import { buttonClass } from "@/lib/ui/variants";
|
||||
|
||||
interface AiToolbarProps {
|
||||
projectId: string;
|
||||
activeNav?: ActiveNav;
|
||||
}
|
||||
|
||||
// T4-a · 项目内页常驻 AI 工具条(顶栏下,UX §3/§5)。
|
||||
// 桌面展示完整工具条;窄屏只保留写本章/审稿,把低频入口收进更多菜单。
|
||||
export function AiToolbar({ projectId, activeNav }: AiToolbarProps) {
|
||||
const primaryItems = primaryAiToolItems(projectId);
|
||||
const secondaryItems = secondaryAiToolItems(projectId);
|
||||
return (
|
||||
<nav
|
||||
aria-label="AI 工具条"
|
||||
className="sticky top-16 z-20 flex h-12 items-center gap-2 border-b border-line bg-panel px-4 sm:px-6"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden lg:hidden">
|
||||
{primaryItems.map((item) => {
|
||||
const isActive = item.key === activeNav;
|
||||
const className = toolClassName(item.primary === true, isActive);
|
||||
const Icon = toolIcon(item.key);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={className}
|
||||
>
|
||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<AiToolbarMoreMenu items={secondaryItems} activeNav={activeNav} />
|
||||
</div>
|
||||
|
||||
<div className="hidden items-center gap-2 overflow-x-auto whitespace-nowrap lg:flex">
|
||||
{aiToolItems(projectId).map((item) => {
|
||||
const isActive = item.key === activeNav;
|
||||
const className = toolClassName(item.primary === true, isActive);
|
||||
const Icon = toolIcon(item.key);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={className}
|
||||
>
|
||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function toolIcon(key: ActiveNav): LucideIcon {
|
||||
if (key === "write") return PenLine;
|
||||
if (key === "review") return ClipboardCheck;
|
||||
if (key === "outline") return ListTree;
|
||||
if (key === "codex") return BookOpen;
|
||||
return Sparkles;
|
||||
}
|
||||
|
||||
function toolClassName(isPrimary: boolean, isActive: boolean): string {
|
||||
if (isActive) {
|
||||
return buttonClass({ variant: "outline", size: "sm" });
|
||||
}
|
||||
if (isPrimary) {
|
||||
return buttonClass({ variant: "primary", size: "sm" });
|
||||
}
|
||||
return buttonClass({ variant: "secondary", size: "sm" });
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { MoreHorizontal } from "lucide-react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
|
||||
import type { AiToolItem } from "@/lib/nav/ai-tools";
|
||||
import type { ActiveNav } from "@/lib/nav/items";
|
||||
import { buttonClass, cn, focusRing } from "@/lib/ui/variants";
|
||||
|
||||
interface AiToolbarMoreMenuProps {
|
||||
items: AiToolItem[];
|
||||
activeNav?: ActiveNav;
|
||||
}
|
||||
|
||||
export function AiToolbarMoreMenu({
|
||||
items,
|
||||
activeNav,
|
||||
}: AiToolbarMoreMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuId = useId();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent): void => {
|
||||
if (event.key === "Escape") {
|
||||
setOpen(false);
|
||||
buttonRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const onPointerDown = (event: PointerEvent): void => {
|
||||
const target = event.target;
|
||||
if (target instanceof Node && !rootRef.current?.contains(target)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
window.addEventListener("pointerdown", onPointerDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
window.removeEventListener("pointerdown", onPointerDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open}
|
||||
aria-controls={menuId}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className={buttonClass({ variant: "secondary", size: "sm" })}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" aria-hidden="true" />
|
||||
更多
|
||||
</button>
|
||||
{open ? (
|
||||
// 诚实的 Tab-only 折叠面板:普通导航链接,不冒充 ARIA menu(无 roving tabindex/方向键)。
|
||||
<nav
|
||||
id={menuId}
|
||||
aria-label="更多工具"
|
||||
className="absolute right-0 z-30 mt-2 min-w-36 rounded border border-line bg-panel p-1 shadow-paper"
|
||||
>
|
||||
{items.map((item) => {
|
||||
const active = item.key === activeNav;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
onClick={() => setOpen(false)}
|
||||
className={cn(
|
||||
"block rounded px-3 py-2 text-sm transition-colors",
|
||||
focusRing,
|
||||
active
|
||||
? "bg-[var(--color-cinnabar-wash)] text-cinnabar"
|
||||
: "text-ink hover:bg-bg hover:text-cinnabar",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import { Settings } from "lucide-react";
|
||||
import { FileQuestion, Settings } from "lucide-react";
|
||||
|
||||
import type { ActiveNav } from "@/lib/nav/items";
|
||||
import { buttonClass, focusRing } from "@/lib/ui/variants";
|
||||
import { AiToolbar } from "./AiToolbar";
|
||||
import { CommandSearchButton } from "./command/CommandPaletteMount";
|
||||
import { LeftNav } from "./LeftNav";
|
||||
import { NavDrawer } from "./NavDrawer";
|
||||
@@ -19,6 +18,9 @@ interface AppShellProps {
|
||||
projectId?: string;
|
||||
// 当前激活的项目级入口键(用于高亮)。
|
||||
activeNav?: ActiveNav;
|
||||
// 专注写作模式(P3-4):隐藏桌面左侧图标导航以加宽正文;顶栏汉堡/命令面板仍可达导航。
|
||||
// 缺省 false → 其余页面外观不变(加法式可选项)。
|
||||
hideNav?: boolean;
|
||||
}
|
||||
|
||||
// 全局外壳:顶栏(64px) + 左导航 + 主区(UX §5.1)。
|
||||
@@ -28,11 +30,12 @@ export function AppShell({
|
||||
subtitle,
|
||||
projectId,
|
||||
activeNav,
|
||||
hideNav = false,
|
||||
}: AppShellProps) {
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen"
|
||||
style={{ ["--chrome" as string]: projectId ? "7rem" : "4rem" }}
|
||||
style={{ ["--chrome" as string]: "4rem" }}
|
||||
>
|
||||
<a
|
||||
href="#main"
|
||||
@@ -65,6 +68,19 @@ export function AppShell({
|
||||
>
|
||||
<CommandSearchButton />
|
||||
<ThemeToggle />
|
||||
<Link
|
||||
href="/help"
|
||||
aria-label="帮助"
|
||||
title="帮助"
|
||||
className={buttonClass({
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
className: "border-transparent",
|
||||
})}
|
||||
>
|
||||
<FileQuestion className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="hidden sm:inline">帮助</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/settings/providers"
|
||||
aria-label="设置"
|
||||
@@ -80,11 +96,8 @@ export function AppShell({
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
{projectId ? (
|
||||
<AiToolbar projectId={projectId} activeNav={activeNav} />
|
||||
) : null}
|
||||
<div className="flex">
|
||||
<LeftNav projectId={projectId} activeNav={activeNav} />
|
||||
{hideNav ? null : <LeftNav projectId={projectId} activeNav={activeNav} />}
|
||||
<main
|
||||
id="main"
|
||||
tabIndex={-1}
|
||||
|
||||
@@ -5,9 +5,14 @@ import { X } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { handleTabTrap } from "@/lib/a11y/focusTrap";
|
||||
import { useRestoreFocus } from "@/lib/a11y/useRestoreFocus";
|
||||
import { useBodyScrollLock } from "@/lib/ui/useBodyScrollLock";
|
||||
import { useMountTransition } from "@/lib/ui/useMountTransition";
|
||||
import { overlayScrim } from "@/lib/ui/variants";
|
||||
|
||||
// 抽屉进出场时长(与 --dur-base 一致)。
|
||||
const DRAWER_MS = 180;
|
||||
|
||||
interface DrawerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -15,6 +20,8 @@ interface DrawerProps {
|
||||
side?: "left" | "right";
|
||||
// 无障碍标签(role=dialog)。
|
||||
label: string;
|
||||
// 抽屉面板 id:供触发按钮 aria-controls 指向(无障碍关联)。
|
||||
id?: string;
|
||||
triggerRef?: RefObject<HTMLElement | null>;
|
||||
children: ReactNode;
|
||||
}
|
||||
@@ -26,24 +33,22 @@ export function Drawer({
|
||||
onClose,
|
||||
side = "left",
|
||||
label,
|
||||
id,
|
||||
triggerRef,
|
||||
children,
|
||||
}: DrawerProps) {
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
const wasOpenRef = useRef(false);
|
||||
|
||||
const { shouldRender, isVisible } = useMountTransition(open, DRAWER_MS);
|
||||
|
||||
useBodyScrollLock(open);
|
||||
useRestoreFocus(open, triggerRef);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
if (wasOpenRef.current) {
|
||||
wasOpenRef.current = false;
|
||||
triggerRef?.current?.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
wasOpenRef.current = true;
|
||||
// 面板真正挂载(shouldRender)且处于打开态时才装监听/落焦——
|
||||
// 因进出场延迟了卸载,若仅依赖 open 会错过挂载帧导致首焦点丢失。
|
||||
if (!open || !shouldRender) return;
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
@@ -54,16 +59,28 @@ export function Drawer({
|
||||
// 焦点陷阱首焦点落在具名关闭按钮上(而非整个面板)。
|
||||
closeRef.current?.focus();
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose, triggerRef]);
|
||||
}, [open, shouldRender, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
if (!shouldRender) return null;
|
||||
|
||||
const sideClass = side === "left" ? "left-0 border-r" : "right-0 border-l";
|
||||
const slideClass =
|
||||
side === "left"
|
||||
? isVisible
|
||||
? "translate-x-0"
|
||||
: "-translate-x-full"
|
||||
: isVisible
|
||||
? "translate-x-0"
|
||||
: "translate-x-full";
|
||||
|
||||
return (
|
||||
<div className={`${overlayScrim} lg:hidden`} onClick={onClose}>
|
||||
<div
|
||||
className={`${overlayScrim} lg:hidden motion-safe:transition-opacity motion-safe:duration-base ${isVisible ? "opacity-100" : "opacity-0"}`}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
id={id}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={label}
|
||||
@@ -72,7 +89,7 @@ export function Drawer({
|
||||
// focus trap:Tab/Shift+Tab 在抽屉内循环,不逃逸到背景(WCAG 2.1.2)。
|
||||
if (panelRef.current) handleTabTrap(panelRef.current, e);
|
||||
}}
|
||||
className={`absolute top-0 h-full w-64 max-w-[80vw] overflow-auto overscroll-contain border-line bg-panel py-4 shadow-paper outline-none ${sideClass}`}
|
||||
className={`absolute top-0 h-full w-64 max-w-[80vw] overflow-auto overscroll-contain border-line bg-panel py-4 shadow-paper outline-none motion-safe:transition-transform motion-safe:duration-base ease-standard ${sideClass} ${slideClass}`}
|
||||
>
|
||||
<div className="mb-2 flex justify-end px-2">
|
||||
<Button
|
||||
|
||||
@@ -15,6 +15,9 @@ interface NavDrawerProps {
|
||||
activeNav?: ActiveNav;
|
||||
}
|
||||
|
||||
// 抽屉面板 id:供汉堡按钮 aria-controls 关联(无障碍)。
|
||||
const DRAWER_ID = "nav-drawer";
|
||||
|
||||
// 移动端导航:汉堡按钮 + 左侧抽屉(仅 <lg;桌面用 LeftNav 静态侧栏)。UX §5.1。
|
||||
export function NavDrawer({ projectId, activeNav }: NavDrawerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -32,6 +35,7 @@ export function NavDrawer({ projectId, activeNav }: NavDrawerProps) {
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="打开导航"
|
||||
aria-expanded={open}
|
||||
aria-controls={DRAWER_ID}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="-ml-2 lg:hidden"
|
||||
@@ -43,6 +47,7 @@ export function NavDrawer({ projectId, activeNav }: NavDrawerProps) {
|
||||
onClose={() => setOpen(false)}
|
||||
side="left"
|
||||
label="主导航"
|
||||
id={DRAWER_ID}
|
||||
triggerRef={triggerRef}
|
||||
>
|
||||
<div className="px-2 pb-2">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Blocks,
|
||||
@@ -19,9 +20,8 @@ import {
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import {
|
||||
GLOBAL_NAV_ITEMS,
|
||||
isNavItemActive,
|
||||
projectNavItems,
|
||||
navSections,
|
||||
type ActiveNav,
|
||||
type NavItem,
|
||||
} from "@/lib/nav/items";
|
||||
@@ -36,13 +36,24 @@ interface NavItemsProps {
|
||||
}
|
||||
|
||||
// 导航条目列表(桌面静态侧栏与移动抽屉共用,避免渲染漂移)。
|
||||
// 项目内页把 10 项按 写作/资料库/审阅/更多 分组(P2-1),每组一个小标题;全局组无标题直显于顶部。
|
||||
export function NavItems({ projectId, activeNav, onNavigate }: NavItemsProps) {
|
||||
const pathname = usePathname() ?? "/";
|
||||
const scoped = projectId ? projectNavItems(projectId) : [];
|
||||
const sections = navSections(projectId);
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col gap-1 px-2">
|
||||
{GLOBAL_NAV_ITEMS.map((item) => (
|
||||
{sections.map((section) => (
|
||||
<Fragment key={section.label ?? "__global"}>
|
||||
{section.label !== null ? (
|
||||
<li
|
||||
className="mt-3 px-3 pb-1 text-xs font-semibold uppercase tracking-wide text-ink-soft/60"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{section.label}
|
||||
</li>
|
||||
) : null}
|
||||
{section.items.map((item) => (
|
||||
<NavLink
|
||||
key={item.label}
|
||||
item={item}
|
||||
@@ -50,21 +61,7 @@ export function NavItems({ projectId, activeNav, onNavigate }: NavItemsProps) {
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
{scoped.length > 0 ? (
|
||||
<li
|
||||
className="mt-3 px-3 pb-1 text-xs font-semibold uppercase tracking-wide text-ink-soft/60"
|
||||
aria-hidden="true"
|
||||
>
|
||||
本作
|
||||
</li>
|
||||
) : null}
|
||||
{scoped.map((item) => (
|
||||
<NavLink
|
||||
key={item.label}
|
||||
item={item}
|
||||
active={isNavItemActive(item, { pathname, activeNav })}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
@@ -107,9 +107,9 @@ export function ProjectWizard() {
|
||||
const isLast = step === WIZARD_STEPS;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl rounded border border-line bg-panel p-6 shadow-paper sm:p-8">
|
||||
<div className="mx-auto max-w-2xl rounded-lg border border-line bg-panel p-6 shadow-paper sm:p-8">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="font-serif text-2xl text-ink">新建作品</h1>
|
||||
<h1 className="font-serif text-display-sm text-ink">新建作品</h1>
|
||||
<StepDots current={step} />
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
@@ -175,7 +175,7 @@ function StepDots({ current }: { current: number }) {
|
||||
{Array.from({ length: WIZARD_STEPS }, (_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`h-2 w-2 rounded ${
|
||||
className={`h-2 w-2 rounded-full ${
|
||||
i + 1 <= current ? "bg-cinnabar" : "bg-line"
|
||||
}`}
|
||||
/>
|
||||
@@ -235,7 +235,7 @@ function PlanAssistant({
|
||||
const generating = status === "generating";
|
||||
|
||||
return (
|
||||
<div className="my-4 rounded border border-line bg-bg p-3">
|
||||
<div className="my-4 rounded-md border border-line bg-bg p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="flex items-center gap-1.5 text-sm text-ink">
|
||||
@@ -449,7 +449,7 @@ function StepConfirm({ form }: { form: WizardForm }) {
|
||||
<p className="mb-3 text-sm text-ink-soft">
|
||||
复核以下信息,确认无误后即可完成立项。其余设定可进工作台后在「设定库」继续完善。
|
||||
</p>
|
||||
<dl className="divide-y divide-line rounded border border-line bg-bg">
|
||||
<dl className="divide-y divide-line rounded-md border border-line bg-bg">
|
||||
{rows.map((row) => (
|
||||
<div key={row.label} className="flex gap-4 px-4 py-2.5 text-sm">
|
||||
<dt className="w-20 shrink-0 text-ink-soft">{row.label}</dt>
|
||||
|
||||
@@ -26,13 +26,6 @@ function readStoredThemeMode(): ThemeMode {
|
||||
}
|
||||
}
|
||||
|
||||
// 初始模式直接读 ThemeScript 在水合前写入的 <html data-theme>,避免夜读首帧渲染错误图标。
|
||||
// SSR 守卫:服务端无 document → 回落默认(与 ThemeScript 的默认一致)。
|
||||
function initialThemeMode(): ThemeMode {
|
||||
if (typeof document === "undefined") return DEFAULT_THEME_MODE;
|
||||
return normalizeThemeMode(document.documentElement.dataset.theme);
|
||||
}
|
||||
|
||||
function writeStoredThemeMode(mode: ThemeMode) {
|
||||
try {
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, mode);
|
||||
@@ -42,7 +35,9 @@ function writeStoredThemeMode(mode: ThemeMode) {
|
||||
}
|
||||
|
||||
export function ThemeToggle() {
|
||||
const [mode, setMode] = useState<ThemeMode>(initialThemeMode);
|
||||
// 首帧用默认模式(与服务端一致)避免水合不一致;挂载后以 localStorage 校正。
|
||||
// 可见的图标由 CSS 按 <html data-theme> 决定(ThemeScript 已在水合前写入),故无首帧闪烁。
|
||||
const [mode, setMode] = useState<ThemeMode>(DEFAULT_THEME_MODE);
|
||||
|
||||
useEffect(() => {
|
||||
// 水合后以 localStorage 为权威源校正(dataset 已由 ThemeScript 同样依据写入,通常一致)。
|
||||
@@ -60,8 +55,8 @@ export function ThemeToggle() {
|
||||
});
|
||||
};
|
||||
|
||||
const Icon = mode === "night" ? Sun : Moon;
|
||||
|
||||
// 图标显隐交给 CSS(按 data-theme),两个图标始终渲染 → 首帧结构与服务端一致、无闪烁。
|
||||
// 首帧 label 用默认模式(与服务端一致),挂载后随 mode 校正为正确文案。
|
||||
return (
|
||||
<Button
|
||||
onClick={toggle}
|
||||
@@ -71,7 +66,8 @@ export function ThemeToggle() {
|
||||
title={themeToggleLabel(mode)}
|
||||
className="border-transparent"
|
||||
>
|
||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||
<Moon className="theme-icon-moon h-4 w-4" aria-hidden="true" />
|
||||
<Sun className="theme-icon-sun h-4 w-4" aria-hidden="true" />
|
||||
<span className="sr-only">{themeModeLabel(mode)}</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -17,12 +17,12 @@ export function ThinkingIndicator({
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span className="inline-flex gap-0.5" aria-hidden="true">
|
||||
<span className="inline-flex gap-1" aria-hidden="true">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="h-1.5 w-1.5 rounded-full bg-current opacity-70 motion-safe:animate-pulse"
|
||||
style={{ animationDelay: `${i * 200}ms` }}
|
||||
className="ai-dot h-2 w-2 rounded-full bg-current"
|
||||
style={{ animationDelay: `${i * 150}ms` }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
|
||||
@@ -98,7 +98,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const renderItem = (t: ToastItem) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`pointer-events-auto flex items-center gap-3 rounded border px-4 py-2 text-sm shadow-paper ${toastShellClass(
|
||||
className={`toast-enter pointer-events-auto flex items-center gap-3 rounded-md border px-4 py-2 text-sm shadow-paper ${toastShellClass(
|
||||
t.kind,
|
||||
)}`}
|
||||
>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Play } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Field } from "@/components/ui/Field";
|
||||
import { Radio } from "@/components/ui/Radio";
|
||||
import { SectionHeader } from "@/components/ui/SectionHeader";
|
||||
import { TextInput } from "@/components/ui/TextInput";
|
||||
import { CHAIN_KINDS, type ChainKind } from "@/lib/chain/chain";
|
||||
@@ -58,8 +59,7 @@ export function ChainStarter({ onStart, disabled }: ChainStarterProps) {
|
||||
key={kind.key}
|
||||
className="flex items-start gap-2 text-sm text-ink"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
<Radio
|
||||
name="chain-kind"
|
||||
value={kind.key}
|
||||
checked={chainKey === kind.key}
|
||||
|
||||
@@ -50,7 +50,7 @@ const RelationshipGraph = dynamic(
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex h-[28rem] items-center justify-center rounded border border-line bg-bg text-sm text-ink-soft">
|
||||
<div className="flex h-[28rem] items-center justify-center rounded-lg border border-line bg-bg text-sm text-ink-soft">
|
||||
关系图谱加载中…
|
||||
</div>
|
||||
),
|
||||
@@ -101,7 +101,7 @@ export function CodexPage({
|
||||
{tab === "characters" ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card as="section" className="p-4">
|
||||
<h3 className="mb-3 font-serif text-sm text-ink">
|
||||
<h3 className="mb-3 font-serif text-title-md text-ink">
|
||||
已入库人物({characters.length})
|
||||
</h3>
|
||||
{characters.length > 0 ? (
|
||||
@@ -109,10 +109,10 @@ export function CodexPage({
|
||||
{characters.map((c, i) => (
|
||||
<li
|
||||
key={`${c.name}-${i}`}
|
||||
className="rounded border border-line bg-bg p-3 text-sm"
|
||||
className="rounded-lg border border-line bg-bg p-3 text-sm"
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded bg-[var(--color-cinnabar-wash)] text-cinnabar">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-md bg-[var(--color-cinnabar-wash)] text-cinnabar">
|
||||
<UserRound className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
@@ -127,7 +127,7 @@ export function CodexPage({
|
||||
{c.relations.map((r, j) => (
|
||||
<li
|
||||
key={`${r.name}-${r.kind}-${j}`}
|
||||
className="rounded bg-panel px-1.5 py-0.5 text-2xs"
|
||||
className="rounded-full bg-panel px-2 py-0.5 text-2xs"
|
||||
title={r.note ?? undefined}
|
||||
>
|
||||
{r.kind} · {r.name}
|
||||
@@ -163,7 +163,7 @@ export function CodexPage({
|
||||
</Card>
|
||||
{characters.length > 0 ? (
|
||||
<Card as="section" className="p-4">
|
||||
<h3 className="mb-3 flex items-center gap-2 font-serif text-sm text-ink">
|
||||
<h3 className="mb-3 flex items-center gap-2 font-serif text-title-md text-ink">
|
||||
<Share2 className="h-4 w-4 text-cinnabar" aria-hidden="true" />
|
||||
关系图谱
|
||||
</h3>
|
||||
@@ -184,7 +184,7 @@ export function CodexPage({
|
||||
{tab === "world" ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card as="section" className="p-4">
|
||||
<h3 className="mb-3 font-serif text-sm text-ink">
|
||||
<h3 className="mb-3 font-serif text-title-md text-ink">
|
||||
已入库世界观({initialWorldEntities.length})
|
||||
</h3>
|
||||
{initialWorldEntities.length > 0 ? (
|
||||
@@ -192,7 +192,7 @@ export function CodexPage({
|
||||
{initialWorldEntities.map((entity, i) => (
|
||||
<article
|
||||
key={`${entity.name}-${i}`}
|
||||
className="rounded border border-line bg-bg p-3 text-sm"
|
||||
className="rounded-lg border border-line bg-bg p-3 text-sm"
|
||||
>
|
||||
<header className="mb-2 flex items-center gap-2">
|
||||
<Globe2
|
||||
|
||||
@@ -66,6 +66,15 @@ export function useCommandPaletteOpen(): boolean {
|
||||
const LIST_ID = "command-list";
|
||||
const optionId = (cmd: Command): string => `command-option-${cmd.id}`;
|
||||
|
||||
// 统一的键帽(kbd)样式:暖纸感描边 + 等宽小字,与 NavDrawer 的 ⌘K 提示一致。
|
||||
function KeyCap({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<kbd className="rounded border border-line bg-panel px-1 font-mono text-2xs text-ink-soft">
|
||||
{children}
|
||||
</kbd>
|
||||
);
|
||||
}
|
||||
|
||||
// 从 pathname 抽取当前 projectId(/projects/<id>/...)。
|
||||
function projectIdFromPath(pathname: string): string | null {
|
||||
const m = pathname.match(/^\/projects\/([^/]+)/);
|
||||
@@ -208,6 +217,7 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
|
||||
if (dialogRef.current) handleTabTrap(dialogRef.current, e);
|
||||
}}
|
||||
>
|
||||
<div className="relative border-b border-line">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
@@ -220,8 +230,13 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
|
||||
aria-controls={LIST_ID}
|
||||
aria-activedescendant={activeCmd ? optionId(activeCmd) : undefined}
|
||||
aria-autocomplete="list"
|
||||
className={`w-full border-b border-line bg-bg px-4 py-3 text-sm text-ink ${focusRing}`}
|
||||
className={`w-full bg-bg py-3 pl-4 pr-12 text-sm text-ink ${focusRing}`}
|
||||
/>
|
||||
{/* 触发快捷键读数:随面板常驻,强化 ⌘K 的可发现性(纯装饰,不入 Tab 序)。 */}
|
||||
<span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<KeyCap>⌘K</KeyCap>
|
||||
</span>
|
||||
</div>
|
||||
<ul
|
||||
id={LIST_ID}
|
||||
role="listbox"
|
||||
@@ -229,7 +244,14 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
|
||||
className="max-h-80 overflow-auto py-1 overscroll-contain"
|
||||
>
|
||||
{results.length === 0 ? (
|
||||
<li className="px-4 py-3 text-sm text-ink-soft">无匹配命令</li>
|
||||
<li className="px-4 py-6 text-center">
|
||||
<p className="text-sm text-ink-soft">
|
||||
没有匹配「{query.trim()}」的命令
|
||||
</p>
|
||||
<p className="mt-1 text-caption text-muted-soft">
|
||||
试试「写本章」「审稿」「生成角色」「搜设定」
|
||||
</p>
|
||||
</li>
|
||||
) : (
|
||||
results.map((cmd, i) => (
|
||||
<li
|
||||
@@ -246,11 +268,30 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
|
||||
}`}
|
||||
>
|
||||
<span>{cmd.title}</span>
|
||||
<span className="text-xs text-ink-soft">{cmd.group}</span>
|
||||
<span className="text-caption text-ink-soft">{cmd.group}</span>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
{/* 键盘操作读数:↑↓ 选择 · ↵ 打开 · esc 关闭(常驻引导,纯装饰)。 */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-line px-4 py-2 text-caption text-muted-soft"
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
<KeyCap>↑</KeyCap>
|
||||
<KeyCap>↓</KeyCap>
|
||||
选择
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<KeyCap>↵</KeyCap>
|
||||
打开
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<KeyCap>esc</KeyCap>
|
||||
关闭
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -58,10 +58,23 @@ export function ForeshadowCard({
|
||||
|
||||
<dl className="mt-2 space-y-0.5 text-xs text-ink-soft">
|
||||
{typeof item.planted_at === "number" ? (
|
||||
<div>埋于 {item.planted_at} 章</div>
|
||||
<div className="flex gap-1">
|
||||
<dt>埋于</dt>
|
||||
<dd>{item.planted_at} 章</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{windowText ? (
|
||||
<div className="flex gap-1">
|
||||
<dt>窗口</dt>
|
||||
<dd>{windowText}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{item.importance ? (
|
||||
<div className="flex gap-1">
|
||||
<dt className="sr-only">重要度</dt>
|
||||
<dd>{item.importance}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{windowText ? <div>窗口 {windowText}</div> : null}
|
||||
{item.importance ? <div>{item.importance}</div> : null}
|
||||
</dl>
|
||||
|
||||
{item.progress && item.progress.length > 0 ? (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Checkbox } from "@/components/ui/Checkbox";
|
||||
import { TextInput } from "@/components/ui/TextInput";
|
||||
import type { CharacterCardView } from "@/lib/api/types";
|
||||
|
||||
@@ -25,12 +26,10 @@ export function CharacterCardItem({
|
||||
}`}
|
||||
>
|
||||
<header className="mb-2 flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
onChange={onToggle}
|
||||
aria-label={`选择角色 ${card.name}`}
|
||||
className="size-4 accent-cinnabar"
|
||||
/>
|
||||
{onEdit ? (
|
||||
<TextInput
|
||||
|
||||
@@ -171,8 +171,8 @@ export function CharacterGenerator({
|
||||
|
||||
<Button
|
||||
onClick={() => void onGenerate()}
|
||||
loading={generating}
|
||||
disabled={
|
||||
generating ||
|
||||
brief.trim().length === 0 ||
|
||||
(mode === "mix"
|
||||
? mixTotal < MIN_CHARACTER_COUNT
|
||||
@@ -181,7 +181,7 @@ export function CharacterGenerator({
|
||||
variant="primary"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||
{generating ? "生成中…" : "生成"}
|
||||
生成
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -66,11 +66,12 @@ export function WorldGenerator({ projectId }: WorldGeneratorProps) {
|
||||
</Field>
|
||||
<Button
|
||||
onClick={() => void onGenerate()}
|
||||
disabled={generating || brief.trim().length === 0}
|
||||
loading={generating}
|
||||
disabled={brief.trim().length === 0}
|
||||
variant="primary"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||
{generating ? "生成中…" : "生成世界观"}
|
||||
生成世界观
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -217,7 +217,7 @@ export function OutlineEditor({
|
||||
) : (
|
||||
volumes.map((group) => (
|
||||
<section key={group.volume} className="mb-6">
|
||||
<h2 className="mb-2 font-serif text-base text-ink">
|
||||
<h2 className="mb-3 font-serif text-title-md text-ink">
|
||||
卷 {group.volume}
|
||||
</h2>
|
||||
<ul className="space-y-1">
|
||||
|
||||
164
apps/web/components/projects/ProjectCard.tsx
Normal file
164
apps/web/components/projects/ProjectCard.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import Link from "next/link";
|
||||
import { ChevronRight, Clock3 } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { StatusDot } from "@/components/ui/StatusDot";
|
||||
import type { ProjectResponse } from "@/lib/api/types";
|
||||
import {
|
||||
formatProjectUpdatedAt,
|
||||
pendingReviewCount,
|
||||
} from "@/lib/projects/projects";
|
||||
import { cardClass, cn, focusRing } from "@/lib/ui/variants";
|
||||
|
||||
interface ProjectCardProps {
|
||||
project: ProjectResponse;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
// 缺一句话故事时的中性占位——避免卡片正文塌陷成空白(P3-2)。
|
||||
const LOGLINE_PLACEHOLDER = "还没有一句话故事,进入写作台补上一句梗概。";
|
||||
|
||||
// 书脊/封面暖色板:全部取自现有语义 token(朱砂/琥珀),仅调透明度,无硬编码 hex。
|
||||
const COVER_TINTS = [
|
||||
"bg-[var(--color-cinnabar-wash)] text-cinnabar",
|
||||
"bg-cinnabar/10 text-cinnabar",
|
||||
"bg-cinnabar/[0.16] text-cinnabar",
|
||||
"bg-overdue/10 text-overdue",
|
||||
"bg-overdue/[0.18] text-overdue",
|
||||
"bg-surface-strong text-body-strong",
|
||||
] as const;
|
||||
|
||||
// 由 id/title 确定性取暖色底色块(djb2 变体哈希,同一本书永远同一色)。
|
||||
function coverTint(seed: string): string {
|
||||
let hash = 0;
|
||||
for (const ch of seed) hash = (hash * 31 + (ch.codePointAt(0) ?? 0)) >>> 0;
|
||||
return COVER_TINTS[hash % COVER_TINTS.length] ?? COVER_TINTS[0];
|
||||
}
|
||||
|
||||
// 取书名首个字符作封面字(Array.from 保证 CJK/emoji 不被截断)。
|
||||
function coverGlyph(title: string): string {
|
||||
return Array.from(title.trim())[0] ?? "书";
|
||||
}
|
||||
|
||||
interface CoverProps {
|
||||
project: ProjectResponse;
|
||||
size: "sm" | "md";
|
||||
}
|
||||
|
||||
// 书脊色块:填补此前的空白,给每本书一个稳定的视觉锚点。
|
||||
function Cover({ project, size }: CoverProps) {
|
||||
const tint = coverTint(project.id || project.title || "");
|
||||
const box = size === "md" ? "h-12 w-12 text-title-lg" : "h-10 w-10 text-title-md";
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center rounded-md font-serif",
|
||||
box,
|
||||
tint,
|
||||
)}
|
||||
>
|
||||
{coverGlyph(project.title || "")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// 作品卡(UX §6.1):书脊色块 + 书名衬线 + 一句话故事 + 元信息行。
|
||||
// M1 无字数/章数端点 → 不展示该统计(避免编造 API)。
|
||||
export function ProjectCard({ project, compact = false }: ProjectCardProps) {
|
||||
const pendingCount = pendingReviewCount(project);
|
||||
const updatedLabel = formatProjectUpdatedAt(project.updated_at);
|
||||
const title = project.title || "未命名作品";
|
||||
const logline = project.logline?.trim();
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<Link
|
||||
href={`/projects/${project.id}/write`}
|
||||
className={cardClass({
|
||||
tone: "card",
|
||||
interactive: true,
|
||||
className: cn("group flex items-center gap-4 p-4", focusRing),
|
||||
})}
|
||||
>
|
||||
<Cover project={project} size="sm" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-serif text-title-md text-ink">
|
||||
〈{title}〉
|
||||
</span>
|
||||
<span className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-caption">
|
||||
<GenreBadge genre={project.genre} />
|
||||
<PendingNote count={pendingCount} />
|
||||
<span className="min-w-0 flex-1 truncate text-muted-soft">
|
||||
{logline ?? LOGLINE_PLACEHOLDER}
|
||||
</span>
|
||||
</span>
|
||||
<span className="mt-1 flex items-center gap-1 font-mono text-2xs text-muted-soft">
|
||||
<Clock3 className="h-3 w-3" aria-hidden="true" />
|
||||
最近编辑 {updatedLabel}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRight
|
||||
className="h-4 w-4 shrink-0 text-muted-soft transition-colors duration-fast ease-standard group-hover:text-cinnabar"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/projects/${project.id}/write`}
|
||||
className={cardClass({
|
||||
tone: "card",
|
||||
interactive: true,
|
||||
className: cn("group flex h-full flex-col gap-4 p-5", focusRing),
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Cover project={project} size="md" />
|
||||
<h2 className="min-w-0 flex-1 truncate font-serif text-title-lg text-ink">
|
||||
〈{title}〉
|
||||
</h2>
|
||||
<ChevronRight
|
||||
className="h-4 w-4 shrink-0 text-muted-soft transition-colors duration-fast ease-standard group-hover:text-cinnabar"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
"line-clamp-2 min-h-[3rem] text-body leading-6",
|
||||
logline ? "text-body" : "text-muted-soft",
|
||||
)}
|
||||
>
|
||||
{logline ?? LOGLINE_PLACEHOLDER}
|
||||
</p>
|
||||
<div className="mt-auto flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<GenreBadge genre={project.genre} />
|
||||
<PendingNote count={pendingCount} />
|
||||
<span className="ml-auto flex items-center gap-1 font-mono text-caption text-muted-soft">
|
||||
<Clock3 className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{updatedLabel}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// 题材徽章:有值用中性徽章,缺失给稳定占位而非空白。
|
||||
function GenreBadge({ genre }: { genre?: string | null }) {
|
||||
const value = genre?.trim();
|
||||
if (value) return <Badge>{value}</Badge>;
|
||||
return <Badge className="border-dashed text-muted-soft">未定题材</Badge>;
|
||||
}
|
||||
|
||||
// 待审提示:状态点 + 文字(不单靠颜色传达状态)。
|
||||
function PendingNote({ count }: { count: number }) {
|
||||
if (count <= 0) return null;
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-caption text-ink-soft">
|
||||
<StatusDot tone="warning" label={`${count} 章待审`} />
|
||||
{count} 待审
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import Link from "next/link";
|
||||
import { Clock3, LayoutGrid, List, Plus, Search } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { ProjectCard } from "@/components/ProjectCard";
|
||||
import { ProjectCard } from "@/components/projects/ProjectCard";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
type ProjectSort,
|
||||
type ProjectViewMode,
|
||||
} from "@/lib/projects/projects";
|
||||
import { buttonClass, cardClass } from "@/lib/ui/variants";
|
||||
import { buttonClass, cardClass, cn, focusRing } from "@/lib/ui/variants";
|
||||
|
||||
interface ProjectLibraryProps {
|
||||
projects: ProjectResponse[];
|
||||
@@ -150,7 +150,7 @@ export function ProjectLibrary({ projects }: ProjectLibraryProps) {
|
||||
}
|
||||
/>
|
||||
) : viewMode === "cards" ? (
|
||||
<ul className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<ul className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{visibleProjects.map((p) => (
|
||||
<li key={p.id}>
|
||||
<ProjectCard project={p} />
|
||||
@@ -189,16 +189,21 @@ function NewProjectCard() {
|
||||
return (
|
||||
<Link
|
||||
href="/projects/new"
|
||||
className={buttonClass({
|
||||
variant: "outline",
|
||||
className: "group h-full min-h-[176px] flex-col border-dashed text-center",
|
||||
className={cardClass({
|
||||
tone: "soft",
|
||||
flat: true,
|
||||
interactive: true,
|
||||
className: cn(
|
||||
"group flex h-full min-h-[184px] flex-col items-center justify-center gap-3 border-dashed p-5 text-center",
|
||||
focusRing,
|
||||
),
|
||||
})}
|
||||
>
|
||||
<span className="mb-3 flex h-10 w-10 items-center justify-center rounded bg-[var(--color-cinnabar-wash)] text-cinnabar transition-colors group-hover:bg-cinnabar group-hover:text-panel">
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-md bg-[var(--color-cinnabar-wash)] text-cinnabar transition-colors duration-fast ease-standard group-hover:bg-cinnabar group-hover:text-panel">
|
||||
<Plus className="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="font-serif text-xl text-cinnabar">新建</span>
|
||||
<span className="mt-2 text-sm text-ink-soft">从一句灵感开始一本书</span>
|
||||
<span className="font-serif text-title-md text-cinnabar">新建作品</span>
|
||||
<span className="text-caption text-muted-soft">从一句灵感开始一本书</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle, CheckCircle2, LocateFixed, Sparkles } from "lucide-react";
|
||||
import { AlertTriangle, CheckCircle2, LocateFixed, Minus, Plus, Sparkles } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { TextInput } from "@/components/ui/TextInput";
|
||||
@@ -80,10 +80,22 @@ export function ConflictCard({
|
||||
{conflict.original && conflict.replacement ? (
|
||||
<div className="mt-2 rounded border border-pass/30 bg-bg/40 px-3 py-1.5 text-xs leading-relaxed">
|
||||
<span className="mr-1 text-ink-soft">改法:</span>
|
||||
{/* 非颜色线索:Minus/「原文」标签,色盲也可辨(不单靠红色 + 删除线) */}
|
||||
<Minus
|
||||
className="mr-0.5 inline-block h-3 w-3 align-[-1px] text-conflict"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="sr-only">原文:</span>
|
||||
<span className="text-conflict line-through">{conflict.original}</span>
|
||||
<span className="mx-1 text-ink-soft" aria-hidden="true">
|
||||
→
|
||||
</span>
|
||||
{/* 非颜色线索:Plus/「建议」标签,色盲也可辨(不单靠绿色) */}
|
||||
<Plus
|
||||
className="mr-0.5 inline-block h-3 w-3 align-[-1px] text-pass"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="sr-only">建议:</span>
|
||||
<span className="text-pass">{conflict.replacement}</span>
|
||||
<span className="ml-2 text-ink-soft">(点「采纳改法」改入终稿)</span>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,11 @@ import { AppShell } from "@/components/AppShell";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Select } from "@/components/ui/Select";
|
||||
import { reviewChapterHref, reviewChapterOptions } from "@/lib/review/chapterNav";
|
||||
import {
|
||||
reviewChapterHref,
|
||||
reviewChapterOptions,
|
||||
type ReviewChapterOption,
|
||||
} from "@/lib/review/chapterNav";
|
||||
import type { ChapterEntry } from "@/lib/workbench/chapter";
|
||||
import type { ProjectResponse, ReviewHistoryItem } from "@/lib/api/types";
|
||||
import { friendlyError } from "@/lib/errors/messages";
|
||||
@@ -66,13 +70,18 @@ import { ReviewSummaryRail } from "./ReviewSummaryRail";
|
||||
import { StylePanel } from "@/components/style/StylePanel";
|
||||
import { RefineView } from "@/components/style/RefineView";
|
||||
|
||||
// 命中区闪烁高亮持续时长(朱砂脉冲)。
|
||||
const FLASH_DURATION_MS = 900;
|
||||
|
||||
interface ReviewReportProps {
|
||||
project: ProjectResponse;
|
||||
chapterNo: number;
|
||||
initialReview: ReviewHistoryItem | undefined;
|
||||
initialDraft: string;
|
||||
// 章节导航目录(大纲章节):用于报告头部的「切换审稿章节」选择器。
|
||||
// 章节导航目录(大纲章节):无 chapterOptions 时回退用。
|
||||
chapters?: ChapterEntry[];
|
||||
// 选章选项(以真实章为准,含可审/已审标记);优先于 chapters 渲染选择器。
|
||||
chapterOptions?: ReviewChapterOption[];
|
||||
}
|
||||
|
||||
// 审稿报告页主体(UX §6.4 / §8.3 / §9)。
|
||||
@@ -84,6 +93,7 @@ export function ReviewReport({
|
||||
initialReview,
|
||||
initialDraft,
|
||||
chapters,
|
||||
chapterOptions,
|
||||
}: ReviewReportProps) {
|
||||
const router = useRouter();
|
||||
const review = useReviewStream();
|
||||
@@ -112,6 +122,16 @@ export function ReviewReport({
|
||||
null,
|
||||
);
|
||||
const seededRef = useRef(false);
|
||||
// flashMark 的收尾定时器句柄:组件卸载或重复触发时清除,避免定时器泄漏。
|
||||
const flashTimerRef = useRef<number | null>(null);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (flashTimerRef.current !== null) {
|
||||
window.clearTimeout(flashTimerRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 进页一次性把历史留痕(冲突 + 伏笔建议 + 节奏)种入流状态(无需重审即可裁决/查看)。
|
||||
const seededConflicts = useMemo(
|
||||
@@ -333,7 +353,13 @@ export function ReviewReport({
|
||||
const mark = document.getElementById(`draft-mark-${index}`);
|
||||
if (!mark) return;
|
||||
mark.classList.add("draft-mark-flash");
|
||||
window.setTimeout(() => mark.classList.remove("draft-mark-flash"), 900);
|
||||
if (flashTimerRef.current !== null) {
|
||||
window.clearTimeout(flashTimerRef.current);
|
||||
}
|
||||
flashTimerRef.current = window.setTimeout(() => {
|
||||
mark.classList.remove("draft-mark-flash");
|
||||
flashTimerRef.current = null;
|
||||
}, FLASH_DURATION_MS);
|
||||
};
|
||||
|
||||
// 跳转/锚点:在可编辑正文里定位、选中并滚动到该冲突对应区域 + 闪烁;定位不到则提示手动查找。
|
||||
@@ -441,7 +467,7 @@ export function ReviewReport({
|
||||
<Select
|
||||
controlSize="sm"
|
||||
aria-label="切换审稿章节"
|
||||
className="hidden max-w-[12rem] shrink-0 sm:block"
|
||||
className="max-w-[12rem] shrink-0"
|
||||
value={chapterNo}
|
||||
onChange={(e) =>
|
||||
router.push(
|
||||
@@ -449,8 +475,10 @@ export function ReviewReport({
|
||||
)
|
||||
}
|
||||
>
|
||||
{reviewChapterOptions(chapters ?? [], chapterNo).map((opt) => (
|
||||
<option key={opt.no} value={opt.no}>
|
||||
{(
|
||||
chapterOptions ?? reviewChapterOptions(chapters ?? [], chapterNo)
|
||||
).map((opt: ReviewChapterOption) => (
|
||||
<option key={opt.no} value={opt.no} disabled={opt.disabled}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
@@ -585,7 +613,7 @@ export function ReviewReport({
|
||||
: "已处理"}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className="h-4 w-4 text-ink-soft transition-transform group-open:rotate-180"
|
||||
className="h-4 w-4 text-ink-soft motion-safe:transition-transform group-open:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
|
||||
@@ -52,7 +52,7 @@ export function ReviewSectionPanel({
|
||||
<Badge variant={statusVariant}>{statusLabel}</Badge>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 text-ink-soft transition-transform",
|
||||
"h-4 w-4 text-ink-soft motion-safe:transition-transform",
|
||||
"group-open:rotate-180",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
|
||||
24
apps/web/components/settings/CapabilityBadges.tsx
Normal file
24
apps/web/components/settings/CapabilityBadges.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import type { CapabilitiesView } from "@/lib/api/types";
|
||||
|
||||
// 探活结果的能力徽章:结构化输出 / 前缀缓存 / 思考,各显示开关态。
|
||||
export function CapabilityBadges({ caps }: { caps: CapabilitiesView }) {
|
||||
const badges: { on: boolean; label: string }[] = [
|
||||
{ on: caps.structured_output, label: "结构化" },
|
||||
{ on: caps.prefix_cache, label: "前缀缓存" },
|
||||
{ on: caps.thinking, label: "思考" },
|
||||
];
|
||||
return (
|
||||
<div className="flex gap-1.5">
|
||||
{badges.map((b) => (
|
||||
<Badge
|
||||
key={b.label}
|
||||
variant={b.on ? "accent" : "neutral"}
|
||||
className="text-2xs"
|
||||
>
|
||||
{b.label}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
apps/web/components/settings/CredentialRow.tsx
Normal file
105
apps/web/components/settings/CredentialRow.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { CheckCircle2, PlugZap, Save, XCircle } from "lucide-react";
|
||||
|
||||
import { CapabilityBadges } from "@/components/settings/CapabilityBadges";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { StatusDot } from "@/components/ui/StatusDot";
|
||||
import { TextInput } from "@/components/ui/TextInput";
|
||||
import type { CapabilitiesView } from "@/lib/api/types";
|
||||
import type { KnownProvider } from "@/lib/settings/providers";
|
||||
|
||||
export interface TestResult {
|
||||
ok: boolean;
|
||||
capabilities: CapabilitiesView;
|
||||
}
|
||||
|
||||
interface CredentialRowProps {
|
||||
provider: KnownProvider;
|
||||
masked: string | null;
|
||||
draft: string;
|
||||
saving: boolean;
|
||||
testing: boolean;
|
||||
result: TestResult | undefined;
|
||||
onDraftChange: (value: string) => void;
|
||||
onSave: () => void;
|
||||
onTest: () => void;
|
||||
}
|
||||
|
||||
// 单个 API-key 提供商行:脱敏态 + Key 输入 + 保存/测试 + 探活结果。
|
||||
export function CredentialRow({
|
||||
provider,
|
||||
masked,
|
||||
draft,
|
||||
saving,
|
||||
testing,
|
||||
result,
|
||||
onDraftChange,
|
||||
onSave,
|
||||
onTest,
|
||||
}: CredentialRowProps) {
|
||||
return (
|
||||
<li className="px-4 py-4">
|
||||
<div className="grid gap-3 lg:grid-cols-[10rem_8rem_minmax(12rem,1fr)_auto_auto] lg:items-center">
|
||||
<span className="flex items-center gap-2 text-sm text-ink">
|
||||
<StatusDot
|
||||
tone={masked ? "success" : "neutral"}
|
||||
label={masked ? "已配置" : "未配置"}
|
||||
/>
|
||||
{provider.label}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-ink-soft">
|
||||
{masked ?? "未配置"}
|
||||
</span>
|
||||
<label className="sr-only" htmlFor={`key-${provider.id}`}>
|
||||
{provider.label} API Key
|
||||
</label>
|
||||
<TextInput
|
||||
id={`key-${provider.id}`}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={draft}
|
||||
onChange={(e) => onDraftChange(e.target.value)}
|
||||
placeholder={masked ? "输入新 Key 以更新" : "输入 API Key"}
|
||||
/>
|
||||
<Button
|
||||
onClick={onSave}
|
||||
disabled={saving || draft.trim().length === 0}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
<Save className="h-4 w-4" aria-hidden="true" />
|
||||
{saving ? "保存中…" : masked ? "更新" : "添加"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onTest}
|
||||
disabled={testing}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
<PlugZap className="h-4 w-4" aria-hidden="true" />
|
||||
{testing ? "测试中…" : "测试"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-5 text-ink-soft lg:pl-[10.5rem]">
|
||||
{masked
|
||||
? "已保存脱敏凭据;输入新 Key 可覆盖更新。"
|
||||
: "保存后再测试连接,成功后即可在档位路由中使用。"}
|
||||
</p>
|
||||
{result ? (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 lg:pl-[10.5rem]">
|
||||
<Badge variant={result.ok ? "success" : "danger"}>
|
||||
{result.ok ? (
|
||||
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
|
||||
) : (
|
||||
<XCircle className="h-3 w-3" aria-hidden="true" />
|
||||
)}
|
||||
{result.ok ? "已连接" : "未连接"}
|
||||
</Badge>
|
||||
<CapabilityBadges caps={result.capabilities} />
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
85
apps/web/components/settings/CredentialsPanel.tsx
Normal file
85
apps/web/components/settings/CredentialsPanel.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { PlugZap } from "lucide-react";
|
||||
|
||||
import {
|
||||
CredentialRow,
|
||||
type TestResult,
|
||||
} from "@/components/settings/CredentialRow";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { SectionHeader } from "@/components/ui/SectionHeader";
|
||||
import { API_KEY_PROVIDERS } from "@/lib/settings/providers";
|
||||
import type { ProviderView } from "@/lib/api/types";
|
||||
|
||||
interface CredentialsPanelProps {
|
||||
providers: ProviderView[];
|
||||
drafts: Record<string, string>;
|
||||
savingId: string | null;
|
||||
testingId: string | null;
|
||||
results: Record<string, TestResult>;
|
||||
onDraftChange: (providerId: string, value: string) => void;
|
||||
onSave: (providerId: string) => void;
|
||||
onTest: (providerId: string) => void;
|
||||
}
|
||||
|
||||
// 提供商凭据面板:API-key 提供商列表,逐行保存/测试(UX §6.10)。
|
||||
export function CredentialsPanel({
|
||||
providers,
|
||||
drafts,
|
||||
savingId,
|
||||
testingId,
|
||||
results,
|
||||
onDraftChange,
|
||||
onSave,
|
||||
onTest,
|
||||
}: CredentialsPanelProps) {
|
||||
const maskedFor = (id: string): string | null =>
|
||||
providers.find((p) => p.provider === id)?.masked_key ?? null;
|
||||
|
||||
return (
|
||||
<section className="min-w-0">
|
||||
<SectionHeader
|
||||
eyebrow="凭据"
|
||||
title="提供商凭据"
|
||||
description="API Key 只用于后端探活和调用,列表中只显示脱敏后的已保存凭据。"
|
||||
/>
|
||||
<div className="mt-3 grid gap-2 text-xs text-ink-soft sm:grid-cols-3">
|
||||
<StatItem label="已保存" value={providers.length} />
|
||||
<StatItem label="可配置" value={API_KEY_PROVIDERS.length} />
|
||||
<StatItem label="测试结果" value={Object.keys(results).length} />
|
||||
</div>
|
||||
{providers.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={PlugZap}
|
||||
title="还没有可用提供商"
|
||||
description="至少连接一个提供商即可开始写作。求质量可选 Anthropic,求性价比可选 DeepSeek。"
|
||||
className="mt-4"
|
||||
/>
|
||||
) : null}
|
||||
<ul className="mt-4 divide-y divide-line rounded border border-line bg-panel">
|
||||
{API_KEY_PROVIDERS.map((prov) => (
|
||||
<CredentialRow
|
||||
key={prov.id}
|
||||
provider={prov}
|
||||
masked={maskedFor(prov.id)}
|
||||
draft={drafts[prov.id] ?? ""}
|
||||
saving={savingId === prov.id}
|
||||
testing={testingId === prov.id}
|
||||
result={results[prov.id]}
|
||||
onDraftChange={(value) => onDraftChange(prov.id, value)}
|
||||
onSave={() => onSave(prov.id)}
|
||||
onTest={() => onTest(prov.id)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StatItem({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="rounded border border-line bg-panel px-3 py-2">
|
||||
{label} <span className="font-mono text-ink">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CheckCircle2,
|
||||
KeyRound,
|
||||
PlugZap,
|
||||
Route,
|
||||
Save,
|
||||
XCircle,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useToast } from "@/components/Toast";
|
||||
import { CredentialsPanel } from "@/components/settings/CredentialsPanel";
|
||||
import type { TestResult } from "@/components/settings/CredentialRow";
|
||||
import { KimiCodeOauth } from "@/components/settings/KimiCodeOauth";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { SectionHeader } from "@/components/ui/SectionHeader";
|
||||
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
||||
import { Select } from "@/components/ui/Select";
|
||||
import { StatusNote } from "@/components/ui/StatusNote";
|
||||
import { TextInput } from "@/components/ui/TextInput";
|
||||
import { api } from "@/lib/api/client";
|
||||
import { cardClass } from "@/lib/ui/variants";
|
||||
import { RoutingPanel } from "@/components/settings/RoutingPanel";
|
||||
import {
|
||||
SECTION_OPTIONS,
|
||||
SettingsNav,
|
||||
type SettingsSection,
|
||||
} from "@/components/settings/SettingsNav";
|
||||
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
||||
import { api } from "@/lib/api/client";
|
||||
import {
|
||||
API_KEY_PROVIDERS,
|
||||
KNOWN_PROVIDERS,
|
||||
TIER_LABELS,
|
||||
applyProviderChange,
|
||||
draftsToRoutingInput,
|
||||
toRoutingDrafts,
|
||||
type RoutingDraft,
|
||||
} from "@/lib/settings/providers";
|
||||
import type {
|
||||
CapabilitiesView,
|
||||
ProvidersResponse,
|
||||
} from "@/lib/api/types";
|
||||
import type { ProvidersResponse } from "@/lib/api/types";
|
||||
|
||||
interface ProvidersSettingsProps {
|
||||
initial: ProvidersResponse;
|
||||
@@ -43,42 +28,6 @@ interface ProvidersSettingsProps {
|
||||
kimiOauth: { connected: boolean; expiresAt: string | null };
|
||||
}
|
||||
|
||||
interface TestResult {
|
||||
ok: boolean;
|
||||
capabilities: CapabilitiesView;
|
||||
}
|
||||
|
||||
type SettingsSection = "routing" | "oauth" | "keys";
|
||||
|
||||
// 分组的单一标签源:桌面 aside 与移动 SegmentedControl 共用,消除「档位路由 / 路由」漂移。
|
||||
const SETTINGS_SECTIONS: Array<{
|
||||
value: SettingsSection;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
}> = [
|
||||
{ value: "routing", label: "档位路由", icon: Route },
|
||||
{ value: "oauth", label: "OAuth", icon: PlugZap },
|
||||
{ value: "keys", label: "API Key", icon: KeyRound },
|
||||
];
|
||||
|
||||
const SECTION_OPTIONS = SETTINGS_SECTIONS.map(({ value, label }) => ({
|
||||
value,
|
||||
label,
|
||||
}));
|
||||
|
||||
// 档位路由「模型」输入框的候选 model id(datalist 建议,仍可自由输入)。按 provider 分组。
|
||||
const MODEL_SUGGESTIONS: Record<string, readonly string[]> = {
|
||||
anthropic: ["claude-opus-4", "claude-sonnet-4", "claude-3-5-haiku"],
|
||||
deepseek: ["deepseek-chat", "deepseek-reasoner"],
|
||||
kimi: ["moonshot-v1-128k", "moonshot-v1-32k", "kimi-k2"],
|
||||
openai: ["gpt-4o", "gpt-4o-mini", "o3-mini"],
|
||||
qwen: ["qwen-max", "qwen-plus", "qwen-turbo"],
|
||||
glm: ["glm-4-plus", "glm-4-air", "glm-4-flash"],
|
||||
gemini: ["gemini-2.0-flash", "gemini-1.5-pro"],
|
||||
"kimi-code-key": ["kimi-for-coding"],
|
||||
"kimi-code": ["kimi-for-coding"],
|
||||
};
|
||||
|
||||
// 设置页主体(UX §6.10):档位路由(可编辑)+ API-key 凭据行 + Kimi Code OAuth 连接区。
|
||||
export function ProvidersSettings({
|
||||
initial,
|
||||
@@ -97,9 +46,6 @@ export function ProvidersSettings({
|
||||
const [testingId, setTestingId] = useState<string | null>(null);
|
||||
const [results, setResults] = useState<Record<string, TestResult>>({});
|
||||
|
||||
const maskedFor = (id: string): string | null =>
|
||||
providers.find((p) => p.provider === id)?.masked_key ?? null;
|
||||
|
||||
const sectionDetail = (value: SettingsSection): string => {
|
||||
if (value === "routing") {
|
||||
return `${routing.filter((r) => r.provider && r.model).length}/3 已配置`;
|
||||
@@ -194,22 +140,17 @@ export function ProvidersSettings({
|
||||
}
|
||||
};
|
||||
|
||||
const updateDraft = (providerId: string, value: string): void => {
|
||||
setDrafts((prev) => ({ ...prev, [providerId]: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-[12rem_1fr]">
|
||||
<aside className="hidden lg:block">
|
||||
<nav aria-label="设置分组" className={cardClass("sticky top-20 p-2")}>
|
||||
{SETTINGS_SECTIONS.map((section) => (
|
||||
<SettingsNavButton
|
||||
key={section.value}
|
||||
active={activeSection === section.value}
|
||||
icon={<section.icon className="h-4 w-4" aria-hidden="true" />}
|
||||
label={section.label}
|
||||
detail={sectionDetail(section.value)}
|
||||
onClick={() => setActiveSection(section.value)}
|
||||
<SettingsNav
|
||||
activeSection={activeSection}
|
||||
onSelect={setActiveSection}
|
||||
detailFor={sectionDetail}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="min-w-0">
|
||||
<SegmentedControl
|
||||
@@ -221,269 +162,37 @@ export function ProvidersSettings({
|
||||
/>
|
||||
|
||||
{activeSection === "routing" ? (
|
||||
<SettingsPanel>
|
||||
<SectionHeader
|
||||
title="能力档位路由"
|
||||
description="写手、分析、轻量三类能力可分别指向不同模型。保存时只提交完整填写的行。"
|
||||
<RoutingPanel
|
||||
routing={routing}
|
||||
saving={savingRouting}
|
||||
onProviderChange={updateRouting}
|
||||
onModelChange={updateRoutingModel}
|
||||
onSave={() => void saveRouting()}
|
||||
/>
|
||||
<StatusNote className="mt-3" variant="info">
|
||||
写章、审稿和摘要提炼会分别读取对应档位;未完整填写的行不会覆盖现有路由。
|
||||
</StatusNote>
|
||||
<ul className="mt-4 divide-y divide-line rounded border border-line bg-panel">
|
||||
{routing.map((row) => (
|
||||
<li
|
||||
key={row.tier}
|
||||
className="grid gap-3 px-4 py-3 text-sm md:grid-cols-[6rem_minmax(10rem,14rem)_1fr]"
|
||||
>
|
||||
<span className="self-center text-ink">
|
||||
{TIER_LABELS[row.tier] ?? row.tier}
|
||||
</span>
|
||||
<label
|
||||
className="sr-only"
|
||||
htmlFor={`route-provider-${row.tier}`}
|
||||
>
|
||||
{TIER_LABELS[row.tier] ?? row.tier} 提供商
|
||||
</label>
|
||||
<Select
|
||||
id={`route-provider-${row.tier}`}
|
||||
value={row.provider}
|
||||
onChange={(e) => updateRouting(row.tier, e.target.value)}
|
||||
>
|
||||
<option value="">(未配置)</option>
|
||||
{KNOWN_PROVIDERS.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<label className="sr-only" htmlFor={`route-model-${row.tier}`}>
|
||||
{TIER_LABELS[row.tier] ?? row.tier} 模型
|
||||
</label>
|
||||
<div className="min-w-0">
|
||||
<TextInput
|
||||
id={`route-model-${row.tier}`}
|
||||
value={row.model}
|
||||
onChange={(e) =>
|
||||
updateRoutingModel(row.tier, e.target.value)
|
||||
}
|
||||
placeholder="model"
|
||||
className="w-full font-mono"
|
||||
list={`route-models-${row.tier}`}
|
||||
/>
|
||||
<datalist id={`route-models-${row.tier}`}>
|
||||
{(MODEL_SUGGESTIONS[row.provider] ?? []).map((m) => (
|
||||
<option key={m} value={m} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-3 flex justify-end">
|
||||
<Button
|
||||
onClick={() => void saveRouting()}
|
||||
disabled={savingRouting}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
<Save className="h-4 w-4" aria-hidden="true" />
|
||||
{savingRouting ? "保存中…" : "保存档位路由"}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsPanel>
|
||||
) : null}
|
||||
|
||||
{activeSection === "oauth" ? (
|
||||
<SettingsPanel>
|
||||
<section className="min-w-0">
|
||||
<KimiCodeOauth
|
||||
initialConnected={kimiOauth.connected}
|
||||
initialExpiresAt={kimiOauth.expiresAt}
|
||||
/>
|
||||
</SettingsPanel>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{activeSection === "keys" ? (
|
||||
<SettingsPanel>
|
||||
<SectionHeader
|
||||
title="提供商凭据"
|
||||
description="API Key 只用于后端探活和调用,列表中只显示脱敏后的已保存凭据。"
|
||||
/>
|
||||
<div className="mt-3 grid gap-2 text-xs text-ink-soft sm:grid-cols-3">
|
||||
<div className="rounded border border-line bg-panel px-3 py-2">
|
||||
已保存 <span className="font-mono text-ink">{providers.length}</span>
|
||||
</div>
|
||||
<div className="rounded border border-line bg-panel px-3 py-2">
|
||||
可配置{" "}
|
||||
<span className="font-mono text-ink">
|
||||
{API_KEY_PROVIDERS.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded border border-line bg-panel px-3 py-2">
|
||||
测试结果{" "}
|
||||
<span className="font-mono text-ink">
|
||||
{Object.keys(results).length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{providers.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={PlugZap}
|
||||
title="还没有可用提供商"
|
||||
description="至少连接一个提供商即可开始写作。求质量可选 Anthropic,求性价比可选 DeepSeek。"
|
||||
className="mt-4"
|
||||
<CredentialsPanel
|
||||
providers={providers}
|
||||
drafts={drafts}
|
||||
savingId={savingId}
|
||||
testingId={testingId}
|
||||
results={results}
|
||||
onDraftChange={updateDraft}
|
||||
onSave={(id) => void saveCredential(id)}
|
||||
onTest={(id) => void testConnection(id)}
|
||||
/>
|
||||
) : null}
|
||||
<ul className="mt-4 divide-y divide-line rounded border border-line bg-panel">
|
||||
{API_KEY_PROVIDERS.map((prov) => {
|
||||
const masked = maskedFor(prov.id);
|
||||
const result = results[prov.id];
|
||||
return (
|
||||
<li key={prov.id} className="px-4 py-4">
|
||||
<div className="grid gap-3 lg:grid-cols-[10rem_8rem_minmax(12rem,1fr)_auto_auto] lg:items-center">
|
||||
<span className="flex items-center gap-2 text-sm text-ink">
|
||||
<span
|
||||
className={`h-2 w-2 rounded ${
|
||||
masked ? "bg-pass" : "bg-line"
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{prov.label}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-ink-soft">
|
||||
{masked ?? "未配置"}
|
||||
</span>
|
||||
<label className="sr-only" htmlFor={`key-${prov.id}`}>
|
||||
{prov.label} API Key
|
||||
</label>
|
||||
<TextInput
|
||||
id={`key-${prov.id}`}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={drafts[prov.id] ?? ""}
|
||||
onChange={(e) =>
|
||||
setDrafts((prev) => ({
|
||||
...prev,
|
||||
[prov.id]: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={
|
||||
masked ? "输入新 Key 以更新" : "输入 API Key"
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => saveCredential(prov.id)}
|
||||
disabled={
|
||||
savingId === prov.id ||
|
||||
(drafts[prov.id] ?? "").trim().length === 0
|
||||
}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
<Save className="h-4 w-4" aria-hidden="true" />
|
||||
{savingId === prov.id
|
||||
? "保存中…"
|
||||
: masked
|
||||
? "更新"
|
||||
: "添加"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => testConnection(prov.id)}
|
||||
disabled={testingId === prov.id}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
<PlugZap className="h-4 w-4" aria-hidden="true" />
|
||||
{testingId === prov.id ? "测试中…" : "测试"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-5 text-ink-soft lg:pl-[10.5rem]">
|
||||
{masked
|
||||
? "已保存脱敏凭据;输入新 Key 可覆盖更新。"
|
||||
: "保存后再测试连接,成功后即可在档位路由中使用。"}
|
||||
</p>
|
||||
{result ? (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 lg:pl-[10.5rem]">
|
||||
<Badge variant={result.ok ? "success" : "danger"}>
|
||||
{result.ok ? (
|
||||
<CheckCircle2
|
||||
className="h-3 w-3"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<XCircle className="h-3 w-3" aria-hidden="true" />
|
||||
)}
|
||||
{result.ok ? "已连接" : "未连接"}
|
||||
</Badge>
|
||||
<CapabilityBadges caps={result.capabilities} />
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</SettingsPanel>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsPanel({ children }: { children: ReactNode }) {
|
||||
return <section className="min-w-0">{children}</section>;
|
||||
}
|
||||
|
||||
interface SettingsNavButtonProps {
|
||||
active: boolean;
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
detail: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function SettingsNavButton({
|
||||
active,
|
||||
icon,
|
||||
label,
|
||||
detail,
|
||||
onClick,
|
||||
}: SettingsNavButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={onClick}
|
||||
className={`mb-1 flex w-full items-start gap-2 rounded px-3 py-2 text-left transition-colors ${
|
||||
active
|
||||
? "bg-[var(--color-cinnabar-wash)] text-cinnabar"
|
||||
: "text-ink hover:bg-bg hover:text-cinnabar"
|
||||
}`}
|
||||
>
|
||||
<span className="mt-0.5 shrink-0">{icon}</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium">{label}</span>
|
||||
<span className="block truncate text-xs text-ink-soft">{detail}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CapabilityBadges({ caps }: { caps: CapabilitiesView }) {
|
||||
const badges: { on: boolean; label: string }[] = [
|
||||
{ on: caps.structured_output, label: "结构化" },
|
||||
{ on: caps.prefix_cache, label: "前缀缓存" },
|
||||
{ on: caps.thinking, label: "思考" },
|
||||
];
|
||||
return (
|
||||
<div className="flex gap-1.5">
|
||||
{badges.map((b) => (
|
||||
<Badge
|
||||
key={b.label}
|
||||
variant={b.on ? "accent" : "neutral"}
|
||||
className="text-2xs"
|
||||
>
|
||||
{b.label}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
126
apps/web/components/settings/RoutingPanel.tsx
Normal file
126
apps/web/components/settings/RoutingPanel.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { Save } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { SectionHeader } from "@/components/ui/SectionHeader";
|
||||
import { Select } from "@/components/ui/Select";
|
||||
import { StatusNote } from "@/components/ui/StatusNote";
|
||||
import { TextInput } from "@/components/ui/TextInput";
|
||||
import {
|
||||
KNOWN_PROVIDERS,
|
||||
TIER_LABELS,
|
||||
type RoutingDraft,
|
||||
} from "@/lib/settings/providers";
|
||||
|
||||
// 档位路由「模型」输入框的候选 model id(datalist 建议,仍可自由输入)。按 provider 分组。
|
||||
const MODEL_SUGGESTIONS: Record<string, readonly string[]> = {
|
||||
anthropic: ["claude-opus-4", "claude-sonnet-4", "claude-3-5-haiku"],
|
||||
deepseek: ["deepseek-chat", "deepseek-reasoner"],
|
||||
kimi: ["moonshot-v1-128k", "moonshot-v1-32k", "kimi-k2"],
|
||||
openai: ["gpt-4o", "gpt-4o-mini", "o3-mini"],
|
||||
qwen: ["qwen-max", "qwen-plus", "qwen-turbo"],
|
||||
glm: ["glm-4-plus", "glm-4-air", "glm-4-flash"],
|
||||
gemini: ["gemini-2.0-flash", "gemini-1.5-pro"],
|
||||
"kimi-code-key": ["kimi-for-coding"],
|
||||
"kimi-code": ["kimi-for-coding"],
|
||||
};
|
||||
|
||||
interface RoutingPanelProps {
|
||||
routing: RoutingDraft[];
|
||||
saving: boolean;
|
||||
onProviderChange: (tier: string, providerId: string) => void;
|
||||
onModelChange: (tier: string, model: string) => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
// 能力档位路由面板:写手/分析/轻量三档各指向一个 provider+model(UX §6.10)。
|
||||
export function RoutingPanel({
|
||||
routing,
|
||||
saving,
|
||||
onProviderChange,
|
||||
onModelChange,
|
||||
onSave,
|
||||
}: RoutingPanelProps) {
|
||||
return (
|
||||
<section className="min-w-0">
|
||||
<SectionHeader
|
||||
eyebrow="路由"
|
||||
title="能力档位路由"
|
||||
description="写手、分析、轻量三类能力可分别指向不同模型。保存时只提交完整填写的行。"
|
||||
/>
|
||||
<StatusNote className="mt-3" variant="info">
|
||||
写章、审稿和摘要提炼会分别读取对应档位;未完整填写的行不会覆盖现有路由。
|
||||
</StatusNote>
|
||||
<ul className="mt-4 divide-y divide-line rounded border border-line bg-panel">
|
||||
{routing.map((row) => (
|
||||
<RoutingRow
|
||||
key={row.tier}
|
||||
row={row}
|
||||
onProviderChange={onProviderChange}
|
||||
onModelChange={onModelChange}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-3 flex justify-end">
|
||||
<Button
|
||||
onClick={onSave}
|
||||
disabled={saving}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
<Save className="h-4 w-4" aria-hidden="true" />
|
||||
{saving ? "保存中…" : "保存档位路由"}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface RoutingRowProps {
|
||||
row: RoutingDraft;
|
||||
onProviderChange: (tier: string, providerId: string) => void;
|
||||
onModelChange: (tier: string, model: string) => void;
|
||||
}
|
||||
|
||||
function RoutingRow({ row, onProviderChange, onModelChange }: RoutingRowProps) {
|
||||
const tierLabel = TIER_LABELS[row.tier] ?? row.tier;
|
||||
return (
|
||||
<li className="grid gap-3 px-4 py-3 text-sm md:grid-cols-[6rem_minmax(10rem,14rem)_1fr]">
|
||||
<span className="self-center text-ink">{tierLabel}</span>
|
||||
<label className="sr-only" htmlFor={`route-provider-${row.tier}`}>
|
||||
{tierLabel} 提供商
|
||||
</label>
|
||||
<Select
|
||||
id={`route-provider-${row.tier}`}
|
||||
value={row.provider}
|
||||
onChange={(e) => onProviderChange(row.tier, e.target.value)}
|
||||
>
|
||||
<option value="">(未配置)</option>
|
||||
{KNOWN_PROVIDERS.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<label className="sr-only" htmlFor={`route-model-${row.tier}`}>
|
||||
{tierLabel} 模型
|
||||
</label>
|
||||
<div className="min-w-0">
|
||||
<TextInput
|
||||
id={`route-model-${row.tier}`}
|
||||
value={row.model}
|
||||
onChange={(e) => onModelChange(row.tier, e.target.value)}
|
||||
placeholder="model"
|
||||
className="w-full font-mono"
|
||||
list={`route-models-${row.tier}`}
|
||||
/>
|
||||
<datalist id={`route-models-${row.tier}`}>
|
||||
{(MODEL_SUGGESTIONS[row.provider] ?? []).map((m) => (
|
||||
<option key={m} value={m} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
89
apps/web/components/settings/SettingsNav.tsx
Normal file
89
apps/web/components/settings/SettingsNav.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { KeyRound, PlugZap, Route, type LucideIcon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { cardClass } from "@/lib/ui/variants";
|
||||
|
||||
export type SettingsSection = "routing" | "oauth" | "keys";
|
||||
|
||||
// 分组的单一标签源:桌面 aside 与移动 SegmentedControl 共用,消除「档位路由 / 路由」漂移。
|
||||
export const SETTINGS_SECTIONS: Array<{
|
||||
value: SettingsSection;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
}> = [
|
||||
{ value: "routing", label: "档位路由", icon: Route },
|
||||
{ value: "oauth", label: "OAuth", icon: PlugZap },
|
||||
{ value: "keys", label: "API Key", icon: KeyRound },
|
||||
];
|
||||
|
||||
export const SECTION_OPTIONS = SETTINGS_SECTIONS.map(({ value, label }) => ({
|
||||
value,
|
||||
label,
|
||||
}));
|
||||
|
||||
interface SettingsNavProps {
|
||||
activeSection: SettingsSection;
|
||||
onSelect: (value: SettingsSection) => void;
|
||||
detailFor: (value: SettingsSection) => string;
|
||||
}
|
||||
|
||||
// 桌面侧栏:设置分组导航(移动端由 SegmentedControl 承担)。
|
||||
export function SettingsNav({
|
||||
activeSection,
|
||||
onSelect,
|
||||
detailFor,
|
||||
}: SettingsNavProps) {
|
||||
return (
|
||||
<aside className="hidden lg:block">
|
||||
<nav aria-label="设置分组" className={cardClass("sticky top-20 p-2")}>
|
||||
{SETTINGS_SECTIONS.map((section) => (
|
||||
<SettingsNavButton
|
||||
key={section.value}
|
||||
active={activeSection === section.value}
|
||||
icon={<section.icon className="h-4 w-4" aria-hidden="true" />}
|
||||
label={section.label}
|
||||
detail={detailFor(section.value)}
|
||||
onClick={() => onSelect(section.value)}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
interface SettingsNavButtonProps {
|
||||
active: boolean;
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
detail: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function SettingsNavButton({
|
||||
active,
|
||||
icon,
|
||||
label,
|
||||
detail,
|
||||
onClick,
|
||||
}: SettingsNavButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={onClick}
|
||||
className={`mb-1 flex w-full items-start gap-2 rounded px-3 py-2 text-left transition-colors ${
|
||||
active
|
||||
? "bg-[var(--color-cinnabar-wash)] text-cinnabar"
|
||||
: "text-ink hover:bg-bg hover:text-cinnabar"
|
||||
}`}
|
||||
>
|
||||
<span className="mt-0.5 shrink-0">{icon}</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium">{label}</span>
|
||||
<span className="block truncate text-xs text-ink-soft">{detail}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
58
apps/web/components/start/DraftComposer.tsx
Normal file
58
apps/web/components/start/DraftComposer.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { TextArea } from "@/components/ui/TextArea";
|
||||
import { useDeferredDraft } from "@/lib/start/useDeferredDraft";
|
||||
|
||||
// 敲入正文后停顿多久才懒建项目(落库)——在停顿点落库,快照即所见,避免打断连续输入。
|
||||
const CREATE_DEBOUNCE_MS = 700;
|
||||
|
||||
// 进来即写:空白编辑器直达。敲入实质正文并停顿后懒建占位项目 + 落首章草稿,随即换入完整工作台。
|
||||
// 空进空出不落库(从不敲字 → 从不 POST)。落库逻辑在 useDeferredDraft(已单测),此处只管交互。
|
||||
export function DraftComposer() {
|
||||
const router = useRouter();
|
||||
const { ensureCreated } = useDeferredDraft();
|
||||
const [text, setText] = useState("");
|
||||
const textRef = useRef("");
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const redirectingRef = useRef(false);
|
||||
|
||||
const tryCreate = async (): Promise<void> => {
|
||||
if (redirectingRef.current) return;
|
||||
const snapshot = textRef.current;
|
||||
if (snapshot.trim().length === 0) return;
|
||||
const id = await ensureCreated(snapshot);
|
||||
if (id && !redirectingRef.current) {
|
||||
redirectingRef.current = true;
|
||||
router.replace(`/projects/${id}/write`);
|
||||
}
|
||||
};
|
||||
|
||||
const onChange = (value: string): void => {
|
||||
setText(value);
|
||||
textRef.current = value;
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => void tryCreate(), CREATE_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="mx-auto flex min-h-[calc(100vh-var(--chrome,4rem))] max-w-3xl flex-col px-6 py-10 sm:px-8">
|
||||
<p className="mb-4 text-sm text-ink-soft">
|
||||
直接开始写——落笔即为你建立作品,正文自动保存。书名、设定、大纲都可在写作中随时补齐。
|
||||
</p>
|
||||
<TextArea
|
||||
value={text}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="min-h-[60vh] flex-1 resize-none text-lg leading-8"
|
||||
placeholder="从这里开始写你的第一章……"
|
||||
autoFocus
|
||||
aria-label="正文"
|
||||
/>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -28,15 +28,17 @@ export function RefineView({
|
||||
onClose,
|
||||
}: RefineViewProps) {
|
||||
const refiner = useRefine();
|
||||
const { refine, abort } = refiner;
|
||||
|
||||
// 进入即触发回炉(段非空)。
|
||||
// 进入即触发回炉(段非空);清理时取消在途请求,避免旧段迟到响应覆盖新段(CR-H12)。
|
||||
useEffect(() => {
|
||||
if (segment.trim().length > 0) {
|
||||
void refiner.refine(projectId, chapterNo, segment);
|
||||
void refine(projectId, chapterNo, segment);
|
||||
}
|
||||
// 仅在段变化时触发。
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [segment, projectId, chapterNo]);
|
||||
return () => {
|
||||
abort();
|
||||
};
|
||||
}, [segment, projectId, chapterNo, refine, abort]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -182,7 +182,7 @@ export function TemplatesManager({ initial, tools }: TemplatesManagerProps) {
|
||||
</form>
|
||||
|
||||
<section className="flex flex-col gap-3" aria-label="模板列表">
|
||||
<h2 className="font-serif text-lg text-ink">已存模板({templates.length})</h2>
|
||||
<h2 className="font-serif text-title-md text-ink">已存模板({templates.length})</h2>
|
||||
{templates.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
@@ -194,7 +194,7 @@ export function TemplatesManager({ initial, tools }: TemplatesManagerProps) {
|
||||
{templates.map((t) => (
|
||||
<li
|
||||
key={t.id}
|
||||
className="flex items-start justify-between gap-4 rounded border border-line bg-bg p-3 text-sm"
|
||||
className="flex items-start justify-between gap-4 rounded-lg border border-line bg-bg p-3 text-sm"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-serif text-base text-ink">{t.title}</p>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { ArrowLeft, Database, Sparkles } from "lucide-react";
|
||||
|
||||
import { useToast } from "@/components/Toast";
|
||||
import { ConflictAdjudication } from "@/components/generation/ConflictAdjudication";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Checkbox } from "@/components/ui/Checkbox";
|
||||
import { Field } from "@/components/ui/Field";
|
||||
import { StatusNote } from "@/components/ui/StatusNote";
|
||||
import { TextArea } from "@/components/ui/TextArea";
|
||||
@@ -23,13 +24,25 @@ import {
|
||||
type PreviewItem,
|
||||
} from "@/lib/toolbox/toolbox";
|
||||
import { ingestTable, isSingleObjectIngest } from "@/lib/toolbox/ingest";
|
||||
import { fieldSummary, renderPreviewText } from "@/lib/toolbox/aiSummary";
|
||||
import { buildManuscriptExcerpt } from "@/lib/workbench/manuscriptExcerpt";
|
||||
import { useGenerator } from "@/lib/toolbox/useGenerator";
|
||||
import type { AiTurnInput } from "@/lib/workbench/useAiConversation";
|
||||
import { TemplateFiller } from "./TemplateFiller";
|
||||
|
||||
interface GeneratorRunnerProps {
|
||||
projectId: string;
|
||||
tool: ToolDescriptorView;
|
||||
onClose: () => void;
|
||||
// 可选:把某条预览文本插入正文(编辑器内联工具箱场景)。工具箱页不传 → 不显示「插入正文」。
|
||||
onInsertText?: (text: string) => void;
|
||||
// 可选:作者【已写正文】。传入且当前工具有 brief 字段时,表单里出现「用当前正文」按钮,
|
||||
// 点击把正文摘录填进 brief,让内容感知型生成器(如书名)据正文反推。工具箱页不传 → 不显示。
|
||||
manuscriptText?: string;
|
||||
// 可选留痕:生成成功后 append 一批 [author(字段摘要), ai(预览文本)]。
|
||||
// 内联工具箱传 conversation.appendMessages + 当前章号;工具箱页传 silent 直投 + chapterNo=null。
|
||||
appendMessages?: (turn: AiTurnInput) => void;
|
||||
logChapterNo?: number | null;
|
||||
}
|
||||
|
||||
// 声明驱动的通用生成器(核心):按 descriptor.input_fields 渲染表单 → 调通用 generate →
|
||||
@@ -39,6 +52,10 @@ export function GeneratorRunner({
|
||||
projectId,
|
||||
tool,
|
||||
onClose,
|
||||
onInsertText,
|
||||
manuscriptText,
|
||||
appendMessages,
|
||||
logChapterNo,
|
||||
}: GeneratorRunnerProps) {
|
||||
const gen = useGenerator();
|
||||
const toast = useToast();
|
||||
@@ -47,6 +64,8 @@ export function GeneratorRunner({
|
||||
);
|
||||
// 入库勾选:预览行下标集合(与 gen.rawPreview 行对齐)。
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
// 本 runner 一次会话 = 一个 thread;多次生成归此组。
|
||||
const threadId = useRef(crypto.randomUUID()).current;
|
||||
|
||||
const setField = useCallback((name: string, value: string): void => {
|
||||
setValues((prev) => ({ ...prev, [name]: value }));
|
||||
@@ -64,6 +83,26 @@ export function GeneratorRunner({
|
||||
[tool.input_fields, fillTarget],
|
||||
);
|
||||
|
||||
// 当前工具是否有名为 brief 的输入字段——只有它才承接「据正文反推」。
|
||||
const hasBriefField = useMemo(
|
||||
() => (tool.input_fields ?? []).some((f) => f.name === "brief"),
|
||||
[tool.input_fields],
|
||||
);
|
||||
// 有 brief 字段且拿到非空正文时,才提供「用当前正文」。
|
||||
const canUseManuscript =
|
||||
hasBriefField && (manuscriptText?.trim().length ?? 0) > 0;
|
||||
|
||||
const onUseManuscript = useCallback((): void => {
|
||||
if (!manuscriptText) return;
|
||||
const excerpt = buildManuscriptExcerpt(manuscriptText);
|
||||
if (excerpt.length === 0) {
|
||||
toast("当前还没有正文可供反推。", "error");
|
||||
return;
|
||||
}
|
||||
setField("brief", excerpt);
|
||||
toast("已按当前正文填入,可继续编辑后生成。", "success");
|
||||
}, [manuscriptText, setField, toast]);
|
||||
|
||||
const onGenerate = useCallback(async (): Promise<void> => {
|
||||
const missing = missingRequiredFields(tool.input_fields, values);
|
||||
if (missing.length > 0) {
|
||||
@@ -71,8 +110,36 @@ export function GeneratorRunner({
|
||||
return;
|
||||
}
|
||||
setSelected(new Set());
|
||||
await gen.generate(projectId, tool.key, buildGenerateRequest(values));
|
||||
}, [gen, projectId, tool, values, toast]);
|
||||
const artifact = await gen.generate(
|
||||
projectId,
|
||||
tool.key,
|
||||
buildGenerateRequest(values),
|
||||
);
|
||||
// 留痕(成功且有可读预览时):author(字段摘要) + ai(渲染预览);空产物/错误路径不记。
|
||||
if (artifact && appendMessages) {
|
||||
const aiText = renderPreviewText(artifact.preview);
|
||||
if (aiText.length > 0) {
|
||||
appendMessages({
|
||||
threadId,
|
||||
kind: "generator",
|
||||
toolKey: tool.key,
|
||||
chapterNo: logChapterNo ?? null,
|
||||
messages: [
|
||||
{
|
||||
role: "author",
|
||||
content: fieldSummary(tool.input_fields ?? [], values) || tool.title,
|
||||
meta: { tool_key: tool.key, input_fields: values },
|
||||
},
|
||||
{
|
||||
role: "ai",
|
||||
content: aiText,
|
||||
meta: { tool_key: tool.key, output_kind: artifact.outputKind },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [gen, projectId, tool, values, toast, appendMessages, logChapterNo, threadId]);
|
||||
|
||||
const rows = useMemo(
|
||||
() => previewRows(gen.rawPreview),
|
||||
@@ -163,6 +230,22 @@ export function GeneratorRunner({
|
||||
targetLabel={fillLabel}
|
||||
onFill={setField}
|
||||
/>
|
||||
{canUseManuscript ? (
|
||||
<div className="flex items-center justify-between gap-2 rounded border border-line bg-bg/50 p-2">
|
||||
<span className="text-xs text-ink-soft">
|
||||
据当前正文反推(自动摘录首尾,控制长度)
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onUseManuscript}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||
用当前正文
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{(tool.input_fields ?? []).map((field) => (
|
||||
<FormField
|
||||
key={field.name}
|
||||
@@ -171,9 +254,9 @@ export function GeneratorRunner({
|
||||
onChange={(v) => setField(field.name, v)}
|
||||
/>
|
||||
))}
|
||||
<Button type="submit" disabled={generating} variant="primary">
|
||||
<Button type="submit" loading={generating} variant="primary">
|
||||
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||
{generating ? "生成中…" : "生成"}
|
||||
生成
|
||||
</Button>
|
||||
{generating ? (
|
||||
<ThinkingIndicator
|
||||
@@ -202,6 +285,11 @@ export function GeneratorRunner({
|
||||
selectable={canIngest && !singleObject}
|
||||
checked={selected.has(i)}
|
||||
onToggle={toggle}
|
||||
onInsert={
|
||||
onInsertText
|
||||
? () => onInsertText(item.body ?? item.heading ?? "")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
@@ -209,13 +297,12 @@ export function GeneratorRunner({
|
||||
{canIngest && gen.ingestStatus !== "conflict" ? (
|
||||
<Button
|
||||
onClick={() => void runIngest(false)}
|
||||
disabled={ingesting || (!singleObject && selected.size === 0)}
|
||||
loading={ingesting}
|
||||
disabled={!singleObject && selected.size === 0}
|
||||
variant="outline"
|
||||
>
|
||||
<Database className="h-4 w-4" aria-hidden="true" />
|
||||
{ingesting
|
||||
? "入库中…"
|
||||
: singleObject
|
||||
{singleObject
|
||||
? `入库为规则至 ${table}`
|
||||
: `入库选中(${selected.size})至 ${table}`}
|
||||
</Button>
|
||||
@@ -276,6 +363,8 @@ interface PreviewRowProps {
|
||||
selectable: boolean;
|
||||
checked: boolean;
|
||||
onToggle: (index: number) => void;
|
||||
// 可选:把本条插入正文(编辑器内联工具箱)。有可插入文本时才渲染按钮。
|
||||
onInsert?: () => void;
|
||||
}
|
||||
|
||||
// 统一预览行渲染(heading + 次要字段 + 正文段),由 mapPreview 归一,跨工具复用。
|
||||
@@ -285,12 +374,13 @@ function PreviewRow({
|
||||
selectable,
|
||||
checked,
|
||||
onToggle,
|
||||
onInsert,
|
||||
}: PreviewRowProps) {
|
||||
const canInsert = onInsert && (item.body || item.heading);
|
||||
return (
|
||||
<li className="flex gap-2 rounded border border-line bg-bg p-3 text-sm">
|
||||
{selectable ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onChange={() => onToggle(index)}
|
||||
className="mt-1"
|
||||
@@ -310,6 +400,17 @@ function PreviewRow({
|
||||
{item.body ? (
|
||||
<p className="whitespace-pre-wrap text-ink">{item.body}</p>
|
||||
) : null}
|
||||
{canInsert ? (
|
||||
<Button
|
||||
onClick={onInsert}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 rotate-90" aria-hidden="true" />
|
||||
插入正文
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import type { ProjectResponse, ToolDescriptorView } from "@/lib/api/types";
|
||||
import { isLegacyTool, resolveLegacyRoute } from "@/lib/toolbox/toolbox";
|
||||
import { useAiConversation } from "@/lib/workbench/useAiConversation";
|
||||
import { ToolCard } from "./ToolCard";
|
||||
import { GeneratorRunner } from "./GeneratorRunner";
|
||||
|
||||
@@ -27,6 +28,8 @@ export function ToolboxPage({
|
||||
initialOpenKey,
|
||||
}: ToolboxPageProps) {
|
||||
const router = useRouter();
|
||||
// 工具箱页无抽屉:silent 直投——项目级(chapter_no=null)留痕,不 GET、失败不 toast。
|
||||
const conversation = useAiConversation(project.id, 0, { silent: true });
|
||||
|
||||
const findOpenable = useCallback(
|
||||
(key: string | undefined): ToolDescriptorView | null => {
|
||||
@@ -76,6 +79,8 @@ export function ToolboxPage({
|
||||
<GeneratorRunner
|
||||
projectId={project.id}
|
||||
tool={active}
|
||||
appendMessages={conversation.appendMessages}
|
||||
logChapterNo={null}
|
||||
onClose={() => setActive(null)}
|
||||
/>
|
||||
) : sorted.length === 0 ? (
|
||||
|
||||
@@ -5,11 +5,14 @@ import {
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
} from "@/lib/ui/variants";
|
||||
import { Spinner } from "./Spinner";
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
children: ReactNode;
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
// 忙碌态:显示转圈 + 禁用 + aria-busy,统一各处"处理中"的过程感。
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
@@ -19,6 +22,8 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
||||
variant,
|
||||
size,
|
||||
type = "button",
|
||||
loading = false,
|
||||
disabled,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
@@ -28,8 +33,11 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={buttonClass({ variant, size, className })}
|
||||
disabled={disabled || loading}
|
||||
aria-busy={loading || undefined}
|
||||
{...props}
|
||||
>
|
||||
{loading ? <Spinner className="h-4 w-4" /> : null}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
import { cardClass } from "@/lib/ui/variants";
|
||||
import { cardClass, type CardTone } from "@/lib/ui/variants";
|
||||
|
||||
interface CardProps extends HTMLAttributes<HTMLElement> {
|
||||
children: ReactNode;
|
||||
as?: "article" | "div" | "section";
|
||||
tone?: CardTone;
|
||||
flat?: boolean;
|
||||
interactive?: boolean;
|
||||
}
|
||||
|
||||
export function Card({
|
||||
as: Component = "div",
|
||||
children,
|
||||
className,
|
||||
tone,
|
||||
flat,
|
||||
interactive,
|
||||
...props
|
||||
}: CardProps) {
|
||||
return (
|
||||
<Component className={cardClass(className)} {...props}>
|
||||
<Component
|
||||
className={cardClass({ tone, flat, interactive, className })}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
|
||||
20
apps/web/components/ui/Checkbox.tsx
Normal file
20
apps/web/components/ui/Checkbox.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { InputHTMLAttributes, ReactNode } from "react";
|
||||
|
||||
import { checkboxClass } from "@/lib/ui/variants";
|
||||
|
||||
interface CheckboxProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
|
||||
label?: ReactNode;
|
||||
}
|
||||
|
||||
// 勾选框:无 label 时只渲染受控原生 input(供已有 <label>/布局包裹的调用点直接替换);
|
||||
// 有 label 时自带 <label> 包裹,保留原生键盘/读屏语义。
|
||||
export function Checkbox({ label, className, ...props }: CheckboxProps) {
|
||||
const input = <input type="checkbox" className={checkboxClass(className)} {...props} />;
|
||||
if (!label) return input;
|
||||
return (
|
||||
<label className="inline-flex items-center gap-2 text-sm text-ink">
|
||||
{input}
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +1,117 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Eyebrow } from "@/components/ui/Eyebrow";
|
||||
import { cn } from "@/lib/ui/variants";
|
||||
|
||||
// 空态外框形态:
|
||||
// - "inline"(默认):虚线中性框,用于列表/面板内联占位(向后兼容旧默认外观)。
|
||||
// - "card":包裹在 Card(tone=soft) 内,用于独立区块的引导卡片。
|
||||
// - "bare":无外框,仅内容,交由调用方自行放置(如已在卡片里)。
|
||||
type EmptyStateVariant = "inline" | "card" | "bare";
|
||||
type EmptyStateSize = "sm" | "md";
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
action?: ReactNode;
|
||||
/** 可选眉题,置于标题上方,用于分类/上下文提示。 */
|
||||
eyebrow?: string;
|
||||
/** 外框形态,默认 "inline"。 */
|
||||
variant?: EmptyStateVariant;
|
||||
/** 留白密度,默认 "md"。 */
|
||||
size?: EmptyStateSize;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
const SIZE_PAD: Record<EmptyStateSize, string> = {
|
||||
sm: "px-5 py-8",
|
||||
md: "px-6 py-10",
|
||||
};
|
||||
|
||||
const SIZE_CHIP: Record<EmptyStateSize, string> = {
|
||||
sm: "h-10 w-10",
|
||||
md: "h-11 w-11",
|
||||
};
|
||||
|
||||
// 内容主体:图标片 + 眉题 + 标题 + 描述 + 动作。中性、编辑部化,留白克制。
|
||||
function EmptyStateBody({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
}: EmptyStateProps) {
|
||||
eyebrow,
|
||||
size,
|
||||
}: Pick<
|
||||
EmptyStateProps,
|
||||
"icon" | "title" | "description" | "action" | "eyebrow"
|
||||
> & {
|
||||
size: EmptyStateSize;
|
||||
}) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div
|
||||
className={cn(
|
||||
"rounded border border-dashed border-line bg-panel/70 px-6 py-10 text-center",
|
||||
className,
|
||||
"mx-auto mb-3 flex items-center justify-center rounded-md bg-surface-soft text-muted-soft",
|
||||
SIZE_CHIP[size],
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded bg-[var(--color-cinnabar-wash)] text-cinnabar">
|
||||
<Icon className="h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
<h2 className="font-serif text-lg text-ink">{title}</h2>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-ink-soft">
|
||||
{eyebrow ? <Eyebrow className="mb-2">{eyebrow}</Eyebrow> : null}
|
||||
<h2 className="font-serif text-title-md text-ink">{title}</h2>
|
||||
<p className="mx-auto mt-2 max-w-sm text-caption leading-6 text-ink-soft">
|
||||
{description}
|
||||
</p>
|
||||
{action ? <div className="mt-4">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
eyebrow,
|
||||
variant = "inline",
|
||||
size = "md",
|
||||
className,
|
||||
}: EmptyStateProps) {
|
||||
const body = (
|
||||
<EmptyStateBody
|
||||
icon={icon}
|
||||
title={title}
|
||||
description={description}
|
||||
action={action}
|
||||
eyebrow={eyebrow}
|
||||
size={size}
|
||||
/>
|
||||
);
|
||||
|
||||
if (variant === "bare") {
|
||||
return <div className={cn(SIZE_PAD[size], className)}>{body}</div>;
|
||||
}
|
||||
|
||||
if (variant === "card") {
|
||||
return (
|
||||
<Card tone="soft" flat className={cn(SIZE_PAD[size], className)}>
|
||||
{body}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border border-dashed border-line bg-panel/60",
|
||||
SIZE_PAD[size],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
23
apps/web/components/ui/Eyebrow.tsx
Normal file
23
apps/web/components/ui/Eyebrow.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { cn } from "@/lib/ui/variants";
|
||||
|
||||
interface EyebrowProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// 眉题/小标签:无衬线、eyebrow 字号(含正字距 0.08em)、说明色、大写。
|
||||
// 统一各页此前 mono/uppercase 互不一致的小标签写法。
|
||||
export function Eyebrow({ children, className }: EyebrowProps) {
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
"font-sans text-eyebrow uppercase text-muted-soft",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
49
apps/web/components/ui/GenerationSkeleton.tsx
Normal file
49
apps/web/components/ui/GenerationSkeleton.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
||||
import { cn } from "@/lib/ui/variants";
|
||||
|
||||
interface GenerationSkeletonProps {
|
||||
// 状态文案(如「续写中」「AI 正在构思本章…」)。
|
||||
label: string;
|
||||
// 呼吸微光占位行数(默认 3)。
|
||||
lines?: number;
|
||||
// sm:面板内小占位;lg:正文区大占位(更粗行 + 稍大标签)。
|
||||
size?: "sm" | "lg";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// 占位行宽度序列(错落感,示意即将落笔的段落)。
|
||||
const LINE_WIDTHS = ["90%", "78%", "84%", "68%", "72%"];
|
||||
|
||||
// AI 同步/构思等待占位:三点波动标签 + `ai-breathe` 呼吸微光占位行,
|
||||
// 比单三点更明显地传达「正在生成」。占位行 aria-hidden;role=status/aria-live 由
|
||||
// ThinkingIndicator 提供,读屏可知在生成。ai-breathe 自带 reduced-motion 回退。
|
||||
export function GenerationSkeleton({
|
||||
label,
|
||||
lines = 3,
|
||||
size = "sm",
|
||||
className,
|
||||
}: GenerationSkeletonProps) {
|
||||
return (
|
||||
<div className={cn("space-y-2.5", className)}>
|
||||
<ThinkingIndicator
|
||||
label={label}
|
||||
className={cn("text-cinnabar", size === "lg" ? "text-sm" : "text-xs")}
|
||||
/>
|
||||
<div className="space-y-2" aria-hidden="true">
|
||||
{Array.from({ length: lines }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"ai-breathe rounded bg-line/60",
|
||||
size === "lg" ? "h-3.5" : "h-3",
|
||||
)}
|
||||
style={{
|
||||
width: LINE_WIDTHS[i % LINE_WIDTHS.length],
|
||||
animationDelay: `${i * 160}ms`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
apps/web/components/ui/Meter.tsx
Normal file
45
apps/web/components/ui/Meter.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { cn } from "@/lib/ui/variants";
|
||||
|
||||
export type MeterTone = "accent" | "success" | "warning" | "danger" | "info";
|
||||
|
||||
const meterTones: Record<MeterTone, string> = {
|
||||
accent: "bg-cinnabar",
|
||||
success: "bg-pass",
|
||||
warning: "bg-overdue",
|
||||
danger: "bg-conflict",
|
||||
info: "bg-info",
|
||||
};
|
||||
|
||||
interface MeterProps {
|
||||
/** 0..1,超出范围自动夹取。 */
|
||||
value: number;
|
||||
tone?: MeterTone;
|
||||
label?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// 进度条:语义色填充,宽度过渡走动效 token;对读屏暴露 progressbar。
|
||||
export function Meter({ value, tone = "accent", label, className }: MeterProps) {
|
||||
const pct = Math.max(0, Math.min(1, value)) * 100;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-1.5 w-full overflow-hidden rounded-full bg-line/60",
|
||||
className,
|
||||
)}
|
||||
role="progressbar"
|
||||
aria-valuenow={Math.round(pct)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={label}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-[width] duration-base ease-standard",
|
||||
meterTones[tone],
|
||||
)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
apps/web/components/ui/PageContainer.tsx
Normal file
30
apps/web/components/ui/PageContainer.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { cn } from "@/lib/ui/variants";
|
||||
|
||||
export type PageWidth = "prose" | "default" | "wide";
|
||||
|
||||
const widths: Record<PageWidth, string> = {
|
||||
prose: "max-w-3xl",
|
||||
default: "max-w-5xl",
|
||||
wide: "max-w-7xl",
|
||||
};
|
||||
|
||||
interface PageContainerProps {
|
||||
children: ReactNode;
|
||||
width?: PageWidth;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// 统一页容器:居中 + 一致的最大宽度与内边距节奏(替换各页手写的 mx-auto max-w-* px-6 py-10)。
|
||||
export function PageContainer({
|
||||
children,
|
||||
width = "default",
|
||||
className,
|
||||
}: PageContainerProps) {
|
||||
return (
|
||||
<div className={cn("mx-auto px-6 py-10 sm:px-8", widths[width], className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Eyebrow } from "./Eyebrow";
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
eyebrow?: string;
|
||||
@@ -16,12 +18,8 @@ export function PageHeader({
|
||||
return (
|
||||
<header className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
{eyebrow ? (
|
||||
<p className="mb-1 font-mono text-xs uppercase tracking-wide text-ink-soft/70">
|
||||
{eyebrow}
|
||||
</p>
|
||||
) : null}
|
||||
<h1 className="font-serif text-2xl text-ink">{title}</h1>
|
||||
{eyebrow ? <Eyebrow className="mb-1.5">{eyebrow}</Eyebrow> : null}
|
||||
<h1 className="font-serif text-display-sm text-ink">{title}</h1>
|
||||
{description ? (
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-ink-soft">
|
||||
{description}
|
||||
|
||||
20
apps/web/components/ui/Radio.tsx
Normal file
20
apps/web/components/ui/Radio.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { InputHTMLAttributes, ReactNode } from "react";
|
||||
|
||||
import { radioClass } from "@/lib/ui/variants";
|
||||
|
||||
interface RadioProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
|
||||
label?: ReactNode;
|
||||
}
|
||||
|
||||
// 单选钮:无 label 时只渲染受控原生 input(供已有 <label>/布局包裹的调用点直接替换);
|
||||
// 有 label 时自带 <label> 包裹,保留原生键盘/读屏语义。
|
||||
export function Radio({ label, className, ...props }: RadioProps) {
|
||||
const input = <input type="radio" className={radioClass(className)} {...props} />;
|
||||
if (!label) return input;
|
||||
return (
|
||||
<label className="inline-flex items-center gap-2 text-sm text-ink">
|
||||
{input}
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,11 @@ import type { ReactNode } from "react";
|
||||
|
||||
import { cn } from "@/lib/ui/variants";
|
||||
|
||||
import { Eyebrow } from "./Eyebrow";
|
||||
|
||||
interface SectionHeaderProps {
|
||||
title: string;
|
||||
eyebrow?: string;
|
||||
description?: ReactNode;
|
||||
action?: ReactNode;
|
||||
className?: string;
|
||||
@@ -11,6 +14,7 @@ interface SectionHeaderProps {
|
||||
|
||||
export function SectionHeader({
|
||||
title,
|
||||
eyebrow,
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
@@ -18,7 +22,8 @@ export function SectionHeader({
|
||||
return (
|
||||
<div className={cn("flex items-start justify-between gap-4", className)}>
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-serif text-base text-ink">{title}</h2>
|
||||
{eyebrow ? <Eyebrow className="mb-1">{eyebrow}</Eyebrow> : null}
|
||||
<h2 className="font-serif text-title-md text-ink">{title}</h2>
|
||||
{description ? (
|
||||
<p className="mt-1 text-sm leading-6 text-ink-soft">{description}</p>
|
||||
) : null}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { KeyboardEvent, ReactNode } from "react";
|
||||
import { useRef } from "react";
|
||||
|
||||
import { cn, segmentedClass } from "@/lib/ui/variants";
|
||||
import { cn, focusRing, segmentedClass, transitionUi } from "@/lib/ui/variants";
|
||||
|
||||
export interface SegmentOption<T extends string> {
|
||||
value: T;
|
||||
@@ -22,18 +23,61 @@ export function SegmentedControl<T extends string>({
|
||||
ariaLabel,
|
||||
className,
|
||||
}: SegmentedControlProps<T>) {
|
||||
const buttonsRef = useRef<Array<HTMLButtonElement | null>>([]);
|
||||
|
||||
const focusOption = (index: number) => {
|
||||
const clamped = (index + options.length) % options.length;
|
||||
const target = options[clamped];
|
||||
if (!target) return;
|
||||
onChange(target.value);
|
||||
buttonsRef.current[clamped]?.focus();
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
|
||||
switch (event.key) {
|
||||
case "ArrowRight":
|
||||
case "ArrowDown":
|
||||
event.preventDefault();
|
||||
focusOption(index + 1);
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
case "ArrowUp":
|
||||
event.preventDefault();
|
||||
focusOption(index - 1);
|
||||
break;
|
||||
case "Home":
|
||||
event.preventDefault();
|
||||
focusOption(0);
|
||||
break;
|
||||
case "End":
|
||||
event.preventDefault();
|
||||
focusOption(options.length - 1);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={segmentedClass(className)} role="group" aria-label={ariaLabel}>
|
||||
{options.map((option) => {
|
||||
<div className={segmentedClass(className)} role="radiogroup" aria-label={ariaLabel}>
|
||||
{options.map((option, index) => {
|
||||
const selected = option.value === value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
ref={(node) => {
|
||||
buttonsRef.current[index] = node;
|
||||
}}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
tabIndex={selected ? 0 : -1}
|
||||
onClick={() => onChange(option.value)}
|
||||
onKeyDown={(event) => handleKeyDown(event, index)}
|
||||
className={cn(
|
||||
"rounded px-3 py-1.5 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35",
|
||||
"rounded-md px-3 py-1.5 text-sm",
|
||||
transitionUi,
|
||||
focusRing,
|
||||
selected
|
||||
? "bg-panel text-cinnabar shadow-paper"
|
||||
: "text-ink-soft hover:text-cinnabar",
|
||||
|
||||
18
apps/web/components/ui/Skeleton.tsx
Normal file
18
apps/web/components/ui/Skeleton.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { cn } from "@/lib/ui/variants";
|
||||
|
||||
interface SkeletonProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// 加载占位:柔和线条底 + motion-safe 脉冲(尊重 prefers-reduced-motion)。
|
||||
export function Skeleton({ className }: SkeletonProps) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"block rounded-md bg-line/60 motion-safe:animate-pulse",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
18
apps/web/components/ui/Spinner.tsx
Normal file
18
apps/web/components/ui/Spinner.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { cn } from "@/lib/ui/variants";
|
||||
|
||||
interface SpinnerProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// 转圈忙碌指示:环形(顶部留缺口)motion-safe 旋转;reduced-motion 下为静止环。
|
||||
export function Spinner({ className }: SpinnerProps) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"inline-block h-4 w-4 shrink-0 rounded-full border-2 border-current border-t-transparent motion-safe:animate-spin",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
41
apps/web/components/ui/StatusDot.tsx
Normal file
41
apps/web/components/ui/StatusDot.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { cn } from "@/lib/ui/variants";
|
||||
|
||||
export type StatusTone =
|
||||
| "neutral"
|
||||
| "accent"
|
||||
| "success"
|
||||
| "warning"
|
||||
| "danger"
|
||||
| "info";
|
||||
|
||||
const dotTones: Record<StatusTone, string> = {
|
||||
neutral: "bg-ink-soft",
|
||||
accent: "bg-cinnabar",
|
||||
success: "bg-pass",
|
||||
warning: "bg-overdue",
|
||||
danger: "bg-conflict",
|
||||
info: "bg-info",
|
||||
};
|
||||
|
||||
interface StatusDotProps {
|
||||
tone?: StatusTone;
|
||||
/** 提供 label 时对读屏可见(不单靠颜色传达状态)。 */
|
||||
label?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// 状态点:语义色圆点,配合文案使用;有 label 时暴露给读屏。
|
||||
export function StatusDot({ tone = "neutral", label, className }: StatusDotProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-2 w-2 shrink-0 rounded-full",
|
||||
dotTones[tone],
|
||||
className,
|
||||
)}
|
||||
role={label ? "img" : undefined}
|
||||
aria-label={label}
|
||||
aria-hidden={label ? undefined : true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user