merge: writing-first 工作台 + QA-CR 整改 + WFW-9 澄清收尾 + 本地端口 +1000(feat/writing-first-workspace,66 提交)

- 写作工作台重构 WFW-0~9:进来即写 / 编辑器内 refine·续写·工具箱·整章重写 / AI 反问澄清 M1–M3
- QA-CR 全项目评审整改:2 CRITICAL + 13 HIGH 全修(对抗式复核 0 遗留)+ MEDIUM/LOW 批
- 本地/compose 宿主端口 +1000 防冲突(web 4000 / api 9000 / pg 6432)
This commit is contained in:
Yaojia Wang
2026-07-08 18:53:57 +02:00
160 changed files with 8776 additions and 732 deletions

View File

@@ -6,6 +6,7 @@ DATABASE_URL_SYNC=postgresql+psycopg://writer:writer@localhost:5432/writer
APP_ENV=dev APP_ENV=dev
LOG_JSON=false LOG_JSON=false
# 凭据加密密钥Fernet, urlsafe-base64 32B— **必填**API 启动即校验,缺失/非法则拒绝启动。 # 凭据加密密钥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())" # 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
View File

@@ -19,3 +19,5 @@ apps/web/lib/api/openapi.json
# os # os
.DS_Store .DS_Store
.gstack/ .gstack/
# coverage artifact不入库覆盖率跑生成误提交由 CR 批 c596a8d 撤销)
.coverage

View File

@@ -481,7 +481,6 @@ class LlmRequest(BaseModel):
input: str | list[Block] # 易变内容(断点之后) input: str | list[Block] # 易变内容(断点之后)
stream: bool = False stream: bool = False
output_schema: type[BaseModel] | None = None # 需结构化输出时(Pydantic 模型/instructor) output_schema: type[BaseModel] | None = None # 需结构化输出时(Pydantic 模型/instructor)
thinking: bool = False # 是否启用思考/推理(有则启用)
max_tokens: int | None = None max_tokens: int | None = None
scope: "Scope" # {project_id; user_id 原型可固定} scope: "Scope" # {project_id; user_id 原型可固定}

View File

@@ -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` 装到 `~/.local/bin`命令前 `export PATH="$HOME/.local/bin:$PATH"`
- **依赖安装**`uv sync`后端仓库根`pnpm install`前端`cd apps/web`)。 - **依赖安装**`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 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 误报)。 - **后端门禁**`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/**`)。 - **前端门禁**`cd apps/web``pnpm lint` · `pnpm typecheck` · `pnpm test`vitest)· `pnpm build`覆盖率门禁`pnpm test:coverage`阈值 80%范围 `lib/**`)。

View File

@@ -17,29 +17,62 @@
| 任务 | 状态 | 负责 | 依赖 | 备注 | | 任务 | 状态 | 负责 | 依赖 | 备注 |
|---|---|---|---|---| |---|---|---|---|---|
| CR-C1 🔴 升级 Next.js 修 RCECVE-2025-66478+ React 补丁 + 轮换密钥 | | @frontend | — | `apps/web/package.json` next **15.1.3→15.1.9**(或 15.5.7/16.xreact/react-dom 19.0.0→补丁版(上游 CVE-2025-55182App Router + RSC 应用正受影响。`pnpm install` 重锁 + `pnpm build` 验;**升级后轮换全部应用密钥**(联动 CR-H8。Next15 LTS 2026-10-21 EOL规划 16.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 | — | `services/chain_runner.py:210` 默认 25 supersteps、每章 4 节点→超 ~6 章即 `GraphRecursionError` 整 job 失败,「批量产一卷(K章)」在目标规模不可用。`config``recursion_limit=(last-start+1)*4+10`;补超默认上限的 mock-gateway 回归测试 | | 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 | — | `services/job_runner.py:75`+`routers/kimi_oauth.py:85` 轮询全程(最坏 >200s占连接池连接、饿死其他请求轮询循环无需 DB仅最终 `upsert_oauth_credential` 开短会话 | | 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 | — | `routers/kimi_oauth.py:73` `Depends(_default_http_client)` 非 generator 依赖、永不 `aclose()`;改 `async def _http_client(): async with httpx.AsyncClient(...) as http: yield http` | | CR-H2 🟠 修 `httpx.AsyncClient` 泄漏 | | @backend | — | `5dd336b`+`26f10d0` 请求级依赖改 `_http_client` 生成器(`async with``aclose`);两处测 override 跟随改,堵住 E2E 误触真网 |
| CR-H3 🟠 Anthropic 结构化输出记真实 usage | | @llm | — | `adapters/anthropic.py:161` 硬编码 `usage=0``usage_ledger` 全记 0 成本、腐蚀计费(违 ARCH §4.8);用 `create_with_completion`原始响应 usage | | CR-H3 🟠 Anthropic 结构化输出记真实 usage | | @llm | — | `cb8fddf` `create_with_completion` `raw.usage`,弃硬编码 0修计费腐蚀 |
| CR-H4 🟠 `request_id` 贯通网关日志 | | @llm | — | `gateway.py:183` 所有 `llm_call`/`llm_provider_failed` 无 request_id`LlmRequest``request_id` 字段并从 API 层透传(违 ARCH §9.3 端到端追踪不变量) | | CR-H4 🟠 `request_id` 贯通网关日志 | | @llm | — | `51fe9f4`+`e08b01a` LlmRequest 加可选 `request_id` + **条件透传**(仅非 None否则覆盖 contextvars 反退化 sync/SSE 追踪);`llm_call`+两处 `llm_provider_failed` 全锁测。契约 C1-扩 |
| CR-H5 🟠 `thinking` 字段:实现或删除 | | @llm | — | `types.py:41` 声明但所有 adapter 忽略,`thinking=True` 静默无效、无报错;按 YAGNI 删除或实现转发 + 测试 | | CR-H5 🟠 `thinking` 字段:实现或删除 | | @llm | — | `afeac51` 删死字段 `LlmRequest.thinking`YAGNI`Capabilities.thinking`(异名活字段);回写 ARCH/contracts |
| CR-H6 🟠 流式路径加 per-provider 重试 | | @llm | — | `gateway.py:268` 流式仅 1 次尝试,首 token 前一次瞬时 429 烧光 fallback 链(与非流式 `_complete_with_retry` 对称);用 `_retrying()` 包裹首 token 前尝试或显式文档化 | | CR-H6 🟠 流式路径加 per-provider 重试 | | @llm | — | `8a2b1b0` `_stream_with_retry` 对称 run仅首 token 前重试mid-stream 失败照抛(不变量 §4.5 |
| CR-H7 🟠 `TOOLBOX` 注册表不可变 | | @backend | — | `packages/skills/ww_skills/toolbox_registry.py:70` 裸 dict 可变;仿同库 `SPECS` `Final[Mapping]=MappingProxyType(...)`(违不可变不变量 | | CR-H7 🟠 `TOOLBOX` 注册表不可变 | | @backend | — | `25d48b7` `Final[Mapping]=MappingProxyType(_TOOLBOX_BY_KEY)`(仿 SPECS |
| CR-H8 🟠 `.env.example` 移除可用 Fernet key | | @devops | — | 已确认提交真实可用 key注释「可直接用于本地」换非功能占位符 + 突出轮换提示;联动 CR-C1 轮换 | | CR-H8 🟠 `.env.example` 移除可用 Fernet key | | @devops | — | `baa41d3` 换不可用占位符Fernet 拒绝→照抄即启动失败)+ 轮换提示。**⚠️ 真 key 仍在 git 历史,曾用该 key 的环境须轮换(用户)** |
| 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-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 | — | `lib/style/{useRefine,useStyleLearn}.ts` 各自重复定义,统一 import 自 `lib/generation/cards.ts:132`DRY 漂移隐患) | | CR-H10 🟠 前端 `errorCode` 去重 | | @frontend | — | `5665cc9` `lib/style/{useRefine,useStyleLearn}` 统一 import 自 `generation/cards` |
| CR-H11 🟠 Drawer 关闭还原焦点 | | @frontend | — | `components/Drawer.tsx:41` 关闭后焦点掉到 body违 WCAG 2.4.3);传 `triggerRef`、关闭时 `.focus()` | | CR-H11 🟠 Drawer 关闭还原焦点 | | @frontend | — | `6e2b478` 行为本分支早已修;提取 `lib/a11y/useRestoreFocus` + renderHook 回归测试 + DRYDrawer/ContextDrawer 共用) |
| CR-H12 🟠 RefineView 取消在途 refine | | @frontend | — | `components/style/RefineView.tsx:33` 无 AbortController段切换时旧响应可覆盖新响应`useRefine` 暴露 abort 在 effect cleanup 调用,去 blanket exhaustive-deps disable | | CR-H12 🟠 RefineView 取消在途 refine | | @frontend | — | `5c02792` useRefine 暴露 `abort`setState-free+ AbortController + aborted 守卫弃迟到响应RefineView cleanup 调用 + 真 deps |
| 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-M1 🟡 后端架构整改批 | ✅ 大部 | @backend | — | `c596a8d` outline volume 下推 DB WHEREProtocol/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 | — | `gateway.py:90` `is_open()` 副作用违 CQS`pricing.py` 未知 model 静默记 0 → 加 warningadapter `str(exc)` 入异常消息可能带 vendor 响应体→只留类名+status | | 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 | — | `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-M3 🟡 LangGraph 链健壮性 | ✅ 大部 | @llm | — | `ec91c8f` runner 改用公开 `graph.aget_state(config).interrupts` 判暂停(弃私有 `__interrupt__`SpyGraph 测同步补 aget_state 透传);`6a739ca` review 节点五审 `asyncio.gather` 并行扇出(复用主图 add-only 记账并发范式 T3.8collect 仍一次落库;并发峰值测 + 五审落齐测)。**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 | — | 可编辑列表去 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-M4 🟡 前端 key/a11y/cleanup 批 | | @frontend | — | `47d5558` ARIANavDrawer `aria-controls` + Drawer `id` + ForeshadowCard 语义化 `<dl>/<dt>/<dd>`+ `125908e` ReviewReport `flashMark` setTimeout 卸载清理ref 存句柄)。**核实为无需改/已具备**index-key三处均 replace-all/append-only 静态展示、无天然稳定 idindex 安全;卡名可编辑作 key 更糟)、`stop`(经查**非**服务端 SSE 帧、是客户端 AbortController 动作加则处理不存在事件、AppShell nav label`<nav aria-label>` 已有)、ProjectWizard 步骤焦点`stepHeadingRef`+首帧守卫 useEffect 已实现) |
| CR-M5 🟡 前端 TS 边界类型收紧 | | @frontend | — | `lib/api/server.ts:34` `parseJsonBody` 加最小结构校验;`useAccept.ts:60` `ApiErrorEnvelope` 从生成 schema 派生;`applyFix.ts` `hasApplicableFix` 改类型谓词`as string` | | 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 | — | `chain.py:256` 静默回退加 warningSSE 端点补 OpenAPI `text/event-stream` 声明;冗余 `as`/`.valueOf()` cast 清理;测试脚本 `print`→logging | | 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 | — | `pyproject.toml` 下界过旧`langgraph>=0.2.40`/`anthropic>=0.34`),抬到接近 lockfile 实锁版本保可复现;后端实锁版本均当前。可选评估 vitest 3.x / tailwind 4.x | | 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/LOWCR-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。 > **维度小结**:后端 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 后端片 ✅ 落地**@backend6 提交 `c596a8d`/`bf3dabe`/`1392830`/`0892ff6`/`49e3cb6`各行为改动先写红测再实现。CR-M1 4/6 完成volume 下推 · N+1→create_many · jobs→JobRepo · 列表分页item#4去 `_client`)与 item#6assemble 并行SKIP理由见 CR-M1 行CR-L1 后端片完成SSE OpenAPIprint 属误报)。**门禁全绿**:后端 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 小步提交,回填全守 HITLAI 绝不 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「用当前正文」按钮把摘录填 briefbook-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 2425,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 范式 tokenrefine 含糊反问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 +194,12 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移无漂移users st
> 格式:`- [YYYY-MM-DD] @skill 完成/进展 Txx — 一句话结果 + 影响的契约/文件` > 格式:`- [YYYY-MM-DD] @skill 完成/进展 Txx — 一句话结果 + 影响的契约/文件`
- [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.4TDD 逐项提交)**。**完成 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` warningCR-L1`ec91c8f` 链 runner 改用公开 `graph.aget_state(config).interrupts` 判暂停(弃私有 `__interrupt__``6a739ca` review 节点五审 `asyncio.gather` 并行扇出(复用主图 add-only 记账并发范式 T3.8collect 一次落库)。**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 非 vacuous0 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+ C3H9 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**+23recursion_limit 1 / poll-no-session 1 / http-gen 1 / toolbox-readonly 1 / input-size 19[17 参数化 + samples 1 + body413 1])· **无 DDL/无迁移**。记 contracts C3CR-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] @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 契约,作客观锚点)。 - [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 +284,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-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-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/reactCodexPage 经 `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。TDDgraphLayout **+22 测**role 映射/节点去空重名/kind 标签+默认/悬空跳过/自指跳过/无向去重/边 id 唯一/悬空计数/布局确定性+不可变/端到端);组件走人工/E2E。前端门禁全绿lint 干净 · typecheck 干净 · **vitest 565 passed** · **coverage lib 95.4%graphLayout.ts 97.6%** · **build OK**codex 路由 7.58 kBReact Flow 懒加载独立 chunk。注`pnpm build` 门禁与运行中的 `next dev`(3001) 共享 `.next` → 触发 dev 服务临时 500stale chunk非代码缺陷、dev watcher 下次重编自愈,未重启服务。 - [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/reactCodexPage 经 `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。TDDgraphLayout **+22 测**role 映射/节点去空重名/kind 标签+默认/悬空跳过/自指跳过/无向去重/边 id 唯一/悬空计数/布局确定性+不可变/端到端);组件走人工/E2E。前端门禁全绿lint 干净 · typecheck 干净 · **vitest 565 passed** · **coverage lib 95.4%graphLayout.ts 97.6%** · **build OK**codex 路由 7.58 kBReact Flow 懒加载独立 chunk。注`pnpm build` 门禁与运行中的 `next dev`(3001) 共享 `.next` → 触发 dev 服务临时 500stale 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**

View File

@@ -43,6 +43,8 @@
前置Python 3.12+ · Node 22 · [uv](https://docs.astral.sh/uv/) · pnpm · Docker 前置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 ```bash
# 1. 克隆后安装后端依赖仓库根uv workspace # 1. 克隆后安装后端依赖仓库根uv workspace
uv sync uv sync
@@ -59,17 +61,17 @@ cp .env.example .env
docker compose up -d pg docker compose up -d pg
uv run alembic upgrade head uv run alembic upgrade head
# 5. 起后端 API默认 http://localhost:8000文档 /docs # 5. 起后端 APIhttp://localhost:9000文档 /docs— 宿主端口 +1000
uv run uvicorn ww_api.main:app --reload 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 cd apps/web && pnpm dev
``` ```
或一键起全套pg + api + web 或一键起全套pg + api + web,宿主端口均已 +1000
```bash ```bash
docker compose up docker compose up # web → :4000 · api → :9000 · pg → :6432
``` ```
> **LLM API Key 不放 `.env`**:在应用设置页 **`/settings/providers`** 录入,密文存于数据库凭据库(用 `CREDENTIAL_ENC_KEY` 加密)。`.env` 里只有那把加密主密钥本身。 > **LLM API Key 不放 `.env`**:在应用设置页 **`/settings/providers`** 录入,密文存于数据库凭据库(用 `CREDENTIAL_ENC_KEY` 加密)。`.env` 里只有那把加密主密钥本身。

View File

@@ -3,10 +3,10 @@ name = "ww-api"
version = "0.0.0" version = "0.0.0"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"fastapi>=0.111", "fastapi>=0.137",
"uvicorn[standard]>=0.30", "uvicorn[standard]>=0.49",
"structlog>=24.1", "structlog>=26.1",
"cryptography>=43", "cryptography>=49",
"ww-shared", "ww-shared",
"ww-config", "ww-config",
"ww-db", "ww-db",

View File

@@ -63,8 +63,13 @@ class FakeProjectRepo:
self.rows[pid] = (owner_id, view) self.rows[pid] = (owner_id, view)
return view return view
async def list_for_owner(self, owner_id: uuid.UUID) -> list[ProjectView]: async def list_for_owner(
return [v for (o, v) in self.rows.values() if o == owner_id] 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: async def get(self, owner_id: uuid.UUID, project_id: uuid.UUID) -> ProjectView | None:
entry = self.rows.get(project_id) 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: async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None:
return self.rows.get((project_id, chapter_no)) return self.rows.get((project_id, chapter_no))
async def list_for_project(self, project_id: uuid.UUID) -> list[OutlineView]: async def list_for_project(
views = [v for (p, _), v in self.rows.items() if p == project_id] 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) return sorted(views, key=lambda v: v.chapter_no)

View File

@@ -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] 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-C2run_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 判 interruptCR-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( async def test_run_chain_job_conflict_sets_awaiting_then_resume_done(
_patched_runner: None, _patched_runner: None,
) -> None: ) -> None:

View File

@@ -28,7 +28,7 @@ from ww_agents import (
WorldEntityCard, WorldEntityCard,
WorldGenResult, 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.project_repo import ProjectCreate
from ww_core.domain.rule_repo import RuleListItemView from ww_core.domain.rule_repo import RuleListItemView
from ww_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage 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) 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: class _FakeRulesReadRepo:
"""实现规则 repo 的读侧 `list_for_project`(带 id供 list_rules 端点)。""" """实现规则 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 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 @pytest.mark.asyncio
async def test_ingest_characters_with_conflict_blocks_409() -> None: async def test_ingest_characters_with_conflict_blocks_409() -> None:
repo = FakeProjectRepo() repo = FakeProjectRepo()

View File

@@ -38,8 +38,11 @@ class _OutlineRepo:
async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None: async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None:
return self._rows.get(chapter_no) return self._rows.get(chapter_no)
async def list_for_project(self, project_id: uuid.UUID) -> list[OutlineView]: async def list_for_project(
return [self._rows[k] for k in sorted(self._rows)] 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: class _CharRepo:

View 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 字符 OK20_001 → ValidationError最大 DoS 面:每条正文都设上界)。
StyleLearnRequest(samples=["x" * 20_000])
with pytest.raises(ValidationError):
StyleLearnRequest(samples=["x" * 20_001])
# 列表 == 20 条 OK21 条 → 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"

View File

@@ -15,6 +15,8 @@
from __future__ import annotations from __future__ import annotations
import os import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any from typing import Any
import pytest import pytest
@@ -51,12 +53,65 @@ class _ScriptedHttp:
return None 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( def _make_client(
*, *,
store: FakeCredentialStore, store: FakeCredentialStore,
http: _ScriptedHttp, http: Any,
enc_key: str, enc_key: str,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
session_factory: Any = None,
) -> TestClient: ) -> TestClient:
os.environ["CREDENTIAL_ENC_KEY"] = enc_key os.environ["CREDENTIAL_ENC_KEY"] = enc_key
from ww_config import get_settings from ww_config import get_settings
@@ -88,12 +143,15 @@ def _make_client(
job_repo = FakeJobRepo() job_repo = FakeJobRepo()
monkeypatch.setattr(job_runner, "SqlJobRepo", lambda _session: job_repo) monkeypatch.setattr(job_runner, "SqlJobRepo", lambda _session: job_repo)
sf = session_factory if session_factory is not None else FakeSessionFactory()
app = create_app() app = create_app()
app.dependency_overrides[get_credential_store] = lambda: store app.dependency_overrides[get_credential_store] = lambda: store
app.dependency_overrides[get_job_repo] = lambda: job_repo app.dependency_overrides[get_job_repo] = lambda: job_repo
app.dependency_overrides[get_session] = lambda: FakeSession() app.dependency_overrides[get_session] = lambda: FakeSession()
app.dependency_overrides[get_session_factory] = lambda: FakeSessionFactory() app.dependency_overrides[get_session_factory] = lambda: sf
app.dependency_overrides[routers.kimi_oauth._default_http_client] = lambda: http # 请求级 http 现为生成器依赖 `_http_client`CR-H2——override 它注 fake否则真生成器联网
app.dependency_overrides[routers.kimi_oauth._http_client] = lambda: http
return TestClient(app) 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 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: def test_disconnect_clears_credential(monkeypatch: pytest.MonkeyPatch) -> None:
enc_key = Fernet.generate_key().decode() enc_key = Fernet.generate_key().decode()
store = FakeCredentialStore() store = FakeCredentialStore()

View File

@@ -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) assert all(c["volume"] == 2 for c in vol2_chapters)
@pytest.mark.asyncio
async def test_outline_repo_volume_filter_pushed_into_query() -> None:
# 卷过滤下推到 repoDB 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 @pytest.mark.asyncio
async def test_get_outline_returns_empty_list_when_no_outline() -> None: async def test_get_outline_returns_empty_list_when_no_outline() -> None:
project_repo = FakeProjectRepo() project_repo = FakeProjectRepo()

View File

@@ -10,7 +10,14 @@ import uuid
import httpx import httpx
import pytest import pytest
from cryptography.fernet import Fernet 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_api.services.credentials import STUB_OWNER_ID
from ww_core.domain.project_repo import ProjectView from ww_core.domain.project_repo import ProjectView
from ww_core.domain.repositories import ( 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: async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None:
return 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 [] return []
@@ -213,6 +222,33 @@ async def test_list_projects() -> None:
assert all(p["pending_review_count"] == 0 for p in projects) 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 422limit∈[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 @pytest.mark.asyncio
async def test_get_project_detail() -> None: async def test_get_project_detail() -> None:
client, _, _, _ = _make_client() 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") resp = await client.get(f"/projects/{pid}/chapters/9/draft")
assert resp.status_code == 404 assert resp.status_code == 404
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND 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 端点依赖的 appproject_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
# 不变量 #2analyst 档;意见折进网关 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

View File

@@ -59,10 +59,12 @@ def _app_with_overrides(
session_factory: FakeSessionFactory | None = None, session_factory: FakeSessionFactory | None = None,
extract_gateway: object | None = None, extract_gateway: object | None = None,
refine_gateway: object | None = None, refine_gateway: object | None = None,
clarify_gateway: object | None = None,
) -> FastAPI: ) -> FastAPI:
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode()) os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
from ww_api.main import create_app from ww_api.main import create_app
from ww_api.services.project_deps import ( from ww_api.services.project_deps import (
get_clarify_gateway,
get_job_repo, get_job_repo,
get_project_repo, get_project_repo,
get_refine_gateway, get_refine_gateway,
@@ -85,6 +87,8 @@ def _app_with_overrides(
app.dependency_overrides[get_style_extract_gateway] = lambda: extract_gateway app.dependency_overrides[get_style_extract_gateway] = lambda: extract_gateway
if refine_gateway is not None: if refine_gateway is not None:
app.dependency_overrides[get_refine_gateway] = lambda: refine_gateway 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 return app
@@ -394,3 +398,134 @@ async def test_refine_without_credentials_503() -> None:
assert resp.status_code == 503 assert resp.status_code == 503
assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE
assert session.commits == 0 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

View File

@@ -33,8 +33,13 @@ class _FakeTemplateRepo:
self.rows[view.id] = view self.rows[view.id] = view
return view return view
async def list_for_owner(self, owner_id: uuid.UUID) -> list[TemplateView]: async def list_for_owner(
return list(self.rows.values()) 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: async def delete(self, owner_id: uuid.UUID, template_id: uuid.UUID) -> bool:
if template_id in self.rows: if template_id in self.rows:

View File

@@ -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.project_repo import ProjectCreate
from ww_core.domain.repositories import OutlineView from ww_core.domain.repositories import OutlineView
from ww_core.domain.rule_repo import RuleWriteView 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_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage
from ww_shared import AppError, ErrorCode from ww_shared import AppError, ErrorCode
@@ -75,6 +75,15 @@ class _FakeWorldWriteRepo:
self.rows.append({"type": type, "name": name, "rules": list(rules)}) self.rows.append({"type": type, "name": name, "rules": list(rules)})
return WorldEntityWriteView(id=uuid.uuid4(), type=type, name=name) 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: class _FakeOutlineWriteRepo:
def __init__(self) -> None: def __init__(self) -> None:
@@ -569,6 +578,30 @@ async def test_ingest_world_entities_no_conflict_writes_201() -> None:
assert session.commits == 1 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 @pytest.mark.asyncio
async def test_ingest_world_entities_conflict_blocks_409() -> None: async def test_ingest_world_entities_conflict_blocks_409() -> None:
repo = FakeProjectRepo() repo = FakeProjectRepo()

View File

@@ -14,7 +14,11 @@ from ww_db import get_sessionmaker
from ww_shared import AppError, ErrorBody, ErrorCode, ErrorEnvelope from ww_shared import AppError, ErrorBody, ErrorCode, ErrorEnvelope
from ww_api.logging_config import configure_logging, get_logger 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 ( from ww_api.routers import (
chain, chain,
foreshadow, foreshadow,
@@ -74,6 +78,8 @@ def create_app() -> FastAPI:
allow_headers=["content-type", "authorization", REQUEST_ID_HEADER], allow_headers=["content-type", "authorization", REQUEST_ID_HEADER],
) )
app.middleware("http")(request_id_middleware) app.middleware("http")(request_id_middleware)
# 请求体大小守卫CR-H9在路由前拦截超大 body直接 413。
app.middleware("http")(body_size_limit_middleware)
@app.exception_handler(AppError) @app.exception_handler(AppError)
async def _app_error_handler(request: Request, exc: AppError) -> JSONResponse: async def _app_error_handler(request: Request, exc: AppError) -> JSONResponse:

View File

@@ -12,10 +12,14 @@ from collections.abc import Awaitable, Callable
import structlog import structlog
from starlette.requests import Request 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" REQUEST_ID_HEADER = "x-request-id"
# 请求体大小上限2 MiB超过即 413防超大 body 撑爆内存/成本CR-H9
MAX_BODY_BYTES = 2 * 1024 * 1024
# 放行安全字符集(字母数字 . _ -1128 位):覆盖 uuid4().hex、常见 trace id 与客户端 # 放行安全字符集(字母数字 . _ -1128 位):覆盖 uuid4().hex、常见 trace id 与客户端
# 自定义短 id含空白/控制字符/换行/注入序列/超长的一律丢弃改生成新 uuid——防止把未经 # 自定义短 id含空白/控制字符/换行/注入序列/超长的一律丢弃改生成新 uuid——防止把未经
# 校验的客户端值原样写进日志/响应头(日志注入防护)。 # 校验的客户端值原样写进日志/响应头(日志注入防护)。
@@ -38,3 +42,29 @@ async def request_id_middleware(
response = await call_next(request) response = await call_next(request)
response.headers[REQUEST_ID_HEADER] = request_id response.headers[REQUEST_ID_HEADER] = request_id
return response return response
async def body_size_limit_middleware(
request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
"""按 `Content-Length` 拦截超大请求体,超限直接 413CR-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)

View 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)]

View File

