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:
@@ -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
2
.gitignore
vendored
@@ -19,3 +19,5 @@ apps/web/lib/api/openapi.json
|
|||||||
# os
|
# os
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.gstack/
|
.gstack/
|
||||||
|
# coverage artifact(不入库;覆盖率跑生成,误提交由 CR 批 c596a8d 撤销)
|
||||||
|
.coverage
|
||||||
|
|||||||
@@ -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 原型可固定}
|
||||||
|
|
||||||
|
|||||||
@@ -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/**`)。
|
||||||
|
|||||||
82
PROGRESS.md
82
PROGRESS.md
@@ -17,29 +17,62 @@
|
|||||||
|
|
||||||
| 任务 | 状态 | 负责 | 依赖 | 备注 |
|
| 任务 | 状态 | 负责 | 依赖 | 备注 |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| CR-C1 🔴 升级 Next.js 修 RCE(CVE-2025-66478)+ React 补丁 + 轮换密钥 | ⬜ | @frontend | — | `apps/web/package.json` next **15.1.3→15.1.9**(或 15.5.7/16.x),react/react-dom 19.0.0→补丁版(上游 CVE-2025-55182);App Router + RSC 应用正受影响。`pnpm install` 重锁 + `pnpm build` 验;**升级后轮换全部应用密钥**(联动 CR-H8)。Next15 LTS 2026-10-21 EOL,规划 16.x 迁移 |
|
| CR-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 回归测试 + DRY(Drawer/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 WHERE(Protocol/Sql/fake 加 keyword `volume`);`bf3dabe` 角色/世界观入库 N+1→`create_many`(世界观 add_all;角色一次 IN 查+一次 flush 保 upsert 幂等/批内去重;单条 create 薄封装);`1392830` jobs 读端点裸 ORM→JobRepo;`0892ff6` projects/templates 列表加分页(`ww_api.pagination` limit∈[1,200]/offset≥0,越界 422,下推 LIMIT/OFFSET)。**item#4 provider_deps `_client`→已由 @llm 交付**(`832ab7d` `OpenAICompatAdapter.probe_connection()` 公开探测 + provider_deps 改调,删私有 `_client` 反手 + `type:ignore`/`noqa:SLF001`;isinstance 收窄兜非 OpenAI 兼容)。**item#6 assemble 并行→SKIP**:8 查询共享同一注入 AsyncSession,`gather` 会腐蚀(ledger 警告属实);安全化须把 session 工厂穿透 assemble + 8 repo 各独立 session + 并发正确性测,对单用户几 ms 收益不值该回归风险 |
|
||||||
| CR-M2 🟡 网关/adapters 整改批 | ⬜ | @llm | — | `gateway.py:90` `is_open()` 副作用违 CQS;`pricing.py` 未知 model 静默记 0 → 加 warning;adapter `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.8,collect 仍一次落库;并发峰值测 + 五审落齐测)。**item `run_overdue_scan` 幂等→SKIP**:`scan_overdue` 本已幂等(单条 `UPDATE...RETURNING` + `status NOT IN [CLOSED,OVERDUE]` + commit-only-if-changed,原子无部分空洞),且下一章扫描 current+1 自愈漏标;再动会触碰验收事务边界,单用户零收益 |
|
||||||
| CR-M4 🟡 前端 key/a11y/cleanup 批 | ⬜ | @frontend | — | 可编辑列表去 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` ARIA(NavDrawer `aria-controls` + Drawer `id` + ForeshadowCard 语义化 `<dl>/<dt>/<dd>`)+ `125908e` ReviewReport `flashMark` setTimeout 卸载清理(ref 存句柄)。**核实为无需改/已具备**:index-key(三处均 replace-all/append-only 静态展示、无天然稳定 id,index 安全;卡名可编辑作 key 更糟)、`stop`(经查**非**服务端 SSE 帧、是客户端 AbortController 动作,加则处理不存在事件)、AppShell nav label(`<nav aria-label>` 已有)、ProjectWizard 步骤焦点(`stepHeadingRef`+首帧守卫 useEffect 已实现) |
|
||||||
| CR-M5 🟡 前端 TS 边界类型收紧 | ⬜ | @frontend | — | `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` 静默回退加 warning;SSE 端点补 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/LOW(CR-M1..M5 / L1 / D1)亦全部清理 ✅**(四簇串行整合):明确取舍——高价值低风险项落地、风险>价值或前提已陈旧的子项**有据 SKIP/NOOP**(assemble 并行 gather / breaker is_open CQS / overdue-scan 已幂等 / index-key 安全 / `stop` 非服务端帧 / 已具备的 nav-label、wizard 焦点)。**门禁全绿**:后端 mypy **232** · alembic 无漂移 · pytest **971**;前端 vitest **681** · build · coverage **95.45%**。附带修复:一 agent 误提交的非源文件(`.coverage`/`AGENTS.md`/`example_skills`)已撤销还原为未跟踪(`1bda024`)。
|
||||||
|
>
|
||||||
> **维度小结**:后端 FastAPI 架构 0C/2H/7M·Python 规范 0C/4H/6M·LangGraph 1C/2H/5M·前端 React 0C/2H/9M·前端 TS 0C/1H/8L·安全 0C/2H/4M·依赖 1C。后端依赖全部当前大版本线;前端唯一关键缺口=Next RCE。
|
> **维度小结**:后端 FastAPI 架构 0C/2H/7M·Python 规范 0C/4H/6M·LangGraph 1C/2H/5M·前端 React 0C/2H/9M·前端 TS 0C/1H/8L·安全 0C/2H/4M·依赖 1C。后端依赖全部当前大版本线;前端唯一关键缺口=Next RCE。
|
||||||
|
>
|
||||||
|
> **[2026-07-08] CR-M1 + CR-L1 后端片 ✅ 落地**(@backend,6 提交 `c596a8d`/`bf3dabe`/`1392830`/`0892ff6`/`49e3cb6`,各行为改动先写红测再实现)。CR-M1 4/6 完成(volume 下推 · N+1→create_many · jobs→JobRepo · 列表分页),item#4(去 `_client`)与 item#6(assemble 并行)SKIP(理由见 CR-M1 行);CR-L1 后端片完成(SSE OpenAPI;print 属误报)。**门禁全绿**:后端 ruff/format 干净 · mypy **230** · pytest **960 passed**(较基线 955 +5 新测:volume 下推 1 + 分页 2 + 批量入库 2)· 无模型改动故免 alembic。新增 5 测无回归,既有 upsert 幂等/冲突 gate + m5 真 pg E2E 全绿。
|
||||||
|
>
|
||||||
|
> **[请求 → @llm]** CR-M1 item#4:`apps/api/ww_api/services/provider_deps.py:91` 需去掉对 adapter 私有 `_client` 的探测(`adapter._client.models.list()`)。owning 类 `OpenAICompatAdapter`/`ProviderAdapter`(`packages/llm_gateway/ww_llm_gateway/adapters/`)在 @llm 域——请在 adapter 上加一个公开 `async probe_connection()`(内部最小请求验 Key,如 `models.list()`),@backend 改调该公开方法即可删私有属性访问 + `type: ignore`/`noqa: SLF001`。行为保持不变。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 进行中:写作工作台重构(writing-first)· 分支 `feat/writing-first-workspace`
|
||||||
|
|
||||||
|
> **背景**:[2026-07-07] 用户对写作体验提 8 点反馈,产品主张升级为 **writing-first 工作台**——进来即写、所有工具/AI 动作从编辑器内按需调用并回填正文、元数据由内容渐进生成。多 agent 工作流(扎根代码→Web+GitHub 调研→三方设计→四镜头对抗评审→定稿)产出 DEV_PLAN(`scratchpad/writing-first-workspace-DEVPLAN.md`,**ephemeral 未入库**;灵感/取舍见 `memory/selectable-write-prompts.md`)。
|
||||||
|
> **已拍板决策**:进来即写=**延迟落库**(首次击键才建占位项目 + 列表过滤空稿);可选提示词 MVP=**纯前端** directive→volatile(不改 `DraftStreamRequest` 契约、不动 `write_craft.md` 字节 → 金标准零回归);预设 MVP=先只上内置;续写=多候选卡点选插入。
|
||||||
|
> **纪律**:全程 TDD 小步提交,回填全守 HITL(AI 绝不 auto-replace,点选/接受才落草稿);`/refine`、`/rewrite` 只读不写库(不变量 #3)。**门禁全绿**:前端 vitest **678** / lint / tsc / build / coverage **95.36%**;后端 ruff / mypy **229** / pytest **955**(含 WFW-9 clarify E2E)/ alembic 无漂移。**WFW-0~9 全部交付**(WFW-5/6/7 多 agent 并行 → `3dd7d28`;WFW-8 整章重写跨栈 → `6ed9c89`+`542c2ab`;**WFW-9 三段全交付** M1 `1652ad9`+`e30c933` / M2 `4a09f27`..`8e29b33` / M3 `ea9a9d9`+`a703a90`,契约 C-Rewrite+C-Clarify 见 `memory/contracts.md`)。**未 push、未开 PR**(等用户示意)。
|
||||||
|
|
||||||
|
| 任务 | 状态 | 负责 | 依赖 | 反馈# | 备注 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| WFW-0 进来即写(延迟落库) | ✅ | @frontend | — | #5 | `8389572` `/write`(DraftComposer) + `lib/start/useDeferredDraft`(懒建占位项目+种首章草稿,双重幂等,空进空出不落库) + 落地页 CTA「直接开始写」主/「先立项」辅 |
|
||||||
|
| WFW-1 润色 / 再沟通 | ✅ | @frontend | — | #1#2 | `2f0b841` `useRefine`(同步/refine,版本栈,再沟通=上一版产出+新意见再回炉) + `refineApply`(接受回填按内容重锚,原文变则提示重选) + RefinePanel 内联卡 + Editor 上报选区 |
|
||||||
|
| WFW-2 续写(多候选) | ✅ | @frontend | WFW-0 | #2 | `9195cf3` `useContinue`(continue 生成器 with_prior_chapter 读前文,多候选累加) + ContinuePanel 候选卡点选插入章末 + flush 后读最新草稿 |
|
||||||
|
| WFW-3 内联工具箱 + 金手指露出 | ✅ | @frontend | — | #4#8 | `04f4785` `useToolboxTools`(客户端拉描述符) + InlineToolbox(列非 legacy 生成器含金手指) + `GeneratorRunner` 加 `onInsertText`(文本产物插入正文) |
|
||||||
|
| WFW-4 AI 写作三槽(文风/剧情/自定义) | ✅ | @frontend | — | #3 | `44b0b8a` `directive.ts` 加 `PLOT_PRESETS` + `composeDirective` 合并;DirectivePanel 拆文风/剧情两组 chips + 自定义。纯前端零契约,`write_craft.md` 字节不变 |
|
||||||
|
| WFW-5 内容感知书名 | ✅ | @frontend | — | #6 | `3dd7d28` 纯前端零后端 MVP:`buildManuscriptExcerpt`(首尾摘录+硬上界) + GeneratorRunner「用当前正文」按钮把摘录填 brief,book-title 据正文反推。**后续更佳版**:后端富化 book-title.md + `with_manuscript` 策略(未做) |
|
||||||
|
| WFW-6 侧栏 IA 收敛(ContextDrawer) | ✅ | @frontend | — | #7 | `3dd7d28` 加法式 ContextDrawer 右侧速查抽屉 + 底栏「速查」按钮;设定库/大纲**完整只读速查**,伏笔/规则/文风**摘要+跳全宽页**(后续可补真实数据)。`CONTEXT_DRAWER_ENABLED` 一键回滚、旧侧栏/全宽页路由全保留 |
|
||||||
|
| WFW-7 首次 AI 写作前极轻 genre 采集 + 无大纲降级 | ✅ | @frontend | WFW-0 | — | `3dd7d28` 无题材首次写章前弹 GenrePicker(临时并入本次 directive 不落库) + chapters 空时「尚无大纲,将按设定自由生成」提示,杜绝无题材 slop |
|
||||||
|
| WFW-8 整章级「再沟通/重写」(对话式迭代) | ✅ | @backend·@llm·@frontend | — | 新#1 | `6ed9c89`(后端)+`542c2ab`(前端)。后端 SSE `POST .../rewrite`(`rewrite.md` 教条 + `rewrite_node` + `RewriteStreamRequest`,复用 assemble 记忆注入,只读不写库、工具非写节点、守 #3/#7/#9);前端 `useChapterRewrite`(版本栈)+`ChapterRewritePanel`+底栏「整章重写」:给意见→流式重写整章→多轮迭代→接受某版才替换正文(HITL)。契约 C-Rewrite。用户反馈「整章不满意要跟 AI 交流」 |
|
||||||
|
| WFW-9 M1 AI 反问澄清(refine 侧,路线A 两阶段) | ✅ | @backend·@llm·@frontend | — | 新#2 | `1652ad9`(后端)+`e30c933`(前端)。多agent并行(后端整片‖ChoiceChips)+主线集成。非流式预检 `POST .../refine/clarify`→`ClarifyDecision`(结构化,analyst,只读,失败回退放行)+clarify_refine spec/教条(SPECS#24);既有 refine 未改。前端 `useClarify`+`ChoiceChips`+`clarify.ts`(foldClarifications/门控)+RefinePanel 门控接线:意见<10字或点「帮我理清方向」→AI反问选项→折答案进instruction→走既有refine。契约 C-Clarify。**M1 refine 侧完成;M2/M3 见下两行** |
|
||||||
|
| WFW-9 M2 AI 反问澄清(rewrite 侧,整章重写路径) | ✅ | @backend·@llm·@frontend | — | 新#2 | `4a09f27`+`6fd6375`(后端)+`9b32743`+`4b1088a`+`8e29b33`(前端),多 agent 串行整合。新非流式预检 `POST .../rewrite/clarify`→`ClarifyDecision`(复用 M1 schema/`run_clarify`)+新 `clarify_rewrite` spec/教条(章级,SPECS 24→25,prompt_hashes +1)+`RewriteClarifyRequest{feedback,prior_draft?}`(仅取有界 3000 字摘录喂 analyst,不整章入 LLM);只读不写库、失败放行。前端 `useRewriteClarify`+`toVM/PROCEED` 提取入共享 `clarify.ts`(DRY)+`ChapterRewritePanel` 门控(镜像 RefinePanel):意见<10字或「帮我理清方向」→反问→折进 feedback→走既有 rewrite SSE。契约 C-Clarify 扩;gen:api 纳入新端点 |
|
||||||
|
| WFW-9 M3 澄清 UX 打磨 + E2E | ✅ | @frontend·@qa | — | — | `ea9a9d9`(a11y):refine/rewrite 两面板 AI 反问块加 `role=status` 向读屏动态播报(WCAG 4.1.3 Status Messages)。`a703a90`(E2E):`tests/test_wfw9_clarify_e2e.py` 7 用例真 pg+mock 网关零 token——refine/rewrite 含糊→反问 / 清晰→放行 / 失败开路(parsed=None→200 need_clarification=false,不 500) / 只读不写业务表但记 usage_ledger / 404。无产品 bug。**深度视觉/交互 QA(真起前端浏览)为人工验收事项** |
|
||||||
|
| WFW-9 M2 AI 反问澄清(rewrite 侧,路线A 两阶段) | ✅ 后端 | @llm·@backend | WFW-8·WFW-9 M1 | 新#2 | `4a09f27`(llm)+`6fd6375`(后端)。镜像 M1:非流式预检 `POST .../rewrite/clarify`←`RewriteClarifyRequest{feedback(必给),prior_draft(可选)}`→`ClarifyDecision`(复用同一 schema,analyst,只读,失败回退放行)+`clarify_rewrite_spec`/教条 `clarify_rewrite.md`(segment→整章,SPECS#25);既有 `.../rewrite` SSE 未改。端点只喂 prior_draft 开头 3000 字摘录(不整章入 LLM)、日志脱敏。契约 C-Clarify(扩)。门禁绿:后端 ruff/format·mypy229·pytest948·alembic 无漂移。**前端仍欠 `pnpm gen:api`+M2 UI 接线(仿 RefinePanel 门控 ChapterRewritePanel) + M3 UX/E2E 待办** |
|
||||||
|
| WFW-9 E2E(两阶段 AI 反问澄清,refine+rewrite) | ✅ | @qa | WFW-9 M1·M2 | 新#2 | `tests/test_wfw9_clarify_e2e.py` 7 用例真 pg + mock 网关(provider/adapter 层,同 M2/T6 范式)零 token:refine 含糊→反问1问 / refine 明确→放行、rewrite 含糊→反问 / rewrite 明确→放行、失败开放(网关产非 ClarifyDecision→仍200 need_clarification=false 不 500 且仍记账)、只读(seed chapter 字节一致+无 review/digest 行)但 usage_ledger 真落 analyst 一行、404 信封。响应绝不回灌原文摘录。无产品 bug。门禁绿:ruff/format·mypy 本文件净·pytest 955 passed |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -161,6 +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.4,TDD 逐项提交)**。**完成 6**:`32451a9` pricing 未知 model 发 `pricing_unknown_model` warning(成本仍 0);`9439b97` 三 adapter 瞬时错误消息经 `provider_error_summary(exc)` 只留类名+status(不泄 vendor 响应体);`832ab7d` `OpenAICompatAdapter.probe_connection()` 公开探测 + provider_deps 弃私有 `_client` 反手(CR-M1.4);`c9ffada` 续写链前一章无已验收终稿发 `chain_prior_accepted_missing` warning(CR-L1);`ec91c8f` 链 runner 改用公开 `graph.aget_state(config).interrupts` 判暂停(弃私有 `__interrupt__`);`6a739ca` review 节点五审 `asyncio.gather` 并行扇出(复用主图 add-only 记账并发范式 T3.8,collect 一次落库)。**SKIP 2**:CR-M2 `is_open()` CQS(惰性半开转移是唯一转半开点、拆分漏发命令会致熔断永不复位,风险>收益);CR-M3 `run_overdue_scan` 幂等(`scan_overdue` 本已是原子 `UPDATE...RETURNING`+`NOT IN[CLOSED,OVERDUE]`+commit-only-if-changed,且下一章 current+1 扫描自愈漏标,再动会触碰验收事务边界)。**门禁全绿**:后端 ruff/format 干净 · mypy **232 文件** · pytest **971 passed**(+11 新测:pricing 2 / 错误脱敏 4 / probe 2 / 续写缺前文告警 2 / 并行审 1)· 无 DDL/无迁移。守不变量 #1/#3/#4(熔断/验收事务/add-only 记账 sink 未动)。
|
||||||
|
- [2026-07-08] @llm/@backend/@devops/@frontend — **✅ QA-CR CRITICAL + HIGH 整改批全部落地**(14 项 + 4 低危收尾)。多 agent 工作流:先 14 个只读 agent 逐项**扎根真实代码**产精确修复+测试规格(纠出 3 处台账前提漂移),再按 owner 四簇**串行整合**入单工作树(避免并发写树 + repo 级 `ruff format .` 竞态),各 TDD 红→绿、逐项补回归测试、逐 fix 小步提交,末尾 14 个 skeptic **对抗式复核**(实证每条 test 非 vacuous,0 problem)。**关键落地/纠偏**:C1 next 15.1.3→**15.5.20**+react **19.2.7**(`pnpm audit` 清、`dependencyFloors.ts` 下界守卫,**CVE 号无法对真库核验+密钥轮换为用户运维事项**);C2 **前提证伪**(langgraph 默认上限 10007 非 25,无实活崩溃)仍加显式顶层 `recursion_limit`;H4 request_id **条件透传**(否则覆盖 contextvars 反退化);H2 修请求级 httpx 泄漏时**揪出 E2E 会误触真网**(override 跟随修);H9 全文本字段上限+2 MiB body 中间件 413 兜底(契约 C3,`gen:api` 无漂移);H8 `.env.example` 换不可用占位符(**真 key 仍在 git 历史,曾用环境须轮换**)。**门禁全绿**:后端 mypy 229/pytest **934**/alembic 无漂移;前端 vitest **669**/build/coverage 95.41%。契约 C1-扩(LlmRequest.request_id)+ C3(H9 caps)。**MEDIUM/LOW 择批后清理。** 提交 `cb8fddf`→`dd6d2fc`(19 提交)。
|
||||||
|
|
||||||
|
- [2026-07-08] @backend — **✅ QA-CR 后端整改 5 项(TDD,逐 fix 提交)**。**CR-C2**:`chain_runner.run_chain_job` 给 `graph.ainvoke` 传显式 top-level `recursion_limit=count*NODES_PER_CHAPTER+HEADROOM`(防御硬化,命名常量)。**CR-H1**:Kimi OAuth 设备轮询移出 DB 会话——新增 `_poll_for_token`(纯 HTTP)/`_make_persist_work`/`_run_kimi_oauth_poll`,仅最终持久化经 `run_job` 开短会话(防连接池饥饿;删 `_make_poll_work`)。**CR-H2**:请求级 httpx 客户端改**生成器依赖** `_http_client`(`async with ... yield`,请求结束 aclose 修泄漏);`_default_http_client` 保留供后台裸调;E2E + 端点测试 override 键跟随改 `_http_client`。**CR-H7**:冻结 `TOOLBOX` 为只读 `MappingProxyType`(`_TOOLBOX_BY_KEY` 私有底 dict + `Final[Mapping]`,仿 SPECS)。**CR-H9**:用户请求字符串字段补 `max_length`(providers/generation/projects/style/foreshadow/rules/templates,命名常量无魔数,样本列表长度+每条双上界)+ 新错误码 `ErrorCode.PAYLOAD_TOO_LARGE`(413) + 应用级 `body_size_limit_middleware`(2 MiB,按 Content-Length 拦截自建 413 信封),`main.py` 注册。**门禁全绿**:ruff/format 干净 · mypy **229 文件**(0 错)· pytest **928**(+23:recursion_limit 1 / poll-no-session 1 / http-gen 1 / toolbox-readonly 1 / input-size 19[17 参数化 + samples 1 + body413 1])· **无 DDL/无迁移**。记 contracts C3(CR-H9:新增 `maxLength` + `PAYLOAD_TOO_LARGE`)。→ **@frontend 须 `pnpm gen:api`**(请求字段现有 `maxLength`;本 cluster 未代跑)。守不变量 #3/#8/#9。
|
||||||
|
|
||||||
- [2026-07-06] @backend/@llm/@frontend — **✅ 灵感⑦ 群像按定位配比·缩减版 + §0 归属纠偏(develop 直提)**。角色群像生成支持「按定位配比」下发。**契约变更** `CharacterGenerateRequest` 加 `role_mix:dict[str,int]|None`——`model_validator(mode="after")` 定义与既有 `count` 的关系:在场时校验(非空、每项≥1、总和≤`MAX_GENERATE_COUNT`=12)并把 `count` 归一为各值之和(构造期归一化,忽略客户端 count);缺省退回单一 `role`+`count`。**§0 归属纠偏**:`build_character_gen_context`+`run_character_gen`(`ww_core/orchestrator/generation_node.py`)确为 **@llm 域**——加末位默认 `role_mix=None` 关键字参,在场时注入「角色定位配比」块(保插入序、确定性、无时间戳,守 #6/#7 assemble 纯函数;覆盖单一 role 行);`routers/generation.py` 穿 `role_mix=body.role_mix`。`character-gen.md` 加「按配比严格分配 role、各定位之和=总数」纪律 → **regen `prompt_hashes.json`**(仅 character-gen 哈希变,**不进 SPECS→不 bump 计数**)。**前端** `cards.ts` 加 `sanitizeRoleMix`(去空/去重/clamp≥1/按 12 截断)+`roleMixTotal`+`buildCharacterGenerateRequest` 第 4 参 roleMix;`useCharacterGen` `CharacterGenInput.roleMix` 穿参;新 `RoleMixEditor.tsx` + `CharacterGenerator` SegmentedControl 单一/配比双模。**TDD**:schema 校验 7 例(总和≤12/空 dict/非正值拒绝)+ context 注入 2 例 + run 穿参 1 例 + 前端 cards 构造/clamp 5 例 + hook renderHook 1 例。**门禁全绿**:后端 ruff/format 干净 · mypy **222** · pytest **858**(+11)· 无迁移(未动模型,未跑 alembic);前端 gen:api/lint/typecheck 干净 · vitest **543**(+6)· coverage 95%(cards.ts 100%)· build OK。记 contracts C3。守不变量 #2/#6/#7/#9。**无 DB 迁移**。
|
- [2026-07-06] @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/react;CodexPage 经 `dynamic(ssr:false)` 挂载于人物 tab,显式容器高度 h-[28rem] + `import '@xyflow/react/dist/style.css'` + prefers-reduced-motion 关 fitView 动画 + 纸感 CSS 变量配色 + 悬空计数提示)。依赖:`@xyflow/react@^12.11.2`+`@dagrejs/dagre@^3.0.0`(见 contracts.md 2026-07-06)。TDD:graphLayout **+22 测**(role 映射/节点去空重名/kind 标签+默认/悬空跳过/自指跳过/无向去重/边 id 唯一/悬空计数/布局确定性+不可变/端到端);组件走人工/E2E。前端门禁全绿:lint 干净 · typecheck 干净 · **vitest 565 passed** · **coverage lib 95.4%(graphLayout.ts 97.6%)** · **build OK**(codex 路由 7.58 kB,React Flow 懒加载独立 chunk)。注:`pnpm build` 门禁与运行中的 `next dev`(3001) 共享 `.next` → 触发 dev 服务临时 500(stale chunk),非代码缺陷、dev watcher 下次重编自愈,未重启服务。
|
- [2026-07-06] @frontend — **✅ 灵感⑥ 关系图谱(缩减版)+ D4 完成**(纯前端,无后端契约/迁移/gen:api)。新增 `lib/characters/graphLayout.ts`(纯逻辑:`roleStyle` role→分组配色关键词映射、`buildGraphNodes`/`buildGraphEdges`(kind→标签,`DEFAULT_RELATION_LABEL` 命名常量)、`countDanglingRelations`、`layoutGraph`(@dagrejs/dagre 一次性确定性初始坐标、中心→左上、不可变)、`buildRelationshipGraph` 端到端)——**仅用现有 `CharacterCardView.relations={name,kind,note}`**:边按 name-join、kind 作中文标签、节点按 role 上色,无 closeness/阵营(数据不存在,不硬造);悬空边(引用未入库角色)跳过并计数。新增 `components/characters/RelationshipGraph.tsx`(`'use client'`,React Flow @xyflow/react;CodexPage 经 `dynamic(ssr:false)` 挂载于人物 tab,显式容器高度 h-[28rem] + `import '@xyflow/react/dist/style.css'` + prefers-reduced-motion 关 fitView 动画 + 纸感 CSS 变量配色 + 悬空计数提示)。依赖:`@xyflow/react@^12.11.2`+`@dagrejs/dagre@^3.0.0`(见 contracts.md 2026-07-06)。TDD:graphLayout **+22 测**(role 映射/节点去空重名/kind 标签+默认/悬空跳过/自指跳过/无向去重/边 id 唯一/悬空计数/布局确定性+不可变/端到端);组件走人工/E2E。前端门禁全绿:lint 干净 · typecheck 干净 · **vitest 565 passed** · **coverage lib 95.4%(graphLayout.ts 97.6%)** · **build OK**(codex 路由 7.58 kB,React Flow 懒加载独立 chunk)。注:`pnpm build` 门禁与运行中的 `next dev`(3001) 共享 `.next` → 触发 dev 服务临时 500(stale chunk),非代码缺陷、dev watcher 下次重编自愈,未重启服务。
|
||||||
|
- [2026-07-08] @frontend — **CR-M4/M5/L1 前端评审收尾(MEDIUM/LOW 打磨,仅 apps/web,无后端契约/gen:api)**。DONE:①a11y——`NavDrawer` 汉堡按钮补 `aria-controls`(新增 `Drawer` 可选 `id` prop 指向面板,默认 `nav-drawer`)、`ForeshadowCard` 的 `<dl>` 裸 `<div>` 行改真 `<dt>/<dd>` 结构(可视文案不变,importance 用 sr-only 标签);②`ReviewReport.flashMark` 的 `setTimeout` 句柄入 ref + 卸载 useEffect 清理(防定时器泄漏,提 `FLASH_DURATION_MS` 常量);③`useAccept` 手写 `ApiErrorEnvelope` 改由生成 schema 派生 `components["schemas"]["ErrorEnvelope"]`(端点专属 details 就地收窄,行为不变);④`applyFix.hasApplicableFix` 重写为对象入参**类型谓词** `patch is ApplicablePatch`,消除 `applyConflictFix` 里两处 `as string`(含收窄单测);⑤`server.parseJsonBody` 已具边界校验,补两条守卫分支测试(非对象体/非 JSON 体);⑥去冗余 cast:`stream/sse.ts` `idx.valueOf()`→`idx`、`jobs/job.ts` `dict["error"] as string`(typeof 守卫后 TS 自动收窄)。SKIP/NOOP(前提已过时/本就安全):`ProjectWizard` 换步聚焦标题已实现;`AppShell`/`LeftNav` 两处 `<nav>` 均已带 `aria-label`;`KNOWN_EVENTS` 加 `stop`——`stop` 是客户端 abort 动作非服务端 SSE 帧(对照 contracts.md 草稿 token/done/error、续审 section/conflict/foreshadow/pace/style/characterization/done/error 均无 stop),加入会为不存在的事件建处理;`CharacterGenerator`/`GeneratorRunner`/`ForeshadowCard.progress` 的 index-key——三处均为整表替换/追加式/静态展示列表(无逐项重排或删除,`CharacterCardItem`/`PreviewRow` 为无内部状态受控组件,选择/编辑均按 index 且随新预览整体重置),index 作 key 安全且无天然稳定 id,加合成 UUID 需重做 index 选择/编辑态属过度改造。门禁全绿:lint 干净 · typecheck 干净 · **vitest 681 passed**(+3)· **coverage lib 95.45%**(exit 0)· **build OK**。
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -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. 起后端 API(http://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` 里只有那把加密主密钥本身。
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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-C2:run_chain_job 给 graph.ainvoke 传显式 top-level recursion_limit(防御硬化)。
|
||||||
|
|
||||||
|
langgraph 默认上界已足够(无实时崩溃),但每链上界应显式 = count*NODES_PER_CHAPTER +
|
||||||
|
HEADROOM。断言 config 键值 + 仍正常跑完(行为不变)。用 PROXY 包裹真 graph 记录 config
|
||||||
|
(不 setattr 到可能冻结的 CompiledStateGraph 上)。
|
||||||
|
"""
|
||||||
|
from ww_core.orchestrator.chain import build_chain_graph as real_build
|
||||||
|
|
||||||
|
captured: dict[str, Any] = {}
|
||||||
|
|
||||||
|
class _SpyGraph:
|
||||||
|
def __init__(self, inner: Any) -> None:
|
||||||
|
self._inner = inner
|
||||||
|
|
||||||
|
async def ainvoke(self, inp: Any, *, config: Any = None, **kw: Any) -> Any:
|
||||||
|
captured["config"] = config
|
||||||
|
return await self._inner.ainvoke(inp, config=config, **kw)
|
||||||
|
|
||||||
|
async def aget_state(self, config: Any, **kw: Any) -> Any:
|
||||||
|
# runner 现经公开 aget_state 判 interrupt(CR-M3)——代理透传给真 graph。
|
||||||
|
return await self._inner.aget_state(config, **kw)
|
||||||
|
|
||||||
|
def _spy_build(*args: Any, **kwargs: Any) -> Any:
|
||||||
|
return _SpyGraph(real_build(*args, **kwargs))
|
||||||
|
|
||||||
|
monkeypatch.setattr("ww_api.services.chain_runner.build_chain_graph", _spy_build)
|
||||||
|
|
||||||
|
fakes = _chain_runner_fakes()
|
||||||
|
gateway = _FakeChainGateway(conflicts=[])
|
||||||
|
job_id = uuid.uuid4()
|
||||||
|
saver = MemorySaver()
|
||||||
|
|
||||||
|
await run_chain_job(
|
||||||
|
_session_factory,
|
||||||
|
job_id,
|
||||||
|
project_id=uuid.uuid4(),
|
||||||
|
user_id=USER,
|
||||||
|
chain_key="draft_volume",
|
||||||
|
start_chapter_no=1,
|
||||||
|
count=7,
|
||||||
|
chain_gateway_builder=_async_returning(gateway),
|
||||||
|
checkpointer_ctx=lambda: _memsaver_ctx(saver),
|
||||||
|
accept_op=fakes["accept_op"],
|
||||||
|
chapter_repo_factory=fakes["chapter_repo_factory"],
|
||||||
|
review_repo_factory=fakes["review_repo_factory"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# (1) 显式上界 = count*NODES_PER_CHAPTER + HEADROOM = 7*4+10 = 38。
|
||||||
|
assert captured["config"]["recursion_limit"] == 7 * 4 + 10 == 38
|
||||||
|
# (2) recursion_limit 是 top-level 键(不在 configurable 下——langgraph 只认 top-level)。
|
||||||
|
assert "recursion_limit" not in captured["config"]["configurable"]
|
||||||
|
# (3) 仍正常跑完(行为不变)。
|
||||||
|
rec = _RecordingJobRepo.state[job_id]
|
||||||
|
assert rec["status"] == STATUS_DONE
|
||||||
|
assert rec["result"]["written"] == list(range(1, 8))
|
||||||
|
|
||||||
|
|
||||||
async def test_run_chain_job_conflict_sets_awaiting_then_resume_done(
|
async def test_run_chain_job_conflict_sets_awaiting_then_resume_done(
|
||||||
_patched_runner: None,
|
_patched_runner: None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
112
apps/api/tests/test_input_size_limits.py
Normal file
112
apps/api/tests/test_input_size_limits.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
"""CR-H9:用户请求文本字段 max_length 上界 + 应用级请求体大小中间件(防 DoS/成本)。
|
||||||
|
|
||||||
|
- 单元:各请求 schema 的字符串字段在 cap 处可构造、cap+1 触发 ValidationError(含样本列表长度
|
||||||
|
与列表项两处上界——最大 DoS 面)。
|
||||||
|
- 集成(无 pg):超大请求体经中间件直接 413(不入路由/DB)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from pydantic import BaseModel, ValidationError
|
||||||
|
|
||||||
|
# create_app() 于 import ww_api.main 时构建,需 CREDENTIAL_ENC_KEY——先于任何 main 导入置好。
|
||||||
|
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
|
||||||
|
|
||||||
|
from ww_api.schemas.foreshadow import ForeshadowRegisterRequest
|
||||||
|
from ww_api.schemas.generation import CharacterGenerateRequest, WorldGenerateRequest
|
||||||
|
from ww_api.schemas.projects import (
|
||||||
|
AcceptRequest,
|
||||||
|
DraftSaveRequest,
|
||||||
|
DraftStreamRequest,
|
||||||
|
ProjectCreateRequest,
|
||||||
|
ReviewRequest,
|
||||||
|
)
|
||||||
|
from ww_api.schemas.providers import (
|
||||||
|
ProviderCredentialInput,
|
||||||
|
TierRoutingInput,
|
||||||
|
)
|
||||||
|
from ww_api.schemas.providers import (
|
||||||
|
TestConnectionRequest as _TestConnectionRequest, # 别名避免 pytest 误采集 Test* 类
|
||||||
|
)
|
||||||
|
from ww_api.schemas.rules import RuleCreateRequest
|
||||||
|
from ww_api.schemas.style import RefineRequest, StyleLearnRequest
|
||||||
|
from ww_api.schemas.templates import TemplateCreateRequest
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _StrCase:
|
||||||
|
"""一个字符串字段的上界用例:cap 处可构造、cap+1 触发 ValidationError。"""
|
||||||
|
|
||||||
|
schema: type[BaseModel]
|
||||||
|
field_name: str
|
||||||
|
cap: int
|
||||||
|
case_id: str
|
||||||
|
base: dict[str, Any] = field(default_factory=dict) # 该 schema 其余必填字段
|
||||||
|
|
||||||
|
|
||||||
|
_STR_CASES: list[_StrCase] = [
|
||||||
|
_StrCase(ProviderCredentialInput, "api_key", 512, "api_key<=512", {"provider": "p"}),
|
||||||
|
_StrCase(ProviderCredentialInput, "provider", 64, "provider<=64", {"api_key": "k"}),
|
||||||
|
_StrCase(TierRoutingInput, "model", 128, "model<=128", {"tier": "writer", "provider": "p"}),
|
||||||
|
_StrCase(
|
||||||
|
TierRoutingInput, "provider", 64, "routing-provider<=64", {"tier": "writer", "model": "m"}
|
||||||
|
),
|
||||||
|
_StrCase(_TestConnectionRequest, "provider", 64, "test-provider<=64"),
|
||||||
|
_StrCase(WorldGenerateRequest, "brief", 10_000, "world-brief<=10000"),
|
||||||
|
_StrCase(CharacterGenerateRequest, "brief", 10_000, "char-brief<=10000"),
|
||||||
|
_StrCase(ProjectCreateRequest, "title", 200, "title<=200"),
|
||||||
|
_StrCase(AcceptRequest, "final_text", 200_000, "final_text<=200000"),
|
||||||
|
_StrCase(DraftSaveRequest, "text", 200_000, "draft-text<=200000"),
|
||||||
|
_StrCase(ReviewRequest, "draft", 200_000, "review-draft<=200000"),
|
||||||
|
_StrCase(RefineRequest, "segment", 20_000, "segment<=20000"),
|
||||||
|
_StrCase(RefineRequest, "instruction", 2_000, "refine-instruction<=2000", {"segment": "s"}),
|
||||||
|
_StrCase(DraftStreamRequest, "directive", 20_000, "directive<=20000"),
|
||||||
|
_StrCase(ForeshadowRegisterRequest, "code", 100, "code<=100", {"title": "t"}),
|
||||||
|
_StrCase(ForeshadowRegisterRequest, "title", 500, "foreshadow-title<=500", {"code": "c"}),
|
||||||
|
_StrCase(RuleCreateRequest, "content", 10_000, "rule-content<=10000", {"level": "project"}),
|
||||||
|
_StrCase(TemplateCreateRequest, "title", 200, "template-title<=200", {"body": "b"}),
|
||||||
|
_StrCase(TemplateCreateRequest, "body", 20_000, "template-body<=20000", {"title": "t"}),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("case", _STR_CASES, ids=[c.case_id for c in _STR_CASES])
|
||||||
|
def test_request_str_fields_reject_over_max_length(case: _StrCase) -> None:
|
||||||
|
# Arrange / Act — 长度 == cap:构造成功。
|
||||||
|
case.schema(**{**case.base, case.field_name: "x" * case.cap})
|
||||||
|
# Assert — 长度 == cap+1:超界即 ValidationError。
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
case.schema(**{**case.base, case.field_name: "x" * (case.cap + 1)})
|
||||||
|
|
||||||
|
|
||||||
|
def test_style_learn_samples_item_and_list_caps() -> None:
|
||||||
|
# 单条样本 == 20_000 字符 OK;20_001 → ValidationError(最大 DoS 面:每条正文都设上界)。
|
||||||
|
StyleLearnRequest(samples=["x" * 20_000])
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
StyleLearnRequest(samples=["x" * 20_001])
|
||||||
|
# 列表 == 20 条 OK;21 条 → ValidationError(同时约束列表长度)。
|
||||||
|
StyleLearnRequest(samples=["s"] * 20)
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
StyleLearnRequest(samples=["s"] * 21)
|
||||||
|
|
||||||
|
|
||||||
|
def test_oversized_request_body_rejected_413() -> None:
|
||||||
|
"""超大请求体经 body-size 中间件直接 413(不入路由/DB)。
|
||||||
|
|
||||||
|
不以 context manager 进入 TestClient → 不跑 lifespan/seed → 不需 Postgres。中间件在
|
||||||
|
路由前拦截,故请求根本不触达任何 DB。
|
||||||
|
"""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from ww_api.main import create_app
|
||||||
|
from ww_api.middleware import MAX_BODY_BYTES
|
||||||
|
|
||||||
|
client = TestClient(create_app())
|
||||||
|
resp = client.post("/projects", content=b"x" * (MAX_BODY_BYTES + 1))
|
||||||
|
|
||||||
|
assert resp.status_code == 413
|
||||||
|
assert resp.json()["error"]["code"] == "PAYLOAD_TOO_LARGE"
|
||||||
@@ -15,6 +15,8 @@
|
|||||||
from __future__ import annotations
|
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()
|
||||||
|
|||||||
@@ -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:
|
||||||
|
# 卷过滤下推到 repo(DB WHERE),而非端点 Python 侧过滤:直接调 repo 断言只回该卷。
|
||||||
|
repo = FakeOutlineReadRepo()
|
||||||
|
pid = uuid.uuid4()
|
||||||
|
repo.add_chapter(pid, volume=1, chapter_no=1, beats=["卷一·1"])
|
||||||
|
repo.add_chapter(pid, volume=1, chapter_no=2, beats=["卷一·2"])
|
||||||
|
repo.add_chapter(pid, volume=2, chapter_no=3, beats=["卷二·3"])
|
||||||
|
|
||||||
|
vol1 = await repo.list_for_project(pid, volume=1)
|
||||||
|
vol2 = await repo.list_for_project(pid, volume=2)
|
||||||
|
all_rows = await repo.list_for_project(pid)
|
||||||
|
|
||||||
|
assert [v.chapter_no for v in vol1] == [1, 2]
|
||||||
|
assert all(v.volume == 1 for v in vol1)
|
||||||
|
assert [v.chapter_no for v in vol2] == [3]
|
||||||
|
assert [v.chapter_no for v in all_rows] == [1, 2, 3]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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()
|
||||||
|
|||||||
@@ -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 422(limit∈[1,200]、offset≥0)。
|
||||||
|
assert too_small.status_code == 422
|
||||||
|
assert too_large.status_code == 422
|
||||||
|
assert neg_offset.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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 端点依赖的 app(project_repo + session + clarify 网关)。"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
|
||||||
|
from ww_api.main import create_app
|
||||||
|
from ww_api.services.project_deps import get_clarify_gateway, get_project_repo
|
||||||
|
from ww_db import get_session
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
app.dependency_overrides[get_project_repo] = lambda: project_repo
|
||||||
|
app.dependency_overrides[get_session] = lambda: session or FakeSession()
|
||||||
|
if clarify_gateway is not None:
|
||||||
|
app.dependency_overrides[get_clarify_gateway] = lambda: clarify_gateway
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def _clarify_client(app: FastAPI) -> httpx.AsyncClient:
|
||||||
|
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
|
||||||
|
return httpx.AsyncClient(transport=transport, base_url="http://test")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rewrite_clarify_vague_feedback_asks_one_question_and_commits() -> None:
|
||||||
|
# (a) 含糊章级意见 → need_clarification True + 恰 1 问;analyst 档 + 末尾 commit 落账。
|
||||||
|
project_repo = FakeProjectRepo()
|
||||||
|
pid = _seed_project(project_repo)
|
||||||
|
session = FakeSession()
|
||||||
|
gateway = FakeReviewGateway(parsed=_need_clarification_decision())
|
||||||
|
app = _clarify_app(project_repo=project_repo, session=session, clarify_gateway=gateway)
|
||||||
|
|
||||||
|
async with _clarify_client(app) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"/projects/{pid}/chapters/3/rewrite/clarify",
|
||||||
|
json={"feedback": "节奏太慢", "prior_draft": "本章开头……"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["need_clarification"] is True
|
||||||
|
assert len(body["questions"]) == 1
|
||||||
|
assert body["questions"][0]["options"][0]["label"] == "砍支线压缩篇幅"
|
||||||
|
assert body["questions"][0]["allow_free_text"] is True
|
||||||
|
# 不变量 #2:analyst 档;意见折进网关 input;末尾一次 commit(网关 usage_ledger 落库)。
|
||||||
|
assert gateway.requests[0].tier == "analyst"
|
||||||
|
assert "节奏太慢" in str(gateway.requests[0].input)
|
||||||
|
assert session.commits == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rewrite_clarify_clear_feedback_proceeds_read_only() -> None:
|
||||||
|
# (b) 明确意见 → need_clarification False + verification;只读、无回滚。
|
||||||
|
project_repo = FakeProjectRepo()
|
||||||
|
pid = _seed_project(project_repo)
|
||||||
|
session = FakeSession()
|
||||||
|
gateway = FakeReviewGateway(parsed=_clear_rewrite_decision())
|
||||||
|
app = _clarify_app(project_repo=project_repo, session=session, clarify_gateway=gateway)
|
||||||
|
|
||||||
|
async with _clarify_client(app) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"/projects/{pid}/chapters/3/rewrite/clarify",
|
||||||
|
json={"feedback": "把本章高潮提前到前半段,删掉开头回忆插叙。"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["need_clarification"] is False
|
||||||
|
assert body["questions"] == []
|
||||||
|
assert body["verification"] is not None
|
||||||
|
# 只读不写业务表:仅一次 commit 落网关 usage,无回滚(不变量 #3)。
|
||||||
|
assert session.commits == 1
|
||||||
|
assert session.rollbacks == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rewrite_clarify_unknown_project_404() -> None:
|
||||||
|
# (c) 项目不存在 → 404(触网关前 fail-fast)。
|
||||||
|
app = _clarify_app(
|
||||||
|
project_repo=FakeProjectRepo(),
|
||||||
|
clarify_gateway=FakeReviewGateway(parsed=_clear_rewrite_decision()),
|
||||||
|
)
|
||||||
|
async with _clarify_client(app) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"/projects/{uuid.uuid4()}/chapters/1/rewrite/clarify",
|
||||||
|
json={"feedback": "节奏太慢"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rewrite_clarify_fails_open_on_wrong_parsed() -> None:
|
||||||
|
# (d) 网关返回非 ClarifyDecision → 端点仍 200 + need_clarification False(韧性回退,非 500)。
|
||||||
|
from ww_agents import WorldGenResult
|
||||||
|
|
||||||
|
project_repo = FakeProjectRepo()
|
||||||
|
pid = _seed_project(project_repo)
|
||||||
|
session = FakeSession()
|
||||||
|
gateway = FakeReviewGateway(parsed=WorldGenResult(entities=[]))
|
||||||
|
app = _clarify_app(project_repo=project_repo, session=session, clarify_gateway=gateway)
|
||||||
|
|
||||||
|
async with _clarify_client(app) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"/projects/{pid}/chapters/3/rewrite/clarify",
|
||||||
|
json={"feedback": "重写一遍"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["need_clarification"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rewrite_clarify_empty_feedback_422() -> None:
|
||||||
|
project_repo = FakeProjectRepo()
|
||||||
|
pid = _seed_project(project_repo)
|
||||||
|
app = _clarify_app(
|
||||||
|
project_repo=project_repo,
|
||||||
|
clarify_gateway=FakeReviewGateway(parsed=_clear_rewrite_decision()),
|
||||||
|
)
|
||||||
|
async with _clarify_client(app) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"/projects/{pid}/chapters/1/rewrite/clarify", json={"feedback": ""}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rewrite_clarify_bounds_excerpt_and_never_leaks_manuscript() -> None:
|
||||||
|
# 有界摘录:超长 prior_draft 的越界正文不进网关 input,响应也绝不回灌正文(脱敏)。
|
||||||
|
project_repo = FakeProjectRepo()
|
||||||
|
pid = _seed_project(project_repo)
|
||||||
|
session = FakeSession()
|
||||||
|
gateway = FakeReviewGateway(parsed=_clear_rewrite_decision())
|
||||||
|
app = _clarify_app(project_repo=project_repo, session=session, clarify_gateway=gateway)
|
||||||
|
|
||||||
|
# 哨兵放在远超摘录上界处(10k 填充 + 哨兵),确保被截断在摘录外。
|
||||||
|
prior_draft = ("正" * 10_000) + _LEAK_SENTINEL
|
||||||
|
|
||||||
|
async with _clarify_client(app) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"/projects/{pid}/chapters/3/rewrite/clarify",
|
||||||
|
json={"feedback": "重写本章", "prior_draft": prior_draft},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
# 越界正文既不进 LLM 输入,也不出现在响应体(正文只喂有界摘录、绝不回灌)。
|
||||||
|
assert _LEAK_SENTINEL not in str(gateway.requests[0].input)
|
||||||
|
assert _LEAK_SENTINEL not in resp.text
|
||||||
|
|||||||
@@ -59,10 +59,12 @@ def _app_with_overrides(
|
|||||||
session_factory: FakeSessionFactory | None = None,
|
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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
# 放行安全字符集(字母数字 . _ -,1–128 位):覆盖 uuid4().hex、常见 trace id 与客户端
|
# 放行安全字符集(字母数字 . _ -,1–128 位):覆盖 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` 拦截超大请求体,超限直接 413(CR-H9)。
|
||||||
|
|
||||||
|
用户中间件跑在 FastAPI 的 AppError 处理器**之外**,故必须自建错误信封响应(不能靠抛
|
||||||
|
AppError)。分块编码(无 `Content-Length`)无法在此拦截——单用户原型可接受的限制。
|
||||||
|
"""
|
||||||
|
content_length = request.headers.get("content-length")
|
||||||
|
if content_length is not None:
|
||||||
|
try:
|
||||||
|
size = int(content_length)
|
||||||
|
except ValueError:
|
||||||
|
size = 0 # 非法头防御性视作 0,交由下游正常解析/报错。
|
||||||
|
if size > MAX_BODY_BYTES:
|
||||||
|
envelope = ErrorEnvelope(
|
||||||
|
error=ErrorBody(
|
||||||
|
code=ErrorCode.PAYLOAD_TOO_LARGE,
|
||||||
|
message="请求体过大",
|
||||||
|
request_id=_sanitize_request_id(request.headers.get(REQUEST_ID_HEADER)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return JSONResponse(status_code=413, content=envelope.model_dump())
|
||||||
|
return await call_next(request)
|
||||||
|
|||||||
37
apps/api/ww_api/pagination.py
Normal file
37
apps/api/ww_api/pagination.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""列表端点的分页参数(`limit`/`offset`,边界校验 + 合理默认)。
|
||||||
|
|
||||||
|
无界列表端点(owner 维度:projects / templates)原来一次返回全部行,行数随用户
|
||||||
|
数据线性增长。这里提供一个可复用的 FastAPI 依赖 `get_page`,把 `limit`/`offset`
|
||||||
|
在边界处校验(越界 → 422)后交端点。默认返回第一页(`offset=0` + `DEFAULT_PAGE_LIMIT`),
|
||||||
|
不传参的老客户端行为不破坏;`limit` 有上限(`MAX_PAGE_LIMIT`)防一次性拉爆。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends, Query
|
||||||
|
|
||||||
|
# 合理默认 + 上限(单用户原型:默认页足够覆盖常见数据量;上限防一次拉全表)。
|
||||||
|
DEFAULT_PAGE_LIMIT = 50
|
||||||
|
MAX_PAGE_LIMIT = 200
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Page:
|
||||||
|
"""一页的边界(不可变):`limit` 条数上限、`offset` 起始偏移。"""
|
||||||
|
|
||||||
|
limit: int
|
||||||
|
offset: int
|
||||||
|
|
||||||
|
|
||||||
|
def get_page(
|
||||||
|
limit: Annotated[int, Query(ge=1, le=MAX_PAGE_LIMIT)] = DEFAULT_PAGE_LIMIT,
|
||||||
|
offset: Annotated[int, Query(ge=0)] = 0,
|
||||||
|
) -> Page:
|
||||||
|
"""校验并封装分页参数(`limit∈[1,MAX]`、`offset≥0`;越界 FastAPI 返 422)。"""
|
||||||
|
return Page(limit=limit, offset=offset)
|
||||||
|
|
||||||
|
|
||||||
|
PageDep = Annotated[Page, Depends(get_page)]
|
||||||
@@ -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)]
|
||||||
|
|
||||||
# 提交边界:网关 ledger(precheck)+ 角色写侧均只 flush → 端点末尾一次 commit。
|
# 提交边界:网关 ledger(precheck)+ 角色写侧均只 flush → 端点末尾一次 commit。
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,51 +73,102 @@ _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` 自动 aclose(CR-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
|
||||||
|
|
||||||
async def work(session: AsyncSession) -> dict[str, Any]:
|
|
||||||
enc_key = get_settings().credential_enc_key.get_secret_value()
|
# SlowDown 退避每次增大的间隔量(秒)。
|
||||||
store = SqlCredentialStore(session)
|
_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)
|
interval = max(1, device.interval)
|
||||||
|
|
||||||
http = _default_http_client()
|
|
||||||
try:
|
|
||||||
for _ in range(_MAX_POLL_ATTEMPTS):
|
for _ in range(_MAX_POLL_ATTEMPTS):
|
||||||
await asyncio.sleep(interval)
|
await asyncio.sleep(interval)
|
||||||
try:
|
try:
|
||||||
token = await poll_token(http, device.device_code)
|
return await poll_token(http, device.device_code)
|
||||||
except AuthorizationPending:
|
except AuthorizationPending:
|
||||||
continue
|
continue
|
||||||
except SlowDown:
|
except SlowDown:
|
||||||
interval += 5
|
interval += _SLOW_DOWN_STEP_SECONDS
|
||||||
continue
|
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(
|
raise AppError(
|
||||||
ErrorCode.LLM_UNAVAILABLE,
|
ErrorCode.LLM_UNAVAILABLE,
|
||||||
"Kimi 设备授权轮询超时",
|
"Kimi 设备授权轮询超时",
|
||||||
{"provider": KIMI_CODE_PROVIDER},
|
{"provider": KIMI_CODE_PROVIDER},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_persist_work(token: TokenSet) -> Any:
|
||||||
|
"""构造**最终持久化**工作闭包:只做「加密 token → upsert oauth 凭据」的一次短事务。
|
||||||
|
|
||||||
|
`run_job` 用独立 session 调用此 work;轮询已在会话外完成,故此处会话极短。
|
||||||
|
**明文 token 绝不进 job 结果/日志。**
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def work(session: AsyncSession) -> dict[str, Any]:
|
||||||
|
enc_key = get_settings().credential_enc_key.get_secret_value()
|
||||||
|
store = SqlCredentialStore(session)
|
||||||
|
blob = encrypt_oauth_bundle(token, key=enc_key)
|
||||||
|
await store.upsert_oauth_credential(STUB_OWNER_ID, KIMI_CODE_PROVIDER, blob)
|
||||||
|
return {"connected": True, "provider": KIMI_CODE_PROVIDER}
|
||||||
|
|
||||||
|
return work
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_work(exc: Exception) -> Any:
|
||||||
|
"""构造只把 `exc` 重抛的 work(轮询失败路径:交由 `run_job` 分类脱敏并置 job failed)。"""
|
||||||
|
|
||||||
|
async def work(_session: AsyncSession) -> dict[str, Any]:
|
||||||
|
raise exc
|
||||||
|
|
||||||
|
return work
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_kimi_oauth_poll(
|
||||||
|
session_factory: SessionFactory,
|
||||||
|
job_id: uuid.UUID,
|
||||||
|
device: DeviceAuth,
|
||||||
|
*,
|
||||||
|
request_id: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""后台任务:**先在会话外**轮询 token,再经 `run_job` 开短会话持久化(CR-H1)。
|
||||||
|
|
||||||
|
轮询期间不占用任何 DB 会话(防连接池饥饿);成功 → 交由 `run_job` 落库并置 done;
|
||||||
|
失败 → 把异常带进 `run_job`,走其 `_classify_job_error`/`_mark_failed` 脱敏置 failed。
|
||||||
|
"""
|
||||||
|
http = _default_http_client()
|
||||||
|
try:
|
||||||
|
token = await _poll_for_token(http, device)
|
||||||
|
work = _make_persist_work(token)
|
||||||
|
except Exception as exc: # noqa: BLE001 — 失败经 run_job 统一脱敏置 job failed,不在此处冒泡。
|
||||||
|
work = _raise_work(exc)
|
||||||
finally:
|
finally:
|
||||||
# `httpx.AsyncClient` 有 `aclose`(最小 Protocol 无;测试 fake 也实现了它)。
|
# `httpx.AsyncClient` 有 `aclose`(最小 Protocol 无;测试 fake 也实现了它)。
|
||||||
aclose = getattr(http, "aclose", None)
|
aclose = getattr(http, "aclose", None)
|
||||||
if aclose is not None:
|
if aclose is not None:
|
||||||
await aclose()
|
await aclose()
|
||||||
|
await run_job(session_factory, job_id, work, request_id=request_id)
|
||||||
return work
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/start", status_code=202)
|
@router.post("/start", status_code=202)
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:新版停前端/草稿,接受才落库(不变量 #3);rewrite 是工具非写节点,不破坏章
|
||||||
|
纯函数性(不变量 #7)。项目不存在 → 404(触网关前 fail-fast,同 draft,QA C1)。
|
||||||
|
"""
|
||||||
|
if await project_repo.get(STUB_OWNER_ID, project_id) is None:
|
||||||
|
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
|
||||||
|
request_id = getattr(request.state, "request_id", None)
|
||||||
|
override = await injection_repo.get(project_id, chapter_no)
|
||||||
|
context = await assemble(repos, project_id, chapter_no, override=override)
|
||||||
|
log.info(
|
||||||
|
"rewrite_stream_start",
|
||||||
|
project_id=str(project_id),
|
||||||
|
chapter_no=chapter_no,
|
||||||
|
request_id=request_id,
|
||||||
|
stable_core_len=len(context.stable_core),
|
||||||
|
volatile_len=len(context.volatile),
|
||||||
|
prior_draft_len=len(body.prior_draft),
|
||||||
|
feedback_len=len(body.feedback),
|
||||||
|
)
|
||||||
|
|
||||||
|
deltas = stream_chapter_rewrite(
|
||||||
|
gateway,
|
||||||
|
stable_core=context.stable_core,
|
||||||
|
volatile=context.volatile,
|
||||||
|
prior_draft=body.prior_draft,
|
||||||
|
feedback=body.feedback,
|
||||||
|
user_id=STUB_OWNER_ID,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _frames() -> AsyncIterator[str]:
|
||||||
|
async for event in normalize_deltas(deltas, request_id=request_id):
|
||||||
|
yield _encode_sse(event)
|
||||||
|
try:
|
||||||
|
await session.commit()
|
||||||
|
except Exception: # noqa: BLE001 — 流已发完,commit 失败不能再改响应;至少记错误。
|
||||||
|
log.error("sse_commit_failed", request_id=request_id, endpoint="rewrite")
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
_frames(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={"cache-control": "no-cache", "x-accel-buffering": "no"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 整章重写预检的草稿摘录上界:只喂 analyst 预检**开头一小段**供锚定,绝不整章入 LLM
|
||||||
|
# (守成本/上下文,且预检只需判意见含糊度、不需读全章)。
|
||||||
|
_REWRITE_CLARIFY_EXCERPT_MAX = 3000
|
||||||
|
|
||||||
|
|
||||||
|
def _build_rewrite_clarify_input(feedback: str, prior_draft: str | None) -> str:
|
||||||
|
"""组整章重写预检输入:作者章级意见 + 当前章草稿的有界摘录(分块标注,供教条区分)。
|
||||||
|
|
||||||
|
只取 `prior_draft` 开头 `_REWRITE_CLARIFY_EXCERPT_MAX` 字符作锚定摘录——预检不需读全章,
|
||||||
|
避免把 200k 整章喂给 analyst 预检(成本/上下文/脱敏)。无草稿时只喂意见(退化,仍可判含糊)。
|
||||||
|
"""
|
||||||
|
parts = [f"【作者的整章修改意见】\n{feedback}"]
|
||||||
|
if prior_draft:
|
||||||
|
excerpt = prior_draft[:_REWRITE_CLARIFY_EXCERPT_MAX]
|
||||||
|
parts.append(f"【当前章节草稿(摘录,仅供锚定)】\n{excerpt}")
|
||||||
|
return "\n\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{project_id}/chapters/{chapter_no}/rewrite/clarify",
|
||||||
|
responses={
|
||||||
|
404: {"model": ErrorEnvelope, "description": "项目不存在"},
|
||||||
|
503: {"model": ErrorEnvelope, "description": "LLM 不可用"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def clarify_rewrite(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
chapter_no: int,
|
||||||
|
body: RewriteClarifyRequest,
|
||||||
|
request: Request,
|
||||||
|
project_repo: ProjectRepoDep,
|
||||||
|
gateway: ClarifyGatewayDep,
|
||||||
|
session: Annotated[AsyncSession, Depends(get_session)],
|
||||||
|
) -> ClarifyDecision:
|
||||||
|
"""整章重写预检澄清(WFW-9 M2 路线A 两阶段之「问题」阶段):非流式 JSON,只判不改。
|
||||||
|
|
||||||
|
镜像 refine 侧 clarify——只判「作者这条整章重写意见清不清楚」,含糊则反问 1 问 + 给选项,
|
||||||
|
清楚则给确认语放行;**绝不改正文**(整章重写仍走既有 rewrite SSE 端点)。analyst 档
|
||||||
|
(不变量 #2)。只取 `prior_draft` 有界摘录喂预检(不整章入 LLM)。
|
||||||
|
|
||||||
|
项目不存在 → 404(触网关前 fail-fast,同 rewrite/draft);无凭据 → 503(dep 解析阶段拦下)。
|
||||||
|
**只读不写业务表**(不变量 #3);末尾 `commit()` 让网关 usage_ledger 落库(add-only,否则
|
||||||
|
记账静默丢失,同 rewrite 纪律)。判别/校验失败在 `run_clarify` 内确定性回退
|
||||||
|
`need_clarification=false`(不阻塞整章重写主链路)。
|
||||||
|
"""
|
||||||
|
request_id = getattr(request.state, "request_id", None)
|
||||||
|
|
||||||
|
if await project_repo.get(STUB_OWNER_ID, project_id) is None:
|
||||||
|
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
|
||||||
|
|
||||||
|
context = _build_rewrite_clarify_input(body.feedback, body.prior_draft)
|
||||||
|
decision = await run_clarify(
|
||||||
|
clarify_rewrite_spec, # 不变量 #2:analyst 档,不传 model
|
||||||
|
context=context,
|
||||||
|
gateway=gateway,
|
||||||
|
user_id=STUB_OWNER_ID,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 提交边界:只读不写业务表,但网关 ledger add-only → 端点末尾 commit(否则记账丢失)。
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"rewrite_clarify_done",
|
||||||
|
project_id=str(project_id),
|
||||||
|
chapter_no=chapter_no,
|
||||||
|
request_id=request_id,
|
||||||
|
feedback_len=len(body.feedback), # 脱敏:只记长度,不记正文(不变量/日志纪律)
|
||||||
|
need_clarification=decision.need_clarification,
|
||||||
|
question_count=len(decision.questions),
|
||||||
|
)
|
||||||
|
return decision
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{project_id}/chapters/{chapter_no}/draft")
|
@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,
|
||||||
|
|||||||
@@ -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;无凭据 → 503(dep 解析阶段拦下)。**只读不写库**(不变量 #3);
|
||||||
|
末尾 `commit()` 让网关 usage_ledger 落库(add-only,否则记账静默丢失,同 refine 纪律)。
|
||||||
|
判别/校验失败在 `run_clarify` 内确定性回退 `need_clarification=false`(不阻塞润色)。
|
||||||
|
"""
|
||||||
|
request_id = getattr(request.state, "request_id", None)
|
||||||
|
|
||||||
|
project = await project_repo.get(STUB_OWNER_ID, project_id)
|
||||||
|
if project is None:
|
||||||
|
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
|
||||||
|
|
||||||
|
context = _build_refine_input(body.segment, body.instruction or None)
|
||||||
|
decision = await run_clarify(
|
||||||
|
clarify_refine_spec, # 不变量 #2:analyst 档,不传 model
|
||||||
|
context=context,
|
||||||
|
gateway=gateway,
|
||||||
|
user_id=STUB_OWNER_ID,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 提交边界:只读不写业务表,但网关 ledger add-only → 端点末尾 commit(否则记账丢失)。
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"refine_clarify_done",
|
||||||
|
project_id=str(project_id),
|
||||||
|
chapter_no=chapter_no,
|
||||||
|
request_id=request_id,
|
||||||
|
segment_len=len(body.segment),
|
||||||
|
instruction_len=len(body.instruction),
|
||||||
|
need_clarification=decision.need_clarification,
|
||||||
|
question_count=len(decision.questions),
|
||||||
|
)
|
||||||
|
return decision
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from ww_db import get_session
|
|||||||
from ww_shared import AppError, ErrorCode
|
from ww_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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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="预期回收窗口起始章")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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;未知档位 → 422(QA MEDIUM:原接受任意字符串)。
|
# tier 限定已知档位 writer/analyst/light;未知档位 → 422(QA 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):
|
||||||
|
|||||||
@@ -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 后为空 → 422(QA MEDIUM:此前被接受入库)。
|
# 先 strip 再校验长度:纯空白内容(" ")strip 后为空 → 422(QA MEDIUM:此前被接受入库)。
|
||||||
RuleContent = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
|
RuleContent = Annotated[
|
||||||
|
str, StringConstraints(strip_whitespace=True, min_length=1, max_length=_RULE_CONTENT_MAX)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class RuleCreateRequest(BaseModel):
|
class RuleCreateRequest(BaseModel):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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=通用)")
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,11 @@ log = structlog.get_logger(__name__)
|
|||||||
# 链 job 的 kind(jobs.kind 自由 Text 列,零迁移复用;§7)。
|
# 链 job 的 kind(jobs.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-C2):count 已被端点上限 MAX_CHAIN_COUNT
|
||||||
|
# 夹住,故 count*每章步数+余量 是安全且贴合的上界。**必须放 config 顶层**——放到
|
||||||
|
# `configurable` 下 langgraph 会静默忽略。单份 config 同时喂初始 run 与 resume 分支。
|
||||||
|
recursion_limit = count * NODES_PER_CHAPTER + RECURSION_LIMIT_HEADROOM
|
||||||
|
config: RunnableConfig = {
|
||||||
|
"configurable": {"thread_id": str(job_id)},
|
||||||
|
"recursion_limit": recursion_limit,
|
||||||
|
}
|
||||||
graph = build_chain_graph(
|
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",
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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={
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Link
|
<Link
|
||||||
href="/projects/new"
|
href="/write"
|
||||||
className={buttonClass({ variant: "primary" })}
|
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={
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||||
<Link
|
<Link
|
||||||
href="/projects/new"
|
href="/write"
|
||||||
className={buttonClass({ variant: "primary" })}
|
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>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
6
apps/web/app/write/page.tsx
Normal file
6
apps/web/app/write/page.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { DraftComposer } from "@/components/start/DraftComposer";
|
||||||
|
|
||||||
|
// 进来即写入口(无 project 前缀):直达空白编辑器,敲字后懒建项目并换入 /projects/[id]/write。
|
||||||
|
export default function WriteDraftPage() {
|
||||||
|
return <DraftComposer />;
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 跳转/锚点:在可编辑正文里定位、选中并滚动到该冲突对应区域 + 闪烁;定位不到则提示手动查找。
|
// 跳转/锚点:在可编辑正文里定位、选中并滚动到该冲突对应区域 + 闪烁;定位不到则提示手动查找。
|
||||||
|
|||||||
58
apps/web/components/start/DraftComposer.tsx
Normal file
58
apps/web/components/start/DraftComposer.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { TextArea } from "@/components/ui/TextArea";
|
||||||
|
import { useDeferredDraft } from "@/lib/start/useDeferredDraft";
|
||||||
|
|
||||||
|
// 敲入正文后停顿多久才懒建项目(落库)——在停顿点落库,快照即所见,避免打断连续输入。
|
||||||
|
const CREATE_DEBOUNCE_MS = 700;
|
||||||
|
|
||||||
|
// 进来即写:空白编辑器直达。敲入实质正文并停顿后懒建占位项目 + 落首章草稿,随即换入完整工作台。
|
||||||
|
// 空进空出不落库(从不敲字 → 从不 POST)。落库逻辑在 useDeferredDraft(已单测),此处只管交互。
|
||||||
|
export function DraftComposer() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { ensureCreated } = useDeferredDraft();
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const textRef = useRef("");
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const redirectingRef = useRef(false);
|
||||||
|
|
||||||
|
const tryCreate = async (): Promise<void> => {
|
||||||
|
if (redirectingRef.current) return;
|
||||||
|
const snapshot = textRef.current;
|
||||||
|
if (snapshot.trim().length === 0) return;
|
||||||
|
const id = await ensureCreated(snapshot);
|
||||||
|
if (id && !redirectingRef.current) {
|
||||||
|
redirectingRef.current = true;
|
||||||
|
router.replace(`/projects/${id}/write`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onChange = (value: string): void => {
|
||||||
|
setText(value);
|
||||||
|
textRef.current = value;
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = setTimeout(() => void tryCreate(), CREATE_DEBOUNCE_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<div className="mx-auto flex min-h-[calc(100vh-var(--chrome,4rem))] max-w-3xl flex-col px-6 py-10 sm:px-8">
|
||||||
|
<p className="mb-4 text-sm text-ink-soft">
|
||||||
|
直接开始写——落笔即为你建立作品,正文自动保存。书名、设定、大纲都可在写作中随时补齐。
|
||||||
|
</p>
|
||||||
|
<TextArea
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="min-h-[60vh] flex-1 resize-none text-lg leading-8"
|
||||||
|
placeholder="从这里开始写你的第一章……"
|
||||||
|
autoFocus
|
||||||
|
aria-label="正文"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -28,15 +28,17 @@ export function RefineView({
|
|||||||
onClose,
|
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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
194
apps/web/components/workbench/ChapterRewritePanel.tsx
Normal file
194
apps/web/components/workbench/ChapterRewritePanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
apps/web/components/workbench/ChoiceChips.tsx
Normal file
106
apps/web/components/workbench/ChoiceChips.tsx
Normal 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;
|
||||||
|
// 2–4 个具体选项;为空时只渲染自由输入。
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
330
apps/web/components/workbench/ContextDrawer.tsx
Normal file
330
apps/web/components/workbench/ContextDrawer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
apps/web/components/workbench/ContinuePanel.tsx
Normal file
104
apps/web/components/workbench/ContinuePanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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.9(UX §2.3 / §6.3)。
|
// 中栏正文编辑器:宋体、720px 居中、行高 1.9(UX §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 起草,或直接在此书写。"
|
||||||
|
|||||||
62
apps/web/components/workbench/GenrePicker.tsx
Normal file
62
apps/web/components/workbench/GenrePicker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
apps/web/components/workbench/InlineToolbox.tsx
Normal file
89
apps/web/components/workbench/InlineToolbox.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
193
apps/web/components/workbench/RefinePanel.tsx
Normal file
193
apps/web/components/workbench/RefinePanel.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 润色 / 再沟通结果卡(内联,非模态):打开即润色选段;作者可「再沟通」追加意见迭代出新版,
|
||||||
|
// 择版接受回填正文,或放弃。原文只读、生成不写库(不变量 #3),accept 才动草稿。
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,15 +493,37 @@ function DirectivePanel({
|
|||||||
placeholder="本章想怎么写?(可选,覆盖/补充大纲节拍)"
|
placeholder="本章想怎么写?(可选,覆盖/补充大纲节拍)"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div className="flex flex-wrap gap-2" role="group" aria-label="风格预设">
|
{activeCount > 0 ? (
|
||||||
{STYLE_PRESETS.map((preset) => {
|
<StatusNote variant="info">
|
||||||
|
写本章时会把已选文风/剧情预设和你的要求合并进本次生成请求。
|
||||||
|
</StatusNote>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
const active = presetIds.includes(preset.id);
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
key={preset.id}
|
key={preset.id}
|
||||||
type="button"
|
type="button"
|
||||||
aria-pressed={active}
|
aria-pressed={active}
|
||||||
onClick={() => onTogglePreset(preset.id)}
|
onClick={() => onToggle(preset.id)}
|
||||||
variant={active ? "outline" : "secondary"}
|
variant={active ? "outline" : "secondary"}
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
@@ -311,13 +532,7 @@ function DirectivePanel({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{activeCount > 0 ? (
|
|
||||||
<StatusNote variant="info">
|
|
||||||
写本章时会把已选预设和自由指令合并进本次生成请求。
|
|
||||||
</StatusNote>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</details>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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" })}
|
||||||
|
|||||||
65
apps/web/lib/a11y/useRestoreFocus.test.ts
Normal file
65
apps/web/lib/a11y/useRestoreFocus.test.ts
Normal 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("useRestoreFocus(CR-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();
|
||||||
|
});
|
||||||
|
});
|
||||||
24
apps/web/lib/a11y/useRestoreFocus.ts
Normal file
24
apps/web/lib/a11y/useRestoreFocus.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, type RefObject } from "react";
|
||||||
|
|
||||||
|
// 弹层(Drawer / ContextDrawer)关闭后把焦点还给触发元素(a11y 焦点管理,CR-H11)。
|
||||||
|
// wasOpenRef 门闩:仅在「曾打开过」后的关闭才还原,避免从未打开时误抢焦点。
|
||||||
|
// null-safe:triggerRef 或其 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]);
|
||||||
|
}
|
||||||
345
apps/web/lib/api/schema.d.ts
vendored
345
apps/web/lib/api/schema.d.ts
vendored
@@ -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:新版停前端/草稿,接受才落库(不变量 #3);rewrite 是工具非写节点,不破坏章
|
||||||
|
* 纯函数性(不变量 #7)。项目不存在 → 404(触网关前 fail-fast,同 draft,QA 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);无凭据 → 503(dep 解析阶段拦下)。
|
||||||
|
* **只读不写业务表**(不变量 #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;无凭据 → 503(dep 解析阶段拦下)。**只读不写库**(不变量 #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 单条澄清反问:问题 + 2–4 个锚定选项 + 自由输入兜底。
|
||||||
|
*
|
||||||
|
* `options` 常规 2–4 个(锚定本段/本章的具体走法);凑不出具体可区分选项时留空列表,
|
||||||
|
* 退化为纯自由输入。`allow_free_text` 常驻 True——自由输入永远兜底(作者可不选任一项)。
|
||||||
|
*/
|
||||||
|
ClarifyQuestion: {
|
||||||
|
/**
|
||||||
|
* Question
|
||||||
|
* @description 反问的澄清问题(一句话,指向本段的分歧点)
|
||||||
|
*/
|
||||||
|
question: string;
|
||||||
|
/**
|
||||||
|
* Options
|
||||||
|
* @description 2–4 个锚定本段/本章的具体走法选项;凑不出具体选项时为空列表
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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("迈巴赫奔驰");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 收窄为 ApplicablePatch,original/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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
70
apps/web/lib/security/dependencyFloors.test.ts
Normal file
70
apps/web/lib/security/dependencyFloors.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
30
apps/web/lib/security/dependencyFloors.ts
Normal file
30
apps/web/lib/security/dependencyFloors.ts
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
134
apps/web/lib/start/useDeferredDraft.test.ts
Normal file
134
apps/web/lib/start/useDeferredDraft.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
95
apps/web/lib/start/useDeferredDraft.ts
Normal file
95
apps/web/lib/start/useDeferredDraft.ts
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
19
apps/web/lib/style/errorCode-dedup.test.ts
Normal file
19
apps/web/lib/style/errorCode-dedup.test.ts
Normal 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["']/,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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 () => {
|
||||||
|
// Arrange:post 挂起,手动兑现以模拟在途请求。
|
||||||
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
52
apps/web/lib/toolbox/useToolboxTools.test.ts
Normal file
52
apps/web/lib/toolbox/useToolboxTools.test.ts
Normal 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("后端 error:status=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");
|
||||||
|
});
|
||||||
|
});
|
||||||
48
apps/web/lib/toolbox/useToolboxTools.ts
Normal file
48
apps/web/lib/toolbox/useToolboxTools.ts
Normal 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 };
|
||||||
|
}
|
||||||
156
apps/web/lib/workbench/clarify.test.ts
Normal file
156
apps/web/lib/workbench/clarify.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
94
apps/web/lib/workbench/clarify.ts
Normal file
94
apps/web/lib/workbench/clarify.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 一个澄清问题:反问 + 2–4 个具体选项(可为空退化为纯自由输入)+ 常驻自由输入兜底。
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
112
apps/web/lib/workbench/contextDrawer.test.ts
Normal file
112
apps/web/lib/workbench/contextDrawer.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
84
apps/web/lib/workbench/contextDrawer.ts
Normal file
84
apps/web/lib/workbench/contextDrawer.ts
Normal 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),
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -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};再收紧节奏`);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,33 +1,47 @@
|
|||||||
// T4-b · 本章指令组装(纯逻辑,UX §3)。
|
// 本章 AI写作三槽(纯逻辑,UX §3;写作工作台重构 Phase 2)。
|
||||||
// 作者自由文本 + 风格快捷预设 → 单条 directive 串,传入 draft 生成(直通后端 volatile)。
|
// 三槽 = 文风预设 + 剧情需求预设 + 自定义要求(自由文本)→ 单条 directive 串,
|
||||||
|
// 直通后端 volatile(MVP 纯前端、零契约、不动 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();
|
||||||
|
|||||||
61
apps/web/lib/workbench/manuscriptExcerpt.test.ts
Normal file
61
apps/web/lib/workbench/manuscriptExcerpt.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
31
apps/web/lib/workbench/manuscriptExcerpt.ts
Normal file
31
apps/web/lib/workbench/manuscriptExcerpt.ts
Normal 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}`;
|
||||||
|
}
|
||||||
37
apps/web/lib/workbench/refineApply.test.ts
Normal file
37
apps/web/lib/workbench/refineApply.test.ts
Normal 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(
|
||||||
|
"润色后的段落。中间。原文段落。",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
24
apps/web/lib/workbench/refineApply.ts
Normal file
24
apps/web/lib/workbench/refineApply.ts
Normal 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);
|
||||||
|
}
|
||||||
162
apps/web/lib/workbench/useChapterRewrite.test.ts
Normal file
162
apps/web/lib/workbench/useChapterRewrite.test.ts
Normal 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";
|
||||||
|
|
||||||
|
// fetch(SSE 流)是 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_draft(snake_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("网络抛异常(非 Abort):phase=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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
163
apps/web/lib/workbench/useChapterRewrite.ts
Normal file
163
apps/web/lib/workbench/useChapterRewrite.ts
Normal 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 .../rewrite(feedback + prior_draft)→ 消费 SSE → 累积成新一版。
|
||||||
|
// 每轮完成压入版本栈;下一轮以最新版正文为 prior_draft + 新意见迭代(HITL:接受才落草稿)。
|
||||||
|
// 用 fetch+ReadableStream(EventSource 不支持 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_UNAVAILABLE,JSON 信封而非帧)。
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
115
apps/web/lib/workbench/useClarify.test.ts
Normal file
115
apps/web/lib/workbench/useClarify.test.ts
Normal 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→VM,status=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=false:status=proceed,decision 清空", 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:视为放行(proceed,needClarification=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();
|
||||||
|
});
|
||||||
|
});
|
||||||
70
apps/web/lib/workbench/useClarify.ts
Normal file
70
apps/web/lib/workbench/useClarify.ts
Normal 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 };
|
||||||
|
}
|
||||||
165
apps/web/lib/workbench/useContextDrawer.test.ts
Normal file
165
apps/web/lib/workbench/useContextDrawer.test.ts
Normal 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("后端返回 error:error 态 + 错误 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
145
apps/web/lib/workbench/useContextDrawer.ts
Normal file
145
apps/web/lib/workbench/useContextDrawer.ts
Normal 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 };
|
||||||
|
}
|
||||||
106
apps/web/lib/workbench/useContinue.test.ts
Normal file
106
apps/web/lib/workbench/useContinue.test.ts
Normal 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("后端返回 error:status=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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
72
apps/web/lib/workbench/useContinue.ts
Normal file
72
apps/web/lib/workbench/useContinue.ts
Normal 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 };
|
||||||
|
}
|
||||||
65
apps/web/lib/workbench/useGenreGate.test.ts
Normal file
65
apps/web/lib/workbench/useGenreGate.test.ts
Normal 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("项目已有 genre:needsGenre=false,genreDirective 反映项目题材", () => {
|
||||||
|
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("无 genre:needsGenre=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("本章题材:历史");
|
||||||
|
});
|
||||||
|
});
|
||||||
55
apps/web/lib/workbench/useGenreGate.ts
Normal file
55
apps/web/lib/workbench/useGenreGate.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
131
apps/web/lib/workbench/useRefine.test.ts
Normal file
131
apps/web/lib/workbench/useRefine.test.ts
Normal 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("后端返回 error:status=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();
|
||||||
|
});
|
||||||
|
});
|
||||||
112
apps/web/lib/workbench/useRefine.ts
Normal file
112
apps/web/lib/workbench/useRefine.ts
Normal 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],
|
||||||
|
);
|
||||||
|
}
|
||||||
115
apps/web/lib/workbench/useRewriteClarify.test.ts
Normal file
115
apps/web/lib/workbench/useRewriteClarify.test.ts
Normal 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=true:POST rewrite/clarify 带 feedback+prior_draft,映射 VM,status=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=false:status=proceed,decision 清空", 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:视为放行(proceed,needClarification=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
Reference in New Issue
Block a user