@@ -33,6 +33,7 @@ from ww_agents import (
worldbuilder_spec, worldbuilder_spec,
) )
from ww_core.domain import ( from ww_core.domain import (
CharacterWriteFields,
CharacterWriteRepo, CharacterWriteRepo,
ProjectRepo, ProjectRepo,
RuleWriteRepo, RuleWriteRepo,
@@ -372,10 +373,8 @@ async def ingest_characters(
rejected_tables=rejected, rejected_tables=rejected,
) )
created: list[str] = [] fields = [
for card in allowed.get("characters", []): CharacterWriteFields(
view = await char_repo.create(
project_id,
name=card.name, name=card.name,
role=card.role, role=card.role,
traits=list(card.traits), traits=list(card.traits),
@@ -387,7 +386,10 @@ async def ingest_characters(
tags=list(card.tags), tags=list(card.tags),
relations=[r.model_dump() for r in card.relations], 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)]
# 提交边界:网关 ledgerprecheck+ 角色写侧均只 flush → 端点末尾一次 commit。 # 提交边界:网关 ledgerprecheck+ 角色写侧均只 flush → 端点末尾一次 commit。
await session.commit() await session.commit()

View File

@@ -6,13 +6,11 @@ import uuid
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlalchemy import select from ww_core.domain import JobRepo
from sqlalchemy.ext.asyncio import AsyncSession
from ww_db import get_session
from ww_db.models import Job
from ww_shared import AppError, ErrorCode, ErrorEnvelope from ww_shared import AppError, ErrorCode, ErrorEnvelope
from ww_api.schemas.jobs import JobResponse from ww_api.schemas.jobs import JobResponse
from ww_api.services.project_deps import get_job_repo
router = APIRouter(prefix="/jobs", tags=["jobs"]) router = APIRouter(prefix="/jobs", tags=["jobs"])
@@ -23,9 +21,9 @@ router = APIRouter(prefix="/jobs", tags=["jobs"])
) )
async def get_job( async def get_job(
job_id: uuid.UUID, job_id: uuid.UUID,
session: Annotated[AsyncSession, Depends(get_session)], job_repo: Annotated[JobRepo, Depends(get_job_repo)],
) -> JobResponse: ) -> 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: if job is None:
raise AppError(ErrorCode.NOT_FOUND, f"job {job_id} not found") raise AppError(ErrorCode.NOT_FOUND, f"job {job_id} not found")
return JobResponse( return JobResponse(

View File

@@ -17,6 +17,8 @@ token 仅以 Fernet 密文存 `provider_credentials.oauth_enc`。
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import uuid
from collections.abc import AsyncIterator
from typing import Annotated, Any from typing import Annotated, Any
import httpx import httpx
@@ -47,6 +49,7 @@ from ww_api.services.kimi_oauth import (
AuthorizationPending, AuthorizationPending,
DeviceAuth, DeviceAuth,
SlowDown, SlowDown,
TokenSet,
decrypt_oauth_bundle, decrypt_oauth_bundle,
encrypt_oauth_bundle, encrypt_oauth_bundle,
poll_token, poll_token,
@@ -70,53 +73,104 @@ _JOB_KIND_KIMI_OAUTH = "kimi_oauth"
_MAX_POLL_ATTEMPTS = 200 _MAX_POLL_ATTEMPTS = 200
#: 请求/后台 HTTP 客户端超时(秒)。
_HTTP_TIMEOUT_SECONDS = 30.0
def _default_http_client() -> AsyncHttpClient: 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: async def _http_client() -> AsyncIterator[AsyncHttpClient]:
"""构造后台轮询工作闭包:循环 poll token 直到成功/过期/拒绝 """请求级 httpx 客户端**生成器依赖**:请求结束时经 `async with` 自动 acloseCR-H2
`work(session)` 自建 httpx 客户端 + 凭据 store用 `run_job` 传入的独立 session 普通函数依赖无 teardown返回的 client 永不关闭 → 每次 `/start` 泄漏一个连接。故请求路径
按 `interval` 轮询 token → 成功则加密存 `oauth_enc` 并返回非密 job 结果 → 过期/拒绝则 用此生成器依赖(后台路径仍用裸工厂 `_default_http_client`,自管生命周期)。
抛 AppError`run_job` 置 job failed。**token 绝不进 job 结果/日志**。 """
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT_SECONDS) as http:
yield http
# 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)
for _ in range(_MAX_POLL_ATTEMPTS):
await asyncio.sleep(interval)
try:
return await poll_token(http, device.device_code)
except AuthorizationPending:
continue
except SlowDown:
interval += _SLOW_DOWN_STEP_SECONDS
continue
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]: async def work(session: AsyncSession) -> dict[str, Any]:
enc_key = get_settings().credential_enc_key.get_secret_value() enc_key = get_settings().credential_enc_key.get_secret_value()
store = SqlCredentialStore(session) store = SqlCredentialStore(session)
interval = max(1, device.interval) blob = encrypt_oauth_bundle(token, key=enc_key)
await store.upsert_oauth_credential(STUB_OWNER_ID, KIMI_CODE_PROVIDER, blob)
http = _default_http_client() return {"connected": True, "provider": KIMI_CODE_PROVIDER}
try:
for _ in range(_MAX_POLL_ATTEMPTS):
await asyncio.sleep(interval)
try:
token = await poll_token(http, device.device_code)
except AuthorizationPending:
continue
except SlowDown:
interval += 5
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},
)
finally:
# `httpx.AsyncClient` 有 `aclose`(最小 Protocol 无;测试 fake 也实现了它)。
aclose = getattr(http, "aclose", None)
if aclose is not None:
await aclose()
return work 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()
await run_job(session_factory, job_id, work, request_id=request_id)
@router.post("/start", status_code=202) @router.post("/start", status_code=202)
async def start_oauth( async def start_oauth(
request: Request, request: Request,
@@ -125,7 +179,7 @@ async def start_oauth(
job_repo: JobRepoDep, job_repo: JobRepoDep,
session: SessionDep, session: SessionDep,
session_factory: SessionFactoryDep, session_factory: SessionFactoryDep,
http: Annotated[AsyncHttpClient, Depends(_default_http_client)], http: Annotated[AsyncHttpClient, Depends(_http_client)],
) -> OAuthStartResponse: ) -> OAuthStartResponse:
"""发起 device authorization返回 202 + user_code/verification_uri后台轮询 token。 """发起 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) job = await job_repo.create(None, _JOB_KIND_KIMI_OAUTH)
await session.commit() # job 行需在 202 前持久化(供前端立即轮询)。 await session.commit() # job 行需在 202 前持久化(供前端立即轮询)。
# 轮询移出 DB 会话CR-H1后台先在会话外轮询 token仅最终持久化开一个短会话。
background_tasks.add_task( background_tasks.add_task(
run_job, _run_kimi_oauth_poll,
session_factory, session_factory,
job.id, job.id,
_make_poll_work(device), device,
request_id=request_id, request_id=request_id,
) )

View File

@@ -158,9 +158,7 @@ async def get_outline(
if project is None: if project is None:
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}") raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
views = await outline_repo.list_for_project(project_id) views = await outline_repo.list_for_project(project_id, volume=volume)
if volume is not None:
views = [v for v in views if v.volume == volume]
chapters = [ chapters = [
OutlineChapterView( OutlineChapterView(
no=view.chapter_no, no=view.chapter_no,

View File

@@ -20,6 +20,7 @@ from typing import Annotated, Any
from fastapi import APIRouter, BackgroundTasks, Depends, Request from fastapi import APIRouter, BackgroundTasks, Depends, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession 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.chapter_repo import ChapterRepo
from ww_core.domain.digest_repo import DigestAppendRepo from ww_core.domain.digest_repo import DigestAppendRepo
from ww_core.domain.injection_repo import EntityRef, InjectionOverride, InjectionOverrideRepo from ww_core.domain.injection_repo import EntityRef, InjectionOverride, InjectionOverrideRepo
@@ -36,13 +37,16 @@ from ww_core.orchestrator import (
error_event, error_event,
normalize_deltas, normalize_deltas,
normalize_review, normalize_review,
run_clarify,
stream_chapter_draft, stream_chapter_draft,
stream_chapter_rewrite,
) )
from ww_db import get_session from ww_db import get_session
from ww_llm_gateway import Gateway from ww_llm_gateway import Gateway
from ww_shared import AppError, ErrorCode, ErrorEnvelope from ww_shared import AppError, ErrorCode, ErrorEnvelope
from ww_api.logging_config import get_logger from ww_api.logging_config import get_logger
from ww_api.pagination import PageDep
from ww_api.schemas.injection import ( from ww_api.schemas.injection import (
InjectionEntity, InjectionEntity,
InjectionEntityRef, InjectionEntityRef,
@@ -63,6 +67,8 @@ from ww_api.schemas.projects import (
ReviewHistoryItem, ReviewHistoryItem,
ReviewHistoryResponse, ReviewHistoryResponse,
ReviewRequest, ReviewRequest,
RewriteClarifyRequest,
RewriteStreamRequest,
) )
from ww_api.services.accept_service import ( from ww_api.services.accept_service import (
AcceptOutcome, AcceptOutcome,
@@ -74,6 +80,7 @@ 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.foreshadow_scan import SessionFactory, run_overdue_scan
from ww_api.services.project_deps import ( from ww_api.services.project_deps import (
get_chapter_repo, get_chapter_repo,
get_clarify_gateway,
get_digest_append_repo, get_digest_append_repo,
get_digest_gateway, get_digest_gateway,
get_injection_repo, get_injection_repo,
@@ -98,12 +105,20 @@ _ACCEPT_ERRORS: dict[int | str, dict[str, Any]] = {
409: {"model": ErrorEnvelope, "description": "存在未裁决冲突"}, 409: {"model": ErrorEnvelope, "description": "存在未裁决冲突"},
503: {"model": ErrorEnvelope, "description": "LLM 不可用"}, 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)] ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
ChapterRepoDep = Annotated[ChapterRepo, Depends(get_chapter_repo)] ChapterRepoDep = Annotated[ChapterRepo, Depends(get_chapter_repo)]
GatewayDep = Annotated[Gateway, Depends(get_writer_gateway)] GatewayDep = Annotated[Gateway, Depends(get_writer_gateway)]
ReviewGatewayDep = Annotated[Gateway, Depends(get_review_gateway)] ReviewGatewayDep = Annotated[Gateway, Depends(get_review_gateway)]
DigestGatewayDep = Annotated[Gateway, Depends(get_digest_gateway)] DigestGatewayDep = Annotated[Gateway, Depends(get_digest_gateway)]
ClarifyGatewayDep = Annotated[Gateway, Depends(get_clarify_gateway)]
MemoryReposDep = Annotated[MemoryRepos, Depends(get_memory_repos)] MemoryReposDep = Annotated[MemoryRepos, Depends(get_memory_repos)]
InjectionRepoDep = Annotated[InjectionOverrideRepo, Depends(get_injection_repo)] InjectionRepoDep = Annotated[InjectionOverrideRepo, Depends(get_injection_repo)]
ReviewRepoDep = Annotated[ReviewRepo, Depends(get_review_repo)] ReviewRepoDep = Annotated[ReviewRepo, Depends(get_review_repo)]
@@ -137,8 +152,9 @@ async def create_project(body: ProjectCreateRequest, repo: ProjectRepoDep) -> Pr
@router.get("") @router.get("")
async def list_projects(repo: ProjectRepoDep) -> ProjectListResponse: async def list_projects(repo: ProjectRepoDep, page: PageDep) -> ProjectListResponse:
views = await repo.list_for_owner(STUB_OWNER_ID) """作品列表(按 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]) return ProjectListResponse(projects=[_to_response(v) for v in views])
@@ -247,7 +263,7 @@ def _encode_sse(event: SseEvent) -> str:
return f"event: {event.event}\ndata: {payload}\n\n" 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( async def stream_draft(
project_id: uuid.UUID, project_id: uuid.UUID,
chapter_no: int, chapter_no: int,
@@ -310,6 +326,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新版停前端/草稿,接受才落库(不变量 #3rewrite 是工具非写节点,不破坏章
纯函数性(不变量 #7。项目不存在 → 404触网关前 fail-fast同 draftQA 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无凭据 → 503dep 解析阶段拦下)。
**只读不写业务表**(不变量 #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, # 不变量 #2analyst 档,不传 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") @router.put("/{project_id}/chapters/{chapter_no}/draft")
async def save_draft( async def save_draft(
project_id: uuid.UUID, project_id: uuid.UUID,
@@ -391,7 +539,7 @@ async def _resolve_review_draft(
return saved.content 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( async def review_chapter(
project_id: uuid.UUID, project_id: uuid.UUID,
chapter_no: int, chapter_no: int,

View File

@@ -22,10 +22,10 @@ from typing import Annotated, Any
from fastapi import APIRouter, BackgroundTasks, Depends, Request, Response from fastapi import APIRouter, BackgroundTasks, Depends, Request, Response
from sqlalchemy.ext.asyncio import AsyncSession 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 import JobRepo, ProjectRepo, StyleFingerprintWriteRepo
from ww_core.domain.style_repo import SqlStyleFingerprintWriteRepo 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_db import get_session
from ww_llm_gateway import Gateway from ww_llm_gateway import Gateway
from ww_llm_gateway.types import Block, LlmRequest, Scope 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.logging_config import get_logger
from ww_api.schemas.style import ( from ww_api.schemas.style import (
DimensionEntry, DimensionEntry,
RefineClarifyRequest,
RefineRequest, RefineRequest,
RefineResponse, RefineResponse,
StyleFingerprintResponse, 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.job_runner import run_job
from ww_api.services.project_deps import ( from ww_api.services.project_deps import (
build_gateway_for_tier, build_gateway_for_tier,
get_clarify_gateway,
get_job_repo, get_job_repo,
get_project_repo, get_project_repo,
get_refine_gateway, get_refine_gateway,
@@ -62,6 +64,7 @@ JobRepoDep = Annotated[JobRepo, Depends(get_job_repo)]
StyleWriteRepoDep = Annotated[StyleFingerprintWriteRepo, Depends(get_style_write_repo)] StyleWriteRepoDep = Annotated[StyleFingerprintWriteRepo, Depends(get_style_write_repo)]
StyleExtractGatewayDep = Annotated[Gateway, Depends(get_style_extract_gateway)] StyleExtractGatewayDep = Annotated[Gateway, Depends(get_style_extract_gateway)]
RefineGatewayDep = Annotated[Gateway, Depends(get_refine_gateway)] RefineGatewayDep = Annotated[Gateway, Depends(get_refine_gateway)]
ClarifyGatewayDep = Annotated[Gateway, Depends(get_clarify_gateway)]
SessionDep = Annotated[AsyncSession, Depends(get_session)] SessionDep = Annotated[AsyncSession, Depends(get_session)]
SessionFactoryDep = Annotated[SessionFactory, Depends(get_session_factory)] SessionFactoryDep = Annotated[SessionFactory, Depends(get_session_factory)]
@@ -238,3 +241,59 @@ async def refine_segment(
has_instruction=body.instruction is not None, has_instruction=body.instruction is not None,
) )
return RefineResponse(original=body.segment, refined=resp.text) 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无凭据 → 503dep 解析阶段拦下)。**只读不写库**(不变量 #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, # 不变量 #2analyst 档,不传 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

View File

@@ -23,6 +23,7 @@ from ww_db import get_session
from ww_shared import AppError, ErrorCode from ww_shared import AppError, ErrorCode
from ww_api.logging_config import get_logger 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.schemas.templates import TemplateCreateRequest, TemplateResponse
from ww_api.services.credentials import STUB_OWNER_ID from ww_api.services.credentials import STUB_OWNER_ID
from ww_api.services.project_deps import get_template_repo from ww_api.services.project_deps import get_template_repo
@@ -36,9 +37,9 @@ SessionDep = Annotated[AsyncSession, Depends(get_session)]
@router.get("") @router.get("")
async def list_templates(repo: TemplateRepoDep) -> list[TemplateResponse]: async def list_templates(repo: TemplateRepoDep, page: PageDep) -> list[TemplateResponse]:
"""列出当前用户stub的模板。""" """列出当前用户stub的模板(分页;`?limit=&offset=`,默认第一页)"""
views = await repo.list_for_owner(STUB_OWNER_ID) views = await repo.list_for_owner(STUB_OWNER_ID, limit=page.limit, offset=page.offset)
return [ return [
TemplateResponse( TemplateResponse(
id=v.id, title=v.title, body=v.body, category=v.category, tool_key=v.tool_key id=v.id, title=v.title, body=v.body, category=v.category, tool_key=v.tool_key

View File

@@ -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.project_repo import ProjectRepo
from ww_core.domain.repositories import MemoryRepos, OutlineRepo from ww_core.domain.repositories import MemoryRepos, OutlineRepo
from ww_core.domain.rule_repo import RuleWriteRepo 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_core.orchestrator import run_generator
from ww_db import get_session from ww_db import get_session
from ww_llm_gateway import Gateway from ww_llm_gateway import Gateway
@@ -427,12 +427,12 @@ async def _ingest_world_entities(
rejected_tables=rejected, rejected_tables=rejected,
) )
created: list[str] = [] fields = [
for entity in allowed.get("world_entities", []): WorldEntityFields(type=entity.type, name=entity.name, rules=list(entity.rules))
view = await world_write_repo.create( for entity in allowed.get("world_entities", [])
project_id, type=entity.type, name=entity.name, rules=list(entity.rules) ]
) # 批量插入(一次 flush 落全部),保序返回逐条 View。
created.append(view.name) created = [view.name for view in await world_write_repo.create_many(project_id, fields)]
await session.commit() await session.commit()
log.info( log.info(

View File

@@ -11,12 +11,18 @@ from typing import Any
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
# 请求字符串字段长度上界CR-H9防超长入参 DoS/成本)。代号=短标识;标题=一句话。
_CODE_MAX = 100
_TITLE_MAX = 500
class ForeshadowRegisterRequest(BaseModel): class ForeshadowRegisterRequest(BaseModel):
"""POST /projects/:id/foreshadow作者显式登记一条伏笔status 落 OPEN""" """POST /projects/:id/foreshadow作者显式登记一条伏笔status 落 OPEN"""
code: str = Field(min_length=1, description="伏笔代号,`(project_id, code)` 唯一") code: str = Field(
title: str = Field(min_length=1, description="伏笔标题/一句话描述") 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="埋设章号") planted_at: int | None = Field(default=None, description="埋设章号")
content: str | None = Field(default=None, description="伏笔正文/线索") content: str | None = Field(default=None, description="伏笔正文/线索")
expected_close_from: int | None = Field(default=None, description="预期回收窗口起始章") expected_close_from: int | None = Field(default=None, description="预期回收窗口起始章")

View File

@@ -19,6 +19,9 @@ from ww_api.schemas.rules import RuleView
# 批量生成数量上限(防一次性巨量调用;原型保守值)。 # 批量生成数量上限(防一次性巨量调用;原型保守值)。
MAX_GENERATE_COUNT = 12 MAX_GENERATE_COUNT = 12
# 一句话需求文本上界CR-H9防超长入参撑爆上下文/成本)。
_BRIEF_MAX = 10_000
# ---- 世界观生成(预览)---- # ---- 世界观生成(预览)----
@@ -26,7 +29,9 @@ MAX_GENERATE_COUNT = 12
class WorldGenerateRequest(BaseModel): class WorldGenerateRequest(BaseModel):
"""POST /projects/:id/world/generate据需求生成世界观实体预览""" """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): class WorldEntityCardView(BaseModel):
@@ -54,7 +59,7 @@ class CharacterGenerateRequest(BaseModel):
单一 `role` + `count` 语义。 单一 `role` + `count` 语义。
""" """
brief: str = Field(min_length=1, description="角色生成需求") brief: str = Field(min_length=1, max_length=_BRIEF_MAX, description="角色生成需求")
count: int = Field( count: int = Field(
default=1, default=1,
ge=1, ge=1,

View File

@@ -11,11 +11,16 @@ from typing import Any, Literal
from pydantic import BaseModel, Field 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): class ProjectCreateRequest(BaseModel):
"""POST /projects立项向导字段owner_id 由后端补 stub不入参""" """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 genre: str | None = None
logline: str | None = None logline: str | None = None
premise: str | None = None premise: str | None = None
@@ -117,13 +122,44 @@ class DraftStreamRequest(BaseModel):
后端只入 volatile守缓存前缀不变量 #9。无 body 时退化为纯按大纲生成(向后兼容)。 后端只入 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): class DraftSaveRequest(BaseModel):
"""PUT /projects/:id/chapters/:no/draft自动保存草稿正文。""" """PUT /projects/:id/chapters/:no/draft自动保存草稿正文。"""
text: str text: str = Field(max_length=_CHAPTER_TEXT_MAX)
class DraftResponse(BaseModel): class DraftResponse(BaseModel):
@@ -162,7 +198,7 @@ class ReviewRequest(BaseModel):
不传 `draft` 时端点回退到已保存的草稿chapter_repo 不传 `draft` 时端点回退到已保存的草稿chapter_repo
""" """
draft: str | None = None draft: str | None = Field(default=None, max_length=_CHAPTER_TEXT_MAX)
class ReviewConflictView(BaseModel): class ReviewConflictView(BaseModel):
@@ -220,7 +256,9 @@ class ConflictDecision(BaseModel):
class AcceptRequest(BaseModel): class AcceptRequest(BaseModel):
"""POST /projects/:id/chapters/:no/accept裁决清单 + 可能改过的终稿。""" """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( decisions: list[ConflictDecision] = Field(
default_factory=list, description="对最近审稿每个冲突的裁决每冲突必有其一R5" default_factory=list, description="对最近审稿每个冲突的裁决每冲突必有其一R5"
) )

View File

@@ -8,6 +8,11 @@ from __future__ import annotations
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from ww_llm_gateway.types import Tier from ww_llm_gateway.types import Tier
# 请求字符串字段长度上界CR-H9防超长入参 DoS/成本)。
_PROVIDER_MAX = 64
_MODEL_MAX = 128
_API_KEY_MAX = 512
class ProviderView(BaseModel): class ProviderView(BaseModel):
"""已配置提供商(掩码视图)。""" """已配置提供商(掩码视图)。"""
@@ -35,8 +40,8 @@ class ProvidersResponse(BaseModel):
class ProviderCredentialInput(BaseModel): class ProviderCredentialInput(BaseModel):
"""单条提供商凭据写入。""" """单条提供商凭据写入。"""
provider: str = Field(min_length=1) provider: str = Field(min_length=1, max_length=_PROVIDER_MAX)
api_key: str = Field(min_length=1) # 明文入站,加密入库,绝不回显 api_key: str = Field(min_length=1, max_length=_API_KEY_MAX) # 明文入站,加密入库,绝不回显
class TierRoutingInput(BaseModel): class TierRoutingInput(BaseModel):
@@ -44,8 +49,8 @@ class TierRoutingInput(BaseModel):
# tier 限定已知档位 writer/analyst/light未知档位 → 422QA MEDIUM原接受任意字符串 # tier 限定已知档位 writer/analyst/light未知档位 → 422QA MEDIUM原接受任意字符串
tier: Tier tier: Tier
provider: str = Field(min_length=1) provider: str = Field(min_length=1, max_length=_PROVIDER_MAX)
model: str = Field(min_length=1) model: str = Field(min_length=1, max_length=_MODEL_MAX)
fallback: list[str] = Field(default_factory=list) fallback: list[str] = Field(default_factory=list)
@@ -59,7 +64,7 @@ class ProvidersUpsertRequest(BaseModel):
class TestConnectionRequest(BaseModel): class TestConnectionRequest(BaseModel):
"""POST /test最小探测请求。""" """POST /test最小探测请求。"""
provider: str = Field(min_length=1) provider: str = Field(min_length=1, max_length=_PROVIDER_MAX)
class CapabilitiesView(BaseModel): class CapabilitiesView(BaseModel):

View File

@@ -14,8 +14,13 @@ from pydantic import BaseModel, Field, StringConstraints
RuleLevel = Literal["global", "genre", "style", "project"] RuleLevel = Literal["global", "genre", "style", "project"]
# 规则正文长度上界CR-H9防超长入参 DoS/成本)。
_RULE_CONTENT_MAX = 10_000
# 先 strip 再校验长度:纯空白内容(" "strip 后为空 → 422QA MEDIUM此前被接受入库 # 先 strip 再校验长度:纯空白内容(" "strip 后为空 → 422QA 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): class RuleCreateRequest(BaseModel):

View File

@@ -7,15 +7,26 @@ snake_case命名契约见 memory/gotchas。前端经 OpenAPI→TS 客
from __future__ import annotations from __future__ import annotations
import uuid 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): 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" mode: Literal["create", "update"] = "create"
@@ -47,8 +58,9 @@ class StyleFingerprintResponse(BaseModel):
class RefineRequest(BaseModel): class RefineRequest(BaseModel):
"""回炉:重写选中段(可选改写指令)。""" """回炉:重写选中段(可选改写指令)。"""
segment: str = Field(min_length=1) segment: str = Field(min_length=1, max_length=_SEGMENT_MAX)
instruction: str | None = None # CR-H9 收尾:再沟通意见喂 LLM加上界与 clarify instruction 同口径 2000
instruction: str | None = Field(default=None, max_length=_MAX_CLARIFY_INSTRUCTION_LEN)
class RefineResponse(BaseModel): class RefineResponse(BaseModel):
@@ -56,3 +68,15 @@ class RefineResponse(BaseModel):
original: str original: str
refined: 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)

View File

@@ -12,15 +12,24 @@ from typing import Annotated
from pydantic import BaseModel, Field, StringConstraints from pydantic import BaseModel, Field, StringConstraints
# 长度上界CR-H9防超长入参 DoS/成本)。标题=标签级;正文=段落级。
_TITLE_MAX = 200
_BODY_MAX = 20_000
# 先去首尾空白再校验长度:纯空白(" "strip 后为空 → min_length=1 不满足 → 422。 # 先去首尾空白再校验长度:纯空白(" "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): class TemplateCreateRequest(BaseModel):
"""POST /templates新建一条提示词模板。""" """POST /templates新建一条提示词模板。"""
title: NonBlankStr = Field(description="模板标题(非空,纯空白 → 422") title: NonBlankTitle = Field(description="模板标题(非空,纯空白 → 422")
body: NonBlankStr = Field(description="模板正文(非空,一键填入生成器的 brief/text") body: NonBlankBody = Field(description="模板正文(非空,一键填入生成器的 brief/text")
category: str | None = Field(default=None, description="可选分类") category: str | None = Field(default=None, description="可选分类")
tool_key: str | None = Field(default=None, description="可选关联生成器NULL=通用)") tool_key: str | None = Field(default=None, description="可选关联生成器NULL=通用)")

View File

@@ -50,6 +50,11 @@ log = structlog.get_logger(__name__)
# 链 job 的 kindjobs.kind 自由 Text 列零迁移复用§7 # 链 job 的 kindjobs.kind 自由 Text 列零迁移复用§7
JOB_KIND_CHAIN = "chain" 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 档网关跑终稿提炼。 # 「按 session 建 digest 网关」缝accept 节点在自建短事务里需 light 档网关跑终稿提炼。
DigestGatewayBuilder = Callable[[Any], Awaitable[Gateway]] 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: def _extract_awaiting_chapter(interrupts: Sequence[Any]) -> int | None:
"""图返回值判 interrupt有 `__interrupt__` → 取暂停章号;否则 None跑完 """ pending interrupts 判暂停章号:空 → None跑完否则取首个 interrupt 载荷的章号
interrupt 载荷 = `{"chapter_no": n}`(见链图 `_accept` 节点。LangGraph 把它放在 interrupts 经**公开** `graph.aget_state(config).interrupts``StateSnapshot`)拿到,不再
`final["__interrupt__"]`Interrupt 对象列表)。容错读 `.value`/dict 两种形。 反手 ainvoke 返回值里的私有 `__interrupt__` 键。载荷 = `{"chapter_no": n}`(见链图 `_accept`
节点)。容错读 `.value`/dict 两种形。
""" """
interrupts = final.get("__interrupt__")
if not interrupts: if not interrupts:
return None return None
first = interrupts[0] first = interrupts[0]
@@ -207,7 +212,14 @@ async def run_chain_job(
try: try:
async with checkpointer_ctx() as checkpointer: async with checkpointer_ctx() as checkpointer:
await _set_running(session_factory, job_id) await _set_running(session_factory, job_id)
config: RunnableConfig = {"configurable": {"thread_id": str(job_id)}} # 显式每链 recursion_limit 上界防御硬化CR-C2count 已被端点上限 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( graph = build_chain_graph(
chain_gateway_builder, chain_gateway_builder,
session_factory=session_factory, session_factory=session_factory,
@@ -232,9 +244,11 @@ async def run_chain_job(
resume_value = [d.model_dump() for d in resume_decisions] resume_value = [d.model_dump() for d in resume_decisions]
raw_final = await graph.ainvoke(Command(resume=resume_value), config=config) raw_final = await graph.ainvoke(Command(resume=resume_value), config=config)
final: dict[str, Any] = dict(raw_final) # 经公开 `StateSnapshot.interrupts` 判 pending interrupt不反手私有 `__interrupt__`
awaiting_chapter = _extract_awaiting_chapter(final) # checkpointer 仍在 `checkpointer_ctx()` 上下文内aget_state 可读同一 thread 检查点。
result = _chain_result(final, awaiting_chapter=awaiting_chapter) 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) await _finish(session_factory, job_id, result, awaiting=awaiting_chapter is not None)
log.info( log.info(
"chain_job_settled", "chain_job_settled",

View File

@@ -519,6 +519,18 @@ async def get_refine_gateway(
return await build_gateway_for_tier(session, store, "writer") 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( async def get_chain_gateway(
session: Annotated[AsyncSession, Depends(get_session)], session: Annotated[AsyncSession, Depends(get_session)],
) -> Gateway: ) -> Gateway:

View File

@@ -14,6 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from ww_config import get_settings from ww_config import get_settings
from ww_db import get_session from ww_db import get_session
from ww_llm_gateway.adapters.base import Capabilities 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_llm_gateway.factory import build_adapter
from ww_shared import AppError, ErrorCode from ww_shared import AppError, ErrorCode
@@ -85,10 +86,17 @@ class GatewayProviderProbe:
# 经网关工厂构造适配器base_url 单点归网关 build_adapter不在此本地构造 AsyncOpenAI # 经网关工厂构造适配器base_url 单点归网关 build_adapter不在此本地构造 AsyncOpenAI
adapter = build_adapter(provider, api_key=api_key, base_url=base_url) 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: try:
# 最小探测:列模型即可验证 Key 有效(不消耗生成额度)。底层 AsyncOpenAI 由适配器持有 # 最小探测:列模型即可验证 Key 有效(不消耗生成额度)。
# (已知 provider 为 OpenAI 兼容,见 _PROVIDER_BASE_URLS读私有客户端属同仓既有约定。 await adapter.probe_connection()
await adapter._client.models.list() # type: ignore[attr-defined] # noqa: SLF001
except Exception as exc: # noqa: BLE001 — 任一失败都映射为 LLM 不可用 except Exception as exc: # noqa: BLE001 — 任一失败都映射为 LLM 不可用
raise AppError( raise AppError(
ErrorCode.LLM_UNAVAILABLE, ErrorCode.LLM_UNAVAILABLE,

View File

@@ -1,5 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { BookOpen, Plus } from "lucide-react"; import { BookOpen, PenLine, Plus } from "lucide-react";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { BackendDownNotice } from "@/components/BackendDownNotice"; import { BackendDownNotice } from "@/components/BackendDownNotice";
@@ -28,13 +28,22 @@ export default async function DashboardPage() {
title="我的作品" title="我的作品"
description="从一个灵感进入正文、设定、审稿与验收闭环。" description="从一个灵感进入正文、设定、审稿与验收闭环。"
actions={ actions={
<Link <div className="flex flex-wrap items-center gap-2">
href="/projects/new" <Link
className={buttonClass({ variant: "primary" })} 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>
<Link
href="/projects/new"
className={buttonClass({ variant: "secondary" })}
>
<Plus className="h-4 w-4" aria-hidden="true" />
</Link>
</div>
} }
/> />
@@ -44,15 +53,24 @@ export default async function DashboardPage() {
<EmptyState <EmptyState
icon={BookOpen} icon={BookOpen}
title="还没有作品" title="还没有作品"
description="从一句灵感开始,后续的设定、大纲、写章和审稿都会围绕这本书展开。" description="直接开始写,落笔即成书;或先立项,从一句灵感搭好设定、大纲再动笔。"
action={ action={
<Link <div className="flex flex-wrap items-center justify-center gap-2">
href="/projects/new" <Link
className={buttonClass({ variant: "primary" })} 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>
<Link
href="/projects/new"
className={buttonClass({ variant: "secondary" })}
>
<Plus className="h-4 w-4" aria-hidden="true" />
</Link>
</div>
} }
/> />
) : ( ) : (

View File

@@ -0,0 +1,6 @@
import { DraftComposer } from "@/components/start/DraftComposer";
// 进来即写入口(无 project 前缀):直达空白编辑器,敲字后懒建项目并换入 /projects/[id]/write。
export default function WriteDraftPage() {
return <DraftComposer />;
}

View File

@@ -5,6 +5,7 @@ import { X } from "lucide-react";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { handleTabTrap } from "@/lib/a11y/focusTrap"; import { handleTabTrap } from "@/lib/a11y/focusTrap";
import { useRestoreFocus } from "@/lib/a11y/useRestoreFocus";
import { useBodyScrollLock } from "@/lib/ui/useBodyScrollLock"; import { useBodyScrollLock } from "@/lib/ui/useBodyScrollLock";
import { overlayScrim } from "@/lib/ui/variants"; import { overlayScrim } from "@/lib/ui/variants";
@@ -15,6 +16,8 @@ interface DrawerProps {
side?: "left" | "right"; side?: "left" | "right";
// 无障碍标签role=dialog // 无障碍标签role=dialog
label: string; label: string;
// 抽屉面板 id供触发按钮 aria-controls 指向(无障碍关联)。
id?: string;
triggerRef?: RefObject<HTMLElement | null>; triggerRef?: RefObject<HTMLElement | null>;
children: ReactNode; children: ReactNode;
} }
@@ -26,24 +29,18 @@ export function Drawer({
onClose, onClose,
side = "left", side = "left",
label, label,
id,
triggerRef, triggerRef,
children, children,
}: DrawerProps) { }: DrawerProps) {
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
const closeRef = useRef<HTMLButtonElement>(null); const closeRef = useRef<HTMLButtonElement>(null);
const wasOpenRef = useRef(false);
useBodyScrollLock(open); useBodyScrollLock(open);
useRestoreFocus(open, triggerRef);
useEffect(() => { useEffect(() => {
if (!open) { if (!open) return;
if (wasOpenRef.current) {
wasOpenRef.current = false;
triggerRef?.current?.focus();
}
return;
}
wasOpenRef.current = true;
const onKey = (e: KeyboardEvent): void => { const onKey = (e: KeyboardEvent): void => {
if (e.key === "Escape") { if (e.key === "Escape") {
e.preventDefault(); e.preventDefault();
@@ -54,7 +51,7 @@ export function Drawer({
// 焦点陷阱首焦点落在具名关闭按钮上(而非整个面板)。 // 焦点陷阱首焦点落在具名关闭按钮上(而非整个面板)。
closeRef.current?.focus(); closeRef.current?.focus();
return () => window.removeEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey);
}, [open, onClose, triggerRef]); }, [open, onClose]);
if (!open) return null; if (!open) return null;
@@ -64,6 +61,7 @@ export function Drawer({
<div className={`${overlayScrim} lg:hidden`} onClick={onClose}> <div className={`${overlayScrim} lg:hidden`} onClick={onClose}>
<div <div
ref={panelRef} ref={panelRef}
id={id}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={label} aria-label={label}

View File

@@ -15,6 +15,9 @@ interface NavDrawerProps {
activeNav?: ActiveNav; activeNav?: ActiveNav;
} }
// 抽屉面板 id供汉堡按钮 aria-controls 关联(无障碍)。
const DRAWER_ID = "nav-drawer";
// 移动端导航:汉堡按钮 + 左侧抽屉(仅 <lg桌面用 LeftNav 静态侧栏。UX §5.1。 // 移动端导航:汉堡按钮 + 左侧抽屉(仅 <lg桌面用 LeftNav 静态侧栏。UX §5.1。
export function NavDrawer({ projectId, activeNav }: NavDrawerProps) { export function NavDrawer({ projectId, activeNav }: NavDrawerProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -32,6 +35,7 @@ export function NavDrawer({ projectId, activeNav }: NavDrawerProps) {
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
aria-label="打开导航" aria-label="打开导航"
aria-expanded={open} aria-expanded={open}
aria-controls={DRAWER_ID}
variant="ghost" variant="ghost"
size="icon" size="icon"
className="-ml-2 lg:hidden" className="-ml-2 lg:hidden"
@@ -43,6 +47,7 @@ export function NavDrawer({ projectId, activeNav }: NavDrawerProps) {
onClose={() => setOpen(false)} onClose={() => setOpen(false)}
side="left" side="left"
label="主导航" label="主导航"
id={DRAWER_ID}
triggerRef={triggerRef} triggerRef={triggerRef}
> >
<div className="px-2 pb-2"> <div className="px-2 pb-2">

View File

@@ -58,10 +58,23 @@ export function ForeshadowCard({
<dl className="mt-2 space-y-0.5 text-xs text-ink-soft"> <dl className="mt-2 space-y-0.5 text-xs text-ink-soft">
{typeof item.planted_at === "number" ? ( {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} ) : null}
{windowText ? <div> {windowText}</div> : null}
{item.importance ? <div>{item.importance}</div> : null}
</dl> </dl>
{item.progress && item.progress.length > 0 ? ( {item.progress && item.progress.length > 0 ? (

View File

@@ -66,6 +66,9 @@ import { ReviewSummaryRail } from "./ReviewSummaryRail";
import { StylePanel } from "@/components/style/StylePanel"; import { StylePanel } from "@/components/style/StylePanel";
import { RefineView } from "@/components/style/RefineView"; import { RefineView } from "@/components/style/RefineView";
// 命中区闪烁高亮持续时长(朱砂脉冲)。
const FLASH_DURATION_MS = 900;
interface ReviewReportProps { interface ReviewReportProps {
project: ProjectResponse; project: ProjectResponse;
chapterNo: number; chapterNo: number;
@@ -112,6 +115,16 @@ export function ReviewReport({
null, null,
); );
const seededRef = useRef(false); const seededRef = useRef(false);
// flashMark 的收尾定时器句柄:组件卸载或重复触发时清除,避免定时器泄漏。
const flashTimerRef = useRef<number | null>(null);
useEffect(
() => () => {
if (flashTimerRef.current !== null) {
window.clearTimeout(flashTimerRef.current);
}
},
[],
);
// 进页一次性把历史留痕(冲突 + 伏笔建议 + 节奏)种入流状态(无需重审即可裁决/查看)。 // 进页一次性把历史留痕(冲突 + 伏笔建议 + 节奏)种入流状态(无需重审即可裁决/查看)。
const seededConflicts = useMemo( const seededConflicts = useMemo(
@@ -333,7 +346,13 @@ export function ReviewReport({
const mark = document.getElementById(`draft-mark-${index}`); const mark = document.getElementById(`draft-mark-${index}`);
if (!mark) return; if (!mark) return;
mark.classList.add("draft-mark-flash"); 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);
}; };
// 跳转/锚点:在可编辑正文里定位、选中并滚动到该冲突对应区域 + 闪烁;定位不到则提示手动查找。 // 跳转/锚点:在可编辑正文里定位、选中并滚动到该冲突对应区域 + 闪烁;定位不到则提示手动查找。

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

View File

@@ -28,15 +28,17 @@ export function RefineView({
onClose, onClose,
}: RefineViewProps) { }: RefineViewProps) {
const refiner = useRefine(); const refiner = useRefine();
const { refine, abort } = refiner;
// 进入即触发回炉(段非空)。 // 进入即触发回炉(段非空)清理时取消在途请求避免旧段迟到响应覆盖新段CR-H12
useEffect(() => { useEffect(() => {
if (segment.trim().length > 0) { if (segment.trim().length > 0) {
void refiner.refine(projectId, chapterNo, segment); void refine(projectId, chapterNo, segment);
} }
// 仅在段变化时触发。 return () => {
// eslint-disable-next-line react-hooks/exhaustive-deps abort();
}, [segment, projectId, chapterNo]); };
}, [segment, projectId, chapterNo, refine, abort]);
return ( return (
<div <div

View File

@@ -23,6 +23,7 @@ import {
type PreviewItem, type PreviewItem,
} from "@/lib/toolbox/toolbox"; } from "@/lib/toolbox/toolbox";
import { ingestTable, isSingleObjectIngest } from "@/lib/toolbox/ingest"; import { ingestTable, isSingleObjectIngest } from "@/lib/toolbox/ingest";
import { buildManuscriptExcerpt } from "@/lib/workbench/manuscriptExcerpt";
import { useGenerator } from "@/lib/toolbox/useGenerator"; import { useGenerator } from "@/lib/toolbox/useGenerator";
import { TemplateFiller } from "./TemplateFiller"; import { TemplateFiller } from "./TemplateFiller";
@@ -30,6 +31,11 @@ interface GeneratorRunnerProps {
projectId: string; projectId: string;
tool: ToolDescriptorView; tool: ToolDescriptorView;
onClose: () => void; onClose: () => void;
// 可选:把某条预览文本插入正文(编辑器内联工具箱场景)。工具箱页不传 → 不显示「插入正文」。
onInsertText?: (text: string) => void;
// 可选:作者【已写正文】。传入且当前工具有 brief 字段时,表单里出现「用当前正文」按钮,
// 点击把正文摘录填进 brief让内容感知型生成器如书名据正文反推。工具箱页不传 → 不显示。
manuscriptText?: string;
} }
// 声明驱动的通用生成器(核心):按 descriptor.input_fields 渲染表单 → 调通用 generate → // 声明驱动的通用生成器(核心):按 descriptor.input_fields 渲染表单 → 调通用 generate →
@@ -39,6 +45,8 @@ export function GeneratorRunner({
projectId, projectId,
tool, tool,
onClose, onClose,
onInsertText,
manuscriptText,
}: GeneratorRunnerProps) { }: GeneratorRunnerProps) {
const gen = useGenerator(); const gen = useGenerator();
const toast = useToast(); const toast = useToast();
@@ -64,6 +72,26 @@ export function GeneratorRunner({
[tool.input_fields, fillTarget], [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 onGenerate = useCallback(async (): Promise<void> => {
const missing = missingRequiredFields(tool.input_fields, values); const missing = missingRequiredFields(tool.input_fields, values);
if (missing.length > 0) { if (missing.length > 0) {
@@ -163,6 +191,22 @@ export function GeneratorRunner({
targetLabel={fillLabel} targetLabel={fillLabel}
onFill={setField} 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) => ( {(tool.input_fields ?? []).map((field) => (
<FormField <FormField
key={field.name} key={field.name}
@@ -202,6 +246,11 @@ export function GeneratorRunner({
selectable={canIngest && !singleObject} selectable={canIngest && !singleObject}
checked={selected.has(i)} checked={selected.has(i)}
onToggle={toggle} onToggle={toggle}
onInsert={
onInsertText
? () => onInsertText(item.body ?? item.heading ?? "")
: undefined
}
/> />
))} ))}
</ul> </ul>
@@ -276,6 +325,8 @@ interface PreviewRowProps {
selectable: boolean; selectable: boolean;
checked: boolean; checked: boolean;
onToggle: (index: number) => void; onToggle: (index: number) => void;
// 可选:把本条插入正文(编辑器内联工具箱)。有可插入文本时才渲染按钮。
onInsert?: () => void;
} }
// 统一预览行渲染heading + 次要字段 + 正文段),由 mapPreview 归一,跨工具复用。 // 统一预览行渲染heading + 次要字段 + 正文段),由 mapPreview 归一,跨工具复用。
@@ -285,7 +336,9 @@ function PreviewRow({
selectable, selectable,
checked, checked,
onToggle, onToggle,
onInsert,
}: PreviewRowProps) { }: PreviewRowProps) {
const canInsert = onInsert && (item.body || item.heading);
return ( return (
<li className="flex gap-2 rounded border border-line bg-bg p-3 text-sm"> <li className="flex gap-2 rounded border border-line bg-bg p-3 text-sm">
{selectable ? ( {selectable ? (
@@ -310,6 +363,17 @@ function PreviewRow({
{item.body ? ( {item.body ? (
<p className="whitespace-pre-wrap text-ink">{item.body}</p> <p className="whitespace-pre-wrap text-ink">{item.body}</p>
) : null} ) : 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> </div>
</li> </li>
); );

View File

@@ -0,0 +1,194 @@
"use client";
import { useState } from "react";
import { Check, MessagesSquare, RotateCcw, Square, X } from "lucide-react";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { Button } from "@/components/ui/Button";
import { SectionHeader } from "@/components/ui/SectionHeader";
import { StatusNote } from "@/components/ui/StatusNote";
import { TextArea } from "@/components/ui/TextArea";
import { friendlyError } from "@/lib/errors/messages";
import { foldClarifications, needsClarifyGate } from "@/lib/workbench/clarify";
import { useChapterRewrite } from "@/lib/workbench/useChapterRewrite";
import { useRewriteClarify } from "@/lib/workbench/useRewriteClarify";
import { ChoiceChips } from "./ChoiceChips";
// 门控阈值:整章意见 trim 后短于此字数才自动预检反问(清晰意见不交往返税)。
const CLARIFY_MIN_CHARS = 10;
interface ChapterRewritePanelProps {
projectId: string;
chapterNo: number;
// 当前整章正文(首轮重写的 prior_draft
currentDraft: string;
// 接受某一版 → 替换整章正文HITL作者显式点选才落草稿
onAccept: (text: string) => void;
onClose: () => void;
}
// 整章「再沟通 / 重写」结果卡(内联,非模态):作者给意见 → 意见含糊/极短时 AI 先反问给方向选项(预检、只读、
// HITL→ 就着当前整章 + 完整记忆注入流式重写一版 → 版本栈可多轮迭代 → 接受某版才替换整章正文(不变量 #3
export function ChapterRewritePanel({
projectId,
chapterNo,
currentDraft,
onAccept,
onClose,
}: ChapterRewritePanelProps) {
const rewrite = useChapterRewrite();
const clarify = useRewriteClarify();
const [feedback, setFeedback] = useState("");
const [pendingFeedback, setPendingFeedback] = useState<string | null>(null);
const hasVersions = rewrite.versions.length > 0;
const clarifying = clarify.status === "checking";
const canSend =
feedback.trim().length > 0 && !rewrite.isStreaming && !clarifying;
const firstQuestion = clarify.decision?.questions[0] ?? null;
const asking = clarify.status === "asking" && firstQuestion !== null;
// 下一轮以最新版正文为基础(无版本则用当前整章正文)迭代。
const priorDraft = rewrite.latest?.text ?? currentDraft;
const err = rewrite.state.phase === "error" ? rewrite.state.error : null;
// 折入澄清答案(或原意见)后流式重写,并清理澄清态。
const runSend = (fb: string): void => {
clarify.reset();
setPendingFeedback(null);
setFeedback("");
void rewrite.send(projectId, chapterNo, fb, priorDraft);
};
// 「重写整章 / 再改一版」:意见含糊/极短(或 force先预检反问否则直接流式重写。
const onSend = async (force: boolean): Promise<void> => {
const fb = feedback.trim();
if (!fb || rewrite.isStreaming || clarifying) return;
if (force || needsClarifyGate(fb, CLARIFY_MIN_CHARS)) {
const vm = await clarify.check(projectId, chapterNo, fb, priorDraft);
if (vm.needClarification && vm.questions.length > 0) {
setPendingFeedback(fb); // 记住原意见,等作者答完折进去
return;
}
}
runSend(fb);
};
// 作者答复澄清(点选 value 或自由输入)→ 折进意见 → 流式重写。
const onClarifyAnswer = (answer: string): void => {
const question = clarify.decision?.questions[0]?.question ?? "";
const base = pendingFeedback ?? feedback.trim();
runSend(foldClarifications(base, [{ question, answer }]));
};
return (
<div className="max-h-[70vh] overflow-auto border-b border-line bg-panel px-4 py-3 sm:px-6">
<div className="flex items-center justify-between gap-3">
<SectionHeader
title="对整章再沟通 / 重写"
description="给意见 → AI 就着当前整章 + 完整设定重写一版;意见太笼统时会先反问给方向选项,满意再替换正文。"
/>
<Button onClick={onClose} variant="ghost" size="icon" aria-label="关闭整章重写">
<X className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
<div className="mt-3 space-y-2">
<label className="block">
<span className="sr-only"></span>
<TextArea
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
rows={2}
placeholder={
hasVersions
? "对这一版还想怎么改?(如「高潮再拉满」「删掉第 2 段闪回」)"
: "这一章哪里不满意?想怎么改?(意见太笼统时 AI 会先反问、给方向选项)"
}
/>
</label>
{asking && firstQuestion ? (
// role="status"AI 反问动态出现时向读屏播报WCAG 4.1.3 Status Messages
<div role="status" className="rounded border border-cinnabar/40 bg-bg p-3">
<p className="mb-2 text-2xs text-ink-soft">AI </p>
<ChoiceChips
question={firstQuestion.question}
options={firstQuestion.options}
allowFreeText={firstQuestion.allowFreeText}
onPick={onClarifyAnswer}
onFreeText={onClarifyAnswer}
/>
</div>
) : null}
<div className="flex flex-wrap items-center gap-2">
{rewrite.isStreaming ? (
<Button onClick={rewrite.stop} variant="danger" size="sm">
<Square className="h-4 w-4" aria-hidden="true" />
</Button>
) : (
<Button
onClick={() => void onSend(false)}
disabled={!canSend}
variant="primary"
size="sm"
>
<RotateCcw className="h-4 w-4" aria-hidden="true" />
{hasVersions ? "再改一版" : "重写整章"}
</Button>
)}
<Button
onClick={() => void onSend(true)}
disabled={rewrite.isStreaming || clarifying || feedback.trim().length === 0}
variant="ghost"
size="sm"
title="让 AI 先反问、给方向选项再改"
>
<MessagesSquare className="h-4 w-4" aria-hidden="true" />
</Button>
{rewrite.isStreaming ? (
<ThinkingIndicator label="重写中" className="text-xs text-cinnabar" />
) : clarifying ? (
<ThinkingIndicator label="琢磨要不要反问" className="text-xs text-cinnabar" />
) : null}
</div>
{err ? (
<StatusNote variant="danger" className="text-xs" title="整章重写失败">
<p>{friendlyError(err.code, err.message).text}</p>
</StatusNote>
) : null}
</div>
{rewrite.isStreaming && rewrite.state.text ? (
<figure className="mt-3 rounded border border-cinnabar/40 bg-bg p-3">
<figcaption className="mb-1 text-2xs text-ink-soft"></figcaption>
<p className="whitespace-pre-wrap text-sm text-ink">{rewrite.state.text}</p>
</figure>
) : null}
{hasVersions && !rewrite.isStreaming ? (
<ul className="mt-3 space-y-2">
{[...rewrite.versions].reverse().map((version, i) => {
const versionNo = rewrite.versions.length - i;
return (
<li key={versionNo} className="rounded border border-line bg-bg p-3">
<div className="mb-1 flex flex-wrap items-center justify-between gap-2">
<span className="text-2xs text-ink-soft">
{versionNo} · {version.feedback}
</span>
<Button onClick={() => onAccept(version.text)} variant="primary" size="sm">
<Check className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
<p className="max-h-48 overflow-auto whitespace-pre-wrap text-sm text-ink">
{version.text}
</p>
</li>
);
})}
</ul>
) : null}
</div>
);
}

View File

@@ -0,0 +1,106 @@
"use client";
import { useId, useState, type KeyboardEvent } from "react";
import { Button } from "@/components/ui/Button";
import { TextInput } from "@/components/ui/TextInput";
import type { ClarifyOptionVM } from "@/lib/workbench/clarify";
interface ChoiceChipsProps {
// 反问的澄清问题。
question: string;
// 24 个具体选项;为空时只渲染自由输入。
options: ClarifyOptionVM[];
// 是否提供常驻自由输入兜底。
allowFreeText: boolean;
// 点选某选项:回传其 value供上层折进 instruction
onPick: (value: string) => void;
// 提交自由输入文本allowFreeText 且提供此回调时可用)。
onFreeText?: (text: string) => void;
}
// AI 反问澄清的选项芯片组WFW-9 M1渲染一个问题 + 一组选项芯片(复用 Button
// 作者点选一项即高亮并回传 value可选自由输入兜底。纯展示、无副作用、无 API 调用。
export function ChoiceChips({
question,
options,
allowFreeText,
onPick,
onFreeText,
}: ChoiceChipsProps) {
const [picked, setPicked] = useState<string | null>(null);
const [freeText, setFreeText] = useState("");
const inputId = useId();
const hasOptions = options.length > 0;
const canSubmit = Boolean(onFreeText) && freeText.trim().length > 0;
const handlePick = (value: string): void => {
setPicked(value);
onPick(value);
};
const handleFreeSubmit = (): void => {
const trimmed = freeText.trim();
if (!trimmed || !onFreeText) return;
onFreeText(trimmed);
setFreeText("");
};
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
if (event.key !== "Enter") return;
event.preventDefault();
handleFreeSubmit();
};
return (
<div className="space-y-2">
<p className="text-sm text-ink">{question}</p>
{hasOptions ? (
<div className="flex flex-wrap gap-2" role="group" aria-label="澄清选项">
{options.map((option, index) => {
const active = option.value === picked;
return (
<Button
key={`${index}-${option.value}`}
type="button"
aria-pressed={active}
onClick={() => handlePick(option.value)}
variant={active ? "outline" : "secondary"}
size="sm"
>
{option.label}
</Button>
);
})}
</div>
) : null}
{allowFreeText ? (
<div className="flex items-center gap-2">
<label htmlFor={inputId} className="sr-only">
</label>
<TextInput
id={inputId}
value={freeText}
onChange={(event) => setFreeText(event.target.value)}
onKeyDown={handleKeyDown}
controlSize="sm"
placeholder="或直接说你的想法…"
className="flex-1"
/>
<Button
type="button"
onClick={handleFreeSubmit}
disabled={!canSubmit}
variant="secondary"
size="sm"
>
</Button>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,330 @@
"use client";
import Link from "next/link";
import { useEffect, useRef, useState, type RefObject } from "react";
import { ArrowUpRight, BookMarked, X } from "lucide-react";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { Button } from "@/components/ui/Button";
import { StatusNote } from "@/components/ui/StatusNote";
import { handleTabTrap } from "@/lib/a11y/focusTrap";
import { useRestoreFocus } from "@/lib/a11y/useRestoreFocus";
import { useBodyScrollLock } from "@/lib/ui/useBodyScrollLock";
import { badgeClass, cn, overlayScrim } from "@/lib/ui/variants";
import {
CONTEXT_TABS,
contextTabHref,
type CodexGroup,
type ContextTabKey,
type OutlineRow,
} from "@/lib/workbench/contextDrawer";
import {
useContextDrawerData,
type ContextResource,
} from "@/lib/workbench/useContextDrawer";
// 速查抽屉总开关:一键关闭即回滚(触发按钮据此隐藏,旧侧栏/全宽页路由不受影响)。
export const CONTEXT_DRAWER_ENABLED = true;
interface ContextDrawerProps {
projectId: string;
open: boolean;
onClose: () => void;
// 关闭时把焦点还给触发按钮a11y
triggerRef?: RefObject<HTMLElement | null>;
}
// 从编辑器右侧滑出的【只读】上下文速查抽屉:不离开写作界面即可查设定库/大纲/伏笔/规则/文风。
// 视口无关(不同于 components/Drawer 的 lg:hidden 移动端抽屉),全只读、绝不写库。
// a11y 复用命令面板/Drawer 范式role=dialog + aria-modal + Esc 关闭 + focus trap + body scroll lock + 还原焦点。
export function ContextDrawer({
projectId,
open,
onClose,
triggerRef,
}: ContextDrawerProps) {
const [activeTab, setActiveTab] = useState<ContextTabKey>("codex");
const panelRef = useRef<HTMLDivElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const data = useContextDrawerData(projectId, activeTab, open);
useBodyScrollLock(open);
useRestoreFocus(open, triggerRef);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent): void => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
}
};
window.addEventListener("keydown", onKey);
closeRef.current?.focus();
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null;
return (
<div className={overlayScrim} onClick={onClose}>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label="上下文速查"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (panelRef.current) handleTabTrap(panelRef.current, e);
}}
className="absolute right-0 top-0 flex h-full w-96 max-w-[90vw] flex-col border-l border-line bg-panel shadow-paper outline-none"
>
<header className="flex items-center justify-between border-b border-line px-4 py-3">
<span className="flex items-center gap-2 font-serif text-base text-ink">
<BookMarked className="h-4 w-4 text-cinnabar" aria-hidden="true" />
</span>
<Button
ref={closeRef}
onClick={onClose}
aria-label="关闭上下文速查"
variant="ghost"
size="icon"
>
<X className="h-5 w-5" aria-hidden="true" />
</Button>
</header>
<TabBar activeTab={activeTab} onSelect={setActiveTab} />
<div className="flex-1 overflow-auto overscroll-contain px-4 py-4">
<TabPanel projectId={projectId} activeTab={activeTab} data={data} />
</div>
</div>
</div>
);
}
interface TabBarProps {
activeTab: ContextTabKey;
onSelect: (key: ContextTabKey) => void;
}
// Tab 选择条role=tablist设定库/大纲做完整速查,伏笔/规则/文风为摘要+跳链。
function TabBar({ activeTab, onSelect }: TabBarProps) {
return (
<div
role="tablist"
aria-label="上下文分类"
className="flex gap-1 overflow-x-auto border-b border-line px-2 py-2"
>
{CONTEXT_TABS.map((tab) => {
const active = tab.key === activeTab;
return (
<button
key={tab.key}
role="tab"
type="button"
aria-selected={active}
onClick={() => onSelect(tab.key)}
className={cn(
"shrink-0 rounded px-3 py-1.5 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35",
active
? "bg-[var(--color-cinnabar-wash)] text-cinnabar"
: "text-ink-soft hover:text-cinnabar",
)}
>
{tab.label}
</button>
);
})}
</div>
);
}
interface TabPanelProps {
projectId: string;
activeTab: ContextTabKey;
data: ReturnType<typeof useContextDrawerData>;
}
// 按当前 Tab 渲染对应速查面板;每个面板都带一个「去完整页编辑 →」链接。
function TabPanel({ projectId, activeTab, data }: TabPanelProps) {
const href = contextTabHref(projectId, activeTab);
return (
<div role="tabpanel" className="space-y-3">
{activeTab === "codex" ? (
<CodexPanel resource={data.codex} onRetry={data.reloadCodex} />
) : null}
{activeTab === "outline" ? (
<OutlinePanel resource={data.outline} onRetry={data.reloadOutline} />
) : null}
{activeTab === "foreshadow" ? (
<PlaceholderPanel summary="伏笔账本:埋设/回收窗口与逾期告警在完整看板逐条管理。" />
) : null}
{activeTab === "rules" ? (
<PlaceholderPanel summary="世界硬规则:分级约束正文一致性,在规则页增删改。" />
) : null}
{activeTab === "style" ? (
<PlaceholderPanel summary="文风指纹16 维文风画像,在文风页查看维度与证据。" />
) : null}
<FullPageLink href={href} />
</div>
);
}
// 加载中骨架(统一给设定库/大纲复用)。
function LoadingNote() {
return (
<div className="py-6 text-center">
<ThinkingIndicator label="加载中" className="text-sm text-ink-soft" />
</div>
);
}
interface ErrorNoteProps {
message: string;
onRetry: () => void;
}
function ErrorNote({ message, onRetry }: ErrorNoteProps) {
return (
<StatusNote variant="danger" title="加载失败">
<p>{message}</p>
<Button onClick={onRetry} variant="secondary" size="sm" className="mt-2">
</Button>
</StatusNote>
);
}
function EmptyNote({ text }: { text: string }) {
return (
<StatusNote variant="info">
<p>{text}</p>
</StatusNote>
);
}
interface CodexPanelProps {
resource: ContextResource<CodexGroup[]>;
onRetry: () => void;
}
// 设定库速查:按类型分组的实体名单(含硬规则条目),完整只读列表。
function CodexPanel({ resource, onRetry }: CodexPanelProps) {
if (resource.status === "loading" || resource.status === "idle") {
return <LoadingNote />;
}
if (resource.status === "error") {
return <ErrorNote message="设定库暂不可用。" onRetry={onRetry} />;
}
if (resource.data.length === 0) {
return <EmptyNote text="还没有世界观实体,去设定库添加后回来速查。" />;
}
return (
<div className="space-y-4">
{resource.data.map((group) => (
<section key={group.type}>
<h3 className="mb-1.5 flex items-center gap-2 text-sm font-medium text-ink">
{group.type}
<span className={badgeClass({ variant: "neutral" })}>
{group.entities.length}
</span>
</h3>
<ul className="space-y-1.5">
{group.entities.map((entity) => (
<li
key={`${group.type}-${entity.name}`}
className="rounded border border-line bg-bg px-3 py-2"
>
<p className="text-sm text-ink">{entity.name}</p>
{entity.rules && entity.rules.length > 0 ? (
<ul className="mt-1 list-disc space-y-0.5 pl-4 text-xs text-ink-soft">
{entity.rules.map((rule, i) => (
<li key={i}>{rule}</li>
))}
</ul>
) : null}
</li>
))}
</ul>
</section>
))}
</div>
);
}
interface OutlinePanelProps {
resource: ContextResource<OutlineRow[]>;
onRetry: () => void;
}
// 大纲速查:逐章节拍紧凑列表 + 该章伏笔窗口代号徽标,完整只读。
function OutlinePanel({ resource, onRetry }: OutlinePanelProps) {
if (resource.status === "loading" || resource.status === "idle") {
return <LoadingNote />;
}
if (resource.status === "error") {
return <ErrorNote message="大纲暂不可用。" onRetry={onRetry} />;
}
if (resource.data.length === 0) {
return <EmptyNote text="还没有大纲,去大纲页生成后回来速查。" />;
}
return (
<ul className="space-y-1.5">
{resource.data.map((row) => (
<li
key={row.no}
className="rounded border border-line bg-bg px-3 py-2"
>
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-xs text-ink-soft">
{row.no} · {row.volume}
</span>
{row.foreshadowCodes.length > 0 ? (
<span className="flex flex-wrap gap-1">
{row.foreshadowCodes.map((code) => (
<span key={code} className={badgeClass({ variant: "accent" })}>
{code}
</span>
))}
</span>
) : null}
</div>
{row.beats.length > 0 ? (
<ul className="mt-1 list-disc space-y-0.5 pl-4 text-sm text-ink">
{row.beats.map((beat, i) => (
<li key={i}>{beat}</li>
))}
</ul>
) : (
<p className="mt-1 text-xs text-ink-soft"></p>
)}
</li>
))}
</ul>
);
}
// 占位面板(伏笔/规则/文风):抽屉内一句话摘要,完整编辑走下方跳链。
function PlaceholderPanel({ summary }: { summary: string }) {
return (
<StatusNote variant="info">
<p>{summary}</p>
</StatusNote>
);
}
// 「去完整页编辑 →」链接:抽屉只读速查 → 跳全宽页编辑。
function FullPageLink({ href }: { href: string }) {
return (
<Link
href={href}
className="inline-flex items-center gap-1 text-sm text-cinnabar hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35"
>
<ArrowUpRight className="h-4 w-4" aria-hidden="true" />
</Link>
);
}

View File

@@ -0,0 +1,104 @@
"use client";
import { useEffect, useRef } from "react";
import { Check, Plus, X } from "lucide-react";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { Button } from "@/components/ui/Button";
import { SectionHeader } from "@/components/ui/SectionHeader";
import { StatusNote } from "@/components/ui/StatusNote";
import { useContinue } from "@/lib/workbench/useContinue";
interface ContinuePanelProps {
projectId: string;
chapterNo: number;
// 插入选中候选到正文(追加到章末)。
onInsert: (text: string) => void;
onClose: () => void;
}
// 续写结果卡(内联,非模态):打开即读该章前文续写一条候选;可「再来一个」累加多候选,
// 作者点某条「插入正文」才落回草稿HITL不静默写入
export function ContinuePanel({
projectId,
chapterNo,
onInsert,
onClose,
}: ContinuePanelProps) {
const { status, candidates, generate } = useContinue();
const ranRef = useRef(false);
useEffect(() => {
if (ranRef.current) return;
ranRef.current = true;
void generate(projectId, chapterNo);
}, [projectId, chapterNo, generate]);
const busy = status === "generating";
return (
<div className="border-b border-line bg-panel px-4 py-3 sm:px-6">
<div className="flex items-center justify-between gap-3">
<SectionHeader
title="续写候选"
description="承接本章前文续写;点「再来一个」多生成几版,选中意的插入正文。"
/>
<Button onClick={onClose} variant="ghost" size="icon" aria-label="关闭续写">
<X className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
{candidates.length === 0 && busy ? (
<div className="mt-3">
<ThinkingIndicator label="续写中" className="text-xs text-cinnabar" />
</div>
) : null}
{candidates.length === 0 && status === "error" ? (
<StatusNote variant="danger" className="mt-3 text-xs" title="续写失败">
<button
type="button"
onClick={() => void generate(projectId, chapterNo)}
className="mt-1 inline-flex items-center gap-1 text-cinnabar hover:underline"
>
</button>
</StatusNote>
) : null}
<ul className="mt-3 space-y-2">
{candidates.map((text, i) => (
<li
key={i}
className="rounded border border-line bg-bg p-3"
>
<div className="mb-1 flex items-center justify-between gap-2">
<span className="text-2xs text-ink-soft"> {i + 1}</span>
<Button
onClick={() => onInsert(text)}
variant="primary"
size="sm"
>
<Check className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
<p className="whitespace-pre-wrap text-sm text-ink">{text}</p>
</li>
))}
</ul>
<div className="mt-3">
<Button
onClick={() => void generate(projectId, chapterNo)}
disabled={busy}
variant="secondary"
size="sm"
>
<Plus className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
</div>
);
}

View File

@@ -4,17 +4,37 @@ import { useEffect, useRef } from "react";
import { cn, focusRing, proseBody } from "@/lib/ui/variants"; import { cn, focusRing, proseBody } from "@/lib/ui/variants";
export interface EditorSelection {
start: number;
end: number;
text: string;
}
interface EditorProps { interface EditorProps {
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
streaming: boolean; streaming: boolean;
// 选区变化上报(供「润色选段」等选区级 AI 动作取材。空选区也会上报start===end
onSelectionChange?: (selection: EditorSelection) => void;
} }
// 中栏正文编辑器宋体、720px 居中、行高 1.9UX §2.3 / §6.3)。 // 中栏正文编辑器宋体、720px 居中、行高 1.9UX §2.3 / §6.3)。
// 流式中显示朱砂光标提示prefers-reduced-motion 下不闪烁,见 globals.css // 流式中显示朱砂光标提示prefers-reduced-motion 下不闪烁,见 globals.css
export function Editor({ value, onChange, streaming }: EditorProps) { export function Editor({
value,
onChange,
streaming,
onSelectionChange,
}: EditorProps) {
const ref = useRef<HTMLTextAreaElement>(null); const ref = useRef<HTMLTextAreaElement>(null);
const reportSelection = (el: HTMLTextAreaElement): void => {
if (!onSelectionChange) return;
const start = el.selectionStart;
const end = el.selectionEnd;
onSelectionChange({ start, end, text: value.slice(start, end) });
};
// 自适应高度textarea 高度=内容高度overflow-hidden 不内部滚动),由外层写作区 // 自适应高度textarea 高度=内容高度overflow-hidden 不内部滚动),由外层写作区
// 单一滚动条统管。修复正文出现嵌套滚动条 + 定高 60vh 不铺满展示区的怪象。 // 单一滚动条统管。修复正文出现嵌套滚动条 + 定高 60vh 不铺满展示区的怪象。
// min-h-[60vh] 仍作空稿时的最小可写高度(内容更长时按内容增高、外层滚动)。 // min-h-[60vh] 仍作空稿时的最小可写高度(内容更长时按内容增高、外层滚动)。
@@ -35,6 +55,7 @@ export function Editor({ value, onChange, streaming }: EditorProps) {
id="chapter-editor" id="chapter-editor"
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
onSelect={(e) => reportSelection(e.currentTarget)}
readOnly={streaming} readOnly={streaming}
aria-busy={streaming} aria-busy={streaming}
placeholder="夜色如墨……点击「写本章」让 AI 起草,或直接在此书写。" placeholder="夜色如墨……点击「写本章」让 AI 起草,或直接在此书写。"

View File

@@ -0,0 +1,62 @@
"use client";
import { Button } from "@/components/ui/Button";
interface GenrePickerProps {
// 备选题材(来自 lib/wizard/wizard.ts 的 GENRES
genres: readonly string[];
// 当前点选题材(未选 = null
value: string | null;
// 点选某题材(点已选项亦回传该值,交由上层决定是否切换)。
onPick: (genre: string) => void;
// 可选:关闭/跳过采集卡。
onDismiss?: () => void;
}
// 极轻 genre 采集卡WFW-7一组 chips 让作者点本次写作题材,
// 杜绝无题材 slop。纯展示选中态用 aria-pressed 标注。
export function GenrePicker({
genres,
value,
onPick,
onDismiss,
}: GenrePickerProps) {
return (
<div className="border-b border-line bg-panel px-4 py-3 sm:px-6">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="font-serif text-base text-ink"></p>
<p className="mt-0.5 text-sm text-ink-soft">
</p>
</div>
{onDismiss ? (
<Button onClick={onDismiss} variant="secondary" size="sm">
</Button>
) : null}
</div>
<div
className="mt-3 flex flex-wrap gap-2"
role="group"
aria-label="本章题材"
>
{genres.map((genre) => {
const active = genre === value;
return (
<Button
key={genre}
type="button"
aria-pressed={active}
onClick={() => onPick(genre)}
variant={active ? "outline" : "secondary"}
size="sm"
>
{genre}
</Button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,89 @@
"use client";
import { useState } from "react";
import { X } from "lucide-react";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { Button } from "@/components/ui/Button";
import { SectionHeader } from "@/components/ui/SectionHeader";
import { StatusNote } from "@/components/ui/StatusNote";
import { GeneratorRunner } from "@/components/toolbox/GeneratorRunner";
import type { ToolDescriptorView } from "@/lib/api/types";
import { isLegacyTool } from "@/lib/toolbox/toolbox";
import { useToolboxTools } from "@/lib/toolbox/useToolboxTools";
interface InlineToolboxProps {
projectId: string;
// 把生成结果插入正文(追加到章末)。
onInsertText: (text: string) => void;
// 当前正文(编辑器 text。透传给 GeneratorRunner供 book-title 等据正文反推 brief。
manuscriptText?: string;
onClose: () => void;
}
// 编辑器内联工具箱(不离开写作界面):列出生成器(含金手指),就地生成结构化预览,
// 文本类产物点「插入正文」直接填入写作处,可入库类走既有 ingest。legacy 生成器(世界观/
// 人设/大纲)仍走各自更丰富的独立页,不塞进内联面板。
export function InlineToolbox({
projectId,
onInsertText,
manuscriptText,
onClose,
}: InlineToolboxProps) {
const { status, tools } = useToolboxTools();
const [active, setActive] = useState<ToolDescriptorView | null>(null);
const openable = tools.filter((tool) => !isLegacyTool(tool));
return (
<div className="max-h-[70vh] overflow-auto border-b border-line bg-panel px-4 py-3 sm:px-6">
<div className="flex items-center justify-between gap-3">
<SectionHeader
title="工具箱"
description="就地生成,点「插入正文」直接填入写作处;可入库内容经一致性预检。"
/>
<Button onClick={onClose} variant="ghost" size="icon" aria-label="关闭工具箱">
<X className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
{active ? (
<div className="mt-3">
<GeneratorRunner
projectId={projectId}
tool={active}
manuscriptText={manuscriptText}
onClose={() => setActive(null)}
onInsertText={(text) => {
if (text.trim().length > 0) onInsertText(text);
}}
/>
</div>
) : status === "loading" ? (
<div className="mt-3">
<ThinkingIndicator label="加载工具箱" className="text-xs text-cinnabar" />
</div>
) : status === "error" ? (
<StatusNote variant="danger" className="mt-3 text-xs" title="工具箱加载失败">
<p></p>
</StatusNote>
) : openable.length === 0 ? (
<p className="mt-3 text-xs text-ink-soft"></p>
) : (
<ul className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
{openable.map((tool) => (
<li key={tool.key}>
<button
type="button"
onClick={() => setActive(tool)}
className="w-full rounded border border-line bg-bg p-3 text-left transition-colors hover:border-cinnabar"
>
<p className="text-sm text-ink">{tool.title}</p>
<p className="mt-0.5 text-xs text-ink-soft">{tool.subtitle}</p>
</button>
</li>
))}
</ul>
)}
</div>
);
}

View File

@@ -0,0 +1,193 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Check, MessageSquarePlus, MessagesSquare, RotateCcw, X } from "lucide-react";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { Button } from "@/components/ui/Button";
import { SectionHeader } from "@/components/ui/SectionHeader";
import { StatusNote } from "@/components/ui/StatusNote";
import { TextArea } from "@/components/ui/TextArea";
import { useRefine } from "@/lib/workbench/useRefine";
import { useClarify } from "@/lib/workbench/useClarify";
import { foldClarifications, needsClarifyGate } from "@/lib/workbench/clarify";
import { ChoiceChips } from "./ChoiceChips";
// 门控阈值:再沟通意见 trim 后短于此字数才自动预检反问(清晰意见不交往返税)。
const CLARIFY_MIN_CHARS = 10;
interface RefinePanelProps {
projectId: string;
chapterNo: number;
// 选中原文;打开即对其回炉一次。
original: string;
// 接受回填:把选段替换为该产出(调用方按内容重锚落回草稿)。
onAccept: (refined: string) => void;
onClose: () => void;
}
// 润色 / 再沟通结果卡(内联,非模态):打开即润色选段;作者可「再沟通」追加意见迭代出新版,
// 择版接受回填正文,或放弃。原文只读、生成不写库(不变量 #3accept 才动草稿。
export function RefinePanel({
projectId,
chapterNo,
original,
onAccept,
onClose,
}: RefinePanelProps) {
const { status, versions, latest, refine, recommunicate } = useRefine();
const clarify = useClarify();
const [instruction, setInstruction] = useState("");
const [pendingInstruction, setPendingInstruction] = useState<string | null>(null);
const ranRef = useRef(false);
// 打开即对选段回炉一次(仅一次,避免重复请求)。
useEffect(() => {
if (ranRef.current) return;
ranRef.current = true;
void refine(projectId, chapterNo, original);
}, [projectId, chapterNo, original, refine]);
const busy = status === "refining";
const clarifying = clarify.status === "checking";
const firstQuestion = clarify.decision?.questions[0] ?? null;
const asking = clarify.status === "asking" && firstQuestion !== null;
// 折入澄清答案(或原意见)后回炉,并清理澄清态。
const runRecommunicate = (finalInstruction: string): void => {
clarify.reset();
setPendingInstruction(null);
setInstruction("");
void recommunicate(projectId, chapterNo, finalInstruction);
};
// 「再改一版」:意见含糊/极短(或 force先预检反问否则直接回炉。
const onRecommunicate = async (force: boolean): Promise<void> => {
const trimmed = instruction.trim();
if (!trimmed || busy || clarifying) return;
if (force || needsClarifyGate(trimmed, CLARIFY_MIN_CHARS)) {
const segment = latest?.refined ?? original;
const vm = await clarify.check(projectId, chapterNo, segment, trimmed);
if (vm.needClarification && vm.questions.length > 0) {
setPendingInstruction(trimmed); // 记住原意见,等作者答完折进去
return;
}
}
runRecommunicate(trimmed);
};
// 作者答复澄清(点选 value 或自由输入)→ 折进意见 → 回炉。
const onClarifyAnswer = (answer: string): void => {
const question = clarify.decision?.questions[0]?.question ?? "";
const base = pendingInstruction ?? instruction.trim();
runRecommunicate(foldClarifications(base, [{ question, answer }]));
};
return (
<div className="border-b border-line bg-panel px-4 py-3 sm:px-6">
<div className="flex items-center justify-between gap-3">
<SectionHeader
title="润色 / 再沟通"
description="对选中段回炉;不满意可追加意见让 AI 再改一版,满意后接受回填。"
/>
<Button onClick={onClose} variant="ghost" size="icon" aria-label="关闭润色">
<X className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<figure className="rounded border border-line bg-bg p-3">
<figcaption className="mb-1 text-2xs text-ink-soft"></figcaption>
<p className="whitespace-pre-wrap text-sm text-ink-soft">{original}</p>
</figure>
<figure className="rounded border border-cinnabar/40 bg-bg p-3">
<figcaption className="mb-1 flex items-center gap-2 text-2xs text-ink-soft">
{versions.length > 0 ? (
<span className="rounded bg-panel px-1.5 py-0.5 tabular-nums">
{versions.length}
</span>
) : null}
</figcaption>
{busy && !latest ? (
<ThinkingIndicator label="润色中" className="text-xs text-cinnabar" />
) : latest ? (
<p className="whitespace-pre-wrap text-sm text-ink">{latest.refined}</p>
) : status === "error" ? (
<StatusNote variant="danger" className="text-xs" title="回炉失败">
<button
type="button"
onClick={() => void refine(projectId, chapterNo, original)}
className="mt-1 inline-flex items-center gap-1 text-cinnabar hover:underline"
>
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</StatusNote>
) : null}
</figure>
</div>
<div className="mt-3 space-y-2">
<label className="block">
<span className="sr-only"></span>
<TextArea
value={instruction}
onChange={(e) => setInstruction(e.target.value)}
rows={2}
placeholder="再沟通:想怎么改?(意见太笼统时 AI 会先反问、给方向选项)"
/>
</label>
{asking && firstQuestion ? (
// role="status"AI 反问动态出现时向读屏播报WCAG 4.1.3 Status Messages
<div role="status" className="rounded border border-cinnabar/40 bg-bg p-3">
<p className="mb-2 text-2xs text-ink-soft">AI </p>
<ChoiceChips
question={firstQuestion.question}
options={firstQuestion.options}
allowFreeText={firstQuestion.allowFreeText}
onPick={onClarifyAnswer}
onFreeText={onClarifyAnswer}
/>
</div>
) : null}
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={() => void onRecommunicate(false)}
disabled={busy || clarifying || instruction.trim().length === 0}
variant="secondary"
size="sm"
>
<MessageSquarePlus className="h-4 w-4" aria-hidden="true" />
</Button>
<Button
onClick={() => void onRecommunicate(true)}
disabled={busy || clarifying || instruction.trim().length === 0}
variant="ghost"
size="sm"
title="让 AI 先反问、给方向选项再改"
>
<MessagesSquare className="h-4 w-4" aria-hidden="true" />
</Button>
<Button
onClick={() => latest && onAccept(latest.refined)}
disabled={busy || !latest}
variant="primary"
size="sm"
>
<Check className="h-4 w-4" aria-hidden="true" />
</Button>
{busy || clarifying ? (
<ThinkingIndicator
label={clarifying ? "琢磨要不要反问" : "生成中"}
className="text-xs text-cinnabar"
/>
) : null}
</div>
</div>
</div>
);
}

View File

@@ -3,6 +3,7 @@
import Link from "next/link"; import Link from "next/link";
import { useEffect, useId, useRef, useState, type RefObject } from "react"; import { useEffect, useId, useRef, useState, type RefObject } from "react";
import { import {
BookMarked,
BookOpen, BookOpen,
ChevronDown, ChevronDown,
ClipboardCheck, ClipboardCheck,
@@ -11,12 +12,17 @@ import {
PanelLeft, PanelLeft,
PanelRight, PanelRight,
PenLine, PenLine,
RefreshCw,
Sparkles,
Square, Square,
Wand2,
WrapText,
} from "lucide-react"; } from "lucide-react";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { Drawer } from "@/components/Drawer"; import { Drawer } from "@/components/Drawer";
import { ThinkingIndicator } from "@/components/ThinkingIndicator"; import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { useToast } from "@/components/Toast";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { SectionHeader } from "@/components/ui/SectionHeader"; import { SectionHeader } from "@/components/ui/SectionHeader";
import { StatusNote } from "@/components/ui/StatusNote"; import { StatusNote } from "@/components/ui/StatusNote";
@@ -29,11 +35,25 @@ import {
WORKBENCH_CHAPTER_NO, WORKBENCH_CHAPTER_NO,
type ChapterEntry, type ChapterEntry,
} from "@/lib/workbench/chapter"; } from "@/lib/workbench/chapter";
import { composeDirective, STYLE_PRESETS } from "@/lib/workbench/directive"; import {
composeDirective,
PLOT_PRESETS,
STYLE_PRESETS,
type Preset,
} from "@/lib/workbench/directive";
import { applyRefinement } from "@/lib/workbench/refineApply";
import { buttonClass } from "@/lib/ui/variants"; import { buttonClass } from "@/lib/ui/variants";
import { ChapterList, ChapterListContent } from "./ChapterList"; import { ChapterList, ChapterListContent } from "./ChapterList";
import { ChapterAssistant, AssistantContent } from "./ChapterAssistant"; import { ChapterAssistant, AssistantContent } from "./ChapterAssistant";
import { Editor } from "./Editor"; import { Editor, type EditorSelection } from "./Editor";
import { RefinePanel } from "./RefinePanel";
import { ContinuePanel } from "./ContinuePanel";
import { InlineToolbox } from "./InlineToolbox";
import { ChapterRewritePanel } from "./ChapterRewritePanel";
import { ContextDrawer, CONTEXT_DRAWER_ENABLED } from "./ContextDrawer";
import { GenrePicker } from "./GenrePicker";
import { GENRES } from "@/lib/wizard/wizard";
import { useGenreGate, toGenreDirective } from "@/lib/workbench/useGenreGate";
interface WorkbenchProps { interface WorkbenchProps {
project: ProjectResponse; project: ProjectResponse;
@@ -64,11 +84,26 @@ export function Workbench({
// 本章指令T4-b自由文本 + 风格预设 → 写本章时组装传入 draft 生成。 // 本章指令T4-b自由文本 + 风格预设 → 写本章时组装传入 draft 生成。
const [directive, setDirective] = useState(""); const [directive, setDirective] = useState("");
const [presetIds, setPresetIds] = useState<string[]>([]); const [presetIds, setPresetIds] = useState<string[]>([]);
// 选区级 AI 动作(润色/再沟通):编辑器上报的最新选区 + 结果卡开合。
const [selection, setSelection] = useState<EditorSelection | null>(null);
const [refineOpen, setRefineOpen] = useState(false);
const [continueOpen, setContinueOpen] = useState(false);
const [toolboxOpen, setToolboxOpen] = useState(false);
// WFW-8整章「再沟通/重写」结果卡开合(意见 → 流式重写一版 → 版本栈 → 接受替换整章)。
const [rewriteOpen, setRewriteOpen] = useState(false);
// WFW-7首次写章的极轻 genre 采集门。项目无题材 → 写章前先点一个题材(仅临时并入本次指令,不落库)。
const genreGate = useGenreGate(project.genre ?? null);
const [genrePromptOpen, setGenrePromptOpen] = useState(false);
// WFW-6 上下文速查抽屉加法式、flag 灰度;与三张 AI 结果卡互不干扰。
const [contextOpen, setContextOpen] = useState(false);
const toast = useToast();
const autosave = useAutosave(project.id, chapterNo, initialText); const autosave = useAutosave(project.id, chapterNo, initialText);
const stream = useDraftStream(); const stream = useDraftStream();
const lastStreamText = useRef(""); const lastStreamText = useRef("");
const canRefine = selection !== null && selection.text.trim().length > 0;
const chapterTriggerRef = useRef<HTMLButtonElement>(null); const chapterTriggerRef = useRef<HTMLButtonElement>(null);
const assistantTriggerRef = useRef<HTMLButtonElement>(null); const assistantTriggerRef = useRef<HTMLButtonElement>(null);
const contextTriggerRef = useRef<HTMLButtonElement>(null);
// 流式 token 累积进编辑器(打字机);停止/结束后已生成部分留在草稿。 // 流式 token 累积进编辑器(打字机);停止/结束后已生成部分留在草稿。
useEffect(() => { useEffect(() => {
@@ -85,12 +120,29 @@ export function Workbench({
} }
}, [stream.state.phase, stream.state.text, autosave]); }, [stream.state.phase, stream.state.text, autosave]);
// 题材 + 三槽指令合并为一条写章指令题材前置effectiveGenre 无 → 仅原指令。
const startWrite = (effectiveGenre: string | null): void => {
const base = composeDirective(directive, presetIds);
const merged = [toGenreDirective(effectiveGenre), base]
.filter((part) => part.length > 0)
.join("");
void stream.start(project.id, chapterNo, merged);
};
const onWrite = (): void => { const onWrite = (): void => {
void stream.start( // 无题材且未点选 → 先弹采集卡选后再写HITL不静默生成无题材 slop
project.id, if (genreGate.needsGenre && genreGate.chosenGenre === null) {
chapterNo, setGenrePromptOpen(true);
composeDirective(directive, presetIds), return;
); }
startWrite(genreGate.effectiveGenre);
};
// 采集卡点题材:记录本次题材并立刻开写。用 genre 直传绕开 setState 异步。
const onPickGenre = (genre: string): void => {
genreGate.setChosenGenre(genre);
setGenrePromptOpen(false);
startWrite(genre);
}; };
const togglePreset = (id: string): void => { const togglePreset = (id: string): void => {
@@ -104,6 +156,75 @@ export function Workbench({
autosave.onChange(value); autosave.onChange(value);
}; };
// 接受润色/再沟通产出:按内容重锚把选段替换为产出并落草稿;原文已变则提示重选(不盲替换)。
const onAcceptRefine = (refined: string): void => {
if (selection) {
const next = applyRefinement(text, selection.text, refined, {
start: selection.start,
end: selection.end,
});
if (next === null) {
toast("正文已改动,找不到原选段,请重新选择后再润色。", "error");
} else {
setText(next);
autosave.onChange(next);
}
}
setRefineOpen(false);
setSelection(null);
};
// 四张 AI 结果卡(润色/续写/工具箱/整章重写)互斥,避免同时占位。
const openRefine = (): void => {
setContinueOpen(false);
setToolboxOpen(false);
setRewriteOpen(false);
setRefineOpen(true);
};
// 续写前先 flush 自动保存,确保 continue 读到的是最新草稿with_prior_chapter 读库)。
const openContinue = (): void => {
autosave.flush();
setRefineOpen(false);
setToolboxOpen(false);
setRewriteOpen(false);
setContinueOpen(true);
};
const openToolbox = (): void => {
setRefineOpen(false);
setContinueOpen(false);
setRewriteOpen(false);
setToolboxOpen(true);
};
const openRewrite = (): void => {
setRefineOpen(false);
setContinueOpen(false);
setToolboxOpen(false);
setRewriteOpen(true);
};
// 接受某一版整章重写 → 替换整章正文并落草稿HITL作者点选才落
const onAcceptRewrite = (next: string): void => {
setText(next);
autosave.onChange(next);
setRewriteOpen(false);
};
// 追加一段文本到章末并落草稿(续写候选 / 工具箱产物共用)。
const appendToDraft = (chunk: string): void => {
const base = text.trimEnd();
const next = base.length > 0 ? `${base}\n\n${chunk}` : chunk;
setText(next);
autosave.onChange(next);
};
const onInsertContinuation = (candidate: string): void => {
appendToDraft(candidate);
setContinueOpen(false);
};
// 字数统计非空白字符(与中文读者直觉一致:标点/空格不计入正文体量)。 // 字数统计非空白字符(与中文读者直觉一致:标点/空格不计入正文体量)。
const wordCount = text.replace(/\s+/g, "").length; const wordCount = text.replace(/\s+/g, "").length;
const closePanel = (): void => setMobilePanel(null); const closePanel = (): void => setMobilePanel(null);
@@ -147,17 +268,68 @@ export function Workbench({
chapterTriggerRef={chapterTriggerRef} chapterTriggerRef={chapterTriggerRef}
assistantTriggerRef={assistantTriggerRef} assistantTriggerRef={assistantTriggerRef}
/> />
{genrePromptOpen ? (
<GenrePicker
genres={GENRES}
value={genreGate.chosenGenre}
onPick={onPickGenre}
onDismiss={() => setGenrePromptOpen(false)}
/>
) : null}
<DirectivePanel <DirectivePanel
directive={directive} directive={directive}
onDirectiveChange={setDirective} onDirectiveChange={setDirective}
presetIds={presetIds} presetIds={presetIds}
onTogglePreset={togglePreset} onTogglePreset={togglePreset}
/> />
{refineOpen && canRefine && selection ? (
<RefinePanel
projectId={project.id}
chapterNo={chapterNo}
original={selection.text}
onAccept={onAcceptRefine}
onClose={() => setRefineOpen(false)}
/>
) : null}
{continueOpen ? (
<ContinuePanel
projectId={project.id}
chapterNo={chapterNo}
onInsert={onInsertContinuation}
onClose={() => setContinueOpen(false)}
/>
) : null}
{toolboxOpen ? (
<InlineToolbox
projectId={project.id}
manuscriptText={text}
onInsertText={(chunk) => {
appendToDraft(chunk);
setToolboxOpen(false);
}}
onClose={() => setToolboxOpen(false)}
/>
) : null}
{rewriteOpen ? (
<ChapterRewritePanel
projectId={project.id}
chapterNo={chapterNo}
currentDraft={text}
onAccept={onAcceptRewrite}
onClose={() => setRewriteOpen(false)}
/>
) : null}
<div className="flex-1 overflow-auto px-6 py-8"> <div className="flex-1 overflow-auto px-6 py-8">
{chapters.length === 0 ? (
<StatusNote variant="info" className="mb-4">
</StatusNote>
) : null}
<Editor <Editor
value={text} value={text}
onChange={onEditorChange} onChange={onEditorChange}
streaming={stream.isStreaming} streaming={stream.isStreaming}
onSelectionChange={setSelection}
/> />
</div> </div>
<Toolbar <Toolbar
@@ -171,6 +343,13 @@ export function Workbench({
liveMessage={liveMessage} liveMessage={liveMessage}
onWrite={onWrite} onWrite={onWrite}
onStop={stream.stop} onStop={stream.stop}
canRefine={canRefine}
onRefineSelection={openRefine}
onContinue={openContinue}
onToolbox={openToolbox}
onRewrite={openRewrite}
onOpenContext={() => setContextOpen(true)}
contextTriggerRef={contextTriggerRef}
/> />
</section> </section>
@@ -200,6 +379,14 @@ export function Workbench({
> >
<AssistantContent projectId={project.id} chapterNo={chapterNo} /> <AssistantContent projectId={project.id} chapterNo={chapterNo} />
</Drawer> </Drawer>
{/* WFW-6 上下文速查:视口无关的右侧 slide-over旧侧栏/全宽页路由保留、可回滚。 */}
<ContextDrawer
projectId={project.id}
open={contextOpen}
onClose={() => setContextOpen(false)}
triggerRef={contextTriggerRef}
/>
</AppShell> </AppShell>
); );
} }
@@ -282,11 +469,23 @@ function DirectivePanel({
</summary> </summary>
<div id={panelId} className="mt-3 space-y-3"> <div id={panelId} className="mt-3 space-y-3">
<SectionHeader <SectionHeader
title="写作指令" title="AI 写作诉求"
description="用于补充或覆盖本章大纲节拍,只影响本次生成。" description="选文风、点剧情需求、写自己的要求,只影响本次生成。"
/>
<PresetGroup
label="文风"
presets={STYLE_PRESETS}
presetIds={presetIds}
onToggle={onTogglePreset}
/>
<PresetGroup
label="剧情需求"
presets={PLOT_PRESETS}
presetIds={presetIds}
onToggle={onTogglePreset}
/> />
<label className="block"> <label className="block">
<span className="sr-only"></span> <span className="mb-1 block text-sm text-ink-soft"></span>
<TextArea <TextArea
value={directive} value={directive}
onChange={(e) => onDirectiveChange(e.target.value)} onChange={(e) => onDirectiveChange(e.target.value)}
@@ -294,26 +493,9 @@ function DirectivePanel({
placeholder="本章想怎么写?(可选,覆盖/补充大纲节拍)" placeholder="本章想怎么写?(可选,覆盖/补充大纲节拍)"
/> />
</label> </label>
<div className="flex flex-wrap gap-2" role="group" aria-label="风格预设">
{STYLE_PRESETS.map((preset) => {
const active = presetIds.includes(preset.id);
return (
<Button
key={preset.id}
type="button"
aria-pressed={active}
onClick={() => onTogglePreset(preset.id)}
variant={active ? "outline" : "secondary"}
size="sm"
>
{preset.label}
</Button>
);
})}
</div>
{activeCount > 0 ? ( {activeCount > 0 ? (
<StatusNote variant="info"> <StatusNote variant="info">
/
</StatusNote> </StatusNote>
) : null} ) : null}
</div> </div>
@@ -321,6 +503,39 @@ function DirectivePanel({
); );
} }
interface PresetGroupProps {
label: string;
presets: readonly Preset[];
presetIds: readonly string[];
onToggle: (id: string) => void;
}
// 一组预设 chips文风 / 剧情需求):点选 toggle选中高亮。id 统一进 presetIds。
function PresetGroup({ label, presets, presetIds, onToggle }: PresetGroupProps) {
return (
<div>
<p className="mb-1 text-sm text-ink-soft">{label}</p>
<div className="flex flex-wrap gap-2" role="group" aria-label={label}>
{presets.map((preset) => {
const active = presetIds.includes(preset.id);
return (
<Button
key={preset.id}
type="button"
aria-pressed={active}
onClick={() => onToggle(preset.id)}
variant={active ? "outline" : "secondary"}
size="sm"
>
{preset.label}
</Button>
);
})}
</div>
</div>
);
}
// 写章失败提示:友好文案(不暴露 raw code+ 可选「去设置」动作F4 // 写章失败提示:友好文案(不暴露 raw code+ 可选「去设置」动作F4
function StreamErrorNote({ function StreamErrorNote({
streamError, streamError,
@@ -358,6 +573,18 @@ interface ToolbarProps {
liveMessage: string; liveMessage: string;
onWrite: () => void; onWrite: () => void;
onStop: () => void; onStop: () => void;
// 选区级润色:有非空选区时可用;点击打开润色/再沟通结果卡。
canRefine: boolean;
onRefineSelection: () => void;
// 续写:读本章前文续写候选。
onContinue: () => void;
// 工具箱:编辑器内联调用生成器,结果回填正文。
onToolbox: () => void;
// 整章再沟通/重写WFW-8
onRewrite: () => void;
// 打开上下文速查抽屉WFW-6
onOpenContext: () => void;
contextTriggerRef: RefObject<HTMLButtonElement | null>;
} }
function Toolbar({ function Toolbar({
@@ -371,6 +598,13 @@ function Toolbar({
liveMessage, liveMessage,
onWrite, onWrite,
onStop, onStop,
canRefine,
onRefineSelection,
onContinue,
onToolbox,
onRewrite,
onOpenContext,
contextTriggerRef,
}: ToolbarProps) { }: ToolbarProps) {
return ( return (
<div className="sticky bottom-0 z-10 border-t border-line bg-panel/95 px-4 py-2 backdrop-blur sm:px-6 sm:py-3"> <div className="sticky bottom-0 z-10 border-t border-line bg-panel/95 px-4 py-2 backdrop-blur sm:px-6 sm:py-3">
@@ -388,6 +622,32 @@ function Toolbar({
</Button> </Button>
)} )}
{!streaming ? (
<>
<Button onClick={onContinue} variant="secondary" size="sm">
<WrapText className="h-4 w-4" aria-hidden="true" />
</Button>
<Button
onClick={onRefineSelection}
disabled={!canRefine}
variant="secondary"
size="sm"
title={canRefine ? undefined : "先在正文里选中一段再润色"}
>
<Wand2 className="h-4 w-4" aria-hidden="true" />
</Button>
<Button onClick={onToolbox} variant="secondary" size="sm">
<Sparkles className="h-4 w-4" aria-hidden="true" />
</Button>
<Button onClick={onRewrite} variant="secondary" size="sm">
<RefreshCw className="h-4 w-4" aria-hidden="true" />
</Button>
</>
) : null}
{streaming ? ( {streaming ? (
// ThinkingIndicator 自带 role=status开始时播报一次「生成中」 // ThinkingIndicator 自带 role=status开始时播报一次「生成中」
// 易变字数 aria-hidden仅供视觉不逐 token 打扰屏读。 // 易变字数 aria-hidden仅供视觉不逐 token 打扰屏读。
@@ -409,6 +669,18 @@ function Toolbar({
<span className="sr-only" role="status" aria-live="polite"> <span className="sr-only" role="status" aria-live="polite">
{liveMessage} {liveMessage}
</span> </span>
{CONTEXT_DRAWER_ENABLED ? (
<Button
ref={contextTriggerRef}
onClick={onOpenContext}
variant="secondary"
size="sm"
title="打开上下文速查(设定库/大纲/伏笔/规则/文风)"
>
<BookMarked className="h-4 w-4" aria-hidden="true" />
</Button>
) : null}
<Link <Link
href={`/projects/${projectId}/outline`} href={`/projects/${projectId}/outline`}
className={buttonClass({ variant: "secondary", size: "sm" })} className={buttonClass({ variant: "secondary", size: "sm" })}

View File

@@ -0,0 +1,65 @@
// @vitest-environment jsdom
import { renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import type { RefObject } from "react";
import { useRestoreFocus } from "./useRestoreFocus";
const appended: HTMLElement[] = [];
function mountEl<T extends HTMLElement>(el: T): T {
document.body.appendChild(el);
appended.push(el);
return el;
}
afterEach(() => {
for (const el of appended.splice(0)) el.remove();
});
describe("useRestoreFocusCR-H11", () => {
it("关闭时把焦点还给 triggerRef", () => {
const trigger = mountEl(document.createElement("button"));
const other = mountEl(document.createElement("input"));
const ref: RefObject<HTMLElement | null> = { current: trigger };
const { rerender } = renderHook(
({ open }) => useRestoreFocus(open, ref),
{ initialProps: { open: true } },
);
other.focus();
expect(document.activeElement).toBe(other);
rerender({ open: false });
expect(document.activeElement).toBe(trigger);
});
it("从未打开过关闭态不抢焦点wasOpen 门闩)", () => {
const trigger = mountEl(document.createElement("button"));
const other = mountEl(document.createElement("input"));
const ref: RefObject<HTMLElement | null> = { current: trigger };
renderHook(({ open }) => useRestoreFocus(open, ref), {
initialProps: { open: false },
});
other.focus();
expect(document.activeElement).toBe(other);
});
it("triggerRef 为 null / undefined 时关闭不抛错", () => {
const nullRef: RefObject<HTMLElement | null> = { current: null };
const nullCase = renderHook(
({ open }) => useRestoreFocus(open, nullRef),
{ initialProps: { open: true } },
);
expect(() => nullCase.rerender({ open: false })).not.toThrow();
const undefCase = renderHook(
({ open }) => useRestoreFocus(open, undefined),
{ initialProps: { open: true } },
);
expect(() => undefCase.rerender({ open: false })).not.toThrow();
});
});

View File

@@ -0,0 +1,24 @@
"use client";
import { useEffect, useRef, type RefObject } from "react";
// 弹层Drawer / ContextDrawer关闭后把焦点还给触发元素a11y 焦点管理CR-H11
// wasOpenRef 门闩:仅在「曾打开过」后的关闭才还原,避免从未打开时误抢焦点。
// null-safetriggerRef 或其 current 为空时静默跳过。
export function useRestoreFocus(
open: boolean,
triggerRef?: RefObject<HTMLElement | null>,
): void {
const wasOpenRef = useRef(false);
useEffect(() => {
if (!open) {
if (wasOpenRef.current) {
wasOpenRef.current = false;
triggerRef?.current?.focus();
}
return;
}
wasOpenRef.current = true;
}, [open, triggerRef]);
}

View File

@@ -157,6 +157,58 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/projects/{project_id}/chapters/{chapter_no}/rewrite": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Stream Rewrite
* @description 整章再沟通/重写:组装记忆 + 当前草稿 + 作者意见 → 网关流 → 归一 SSE → event-stream。
*
* HITL新版停前端/草稿,接受才落库(不变量 #3rewrite 是工具非写节点,不破坏章
* 纯函数性(不变量 #7。项目不存在 → 404触网关前 fail-fast同 draftQA C1
*/
post: operations["stream_rewrite_projects__project_id__chapters__chapter_no__rewrite_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/projects/{project_id}/chapters/{chapter_no}/rewrite/clarify": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Clarify Rewrite
* @description 整章重写预检澄清WFW-9 M2 路线A 两阶段之「问题」阶段):非流式 JSON只判不改。
*
* 镜像 refine 侧 clarify——只判「作者这条整章重写意见清不清楚」含糊则反问 1 问 + 给选项,
* 清楚则给确认语放行;**绝不改正文**(整章重写仍走既有 rewrite SSE 端点。analyst 档
* (不变量 #2。只取 `prior_draft` 有界摘录喂预检(不整章入 LLM
*
* 项目不存在 → 404触网关前 fail-fast同 rewrite/draft无凭据 → 503dep 解析阶段拦下)。
* **只读不写业务表**(不变量 #3末尾 `commit()` 让网关 usage_ledger 落库add-only否则
* 记账静默丢失,同 rewrite 纪律)。判别/校验失败在 `run_clarify` 内确定性回退
* `need_clarification=false`(不阻塞整章重写主链路)。
*/
post: operations["clarify_rewrite_projects__project_id__chapters__chapter_no__rewrite_clarify_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/projects/{project_id}/chapters/{chapter_no}/review": { "/projects/{project_id}/chapters/{chapter_no}/review": {
parameters: { parameters: {
query?: never; query?: never;
@@ -406,6 +458,33 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/projects/{project_id}/chapters/{chapter_no}/refine/clarify": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Clarify Refine
* @description 润色预检澄清WFW-9 M1 路线A 两阶段之「问题」阶段):非流式 JSON只判不改。
*
* 独立于既有 refine 端点——只判「作者这条再沟通意见清不清楚」,含糊则反问 1 问 + 给选项,
* 清楚则给确认语放行;**绝不改正文**(正文仍走 refine 端点。analyst 档(不变量 #2
*
* 项目不存在 → 404无凭据 → 503dep 解析阶段拦下)。**只读不写库**(不变量 #3
* 末尾 `commit()` 让网关 usage_ledger 落库add-only否则记账静默丢失同 refine 纪律)。
* 判别/校验失败在 `run_clarify` 内确定性回退 `need_clarification=false`(不阻塞润色)。
*/
post: operations["clarify_refine_projects__project_id__chapters__chapter_no__refine_clarify_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/templates": { "/templates": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1086,6 +1165,81 @@ export interface components {
/** Note */ /** Note */
note?: string | null; note?: string | null;
}; };
/**
* ClarifyDecision
* @description 润色预检澄清决策(结构化 LLM 输出 + API 响应)。
*
* 路线A 两阶段之「问题」阶段:仅判定「要不要反问」并给选项,**不改正文**(正文仍走
* 既有 refine 端点)。纯只读(`clarify_refine_spec.writes=()`,不变量 #3
*
* **全字段给默认值守解析韧性**(仿 `StyleDriftReview` 降级范式LLM 漏产/畸形时
* 降级为 `need_clarification=false`(不问、直接放行 refine绝不阻塞润色主链路。
*
* - `need_clarification=false` → `questions` 为空、可给一句 `verification` 确认语;
* - `need_clarification=true` → `questions` 恰 1 问v1 硬上限)、`verification` 可空。
*/
ClarifyDecision: {
/**
* Need Clarification
* @description 是否需要向作者反问澄清(含糊/多走法→true明确→false
* @default false
*/
need_clarification: boolean;
/**
* Questions
* @description 反问清单need_clarification=false 时为空v1 硬上限 1 问)
*/
questions?: components["schemas"]["ClarifyQuestion"][];
/**
* Verification
* @description 明确时的一句『我这样理解对吗』确认语need_clarification=false 时给,可空)
*/
verification?: string | null;
};
/**
* ClarifyOption
* @description 单个澄清选项:展示文案 + 选中回填值(锚定本段/本章的一种具体走法)。
*
* `value` = 作者选中后折进 refine instruction 字符串的内容(零迁移,不落库)。
* 凑不出具体可区分选项时退化为空 options`ClarifyQuestion.options=[]`),突出自由输入。
*/
ClarifyOption: {
/**
* Label
* @description 展示文案(作者看到的选项标签)
*/
label: string;
/**
* Value
* @description 选中后回填/折进 instruction 的具体走法内容
*/
value: string;
};
/**
* ClarifyQuestion
* @description 单条澄清反问:问题 + 24 个锚定选项 + 自由输入兜底。
*
* `options` 常规 24 个(锚定本段/本章的具体走法);凑不出具体可区分选项时留空列表,
* 退化为纯自由输入。`allow_free_text` 常驻 True——自由输入永远兜底作者可不选任一项
*/
ClarifyQuestion: {
/**
* Question
* @description 反问的澄清问题(一句话,指向本段的分歧点)
*/
question: string;
/**
* Options
* @description 24 个锚定本段/本章的具体走法选项;凑不出具体选项时为空列表
*/
options?: components["schemas"]["ClarifyOption"][];
/**
* Allow Free Text
* @description 是否允许自由输入(常驻兜底)
* @default true
*/
allow_free_text: boolean;
};
/** /**
* ConflictDecision * ConflictDecision
* @description 对最近一次审稿留痕里**某个冲突**(按其在 conflicts 列表的下标定位)的裁决。 * @description 对最近一次审稿留痕里**某个冲突**(按其在 conflicts 列表的下标定位)的裁决。
@@ -1757,6 +1911,23 @@ export interface components {
/** Tier Routing */ /** Tier Routing */
tier_routing?: components["schemas"]["TierRoutingInput"][]; tier_routing?: components["schemas"]["TierRoutingInput"][];
}; };
/**
* RefineClarifyRequest
* @description 润色预检澄清:选段 + 再沟通意见WFW-9 M1 路线A 两阶段之「问题」阶段)。
*
* 与 `RefineRequest` 独立——预检只判「意见清不清楚」,不改正文。`instruction` 为作者的
* 再沟通意见(可空/极短,正是触发反问的场景);带长度上界防超长入参。响应=`ClarifyDecision`
* (在 ww_agents端点直接返回供前端 gen:api 生成强类型客户端)。
*/
RefineClarifyRequest: {
/** Segment */
segment: string;
/**
* Instruction
* @default
*/
instruction: string;
};
/** /**
* RefineRequest * RefineRequest
* @description 回炉:重写选中段(可选改写指令)。 * @description 回炉:重写选中段(可选改写指令)。
@@ -1868,6 +2039,36 @@ export interface components {
/** Draft */ /** Draft */
draft?: string | null; draft?: string | null;
}; };
/**
* RewriteClarifyRequest
* @description 整章重写预检澄清:章级意见 + 可选当前草稿WFW-9 M2 路线A 两阶段之「问题」阶段)。
*
* 与 `RewriteStreamRequest` 独立——预检只判「作者这条章级意见清不清楚」,**不改正文**
* (整章重写仍走既有 rewrite SSE 端点)。`feedback` = 作者对整章的修改意见(触发反问的场景,
* 可极短/含糊);`prior_draft` = 当前整章草稿(可选,仅供锚定;端点只取有界摘录喂 analyst 预检,
* 不整章入 LLM。带长度上界防超长入参。响应=`ClarifyDecision`(在 ww_agents端点直接返回
* 供前端 gen:api 生成强类型客户端)。
*/
RewriteClarifyRequest: {
/** Feedback */
feedback: string;
/** Prior Draft */
prior_draft?: string | null;
};
/**
* RewriteStreamRequest
* @description POST /projects/:id/chapters/:no/rewrite整章再沟通/重写。
*
* `feedback` = 作者对本章的修改意见;`prior_draft` = 当前整章草稿(前端传入,据此 + 记忆
* 注入流式重写一版)。临时输入、不持久化、无迁移;只入 volatile守缓存前缀不变量 #9
* 新版停前端/草稿接受才落库HITL不变量 #3
*/
RewriteStreamRequest: {
/** Feedback */
feedback: string;
/** Prior Draft */
prior_draft: string;
};
/** /**
* RuleCreateRequest * RuleCreateRequest
* @description POST /projects/:id/rules新增一条规则。 * @description POST /projects/:id/rules新增一条规则。
@@ -2727,6 +2928,96 @@ export interface operations {
}; };
}; };
}; };
stream_rewrite_projects__project_id__chapters__chapter_no__rewrite_post: {
parameters: {
query?: never;
header?: never;
path: {
project_id: string;
chapter_no: number;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["RewriteStreamRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
clarify_rewrite_projects__project_id__chapters__chapter_no__rewrite_clarify_post: {
parameters: {
query?: never;
header?: never;
path: {
project_id: string;
chapter_no: number;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["RewriteClarifyRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ClarifyDecision"];
};
};
/** @description 项目不存在 */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
/** @description LLM 不可用 */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
};
};
review_chapter_projects__project_id__chapters__chapter_no__review_post: { review_chapter_projects__project_id__chapters__chapter_no__review_post: {
parameters: { parameters: {
query?: never; query?: never;
@@ -3237,6 +3528,60 @@ export interface operations {
}; };
}; };
}; };
clarify_refine_projects__project_id__chapters__chapter_no__refine_clarify_post: {
parameters: {
query?: never;
header?: never;
path: {
project_id: string;
chapter_no: number;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["RefineClarifyRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ClarifyDecision"];
};
};
/** @description 项目不存在 */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
/** @description LLM 不可用 */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
};
};
list_templates_templates_get: { list_templates_templates_get: {
parameters: { parameters: {
query?: never; query?: never;

View File

@@ -66,3 +66,21 @@ describe("fetchDraft", () => {
expect(await fetchDraft("p1", 9)).toBeNull(); expect(await fetchDraft("p1", 9)).toBeNull();
}); });
}); });
// parseJsonBody 边界校验:防御被代理/网关污染的非对象或非 JSON 响应。
describe("parseJsonBody structural validation", () => {
it("throws on a 200 body that is not a JSON object (primitive)", async () => {
mockFetch(200, "surprise-string");
await expect(fetchDraft("p1", 1)).rejects.toThrow("响应体形状异常");
});
it("throws when the 200 body is not valid JSON", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("<html>not json</html>", { status: 200 })),
);
await expect(fetchDraft("p1", 1)).rejects.toThrow("响应非 JSON");
});
});

View File

@@ -45,7 +45,7 @@ export function narrowJob(raw: unknown): JobView {
status: asStatus(dict["status"]), status: asStatus(dict["status"]),
progress: asProgress(dict["progress"]), progress: asProgress(dict["progress"]),
result: asResult(dict["result"]), result: asResult(dict["result"]),
error: typeof dict["error"] === "string" ? (dict["error"] as string) : null, error: typeof dict["error"] === "string" ? dict["error"] : null,
}; };
} }

View File

@@ -4,12 +4,26 @@ import { applyConflictFix, hasApplicableFix } from "./applyFix";
describe("hasApplicableFix", () => { describe("hasApplicableFix", () => {
it("requires non-empty original and string replacement", () => { it("requires non-empty original and string replacement", () => {
expect(hasApplicableFix("迈巴赫", "奔驰")).toBe(true); expect(hasApplicableFix({ original: "迈巴赫", replacement: "奔驰" })).toBe(true);
expect(hasApplicableFix("删我", "")).toBe(true); // 空串=删除,仍可应用 // 空串=删除,仍可应用
expect(hasApplicableFix(null, "奔驰")).toBe(false); expect(hasApplicableFix({ original: "删我", replacement: "" })).toBe(true);
expect(hasApplicableFix("", "奔驰")).toBe(false); expect(hasApplicableFix({ original: null, replacement: "奔驰" })).toBe(false);
expect(hasApplicableFix("迈巴赫", null)).toBe(false); expect(hasApplicableFix({ original: "", replacement: "奔驰" })).toBe(false);
expect(hasApplicableFix(undefined, undefined)).toBe(false); expect(hasApplicableFix({ original: "迈巴赫", replacement: null })).toBe(false);
expect(hasApplicableFix({ original: undefined, replacement: undefined })).toBe(
false,
);
});
it("narrows original/replacement to string when true (type predicate)", () => {
const patch: { original: string | null; replacement: string | null } = {
original: "迈巴赫",
replacement: "奔驰",
};
if (!hasApplicableFix(patch)) throw new Error("expected applicable");
// 编译期收窄:无需强转即可当 string 拼接(`as string` 已消除)。
const combined: string = patch.original + patch.replacement;
expect(combined).toBe("迈巴赫奔驰");
}); });
}); });

View File

@@ -6,15 +6,25 @@ export type FixOutcome =
| { status: "not-found"; text: string } // 有补丁但终稿里找不到原文(作者已改稿/漂移text 原样 | { status: "not-found"; text: string } // 有补丁但终稿里找不到原文(作者已改稿/漂移text 原样
| { status: "no-patch"; text: string }; // 该冲突无可自动应用的补丁text 原样 | { status: "no-patch"; text: string }; // 该冲突无可自动应用的补丁text 原样
// 已确认可应用的补丁original/replacement 均为 string
export interface ApplicablePatch {
original: string;
replacement: string;
}
// 松散补丁入参original/replacement 可能缺失/为 null审稿可局部修复时才带上
interface LoosePatch {
original: string | null | undefined;
replacement: string | null | undefined;
}
// 是否带可应用补丁original 非空 + replacement 为字符串(容许空串=删除)。 // 是否带可应用补丁original 非空 + replacement 为字符串(容许空串=删除)。
export function hasApplicableFix( // 类型谓词:命中即把 original/replacement 收窄为 string调用方无需强转。
original: string | null | undefined, export function hasApplicableFix(patch: LoosePatch): patch is ApplicablePatch {
replacement: string | null | undefined,
): boolean {
return ( return (
typeof original === "string" && typeof patch.original === "string" &&
original.length > 0 && patch.original.length > 0 &&
typeof replacement === "string" typeof patch.replacement === "string"
); );
} }
@@ -25,18 +35,20 @@ export function applyConflictFix(
original: string | null | undefined, original: string | null | undefined,
replacement: string | null | undefined, replacement: string | null | undefined,
): FixOutcome { ): FixOutcome {
if (!hasApplicableFix(original, replacement)) { const patch: LoosePatch = { original, replacement };
if (!hasApplicableFix(patch)) {
return { status: "no-patch", text }; return { status: "no-patch", text };
} }
// hasApplicableFix 已收窄为 string。 // 谓词已把 patch 收窄为 ApplicablePatchoriginal/replacement 皆为 string。
const from = original as string; const idx = text.indexOf(patch.original);
const to = replacement as string;
const idx = text.indexOf(from);
if (idx === -1) { if (idx === -1) {
return { status: "not-found", text }; return { status: "not-found", text };
} }
return { return {
status: "applied", status: "applied",
text: text.slice(0, idx) + to + text.slice(idx + from.length), text:
text.slice(0, idx) +
patch.replacement +
text.slice(idx + patch.original.length),
}; };
} }

View File

@@ -4,6 +4,7 @@ import { useCallback, useState } from "react";
import { api } from "@/lib/api/client"; import { api } from "@/lib/api/client";
import { useToast } from "@/components/Toast"; import { useToast } from "@/components/Toast";
import type { components } from "@/lib/api/schema";
import type { AcceptResponse } from "@/lib/api/types"; import type { AcceptResponse } from "@/lib/api/types";
import { buildAcceptRequest, type DecisionDraft } from "./decisions"; import { buildAcceptRequest, type DecisionDraft } from "./decisions";
@@ -29,12 +30,13 @@ export interface UseAccept {
) => Promise<AcceptOutcome>; ) => Promise<AcceptOutcome>;
} }
interface ApiErrorEnvelope { // 错误信封形状从后端 OpenAPI 生成的 schema 派生,避免与契约漂移(不再手抄字段)。
error?: { type ApiErrorEnvelope = components["schemas"]["ErrorEnvelope"];
code?: string;
message?: string; // accept 端点 409 CONFLICT_UNRESOLVED 在 details 里携带缺判下标;
details?: { missing_conflict_indices?: number[] } | null; // 生成 schema 的 details 为松散 `{[k]: unknown}`,此处就地收窄该端点专属字段。
}; interface ConflictErrorDetails {
missing_conflict_indices?: number[];
} }
// 验收:乐观置 accepting → 成功置 accepted呈现「本次将更新」清单 // 验收:乐观置 accepting → 成功置 accepted呈现「本次将更新」清单
@@ -59,7 +61,8 @@ export function useAccept(): UseAccept {
setStatus("error"); setStatus("error");
const env = error as ApiErrorEnvelope | undefined; const env = error as ApiErrorEnvelope | undefined;
const code = env?.error?.code; const code = env?.error?.code;
const missing = env?.error?.details?.missing_conflict_indices ?? []; const details = env?.error?.details as ConflictErrorDetails | undefined;
const missing = details?.missing_conflict_indices ?? [];
if (code === "CONFLICT_UNRESOLVED") { if (code === "CONFLICT_UNRESOLVED") {
return { result: null, missingIndices: missing, conflictUnresolved: true }; return { result: null, missingIndices: missing, conflictUnresolved: true };
} }

View File

@@ -0,0 +1,70 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { isAtLeast, SECURITY_FLOORS } from "./dependencyFloors";
// 读取本包 package.json 声明版本(相对本测试文件 ../../ → apps/web/package.json
function declaredVersions(): Record<string, string> {
const raw = readFileSync(
new URL("../../package.json", import.meta.url),
"utf-8",
);
const pkg = JSON.parse(raw) as {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
};
return { ...pkg.devDependencies, ...pkg.dependencies };
}
describe("SECURITY_FLOORS 锁定安全下界CR-C1", () => {
const declared = declaredVersions();
it.each(Object.keys(SECURITY_FLOORS))(
"%s 声明版本不得低于安全下界",
(name) => {
const version = declared[name];
if (version === undefined) {
throw new Error(`${name} 未在 package.json 声明`);
}
const floor = SECURITY_FLOORS[name as keyof typeof SECURITY_FLOORS];
expect(
isAtLeast(version, floor),
`${name}@${version} 低于安全下界 ${floor}`,
).toBe(true);
},
);
});
describe("isAtLeast 语义版本比较", () => {
it("相等即满足", () => {
expect(isAtLeast("15.5.20", "15.5.20")).toBe(true);
});
it("patch 更高满足、更低不满足", () => {
expect(isAtLeast("19.2.7", "19.2.0")).toBe(true);
expect(isAtLeast("19.2.0", "19.2.7")).toBe(false);
});
it("minor 边界", () => {
expect(isAtLeast("15.5.0", "15.1.3")).toBe(true);
expect(isAtLeast("15.1.3", "15.5.20")).toBe(false);
});
it("major 边界", () => {
expect(isAtLeast("19.0.0", "18.9.9")).toBe(true);
expect(isAtLeast("18.9.9", "19.0.0")).toBe(false);
});
it("去除前导 ^ / ~ 范围符", () => {
expect(isAtLeast("^19.2.7", "19.2.7")).toBe(true);
expect(isAtLeast("~19.2.7", "19.2.7")).toBe(true);
expect(isAtLeast("19.2.0", "^19.2.7")).toBe(false);
});
it("缺省段按 0 计", () => {
expect(isAtLeast("15", "15.0.0")).toBe(true);
expect(isAtLeast("15.5", "15.5.0")).toBe(true);
expect(isAtLeast("15", "15.5.0")).toBe(false);
});
});

View File

@@ -0,0 +1,30 @@
// 依赖安全下界守卫:锁定 next/react/react-dom 不得低于修补 RCE / 安全补丁的最低版本。
// dependencyFloors.test.ts 读取 package.json 声明版本比对回归防止误降级回易受攻击版本CR-C1
export const SECURITY_FLOORS = {
next: "15.5.20",
react: "19.2.7",
"react-dom": "19.2.7",
} as const;
// 语义版本比较version >= floor仅比对 major.minor.patch忽略预发布 / 构建元数据)。
// 去除前导 ^ / ~ 范围符;缺省段按 0 计;非数字段按 0 计。
export function isAtLeast(version: string, floor: string): boolean {
const a = parseSemver(version);
const b = parseSemver(floor);
for (let i = 0; i < 3; i += 1) {
const av = a[i] ?? 0;
const bv = b[i] ?? 0;
if (av > bv) return true;
if (av < bv) return false;
}
return true;
}
function parseSemver(raw: string): number[] {
const cleaned = raw.trim().replace(/^[\^~]/, "");
return cleaned.split(".").map((part) => {
const n = Number.parseInt(part, 10);
return Number.isNaN(n) ? 0 : n;
});
}

View File

@@ -0,0 +1,134 @@
// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
DRAFT_FIRST_CHAPTER,
DRAFT_PLACEHOLDER_TITLE,
useDeferredDraft,
} from "./useDeferredDraft";
// 后端客户端与 Toast 是 hook 的外部副作用边界,单测一律 mock。
const post = vi.fn();
const put = vi.fn();
const toast = vi.fn();
vi.mock("@/lib/api/client", () => ({
api: {
POST: (...a: unknown[]) => post(...a),
PUT: (...a: unknown[]) => put(...a),
},
}));
vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
describe("useDeferredDraft", () => {
beforeEach(() => {
post.mockReset();
put.mockReset();
toast.mockReset();
});
afterEach(() => vi.clearAllMocks());
it("初始为 idle", () => {
const { result } = renderHook(() => useDeferredDraft());
expect(result.current.status).toBe("idle");
});
it("首次落库:懒建占位项目 + 用已敲入正文种首章草稿,返回新项目 id", async () => {
post.mockResolvedValue({ data: { id: "p1" }, error: null });
put.mockResolvedValue({ data: {}, error: null });
const { result } = renderHook(() => useDeferredDraft());
let id: string | null = null;
await act(async () => {
id = await result.current.ensureCreated("第一段正文");
});
expect(id).toBe("p1");
expect(result.current.status).toBe("created");
expect(post).toHaveBeenCalledTimes(1);
expect(post).toHaveBeenCalledWith("/projects", {
body: { title: DRAFT_PLACEHOLDER_TITLE },
});
expect(put).toHaveBeenCalledWith(
"/projects/{project_id}/chapters/{chapter_no}/draft",
{
params: { path: { project_id: "p1", chapter_no: DRAFT_FIRST_CHAPTER } },
body: { text: "第一段正文" },
},
);
expect(toast).not.toHaveBeenCalled();
});
it("幂等:已建后再次调用复用同一 id绝不重复 POST", async () => {
post.mockResolvedValue({ data: { id: "p1" }, error: null });
put.mockResolvedValue({ data: {}, error: null });
const { result } = renderHook(() => useDeferredDraft());
await act(async () => {
await result.current.ensureCreated("正文一");
});
let second: string | null = null;
await act(async () => {
second = await result.current.ensureCreated("正文二");
});
expect(second).toBe("p1");
expect(post).toHaveBeenCalledTimes(1);
});
it("正文为空白:仍懒建项目但跳过草稿落库", async () => {
post.mockResolvedValue({ data: { id: "p1" }, error: null });
const { result } = renderHook(() => useDeferredDraft());
await act(async () => {
await result.current.ensureCreated(" ");
});
expect(post).toHaveBeenCalledTimes(1);
expect(put).not.toHaveBeenCalled();
});
it("创建失败status=error、弹错误 toast、返回 null、不落草稿", async () => {
post.mockResolvedValue({ data: null, error: { detail: "配额不足" } });
const { result } = renderHook(() => useDeferredDraft());
let id: string | null = "sentinel";
await act(async () => {
id = await result.current.ensureCreated("正文");
});
expect(id).toBeNull();
expect(result.current.status).toBe("error");
expect(put).not.toHaveBeenCalled();
expect(toast).toHaveBeenCalledWith(expect.any(String), "error");
});
it("草稿落库失败为非致命:项目已建仍返回 id提示但不阻断", async () => {
post.mockResolvedValue({ data: { id: "p1" }, error: null });
put.mockResolvedValue({ data: null, error: { detail: "写库失败" } });
const { result } = renderHook(() => useDeferredDraft());
let id: string | null = null;
await act(async () => {
id = await result.current.ensureCreated("正文");
});
expect(id).toBe("p1");
expect(result.current.status).toBe("created");
expect(toast).toHaveBeenCalledWith(expect.any(String), "info");
});
it("请求抛异常status=error、弹网络异常 toast、返回 null", async () => {
post.mockRejectedValue(new Error("network down"));
const { result } = renderHook(() => useDeferredDraft());
let id: string | null = "sentinel";
await act(async () => {
id = await result.current.ensureCreated("正文");
});
expect(id).toBeNull();
expect(result.current.status).toBe("error");
expect(toast).toHaveBeenCalledWith(expect.any(String), "error");
});
});

View File

@@ -0,0 +1,95 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { api } from "@/lib/api/client";
import { useToast } from "@/components/Toast";
// 进来即写(延迟落库):落地页「直接开始写」直达空白编辑器,**只有敲入实质正文后**
// 才懒建占位项目并把已敲入正文种进首章草稿。空进空出不落库,杜绝孤儿「未命名草稿」。
export const DRAFT_PLACEHOLDER_TITLE = "未命名草稿";
export const DRAFT_FIRST_CHAPTER = 1;
export type DeferredStatus = "idle" | "creating" | "created" | "error";
export interface UseDeferredDraft {
status: DeferredStatus;
// 首次有实质正文时调用:懒建占位项目 + 落首章草稿,返回新项目 id调用方据此跳转编辑器
// 幂等:已建/在建时复用同一结果,绝不重复 POST。失败返回 null不跳转
ensureCreated: (text: string) => Promise<string | null>;
}
export function useDeferredDraft(): UseDeferredDraft {
const [status, setStatus] = useState<DeferredStatus>("idle");
const toast = useToast();
// 已建项目 id 与在途 Promise双重幂等闸防抖多次触发只落一次库。
const createdIdRef = useRef<string | null>(null);
const inflightRef = useRef<Promise<string | null> | null>(null);
const ensureCreated = useCallback(
(text: string): Promise<string | null> => {
if (createdIdRef.current) return Promise.resolve(createdIdRef.current);
if (inflightRef.current) return inflightRef.current;
const run = createDraftProject(text, toast, setStatus).then((id) => {
inflightRef.current = null;
if (id) createdIdRef.current = id;
return id;
});
inflightRef.current = run;
return run;
},
[toast],
);
return { status, ensureCreated };
}
type Toast = ReturnType<typeof useToast>;
// 懒建项目 + 落首章草稿(草稿失败为非致命:项目已建,正文仍在前端,进编辑器后自动重存)。
async function createDraftProject(
text: string,
toast: Toast,
setStatus: (s: DeferredStatus) => void,
): Promise<string | null> {
setStatus("creating");
try {
const { data, error } = await api.POST("/projects", {
body: { title: DRAFT_PLACEHOLDER_TITLE },
});
if (error || !data) {
setStatus("error");
toast("创建草稿失败,请重试", "error");
return null;
}
await seedFirstChapter(data.id, text, toast);
setStatus("created");
return data.id;
} catch {
setStatus("error");
toast("创建草稿异常,请检查网络", "error");
return null;
}
}
// 把已敲入正文种进首章草稿;空白正文跳过。落库失败仅提示、不抛(项目已建,不阻断进入编辑器)。
async function seedFirstChapter(
projectId: string,
text: string,
toast: Toast,
): Promise<void> {
if (text.trim().length === 0) return;
const { error } = await api.PUT(
"/projects/{project_id}/chapters/{chapter_no}/draft",
{
params: {
path: { project_id: projectId, chapter_no: DRAFT_FIRST_CHAPTER },
},
body: { text },
},
);
if (error) {
toast("首段草稿保存失败,进入编辑器后会自动重试", "info");
}
}

View File

@@ -85,7 +85,7 @@ export class SseFrameBuffer {
let idx: number; let idx: number;
// 块之间以空行(\n\n兼容 \r\n\r\n分隔。 // 块之间以空行(\n\n兼容 \r\n\r\n分隔。
while ((idx = this.findBoundary(this.buf)) !== -1) { while ((idx = this.findBoundary(this.buf)) !== -1) {
const block = this.buf.slice(0, idx.valueOf()); const block = this.buf.slice(0, idx);
this.buf = this.buf.slice(this.boundaryEnd(this.buf, idx)); this.buf = this.buf.slice(this.boundaryEnd(this.buf, idx));
const evt = parseSseBlock(block); const evt = parseSseBlock(block);
if (evt) events.push(evt); if (evt) events.push(evt);

View File

@@ -85,7 +85,7 @@ describe("useDraftStream", () => {
await result.current.start("p1", 2, " 写得热血一点 "); await result.current.start("p1", 2, " 写得热血一点 ");
}); });
const [, init] = fetchMock.mock.calls[0]; const [, init] = fetchMock.mock.calls[0]!;
expect(init.method).toBe("POST"); expect(init.method).toBe("POST");
expect(init.headers["Content-Type"]).toBe("application/json"); expect(init.headers["Content-Type"]).toBe("application/json");
expect(JSON.parse(init.body as string)).toEqual({ directive: "写得热血一点" }); expect(JSON.parse(init.body as string)).toEqual({ directive: "写得热血一点" });
@@ -99,7 +99,7 @@ describe("useDraftStream", () => {
await result.current.start("p1", 2, " "); await result.current.start("p1", 2, " ");
}); });
const [, init] = fetchMock.mock.calls[0]; const [, init] = fetchMock.mock.calls[0]!;
expect(init.body).toBeUndefined(); expect(init.body).toBeUndefined();
expect(init.headers["Content-Type"]).toBeUndefined(); expect(init.headers["Content-Type"]).toBeUndefined();
}); });

View File

@@ -0,0 +1,19 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
// CR-H10 回归守卫errorCode 只应有一份权威实现lib/generation/cards
// style 侧两个 hook 不得再各自本地定义 `function errorCode(`,须统一 import。
describe("errorCode 去重CR-H10", () => {
it.each(["useRefine.ts", "useStyleLearn.ts"])(
"%s 不再本地定义 errorCode且从 generation/cards 引入",
(file) => {
const src = readFileSync(new URL(`./${file}`, import.meta.url), "utf-8");
expect(src).not.toMatch(/function\s+errorCode\s*\(/);
expect(src).toMatch(
/import\s*\{[^}]*\berrorCode\b[^}]*\}\s*from\s*["']@\/lib\/generation\/cards["']/,
);
},
);
});

View File

@@ -122,4 +122,40 @@ describe("useRefine", () => {
expect(result.current.status).toBe("idle"); expect(result.current.status).toBe("idle");
expect(result.current.result).toBeNull(); expect(result.current.result).toBeNull();
}); });
it("中止后迟到的响应不覆盖状态(段切换竞态)", async () => {
// Arrangepost 挂起,手动兑现以模拟在途请求。
let resolvePost!: (v: unknown) => void;
const pending = new Promise((r) => {
resolvePost = r;
});
post.mockReturnValue(pending);
const { result } = renderHook(() => useRefine());
// Act发起回炉进入 refining随后 abort模拟切段取消在途
let refinePromise!: Promise<unknown>;
act(() => {
refinePromise = result.current.refine("p1", 1, "新段");
});
expect(result.current.status).toBe("refining");
act(() => {
result.current.abort();
});
// 迟到的旧响应兑现——应被丢弃:不写状态、不弹 toast。
await act(async () => {
resolvePost({
data: { original: "旧段", refined: "旧改写" },
error: null,
});
await refinePromise;
});
// Assert结果仍为空、未落到 done、未误报 toast。
expect(result.current.result).toBeNull();
expect(result.current.status).not.toBe("done");
expect(toast).not.toHaveBeenCalled();
});
}); });

View File

@@ -1,9 +1,10 @@
"use client"; "use client";
import { useCallback, useState } from "react"; import { useCallback, useRef, useState } from "react";
import { api } from "@/lib/api/client"; import { api } from "@/lib/api/client";
import { useToast } from "@/components/Toast"; import { useToast } from "@/components/Toast";
import { errorCode } from "@/lib/generation/cards";
import { buildRefineRequest } from "./style"; import { buildRefineRequest } from "./style";
export type RefineStatus = "idle" | "refining" | "done" | "error"; export type RefineStatus = "idle" | "refining" | "done" | "error";
@@ -23,24 +24,25 @@ export interface UseRefine {
segment: string, segment: string,
instruction?: string, instruction?: string,
) => Promise<RefineResult | null>; ) => Promise<RefineResult | null>;
// 取消在途回炉(段切换/卸载时调用)——不触碰 state安全用于 effect 清理。
abort: () => void;
reset: () => void; reset: () => void;
} }
function errorCode(error: unknown): string | undefined {
if (typeof error !== "object" || error === null) return undefined;
const env = error as { error?: { code?: unknown } };
return typeof env.error?.code === "string" ? env.error.code : undefined;
}
// 回炉POST .../refine同步返回新旧 diff不写库不变量#3 // 回炉POST .../refine同步返回新旧 diff不写库不变量#3
// 503 LLM_UNAVAILABLE无凭据优雅提示去设置其余失败 toast。 // 503 LLM_UNAVAILABLE无凭据优雅提示去设置其余失败 toast。
export function useRefine(): UseRefine { export function useRefine(): UseRefine {
const [status, setStatus] = useState<RefineStatus>("idle"); const [status, setStatus] = useState<RefineStatus>("idle");
const [result, setResult] = useState<RefineResult | null>(null); const [result, setResult] = useState<RefineResult | null>(null);
const controllerRef = useRef<AbortController | null>(null);
const toast = useToast(); const toast = useToast();
const refine = useCallback<UseRefine["refine"]>( const refine = useCallback<UseRefine["refine"]>(
async (projectId, chapterNo, segment, instruction) => { async (projectId, chapterNo, segment, instruction) => {
// 取消上一次在途请求,换新 controller段切换竞态防护CR-H12
controllerRef.current?.abort();
const controller = new AbortController();
controllerRef.current = controller;
setStatus("refining"); setStatus("refining");
setResult(null); setResult(null);
try { try {
@@ -51,8 +53,11 @@ export function useRefine(): UseRefine {
path: { project_id: projectId, chapter_no: chapterNo }, path: { project_id: projectId, chapter_no: chapterNo },
}, },
body: buildRefineRequest(segment, instruction), body: buildRefineRequest(segment, instruction),
signal: controller.signal,
}, },
); );
// 已被后续请求/卸载中止:丢弃迟到响应,不写状态。
if (controller.signal.aborted) return null;
if (error || !data) { if (error || !data) {
setStatus("error"); setStatus("error");
const code = errorCode(error); const code = errorCode(error);
@@ -72,6 +77,8 @@ export function useRefine(): UseRefine {
setStatus("done"); setStatus("done");
return outcome; return outcome;
} catch { } catch {
// 主动中止abort触发的异常不该报错 toast。
if (controller.signal.aborted) return null;
setStatus("error"); setStatus("error");
toast("回炉请求异常,请检查网络。", "error"); toast("回炉请求异常,请检查网络。", "error");
return null; return null;
@@ -80,10 +87,18 @@ export function useRefine(): UseRefine {
[toast], [toast],
); );
// 仅取消在途请求,不触碰 state——可安全用于 effect 清理 / 卸载。
const abort = useCallback((): void => {
controllerRef.current?.abort();
controllerRef.current = null;
}, []);
const reset = useCallback((): void => { const reset = useCallback((): void => {
controllerRef.current?.abort();
controllerRef.current = null;
setStatus("idle"); setStatus("idle");
setResult(null); setResult(null);
}, []); }, []);
return { status, result, refine, reset }; return { status, result, refine, abort, reset };
} }

View File

@@ -6,6 +6,7 @@ import { api } from "@/lib/api/client";
import { useToast } from "@/components/Toast"; import { useToast } from "@/components/Toast";
import { useJobPoll } from "@/lib/jobs/useJobPoll"; import { useJobPoll } from "@/lib/jobs/useJobPoll";
import { styleLearnResult } from "@/lib/jobs/job"; import { styleLearnResult } from "@/lib/jobs/job";
import { errorCode } from "@/lib/generation/cards";
import { import {
buildLearnRequest, buildLearnRequest,
normalizeFingerprint, normalizeFingerprint,
@@ -27,12 +28,6 @@ export interface UseStyleLearn {
) => Promise<boolean>; ) => Promise<boolean>;
} }
function errorCode(error: unknown): string | undefined {
if (typeof error !== "object" || error === null) return undefined;
const env = error as { error?: { code?: unknown } };
return typeof env.error?.code === "string" ? env.error.code : undefined;
}
// 学文风编排受理202 job_id→ useJobPoll 轮询 → done 拉 GET /style 展示指纹。 // 学文风编排受理202 job_id→ useJobPoll 轮询 → done 拉 GET /style 展示指纹。
export function useStyleLearn(initial: Fingerprint | null): UseStyleLearn { export function useStyleLearn(initial: Fingerprint | null): UseStyleLearn {
const [fingerprint, setFingerprint] = useState<Fingerprint | null>(initial); const [fingerprint, setFingerprint] = useState<Fingerprint | null>(initial);

View File

@@ -0,0 +1,52 @@
// @vitest-environment jsdom
import { renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useToolboxTools } from "./useToolboxTools";
const get = vi.fn();
const toast = vi.fn();
vi.mock("@/lib/api/client", () => ({
api: { GET: (...a: unknown[]) => get(...a) },
}));
vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
describe("useToolboxTools", () => {
beforeEach(() => {
get.mockReset();
toast.mockReset();
});
afterEach(() => vi.clearAllMocks());
it("加载成功拉全量描述符status=ready", async () => {
const tools = [{ key: "golden-finger", title: "金手指生成器", is_legacy: false }];
get.mockResolvedValue({ data: { tools }, error: null });
const { result } = renderHook(() => useToolboxTools());
await waitFor(() => expect(result.current.status).toBe("ready"));
expect(result.current.tools).toEqual(tools);
expect(get).toHaveBeenCalledWith("/skills/toolbox", {});
expect(toast).not.toHaveBeenCalled();
});
it("data.tools 缺省时回退空数组", async () => {
get.mockResolvedValue({ data: { tools: null }, error: null });
const { result } = renderHook(() => useToolboxTools());
await waitFor(() => expect(result.current.status).toBe("ready"));
expect(result.current.tools).toEqual([]);
});
it("后端 errorstatus=error、弹错误 toast", async () => {
get.mockResolvedValue({ data: null, error: { detail: "boom" } });
const { result } = renderHook(() => useToolboxTools());
await waitFor(() => expect(result.current.status).toBe("error"));
expect(toast).toHaveBeenCalledWith(expect.any(String), "error");
});
it("请求抛异常status=error、弹网络异常 toast", async () => {
get.mockRejectedValue(new Error("network down"));
const { result } = renderHook(() => useToolboxTools());
await waitFor(() => expect(result.current.status).toBe("error"));
expect(toast).toHaveBeenCalledWith("工具箱加载异常,请检查网络。", "error");
});
});

View File

@@ -0,0 +1,48 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api/client";
import { useToast } from "@/components/Toast";
import type { ToolDescriptorView } from "@/lib/api/types";
// 客户端拉取创作工具箱全量描述符GET /skills/toolbox供编辑器内联工具箱调用。
// 描述符只读、少变,进面板时拉一次即可。
export type ToolboxStatus = "loading" | "ready" | "error";
export interface UseToolboxTools {
status: ToolboxStatus;
tools: ToolDescriptorView[];
}
export function useToolboxTools(): UseToolboxTools {
const [status, setStatus] = useState<ToolboxStatus>("loading");
const [tools, setTools] = useState<ToolDescriptorView[]>([]);
const toast = useToast();
useEffect(() => {
let cancelled = false;
void (async (): Promise<void> => {
try {
const { data, error } = await api.GET("/skills/toolbox", {});
if (cancelled) return;
if (error || !data) {
setStatus("error");
toast("工具箱加载失败,请重试。", "error");
return;
}
setTools(data.tools ?? []);
setStatus("ready");
} catch {
if (cancelled) return;
setStatus("error");
toast("工具箱加载异常,请检查网络。", "error");
}
})();
return () => {
cancelled = true;
};
}, [toast]);
return { status, tools };
}

View File

@@ -0,0 +1,156 @@
import { describe, expect, it } from "vitest";
import {
PROCEED,
foldClarifications,
needsClarifyGate,
toVM,
type ClarifyAnswer,
} from "./clarify";
describe("foldClarifications", () => {
it("原意见在前,每条澄清另起一行并保序", () => {
const answers: ClarifyAnswer[] = [
{ question: "这段谁在说话?", answer: "主角内心独白" },
{ question: "结尾要留钩子吗?", answer: "留一个悬念" },
];
const result = foldClarifications("收紧节奏", answers);
expect(result).toBe(
"收紧节奏\n已澄清这段谁在说话 → 主角内心独白\n已澄清结尾要留钩子吗 → 留一个悬念",
);
});
it("无回答时原样返回去空白后的 instruction", () => {
expect(foldClarifications(" 收紧节奏 ", [])).toBe("收紧节奏");
});
it("instruction 为空时只输出澄清行", () => {
const answers: ClarifyAnswer[] = [
{ question: "谁在说话?", answer: "配角旁白" },
];
expect(foldClarifications(" ", answers)).toBe("已澄清:谁在说话? → 配角旁白");
});
it("跳过空/纯空白回答的条目", () => {
const answers: ClarifyAnswer[] = [
{ question: "Q1", answer: " " },
{ question: "Q2", answer: "有效回答" },
];
expect(foldClarifications("原意见", answers)).toBe(
"原意见\n已澄清Q2 → 有效回答",
);
});
it("问题为空(自由输入)时省略问题只留答案", () => {
const answers: ClarifyAnswer[] = [{ question: " ", answer: "就按我说的改" }];
expect(foldClarifications("原意见", answers)).toBe(
"原意见\n已澄清就按我说的改",
);
});
it("去除问题与答案两端空白", () => {
const answers: ClarifyAnswer[] = [
{ question: " 谁在说话? ", answer: " 主角 " },
];
expect(foldClarifications("x", answers)).toBe("x\n已澄清谁在说话 → 主角");
});
it("不可变:不修改入参数组", () => {
const answers: ClarifyAnswer[] = [{ question: "Q", answer: "A" }];
const snapshot = JSON.parse(JSON.stringify(answers));
foldClarifications("原意见", answers);
expect(answers).toEqual(snapshot);
});
it("全部为空回答且 instruction 为空时返回空串", () => {
expect(foldClarifications("", [{ question: "Q", answer: "" }])).toBe("");
});
});
describe("toVM", () => {
it("snake_case ClarifyDecision → camelCase VM逐字段映射", () => {
const vm = toVM({
need_clarification: true,
questions: [
{
question: "想更冷峻还是更抒情?",
options: [
{ label: "冷峻", value: "改得更冷峻克制" },
{ label: "抒情", value: "改得更抒情" },
],
allow_free_text: true,
},
],
verification: "我这样理解对吗",
});
expect(vm).toEqual({
needClarification: true,
questions: [
{
question: "想更冷峻还是更抒情?",
options: [
{ label: "冷峻", value: "改得更冷峻克制" },
{ label: "抒情", value: "改得更抒情" },
],
allowFreeText: true,
},
],
verification: "我这样理解对吗",
});
});
it("字段缺省时取安全默认(空/false/undefined不信任外部数据", () => {
const vm = toVM({});
expect(vm).toEqual({
needClarification: false,
questions: [],
verification: undefined,
});
});
it("questions/options 内字段缺省时逐项补默认", () => {
const vm = toVM({
need_clarification: true,
questions: [{ question: undefined, options: [{}], allow_free_text: undefined }],
verification: null,
});
expect(vm).toEqual({
needClarification: true,
questions: [
{ question: "", options: [{ label: "", value: "" }], allowFreeText: false },
],
verification: undefined,
});
});
});
describe("PROCEED", () => {
it("放行常量:不反问、无问题", () => {
expect(PROCEED).toEqual({ needClarification: false, questions: [] });
});
});
describe("needsClarifyGate", () => {
it("去空白后长度小于 minChars 时需要预检", () => {
expect(needsClarifyGate("改一下", 8)).toBe(true);
});
it("恰好等于 minChars 时不需预检(边界)", () => {
expect(needsClarifyGate("12345678", 8)).toBe(false);
});
it("超过 minChars 时不需预检", () => {
expect(needsClarifyGate("这是一段足够清晰具体的润色意见", 8)).toBe(false);
});
it("纯空白按空计(长度 0 < minChars需预检", () => {
expect(needsClarifyGate(" ", 8)).toBe(true);
});
it("先去两端空白再计长度", () => {
expect(needsClarifyGate(" 改 ", 2)).toBe(true);
});
});

View File

@@ -0,0 +1,94 @@
// AI 反问澄清(纯逻辑 + 前端 view-model 类型WFW-9 M1路线A 两阶段)。
// 「润色/再沟通」意见不清晰时,先走独立非流式 JSON 预检端点让 AI 反问给选项,
// 作者点选/自由输入后,把答案「折进」既有 refine 的 instruction 字符串(零迁移、不动 refine 端点)。
// 这里只放确定性纯函数 + 本地 VM 类型camelCase与后端 snake_case ClarifyDecision 的映射由主线接线时完成。
// 澄清选项(锚定本段/本章的一种具体走法)。
export interface ClarifyOptionVM {
label: string;
value: string;
}
// 一个澄清问题:反问 + 24 个具体选项(可为空退化为纯自由输入)+ 常驻自由输入兜底。
export interface ClarifyQuestionVM {
question: string;
options: ClarifyOptionVM[];
allowFreeText: boolean;
}
// 一回合预检结论(对应后端 ClarifyDecision
export interface ClarifyDecisionVM {
needClarification: boolean;
// v1 硬上限 1 问needClarification=false 时为空。
questions: ClarifyQuestionVM[];
// needClarification=false 时给一句「我这样理解对吗」确认语,可空。
verification?: string;
}
// 作者对某个澄清问题的回答(点选回填 value 或自由输入文本)。
export interface ClarifyAnswer {
question: string;
answer: string;
}
const CLARIFY_PREFIX = "已澄清:";
const CLARIFY_ARROW = " → ";
const LINE_SEPARATOR = "\n";
// 把作者对各澄清问题的回答逐条折进 instruction原意见在前每条澄清另起一行供既有 refine 端点直接使用。
// 纯函数、确定性、不可变:不改动入参;跳过空回答;保留传入顺序。
export function foldClarifications(
instruction: string,
answers: readonly ClarifyAnswer[],
): string {
const lines = answers
.map((entry) => ({
question: entry.question.trim(),
answer: entry.answer.trim(),
}))
.filter((entry) => entry.answer.length > 0)
.map((entry) =>
entry.question
? `${CLARIFY_PREFIX}${entry.question}${CLARIFY_ARROW}${entry.answer}`
: `${CLARIFY_PREFIX}${entry.answer}`,
);
const trimmedInstruction = instruction.trim();
const parts = trimmedInstruction ? [trimmedInstruction, ...lines] : lines;
return parts.join(LINE_SEPARATOR);
}
// 门控instruction 去空白后长度不足 minChars意见太空/太短)才需要预检反问;清晰意见直接走 refine 不发问。
export function needsClarifyGate(instruction: string, minChars: number): boolean {
return instruction.trim().length < minChars;
}
// 澄清预检结论为「放行」(直接去 refine/rewrite不反问——后端 error/前端异常时的确定性回退。
export const PROCEED: ClarifyDecisionVM = { needClarification: false, questions: [] };
// 后端 snake_case ClarifyDecision 的原始形状(外部数据,全部可选——不信任、显式取值 + 默认)。
export interface ClarifyDecisionRaw {
need_clarification?: boolean;
questions?: {
question?: string;
options?: { label?: string; value?: string }[];
allow_free_text?: boolean;
}[];
verification?: string | null;
}
// 后端 snake_case ClarifyDecision → 前端 camelCase VM纯映射refine/rewrite 两侧预检共用)。
export function toVM(data: ClarifyDecisionRaw): ClarifyDecisionVM {
return {
needClarification: Boolean(data.need_clarification),
questions: (data.questions ?? []).map((q) => ({
question: q.question ?? "",
options: (q.options ?? []).map((o) => ({
label: o.label ?? "",
value: o.value ?? "",
})),
allowFreeText: Boolean(q.allow_free_text),
})),
verification: data.verification ?? undefined,
};
}

View File

@@ -0,0 +1,112 @@
import { describe, expect, it } from "vitest";
import type { OutlineChapterView, WorldEntityCardView } from "@/lib/api/types";
import {
CONTEXT_TABS,
contextTabHref,
toCodexGroups,
toOutlineRows,
} from "./contextDrawer";
const entity = (
type: string,
name: string,
rules: string[] = [],
): WorldEntityCardView => ({ type, name, rules });
describe("contextTabHref", () => {
it("按 Tab key 拼出完整编辑页路由", () => {
// Arrange / Act / Assert
expect(contextTabHref("p1", "codex")).toBe("/projects/p1/codex");
expect(contextTabHref("p1", "foreshadow")).toBe("/projects/p1/foreshadow");
expect(contextTabHref("p1", "style")).toBe("/projects/p1/style");
});
it("五个 Tab 覆盖全部读为主入口", () => {
expect(CONTEXT_TABS.map((t) => t.key)).toEqual([
"codex",
"outline",
"foreshadow",
"rules",
"style",
]);
});
});
describe("toCodexGroups", () => {
it("空列表返回空分组", () => {
expect(toCodexGroups([])).toEqual([]);
});
it("按 type 归组并保持首次出现顺序", () => {
const entities = [
entity("势力", "雾港议会"),
entity("地理", "北境"),
entity("势力", "红手会"),
];
const groups = toCodexGroups(entities);
expect(groups.map((g) => g.type)).toEqual(["势力", "地理"]);
expect(groups[0]?.entities.map((e) => e.name)).toEqual([
"雾港议会",
"红手会",
]);
expect(groups[1]?.entities.map((e) => e.name)).toEqual(["北境"]);
});
it("空白/缺省 type 归入「未分类」", () => {
const groups = toCodexGroups([entity(" ", "无名之物")]);
expect(groups[0]?.type).toBe("未分类");
});
it("不修改入参数组", () => {
const entities = [entity("势力", "甲"), entity("势力", "乙")];
const snapshot = [...entities];
toCodexGroups(entities);
expect(entities).toEqual(snapshot);
});
});
describe("toOutlineRows", () => {
const chapter = (
no: number,
volume: number,
beats: string[],
codes: string[] = [],
): OutlineChapterView => ({
no,
volume,
beats,
foreshadow_windows: codes.map((code) => ({ code })),
});
it("空列表返回空数组", () => {
expect(toOutlineRows([])).toEqual([]);
});
it("按章号升序整形并抽出节拍与伏笔代号", () => {
const rows = toOutlineRows([
chapter(2, 1, ["决战"], ["F2"]),
chapter(1, 1, ["开场", "引入"], ["F1"]),
]);
expect(rows.map((r) => r.no)).toEqual([1, 2]);
expect(rows[0]?.beats).toEqual(["开场", "引入"]);
expect(rows[0]?.foreshadowCodes).toEqual(["F1"]);
expect(rows[1]?.foreshadowCodes).toEqual(["F2"]);
});
it("缺省 beats / foreshadow_windows 回退为空数组", () => {
const rows = toOutlineRows([{ no: 1, volume: 1 }]);
expect(rows[0]?.beats).toEqual([]);
expect(rows[0]?.foreshadowCodes).toEqual([]);
});
it("不修改入参数组", () => {
const chapters = [chapter(2, 1, []), chapter(1, 1, [])];
const snapshot = chapters.map((c) => c.no);
toOutlineRows(chapters);
expect(chapters.map((c) => c.no)).toEqual(snapshot);
});
});

View File

@@ -0,0 +1,84 @@
import type { OutlineChapterView, WorldEntityCardView } from "@/lib/api/types";
// 速查抽屉ContextDrawer的 Tab 配置与纯整形逻辑。
// 视图无关、无副作用,供组件与单测复用(组件本身不进 vitest 范围)。
export type ContextTabKey =
| "codex"
| "outline"
| "foreshadow"
| "rules"
| "style";
export interface ContextTab {
key: ContextTabKey;
label: string;
// 完整编辑页路由后缀(拼在 /projects/{id}/ 之后)。
routeSegment: string;
}
// Tab 顺序即抽屉里的呈现顺序:读为主的设定入口,设定库/大纲在前(本期做完整)。
export const CONTEXT_TABS: readonly ContextTab[] = [
{ key: "codex", label: "设定库", routeSegment: "codex" },
{ key: "outline", label: "大纲", routeSegment: "outline" },
{ key: "foreshadow", label: "伏笔", routeSegment: "foreshadow" },
{ key: "rules", label: "规则", routeSegment: "rules" },
{ key: "style", label: "文风", routeSegment: "style" },
];
// 抽屉只读速查 →「去完整页编辑」链接。未知 key 回退用 key 作后缀(防御)。
export function contextTabHref(projectId: string, key: ContextTabKey): string {
const tab = CONTEXT_TABS.find((t) => t.key === key);
const segment = tab?.routeSegment ?? key;
return `/projects/${projectId}/${segment}`;
}
export interface CodexGroup {
type: string;
entities: WorldEntityCardView[];
}
// 设定库实体按 type 归组,保持首次出现顺序(紧凑分组展示)。空 type 归「未分类」。
// 不可变:不改入参,返回全新分组数组。
export function toCodexGroups(
entities: readonly WorldEntityCardView[],
): CodexGroup[] {
const groups: CodexGroup[] = [];
const indexByType = new Map<string, number>();
for (const entity of entities) {
const type = entity.type.trim() || "未分类";
const idx = indexByType.get(type);
if (idx === undefined) {
indexByType.set(type, groups.length);
groups.push({ type, entities: [entity] });
} else {
const group = groups.at(idx);
if (group) {
groups[idx] = { type: group.type, entities: [...group.entities, entity] };
}
}
}
return groups;
}
export interface OutlineRow {
no: number;
volume: number;
beats: string[];
foreshadowCodes: string[];
}
// 大纲章节整形为紧凑速查行:按章号升序,抽出节拍与该章伏笔窗口代号。
// 不可变:先复制再排序,不改入参。
export function toOutlineRows(
chapters: readonly OutlineChapterView[],
): OutlineRow[] {
return [...chapters]
.sort((a, b) => a.no - b.no)
.map((chapter) => ({
no: chapter.no,
volume: chapter.volume,
beats: chapter.beats ?? [],
foreshadowCodes: (chapter.foreshadow_windows ?? []).map((w) => w.code),
}));
}

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { composeDirective, STYLE_PRESETS } from "./directive"; import { composeDirective, PLOT_PRESETS, STYLE_PRESETS } from "./directive";
describe("composeDirective", () => { describe("composeDirective", () => {
it("returns empty string when nothing selected", () => { it("returns empty string when nothing selected", () => {
@@ -23,4 +23,16 @@ describe("composeDirective", () => {
const lessAi = STYLE_PRESETS.find((p) => p.id === "less-ai")!.text; const lessAi = STYLE_PRESETS.find((p) => p.id === "less-ai")!.text;
expect(composeDirective("", ["nope", "less-ai"])).toBe(lessAi); expect(composeDirective("", ["nope", "less-ai"])).toBe(lessAi);
}); });
it("resolves 剧情需求 presets alongside 文风", () => {
const plot = PLOT_PRESETS[0]!;
expect(composeDirective("", [plot.id])).toBe(plot.text);
});
it("orders 文风 presets before 剧情 presets, then free text", () => {
const style = STYLE_PRESETS[0]!;
const plot = PLOT_PRESETS[0]!;
const result = composeDirective("再收紧节奏", [plot.id, style.id]);
expect(result).toBe(`${style.text}${plot.text};再收紧节奏`);
});
}); });

View File

@@ -1,33 +1,47 @@
// T4-b · 本章指令组装纯逻辑UX §3)。 // 本章 AI写作三槽纯逻辑UX §3写作工作台重构 Phase 2)。
// 作者自由文本 + 风格快捷预设 → 单条 directive 串,传入 draft 生成(直通后端 volatile // 三槽 = 文风预设 + 剧情需求预设 + 自定义要求(自由文本)→ 单条 directive 串,
// 直通后端 volatileMVP 纯前端、零契约、不动 write_craft.md 字节 → 金标准零回归)。
// 组件只渲染 chips/textarea预设清单与组装规则集中在此确定性、不可变。 // 组件只渲染 chips/textarea预设清单与组装规则集中在此确定性、不可变。
export interface StylePreset { export interface Preset {
id: string; id: string;
label: string; label: string;
text: string; text: string;
} }
// 风格快捷预设(点选拼入指令开头,引导本章文风) // 文风预设(书级文风倾向;点选引导本章表达)。来源:现有 + style_extract 维度离散化
export const STYLE_PRESETS: readonly StylePreset[] = [ export const STYLE_PRESETS: readonly Preset[] = [
{ id: "less-ai", label: "减少AI味", text: "减少 AI 腔,去掉套话与排比堆砌" }, { id: "less-ai", label: "减少AI味", text: "减少 AI 腔,去掉套话与排比堆砌" },
{ id: "colloquial", label: "口语化", text: "用更口语、贴近人物的表达" }, { id: "colloquial", label: "口语化", text: "用更口语、贴近人物的表达" },
{ id: "fast-pace", label: "快节奏", text: "加快节奏,少铺垫多冲突推进" }, { id: "fast-pace", label: "快节奏", text: "加快节奏,少铺垫多冲突推进" },
]; ];
const PRESET_BY_ID: ReadonlyMap<string, StylePreset> = new Map( // 剧情需求预设章级桥段诉求作者「卡文给方向」。来源plan §4.2 分类法
STYLE_PRESETS.map((preset) => [preset.id, preset]), // tianyayu6/write-web-novels + oh-story-claudecode均 MIT
export const PLOT_PRESETS: readonly Preset[] = [
{ id: "plot-faceslap", label: "打脸反转", text: "本章安排一次打脸/反转,先抑后扬给足爽感" },
{ id: "plot-hook-end", label: "章末钩子", text: "章末留一个强钩子/悬念,驱动追读" },
{ id: "plot-climax", label: "高潮爆发", text: "本章为高潮,冲突集中爆发、情绪拉满" },
{ id: "plot-turn", label: "关键转折", text: "本章制造关键转折,改变人物处境或目标" },
{ id: "plot-reveal", label: "扮猪吃虎", text: "先藏拙后亮实力,制造反差与压制感" },
];
// 全部预设(文风在前、剧情在后)——组装按此声明序,保证输出稳定可测。
const ALL_PRESETS: readonly Preset[] = [...STYLE_PRESETS, ...PLOT_PRESETS];
const PRESET_BY_ID: ReadonlyMap<string, Preset> = new Map(
ALL_PRESETS.map((preset) => [preset.id, preset]),
); );
const SEPARATOR = ""; const SEPARATOR = "";
// 组合选中预设文案(按 STYLE_PRESETS 原序+ 自由文本为一条指令。空 → ""。 // 组合选中预设文案(按 ALL_PRESETS 原序:文风→剧情)+ 自定义自由文本为一条指令。空 → ""。
export function composeDirective( export function composeDirective(
freeText: string, freeText: string,
presetIds: readonly string[], presetIds: readonly string[],
): string { ): string {
const selected = new Set(presetIds); const selected = new Set(presetIds);
const presetTexts = STYLE_PRESETS.filter((preset) => const presetTexts = ALL_PRESETS.filter((preset) =>
selected.has(preset.id), selected.has(preset.id),
).map((preset) => preset.text); ).map((preset) => preset.text);
const trimmedFree = freeText.trim(); const trimmedFree = freeText.trim();

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import {
buildManuscriptExcerpt,
DEFAULT_EXCERPT_MAX_CHARS,
} from "./manuscriptExcerpt";
describe("buildManuscriptExcerpt", () => {
it("短文未超界trim 后原样返回,无省略标记", () => {
const text = " 第一章 少年初入江湖。 ";
const excerpt = buildManuscriptExcerpt(text, 1800);
expect(excerpt).toBe("第一章 少年初入江湖。");
expect(excerpt).not.toContain("中略");
});
it("空串与纯空白 → 返回 \"\"", () => {
expect(buildManuscriptExcerpt("", 1800)).toBe("");
expect(buildManuscriptExcerpt(" \n\t ", 1800)).toBe("");
});
it("正好等于上界 → 原样返回,不截断", () => {
const text = "字".repeat(1800);
const excerpt = buildManuscriptExcerpt(text, 1800);
expect(excerpt).toBe(text);
expect(excerpt).not.toContain("中略");
});
it("超长 → 取首尾拼接、含省略标记,且首尾片段都保留", () => {
const head = "甲".repeat(1000);
const tail = "乙".repeat(1000);
const text = head + tail; // 2000 > 1800
const excerpt = buildManuscriptExcerpt(text, 1800);
expect(excerpt).toContain("中略");
// 首段来自开头、尾段来自结尾。
expect(excerpt.startsWith("甲")).toBe(true);
expect(excerpt.endsWith("乙")).toBe(true);
});
it("超长摘录长度 ≤ 上界 + 少许标记冗余(正文部分不超界)", () => {
const text = "文".repeat(5000);
const maxChars = 1800;
const excerpt = buildManuscriptExcerpt(text, maxChars);
// 省略标记之外的正文字符数不得超过上界。
const bodyLen = excerpt.replace(/……(中略)……/g, "").replace(/\s+/g, "")
.length;
expect(bodyLen).toBeLessThanOrEqual(maxChars);
// 整体长度仅比上界多出标记长度这点冗余。
expect(excerpt.length).toBeLessThanOrEqual(maxChars + 20);
});
it("默认上界常量生效(不传 maxChars", () => {
const text = "字".repeat(DEFAULT_EXCERPT_MAX_CHARS + 500);
const excerpt = buildManuscriptExcerpt(text);
expect(excerpt).toContain("中略");
const bodyLen = excerpt.replace(/……(中略)……/g, "").replace(/\s+/g, "")
.length;
expect(bodyLen).toBeLessThanOrEqual(DEFAULT_EXCERPT_MAX_CHARS);
});
});

View File

@@ -0,0 +1,31 @@
// 从作者【已写正文】抽一段代表性摘录,喂给内容感知型生成器(如书名反推)的 brief 字段。
// 纯函数、无副作用;硬上界 maxChars 控 token保证首尾都进入摘录。
/** 默认摘录上界(字符数),约束喂给 LLM 的 token 体量。 */
export const DEFAULT_EXCERPT_MAX_CHARS = 1800;
/** 首尾之间插入的省略标记,提示中间正文被截去。 */
const ELLIPSIS_MARKER = "\n\n……中略……\n\n";
/**
* 取正文的代表性摘录:
* - 空/纯空白 → 返回 ""。
* - 长度 ≤ maxChars → 原样返回trim 后)。
* - 超界 → 取「开头一段 + 结尾一段」拼接,中间插省略标记,保证首尾都在。
* 正文预算按 maxChars 均分给首尾两段;返回长度 ≤ maxChars + 标记长度。
*/
export function buildManuscriptExcerpt(
fullText: string,
maxChars: number = DEFAULT_EXCERPT_MAX_CHARS,
): string {
const trimmed = fullText.trim();
if (trimmed.length === 0) return "";
if (trimmed.length <= maxChars) return trimmed;
// 正文预算(不含标记)均分给首尾,向下取整避免超界。
const headBudget = Math.floor(maxChars / 2);
const tailBudget = maxChars - headBudget;
const head = trimmed.slice(0, headBudget).trimEnd();
const tail = trimmed.slice(trimmed.length - tailBudget).trimStart();
return `${head}${ELLIPSIS_MARKER}${tail}`;
}

View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { applyRefinement } from "./refineApply";
describe("applyRefinement", () => {
const full = "第一段。原文段落。第三段。";
const original = "原文段落。";
const refined = "润色后的段落。";
// "第一段。" = 4 字,原文段落在 [4, 4+len)
const range = { start: 4, end: 4 + original.length };
it("选区未漂移:按 range 原地替换", () => {
expect(applyRefinement(full, original, refined, range)).toBe(
"第一段。润色后的段落。第三段。",
);
});
it("选区漂移但原文唯一:按内容重锚替换", () => {
// range 指向错位(如流式追加导致偏移),但原文仍能唯一定位。
const drifted = { start: 0, end: 3 };
expect(applyRefinement(full, original, refined, drifted)).toBe(
"第一段。润色后的段落。第三段。",
);
});
it("原文已不存在(正文被大改):返回 null 让调用方提示重选", () => {
expect(applyRefinement("完全不同的正文", original, refined, range)).toBeNull();
});
it("只替换首个匹配(重锚回退时保守取第一处)", () => {
const twice = "原文段落。中间。原文段落。";
const drifted = { start: 99, end: 100 };
expect(applyRefinement(twice, original, refined, drifted)).toBe(
"润色后的段落。中间。原文段落。",
);
});
});

View File

@@ -0,0 +1,24 @@
// 润色/再沟通 accept 回填:把选中原文替换为产出。选区偏移可能因异步/流式漂移,
// 故先按 range 校验未命中则按原文内容重锚indexOf——原文已不存在则返回 null
// 由调用方提示作者重新选择绝不盲替换plan §3.3 mustFix
export interface SelectionRange {
start: number;
end: number;
}
export function applyRefinement(
fullText: string,
original: string,
refined: string,
range: SelectionRange,
): string | null {
// 选区未漂移range 处正好是原文 → 原地替换。
if (fullText.slice(range.start, range.end) === original) {
return fullText.slice(0, range.start) + refined + fullText.slice(range.end);
}
// 漂移:按内容重锚,保守取首个匹配。
const idx = fullText.indexOf(original);
if (idx === -1) return null;
return fullText.slice(0, idx) + refined + fullText.slice(idx + original.length);
}

View File

@@ -0,0 +1,162 @@
// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useChapterRewrite } from "./useChapterRewrite";
// fetchSSE 流)是 hook 的外部副作用边界,单测一律 stub 全局 fetch。
const fetchMock = vi.fn();
function sseStream(chunks: string[]): ReadableStream<Uint8Array> {
const enc = new TextEncoder();
return new ReadableStream({
start(c) {
for (const ch of chunks) c.enqueue(enc.encode(ch));
c.close();
},
});
}
function sseResponse(chunks: string[]): Response {
return new Response(sseStream(chunks), {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
}
describe("useChapterRewrite", () => {
beforeEach(() => {
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it("初始为 idle、无版本", () => {
const { result } = renderHook(() => useChapterRewrite());
expect(result.current.state.phase).toBe("idle");
expect(result.current.versions).toEqual([]);
expect(result.current.latest).toBeNull();
});
it("重写完成token+done流式累积正文并压入版本栈带作者意见", async () => {
fetchMock.mockResolvedValue(
sseResponse([
'event:token\ndata:{"text":"新版"}\n\n',
'event:token\ndata:{"text":"正文"}\n\n',
'event:done\ndata:{"length":4}\n\n',
]),
);
const { result } = renderHook(() => useChapterRewrite());
await act(async () => {
await result.current.send("p1", 3, "节奏太慢", "旧草稿");
});
// POST body 带 feedback + prior_draftsnake_case
const call = fetchMock.mock.calls[0];
expect(call?.[0]).toContain("/projects/p1/chapters/3/rewrite");
expect(JSON.parse((call?.[1]?.body as string) ?? "")).toEqual({
feedback: "节奏太慢",
prior_draft: "旧草稿",
});
expect(result.current.state.phase).toBe("done");
expect(result.current.versions).toEqual([
{ feedback: "节奏太慢", text: "新版正文" },
]);
expect(result.current.latest?.text).toBe("新版正文");
});
it("多轮迭代第二轮再压一版latest 为最新版", async () => {
fetchMock
.mockResolvedValueOnce(
sseResponse([
'event:token\ndata:{"text":"第一版"}\n\n',
'event:done\ndata:{"length":3}\n\n',
]),
)
.mockResolvedValueOnce(
sseResponse([
'event:token\ndata:{"text":"第二版"}\n\n',
'event:done\ndata:{"length":3}\n\n',
]),
);
const { result } = renderHook(() => useChapterRewrite());
await act(async () => {
await result.current.send("p1", 1, "意见一", "原草稿");
});
await act(async () => {
await result.current.send("p1", 1, "意见二", "第一版");
});
expect(result.current.versions).toHaveLength(2);
expect(result.current.latest).toEqual({ feedback: "意见二", text: "第二版" });
});
it("error 帧phase=error 且不入版本栈", async () => {
fetchMock.mockResolvedValue(
sseResponse([
'event:error\ndata:{"code":"RATE_LIMIT","message":"配额不足"}\n\n',
]),
);
const { result } = renderHook(() => useChapterRewrite());
await act(async () => {
await result.current.send("p1", 1, "改", "草稿");
});
expect(result.current.state.phase).toBe("error");
expect(result.current.state.error).toMatchObject({ code: "RATE_LIMIT" });
expect(result.current.versions).toEqual([]);
});
it("流前错误(!res.ok解析 JSON 信封提取错误码", async () => {
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({ error: { code: "LLM_UNAVAILABLE", message: "无可用凭据" } }),
{ status: 503 },
),
);
const { result } = renderHook(() => useChapterRewrite());
await act(async () => {
await result.current.send("p1", 1, "改", "草稿");
});
expect(result.current.state.error).toMatchObject({ code: "LLM_UNAVAILABLE" });
expect(result.current.versions).toEqual([]);
});
it("网络抛异常(非 Abortphase=error 且 code=NETWORK", async () => {
fetchMock.mockRejectedValue(new Error("connection reset"));
const { result } = renderHook(() => useChapterRewrite());
await act(async () => {
await result.current.send("p1", 1, "改", "草稿");
});
expect(result.current.state.error).toMatchObject({
code: "NETWORK",
message: "connection reset",
});
});
it("reset 清回 idle 与空版本栈", async () => {
fetchMock.mockResolvedValue(
sseResponse([
'event:token\ndata:{"text":"x"}\n\n',
'event:done\ndata:{"length":1}\n\n',
]),
);
const { result } = renderHook(() => useChapterRewrite());
await act(async () => {
await result.current.send("p1", 1, "改", "草稿");
});
act(() => result.current.reset());
expect(result.current.state.phase).toBe("idle");
expect(result.current.versions).toEqual([]);
});
});

View File

@@ -0,0 +1,163 @@
"use client";
import { useCallback, useReducer, useRef, useState } from "react";
import { API_BASE_PUBLIC } from "@/lib/api/config";
import {
SseFrameBuffer,
initialStreamState,
reduceStream,
type StreamState,
} from "@/lib/stream/sse";
// 一轮整章重写的产出(作者意见 + 产出正文)。多轮迭代累积成版本栈。
export interface RewriteVersion {
feedback: string;
text: string;
}
type Action =
| { type: "start" }
| { type: "events"; events: ReturnType<SseFrameBuffer["push"]> }
| { type: "abort" }
| { type: "fail"; code: string; message: string }
| { type: "reset" };
function reducer(state: StreamState, action: Action): StreamState {
switch (action.type) {
case "start":
return { phase: "streaming", text: "", error: null };
case "events":
return action.events.reduce(reduceStream, state);
case "abort":
return { ...state, phase: "aborted" };
case "fail":
return {
...state,
phase: "error",
error: { code: action.code, message: action.message },
};
case "reset":
return initialStreamState;
default:
return state;
}
}
export interface UseChapterRewrite {
// 当前流式状态text = 正在生成/最新一版正文,供 live 展示)。
state: StreamState;
isStreaming: boolean;
// 已完成的版本栈(每轮一版)。
versions: RewriteVersion[];
latest: RewriteVersion | null;
// 发起一轮整章重写:作者意见 + 当前整章草稿 → 流式重写一版。
send: (
projectId: string,
chapterNo: number,
feedback: string,
priorDraft: string,
) => Promise<void>;
stop: () => void;
reset: () => void;
}
// 整章再沟通/重写流POST .../rewritefeedback + prior_draft→ 消费 SSE → 累积成新一版。
// 每轮完成压入版本栈;下一轮以最新版正文为 prior_draft + 新意见迭代HITL接受才落草稿
// 用 fetch+ReadableStreamEventSource 不支持 POST复用 lib/stream/sse 的帧解析与归一。
export function useChapterRewrite(): UseChapterRewrite {
const [state, dispatch] = useReducer(reducer, initialStreamState);
const [versions, setVersions] = useState<RewriteVersion[]>([]);
const controllerRef = useRef<AbortController | null>(null);
const textRef = useRef("");
const stop = useCallback(() => {
controllerRef.current?.abort();
controllerRef.current = null;
dispatch({ type: "abort" });
}, []);
const reset = useCallback(() => {
dispatch({ type: "reset" });
setVersions([]);
}, []);
const send = useCallback<UseChapterRewrite["send"]>(
async (projectId, chapterNo, feedback, priorDraft) => {
const controller = new AbortController();
controllerRef.current = controller;
textRef.current = "";
dispatch({ type: "start" });
try {
const res = await fetch(
`${API_BASE_PUBLIC}/projects/${projectId}/chapters/${chapterNo}/rewrite`,
{
method: "POST",
headers: {
Accept: "text/event-stream",
"Content-Type": "application/json",
},
body: JSON.stringify({ feedback, prior_draft: priorDraft }),
signal: controller.signal,
},
);
if (!res.ok || !res.body) {
// 流前错误(如无凭据 → 503 LLM_UNAVAILABLEJSON 信封而非帧)。
let code = "STREAM_FAILED";
let message = `整章重写请求失败(${res.status}`;
try {
const body = (await res.json()) as {
error?: { code?: string; message?: string };
};
if (body.error?.code) code = body.error.code;
if (body.error?.message) message = body.error.message;
} catch {
// 非 JSON 信封,沿用默认文案。
}
dispatch({ type: "fail", code, message });
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
const buffer = new SseFrameBuffer();
let done = false;
for (;;) {
const { value, done: readerDone } = await reader.read();
if (readerDone) break;
const events = buffer.push(decoder.decode(value, { stream: true }));
if (events.length > 0) {
dispatch({ type: "events", events });
for (const ev of events) {
if (ev.event === "token") textRef.current += ev.data.text;
if (ev.event === "done") done = true;
}
}
}
// 收到 done 且有正文 → 压入版本栈(未 done/空文本不入栈,避免脏版本)。
if (done && textRef.current.length > 0) {
const version: RewriteVersion = { feedback, text: textRef.current };
setVersions((prev) => [...prev, version]);
}
} catch (err: unknown) {
if (err instanceof DOMException && err.name === "AbortError") {
return; // 用户主动停止state 已置 aborted。
}
const message = err instanceof Error ? err.message : "未知网络错误";
dispatch({ type: "fail", code: "NETWORK", message });
} finally {
controllerRef.current = null;
}
},
[],
);
return {
state,
isStreaming: state.phase === "streaming",
versions,
latest: versions.at(-1) ?? null,
send,
stop,
reset,
};
}

View File

@@ -0,0 +1,115 @@
// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClarify } from "./useClarify";
import type { ClarifyDecisionVM } from "./clarify";
const post = vi.fn();
vi.mock("@/lib/api/client", () => ({
api: { POST: (...a: unknown[]) => post(...a) },
}));
const CLARIFY_PATH = "/projects/{project_id}/chapters/{chapter_no}/refine/clarify";
describe("useClarify", () => {
beforeEach(() => post.mockReset());
afterEach(() => vi.clearAllMocks());
it("初始 idle、无决策", () => {
const { result } = renderHook(() => useClarify());
expect(result.current.status).toBe("idle");
expect(result.current.decision).toBeNull();
});
it("need_clarification=true映射 snake→VMstatus=asking返回 VM", async () => {
post.mockResolvedValue({
data: {
need_clarification: true,
questions: [
{
question: "想更冷峻还是更抒情?",
options: [
{ label: "冷峻", value: "改得更冷峻克制" },
{ label: "抒情", value: "改得更抒情" },
],
allow_free_text: true,
},
],
verification: null,
},
error: null,
});
const { result } = renderHook(() => useClarify());
let vm: ClarifyDecisionVM | undefined;
await act(async () => {
vm = await result.current.check("p1", 3, "这段", "改改");
});
expect(post).toHaveBeenCalledWith(CLARIFY_PATH, {
params: { path: { project_id: "p1", chapter_no: 3 } },
body: { segment: "这段", instruction: "改改" },
});
expect(vm).toEqual({
needClarification: true,
questions: [
{
question: "想更冷峻还是更抒情?",
options: [
{ label: "冷峻", value: "改得更冷峻克制" },
{ label: "抒情", value: "改得更抒情" },
],
allowFreeText: true,
},
],
verification: undefined,
});
expect(result.current.status).toBe("asking");
expect(result.current.decision?.needClarification).toBe(true);
});
it("need_clarification=falsestatus=proceeddecision 清空", async () => {
post.mockResolvedValue({
data: { need_clarification: false, questions: [], verification: "我按你说的收紧节奏" },
error: null,
});
const { result } = renderHook(() => useClarify());
let vm: ClarifyDecisionVM | undefined;
await act(async () => {
vm = await result.current.check("p1", 1, "段", "再收紧节奏,删掉第二段闪回");
});
expect(vm?.needClarification).toBe(false);
expect(result.current.status).toBe("proceed");
expect(result.current.decision).toBeNull();
});
it("后端 error视为放行proceedneedClarification=false", async () => {
post.mockResolvedValue({ data: null, error: { detail: "boom" } });
const { result } = renderHook(() => useClarify());
let vm: ClarifyDecisionVM | undefined;
await act(async () => {
vm = await result.current.check("p1", 1, "段", "改");
});
expect(vm?.needClarification).toBe(false);
expect(result.current.status).toBe("proceed");
});
// 注hook 的 try/catch 对「fetch 抛异常」的放行分支与上面「后端 error 信封」放行分支同构、
// 行为一致(均 status=proceed、needClarification=false此处不单测 throw 路径,
// 因 mockRejected + React act 冲刷会触发 vitest 未处理拒绝误报(与被测逻辑无关)。
it("reset 清回 idle", async () => {
post.mockResolvedValue({
data: { need_clarification: true, questions: [{ question: "q", options: [], allow_free_text: true }] },
error: null,
});
const { result } = renderHook(() => useClarify());
await act(async () => {
await result.current.check("p1", 1, "段", "改");
});
act(() => result.current.reset());
expect(result.current.status).toBe("idle");
expect(result.current.decision).toBeNull();
});
});

View File

@@ -0,0 +1,70 @@
"use client";
import { useCallback, useState } from "react";
import { api } from "@/lib/api/client";
import { PROCEED, toVM, type ClarifyDecisionVM } from "./clarify";
export type ClarifyStatus = "idle" | "checking" | "asking" | "proceed" | "error";
export interface UseClarify {
status: ClarifyStatus;
// status=asking 时的待答问题(含选项);其它时为 null。
decision: ClarifyDecisionVM | null;
// 预检:调 refine/clarify 端点,返回 VM 供调用方同步分支。失败=放行needClarification=false不阻塞润色。
check: (
projectId: string,
chapterNo: number,
segment: string,
instruction: string,
) => Promise<ClarifyDecisionVM>;
reset: () => void;
}
// AI 反问澄清预检WFW-9 M1路线A 两阶段之「问题」阶段)。
// 「再沟通」意见含糊/极短时先调独立非流式端点让 AI 反问needClarification=false 或失败=放行去既有 refine。
export function useClarify(): UseClarify {
const [status, setStatus] = useState<ClarifyStatus>("idle");
const [decision, setDecision] = useState<ClarifyDecisionVM | null>(null);
const check = useCallback<UseClarify["check"]>(
async (projectId, chapterNo, segment, instruction) => {
setStatus("checking");
try {
const { data, error } = await api.POST(
"/projects/{project_id}/chapters/{chapter_no}/refine/clarify",
{
params: { path: { project_id: projectId, chapter_no: chapterNo } },
body: { segment, instruction },
},
);
if (error || !data) {
setStatus("proceed");
setDecision(null);
return PROCEED;
}
const vm = toVM(data);
if (vm.needClarification && vm.questions.length > 0) {
setStatus("asking");
setDecision(vm);
} else {
setStatus("proceed");
setDecision(null);
}
return vm;
} catch {
setStatus("proceed");
setDecision(null);
return PROCEED;
}
},
[],
);
const reset = useCallback((): void => {
setStatus("idle");
setDecision(null);
}, []);
return { status, decision, check, reset };
}

View File

@@ -0,0 +1,165 @@
// @vitest-environment jsdom
import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useContextDrawerData } from "./useContextDrawer";
// 后端客户端与 Toast 是 hook 的外部副作用边界,单测一律 mock。
const get = vi.fn();
const toast = vi.fn();
vi.mock("@/lib/api/client", () => ({
api: { GET: (...a: unknown[]) => get(...a) },
}));
vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
const CODEX_PATH = "/projects/{project_id}/world_entities";
const OUTLINE_PATH = "/projects/{project_id}/outline";
// 按路径分流 codex / outline 两个端点的 mock 响应。
function routeGet(
responses: {
codex?: unknown;
outline?: unknown;
codexReject?: boolean;
outlineReject?: boolean;
} = {},
): void {
get.mockImplementation((path: string) => {
if (path === CODEX_PATH) {
if (responses.codexReject) return Promise.reject(new Error("net"));
return Promise.resolve(responses.codex ?? { data: null, error: null });
}
if (path === OUTLINE_PATH) {
if (responses.outlineReject) return Promise.reject(new Error("net"));
return Promise.resolve(responses.outline ?? { data: null, error: null });
}
return Promise.resolve({ data: null, error: null });
});
}
describe("useContextDrawerData", () => {
beforeEach(() => {
get.mockReset();
toast.mockReset();
});
afterEach(() => vi.clearAllMocks());
it("抽屉关闭时不发请求,两资源保持 idle", () => {
routeGet();
const { result } = renderHook(() =>
useContextDrawerData("p1", "codex", false),
);
expect(get).not.toHaveBeenCalled();
expect(result.current.codex.status).toBe("idle");
expect(result.current.outline.status).toBe("idle");
});
it("打开且在设定库 Tab拉取并归组到 ready", async () => {
routeGet({
codex: {
data: {
world_entities: [
{ type: "势力", name: "雾港议会", rules: [] },
{ type: "地理", name: "北境", rules: [] },
],
},
error: null,
},
});
const { result } = renderHook(() =>
useContextDrawerData("p1", "codex", true),
);
await waitFor(() => expect(result.current.codex.status).toBe("ready"));
expect(result.current.codex.data.map((g) => g.type)).toEqual([
"势力",
"地理",
]);
expect(get).toHaveBeenCalledTimes(1);
expect(toast).not.toHaveBeenCalled();
});
it("打开且在大纲 Tab拉取并按章号升序整形", async () => {
routeGet({
outline: {
data: {
chapters: [
{ no: 2, volume: 1, beats: ["决战"], foreshadow_windows: [] },
{ no: 1, volume: 1, beats: ["开场"], foreshadow_windows: [] },
],
},
error: null,
},
});
const { result } = renderHook(() =>
useContextDrawerData("p1", "outline", true),
);
await waitFor(() => expect(result.current.outline.status).toBe("ready"));
expect(result.current.outline.data.map((r) => r.no)).toEqual([1, 2]);
});
it("data 为空时 ready 且为空数组", async () => {
routeGet({ codex: { data: { world_entities: null }, error: null } });
const { result } = renderHook(() =>
useContextDrawerData("p1", "codex", true),
);
await waitFor(() => expect(result.current.codex.status).toBe("ready"));
expect(result.current.codex.data).toEqual([]);
});
it("后端返回 errorerror 态 + 错误 toast", async () => {
routeGet({ codex: { data: null, error: { detail: "boom" } } });
const { result } = renderHook(() =>
useContextDrawerData("p1", "codex", true),
);
await waitFor(() => expect(result.current.codex.status).toBe("error"));
expect(toast).toHaveBeenCalledWith(expect.any(String), "error");
});
it("请求抛异常error 态 + 网络异常 toast", async () => {
routeGet({ codexReject: true });
const { result } = renderHook(() =>
useContextDrawerData("p1", "codex", true),
);
await waitFor(() => expect(result.current.codex.status).toBe("error"));
expect(toast).toHaveBeenCalledWith(
"设定库加载异常,请检查网络。",
"error",
);
});
it("已加载后切走再切回不重复拉取(缓存)", async () => {
routeGet({
codex: {
data: { world_entities: [{ type: "势力", name: "甲", rules: [] }] },
error: null,
},
});
const { result, rerender } = renderHook(
({ tab }: { tab: "codex" | "outline" }) =>
useContextDrawerData("p1", tab, true),
{ initialProps: { tab: "codex" } as { tab: "codex" | "outline" } },
);
await waitFor(() => expect(result.current.codex.status).toBe("ready"));
rerender({ tab: "outline" });
rerender({ tab: "codex" });
expect(get.mock.calls.filter((c) => c[0] === CODEX_PATH)).toHaveLength(1);
});
it("reloadCodex 在出错后可重试拉取", async () => {
routeGet({ codex: { data: null, error: { detail: "boom" } } });
const { result } = renderHook(() =>
useContextDrawerData("p1", "codex", true),
);
await waitFor(() => expect(result.current.codex.status).toBe("error"));
routeGet({
codex: {
data: { world_entities: [{ type: "势力", name: "甲", rules: [] }] },
error: null,
},
});
act(() => result.current.reloadCodex());
await waitFor(() => expect(result.current.codex.status).toBe("ready"));
expect(result.current.codex.data).toHaveLength(1);
});
});

View File

@@ -0,0 +1,145 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { api } from "@/lib/api/client";
import { useToast } from "@/components/Toast";
import {
toCodexGroups,
toOutlineRows,
type CodexGroup,
type ContextTabKey,
type OutlineRow,
} from "./contextDrawer";
// 速查抽屉的数据层:抽屉打开且切到对应 Tab 时【懒加载】设定库 / 大纲(只读,绝不写库)。
// 加载成功后缓存(切走再切回不重复拉);项目切换清缓存;失败可 reload 重试。
export type ResourceStatus = "idle" | "loading" | "ready" | "error";
export interface ContextResource<T> {
status: ResourceStatus;
data: T;
}
export interface UseContextDrawerData {
codex: ContextResource<CodexGroup[]>;
outline: ContextResource<OutlineRow[]>;
reloadCodex: () => void;
reloadOutline: () => void;
}
const CODEX_PATH = "/projects/{project_id}/world_entities" as const;
const OUTLINE_PATH = "/projects/{project_id}/outline" as const;
const EMPTY_CODEX: CodexGroup[] = [];
const EMPTY_OUTLINE: OutlineRow[] = [];
export function useContextDrawerData(
projectId: string,
activeTab: ContextTabKey,
open: boolean,
): UseContextDrawerData {
const [codex, setCodex] = useState<ContextResource<CodexGroup[]>>({
status: "idle",
data: EMPTY_CODEX,
});
const [outline, setOutline] = useState<ContextResource<OutlineRow[]>>({
status: "idle",
data: EMPTY_OUTLINE,
});
const toast = useToast();
// 单飞哨兵:已发起过请求就不再重复(成功缓存 / 在途去重);出错时重置以允许重试。
const codexReqRef = useRef(false);
const outlineReqRef = useRef(false);
// reload 触发器:自增即重新拉取(配合把 req 哨兵复位)。
const [codexNonce, setCodexNonce] = useState(0);
const [outlineNonce, setOutlineNonce] = useState(0);
// 项目切换 → 清缓存 + 复位哨兵,允许对新项目重新拉取。声明在拉取 effect 之前,
// 保证同一次提交里先复位、后续 effect 才据此触发。
useEffect(() => {
codexReqRef.current = false;
outlineReqRef.current = false;
setCodex({ status: "idle", data: EMPTY_CODEX });
setOutline({ status: "idle", data: EMPTY_OUTLINE });
}, [projectId]);
useEffect(() => {
if (!open || activeTab !== "codex" || codexReqRef.current) return;
codexReqRef.current = true;
let cancelled = false;
setCodex({ status: "loading", data: EMPTY_CODEX });
void (async () => {
try {
const { data, error } = await api.GET(CODEX_PATH, {
params: { path: { project_id: projectId } },
});
if (cancelled) return;
if (error || !data) {
codexReqRef.current = false;
setCodex({ status: "error", data: EMPTY_CODEX });
toast("设定库加载失败,请重试。", "error");
return;
}
setCodex({
status: "ready",
data: toCodexGroups(data.world_entities ?? []),
});
} catch {
if (cancelled) return;
codexReqRef.current = false;
setCodex({ status: "error", data: EMPTY_CODEX });
toast("设定库加载异常,请检查网络。", "error");
}
})();
return () => {
cancelled = true;
};
}, [open, activeTab, projectId, codexNonce, toast]);
useEffect(() => {
if (!open || activeTab !== "outline" || outlineReqRef.current) return;
outlineReqRef.current = true;
let cancelled = false;
setOutline({ status: "loading", data: EMPTY_OUTLINE });
void (async () => {
try {
const { data, error } = await api.GET(OUTLINE_PATH, {
params: { path: { project_id: projectId } },
});
if (cancelled) return;
if (error || !data) {
outlineReqRef.current = false;
setOutline({ status: "error", data: EMPTY_OUTLINE });
toast("大纲加载失败,请重试。", "error");
return;
}
setOutline({
status: "ready",
data: toOutlineRows(data.chapters ?? []),
});
} catch {
if (cancelled) return;
outlineReqRef.current = false;
setOutline({ status: "error", data: EMPTY_OUTLINE });
toast("大纲加载异常,请检查网络。", "error");
}
})();
return () => {
cancelled = true;
};
}, [open, activeTab, projectId, outlineNonce, toast]);
const reloadCodex = (): void => {
codexReqRef.current = false;
setCodex({ status: "idle", data: EMPTY_CODEX });
setCodexNonce((n) => n + 1);
};
const reloadOutline = (): void => {
outlineReqRef.current = false;
setOutline({ status: "idle", data: EMPTY_OUTLINE });
setOutlineNonce((n) => n + 1);
};
return { codex, outline, reloadCodex, reloadOutline };
}

View File

@@ -0,0 +1,106 @@
// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useContinue } from "./useContinue";
const post = vi.fn();
const toast = vi.fn();
vi.mock("@/lib/api/client", () => ({
api: { POST: (...a: unknown[]) => post(...a) },
}));
vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
const GEN_PATH = "/projects/{project_id}/skills/{tool_key}/generate";
describe("useContinue", () => {
beforeEach(() => {
post.mockReset();
toast.mockReset();
});
afterEach(() => vi.clearAllMocks());
it("初始为 idle无候选", () => {
const { result } = renderHook(() => useContinue());
expect(result.current.status).toBe("idle");
expect(result.current.candidates).toEqual([]);
});
it("续写:调 continue 生成器读该章前文产出一条候选status=ready", async () => {
post.mockResolvedValue({
data: { output_kind: "ContinuationResult", preview: { text: "续写正文一" } },
error: null,
});
const { result } = renderHook(() => useContinue());
await act(async () => {
await result.current.generate("p1", 3);
});
expect(post).toHaveBeenCalledWith(GEN_PATH, {
params: { path: { project_id: "p1", tool_key: "continue" } },
body: { brief: "", chapter_no: 3 },
});
expect(result.current.status).toBe("ready");
expect(result.current.candidates).toEqual(["续写正文一"]);
});
it("多候选:再次生成累加候选,不覆盖前一条", async () => {
post
.mockResolvedValueOnce({ data: { preview: { text: "候选一" } }, error: null })
.mockResolvedValueOnce({ data: { preview: { text: "候选二" } }, error: null });
const { result } = renderHook(() => useContinue());
await act(async () => {
await result.current.generate("p1", 1);
});
await act(async () => {
await result.current.generate("p1", 1);
});
expect(result.current.candidates).toEqual(["候选一", "候选二"]);
});
it("产出为空不加候选、提示重试、status=ready", async () => {
post.mockResolvedValue({ data: { preview: { text: "" } }, error: null });
const { result } = renderHook(() => useContinue());
await act(async () => {
await result.current.generate("p1", 1);
});
expect(result.current.candidates).toEqual([]);
expect(result.current.status).toBe("ready");
expect(toast).toHaveBeenCalledWith(expect.any(String), "info");
});
it("后端返回 errorstatus=error、弹错误 toast、候选不变", async () => {
post.mockResolvedValue({ data: null, error: { detail: "配额不足" } });
const { result } = renderHook(() => useContinue());
await act(async () => {
await result.current.generate("p1", 1);
});
expect(result.current.status).toBe("error");
expect(result.current.candidates).toEqual([]);
expect(toast).toHaveBeenCalledWith(expect.any(String), "error");
});
it("请求抛异常status=error、弹网络异常 toast", async () => {
post.mockRejectedValue(new Error("network down"));
const { result } = renderHook(() => useContinue());
await act(async () => {
await result.current.generate("p1", 1);
});
expect(result.current.status).toBe("error");
expect(toast).toHaveBeenCalledWith("续写请求异常,请检查网络。", "error");
});
it("reset 清回 idle 与空候选", async () => {
post.mockResolvedValue({ data: { preview: { text: "候选" } }, error: null });
const { result } = renderHook(() => useContinue());
await act(async () => {
await result.current.generate("p1", 1);
});
act(() => result.current.reset());
expect(result.current.status).toBe("idle");
expect(result.current.candidates).toEqual([]);
});
});

View File

@@ -0,0 +1,72 @@
"use client";
import { useCallback, useState } from "react";
import { api } from "@/lib/api/client";
import { useToast } from "@/components/Toast";
import { generationErrorMessage } from "@/lib/generation/cards";
// 编辑器「续写」:复用 continue 生成器with_prior_chapter自动读该章已写正文承接
// 每次生成一条候选并累加成多候选作者点选某条插入正文HITL不静默写入
export type ContinueStatus = "idle" | "generating" | "ready" | "error";
export interface UseContinue {
status: ContinueStatus;
candidates: string[];
// 生成一条续写候选累加不覆盖chapterNo 指定承接哪一章。
generate: (projectId: string, chapterNo: number) => Promise<void>;
reset: () => void;
}
function extractText(preview: unknown): string {
if (preview && typeof preview === "object" && "text" in preview) {
const text = (preview as { text: unknown }).text;
return typeof text === "string" ? text : "";
}
return "";
}
export function useContinue(): UseContinue {
const [status, setStatus] = useState<ContinueStatus>("idle");
const [candidates, setCandidates] = useState<string[]>([]);
const toast = useToast();
const generate = useCallback<UseContinue["generate"]>(
async (projectId, chapterNo) => {
setStatus("generating");
try {
const { data, error } = await api.POST(
"/projects/{project_id}/skills/{tool_key}/generate",
{
params: { path: { project_id: projectId, tool_key: "continue" } },
body: { brief: "", chapter_no: chapterNo },
},
);
if (error || !data) {
setStatus("error");
toast(generationErrorMessage(error), "error");
return;
}
const text = extractText(data.preview).trim();
if (text.length === 0) {
setStatus("ready");
toast("本次未生成有效续写,请再试一次。", "info");
return;
}
setCandidates((prev) => [...prev, text]);
setStatus("ready");
} catch {
setStatus("error");
toast("续写请求异常,请检查网络。", "error");
}
},
[toast],
);
const reset = useCallback((): void => {
setStatus("idle");
setCandidates([]);
}, []);
return { status, candidates, generate, reset };
}

View File

@@ -0,0 +1,65 @@
// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { toGenreDirective, useGenreGate } from "./useGenreGate";
describe("toGenreDirective", () => {
it("有题材时形如「本章题材:<genre>」", () => {
expect(toGenreDirective("玄幻")).toBe("本章题材:玄幻");
});
it("空/纯空白/null → 空串", () => {
expect(toGenreDirective(null)).toBe("");
expect(toGenreDirective("")).toBe("");
expect(toGenreDirective(" ")).toBe("");
});
});
describe("useGenreGate", () => {
it("项目已有 genreneedsGenre=falsegenreDirective 反映项目题材", () => {
const { result } = renderHook(() => useGenreGate("仙侠"));
expect(result.current.needsGenre).toBe(false);
expect(result.current.effectiveGenre).toBe("仙侠");
expect(result.current.genreDirective).toBe("本章题材:仙侠");
});
it("项目 genre 为纯空白也视为缺失", () => {
const { result } = renderHook(() => useGenreGate(" "));
expect(result.current.needsGenre).toBe(true);
expect(result.current.effectiveGenre).toBeNull();
expect(result.current.genreDirective).toBe("");
});
it("无 genreneedsGenre=true初始无生效题材与题材指令", () => {
const { result } = renderHook(() => useGenreGate(null));
expect(result.current.needsGenre).toBe(true);
expect(result.current.chosenGenre).toBeNull();
expect(result.current.effectiveGenre).toBeNull();
expect(result.current.genreDirective).toBe("");
});
it("无 genre 时 setChosenGenre 后 effectiveGenre/genreDirective 更新", () => {
const { result } = renderHook(() => useGenreGate(null));
act(() => result.current.setChosenGenre("都市"));
expect(result.current.chosenGenre).toBe("都市");
expect(result.current.effectiveGenre).toBe("都市");
expect(result.current.genreDirective).toBe("本章题材:都市");
});
it("setChosenGenre 传空串 → 回落 null不产生题材指令", () => {
const { result } = renderHook(() => useGenreGate(null));
act(() => result.current.setChosenGenre("科幻"));
act(() => result.current.setChosenGenre(" "));
expect(result.current.chosenGenre).toBeNull();
expect(result.current.genreDirective).toBe("");
});
it("项目 genre 优先于作者点选", () => {
const { result } = renderHook(() => useGenreGate("历史"));
act(() => result.current.setChosenGenre("悬疑"));
// needsGenre=false 时 UI 不会调 setChosenGenre但即便调了项目题材仍优先。
expect(result.current.effectiveGenre).toBe("历史");
expect(result.current.genreDirective).toBe("本章题材:历史");
});
});

View File

@@ -0,0 +1,55 @@
"use client";
import { useCallback, useMemo, useState } from "react";
// 极轻 genre 采集门WFW-7纯前端零契约。首次写章前若项目无 genre
// 让作者点一个题材仅【临时并入本次写作指令】——MVP 不落库(无项目更新端点),
// 避免无题材 slop。纯状态无副作用组件只负责渲染 chips + 触发 onWrite。
// 有 effectiveGenre 时的题材指令前缀(并进 composeDirective。空题材 → ""。
export function toGenreDirective(genre: string | null): string {
const trimmed = genre?.trim() ?? "";
return trimmed.length > 0 ? `本章题材:${trimmed}` : "";
}
export interface GenreGate {
// 项目缺 genre需先采集。为空串/纯空白亦视为缺失。
needsGenre: boolean;
// 作者本次临时点选的题材(未选 = null
chosenGenre: string | null;
setChosenGenre: (genre: string) => void;
// 生效题材:项目 genre 优先,其次作者点选,都无 → null。
effectiveGenre: string | null;
// 并入本次 directive 的题材串(形如「本章题材:玄幻」);无生效题材 → ""。
genreDirective: string;
}
export function useGenreGate(projectGenre: string | null): GenreGate {
const [chosenGenre, setChosenGenreState] = useState<string | null>(null);
const setChosenGenre = useCallback((genre: string): void => {
const trimmed = genre.trim();
setChosenGenreState(trimmed.length > 0 ? trimmed : null);
}, []);
const needsGenre = (projectGenre?.trim() ?? "").length === 0;
const effectiveGenre = useMemo<string | null>(() => {
const fromProject = projectGenre?.trim() ?? "";
if (fromProject.length > 0) return fromProject;
return chosenGenre;
}, [projectGenre, chosenGenre]);
const genreDirective = useMemo(
() => toGenreDirective(effectiveGenre),
[effectiveGenre],
);
return {
needsGenre,
chosenGenre,
setChosenGenre,
effectiveGenre,
genreDirective,
};
}

View File

@@ -0,0 +1,131 @@
// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useRefine } from "./useRefine";
// 后端客户端与 Toast 是 hook 的外部副作用边界,单测一律 mock。
const post = vi.fn();
const toast = vi.fn();
vi.mock("@/lib/api/client", () => ({
api: { POST: (...a: unknown[]) => post(...a) },
}));
vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
const REFINE_PATH = "/projects/{project_id}/chapters/{chapter_no}/refine";
describe("useRefine", () => {
beforeEach(() => {
post.mockReset();
toast.mockReset();
});
afterEach(() => vi.clearAllMocks());
it("初始为 idle无版本、无原文", () => {
const { result } = renderHook(() => useRefine());
expect(result.current.status).toBe("idle");
expect(result.current.versions).toEqual([]);
expect(result.current.latest).toBeNull();
expect(result.current.original).toBeNull();
});
it("润色:调 /refine压入一版original=选中原文status=ready", async () => {
post.mockResolvedValue({
data: { original: "原文段", refined: "润色后的段落" },
error: null,
});
const { result } = renderHook(() => useRefine());
await act(async () => {
await result.current.refine("p1", 3, "原文段");
});
expect(post).toHaveBeenCalledWith(REFINE_PATH, {
params: { path: { project_id: "p1", chapter_no: 3 } },
body: { segment: "原文段" },
});
expect(result.current.status).toBe("ready");
expect(result.current.original).toBe("原文段");
expect(result.current.latest).toEqual({
segment: "原文段",
instruction: "",
refined: "润色后的段落",
});
expect(result.current.versions).toHaveLength(1);
});
it("润色带指令instruction 透传后端", async () => {
post.mockResolvedValue({ data: { original: "s", refined: "r" }, error: null });
const { result } = renderHook(() => useRefine());
await act(async () => {
await result.current.refine("p1", 1, "原文", "更口语一点");
});
expect(post).toHaveBeenCalledWith(REFINE_PATH, {
params: { path: { project_id: "p1", chapter_no: 1 } },
body: { segment: "原文", instruction: "更口语一点" },
});
});
it("再沟通:以上一版产出为输入 + 作者新意见压入新版original 不变", async () => {
post
.mockResolvedValueOnce({ data: { original: "原文", refined: "第一版" }, error: null })
.mockResolvedValueOnce({ data: { original: "第一版", refined: "第二版" }, error: null });
const { result } = renderHook(() => useRefine());
await act(async () => {
await result.current.refine("p1", 2, "原文");
});
await act(async () => {
await result.current.recommunicate("p1", 2, "再收紧节奏");
});
expect(post).toHaveBeenLastCalledWith(REFINE_PATH, {
params: { path: { project_id: "p1", chapter_no: 2 } },
body: { segment: "第一版", instruction: "再收紧节奏" },
});
expect(result.current.versions).toHaveLength(2);
expect(result.current.original).toBe("原文");
expect(result.current.latest?.refined).toBe("第二版");
});
it("无历史版本时再沟通为空操作(不打后端)", async () => {
const { result } = renderHook(() => useRefine());
await act(async () => {
await result.current.recommunicate("p1", 1, "改一下");
});
expect(post).not.toHaveBeenCalled();
});
it("后端返回 errorstatus=error、弹错误 toast、版本栈不变", async () => {
post.mockResolvedValue({ data: null, error: { detail: "配额不足" } });
const { result } = renderHook(() => useRefine());
await act(async () => {
await result.current.refine("p1", 1, "原文");
});
expect(result.current.status).toBe("error");
expect(result.current.versions).toEqual([]);
expect(toast).toHaveBeenCalledWith(expect.any(String), "error");
});
it("请求抛异常status=error、弹网络异常 toast", async () => {
post.mockRejectedValue(new Error("network down"));
const { result } = renderHook(() => useRefine());
await act(async () => {
await result.current.refine("p1", 1, "原文");
});
expect(result.current.status).toBe("error");
expect(toast).toHaveBeenCalledWith("回炉请求异常,请检查网络。", "error");
});
it("reset 清回 idle 与空版本", async () => {
post.mockResolvedValue({ data: { original: "s", refined: "r" }, error: null });
const { result } = renderHook(() => useRefine());
await act(async () => {
await result.current.refine("p1", 1, "s");
});
act(() => result.current.reset());
expect(result.current.status).toBe("idle");
expect(result.current.versions).toEqual([]);
expect(result.current.latest).toBeNull();
});
});

View File

@@ -0,0 +1,112 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { api } from "@/lib/api/client";
import { useToast } from "@/components/Toast";
// 编辑器内「润色 / 再沟通」核心(复用同步 /refine只读、不写库——不变量 #3
// 润色:对选中段回炉一次。再沟通:以上一版产出为输入 + 作者新意见再回炉,形成版本栈,
// 作者可回看每一版、择一 accept 回填正文accept/回填由组件负责,此处只管生成与版本历史)。
export interface RefineVersion {
// 本次回炉的输入段:首轮 = 选中原文;再沟通 = 上一版产出。
segment: string;
// 本次改写指令(再沟通时为作者的新意见);无则空串。
instruction: string;
refined: string;
}
export type RefineStatus = "idle" | "refining" | "ready" | "error";
export interface UseRefine {
status: RefineStatus;
versions: RefineVersion[];
// 版本栈顶(最新产出)。
latest: RefineVersion | null;
// 最初选中的原文accept 回填的对比基线)。
original: string | null;
refine: (
projectId: string,
chapterNo: number,
segment: string,
instruction?: string,
) => Promise<void>;
// 以上一版产出为输入 + 作者新意见再回炉一次。无历史版本时为空操作。
recommunicate: (
projectId: string,
chapterNo: number,
instruction: string,
) => Promise<void>;
reset: () => void;
}
export function useRefine(): UseRefine {
const [status, setStatus] = useState<RefineStatus>("idle");
const [versions, setVersions] = useState<RefineVersion[]>([]);
const toast = useToast();
const runRefine = useCallback(
async (
projectId: string,
chapterNo: number,
segment: string,
instruction: string,
): Promise<void> => {
setStatus("refining");
try {
const trimmed = instruction.trim();
const { data, error } = await api.POST(
"/projects/{project_id}/chapters/{chapter_no}/refine",
{
params: { path: { project_id: projectId, chapter_no: chapterNo } },
body: trimmed ? { segment, instruction: trimmed } : { segment },
},
);
if (error || !data) {
setStatus("error");
toast("回炉失败,请重试。", "error");
return;
}
setVersions((prev) => [
...prev,
{ segment, instruction: trimmed, refined: data.refined },
]);
setStatus("ready");
} catch {
setStatus("error");
toast("回炉请求异常,请检查网络。", "error");
}
},
[toast],
);
const refine = useCallback<UseRefine["refine"]>(
(projectId, chapterNo, segment, instruction) =>
runRefine(projectId, chapterNo, segment, instruction ?? ""),
[runRefine],
);
const recommunicate = useCallback<UseRefine["recommunicate"]>(
(projectId, chapterNo, instruction) => {
const last = versions[versions.length - 1];
// 无历史版本 → 无从「再沟通」;空操作,不打后端。
if (!last) return Promise.resolve();
return runRefine(projectId, chapterNo, last.refined, instruction);
},
[runRefine, versions],
);
const reset = useCallback((): void => {
setStatus("idle");
setVersions([]);
}, []);
const latest = versions.at(-1) ?? null;
const original = versions[0]?.segment ?? null;
return useMemo(
() => ({ status, versions, latest, original, refine, recommunicate, reset }),
[status, versions, latest, original, refine, recommunicate, reset],
);
}

View File

@@ -0,0 +1,115 @@
// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useRewriteClarify } from "./useRewriteClarify";
import type { ClarifyDecisionVM } from "./clarify";
const post = vi.fn();
vi.mock("@/lib/api/client", () => ({
api: { POST: (...a: unknown[]) => post(...a) },
}));
const CLARIFY_PATH = "/projects/{project_id}/chapters/{chapter_no}/rewrite/clarify";
describe("useRewriteClarify", () => {
beforeEach(() => post.mockReset());
afterEach(() => vi.clearAllMocks());
it("初始 idle、无决策", () => {
const { result } = renderHook(() => useRewriteClarify());
expect(result.current.status).toBe("idle");
expect(result.current.decision).toBeNull();
});
it("need_clarification=truePOST rewrite/clarify 带 feedback+prior_draft映射 VMstatus=asking", async () => {
post.mockResolvedValue({
data: {
need_clarification: true,
questions: [
{
question: "高潮想更爽还是更虐?",
options: [
{ label: "更爽", value: "把高潮写得更爽快" },
{ label: "更虐", value: "把高潮写得更虐" },
],
allow_free_text: true,
},
],
verification: null,
},
error: null,
});
const { result } = renderHook(() => useRewriteClarify());
let vm: ClarifyDecisionVM | undefined;
await act(async () => {
vm = await result.current.check("p1", 3, "改改", "整章草稿正文");
});
expect(post).toHaveBeenCalledWith(CLARIFY_PATH, {
params: { path: { project_id: "p1", chapter_no: 3 } },
body: { feedback: "改改", prior_draft: "整章草稿正文" },
});
expect(vm).toEqual({
needClarification: true,
questions: [
{
question: "高潮想更爽还是更虐?",
options: [
{ label: "更爽", value: "把高潮写得更爽快" },
{ label: "更虐", value: "把高潮写得更虐" },
],
allowFreeText: true,
},
],
verification: undefined,
});
expect(result.current.status).toBe("asking");
expect(result.current.decision?.needClarification).toBe(true);
});
it("need_clarification=falsestatus=proceeddecision 清空", async () => {
post.mockResolvedValue({
data: { need_clarification: false, questions: [], verification: "我按你说的拉满高潮" },
error: null,
});
const { result } = renderHook(() => useRewriteClarify());
let vm: ClarifyDecisionVM | undefined;
await act(async () => {
vm = await result.current.check("p1", 1, "把第 3 段的高潮再拉满,删掉闪回", "草稿");
});
expect(vm?.needClarification).toBe(false);
expect(result.current.status).toBe("proceed");
expect(result.current.decision).toBeNull();
});
it("后端 error视为放行proceedneedClarification=false不阻塞重写", async () => {
post.mockResolvedValue({ data: null, error: { detail: "boom" } });
const { result } = renderHook(() => useRewriteClarify());
let vm: ClarifyDecisionVM | undefined;
await act(async () => {
vm = await result.current.check("p1", 1, "改", "草稿");
});
expect(vm?.needClarification).toBe(false);
expect(result.current.status).toBe("proceed");
});
// 注hook 的 try/catch 对「fetch 抛异常」的放行分支与上面「后端 error 信封」放行分支同构、
// 行为一致(均 status=proceed、needClarification=false、返回 PROCEED此处不单测 throw 路径,
// 因 mock 抛错 + React act 冲刷会触发 vitest 未处理错误误报(与被测逻辑无关,同 M1 useClarify.test.ts
it("reset 清回 idle", async () => {
post.mockResolvedValue({
data: { need_clarification: true, questions: [{ question: "q", options: [], allow_free_text: true }] },
error: null,
});
const { result } = renderHook(() => useRewriteClarify());
await act(async () => {
await result.current.check("p1", 1, "改", "草稿");
});
act(() => result.current.reset());
expect(result.current.status).toBe("idle");
expect(result.current.decision).toBeNull();
});
});

Some files were not shown because too many files have changed in this diff Show More