Compare commits

...

90 Commits

Author SHA1 Message Date
Yaojia Wang
57b3183564 docs: 写作台放置规则入 decisions + 登记 UX P2 完成(P2-3,P0/P1/P2 整套完成,P2-4 延后)
decisions.md 落四象限唯一落点规则(导航→左栏/AI 动作→中央/状态→底栏/参考→右栏)防三重复回潮;PROGRESS 登记 P2-1..P2-3 落地、门禁绿(vitest 716/build OK),P2-4 统一对话面板按计划延后。
2026-07-10 18:14:56 +02:00
Yaojia Wang
dbece64d4d feat(frontend): 「目录」列可折叠、记忆折叠状态(UX P2-2)
useChapterListCollapse 用 localStorage 记忆折叠态(SSR 首帧恒展开避免 hydration 不一致);收起降为带展开按钮的窄轨、让宽给正文,展开时标题旁加收起按钮;三栏栅格据折叠态切列宽,章节列表/高亮/切章导航全保留。
2026-07-10 18:14:49 +02:00
Yaojia Wang
3c87b01379 feat(frontend): 左栏 10 项按写作/资料库/审阅/更多分组(UX P2-1)
navSections(projectId?) 纯函数把平铺项目项归为四组,NavItems 复用「本作」小标题模式逐组渲染;链接/路由/激活键不变,工具箱收进「更多」不丢入口。
2026-07-10 18:14:41 +02:00
Yaojia Wang
e9c5502c2d feat(frontend): 底栏瘦身 + 或让 AI 动作行 + 动词范围提示 + 题材去弹窗(UX P1-1/P1-4/P1-5) 2026-07-10 18:00:42 +02:00
Yaojia Wang
9f6cbb5598 refactor(frontend): 右栏去术语——本章参考/AI 写这章会参考/写完后检查,删重复去审稿(UX P1-3) 2026-07-10 18:00:36 +02:00
Yaojia Wang
59d7c75737 feat(frontend): 统一「采用」动词族——替换整章/加到章末/替换这段,弃「回填」(UX P1-2) 2026-07-10 18:00:28 +02:00
Yaojia Wang
ceae24740a feat(frontend): 正文上方常驻 AI 输入条 + 对话记录正名 + 润色提示 + HITL 微文案
回答用户原话「我怎么和 AI 对话」(docs/design/ux-exploration.md §4 P0-1/2/4/5)。

P0-2 把折叠的「本章写作指令[可选]」原地提升为正文正上方常驻输入条:标题
「告诉 AI 这章想怎么写」、去「可选」徽标、空章默认展开、「AI 写作诉求」→
「你的写作要求」、邀请式 placeholder;发送键即「写本章」(streaming 时切
「停」),复用 composeDirective + 文风/剧情 chips + 流式打字机,不改成聊天气泡。
P0-1 底栏「AI 对话」→「对话记录」(只读日志正名),抽屉标题/aria/空态同改。
P0-4「润色选段」常驻可点,无选区点击弹 toast 提示(去 hover-only title)。
P0-5 输入条旁常驻 HITL 微文案 +「?这是什么」可再发现弹层(非一次性 coach)。
2026-07-10 17:43:59 +02:00
Yaojia Wang
61a6931bb1 refactor(frontend): 删顶部「AI 工具条」,--chrome 归位 4rem(写作台 UX P0-3)
顶栏「AI 工具条」5 项全是重复左栏的导航链接,且让「写本章」同时是导航
Link 与生成动作两种相反含义。删掉后导航只归左栏,同时消灭最严重的三重复
+ 假「AI」标签 + 「写本章」双重含义,并省下 48px(--chrome 7rem→4rem)。
死代码 AiToolbar/AiToolbarMoreMenu/ai-tools 全仓仅 AppShell 引用,一并移除。
2026-07-10 17:43:46 +02:00
Yaojia Wang
5317d601dd docs: 墨痕写作台 UX 探索报告——多 agent 诊断 + P0/P1/P2 分级改造计划 2026-07-10 17:33:01 +02:00
Yaojia Wang
222cc48f5a docs: 登记 AC-3 后续——润色/重写内联对话气泡流(用户反馈,抽屉保留) 2026-07-10 16:32:15 +02:00
Yaojia Wang
1185069572 feat(frontend): 润色/整章重写面板内联对话气泡流(就地可见)
用户反馈:不想每次开「AI 对话」抽屉才看得到对话,要在对话时就地内联可见。
RefinePanel/ChapterRewritePanel 内联渲染 role=log 滚动气泡流(本章 refine/rewrite
+ 其 clarify 往复 ∪ 本轮进行中气泡:流式 token 逐字进气泡、缓冲的 clarify Q&A
亦实时可见),输入框置底像聊天。pending→persisted 无重影(进行中气泡结束即清、
已落库去重);fail-soft(历史加载失败仍显示本轮实时气泡,不阻塞回炉/重写);
气泡「再接受/回填」复用既有 DRAFT-only HITL 回调(drift-safe,正文仍只在验收才变);
clarify gating/abort/停/接受悉数保留。Workbench 改传 conversation + onReaccept。
AiConversationDrawer 保留原样(底栏仍可开,跨章/项目级全量历史浏览)。
2026-07-10 16:32:10 +02:00
Yaojia Wang
5af45f5034 refactor(frontend): 抽出可复用 ConversationThread + inlineConversation 纯助手
把 AiConversationDrawer 内联的气泡渲染(Bubble/readOptions/ACCEPT_LABEL)提取为
共享的 ConversationThread 组件,抽屉改为复用之(外观不变,DRY)。新增纯助手
lib/workbench/inlineConversation.ts:mergeInlineThreads(已落库留痕 ∪ 进行中气泡,
按 id 去重 + 复用 groupByThread 定序)、toPendingView(合成未落库气泡,meta._pending/
_streaming UI 标记)、isPendingView/isStreamingView/readSegment,配 9 单测(RED→GREEN)。
为内联对话视图铺路;抽屉行为完全不变。
2026-07-10 16:31:53 +02:00
Yaojia Wang
f6e88d7438 test(qa): AI 对话(聊天记录)E2E + #1/#6 真源围栏守卫(AC-4)
计划 §6/§8。收口 AI-chat-history 功能(AC-1..AC-4 全交付)。

- tests/test_ai_chat_history_e2e.py:真 pg、零 LLM/无网关(纯 CRUD 侧记录),
  覆盖 §6 全 6 用例——缓冲多轮线程一批落库 + GET newest-first/meta 保真、
  作用域分区(本章 ∪ 项目级 NULL)、跨批分页 + kind 过滤、
  append 只写 ai_messages(其它业务表零变 + usage_ledger 恒 0)/GET 只读、
  404 + 五类 422 失败零落库、clarify-abandon(服务端无部分写路径)。
- tests/test_ai_messages_not_in_generation_path.py:AST 静态围栏——
  memory/orchestrator 两目录无一 import/引用 ai_messages,
  MemoryRepos 捆绑与 assemble 形参都不含它;植入违规自证探测器非空,能变红。
- memory/gotchas.md:登记 ai_messages 真源围栏(append-only 侧记录,
  绝不喂 assemble/prompt;接入生成链 = code-review BLOCKER)。

门禁全绿:ruff/format · mypy 238 · alembic 无漂移 · pytest 1002 passed(+10)。
2026-07-09 17:42:45 +02:00
Yaojia Wang
5ba3eabda3 docs: 登记 AC-3 ——AI 对话前端(gen:api + hook/抽屉 + 五类交互留痕)+ 台账 2026-07-09 17:28:11 +02:00
Yaojia Wang
33a9b7656c feat(frontend): 五类交互接入 AI 对话留痕 + Workbench 抽屉入口(AC-3)
- RefinePanel/ChapterRewritePanel:结束后 append [author(意见/答复), ai(产出)];
  clarify buffer-until-concluded——澄清 Q&A(原意见→反问→答复)缓冲,随最终产出批
  一起落库同 thread;纯放弃不记。refine ai 行 meta 存 version_no+原选段(每版重锚)。
- ContinuePanel:ai-only(续写无作者输入),每候选一条 ai 气泡 meta{candidate_index}。
- GeneratorRunner:成功后 append [author(字段摘要, meta{tool_key,input_fields}),
  ai(渲染预览, meta{tool_key,output_kind})];空产物不记。lib/toolbox/aiSummary.ts 纯助手+单测。
- InlineToolbox→GeneratorRunner 透传 append+本章号;ToolboxPage 用 silent 直投项目级(chapter_no=null)。
- Workbench:挂 useAiConversation 单实例 + AiConversationDrawer + 底栏「AI 对话」入口(flag 灰度),
  三个 DRAFT-only 再接受回调(替换整章/插入正文/回填选段),成功关抽屉+toast、漂移不盲替换。
2026-07-09 17:27:14 +02:00
Yaojia Wang
59f2f60a7e refactor(frontend): 四生成 hook 结束后额外返回产物(AC-3 留痕前置)
useRefine.refine/recommunicate→RefineVersion|null、useContinue.generate→string|null、
useChapterRewrite.send→RewriteVersion|null、useGenerator.generate→GeneratorArtifact|null。
向后兼容(既有调用忽略返回值不受影响);各 hook 成功用例补返回值断言。
面板据此 if(artifact) 才 append,空候选/错误路径不留痕。
2026-07-09 17:15:49 +02:00
Yaojia Wang
ddf7fa061a feat(frontend): AI 对话核心——视图模型 + 数据/追加 hook + 抽屉(AC-3)
- lib/workbench/aiConversation.ts:纯视图模型(kind 标签/徽标、bubbleSide、
  按 thread 归组 (created_at,seq) 定序、按作用域分区、接受动作映射)+ 单测。
- lib/workbench/useAiConversation.ts:Workbench 级懒加载 + 批量 append(乐观插入 +
  正位对账 / 失败回滚),fail-soft 绝不 throw 进生成/HITL;silent 直投供工具箱页。
  renderHook 测试覆盖懒加载去重、对账、回滚、silent、项目切换复位。
- components/workbench/AiConversationDrawer.tsx:本章对话 + 项目级生成两段的气泡抽屉,
  克隆 ContextDrawer 的 a11y(role=dialog/log、Esc、focus trap、还原焦点);
  ai 气泡挂 DRAFT-only 再接受按钮(替换整章/插入正文/回填选段),不绕开验收事务。
- lib/api/types.ts:re-export AiMessage* 码生成名。
2026-07-09 17:11:09 +02:00
Yaojia Wang
dce882ca91 chore(frontend): pnpm gen:api 纳入 AI 对话端点(AC-3)
POST/GET /projects/{id}/ai-messages + AiMessageAppendRequest/AiMessageInput/
AiMessageView/AiMessageListResponse 由后端 OpenAPI 重生成进 schema.d.ts。
2026-07-09 17:00:14 +02:00
Yaojia Wang
7c4b3c95be docs: 登记 AC-2 契约 C3 扩(AI 对话端点)+ PROGRESS 台账
- memory/contracts.md:C3 扩(AC-2)AI 对话端点登记「稳定」——POST/GET
  /projects/{id}/ai-messages 请求/响应 schema + per-kind meta 形状 +
  冻结 schema 名;标注 @frontend AC-3 须 pnpm gen:api。
- PROGRESS.md:AC-2 标 (commit c675a74)+ 一行台账 + 变更日志。
2026-07-09 16:55:39 +02:00
Yaojia Wang
c675a74f1d feat(backend): AI 对话聊天记录 repo + append/list 端点(AC-2)
在 C2 扩(AC-1) `ai_messages` 表上落 AC-2 后端(计划 §3):
- repo `SqlAiMessageRepo`:批 append(seq=批内 0 基位置、同 thread_id、只 flush)
  + `list_for_chapter` newest-first(created_at DESC, seq DESC)、chapter_no 过滤含
  项目级 NULL union、可选 kind、limit/offset。
- schemas `ai_messages.py`:批级 append 信封 + Literal 枚举权威(kind/role)+ content
  200k 上限 + meta 序列化兜底。
- 端点 `POST/GET /projects/{id}/ai-messages`(tag ai-messages,已注册):显式记录写入,
  五个生成端点保持只读不变(守 #3);项目 404、校验 422、POST 端点提交。
- 注入缝 `get_ai_message_repo`。

TDD:repo 真 pg 单测(seq/定序/union/分页/flush-only)9 passed;端点集成测试
(round-trip/union/kind/404/422×5/commit 边界)12 passed。门禁全绿:ruff/format/mypy
238 files/alembic check 无漂移/pytest 992 passed;新模块 cov router+schemas 100% / repo 97%。
2026-07-09 16:54:52 +02:00
Yaojia Wang
1a188e6e5b feat(db): ai_messages 表 + 迁移——作者↔AI 往复聊天记录 append-only 侧表(AC-1)
计划 §2 canonical DDL:AiMessage(UuidPk, CreatedAt, Base),project_id(FK CASCADE)/
chapter_no(nullable=项目级)/thread_id/seq(repo 批内定序)/kind/tool_key/role(CHECK)/
content/meta(JSONB '{}'),append-only 无 updated_at。复合索引 (project_id,chapter_no,
created_at DESC) 手写 sa.text DESC + FK 单列索引。迁移 ceabd3c2c00d(down=f6a7b8c9d0e1),
autogenerate 仅捕获 ai_messages 无杂散漂移。T0.2 后第一张新业务表;旁路侧记录非手稿真源,
assemble()/agent 永不读(守 #1/#3/#6)。契约 C2 扩已「稳定」。
2026-07-09 16:39:24 +02:00
Yaojia Wang
9da938d39b docs: AI 对话(聊天记录)实施计划入库 + 台账登记(决策全 A,开工) 2026-07-09 16:33:14 +02:00
Yaojia Wang
31067abbed 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)
2026-07-08 18:53:57 +02:00
Yaojia Wang
c34dbf5d2d chore(devops): 本地/compose 宿主端口 +1000 防冲突(web 4000 / api 9000 / pg 6432)+ 文档登记 2026-07-08 17:33:43 +02:00
Yaojia Wang
2145d33cc7 docs: 修正台账——CR-M4/M5/L1 状态标 (代码早已提交,前端 agent 漏翻表格状态)+ MEDIUM/LOW 批收口 2026-07-08 14:37:35 +02:00
Yaojia Wang
a039a1e5d9 chore(devops): 抬高后端依赖下界至 lockfile 主次版本,保可复现(CR-D1) 2026-07-08 13:34:36 +02:00
Yaojia Wang
05f49d0b7f docs: 登记 CR-M4/M5/L1 前端评审收尾(done/skipped) 2026-07-08 13:27:46 +02:00
Yaojia Wang
64145422eb refactor(web): 去冗余 cast(idx.valueOf / dict[error] as string)
stream/sse.ts SseFrameBuffer 去 idx.valueOf()(idx 本就是 number);jobs/job.ts 去 dict["error"] as string(typeof 守卫后 TS 自动收窄)。typecheck 绿。
2026-07-08 13:27:45 +02:00
Yaojia Wang
16f2eae2d3 test(web): parseJsonBody 补非对象/非 JSON 守卫分支测试
server.parseJsonBody 已具边界校验,补两条守卫分支测试(200 返回原始值/非 JSON 体均抛结构化错误)。
2026-07-08 13:27:45 +02:00
Yaojia Wang
9cf4915d49 refactor(web): hasApplicableFix 改类型谓词,消除 applyConflictFix 强转
hasApplicableFix 重写为对象入参类型谓词 patch is ApplicablePatch,谓词命中后 applyConflictFix 里 original/replacement 自动收窄为 string,删两处 as string;补收窄单测。
2026-07-08 13:27:45 +02:00
Yaojia Wang
d80d41a08d refactor(web): ApiErrorEnvelope 改由生成 schema 派生,防契约漂移
手写 ApiErrorEnvelope 改由 components["schemas"]["ErrorEnvelope"] 派生;accept 端点专属 details.missing_conflict_indices 就地收窄。运行时行为不变(既有 useAccept 测试全绿)。
2026-07-08 13:27:32 +02:00
Yaojia Wang
125908ed83 fix(web): ReviewReport flashMark 定时器卸载清理,防泄漏
flashMark 的 setTimeout 句柄入 ref + 卸载 useEffect 清理,重复触发先清旧定时器;提 FLASH_DURATION_MS 常量。闪烁行为不变。
2026-07-08 13:27:32 +02:00
Yaojia Wang
47d5558e4a feat(web): NavDrawer 补 aria-controls + ForeshadowCard 语义化 dl/dt/dd(a11y)
NavDrawer 汉堡按钮补 aria-controls 指向抽屉面板(Drawer 新增可选 id prop,默认 nav-drawer);ForeshadowCard 的 <dl> 裸 <div> 行改真 <dt>/<dd> 结构,可视文案不变,importance 用 sr-only 标签。行为不变。
2026-07-08 13:27:32 +02:00
Yaojia Wang
1bda024141 chore: 撤销 c596a8d 误提交的非源文件(.coverage/AGENTS.md/example_skills),忽略 .coverage 2026-07-08 13:13:38 +02:00
Yaojia Wang
f160a7dac1 docs: 登记 QA-CR MEDIUM/LOW 网关+链整改批(CR-M2/M3/L1@llm/M1.4)——6 done 2 skip 2026-07-08 13:11:18 +02:00
Yaojia Wang
6a739cad5e perf(chain): review 节点并行扇出五审——复用主图 add-only 记账并发范式(CR-M3-6) 2026-07-08 13:08:19 +02:00
Yaojia Wang
ec91c8fdf7 refactor(chain): 链 runner 经公开 aget_state.interrupts 判暂停——弃私有 __interrupt__ 键(CR-M3) 2026-07-08 13:04:28 +02:00
Yaojia Wang
c9ffada503 feat(orchestrator): 续写链前一章无已验收终稿发 warning——静默丢前文引子可观测(CR-L1) 2026-07-08 13:02:30 +02:00
Yaojia Wang
832ab7dfa1 refactor(gateway): 适配器暴露 probe_connection 公开探测——provider_deps 弃私有 _client 反手(CR-M1.4) 2026-07-08 13:00:50 +02:00
Yaojia Wang
9439b97804 fix(gateway): 适配器瞬时错误消息脱敏——只留类名+状态码不泄漏厂商响应体(CR-M2-2) 2026-07-08 12:58:25 +02:00
Yaojia Wang
32451a9bd0 feat(gateway): 未知模型缺价发结构化告警——成本账缺项可观测(CR-M2-1) 2026-07-08 12:56:00 +02:00
Yaojia Wang
9ee7c7b000 docs: 登记 CR-M1 + CR-L1 后端片落地——台账 + @llm item#4 请求
CR-M1 4/6 完成(volume 下推 / N+1→create_many / jobs→JobRepo / 列表分页),
item#4(_client) 与 item#6(assemble 并行) SKIP(理由入台账);CR-L1 后端片
(SSE OpenAPI)完成,print 属误报。附给 @llm 的 probe_connection 请求。
2026-07-08 12:48:51 +02:00
Yaojia Wang
49e3cb6dc4 docs(backend): SSE 端点在 OpenAPI 如实声明 text/event-stream
draft/rewrite/review 三个流式端点返回 StreamingResponse,OpenAPI 原按默认
application/json 标注 200,schema 不诚实。新增共享 _SSE_RESPONSE 声明 200 的
content 为 text/event-stream 并挂到三个装饰器。export_openapi 通过,schema 现
出现 3 处 text/event-stream。未跑前端 gen:api(按要求)。
2026-07-08 12:46:15 +02:00
Yaojia Wang
bf3dabea82 perf(backend): 角色/世界观入库改批量——create_many 一次 flush 落全部行
角色入库端点与 toolbox world_entities 入库端点原逐卡/逐条 create(每行一次
SELECT/flush/refresh,N+1 round-trip)。新增 create_many:世界观用 add_all,
角色用一次 IN 查已有名 + 一次 flush(保持 (project_id,name) upsert 幂等与批内
重名去重、保序返回)。单条 create 薄封装 create_many,行为不变(既有调用方/E2E
复用)。复活并复用 CharacterWriteFields、新增 WorldEntityFields 作批量入参。
补两端点「多行一次落库」测试;既有幂等/冲突 gate 测试与 m5 真 pg E2E 全绿。
2026-07-08 12:44:07 +02:00
Yaojia Wang
0892ff6cda feat(backend): 无界列表端点加分页——projects/templates 支持 limit/offset
GET /projects 与 GET /templates 原一次返回全部 owner 行,行数随数据线性增长。
新增可复用分页依赖 ww_api.pagination.get_page(limit∈[1,200] 默认 50、offset≥0,
越界 → 422),两端点接入,Repository.list_for_owner 加 keyword-only limit/offset
把 LIMIT/OFFSET 下推 DB。向后兼容:不传参返回第一页。补 projects 分页 + 边界 422 测试。
2026-07-08 12:39:31 +02:00
Yaojia Wang
1392830e57 refactor(backend): jobs 读端点走 JobRepo 而非路由内裸 ORM
GET /jobs/{id} 原在路由里直接 select(Job) 裸 ORM 查询;改为经 get_job_repo
注入 JobRepo 调 .get(job_id),与项目其它端点的 Repository 分层一致,
测试可经 dependency_overrides 注 fake。行为不变(同一 session 同一查询)。
2026-07-08 12:35:09 +02:00
Yaojia Wang
c596a8d342 perf(backend): 大纲卷过滤下推 DB WHERE——list_for_project 加 volume 参数
GET /outline?volume=N 原在端点 Python 侧过滤全部章节;现把 volume 条件下推到
OutlineRepo.list_for_project 的 DB WHERE,只查该卷行(少读多余卷 + 语义正确)。
Protocol/SqlOutlineRepo/各 OutlineRepo fake 同步加 keyword-only volume;补 repo 级
下推断言测试。向后兼容:不传 volume 仍返回全部(默认 None)。
2026-07-08 12:34:06 +02:00
Yaojia Wang
c22f6b41d1 docs: WFW-9 M2/M3 收口——rewrite 侧澄清 + a11y + E2E 全交付,台账标完成 2026-07-08 12:23:05 +02:00
Yaojia Wang
a703a90c0c test(qa): WFW-9 两阶段 AI 反问澄清 E2E——refine+rewrite 真 pg+mock 网关零 token 2026-07-08 12:16:06 +02:00
Yaojia Wang
ea9a9d9953 fix(frontend): 澄清反问块加 role=status 向读屏播报(WFW-9 M3,WCAG 4.1.3) 2026-07-08 12:05:54 +02:00
Yaojia Wang
8e29b33a11 feat(frontend): 整章重写接入 AI 反问澄清——useRewriteClarify + ChapterRewritePanel 门控(WFW-9 M2) 2026-07-08 12:00:17 +02:00
Yaojia Wang
4b1088a179 refactor(frontend): clarify toVM/PROCEED 提取到 clarify.ts 共享(WFW-9 M2) 2026-07-08 11:54:05 +02:00
Yaojia Wang
9b32743606 chore(frontend): gen:api 纳入 rewrite/clarify 端点(WFW-9 M2) 2026-07-08 11:52:11 +02:00
Yaojia Wang
6b7391d1b7 docs: 登记 WFW-9 M2 整章重写预检澄清——契约 C-Clarify 扩 + PROGRESS 台账 2026-07-08 11:47:26 +02:00
Yaojia Wang
6fd637596b feat(backend): 整章重写 AI 反问澄清预检端点 rewrite/clarify(WFW-9 M2)
新增 POST /projects/{id}/chapters/{no}/rewrite/clarify(非流式 JSON)→ ClarifyDecision,
镜像 refine 侧 clarify:404 缺项目 → _build_rewrite_clarify_input(章级意见 + prior_draft
有界摘录 3000 字,不整章入 LLM)→ run_clarify(clarify_rewrite_spec, analyst 档) → 末尾
commit 落 usage_ledger → 返回决策。只读不写业务表(不变量 #3);日志只记 feedback_len(脱敏);
畸形 parsed 经 run_clarify 韧性回退 need_clarification=false(非 500)。新增 RewriteClarifyRequest
(feedback 必给、prior_draft 可选)。6 端点用例覆盖含糊/明确/404/fail-open/422/摘录有界不泄漏。
2026-07-08 11:46:24 +02:00
Yaojia Wang
4a09f273d9 feat(llm): 整章重写预检澄清 spec + 教条 clarify_rewrite(WFW-9 M2)
镜像 clarify_refine(仅 name/prompt 不同,复用 ClarifyDecision,不新增 schema):
新增 clarify_rewrite_spec(analyst 档、reads=()/writes=() 只读,不变量 #2/#3)+ 章级
教条 prompts/clarify_rewrite.md(segment→整章:判作者章级重写意见是否含糊)。注册进
SPECS/SCHEMA_CATALOG,自检数量 24→25,__init__ 导出,金标准仅新增 1 条哈希无漂移。
2026-07-08 11:42:21 +02:00
Yaojia Wang
331a07abc4 docs: QA-CR CRITICAL+HIGH 全部 收口——台账标完成 + 变更日志登记 2026-07-08 11:32:47 +02:00
Yaojia Wang
dd6d2fce2b docs(test): 更正 K1 E2E docstring 依赖名为 _http_client(CR-H2 收尾) 2026-07-08 11:29:27 +02:00
Yaojia Wang
e08b01ac58 test(llm): 锁定 llm_provider_failed 失败日志 request_id 条件透传(CR-H4 收尾) 2026-07-08 11:29:27 +02:00
Yaojia Wang
d6b53d08b2 fix(backend): 补 directive/instruction 文本上界——LLM 入参防超长(CR-H9 收尾) 2026-07-08 11:29:27 +02:00
Yaojia Wang
6e2b478ca9 fix(frontend): Drawer 焦点还原提取 useRestoreFocus + 回归测试 + DRY(CR-H11) 2026-07-08 11:10:23 +02:00
Yaojia Wang
5c02792e5f fix(frontend): RefineView 取消在途 refine——AbortController 防段切换竞态(CR-H12) 2026-07-08 11:08:21 +02:00
Yaojia Wang
5665cc9a16 fix(frontend): errorCode 统一自 generation/cards 去重复定义(CR-H10) 2026-07-08 11:06:30 +02:00
Yaojia Wang
f4c2c4e8ec fix(frontend): 升级 Next→15.5.20 + React 19.2.7 修 RCE/补丁 + 版本下界守卫(CR-C1)
- next/eslint-config-next 15.1.3→15.5.20(backport 安全维护线),react/react-dom 19.0.0→19.2.7
- @types/react(-dom) →^19.2.0;pnpm audit --prod 无 next/react/react-dom 高危/严重告警
- 新增 lib/security/dependencyFloors 守卫 + 回归测试锁定安全下界
- 连带修 useDraftStream.test noUncheckedIndexedAccess(重锁失效增量缓存后暴露,对齐 useReviewStream 既有 ! 约定)
2026-07-08 11:04:55 +02:00
Yaojia Wang
baa41d370b fix(devops): .env.example 移除可用 Fernet key 换不可用占位符 + 轮换提示(CR-H8) 2026-07-08 10:56:01 +02:00
Yaojia Wang
e1a24a2c2c fix(backend): API 文本输入加 max_length + 请求体大小中间件(CR-H9) 2026-07-08 10:53:44 +02:00
Yaojia Wang
26f10d023d fix(backend): Kimi OAuth E2E 请求依赖 override 跟随改为 _http_client(CR-H2 lockstep) 2026-07-08 10:51:12 +02:00
Yaojia Wang
25d48b7aa7 fix(backend): 冻结 TOOLBOX 注册表为只读 MappingProxyType(CR-H7) 2026-07-08 10:41:15 +02:00
Yaojia Wang
5dd336b011 fix(backend): Kimi OAuth 请求级 httpx 客户端改生成器依赖修泄漏(CR-H2) 2026-07-08 10:40:14 +02:00
Yaojia Wang
ca8fbc89a1 fix(backend): Kimi OAuth 轮询移出 DB 会话——只在最终持久化开短会话(CR-H1) 2026-07-08 10:37:42 +02:00
Yaojia Wang
ed0a679322 fix(backend): 多章链设显式 recursion_limit 防御硬化(CR-C2) 2026-07-08 10:32:37 +02:00
Yaojia Wang
8a2b1b09dc fix(llm): 流式路径加首 token 前 per-provider 重试(CR-H6) 2026-07-08 10:26:29 +02:00
Yaojia Wang
51fe9f44fe fix(llm): request_id 条件透传贯通网关日志(CR-H4,守 §9.3) 2026-07-08 10:24:55 +02:00
Yaojia Wang
afeac51422 fix(llm): 删除未实现的 LlmRequest.thinking 字段(CR-H5,YAGNI) 2026-07-08 10:22:34 +02:00
Yaojia Wang
cb8fddfc3b fix(llm): Anthropic 结构化输出记真实 usage——修计费记 0 成本(CR-H3) 2026-07-08 10:21:18 +02:00
Yaojia Wang
c69e613c97 docs: 登记 WFW-9 M1 AI 反问澄清——契约 C-Clarify + PROGRESS 台账
memory/contracts.md 立 C-Clarify(POST .../refine/clarify + ClarifyDecision,非流式结构化,
analyst,只读,既有 refine 未改,无迁移);PROGRESS 加 WFW-9 M1 行(),标注 M2/M3 待办。
2026-07-08 08:49:58 +02:00
Yaojia Wang
e30c933c76 feat(frontend): 润色再沟通接入 AI 反问澄清——选项芯片 + 折答案回炉(WFW-9 M1)
RefinePanel「再沟通」升级为对话式:意见含糊/极短(门控 <10 字,或点「帮我理清方向」)时先调
clarify 预检端点让 AI 反问,渲染 ChoiceChips 选项芯片(+自由输入兜底);作者选/答后
foldClarifications 把答案折进 instruction,再走既有 refine 回炉(零迁移)。清晰意见直接回炉、
不交往返税;预检失败=放行(useClarify 视 error/异常为 needClarification=false,不阻塞润色)。
新增:lib/workbench/clarify.ts(VM 类型+foldClarifications+needsClarifyGate 纯逻辑) +
useClarify(SSE 外调映射 snake→VM) + ChoiceChips.tsx;gen:api 重生成客户端。
门禁绿:tsc/lint/vitest 654(+clarify/useClarify 18 例)/build/coverage 95.36%。
2026-07-08 08:47:28 +02:00
Yaojia Wang
1652ad9d20 feat(backend): AI 反问澄清预检端点——refine 侧结构化 clarify(WFW-9 M1,路线A 两阶段)
润色「再沟通」意见含糊时先反问给选项(路线A:问题走独立非流式 JSON 预检端点,正文仍走
既有 refine 一字不改)。新增:
- ClarifyDecision/ClarifyQuestion/ClarifyOption 结构化 schema(既有 output-schema 处,供
  producer 与端点共用);clarify_refine.md 教条(含糊→need_clarification+≤1问+2–4锚定选项+
  自由输入;明确→verification 放行;防循环);注册 clarify_refine_spec(analyst 档,#24)+
  SCHEMA_CATALOG + 重生成金标准。
- clarify_node:build_clarify_request 纯函数(缓存前缀不含易变) + run_clarify(gateway.run
  结构化,判别/校验失败确定性回退 need_clarification=false,只读不写库)。
- POST /projects/{id}/chapters/{no}/refine/clarify → ClarifyDecision(analyst 网关,404/503,
  末尾 commit 记账)。**既有 refine 端点/RefineRequest/Response 完全未改**。
门禁绿:ruff/mypy 227/pytest 900(+test_clarify_spec/_node/style clarify)/alembic 无漂移。
2026-07-08 08:47:13 +02:00
Yaojia Wang
fb5caa3d89 docs: 登记 WFW-8 整章再沟通/重写——契约 C-Rewrite + PROGRESS 台账
memory/contracts.md 立 C-Rewrite(POST .../rewrite + RewriteStreamRequest,SSE,只读,
守 #3/#7/#9,无迁移);PROGRESS 加 WFW-8 行() + 门禁基线更新(前端 vitest 636/cov 95.36%,
后端 mypy 224/pytest 含 test_rewrite_node)。
2026-07-08 07:17:11 +02:00
Yaojia Wang
542c2abac9 feat(frontend): 整章「再沟通/重写」面板——给意见流式重写整章,版本栈迭代,接受替换正文
WFW-8 整章级再沟通(前端)。底栏新增「整章重写」→ ChapterRewritePanel:
- 作者给意见 → useChapterRewrite 消费 POST .../rewrite 的 SSE 流(复用 lib/stream/sse
  帧解析),流式 live 展示新一版;
- 每轮完成压入版本栈,「再改一版」以最新版为 prior_draft + 新意见迭代(多轮对话式);
- 接受某版才「替换整章正文」落草稿(HITL,AI 绝不静默覆盖);停/错误/网络异常均归一处理。
四张 AI 结果卡(润色/续写/工具箱/整章重写)互斥。gen:api 重生成 TS 客户端。
门禁绿:tsc/lint/vitest 636(+7 useChapterRewrite)/build/coverage 95.36%。
2026-07-08 07:12:04 +02:00
Yaojia Wang
6ed9c89430 feat(backend): 整章再沟通/重写 SSE 端点——喂当前草稿+意见+完整记忆注入流式重写一版
WFW-8 整章级再沟通(后端)。新增 POST /projects/{id}/chapters/{no}/rewrite(SSE):
- rewrite.md 教条(品类无关内核,非 SPEC,仿 write_craft 经 load_prompt 读盘);
- rewrite_node:build_rewrite_request 纯函数(system=[rewrite教条,stable_core] cache 前缀;
  input=近况+当前草稿+作者意见,断点后不入缓存前缀——守不变量 #9)+ stream_chapter_rewrite;
- RewriteStreamRequest{feedback,prior_draft}(带 max_length 上界);端点复用 assemble 记忆注入
  + normalize_deltas SSE,只读不写库(HITL,新版停前端接受才落,不变量 #3),是工具非写节点
  (不破坏 章=f(outline,state) 纯函数,不变量 #7),项目不存在触网关前 404。

门禁绿:ruff / mypy 224 files / pytest(新增 test_rewrite_node 5 例 + 更新 doctrine 一一对应)/
alembic 无漂移。OpenAPI 含新路径 + schema(前端待 gen:api)。
2026-07-08 07:04:42 +02:00
Yaojia Wang
4977c760d8 docs: PROGRESS 标记 WFW-5/6/7 完成——写作工作台重构全 8 项交付
WFW-5 内容感知书名 / WFW-6 上下文速查抽屉 / WFW-7 genre采集 均 (commit 3dd7d28);
门禁基线更新为 vitest 629 / coverage 95.43%。
2026-07-07 20:45:24 +02:00
Yaojia Wang
3dd7d2861f feat(frontend): 写作工作台补齐三项——内容感知书名 / 上下文速查抽屉 / genre采集
写作工作台重构剩余 3 项(多 agent 并行构建各自新文件,主线统一集成):

- WFW-5 内容感知书名(#6):buildManuscriptExcerpt 取正文首尾摘录(硬上界控 token);
  GeneratorRunner 加 manuscriptText,有 brief 字段时出现「用当前正文」按钮把摘录填入
  brief,让 book-title 等据正文反推。纯前端零后端;HITL——只填表单,生成仍需作者触发。
- WFW-6 上下文速查抽屉(#7):ContextDrawer 视口无关右侧 slide-over,设定库/大纲完整
  只读速查 + 伏笔/规则/文风摘要跳链;底栏「速查」按钮触发。加法式、CONTEXT_DRAWER_ENABLED
  一键回滚、旧侧栏/全宽页路由全保留。
- WFW-7 genre 采集 + 无大纲降级:项目无题材时首次写章前弹 GenrePicker 采集(临时并入
  本次 directive 不落库);chapters 为空时编辑器显式提示「尚无大纲,将按设定自由生成」。

各项 TDD:manuscriptExcerpt/useGenreGate/contextDrawer/useContextDrawer 共 32 新单测。
门禁全绿:vitest 629 / tsc / lint / build / coverage 95.43%。
2026-07-07 20:44:09 +02:00
Yaojia Wang
351fbc99e8 docs: PROGRESS 登记「写作工作台重构(writing-first)」——WFW-0~4 已交付、WFW-5~7 待办
新增进行中章节:8 点反馈背景 + 已拍板决策(延迟落库/纯前端预设/内置优先/多候选) +
任务台账(WFW-0 进来即写、WFW-1 润色再沟通、WFW-2 续写、WFW-3 内联工具箱+金手指、
WFW-4 三槽 ;WFW-5 内容感知书名、WFW-6 侧栏IA收敛、WFW-7 genre采集 )。
2026-07-07 20:16:18 +02:00
Yaojia Wang
44b0b8a7f0 feat(frontend): AI写作三槽——文风 / 剧情需求 / 自定义要求,写章前按需组合
Phase 2(写作工作台重构):把写章指令升级为三槽——文风预设 + 新增剧情需求预设
(打脸反转/章末钩子/高潮/转折/扮猪吃虎,源自 plan §4.2 分类法,tianyayu6/oh-story MIT)
+ 自定义自由文本。三者经 composeDirective 合并为单条 directive→volatile(MVP 纯前端、
零契约、不动 write_craft.md 字节,金标准零回归)。DirectivePanel 拆成文风/剧情两组 chips。
2026-07-07 19:51:19 +02:00
Yaojia Wang
04f4785292 feat(frontend): 编辑器内联工具箱——就地调生成器(含金手指),结果一键插入正文
Phase 1(写作工作台重构):编辑器「工具箱」按钮打开内联面板,不离开写作界面即可调用
全部生成器(含金手指——后端 golden-finger 早已齐全,此处首次露出)。复用 GeneratorRunner,
为其加可选 onInsertText:文本类产物(开篇/简介/续写/扩写等)点「插入正文」直接追加到章末,
可入库类仍走既有 ingest。useToolboxTools 客户端拉描述符(已单测)。润色/续写/工具箱三卡互斥。
2026-07-07 19:48:13 +02:00
Yaojia Wang
9195cf36f3 feat(frontend): 编辑器内「续写」——读本章前文生成多候选,点选插入正文
Phase 1(写作工作台重构):编辑器「续写」按钮复用 continue 生成器(with_prior_chapter
自动读该章已写正文承接),每次产出一条候选并累加成多候选(useContinue,已单测),
「再来一个」可多生成几版;作者点某条「插入正文」才追加到章末(HITL,不静默写入)。
续写前 flush 自动保存,确保读到最新草稿。润色/续写结果卡互斥不并占。
2026-07-07 19:41:57 +02:00
Yaojia Wang
2f0b84191a feat(frontend): 编辑器内「润色 / 再沟通」——选段回炉 + 追加意见迭代出版本栈,接受回填
Phase 1(写作工作台重构):编辑器选中一段即可「润色选段」,复用同步 /refine 回炉
(只读、不写库,守不变量 #3);不满意可「再沟通」——以上一版产出为输入 + 作者新意见
再改一版,形成版本栈(useRefine,已单测)。接受回填按内容重锚落回草稿,原文已变则
提示重选、绝不盲替换(applyRefinement,已单测)。Editor 上报选区,结果以内联卡片展示。
2026-07-07 18:26:47 +02:00
Yaojia Wang
8389572cbb feat(frontend): 进来即写——落地页「直接开始写」直达空白编辑器,敲字后延迟落库
Phase 0(写作工作台重构):新增 /write 草稿编辑器入口,只有敲入实质正文并停顿后
才懒建占位「未命名草稿」项目并种入首章草稿(useDeferredDraft,双重幂等闸 + 草稿落库
非致命降级),随即 replace 换入完整工作台;空进空出不落库,杜绝孤儿草稿。
落地页 CTA 改为「直接开始写」为主、「先立项」(立项向导)为辅。
2026-07-07 18:18:18 +02:00
Yaojia Wang
e703d0a65c fix(devops): api 容器 CMD 加 --no-sync,用构建期烤好的 venv 直接起,杜绝启动时现场下载依赖 2026-07-07 14:28:09 +02:00
200 changed files with 13541 additions and 1132 deletions

View File

@@ -6,6 +6,7 @@ DATABASE_URL_SYNC=postgresql+psycopg://writer:writer@localhost:5432/writer
APP_ENV=dev
LOG_JSON=false
# 凭据加密密钥Fernet, urlsafe-base64 32B— **必填**API 启动即校验,缺失/非法则拒绝启动。
# 下方为开发占位 key可直接用于本地生产请换新值
# ⚠️ 下方为**不可用占位符**,必须替换:`cp .env.example .env` 后用下方命令生成真实 key 填入。
# 切勿把真实 key 提交进版本库;任何曾经泄露过的 key 必须立即轮换(重新加密已存凭据)。
# uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
CREDENTIAL_ENC_KEY=cnMpG6QQxJejuDLHTe_S-nq2snoKgXCqWFfsctEHB-4=
CREDENTIAL_ENC_KEY=replace-me-with-generated-fernet-key

2
.gitignore vendored
View File

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

View File

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

View File

@@ -127,7 +127,7 @@ Aligns `ARCHITECTURE.md §9.3`. This is how every failure gets diagnosed.
- **环境变量**`uv` 装到 `~/.local/bin`命令前 `export PATH="$HOME/.local/bin:$PATH"`
- **依赖安装**`uv sync`后端仓库根`pnpm install`前端`cd apps/web`)。
- **本地起服务**`docker compose up`pg + api + web)。仅起库`docker compose up -d pg`裸跑 API`uv run uvicorn ww_api.main:app --reload`
- **本地起服务宿主端口 +1000 防冲突web 4000 / api 9000 / pg 6432容器内仍 3000/8000/5432**`docker compose up`pg + api + web端口已 +1000)。仅起库`docker compose up -d pg`宿主 6432裸跑API `uv run uvicorn ww_api.main:app --port 9000 --reload`读根 `.env`pg 6432 / CORS 放行 4000前端 `cd apps/web && pnpm dev``next dev -p 4000` `apps/web/.env.local``NEXT_PUBLIC_API_BASE=http://localhost:9000`)。`.env`/`.env.local` 为本机文件gitignored
- **迁移**`uv run alembic upgrade head`改模型后 `uv run alembic revision --autogenerate -m "..."`漂移校验 `uv run alembic check` pg 在跑)。
- **后端门禁**`uv run ruff check .` · `uv run ruff format .` · `uv run mypy packages apps` · `uv run pytest -q`覆盖率门禁`uv run pytest -q --cov --cov-report=term-missing --cov-fail-under=80`单测`uv run pytest path::test -q`单测不带 `--cov`避免 fail-under 误报)。
- **前端门禁**`cd apps/web``pnpm lint` · `pnpm typecheck` · `pnpm test`vitest)· `pnpm build`覆盖率门禁`pnpm test:coverage`阈值 80%范围 `lib/**`)。

View File

@@ -8,7 +8,21 @@
---
## 当前阶段QA-CR · 全项目 Code Review 整改批
## ✅ 已交付AI 对话聊天记录持久化AC-1..AC-4 全绿)· develop 上新功能
> **背景**[2026-07-09] 用户要求把作者↔AI 每轮往复refine/rewrite/clarify/continue/工具箱生成器)**落库为聊天记录**每章一个「AI 对话」抽屉展示。多 agent4 学科起草→4 视角交叉评审→合稿)产出实施计划 `docs/design/ai-chat-history-plan.md`。**决策全 A**clarify buffer-until-concluded / v1 只记 5 类交互 / append-only 不删。
> **契约与不变量纪律**@db 建表(契约 C2 扩)→@backend 端点(C3 扩)→@frontend gen:api+接线→@qa E2E逐级等上游契约「稳定」。守 #1/#3/#8聊天记录是**旁路留痕**(同 `usage_ledger` 物种),前端**显式 append**、生成端点**保持只读不变**、**绝不**喂 `assemble`/prompt计划 §8 有围栏 + code-review 守卫)。
| 任务 | 状态 | 负责 | 依赖 | 备注 |
|---|---|---|---|---|
| AC-1 `ai_messages` 表 + 迁移 | ✅ @db 2026-07-09 | @db | — | 计划 §2 canonical DDL迁移 `ceabd3c2c00d`(down=`f6a7b8c9d0e1`)C2 扩已「稳定」AC-2 可动工 |
| AC-2 repo + append/list 端点 + schema + 契约 | ✅ @backend 2026-07-09 | @backend | AC-1 | `c675a74``SqlAiMessageRepo`(批 append seq/thread_id + newest-first union/分页) + `POST/GET /projects/{id}/ai-messages` + schemasC3 扩已「稳定」→ AC-3 可动工(须 `pnpm gen:api` |
| AC-3 gen:api + AiConversationDrawer + useAiConversation + 接线 5 处交互 | ✅ @frontend 2026-07-09 | @frontend | AC-2 | `dce882c`/`ddf7fa0`/`59f2f60`/`33a9b76`gen:api + 纯视图模型 + hook + 抽屉 + 五类交互留痕clarify buffer-until-concludedvitest 706 passed / cov 95% / build OK |
| AC-4 E2E + #1/#6 围栏守卫 | ✅ @qa 2026-07-09 | @qa | AC-3 | 计划 §6/§8`tests/test_ai_chat_history_e2e.py`6 用例,真 pg 零 LLM+ `tests/test_ai_messages_not_in_generation_path.py`AST 围栏能变红pytest **1002 passed**+10。**AI 对话聊天记录AC-1..AC-4 全交付** |
---
## 已完成阶段QA-CR · 全项目 Code Review 整改批(全 ✅)
> **背景**[2026-06-26] 对全仓做了一次**七维度 Code Review**(后端 Python 规范 / LangGraph 最佳实践 / 后端 FastAPI 架构 / 前端编程规范 / 前端架构 / 应用安全 / 依赖时效6 个专科 reviewer 并行 + 依赖自查。绿门禁下仍揪出 **2 CRITICAL + 13 HIGH + 一批 MEDIUM/LOW**——再次证伪「测试过=没问题」。架构本身成熟、不变量纪律强provider 中立 / HITL accept 单写 / checkpoint 仅控制流 / SecretStr 全覆盖);**确认无虞**SSRF`base_url` 仅来自硬编码 `_PROVIDER_BASE_URLS`,无用户 URL 入网)/ SQL 注入(全 ORM 参数化)/ XSS无 `dangerouslySetInnerHTML`/`eval`/ 错误信封不泄栈密钥 / Kimi 用 RFC 8628 device flow 无 CSRF 面。
> **整改 DoD**CRITICAL + HIGH 全部 ✅ 且各自补回归测试MEDIUM 择批清理;门禁全绿(后端 ruff/format/mypy/pytest + alembic 无漂移;前端 lint/tsc/vitest/build。**守 §Definition of Done**:每项 TDD 先红后绿、完成即小步 commit。
@@ -17,29 +31,62 @@
| 任务 | 状态 | 负责 | 依赖 | 备注 |
|---|---|---|---|---|
| CR-C1 🔴 升级 Next.js 修 RCECVE-2025-66478+ React 补丁 + 轮换密钥 | | @frontend | — | `apps/web/package.json` next **15.1.3→15.1.9**(或 15.5.7/16.xreact/react-dom 19.0.0→补丁版(上游 CVE-2025-55182App Router + RSC 应用正受影响。`pnpm install` 重锁 + `pnpm build` 验;**升级后轮换全部应用密钥**(联动 CR-H8。Next15 LTS 2026-10-21 EOL规划 16.x 迁移 |
| CR-C2 🔴 多章链设 `recursion_limit` | | @backend | — | `services/chain_runner.py:210` 默认 25 supersteps、每章 4 节点→超 ~6 章即 `GraphRecursionError` 整 job 失败,「批量产一卷(K章)」在目标规模不可用。`config``recursion_limit=(last-start+1)*4+10`;补超默认上限的 mock-gateway 回归测试 |
| CR-H1 🟠 Kimi OAuth 轮询不占 DB 会话 | | @backend | — | `services/job_runner.py:75`+`routers/kimi_oauth.py:85` 轮询全程(最坏 >200s占连接池连接、饿死其他请求轮询循环无需 DB仅最终 `upsert_oauth_credential` 开短会话 |
| CR-H2 🟠 修 `httpx.AsyncClient` 泄漏 | | @backend | — | `routers/kimi_oauth.py:73` `Depends(_default_http_client)` 非 generator 依赖、永不 `aclose()`;改 `async def _http_client(): async with httpx.AsyncClient(...) as http: yield http` |
| CR-H3 🟠 Anthropic 结构化输出记真实 usage | | @llm | — | `adapters/anthropic.py:161` 硬编码 `usage=0``usage_ledger` 全记 0 成本、腐蚀计费(违 ARCH §4.8);用 `create_with_completion`原始响应 usage |
| CR-H4 🟠 `request_id` 贯通网关日志 | | @llm | — | `gateway.py:183` 所有 `llm_call`/`llm_provider_failed` 无 request_id`LlmRequest``request_id` 字段并从 API 层透传(违 ARCH §9.3 端到端追踪不变量) |
| CR-H5 🟠 `thinking` 字段:实现或删除 | | @llm | — | `types.py:41` 声明但所有 adapter 忽略,`thinking=True` 静默无效、无报错;按 YAGNI 删除或实现转发 + 测试 |
| CR-H6 🟠 流式路径加 per-provider 重试 | | @llm | — | `gateway.py:268` 流式仅 1 次尝试,首 token 前一次瞬时 429 烧光 fallback 链(与非流式 `_complete_with_retry` 对称);用 `_retrying()` 包裹首 token 前尝试或显式文档化 |
| CR-H7 🟠 `TOOLBOX` 注册表不可变 | | @backend | — | `packages/skills/ww_skills/toolbox_registry.py:70` 裸 dict 可变;仿同库 `SPECS` `Final[Mapping]=MappingProxyType(...)`(违不可变不变量 |
| CR-H8 🟠 `.env.example` 移除可用 Fernet key | | @devops | — | 已确认提交真实可用 key注释「可直接用于本地」换非功能占位符 + 突出轮换提示;联动 CR-C1 轮换 |
| CR-H9 🟠 API 文本输入加 `max_length` | | @backend | — | `schemas/{providers,generation,projects}.py` `api_key`/`brief`/`final_text`/`segment``min_length=1`DoS/成本失控加上限api_key≤512、brief≤1e4、final_text≤2e5+ ASGI 请求体大小限 |
| CR-H10 🟠 前端 `errorCode` 去重 | | @frontend | — | `lib/style/{useRefine,useStyleLearn}.ts` 各自重复定义,统一 import 自 `lib/generation/cards.ts:132`DRY 漂移隐患) |
| CR-H11 🟠 Drawer 关闭还原焦点 | | @frontend | — | `components/Drawer.tsx:41` 关闭后焦点掉到 body违 WCAG 2.4.3);传 `triggerRef`、关闭时 `.focus()` |
| CR-H12 🟠 RefineView 取消在途 refine | | @frontend | — | `components/style/RefineView.tsx:33` 无 AbortController段切换时旧响应可覆盖新响应`useRefine` 暴露 abort 在 effect cleanup 调用,去 blanket exhaustive-deps disable |
| CR-M1 🟡 后端架构整改批 | | @backend | — | `outline.py:162` volume 下推 DB 过滤;`generation.py:326`/`toolbox.py:431` N+1 INSERT→`bulk_create``jobs.py:28` 裸 ORM→repo`provider_deps.py:91` 去私有 `_client`(加 `probe_connection()`);列表端点加分页。⚠️`assemble.py:155` 8 串行查询:**AsyncSession 不支持同一 session 并发,`gather` 须每查询独立 session**,不可照搬 |
| CR-M2 🟡 网关/adapters 整改批 | | @llm | — | `gateway.py:90` `is_open()` 副作用违 CQS`pricing.py` 未知 model 静默记 0 → 加 warningadapter `str(exc)` 入异常消息可能带 vendor 响应体→只留类名+status |
| CR-M3 🟡 LangGraph 链健壮性 | | @llm | — | `chain_runner.py:124` accept commit 后 `run_overdue_scan` 幂等且失败无法 resume→部分进度空洞幂等化或纳入事务`chain_runner.py:159` 改用公开 `get_state().next` 替私有 `__interrupt__``chain/nodes.py:213` 链内 4 审串行→并行/复用 review 子图 |
| CR-M4 🟡 前端 key/a11y/cleanup 批 | | @frontend | — | 可编辑列表去 index-key`CharacterGenerator.tsx:135`/`GeneratorRunner.tsx:178`/`ForeshadowCard.tsx:68`SSE `KNOWN_EVENTS``stop`(核对 `memory/contracts.md`ARIA 补全AppShell nav label / NavDrawer aria-controls / ForeshadowCard `<dl>` 结构);ProjectWizard 步骤焦点`ReviewReport.tsx:301` setTimeout 卸载清理 |
| CR-M5 🟡 前端 TS 边界类型收紧 | | @frontend | — | `lib/api/server.ts:34` `parseJsonBody` 加最小结构校验;`useAccept.ts:60` `ApiErrorEnvelope` 从生成 schema 派生;`applyFix.ts` `hasApplicableFix` 改类型谓词`as string` |
| CR-L1 🟢 LOW 清理批 | | @backend·@llm·@frontend | — | `chain.py:256` 静默回退加 warningSSE 端点补 OpenAPI `text/event-stream` 声明;冗余 `as`/`.valueOf()` cast 清理;测试脚本 `print`→logging |
| CR-D1 🟡 抬高后端依赖下界(可选升级评估) | | @devops | — | `pyproject.toml` 下界过旧`langgraph>=0.2.40`/`anthropic>=0.34`),抬到接近 lockfile 实锁版本保可复现;后端实锁版本均当前。可选评估 vitest 3.x / tailwind 4.x |
| CR-C1 🔴 升级 Next.js 修 RCE + React 补丁 | | @frontend | — | `f4c2c4e` next 15.1.3→**15.5.20**registry backport 线,避 16.x 大迁移、react/react-dom 19.0.0→**19.2.7**、eslint-config-next/@types 同步;`pnpm install` 重锁 + `pnpm build` 绿;`pnpm audit` next/react/react-dom 无 high/critical`lib/security/dependencyFloors.ts` 版本下界守卫。**⚠️ 运维待办(用户执行)**CVE 号无法对真库核验(升级后 `pnpm audit`/GitHub advisory 复核)+ 升级后轮换全部应用密钥 |
| CR-C2 🔴 多章链设 `recursion_limit` | | @backend | — | `ed0a679` **前提证伪**langgraph 1.2.5 默认上限=10007非 25、count≤50→~200 步,**无实活崩溃**;仍加显式**顶层** `recursion_limit=count*4+10`(防御硬化);测断言 config 键(行为「跑长链完成」测会 vacuous |
| CR-H1 🟠 Kimi OAuth 轮询不占 DB 会话 | | @backend | — | `ca8fbc8` 轮询移出会话:`_poll_for_token`(无会话)+`_run_kimi_oauth_poll`,仅最终 persist 走 run_job 短会话;`job_runner` 未动(与 style_learn 共享) |
| CR-H2 🟠 修 `httpx.AsyncClient` 泄漏 | | @backend | — | `5dd336b`+`26f10d0` 请求级依赖改 `_http_client` 生成器(`async with``aclose`);两处测 override 跟随改,堵住 E2E 误触真网 |
| CR-H3 🟠 Anthropic 结构化输出记真实 usage | | @llm | — | `cb8fddf` `create_with_completion` `raw.usage`,弃硬编码 0修计费腐蚀 |
| CR-H4 🟠 `request_id` 贯通网关日志 | | @llm | — | `51fe9f4`+`e08b01a` LlmRequest 加可选 `request_id` + **条件透传**(仅非 None否则覆盖 contextvars 反退化 sync/SSE 追踪);`llm_call`+两处 `llm_provider_failed` 全锁测。契约 C1-扩 |
| CR-H5 🟠 `thinking` 字段:实现或删除 | | @llm | — | `afeac51` 删死字段 `LlmRequest.thinking`YAGNI`Capabilities.thinking`(异名活字段);回写 ARCH/contracts |
| CR-H6 🟠 流式路径加 per-provider 重试 | | @llm | — | `8a2b1b0` `_stream_with_retry` 对称 run仅首 token 前重试mid-stream 失败照抛(不变量 §4.5 |
| CR-H7 🟠 `TOOLBOX` 注册表不可变 | | @backend | — | `25d48b7` `Final[Mapping]=MappingProxyType(_TOOLBOX_BY_KEY)`(仿 SPECS |
| CR-H8 🟠 `.env.example` 移除可用 Fernet key | | @devops | — | `baa41d3` 换不可用占位符Fernet 拒绝→照抄即启动失败)+ 轮换提示。**⚠️ 真 key 仍在 git 历史,曾用该 key 的环境须轮换(用户)** |
| CR-H9 🟠 API 文本输入加 `max_length` | | @backend | — | `e1a24a2`+`d6b53d0` 全请求文本字段加上限api_key≤512/brief≤1e4/final_text≤2e5/segment≤2e4/directive≤2e4/instruction≤2e3…+ **2 MiB body 中间件 413 兜底** + `PAYLOAD_TOO_LARGE`。契约 C3`gen:api` 无漂移 |
| CR-H10 🟠 前端 `errorCode` 去重 | | @frontend | — | `5665cc9` `lib/style/{useRefine,useStyleLearn}` 统一 import 自 `generation/cards` |
| CR-H11 🟠 Drawer 关闭还原焦点 | | @frontend | — | `6e2b478` 行为本分支早已修;提取 `lib/a11y/useRestoreFocus` + renderHook 回归测试 + DRYDrawer/ContextDrawer 共用) |
| CR-H12 🟠 RefineView 取消在途 refine | | @frontend | — | `5c02792` useRefine 暴露 `abort`setState-free+ AbortController + aborted 守卫弃迟到响应RefineView cleanup 调用 + 真 deps |
| CR-M1 🟡 后端架构整改批 | ✅ 大部 | @backend | — | `c596a8d` outline volume 下推 DB WHEREProtocol/Sql/fake 加 keyword `volume``bf3dabe` 角色/世界观入库 N+1→`create_many`(世界观 add_all角色一次 IN 查+一次 flush 保 upsert 幂等/批内去重;单条 create 薄封装);`1392830` jobs 读端点裸 ORM→JobRepo`0892ff6` projects/templates 列表加分页(`ww_api.pagination` limit∈[1,200]/offset≥0越界 422下推 LIMIT/OFFSET。**item#4 provider_deps `_client`→已由 @llm 交付**`832ab7d` `OpenAICompatAdapter.probe_connection()` 公开探测 + provider_deps 改调,删私有 `_client` 反手 + `type:ignore`/`noqa:SLF001`isinstance 收窄兜非 OpenAI 兼容)。**item#6 assemble 并行→SKIP**8 查询共享同一注入 AsyncSession`gather` 会腐蚀ledger 警告属实);安全化须把 session 工厂穿透 assemble + 8 repo 各独立 session + 并发正确性测,对单用户几 ms 收益不值该回归风险 |
| CR-M2 🟡 网关/adapters 整改批 | ✅ 大部 | @llm | — | `32451a9` pricing 未知 model 发 `pricing_unknown_model` warning成本仍 0、照常记账`9439b97` 三 adapter 瞬时错误消息经 `provider_error_summary(exc)` 只留类名+status不泄 vendor 响应体)。**item `is_open()` CQS→SKIP**:冷却过读时半开清态是**故意的惰性转移**(唯一转半开点),拆 CQS 需两处调用方各先发命令、漏发则熔断永不复位(更差),风格分不值动熔断语义 |
| CR-M3 🟡 LangGraph 链健壮性 | ✅ 大部 | @llm | — | `ec91c8f` runner 改用公开 `graph.aget_state(config).interrupts` 判暂停(弃私有 `__interrupt__`SpyGraph 测同步补 aget_state 透传);`6a739ca` review 节点五审 `asyncio.gather` 并行扇出(复用主图 add-only 记账并发范式 T3.8collect 仍一次落库;并发峰值测 + 五审落齐测)。**item `run_overdue_scan` 幂等→SKIP**`scan_overdue` 本已幂等(单条 `UPDATE...RETURNING` + `status NOT IN [CLOSED,OVERDUE]` + commit-only-if-changed原子无部分空洞且下一章扫描 current+1 自愈漏标;再动会触碰验收事务边界,单用户零收益 |
| CR-M4 🟡 前端 key/a11y/cleanup 批 | | @frontend | — | `47d5558` ARIANavDrawer `aria-controls` + Drawer `id` + ForeshadowCard 语义化 `<dl>/<dt>/<dd>`+ `125908e` ReviewReport `flashMark` setTimeout 卸载清理ref 存句柄)。**核实为无需改/已具备**index-key三处均 replace-all/append-only 静态展示、无天然稳定 idindex 安全;卡名可编辑作 key 更糟)、`stop`(经查**非**服务端 SSE 帧、是客户端 AbortController 动作加则处理不存在事件、AppShell nav label`<nav aria-label>` 已有)、ProjectWizard 步骤焦点`stepHeadingRef`+首帧守卫 useEffect 已实现) |
| CR-M5 🟡 前端 TS 边界类型收紧 | | @frontend | — | `d80d41a` `ApiErrorEnvelope` 改由生成 schema`components.schemas.ErrorEnvelope`)派生防漂移 + `9cf4915` `hasApplicableFix`对象参类型谓词、消除 `applyConflictFix` 两处 `as string` + `16f2eae` `parseJsonBody`(结构校验 HEAD 已具备,补非对象/非 JSON 守卫分支测试) |
| CR-L1 🟢 LOW 清理批 | | @backend·@llm·@frontend | — | **@backend**`49e3cb6` SSE draft/rewrite/review 补 OpenAPI `text/event-stream` 声明(共享 `_SSE_RESPONSE``print`→logging **无需改**owned 目录唯一 `print(``export_openapi.py` CLI stdout前端 gen:api 管道消费,改则断管道)。**@llm**`c9ffada` 续写链前一章无已验收终稿发 `chain_prior_accepted_missing` warning。**@frontend**`6414542` 去冗余 `as`/`.valueOf()` cast`stream/sse.ts` idx.valueOf、`jobs/job.ts` `dict[error] as string`;保留 load-bearing 的 CSS-var 键/变量键强转) |
| CR-D1 🟡 抬高后端依赖下界(可选升级评估) | | @devops | — | 8 个 pyproject 后端运行时下界抬到 lockfile 主次版本`langgraph>=1.2`/`anthropic>=0.111`/`openai>=2.43`/`pydantic>=2.13`/`fastapi>=0.137`/`cryptography>=49` 等,每条 ≤ 实锁);`uv.lock` **纯元数据刷新**(仅 `requires-dist` specifier零 pinned 版本变动);`uv lock --check` 绿、pytest **971**、ruff 干净。root dev 工具下界 + 前端 vitest/tailwind 升级评估未纳入(超范围) |
> **[2026-07-08] CRITICAL + HIGH 全部 ✅ 落地**14 项 + 4 项低危收尾),多 agent 并行开发(@llm/@backend/@devops/@frontend 四簇串行整合,各 TDD 红→绿、逐项补回归测试)。**门禁全绿**:后端 ruff/format 干净 · mypy **229** · alembic **无漂移** · pytest **934**;前端 lint/tsc 干净 · vitest **669** · build OK · coverage **95.41%**。**14 项对抗式复核 0 problem**skeptic 逐条实证 test 非 vacuous。落地含 3 处前提证伪C2 非实活崩溃/H4 sync-SSE 已由 contextvars 贯通/H11 行为本分支已修)。
>
> **[2026-07-08] MEDIUM/LOWCR-M1..M5 / L1 / D1亦全部清理 ✅**(四簇串行整合):明确取舍——高价值低风险项落地、风险>价值或前提已陈旧的子项**有据 SKIP/NOOP**assemble 并行 gather / breaker is_open CQS / overdue-scan 已幂等 / index-key 安全 / `stop` 非服务端帧 / 已具备的 nav-label、wizard 焦点)。**门禁全绿**:后端 mypy **232** · alembic 无漂移 · pytest **971**;前端 vitest **681** · build · coverage **95.45%**。附带修复:一 agent 误提交的非源文件(`.coverage`/`AGENTS.md`/`example_skills`)已撤销还原为未跟踪(`1bda024`)。
>
> **维度小结**:后端 FastAPI 架构 0C/2H/7M·Python 规范 0C/4H/6M·LangGraph 1C/2H/5M·前端 React 0C/2H/9M·前端 TS 0C/1H/8L·安全 0C/2H/4M·依赖 1C。后端依赖全部当前大版本线前端唯一关键缺口=Next RCE。
>
> **[2026-07-08] CR-M1 + CR-L1 后端片 ✅ 落地**@backend6 提交 `c596a8d`/`bf3dabe`/`1392830`/`0892ff6`/`49e3cb6`各行为改动先写红测再实现。CR-M1 4/6 完成volume 下推 · N+1→create_many · jobs→JobRepo · 列表分页item#4去 `_client`)与 item#6assemble 并行SKIP理由见 CR-M1 行CR-L1 后端片完成SSE OpenAPIprint 属误报)。**门禁全绿**:后端 ruff/format 干净 · mypy **230** · pytest **960 passed**(较基线 955 +5 新测volume 下推 1 + 分页 2 + 批量入库 2· 无模型改动故免 alembic。新增 5 测无回归,既有 upsert 幂等/冲突 gate + m5 真 pg E2E 全绿。
>
> **[请求 → @llm]** CR-M1 item#4`apps/api/ww_api/services/provider_deps.py:91` 需去掉对 adapter 私有 `_client` 的探测(`adapter._client.models.list()`。owning 类 `OpenAICompatAdapter`/`ProviderAdapter``packages/llm_gateway/ww_llm_gateway/adapters/`)在 @llm 域——请在 adapter 上加一个公开 `async probe_connection()`(内部最小请求验 Key如 `models.list()`@backend 改调该公开方法即可删私有属性访问 + `type: ignore`/`noqa: SLF001`。行为保持不变。
---
## 进行中写作工作台重构writing-first· 分支 `feat/writing-first-workspace`
> **背景**[2026-07-07] 用户对写作体验提 8 点反馈,产品主张升级为 **writing-first 工作台**——进来即写、所有工具/AI 动作从编辑器内按需调用并回填正文、元数据由内容渐进生成。多 agent 工作流扎根代码→Web+GitHub 调研→三方设计→四镜头对抗评审→定稿)产出 DEV_PLAN`scratchpad/writing-first-workspace-DEVPLAN.md`**ephemeral 未入库**;灵感/取舍见 `memory/selectable-write-prompts.md`)。
> **已拍板决策**:进来即写=**延迟落库**(首次击键才建占位项目 + 列表过滤空稿);可选提示词 MVP=**纯前端** directive→volatile不改 `DraftStreamRequest` 契约、不动 `write_craft.md` 字节 → 金标准零回归);预设 MVP=先只上内置;续写=多候选卡点选插入。
> **纪律**:全程 TDD 小步提交,回填全守 HITLAI 绝不 auto-replace点选/接受才落草稿);`/refine`、`/rewrite` 只读不写库(不变量 #3。**门禁全绿**:前端 vitest **678** / lint / tsc / build / coverage **95.36%**;后端 ruff / mypy **229** / pytest **955**(含 WFW-9 clarify E2E/ alembic 无漂移。**WFW-0~9 全部交付**WFW-5/6/7 多 agent 并行 → `3dd7d28`WFW-8 整章重写跨栈 → `6ed9c89`+`542c2ab`**WFW-9 三段全交付** M1 `1652ad9`+`e30c933` / M2 `4a09f27`..`8e29b33` / M3 `ea9a9d9`+`a703a90`,契约 C-Rewrite+C-Clarify 见 `memory/contracts.md`)。**未 push、未开 PR**(等用户示意)。
| 任务 | 状态 | 负责 | 依赖 | 反馈# | 备注 |
|---|---|---|---|---|---|
| WFW-0 进来即写(延迟落库) | ✅ | @frontend | — | #5 | `8389572` `/write`(DraftComposer) + `lib/start/useDeferredDraft`(懒建占位项目+种首章草稿,双重幂等,空进空出不落库) + 落地页 CTA「直接开始写」主/「先立项」辅 |
| WFW-1 润色 / 再沟通 | ✅ | @frontend | — | #1#2 | `2f0b841` `useRefine`(同步/refine,版本栈,再沟通=上一版产出+新意见再回炉) + `refineApply`(接受回填按内容重锚,原文变则提示重选) + RefinePanel 内联卡 + Editor 上报选区 |
| WFW-2 续写(多候选) | ✅ | @frontend | WFW-0 | #2 | `9195cf3` `useContinue`(continue 生成器 with_prior_chapter 读前文,多候选累加) + ContinuePanel 候选卡点选插入章末 + flush 后读最新草稿 |
| WFW-3 内联工具箱 + 金手指露出 | ✅ | @frontend | — | #4#8 | `04f4785` `useToolboxTools`(客户端拉描述符) + InlineToolbox(列非 legacy 生成器含金手指) + `GeneratorRunner``onInsertText`(文本产物插入正文) |
| WFW-4 AI 写作三槽(文风/剧情/自定义) | ✅ | @frontend | — | #3 | `44b0b8a` `directive.ts``PLOT_PRESETS` + `composeDirective` 合并;DirectivePanel 拆文风/剧情两组 chips + 自定义。纯前端零契约,`write_craft.md` 字节不变 |
| WFW-5 内容感知书名 | ✅ | @frontend | — | #6 | `3dd7d28` 纯前端零后端 MVP`buildManuscriptExcerpt`(首尾摘录+硬上界) + GeneratorRunner「用当前正文」按钮把摘录填 briefbook-title 据正文反推。**后续更佳版**:后端富化 book-title.md + `with_manuscript` 策略(未做) |
| WFW-6 侧栏 IA 收敛ContextDrawer | ✅ | @frontend | — | #7 | `3dd7d28` 加法式 ContextDrawer 右侧速查抽屉 + 底栏「速查」按钮;设定库/大纲**完整只读速查**,伏笔/规则/文风**摘要+跳全宽页**(后续可补真实数据)。`CONTEXT_DRAWER_ENABLED` 一键回滚、旧侧栏/全宽页路由全保留 |
| WFW-7 首次 AI 写作前极轻 genre 采集 + 无大纲降级 | ✅ | @frontend | WFW-0 | — | `3dd7d28` 无题材首次写章前弹 GenrePicker(临时并入本次 directive 不落库) + chapters 空时「尚无大纲,将按设定自由生成」提示,杜绝无题材 slop |
| WFW-8 整章级「再沟通/重写」(对话式迭代) | ✅ | @backend·@llm·@frontend | — | 新#1 | `6ed9c89`(后端)+`542c2ab`(前端)。后端 SSE `POST .../rewrite``rewrite.md` 教条 + `rewrite_node` + `RewriteStreamRequest`,复用 assemble 记忆注入,只读不写库、工具非写节点、守 #3/#7/#9);前端 `useChapterRewrite`(版本栈)+`ChapterRewritePanel`+底栏「整章重写」:给意见→流式重写整章→多轮迭代→接受某版才替换正文(HITL)。契约 C-Rewrite。用户反馈「整章不满意要跟 AI 交流」 |
| WFW-9 M1 AI 反问澄清refine 侧路线A 两阶段) | ✅ | @backend·@llm·@frontend | — | 新#2 | `1652ad9`(后端)+`e30c933`(前端)。多agent并行(后端整片‖ChoiceChips)+主线集成。非流式预检 `POST .../refine/clarify``ClarifyDecision`(结构化,analyst,只读,失败回退放行)+clarify_refine spec/教条(SPECS#24);既有 refine 未改。前端 `useClarify`+`ChoiceChips`+`clarify.ts`(foldClarifications/门控)+RefinePanel 门控接线:意见<10字或点帮我理清方向」→AI反问选项折答案进instruction走既有refine契约 C-Clarify。**M1 refine 侧完成M2/M3 见下两行** |
| WFW-9 M2 AI 反问澄清rewrite 整章重写路径 | | @backend·@llm·@frontend | | #2 | `4a09f27`+`6fd6375`(后端)+`9b32743`+`4b1088a`+`8e29b33`(前端) agent 串行整合新非流式预检 `POST .../rewrite/clarify``ClarifyDecision`(复用 M1 schema/`run_clarify`)+ `clarify_rewrite` spec/教条(章级,SPECS 2425,prompt_hashes +1)+`RewriteClarifyRequest{feedback,prior_draft?}`(仅取有界 3000 字摘录喂 analyst,不整章入 LLM);只读不写库失败放行前端 `useRewriteClarify`+`toVM/PROCEED` 提取入共享 `clarify.ts`(DRY)+`ChapterRewritePanel` 门控(镜像 RefinePanel):意见<10字或帮我理清方向」→反问折进 feedback走既有 rewrite SSE契约 C-Clarify ;gen:api 纳入新端点 |
| WFW-9 M3 澄清 UX 打磨 + E2E | | @frontend·@qa | | | `ea9a9d9`(a11y):refine/rewrite 两面板 AI 反问块加 `role=status` 向读屏动态播报(WCAG 4.1.3 Status Messages)。`a703a90`(E2E):`tests/test_wfw9_clarify_e2e.py` 7 用例真 pg+mock 网关零 token——refine/rewrite 含糊反问 / 清晰放行 / 失败开路(parsed=None→200 need_clarification=false,不 500) / 只读不写业务表但记 usage_ledger / 404无产品 bug。**深度视觉/交互 QA(真起前端浏览)为人工验收事项** |
| WFW-9 M2 AI 反问澄清rewrite 路线A 两阶段 | 后端 | @llm·@backend | WFW-8·WFW-9 M1 | #2 | `4a09f27`(llm)+`6fd6375`(后端)。镜像 M1非流式预检 `POST .../rewrite/clarify``RewriteClarifyRequest{feedback(必给),prior_draft(可选)}``ClarifyDecision`(复用同一 schema,analyst,只读,失败回退放行)+`clarify_rewrite_spec`/教条 `clarify_rewrite.md`(segment整章,SPECS#25);既有 `.../rewrite` SSE 未改端点只喂 prior_draft 开头 3000 字摘录(不整章入 LLM)、日志脱敏契约 C-Clarify()。门禁绿:后端 ruff/format·mypy229·pytest948·alembic 无漂移。**前端仍欠 `pnpm gen:api`+M2 UI 接线(仿 RefinePanel 门控 ChapterRewritePanel) + M3 UX/E2E 待办** |
| WFW-9 E2E两阶段 AI 反问澄清refine+rewrite | | @qa | WFW-9 M1·M2 | #2 | `tests/test_wfw9_clarify_e2e.py` 7 用例真 pg + mock 网关provider/adapter M2/T6 范式 tokenrefine 含糊反问1问 / refine 明确放行rewrite 含糊反问 / rewrite 明确放行失败开放(网关产非 ClarifyDecision仍200 need_clarification=false 500 且仍记账)、只读(seed chapter 字节一致+ review/digest ) usage_ledger 真落 analyst 一行404 信封响应绝不回灌原文摘录无产品 bug门禁绿:ruff/format·mypy 本文件净·pytest 955 passed |
---
@@ -161,6 +208,20 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移无漂移users st
> 格式:`- [YYYY-MM-DD] @skill 完成/进展 Txx — 一句话结果 + 影响的契约/文件`
- [2026-07-10] @frontend**✅ 写作台 UX 改造 P2-1..P2-3 全落地 → P0/P1/P2 整套 IA 改造完成**`docs/design/ux-exploration.md` §4/§5P2-4 按计划延后不做)。**P2-1 左栏分组**`lib/nav/items.ts` 新增纯函数 `navSections(projectId?)`——把 10 个平铺项目项归为 **写作 / 资料库(大纲·设定库·伏笔·文风)/ 审阅(审稿·多章链)/ 更多(工具箱·规则·技能库)** 四组(全局组 作品库/模板库/设置 无标题恒在顶部),`NavItems.tsx` 复用现有「本作」小标题样式逐组渲染小标题(桌面侧栏/移动抽屉共用,链接/路由/激活键均不变,纯重排+加标题);工具箱在原分组清单缺列,收进「更多」以不丢入口(记 decisions。**P2-2 目录归位(可折叠列)**:新增 `lib/workbench/useChapterListCollapse.ts``localStorage``ww.workbench_chapter_list_collapsed`SSR 首帧恒展开避免 hydration 不一致、挂载后恢复、toggle 即时写回),`ChapterList` 收起时降为一条带「展开目录」按钮的窄轨(`2.75rem`)、展开时目录标题旁加「收起目录」按钮,`Workbench` 三栏栅格据折叠态在 `xl:grid-cols-[2.75rem_1fr_18rem]``[12rem_1fr_18rem]` 间切换(章节列表/当前章高亮/切章导航全保留;窄屏抽屉不受影响、不带折叠按钮)。**P2-3 放置规则**`memory/decisions.md` 落一条四象限唯一落点规则——导航→左栏、AI 动作→中央输入条+「或让 AI:」行、章节状态→底栏、参考/透明度→右栏「本章参考」,防三重复回潮。**P2-4 统一对话面板按计划延后**§3/§4-P2-4/§6 明列为「先观察的大赌注、非开局」,本波不实现)。**TDD**`navSections` +6 断言(分组标签/成员/全项恰好一次覆盖)、`useChapterListCollapse` +3默认展开/toggle 持久化/挂载恢复)均 RED→GREEN。**门禁全绿**lint/typecheck 干净 · vitest **716 passed**710 基线 +6 新)· `test:coverage` 通过(`lib/**` 95.43% ≥ 80· `build` OK。仅动 `apps/web/` + `memory/decisions.md` + `PROGRESS.md`
- [2026-07-10] @frontend**✅ 写作台 UX 改造 P1-1..P1-5 全落地**`docs/design/ux-exploration.md` §4接 P0 新布局)。**P1-1 底栏瘦身 + act-on-chapter/导航分家**:把生成动词(续写/润色选段/整章重写/工具箱)从底栏搬进中央输入条正下方常驻的**「或让 AI」次级动作行**(新 `AiVerbsRow`/`VerbButton`,读作「同一个 AI 的其它用法」,生成中禁用而非隐藏,触发沿用既有 `openContinue/openRefine/openRewrite/openToolbox`——仅搬家不改行为);删底栏纯导航「大纲」「伏笔」(左栏已有,连带下线 `BookOpen/Library` import**唯一强调的「审稿」** 作为写→审→验收前进 CTA 保留outline`写本章`「发送键」上移到 `DirectivePanel` 表头**常驻可见**折叠时也在streaming 切「停」),底栏遂只剩 字数/生成中 · 保存 · 里程碑播报 · streaming 时的「停」sticky 底栏够得着)+ 审稿 CTA + 速查/对话记录抽屉入口。**P1-2 统一「采用」动词族**`ConversationThread.ACCEPT_LABEL`→采用(替换整章)/采用(加到章末)/采用(替换这段);`RefinePanel`「接受回填」+`ContinuePanel`「插入正文」+`ChapterRewritePanel`「接受最新版」同步;弃「回填」(含 Workbench 三处 toast 与两处面板描述)。**P1-3 右栏去术语(不降级,守 #6 透明度)**`ChapterAssistant`「本章助手」→本章参考、「本章注入透明」→AI 写这章会参考、「四审」→写完后检查(白话描述,**删重复的「去审稿」快捷** + 去 `next/link` import、空/加载/错误态与「已排除」及 pin/排除 aria 全部注入→参考白话;实体透明内容本身未动;移动抽屉 label 同改本章参考。**P1-4 四动词可见范围提示**(非 hover-only写本章=整章·从头写(表头按钮内)、续写=接着往下写、整章重写=整章·基于现有重写、润色选段=选中段·打磨(「或让 AI」行内 2xs 副标签)。**P1-5 题材去弹窗**:删中途弹出的 `genrePromptOpen`/`onPickGenre`,改为项目无题材时正文上方**常驻内联题材条**(复用 `GenrePicker``onPick=setChosenGenre` 不自动开写)→ 首次「写本章」顺滑流式不被打断no-slop 守卫保留(未选题材点写本章给 toast 引导而非静默生成)。守 §3五交互未并框、写本章仍流式进正文、右栏透明度未删。**纯表现层,无新增 `lib/**` 逻辑**`useGenreGate`/`directive` 未动 → 无新 vitest。**门禁全绿**lint/typecheck 干净 · vitest **710 passed**(基线持平)· `test:coverage` 通过(`lib/**` 95.47% ≥ 80· `build` OK。仅动 `apps/web/`。**决策**:速查/对话记录两抽屉flag 均 true非导航亦非那 4 个生成动词,留在底栏作辅助入口(保全接线,符「底栏 essentially 只剩状态 + 审稿」)。**P2左栏分组/目录归位/放置规则)待后续。**
- [2026-07-10] @frontend**✅ 写作台 UX 改造 P0-1..P0-5 全落地**`docs/design/ux-exploration.md` §4回答用户原话「我怎么和 AI 对话」)。**P0-1** 底栏「AI 对话」→「对话记录」(只读日志正名,让「对话」一词归还真输入框):`AiConversationDrawer` 标题/`aria-label`/关闭 aria/空态(→「这里留存你和 AI 的往复记录」)+ 底栏按钮文案/title 同改。**P0-2** 把折叠 `<details>`「本章写作指令[可选]」原地提升为正文正上方**常驻输入条**`Workbench.tsx` 内联 `DirectivePanel`):标题「告诉 AI 这章想怎么写」、去「可选」徽标、空章默认展开(`defaultOpen={initialText 空}`,受控 `useState` 取代原生 details、「AI 写作诉求」→「你的写作要求」、textarea 邀请式 placeholder**发送键即「写本章」**(内联 primary 写本章 / streaming 时切「停」,复用既有 `onWrite`/`stop` + `composeDirective` + 文风/剧情 chips + 流式打字机,**未改成聊天气泡**)。**P0-3** 删顶部「AI 工具条」:`AppShell``AiToolbar` 渲染 + `--chrome` 恒 4rem原 7rem省 48px`git rm` 死代码 `components/AiToolbar.tsx`+`AiToolbarMoreMenu.tsx`+`lib/nav/ai-tools.ts`+`ai-tools.test.ts`(全仓仅 AppShell 用,无其它 importer。**P0-4** 底栏「润色选段」常驻可点(去 `disabled`/hover-only title无选区点击弹 toast「先在正文里选中一段再润色」`openRefine` 守卫 + `canRefine``Toolbar` props 下线,仅留 Workbench 内做面板渲染守卫)。**P0-5** 输入条旁常驻一行 HITL 微文案「AI 给草稿,你决定采不采用,原文永远你说了算」+ 可再发现的「?这是什么」弹层(`HitlHelp`,非一次性 coachEsc/点击外部关闭)。守 §3五种交互未并框、写本章未改聊天、右栏透明度未删、未用 Sparkles 作 AI 标记、润色未藏到有选区才现。**纯表现层改造,无新增 `lib/**` 逻辑**`directive.ts` 未动 → 无新 vitest`ai-tools.test.ts` 使基线 715→710。**门禁全绿**lint/typecheck 干净 · vitest **710 passed** · `test:coverage` 通过(`lib/**` 95.47% ≥ 80· `build` OK。仅动 `apps/web/`。**P1底栏瘦身/统一采用词/右栏去术语)+ P2左栏分组/目录归位/放置规则)待后续。**
- [2026-07-10] @frontend**✅ AC-3 后续:润色/整章重写面板内联对话气泡流**用户反馈不想每次开「AI 对话」抽屉才看得到对话,要在对话时就地内联可见)。抽出复用组件 `components/workbench/ConversationThread.tsx`(把抽屉内联的 `Bubble`/`readOptions`/`ACCEPT_LABEL` 提取为共享气泡列表,抽屉与内联面板共用 → 外观一致 DRY新纯助手 `lib/workbench/inlineConversation.ts``mergeInlineThreads` 把已落库留痕〔本章 + 指定 kinds与「进行中」实时气泡按 id 去重合并、复用 `groupByThread` 定序;`toPendingView` 合成未落库气泡〔`meta._pending/_streaming` UI 标记〕;`isPendingView/isStreamingView/readSegment`+ **RED→GREEN 9 单测**。RefinePanel/ChapterRewritePanel 内联渲染 `role=log` 滚动气泡流(本章 refine/rewrite + 其 clarify 往复 **** 本轮进行中气泡:流式 token 逐字进气泡、缓冲的 clarify Q&A 亦实时可见),输入框置底像聊天;**pending→persisted 无重影**(进行中气泡结束即清、已落库经 `mergeById` 去重);**fail-soft**(历史加载失败仍显示本轮实时气泡,不阻塞回炉/重写);气泡「再接受/回填」复用既有 DRAFT-only HITL 回调drift-safe正文仍只在验收才变clarify gating/abort/停/接受悉数保留。**抽屉保留原样**(底栏仍可开,跨章/项目级全量历史浏览)。**门禁全绿**lint/typecheck 干净 · vitest **715 passed**706 基线 + 9· `test:coverage` 通过(`lib/**` 95.48% ≥ 80· `build` OK。仅动 `apps/web/`
- [2026-07-09] @qa**✅ AC-4 E2E + #1/#6 真源围栏守卫AI 对话聊天记录AC-1..AC-4 全交付**(计划 §6/§8。**E2E**`tests/test_ai_chat_history_e2e.py`,真 pg、**零 LLM 无网关**——纯 CRUD 侧记录,镜像 `test_t6_toolbox_e2e.py``e2e_sm` 生命周期)覆盖 §6 全 6 用例:① 缓冲多轮线程一批落库作者含糊→AI 反问〔选项进 meta→作者答→AI 定稿,同 `thread_id`/`seq` 0..3)→ GET newest-first`seq`/`role`/`thread_id`/`meta{options,version_no,segment}` 往复保真、全文 content 未截断);② 作用域分区(章级 项目级 `chapter_no IS NULL`——`?chapter_no=N` 两者都返回、缺省仅项目级、项目级现于任一章抽屉);③ 跨多批 newest-first + `limit/offset` 分页 + `kind` 过滤;④ **append 只写 `ai_messages`**UsageLedger/Chapter/ChapterReview/WorldEntity/Job 逐表零变化、`usage_ledger` 恒 0 证无网关、GET 只读不建行;⑤ 未知项目 POST/GET 皆 404、非法 role/kind·空 messages·超上限 content/meta 五类 422 且失败零落库;⑥ clarify-abandon 代理(服务端无部分写路径,只持久化被显式 POST 的已定线程)。**围栏**`tests/test_ai_messages_not_in_generation_path.py`AST 静态守卫,**能变红**memory/orchestrator 两目录 23 文件无一 import/引用 `AiMessage*`/`ai_message_repo`Name/Attribute/Import 全扫)+ `MemoryRepos` 捆绑与 `assemble` 形参都不含它 + 植入违规自证探测器非空——把 ai_message repo 接进 `assemble()` 会直接失败(已用真 assemble.py 源注入验证变红)。守 #1/#6:聊天记录是 append-only 旁路侧记录(同 `usage_ledger` 物种),绝不喂 prompt/记忆注入。**门禁全绿**ruff/format 干净 · mypy **238 文件** · **`alembic check` 无漂移** · pytest **1002 passed**992 基线 + 10E2E 6 + 围栏 4。gotcha 已登记ai_messages 真源围栏 = code-review BLOCKER。仅动 `tests/` + `memory/gotchas.md` + `PROGRESS.md`**未碰任何产品代码**。
- [2026-07-09] @frontend**✅ AC-3 gen:api + AI 对话 hook/抽屉 + 五类交互留痕**(计划 §4`dce882c`/`ddf7fa0`/`59f2f60`/`33a9b76`)。**gen:api**`dce882c``POST/GET /projects/{id}/ai-messages` + `AiMessage*` 4 schema 入 `schema.d.ts`openapi.json 为 gitignore 构建产物),`lib/api/types.ts` re-export 码生成名。**核心**`ddf7fa0``lib/workbench/aiConversation.ts` 纯视图模型(`kindLabel/kindBadge``bubbleSide``groupByThread``(created_at,seq)` 组内+线程定序〔**绝不单靠 created_at**——同批共享 created_at 靠 repo 的 seq 消歧〕、`partitionByScope`〔本章 项目级〕、`acceptActionFor`rewrite→替换整章 / continue·generator→插入正文 / refine→回填选段 / clarify→无+ 11 单测;`lib/workbench/useAiConversation.ts` Workbench 级单实例(懒加载去重 + `mergeById` 防并发覆盖 + 乐观插入/正位对账/失败回滚,**fail-soft 绝不 throw 进生成/HITL**〔守 #3 意图〕;`silent` 直投供无抽屉的工具箱页)+ 9 renderHook 测试;`components/workbench/AiConversationDrawer.tsx` 克隆 ContextDrawer 的 a11y`role=dialog/log``aria-live` additions、Esc、focus trap、还原焦点、prefers-reduced-motion本章对话 + 项目级生成两段气泡、clarify 只读选项芯片、ai 气泡挂 DRAFT-only 再接受。**hook 返回产物**`59f2f60`):四生成 hook 加返回值(`RefineVersion|null`/`string|null`/`RewriteVersion|null`/`GeneratorArtifact|null`,向后兼容,补成功用例断言)。**五处接线**`33a9b76`RefinePanel/ChapterRewritePanel 结束后 append `[author(意见/答复), ai(产出)]`**clarify buffer-until-concluded**——澄清 Q&A原含糊意见→AI 反问〔选项进 meta→作者答复缓冲随最终产出批一起落库同 `thread_id`**纯放弃不记**refine ai 行 meta 存 `version_no`+原选段每版重锚ContinuePanel ai-only续写无作者输入meta `candidate_index`GeneratorRunner append `[author(字段摘要,meta{tool_key,input_fields}), ai(渲染预览,meta{tool_key,output_kind})]`(空产物不记,`lib/toolbox/aiSummary.ts` 纯助手+5 单测InlineToolbox 挂本章号、ToolboxPage silent 项目级(`chapter_no=null`)Workbench 挂 hook 单实例 + 抽屉 + 底栏「AI 对话」入口(`AI_CONVERSATION_ENABLED` 灰度)+ 三个再接受回调(成功关抽屉+toast、refine 漂移不盲替换)。**留痕永不改「是否/何时写正文」**——正文仍只在验收事务落库(守 #3)。**门禁全绿**lint/typecheck 干净 · vitest **706 passed**681 基线 + 25aiConversation 11 + useAiConversation 9 + aiSummary 5· `test:coverage` 通过(`lib/**` 95.44% ≥ 80新纯模块/hook 全测)· `build` OK。→ 剩 **AC-4 @qa** E2E计划 §6+ P4 #1/#6 围栏。
- [2026-07-09] @backend**✅ AC-2 repo + append/list 端点 + schema + 契约**(计划 §3`c675a74`。repo `packages/core/ww_core/domain/ai_message_repo.py``AiMessageRepo`/`SqlAiMessageRepo`/`AiMessageView`/`AiTurnRow`,经 `ww_core.domain` 导出):`append`(一批多条 bubble → 批内 `seq`=enumerate 0 基位置、同 `thread_id`、meta 新 dict 不原地 mutate、`add_all`+一次 flush+refresh**只 flush 不 commit**) + `list_for_chapter`(`created_at DESC, seq DESC` newest-first`chapter_no` 非 None → 本章 项目级 `IS NULL`None → 仅项目级;可选 `kind``limit/offset`)。schemas `apps/api/ww_api/schemas/ai_messages.py`:批级 append 信封 `AiMessageAppendRequest{thread_id,chapter_no(ge=1)|null,kind,tool_key,messages}` + `AiMessageInput{role,content(min1,max200_000),meta(序列化≤50_000)}` + `AiMessageView` + `AiMessageListResponse{messages}``Literal` 为 kind/role 枚举权威(边界 422。端点 `apps/api/ww_api/routers/ai_messages.py`prefix `/projects`tag `ai-messages`,已注册 main**`POST /projects/{id}/ai-messages`**(201项目 404校验 422append 只 flush + 端点 `commit()`) + **`GET /projects/{id}/ai-messages?chapter_no=&kind=`+`PageDep`**(200newest-first只读)。**这是显式记录写入**——五个生成端点逐字节不变、只读(守 #3`ai_messages` 旁路侧记录,`assemble()`/agent 永不读(守 #1/#6)。注入缝 `get_ai_message_repo`。**TDD**repo 真 pg 单测 9 passedseq 0..n-1 / 同 thread_id / `created_at DESC,seq DESC` / union 含 NULL / 分页 / flush-only 跨 session 不可见);端点集成 12 passedround-trip / 本章∪项目级 / kind 过滤 / 404×2 / 422×5非法 role/kind、空 messages、空 content、超上限 content、超上限 meta / commit 边界 / 响应 key 集合不泄漏)。**门禁全绿**ruff/format 干净 · mypy **238 文件** · **`alembic check` 无漂移**(本任务不加模型)· `export_openapi` 成功(新端点+4 schema 已入表面)· pytest **992 passed**971 基线 + 21· 新模块 cov router 100%/schemas 100%/repo 97%(余 Protocol `...` 桩),全局 89%。契约 **C3 扩AC-2已登记「稳定」****@frontend AC-3 须 `cd apps/web && pnpm gen:api`** 后消费码生成名。
- [2026-07-09] @db**✅ AC-1 `ai_messages` 表 + 迁移**(计划 §2 canonical DDL`packages/db/ww_db/models.py``AiMessage(UuidPk, CreatedAt, Base)`(补 `Uuid` 导入):`project_id`(FK projects CASCADE, NOT NULL, indexed)/`chapter_no`(Integer nullable=项目级)/`thread_id`(Uuid NOT NULL)/`seq`(Integer NOT NULL, repo 批内 0 基定序)/`kind`(Text 无 CHECK)/`tool_key`(Text nullable)/`role`(Text + `ck_ai_messages_role` CHECK author|ai)/`content`(Text NOT NULL 全文)/`meta`(JSONB NOT NULL server_default `'{}'`)/`id`+`created_at`(mixin)append-only 无 updated_at。复合索引 `ix_ai_messages_project_chapter=(project_id,chapter_no,created_at DESC)` 手写 `sa.text` DESC + FK 单列索引。迁移 `ceabd3c2c00d`down=`f6a7b8c9d0e1`**autogenerate 仅捕获 ai_messages 无杂散漂移**DESC 表达式归一为 `sa.text`)。**T0.2 MVP schema 后第一张新业务表**——对话历史是 18 表都无的能力、正交于 review/job/手稿,属真新增(非重复建表);是**旁路侧记录非手稿真源**`assemble()`/agent 永不读(守 #1/#3/#6,围栏留 AC-4/P4 收口)。**门禁全绿**ruff/format 干净 · mypy **233 文件** · `alembic upgrade head` + **`alembic check` 无漂移** + **downgrade→upgrade 往复干净** · pytest **971 passed**(基线不变,无代码用新表)。契约 **C2 扩AC-1已登记「稳定」** → AC-2 @backend 可动工。
- [2026-07-08] @devops**✅ QA-CR CR-D1 抬高后端依赖下界至 lockfile 主次版本(保可复现)**。8 个 pyproject`apps/api` + `packages/{core,agents,config,db,llm_gateway,shared,skills}`)的后端运行时依赖下界从过旧值抬到实锁 MAJOR.MINOR`langgraph>=1.2`/`langgraph-checkpoint-postgres>=3.1`/`openai>=2.43`/`anthropic>=0.111`/`google-genai>=2.9`/`instructor>=1.15`/`tenacity>=9.1`/`pydantic>=2.13`/`pydantic-settings>=2.14`/`structlog>=26.1`/`asyncpg>=0.31`/`alembic>=1.18`/`fastapi>=0.137`/`uvicorn>=0.49`/`cryptography>=49`(每条 ≤ lockfile 实锁,同小版本线的 sqlalchemy/psycopg 留原样)。`uv.lock` 变动为**纯元数据刷新**——仅各 workspace 成员 `[package.metadata].requires-dist` 的 specifier 字符串随下界更新,**零 pinned `version=` 变动、零 re-resolve、零第三方版本 bump**。root dev 工具下界pytest/ruff/mypy/httpx+ 前端 vitest 3.x/tailwind 4.x 升级评估**明确不纳入**(非运行时可复现关键,超范围)。**门禁**`uv lock --check` 绿(元数据刷新后一致)· `uv run ruff check .` 干净 · pytest **971 passed**(与基线一致,无回归)。无源码/测试改动。
- [2026-07-08] @llm**✅ QA-CR MEDIUM/LOW 网关+链整改批CR-M2/M3/L1@llm/M1.4TDD 逐项提交)**。**完成 6**`32451a9` pricing 未知 model 发 `pricing_unknown_model` warning成本仍 0`9439b97` 三 adapter 瞬时错误消息经 `provider_error_summary(exc)` 只留类名+status不泄 vendor 响应体);`832ab7d` `OpenAICompatAdapter.probe_connection()` 公开探测 + provider_deps 弃私有 `_client` 反手CR-M1.4`c9ffada` 续写链前一章无已验收终稿发 `chain_prior_accepted_missing` warningCR-L1`ec91c8f` 链 runner 改用公开 `graph.aget_state(config).interrupts` 判暂停(弃私有 `__interrupt__``6a739ca` review 节点五审 `asyncio.gather` 并行扇出(复用主图 add-only 记账并发范式 T3.8collect 一次落库)。**SKIP 2**CR-M2 `is_open()` CQS惰性半开转移是唯一转半开点、拆分漏发命令会致熔断永不复位风险>收益CR-M3 `run_overdue_scan` 幂等(`scan_overdue` 本已是原子 `UPDATE...RETURNING`+`NOT IN[CLOSED,OVERDUE]`+commit-only-if-changed且下一章 current+1 扫描自愈漏标,再动会触碰验收事务边界)。**门禁全绿**:后端 ruff/format 干净 · mypy **232 文件** · pytest **971 passed**+11 新测pricing 2 / 错误脱敏 4 / probe 2 / 续写缺前文告警 2 / 并行审 1· 无 DDL/无迁移。守不变量 #1/#3/#4(熔断/验收事务/add-only 记账 sink 未动)。
- [2026-07-08] @llm/@backend/@devops/@frontend**✅ QA-CR CRITICAL + HIGH 整改批全部落地**14 项 + 4 低危收尾)。多 agent 工作流:先 14 个只读 agent 逐项**扎根真实代码**产精确修复+测试规格(纠出 3 处台账前提漂移),再按 owner 四簇**串行整合**入单工作树(避免并发写树 + repo 级 `ruff format .` 竞态),各 TDD 红→绿、逐项补回归测试、逐 fix 小步提交,末尾 14 个 skeptic **对抗式复核**(实证每条 test 非 vacuous0 problem。**关键落地/纠偏**C1 next 15.1.3→**15.5.20**+react **19.2.7**`pnpm audit` 清、`dependencyFloors.ts` 下界守卫,**CVE 号无法对真库核验+密钥轮换为用户运维事项**C2 **前提证伪**langgraph 默认上限 10007 非 25无实活崩溃仍加显式顶层 `recursion_limit`H4 request_id **条件透传**(否则覆盖 contextvars 反退化H2 修请求级 httpx 泄漏时**揪出 E2E 会误触真网**override 跟随修H9 全文本字段上限+2 MiB body 中间件 413 兜底(契约 C3`gen:api` 无漂移H8 `.env.example` 换不可用占位符(**真 key 仍在 git 历史,曾用环境须轮换**)。**门禁全绿**:后端 mypy 229/pytest **934**/alembic 无漂移;前端 vitest **669**/build/coverage 95.41%。契约 C1-扩LlmRequest.request_id+ C3H9 caps。**MEDIUM/LOW 择批后清理。** 提交 `cb8fddf``dd6d2fc`19 提交)。
- [2026-07-08] @backend**✅ QA-CR 后端整改 5 项TDD逐 fix 提交)**。**CR-C2**`chain_runner.run_chain_job``graph.ainvoke` 传显式 top-level `recursion_limit=count*NODES_PER_CHAPTER+HEADROOM`(防御硬化,命名常量)。**CR-H1**Kimi OAuth 设备轮询移出 DB 会话——新增 `_poll_for_token`(纯 HTTP)/`_make_persist_work`/`_run_kimi_oauth_poll`,仅最终持久化经 `run_job` 开短会话(防连接池饥饿;删 `_make_poll_work`)。**CR-H2**:请求级 httpx 客户端改**生成器依赖** `_http_client``async with ... yield`,请求结束 aclose 修泄漏);`_default_http_client` 保留供后台裸调E2E + 端点测试 override 键跟随改 `_http_client`。**CR-H7**:冻结 `TOOLBOX` 为只读 `MappingProxyType``_TOOLBOX_BY_KEY` 私有底 dict + `Final[Mapping]`,仿 SPECS。**CR-H9**:用户请求字符串字段补 `max_length`providers/generation/projects/style/foreshadow/rules/templates命名常量无魔数样本列表长度+每条双上界)+ 新错误码 `ErrorCode.PAYLOAD_TOO_LARGE`(413) + 应用级 `body_size_limit_middleware`(2 MiB按 Content-Length 拦截自建 413 信封)`main.py` 注册。**门禁全绿**ruff/format 干净 · mypy **229 文件**0 错)· pytest **928**+23recursion_limit 1 / poll-no-session 1 / http-gen 1 / toolbox-readonly 1 / input-size 19[17 参数化 + samples 1 + body413 1])· **无 DDL/无迁移**。记 contracts C3CR-H9新增 `maxLength` + `PAYLOAD_TOO_LARGE`)。→ **@frontend`pnpm gen:api`**(请求字段现有 `maxLength`;本 cluster 未代跑)。守不变量 #3/#8/#9
- [2026-07-06] @backend/@llm/@frontend**✅ 灵感⑦ 群像按定位配比·缩减版 + §0 归属纠偏develop 直提)**。角色群像生成支持「按定位配比」下发。**契约变更** `CharacterGenerateRequest``role_mix:dict[str,int]|None`——`model_validator(mode="after")` 定义与既有 `count` 的关系在场时校验非空、每项≥1、总和≤`MAX_GENERATE_COUNT`=12并把 `count` 归一为各值之和(构造期归一化,忽略客户端 count缺省退回单一 `role`+`count`。**§0 归属纠偏**`build_character_gen_context`+`run_character_gen``ww_core/orchestrator/generation_node.py`)确为 **@llm 域**——加末位默认 `role_mix=None` 关键字参,在场时注入「角色定位配比」块(保插入序、确定性、无时间戳,守 #6/#7 assemble 纯函数;覆盖单一 role 行);`routers/generation.py` 穿 `role_mix=body.role_mix``character-gen.md` 加「按配比严格分配 role、各定位之和=总数」纪律 → **regen `prompt_hashes.json`**(仅 character-gen 哈希变,**不进 SPECS→不 bump 计数**)。**前端** `cards.ts``sanitizeRoleMix`(去空/去重/clamp≥1/按 12 截断)+`roleMixTotal`+`buildCharacterGenerateRequest` 第 4 参 roleMix`useCharacterGen` `CharacterGenInput.roleMix` 穿参;新 `RoleMixEditor.tsx` + `CharacterGenerator` SegmentedControl 单一/配比双模。**TDD**schema 校验 7 例总和≤12/空 dict/非正值拒绝)+ context 注入 2 例 + run 穿参 1 例 + 前端 cards 构造/clamp 5 例 + hook renderHook 1 例。**门禁全绿**:后端 ruff/format 干净 · mypy **222** · pytest **858**+11· 无迁移(未动模型,未跑 alembic前端 gen:api/lint/typecheck 干净 · vitest **543**+6· coverage 95%cards.ts 100%)· build OK。记 contracts C3。守不变量 #2/#6/#7/#9。**无 DB 迁移**。
- [2026-07-06] @llm/@db/@backend/@frontend**✅ 灵感③ 人物塑造 advisory 审develop 直提)**。写→审链新增第五审「人物塑造」:**advisory 单维——仅建议、不阻断验收**(不进 conflicts、不碰 `assert_conflicts_resolved`、**不入 `REVIEW_RESERVED_NAMES`**D2只做人物塑造、不做氛围氛围噪声大折进 style/pace。**新 SPEC** `characterization_spec``tier="analyst"` 不写 model`reads=("characters",)` 以人物卡 motive/appearance 为客观锚点,`writes=()` 只读,`prompts/characterization.md` **显式忽略文本长度与辞藻华丽度**、引用逐字片段+置信度对抗 LLM-judge 冗长/辞藻偏见)+ schema `CharacterizationReview{issues:[…character/aspect/where/quote/diagnosis/suggestion/confidence]}`(全字段默认值守解析韧性)+ 注册 `SCHEMA_CATALOG["characterization"]`**计数三处 22→23**`specs.py` assert / `test_prompt_loader.py` len / `schema_catalog.py` docstring+ **regen `prompt_hashes.json`**(仅加一条,无其余漂移)。**DB** `chapter_reviews` 加 1 列 `characterization`**JSONB nullable**,迁移 `f6a7b8c9d0e1`nullable 免 backfill。**契约变更** `ReviewHistoryItem`+`ReviewView``characterization`;接线链 `graph.py REVIEW_SPECS` 末位追加 + `chain/nodes.py ReviewRecordRepo` + `collect.py {CHARACTERIZATION/extract_characterization/ReviewRecorder/collect_reviews}` + `review_repo.py {Protocol/SqlReviewRepo/_to_view}` + `sse.py {EVENT_CHARACTERIZATION/characterization_event/_section_result_events}`normalize 按 sorted 键自动纳入,字典序排最前不打乱既有四审断言)。**前端** `lib/review/{sse.ts,history.ts,useReviewStream.ts}` + 新 `CharacterizationPanel.tsx`advisory 只展示、**无裁决 UI**、按置信度降序 + 低置信度折叠控注意力预算)+ `ReviewReport.tsx` 挂面板 + 双 seed。**门禁全绿**:后端 ruff/format 干净 · mypy **221** · pytest **848**+核心 char 审 12 + agents spec 11 + api accept 2 等)· alembic 无漂移;前端 gen:api/lint/typecheck 干净 · vitest **536**+10 含 `test_characterization_does_not_block_accept` 对应前端 reducer/normalize/parse· coverage 95% · build OK。记 contracts C3+C4。守不变量 #2/#3/#9。依赖 ⑧motive/appearance 已入 CharacterCard 契约,作客观锚点)。
@@ -245,3 +306,4 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移无漂移users st
- [2026-06-28] @backend/@frontend**UI Phase G 后端依赖补齐**`ProjectResponse` 增加 `updated_at` / `pending_review_count``chapters``updated_at` 迁移 `8c1d2e3f4a5b``SqlProjectRepo` 聚合项目自身、章节、审稿时间作为最近编辑,并按草稿是否晚于最近 accepted/review 统计待审稿章数。前端 `pnpm gen:api` 后项目库默认按最近编辑排序,新增“待审稿”筛选,作品卡显示最近编辑与待审稿徽标。契约记 `memory/contracts.md` C3 扩UI 计划记 `docs/design/ui-improvement-plan.md` 0.16。
- [2026-06-29] @frontend/@qa**UI review follow-up 修复完成**:补 `lib/review/chapterNav.ts` 使新增章导航测试可运行;`ReviewSectionPanel` 改为本地 open 状态,避免用户折叠后重渲染强制弹开;`ReviewReport.hasReport` 纳入 clean review新增 `tests/test_project_metadata_e2e.py` 用真实 DB 覆盖项目最近编辑/待审稿统计口径。验证:前端 lint/typecheck/vitest **490 passed**/build后端 ruff/format/mypy、pytest **763 passed**;浏览器抽样 review/settings 无 console error/横向溢出。
- [2026-07-06] @frontend**✅ 灵感⑥ 关系图谱(缩减版)+ D4 完成**(纯前端,无后端契约/迁移/gen:api。新增 `lib/characters/graphLayout.ts`(纯逻辑:`roleStyle` role→分组配色关键词映射、`buildGraphNodes`/`buildGraphEdges`(kind→标签`DEFAULT_RELATION_LABEL` 命名常量)、`countDanglingRelations``layoutGraph`(@dagrejs/dagre 一次性确定性初始坐标、中心→左上、不可变)、`buildRelationshipGraph` 端到端)——**仅用现有 `CharacterCardView.relations={name,kind,note}`**:边按 name-join、kind 作中文标签、节点按 role 上色,无 closeness/阵营(数据不存在,不硬造);悬空边(引用未入库角色)跳过并计数。新增 `components/characters/RelationshipGraph.tsx``'use client'`React Flow @xyflow/reactCodexPage 经 `dynamic(ssr:false)` 挂载于人物 tab显式容器高度 h-[28rem] + `import '@xyflow/react/dist/style.css'` + prefers-reduced-motion 关 fitView 动画 + 纸感 CSS 变量配色 + 悬空计数提示)。依赖:`@xyflow/react@^12.11.2`+`@dagrejs/dagre@^3.0.0`(见 contracts.md 2026-07-06。TDDgraphLayout **+22 测**role 映射/节点去空重名/kind 标签+默认/悬空跳过/自指跳过/无向去重/边 id 唯一/悬空计数/布局确定性+不可变/端到端);组件走人工/E2E。前端门禁全绿lint 干净 · typecheck 干净 · **vitest 565 passed** · **coverage lib 95.4%graphLayout.ts 97.6%** · **build OK**codex 路由 7.58 kBReact Flow 懒加载独立 chunk。注`pnpm build` 门禁与运行中的 `next dev`(3001) 共享 `.next` → 触发 dev 服务临时 500stale chunk非代码缺陷、dev watcher 下次重编自愈,未重启服务。
- [2026-07-08] @frontend**CR-M4/M5/L1 前端评审收尾MEDIUM/LOW 打磨,仅 apps/web无后端契约/gen:api**。DONE①a11y——`NavDrawer` 汉堡按钮补 `aria-controls`(新增 `Drawer` 可选 `id` prop 指向面板,默认 `nav-drawer`)、`ForeshadowCard``<dl>``<div>` 行改真 `<dt>/<dd>` 结构可视文案不变importance 用 sr-only 标签);②`ReviewReport.flashMark``setTimeout` 句柄入 ref + 卸载 useEffect 清理(防定时器泄漏,提 `FLASH_DURATION_MS` 常量);③`useAccept` 手写 `ApiErrorEnvelope` 改由生成 schema 派生 `components["schemas"]["ErrorEnvelope"]`(端点专属 details 就地收窄,行为不变);④`applyFix.hasApplicableFix` 重写为对象入参**类型谓词** `patch is ApplicablePatch`,消除 `applyConflictFix` 里两处 `as string`(含收窄单测);⑤`server.parseJsonBody` 已具边界校验,补两条守卫分支测试(非对象体/非 JSON 体);⑥去冗余 cast`stream/sse.ts` `idx.valueOf()``idx``jobs/job.ts` `dict["error"] as string`typeof 守卫后 TS 自动收窄。SKIP/NOOP前提已过时/本就安全):`ProjectWizard` 换步聚焦标题已实现;`AppShell`/`LeftNav` 两处 `<nav>` 均已带 `aria-label``KNOWN_EVENTS``stop`——`stop` 是客户端 abort 动作非服务端 SSE 帧(对照 contracts.md 草稿 token/done/error、续审 section/conflict/foreshadow/pace/style/characterization/done/error 均无 stop加入会为不存在的事件建处理`CharacterGenerator`/`GeneratorRunner`/`ForeshadowCard.progress` 的 index-key——三处均为整表替换/追加式/静态展示列表(无逐项重排或删除,`CharacterCardItem`/`PreviewRow` 为无内部状态受控组件,选择/编辑均按 index 且随新预览整体重置index 作 key 安全且无天然稳定 id加合成 UUID 需重做 index 选择/编辑态属过度改造。门禁全绿lint 干净 · typecheck 干净 · **vitest 681 passed**+3· **coverage lib 95.45%**exit 0· **build OK**

View File

@@ -43,6 +43,8 @@
前置Python 3.12+ · Node 22 · [uv](https://docs.astral.sh/uv/) · pnpm · Docker
> **本地端口(宿主已 +1000防与其他项目冲突**:前端 **http://localhost:4000** · 后端 **http://localhost:9000**API 文档 `/docs`)· Postgres **localhost:6432**。容器内部端口不变3000 / 8000 / 5432只改宿主发布端口要改回默认各减 1000 即可。接线:前端 `apps/web/.env.local` 的 `NEXT_PUBLIC_API_BASE=http://localhost:9000` 指向后端;后端 `.env` 的 `CORS_ORIGINS` 放行 `http://localhost:4000`、`DATABASE_URL` 指向 `localhost:6432`。
```bash
# 1. 克隆后安装后端依赖仓库根uv workspace
uv sync
@@ -59,17 +61,17 @@ cp .env.example .env
docker compose up -d pg
uv run alembic upgrade head
# 5. 起后端 API默认 http://localhost:8000文档 /docs
uv run uvicorn ww_api.main:app --reload
# 5. 起后端 APIhttp://localhost:9000文档 /docs— 宿主端口 +1000
uv run uvicorn ww_api.main:app --port 9000 --reload
# 6. 起前端(另开终端,默认 http://localhost:3000
# 6. 起前端另开终端http://localhost:4000— pnpm dev 已内置 -p 4000
cd apps/web && pnpm dev
```
或一键起全套pg + api + web
或一键起全套pg + api + web,宿主端口均已 +1000
```bash
docker compose up
docker compose up # web → :4000 · api → :9000 · pg → :6432
```
> **LLM API Key 不放 `.env`**:在应用设置页 **`/settings/providers`** 录入,密文存于数据库凭据库(用 `CREDENTIAL_ENC_KEY` 加密)。`.env` 里只有那把加密主密钥本身。

View File

@@ -12,4 +12,6 @@ EXPOSE 8000
# 存活探测:镜像无 curl用 stdlib urllib 打 /health非 200/连接失败会抛异常,退出非零)。
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health').read()"]
CMD ["uv", "run", "uvicorn", "ww_api.main:app", "--host", "0.0.0.0", "--port", "8000"]
# --no-sync用构建期已烤好的 --no-dev venv 直接起,禁止启动时重新同步(否则 `uv run` 会在
# 运行时按默认组重装依赖、连 ruff/mypy 等 dev 工具都现场下载,慢且需运行时联网、每次启动重来)。
CMD ["uv", "run", "--no-sync", "uvicorn", "ww_api.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

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

View File

@@ -63,8 +63,13 @@ class FakeProjectRepo:
self.rows[pid] = (owner_id, view)
return view
async def list_for_owner(self, owner_id: uuid.UUID) -> list[ProjectView]:
return [v for (o, v) in self.rows.values() if o == owner_id]
async def list_for_owner(
self, owner_id: uuid.UUID, *, limit: int | None = None, offset: int = 0
) -> list[ProjectView]:
views = [v for (o, v) in self.rows.values() if o == owner_id]
if limit is None:
return views
return views[offset : offset + limit]
async def get(self, owner_id: uuid.UUID, project_id: uuid.UUID) -> ProjectView | None:
entry = self.rows.get(project_id)
@@ -358,8 +363,14 @@ class FakeOutlineReadRepo:
async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None:
return self.rows.get((project_id, chapter_no))
async def list_for_project(self, project_id: uuid.UUID) -> list[OutlineView]:
views = [v for (p, _), v in self.rows.items() if p == project_id]
async def list_for_project(
self, project_id: uuid.UUID, *, volume: int | None = None
) -> list[OutlineView]:
views = [
v
for (p, _), v in self.rows.items()
if p == project_id and (volume is None or v.volume == volume)
]
return sorted(views, key=lambda v: v.chapter_no)

View File

@@ -0,0 +1,325 @@
"""AC-2 POST/GET /projects/{id}/ai-messages 端点(内存替身,无 DB/无网络)。
docs/design/ai-chat-history-plan.md §3.3 / §6。覆盖
- POST 201 + 回显持久化视图seq/id/created_at+ 端点 commit 边界;
- append→list 回环;项目级(chapter_no null) vs 本章 union
- 校验 → 422非法 role、超上限 content、空 messages、超大 meta
- 未知项目 → 404响应 key 集合不泄漏意外字段。
owner_id 全程 stub单用户原型。AI 一律不触达(纯 CRUD
"""
from __future__ import annotations
import os
import uuid
from datetime import UTC, datetime, timedelta
import httpx
import pytest
from cryptography.fernet import Fernet
from fakes_projects import FakeProjectRepo, FakeSession
from ww_core.domain.ai_message_repo import AiMessageView, AiTurnRow
from ww_core.domain.project_repo import ProjectView
# 期望的响应 bubble 字段集合(防泄漏意外字段)。
_EXPECTED_KEYS = {
"id",
"project_id",
"chapter_no",
"thread_id",
"seq",
"kind",
"tool_key",
"role",
"content",
"meta",
"created_at",
}
class _FakeAiMessageRepo:
"""镜像 `SqlAiMessageRepo` 语义的内存版(批内 seq、newest-first union"""
def __init__(self) -> None:
self.rows: list[AiMessageView] = []
self._clock = 0
async def append(
self,
project_id: uuid.UUID,
*,
thread_id: uuid.UUID,
chapter_no: int | None,
kind: str,
tool_key: str | None,
rows: list[AiTurnRow],
) -> list[AiMessageView]:
self._clock += 1
created = datetime(2026, 7, 9, tzinfo=UTC) + timedelta(seconds=self._clock)
out: list[AiMessageView] = []
for i, r in enumerate(rows):
view = AiMessageView(
id=uuid.uuid4(),
project_id=project_id,
chapter_no=chapter_no,
thread_id=thread_id,
seq=i,
kind=kind,
tool_key=tool_key,
role=r.role,
content=r.content,
meta=dict(r.meta),
created_at=created,
)
self.rows.append(view)
out.append(view)
return out
async def list_for_chapter(
self,
project_id: uuid.UUID,
chapter_no: int | None,
*,
kind: str | None = None,
limit: int,
offset: int,
) -> list[AiMessageView]:
matched = [r for r in self.rows if r.project_id == project_id]
if chapter_no is not None:
matched = [r for r in matched if r.chapter_no in (chapter_no, None)]
else:
matched = [r for r in matched if r.chapter_no is None]
if kind is not None:
matched = [r for r in matched if r.kind == kind]
matched.sort(key=lambda r: (r.created_at, r.seq), reverse=True)
return matched[offset : offset + limit]
def _make_client() -> tuple[httpx.AsyncClient, _FakeAiMessageRepo, FakeProjectRepo, FakeSession]:
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
from ww_api.main import create_app
from ww_api.services.credentials import STUB_OWNER_ID
from ww_api.services.project_deps import get_ai_message_repo, get_project_repo
from ww_db import get_session
repo = _FakeAiMessageRepo()
project_repo = FakeProjectRepo()
session = FakeSession()
# 一个已存在的项目owner = stub供 404 对照——直接种入 fake 的行表(避开事件循环)。
project_id = uuid.uuid4()
project_repo.rows[project_id] = (STUB_OWNER_ID, ProjectView(id=project_id, title="AC-2"))
app = create_app()
app.dependency_overrides[get_ai_message_repo] = lambda: repo
app.dependency_overrides[get_project_repo] = lambda: project_repo
app.dependency_overrides[get_session] = lambda: session
transport = httpx.ASGITransport(app=app)
client = httpx.AsyncClient(transport=transport, base_url="http://test")
client.project_id = project_id # type: ignore[attr-defined]
return client, repo, project_repo, session
def _refine_batch(chapter_no: int | None = 5) -> dict[str, object]:
return {
"thread_id": str(uuid.uuid4()),
"chapter_no": chapter_no,
"kind": "refine",
"messages": [
{"role": "author", "content": "原选段"},
{"role": "ai", "content": "重写段", "meta": {"version_no": 1, "segment": "原选段"}},
],
}
@pytest.mark.asyncio
async def test_append_returns_201_persisted_views_and_commits() -> None:
client, repo, _pr, session = _make_client()
pid = client.project_id # type: ignore[attr-defined]
async with client:
resp = await client.post(f"/projects/{pid}/ai-messages", json=_refine_batch(5))
assert resp.status_code == 201, resp.text
body = resp.json()
msgs = body["messages"]
assert [m["seq"] for m in msgs] == [0, 1]
assert [m["role"] for m in msgs] == ["author", "ai"]
assert msgs[1]["meta"] == {"version_no": 1, "segment": "原选段"}
assert all(set(m.keys()) == _EXPECTED_KEYS for m in msgs)
assert all(m["id"] and m["created_at"] for m in msgs)
assert session.commits == 1
assert len(repo.rows) == 2
@pytest.mark.asyncio
async def test_append_then_list_roundtrip() -> None:
client, _repo, _pr, _session = _make_client()
pid = client.project_id # type: ignore[attr-defined]
async with client:
await client.post(f"/projects/{pid}/ai-messages", json=_refine_batch(5))
resp = await client.get(f"/projects/{pid}/ai-messages", params={"chapter_no": 5})
assert resp.status_code == 200
msgs = resp.json()["messages"]
# newest-first批内 seq DESC → ai(seq1) 先于 author(seq0)。
assert [m["content"] for m in msgs] == ["重写段", "原选段"]
@pytest.mark.asyncio
async def test_list_chapter_union_includes_project_level() -> None:
client, _repo, _pr, _session = _make_client()
pid = client.project_id # type: ignore[attr-defined]
async with client:
await client.post(f"/projects/{pid}/ai-messages", json=_refine_batch(5))
# 项目级工具箱生成chapter_no 省略 → NULL
await client.post(
f"/projects/{pid}/ai-messages",
json={
"thread_id": str(uuid.uuid4()),
"kind": "generator",
"tool_key": "expand",
"messages": [{"role": "ai", "content": "扩写预览"}],
},
)
chapter_view = await client.get(f"/projects/{pid}/ai-messages", params={"chapter_no": 5})
toolbox_view = await client.get(f"/projects/{pid}/ai-messages")
chapter_contents = {m["content"] for m in chapter_view.json()["messages"]}
toolbox_contents = {m["content"] for m in toolbox_view.json()["messages"]}
assert chapter_contents == {"原选段", "重写段", "扩写预览"} # 本章 项目级
assert toolbox_contents == {"扩写预览"} # 仅项目级
@pytest.mark.asyncio
async def test_list_kind_filter() -> None:
client, _repo, _pr, _session = _make_client()
pid = client.project_id # type: ignore[attr-defined]
async with client:
await client.post(f"/projects/{pid}/ai-messages", json=_refine_batch(2))
await client.post(
f"/projects/{pid}/ai-messages",
json={
"thread_id": str(uuid.uuid4()),
"chapter_no": 2,
"kind": "rewrite",
"messages": [{"role": "ai", "content": "整章新版"}],
},
)
resp = await client.get(
f"/projects/{pid}/ai-messages", params={"chapter_no": 2, "kind": "rewrite"}
)
msgs = resp.json()["messages"]
assert [m["kind"] for m in msgs] == ["rewrite"]
assert [m["content"] for m in msgs] == ["整章新版"]
@pytest.mark.asyncio
async def test_append_unknown_project_returns_404() -> None:
client, repo, _pr, session = _make_client()
async with client:
resp = await client.post(f"/projects/{uuid.uuid4()}/ai-messages", json=_refine_batch(5))
assert resp.status_code == 404
assert resp.json()["error"]["code"] == "NOT_FOUND"
assert session.commits == 0
assert repo.rows == []
@pytest.mark.asyncio
async def test_list_unknown_project_returns_404() -> None:
client, _repo, _pr, _session = _make_client()
async with client:
resp = await client.get(f"/projects/{uuid.uuid4()}/ai-messages", params={"chapter_no": 1})
assert resp.status_code == 404
assert resp.json()["error"]["code"] == "NOT_FOUND"
@pytest.mark.asyncio
async def test_append_invalid_role_returns_422() -> None:
client, _repo, _pr, session = _make_client()
pid = client.project_id # type: ignore[attr-defined]
async with client:
resp = await client.post(
f"/projects/{pid}/ai-messages",
json={
"thread_id": str(uuid.uuid4()),
"chapter_no": 5,
"kind": "refine",
"messages": [{"role": "system", "content": "x"}],
},
)
assert resp.status_code == 422
assert session.commits == 0
@pytest.mark.asyncio
async def test_append_invalid_kind_returns_422() -> None:
client, _repo, _pr, _session = _make_client()
pid = client.project_id # type: ignore[attr-defined]
async with client:
resp = await client.post(
f"/projects/{pid}/ai-messages",
json={
"thread_id": str(uuid.uuid4()),
"kind": "translate", # 非五类枚举 → 422
"messages": [{"role": "ai", "content": "x"}],
},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_append_empty_messages_returns_422() -> None:
client, _repo, _pr, _session = _make_client()
pid = client.project_id # type: ignore[attr-defined]
async with client:
resp = await client.post(
f"/projects/{pid}/ai-messages",
json={"thread_id": str(uuid.uuid4()), "kind": "refine", "messages": []},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_append_empty_content_returns_422() -> None:
client, _repo, _pr, _session = _make_client()
pid = client.project_id # type: ignore[attr-defined]
async with client:
resp = await client.post(
f"/projects/{pid}/ai-messages",
json={
"thread_id": str(uuid.uuid4()),
"kind": "refine",
"messages": [{"role": "ai", "content": ""}],
},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_append_content_over_cap_returns_422() -> None:
client, _repo, _pr, _session = _make_client()
pid = client.project_id # type: ignore[attr-defined]
async with client:
resp = await client.post(
f"/projects/{pid}/ai-messages",
json={
"thread_id": str(uuid.uuid4()),
"kind": "rewrite",
"messages": [{"role": "ai", "content": "x" * 200_001}],
},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_append_oversize_meta_returns_422() -> None:
client, _repo, _pr, _session = _make_client()
pid = client.project_id # type: ignore[attr-defined]
async with client:
resp = await client.post(
f"/projects/{pid}/ai-messages",
json={
"thread_id": str(uuid.uuid4()),
"kind": "refine",
"messages": [{"role": "ai", "content": "x", "meta": {"blob": "y" * 60_000}}],
},
)
assert resp.status_code == 422

View File

@@ -572,6 +572,66 @@ async def test_run_chain_job_no_conflict_completes_done(_patched_runner: None) -
assert [a["chapter_no"] for a in fakes["accepted"]] == [1, 2]
async def test_run_chain_job_sets_recursion_limit_scaled_to_count(
_patched_runner: None, monkeypatch: pytest.MonkeyPatch
) -> None:
"""CR-C2run_chain_job 给 graph.ainvoke 传显式 top-level recursion_limit防御硬化
langgraph 默认上界已足够(无实时崩溃),但每链上界应显式 = count*NODES_PER_CHAPTER +
HEADROOM。断言 config 键值 + 仍正常跑完(行为不变)。用 PROXY 包裹真 graph 记录 config
(不 setattr 到可能冻结的 CompiledStateGraph 上)。
"""
from ww_core.orchestrator.chain import build_chain_graph as real_build
captured: dict[str, Any] = {}
class _SpyGraph:
def __init__(self, inner: Any) -> None:
self._inner = inner
async def ainvoke(self, inp: Any, *, config: Any = None, **kw: Any) -> Any:
captured["config"] = config
return await self._inner.ainvoke(inp, config=config, **kw)
async def aget_state(self, config: Any, **kw: Any) -> Any:
# runner 现经公开 aget_state 判 interruptCR-M3——代理透传给真 graph。
return await self._inner.aget_state(config, **kw)
def _spy_build(*args: Any, **kwargs: Any) -> Any:
return _SpyGraph(real_build(*args, **kwargs))
monkeypatch.setattr("ww_api.services.chain_runner.build_chain_graph", _spy_build)
fakes = _chain_runner_fakes()
gateway = _FakeChainGateway(conflicts=[])
job_id = uuid.uuid4()
saver = MemorySaver()
await run_chain_job(
_session_factory,
job_id,
project_id=uuid.uuid4(),
user_id=USER,
chain_key="draft_volume",
start_chapter_no=1,
count=7,
chain_gateway_builder=_async_returning(gateway),
checkpointer_ctx=lambda: _memsaver_ctx(saver),
accept_op=fakes["accept_op"],
chapter_repo_factory=fakes["chapter_repo_factory"],
review_repo_factory=fakes["review_repo_factory"],
)
# (1) 显式上界 = count*NODES_PER_CHAPTER + HEADROOM = 7*4+10 = 38。
assert captured["config"]["recursion_limit"] == 7 * 4 + 10 == 38
# (2) recursion_limit 是 top-level 键(不在 configurable 下——langgraph 只认 top-level
assert "recursion_limit" not in captured["config"]["configurable"]
# (3) 仍正常跑完(行为不变)。
rec = _RecordingJobRepo.state[job_id]
assert rec["status"] == STATUS_DONE
assert rec["result"]["written"] == list(range(1, 8))
async def test_run_chain_job_conflict_sets_awaiting_then_resume_done(
_patched_runner: None,
) -> None:

View File

@@ -28,7 +28,7 @@ from ww_agents import (
WorldEntityCard,
WorldGenResult,
)
from ww_core.domain.character_repo import CharacterWriteView
from ww_core.domain.character_repo import CharacterWriteFields, CharacterWriteView
from ww_core.domain.project_repo import ProjectCreate
from ww_core.domain.rule_repo import RuleListItemView
from ww_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage
@@ -120,6 +120,27 @@ class _FakeCharacterWriteRepo:
)
return CharacterWriteView(id=row_id, name=name, role=role)
async def create_many(
self, project_id: uuid.UUID, cards: list[CharacterWriteFields]
) -> list[CharacterWriteView]:
# 镜像 Sql逐卡 upsert含批内重名去重保序返回逐卡 View。
return [
await self.create(
project_id,
name=c.name,
role=c.role,
traits=list(c.traits),
appearance=c.appearance,
motive=c.motive,
backstory=c.backstory,
arc=c.arc,
speech_tics=list(c.speech_tics),
tags=list(c.tags),
relations=[dict(r) for r in c.relations],
)
for c in cards
]
class _FakeRulesReadRepo:
"""实现规则 repo 的读侧 `list_for_project`(带 id供 list_rules 端点)。"""
@@ -352,6 +373,39 @@ async def test_ingest_characters_no_conflict_writes_and_201() -> None:
assert session.commits == 1
@pytest.mark.asyncio
async def test_ingest_multiple_characters_all_land_in_one_batch() -> None:
# 批量入库:多张卡一次 create_many 全部落库created 保序,单次 commit。
repo = FakeProjectRepo()
pid = await _seed_project(repo)
gateway = _SchemaRoutingGateway({ContinuityReview: _no_conflicts()})
char_repo = _FakeCharacterWriteRepo()
app, session = _make_app(project_repo=repo, gateway=gateway, char_repo=char_repo)
def _card(name: str) -> dict[str, Any]:
return {
"name": name,
"role": "配角",
"traits": [],
"backstory": "x",
"arc": "y",
"speech_tics": [],
"tags": [],
"relations": [],
}
async with _client(app) as client:
resp = await client.post(
f"/projects/{pid}/characters",
json={"cards": [_card(""), _card(""), _card("")]},
)
assert resp.status_code == 201
assert resp.json()["created"] == ["", "", ""]
assert [r["name"] for r in char_repo.rows] == ["", "", ""]
assert session.commits == 1
@pytest.mark.asyncio
async def test_ingest_characters_with_conflict_blocks_409() -> None:
repo = FakeProjectRepo()

View File

@@ -38,8 +38,11 @@ class _OutlineRepo:
async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None:
return self._rows.get(chapter_no)
async def list_for_project(self, project_id: uuid.UUID) -> list[OutlineView]:
return [self._rows[k] for k in sorted(self._rows)]
async def list_for_project(
self, project_id: uuid.UUID, *, volume: int | None = None
) -> list[OutlineView]:
rows = [self._rows[k] for k in sorted(self._rows)]
return [v for v in rows if volume is None or v.volume == volume]
class _CharRepo:

View File

@@ -0,0 +1,112 @@
"""CR-H9用户请求文本字段 max_length 上界 + 应用级请求体大小中间件(防 DoS/成本)。
- 单元:各请求 schema 的字符串字段在 cap 处可构造、cap+1 触发 ValidationError含样本列表长度
与列表项两处上界——最大 DoS 面)。
- 集成(无 pg超大请求体经中间件直接 413不入路由/DB
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from typing import Any
import pytest
from cryptography.fernet import Fernet
from pydantic import BaseModel, ValidationError
# create_app() 于 import ww_api.main 时构建,需 CREDENTIAL_ENC_KEY——先于任何 main 导入置好。
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
from ww_api.schemas.foreshadow import ForeshadowRegisterRequest
from ww_api.schemas.generation import CharacterGenerateRequest, WorldGenerateRequest
from ww_api.schemas.projects import (
AcceptRequest,
DraftSaveRequest,
DraftStreamRequest,
ProjectCreateRequest,
ReviewRequest,
)
from ww_api.schemas.providers import (
ProviderCredentialInput,
TierRoutingInput,
)
from ww_api.schemas.providers import (
TestConnectionRequest as _TestConnectionRequest, # 别名避免 pytest 误采集 Test* 类
)
from ww_api.schemas.rules import RuleCreateRequest
from ww_api.schemas.style import RefineRequest, StyleLearnRequest
from ww_api.schemas.templates import TemplateCreateRequest
@dataclass(frozen=True)
class _StrCase:
"""一个字符串字段的上界用例cap 处可构造、cap+1 触发 ValidationError。"""
schema: type[BaseModel]
field_name: str
cap: int
case_id: str
base: dict[str, Any] = field(default_factory=dict) # 该 schema 其余必填字段
_STR_CASES: list[_StrCase] = [
_StrCase(ProviderCredentialInput, "api_key", 512, "api_key<=512", {"provider": "p"}),
_StrCase(ProviderCredentialInput, "provider", 64, "provider<=64", {"api_key": "k"}),
_StrCase(TierRoutingInput, "model", 128, "model<=128", {"tier": "writer", "provider": "p"}),
_StrCase(
TierRoutingInput, "provider", 64, "routing-provider<=64", {"tier": "writer", "model": "m"}
),
_StrCase(_TestConnectionRequest, "provider", 64, "test-provider<=64"),
_StrCase(WorldGenerateRequest, "brief", 10_000, "world-brief<=10000"),
_StrCase(CharacterGenerateRequest, "brief", 10_000, "char-brief<=10000"),
_StrCase(ProjectCreateRequest, "title", 200, "title<=200"),
_StrCase(AcceptRequest, "final_text", 200_000, "final_text<=200000"),
_StrCase(DraftSaveRequest, "text", 200_000, "draft-text<=200000"),
_StrCase(ReviewRequest, "draft", 200_000, "review-draft<=200000"),
_StrCase(RefineRequest, "segment", 20_000, "segment<=20000"),
_StrCase(RefineRequest, "instruction", 2_000, "refine-instruction<=2000", {"segment": "s"}),
_StrCase(DraftStreamRequest, "directive", 20_000, "directive<=20000"),
_StrCase(ForeshadowRegisterRequest, "code", 100, "code<=100", {"title": "t"}),
_StrCase(ForeshadowRegisterRequest, "title", 500, "foreshadow-title<=500", {"code": "c"}),
_StrCase(RuleCreateRequest, "content", 10_000, "rule-content<=10000", {"level": "project"}),
_StrCase(TemplateCreateRequest, "title", 200, "template-title<=200", {"body": "b"}),
_StrCase(TemplateCreateRequest, "body", 20_000, "template-body<=20000", {"title": "t"}),
]
@pytest.mark.parametrize("case", _STR_CASES, ids=[c.case_id for c in _STR_CASES])
def test_request_str_fields_reject_over_max_length(case: _StrCase) -> None:
# Arrange / Act — 长度 == cap构造成功。
case.schema(**{**case.base, case.field_name: "x" * case.cap})
# Assert — 长度 == cap+1超界即 ValidationError。
with pytest.raises(ValidationError):
case.schema(**{**case.base, case.field_name: "x" * (case.cap + 1)})
def test_style_learn_samples_item_and_list_caps() -> None:
# 单条样本 == 20_000 字符 OK20_001 → ValidationError最大 DoS 面:每条正文都设上界)。
StyleLearnRequest(samples=["x" * 20_000])
with pytest.raises(ValidationError):
StyleLearnRequest(samples=["x" * 20_001])
# 列表 == 20 条 OK21 条 → ValidationError同时约束列表长度
StyleLearnRequest(samples=["s"] * 20)
with pytest.raises(ValidationError):
StyleLearnRequest(samples=["s"] * 21)
def test_oversized_request_body_rejected_413() -> None:
"""超大请求体经 body-size 中间件直接 413不入路由/DB
不以 context manager 进入 TestClient → 不跑 lifespan/seed → 不需 Postgres。中间件在
路由前拦截,故请求根本不触达任何 DB。
"""
from fastapi.testclient import TestClient
from ww_api.main import create_app
from ww_api.middleware import MAX_BODY_BYTES
client = TestClient(create_app())
resp = client.post("/projects", content=b"x" * (MAX_BODY_BYTES + 1))
assert resp.status_code == 413
assert resp.json()["error"]["code"] == "PAYLOAD_TOO_LARGE"

View File

@@ -15,6 +15,8 @@
from __future__ import annotations
import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any
import pytest
@@ -51,12 +53,65 @@ class _ScriptedHttp:
return None
class _TrackingSessionFactory:
"""记录同一时刻在持会话数open_count+ 累计开启次数total_opened的 session 工厂替身。
CR-H1 断言用:轮询期间 open_count 应恒 0无会话被长时间占用持久化时 total_opened≥1。
"""
def __init__(self) -> None:
self.open_count = 0
self.total_opened = 0
def __call__(self) -> Any:
from fakes_projects import FakeSession
@asynccontextmanager
async def _cm() -> AsyncIterator[Any]:
self.open_count += 1
self.total_opened += 1
try:
yield FakeSession()
finally:
self.open_count -= 1
return _cm()
class _ObservingHttp:
"""观测型 scripted http每次 POST 到 TOKEN_URL轮询时记录当下在持会话数。aclose 无操作。"""
def __init__(
self,
responses: list[_FakeResponse],
*,
factory: _TrackingSessionFactory,
token_url: str,
) -> None:
self._responses = responses
self._factory = factory
self._token_url = token_url
self.calls: list[tuple[str, dict[str, str]]] = []
self.open_during_poll: list[int] = []
async def post(self, url: str, *, data: dict[str, str]) -> _FakeResponse:
idx = len(self.calls)
self.calls.append((url, data))
if url == self._token_url: # 轮询 POST区别于 device_authorization POST
self.open_during_poll.append(self._factory.open_count)
return self._responses[idx]
async def aclose(self) -> None:
return None
def _make_client(
*,
store: FakeCredentialStore,
http: _ScriptedHttp,
http: Any,
enc_key: str,
monkeypatch: pytest.MonkeyPatch,
session_factory: Any = None,
) -> TestClient:
os.environ["CREDENTIAL_ENC_KEY"] = enc_key
from ww_config import get_settings
@@ -88,12 +143,15 @@ def _make_client(
job_repo = FakeJobRepo()
monkeypatch.setattr(job_runner, "SqlJobRepo", lambda _session: job_repo)
sf = session_factory if session_factory is not None else FakeSessionFactory()
app = create_app()
app.dependency_overrides[get_credential_store] = lambda: store
app.dependency_overrides[get_job_repo] = lambda: job_repo
app.dependency_overrides[get_session] = lambda: FakeSession()
app.dependency_overrides[get_session_factory] = lambda: FakeSessionFactory()
app.dependency_overrides[routers.kimi_oauth._default_http_client] = lambda: http
app.dependency_overrides[get_session_factory] = lambda: sf
# 请求级 http 现为生成器依赖 `_http_client`CR-H2——override 它注 fake否则真生成器联网
app.dependency_overrides[routers.kimi_oauth._http_client] = lambda: http
return TestClient(app)
@@ -163,6 +221,90 @@ def test_start_polls_through_authorization_pending(monkeypatch: pytest.MonkeyPat
assert (STUB_OWNER_ID, "kimi-code") in store.oauth
def test_poll_loop_does_not_hold_db_session(monkeypatch: pytest.MonkeyPatch) -> None:
"""CR-H1设备授权轮询期间**不得**占用 DB 会话(否则连接池被长时间空占→饥饿)。
轮询循环只做 HTTP无 DB仅最终持久化才开一个**短**会话。断言:轮询 POST 期间
session_factory 未开会话open_count==0而持久化确开过短会话凭据已落库、响应不泄 token。
"""
from ww_api.services.kimi_oauth import TOKEN_URL
enc_key = Fernet.generate_key().decode()
store = FakeCredentialStore()
factory = _TrackingSessionFactory()
# device auth → poll #1 pending → poll #2 success≥2 次轮询 POST
http = _ObservingHttp(
[
_device_auth_response(),
_FakeResponse(400, {"error": "authorization_pending"}),
_token_response(),
],
factory=factory,
token_url=TOKEN_URL,
)
client = _make_client(
store=store,
http=http,
enc_key=enc_key,
monkeypatch=monkeypatch,
session_factory=factory,
)
resp = client.post("/settings/providers/kimi-code/oauth/start")
assert resp.status_code == 202
# 核心断言:轮询 POST 期间无 session_factory 会话在持。
assert http.open_during_poll # 确有轮询 POST 发生
assert all(c == 0 for c in http.open_during_poll)
# 持久化时确开过一个短会话。
assert factory.total_opened >= 1
# 凭据已落库;响应绝不含 token。
assert (STUB_OWNER_ID, "kimi-code") in store.oauth
assert "secret-acc" not in resp.text
assert "secret-ref" not in resp.text
async def test_http_client_dependency_is_generator_and_closes_client(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""CR-H2请求级 `_http_client` 是**生成器依赖**,请求结束时 aclose 客户端(不泄漏连接)。
驱动生成器一轮enter→yield→exit断言是 async 生成器yield 时未关闭;耗尽时恰好
关闭一次。RED修复前 `_http_client` 不存在AttributeError
"""
import inspect
from ww_api.routers import kimi_oauth
closes = {"count": 0}
class _SpyClient:
def __init__(self, *args: Any, **kwargs: Any) -> None:
pass
async def __aenter__(self) -> _SpyClient:
return self
async def __aexit__(self, *exc: Any) -> None:
closes["count"] += 1
async def aclose(self) -> None:
closes["count"] += 1
monkeypatch.setattr("ww_api.routers.kimi_oauth.httpx.AsyncClient", _SpyClient)
# 是 async 生成器依赖(有 teardown非普通函数普通函数依赖无 teardown → 泄漏)。
assert inspect.isasyncgenfunction(kimi_oauth._http_client)
gen = kimi_oauth._http_client()
client = await gen.__anext__()
assert client is not None
assert closes["count"] == 0 # yield 时尚未关闭
with pytest.raises(StopAsyncIteration):
await gen.__anext__()
assert closes["count"] == 1 # 请求结束恰好关闭一次
def test_disconnect_clears_credential(monkeypatch: pytest.MonkeyPatch) -> None:
enc_key = Fernet.generate_key().decode()
store = FakeCredentialStore()

View File

@@ -264,6 +264,25 @@ async def test_get_outline_with_volume_filters_to_that_volume() -> None:
assert all(c["volume"] == 2 for c in vol2_chapters)
@pytest.mark.asyncio
async def test_outline_repo_volume_filter_pushed_into_query() -> None:
# 卷过滤下推到 repoDB WHERE而非端点 Python 侧过滤:直接调 repo 断言只回该卷。
repo = FakeOutlineReadRepo()
pid = uuid.uuid4()
repo.add_chapter(pid, volume=1, chapter_no=1, beats=["卷一·1"])
repo.add_chapter(pid, volume=1, chapter_no=2, beats=["卷一·2"])
repo.add_chapter(pid, volume=2, chapter_no=3, beats=["卷二·3"])
vol1 = await repo.list_for_project(pid, volume=1)
vol2 = await repo.list_for_project(pid, volume=2)
all_rows = await repo.list_for_project(pid)
assert [v.chapter_no for v in vol1] == [1, 2]
assert all(v.volume == 1 for v in vol1)
assert [v.chapter_no for v in vol2] == [3]
assert [v.chapter_no for v in all_rows] == [1, 2, 3]
@pytest.mark.asyncio
async def test_get_outline_returns_empty_list_when_no_outline() -> None:
project_repo = FakeProjectRepo()

View File

@@ -10,7 +10,14 @@ import uuid
import httpx
import pytest
from cryptography.fernet import Fernet
from fakes_projects import FakeChapterRepo, FakeProjectRepo, FakeWriterGateway
from fakes_projects import (
FakeChapterRepo,
FakeProjectRepo,
FakeReviewGateway,
FakeSession,
FakeWriterGateway,
)
from fastapi import FastAPI
from ww_api.services.credentials import STUB_OWNER_ID
from ww_core.domain.project_repo import ProjectView
from ww_core.domain.repositories import (
@@ -31,7 +38,9 @@ class _EmptyOutlineRepo:
async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None:
return None
async def list_for_project(self, project_id: uuid.UUID) -> list[OutlineView]:
async def list_for_project(
self, project_id: uuid.UUID, *, volume: int | None = None
) -> list[OutlineView]:
return []
@@ -213,6 +222,33 @@ async def test_list_projects() -> None:
assert all(p["pending_review_count"] == 0 for p in projects)
@pytest.mark.asyncio
async def test_list_projects_paginates_with_limit_and_offset() -> None:
client, _, _, _ = _make_client()
async with client:
for t in ("", "", ""):
await client.post("/projects", json={"title": t})
page1 = await client.get("/projects?limit=2")
page2 = await client.get("/projects?limit=2&offset=2")
assert page1.status_code == 200
assert [p["title"] for p in page1.json()["projects"]] == ["", ""]
assert page2.status_code == 200
assert [p["title"] for p in page2.json()["projects"]] == [""]
@pytest.mark.asyncio
async def test_list_projects_rejects_out_of_range_pagination() -> None:
client, _, _, _ = _make_client()
async with client:
too_small = await client.get("/projects?limit=0")
too_large = await client.get("/projects?limit=201")
neg_offset = await client.get("/projects?offset=-1")
# 边界越界 → FastAPI 422limit∈[1,200]、offset≥0
assert too_small.status_code == 422
assert too_large.status_code == 422
assert neg_offset.status_code == 422
@pytest.mark.asyncio
async def test_get_project_detail() -> None:
client, _, _, _ = _make_client()
@@ -361,3 +397,191 @@ async def test_get_draft_404_when_no_draft() -> None:
resp = await client.get(f"/projects/{pid}/chapters/9/draft")
assert resp.status_code == 404
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
# ---- POST /rewrite/clarify整章重写预检澄清WFW-9 M2----
# 摘录上界外的哨兵:喂超长 prior_draft断言超界正文不进网关 input不喂全章、不泄漏
_LEAK_SENTINEL = "泄漏哨兵段落禁止外泄"
def _need_clarification_decision() -> object:
from ww_agents import ClarifyDecision, ClarifyOption, ClarifyQuestion
return ClarifyDecision(
need_clarification=True,
questions=[
ClarifyQuestion(
question="你说本章节奏太慢,是想往哪个方向重写整章?",
options=[
ClarifyOption(label="砍支线压缩篇幅", value="删掉次要支线,压缩铺陈直入主线"),
ClarifyOption(label="提前引爆冲突", value="把本章高潮冲突提前到前半段"),
],
allow_free_text=True,
)
],
)
def _clear_rewrite_decision() -> object:
from ww_agents import ClarifyDecision
return ClarifyDecision(
need_clarification=False,
verification="我会把本章高潮提前、删掉开头回忆插叙,直接进冲突,对吗?",
)
def _clarify_app(
*,
project_repo: FakeProjectRepo,
session: FakeSession | None = None,
clarify_gateway: object | None = None,
) -> FastAPI:
"""构造只挂 rewrite/clarify 端点依赖的 appproject_repo + session + clarify 网关)。"""
import os
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
from ww_api.main import create_app
from ww_api.services.project_deps import get_clarify_gateway, get_project_repo
from ww_db import get_session
app = create_app()
app.dependency_overrides[get_project_repo] = lambda: project_repo
app.dependency_overrides[get_session] = lambda: session or FakeSession()
if clarify_gateway is not None:
app.dependency_overrides[get_clarify_gateway] = lambda: clarify_gateway
return app
def _clarify_client(app: FastAPI) -> httpx.AsyncClient:
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
return httpx.AsyncClient(transport=transport, base_url="http://test")
@pytest.mark.asyncio
async def test_rewrite_clarify_vague_feedback_asks_one_question_and_commits() -> None:
# (a) 含糊章级意见 → need_clarification True + 恰 1 问analyst 档 + 末尾 commit 落账。
project_repo = FakeProjectRepo()
pid = _seed_project(project_repo)
session = FakeSession()
gateway = FakeReviewGateway(parsed=_need_clarification_decision())
app = _clarify_app(project_repo=project_repo, session=session, clarify_gateway=gateway)
async with _clarify_client(app) as client:
resp = await client.post(
f"/projects/{pid}/chapters/3/rewrite/clarify",
json={"feedback": "节奏太慢", "prior_draft": "本章开头……"},
)
assert resp.status_code == 200
body = resp.json()
assert body["need_clarification"] is True
assert len(body["questions"]) == 1
assert body["questions"][0]["options"][0]["label"] == "砍支线压缩篇幅"
assert body["questions"][0]["allow_free_text"] is True
# 不变量 #2analyst 档;意见折进网关 input末尾一次 commit网关 usage_ledger 落库)。
assert gateway.requests[0].tier == "analyst"
assert "节奏太慢" in str(gateway.requests[0].input)
assert session.commits == 1
@pytest.mark.asyncio
async def test_rewrite_clarify_clear_feedback_proceeds_read_only() -> None:
# (b) 明确意见 → need_clarification False + verification只读、无回滚。
project_repo = FakeProjectRepo()
pid = _seed_project(project_repo)
session = FakeSession()
gateway = FakeReviewGateway(parsed=_clear_rewrite_decision())
app = _clarify_app(project_repo=project_repo, session=session, clarify_gateway=gateway)
async with _clarify_client(app) as client:
resp = await client.post(
f"/projects/{pid}/chapters/3/rewrite/clarify",
json={"feedback": "把本章高潮提前到前半段,删掉开头回忆插叙。"},
)
assert resp.status_code == 200
body = resp.json()
assert body["need_clarification"] is False
assert body["questions"] == []
assert body["verification"] is not None
# 只读不写业务表:仅一次 commit 落网关 usage无回滚不变量 #3
assert session.commits == 1
assert session.rollbacks == 0
@pytest.mark.asyncio
async def test_rewrite_clarify_unknown_project_404() -> None:
# (c) 项目不存在 → 404触网关前 fail-fast
app = _clarify_app(
project_repo=FakeProjectRepo(),
clarify_gateway=FakeReviewGateway(parsed=_clear_rewrite_decision()),
)
async with _clarify_client(app) as client:
resp = await client.post(
f"/projects/{uuid.uuid4()}/chapters/1/rewrite/clarify",
json={"feedback": "节奏太慢"},
)
assert resp.status_code == 404
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
@pytest.mark.asyncio
async def test_rewrite_clarify_fails_open_on_wrong_parsed() -> None:
# (d) 网关返回非 ClarifyDecision → 端点仍 200 + need_clarification False韧性回退非 500
from ww_agents import WorldGenResult
project_repo = FakeProjectRepo()
pid = _seed_project(project_repo)
session = FakeSession()
gateway = FakeReviewGateway(parsed=WorldGenResult(entities=[]))
app = _clarify_app(project_repo=project_repo, session=session, clarify_gateway=gateway)
async with _clarify_client(app) as client:
resp = await client.post(
f"/projects/{pid}/chapters/3/rewrite/clarify",
json={"feedback": "重写一遍"},
)
assert resp.status_code == 200
assert resp.json()["need_clarification"] is False
@pytest.mark.asyncio
async def test_rewrite_clarify_empty_feedback_422() -> None:
project_repo = FakeProjectRepo()
pid = _seed_project(project_repo)
app = _clarify_app(
project_repo=project_repo,
clarify_gateway=FakeReviewGateway(parsed=_clear_rewrite_decision()),
)
async with _clarify_client(app) as client:
resp = await client.post(
f"/projects/{pid}/chapters/1/rewrite/clarify", json={"feedback": ""}
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_rewrite_clarify_bounds_excerpt_and_never_leaks_manuscript() -> None:
# 有界摘录:超长 prior_draft 的越界正文不进网关 input响应也绝不回灌正文脱敏
project_repo = FakeProjectRepo()
pid = _seed_project(project_repo)
session = FakeSession()
gateway = FakeReviewGateway(parsed=_clear_rewrite_decision())
app = _clarify_app(project_repo=project_repo, session=session, clarify_gateway=gateway)
# 哨兵放在远超摘录上界处10k 填充 + 哨兵),确保被截断在摘录外。
prior_draft = ("" * 10_000) + _LEAK_SENTINEL
async with _clarify_client(app) as client:
resp = await client.post(
f"/projects/{pid}/chapters/3/rewrite/clarify",
json={"feedback": "重写本章", "prior_draft": prior_draft},
)
assert resp.status_code == 200
# 越界正文既不进 LLM 输入,也不出现在响应体(正文只喂有界摘录、绝不回灌)。
assert _LEAK_SENTINEL not in str(gateway.requests[0].input)
assert _LEAK_SENTINEL not in resp.text

View File

@@ -59,10 +59,12 @@ def _app_with_overrides(
session_factory: FakeSessionFactory | None = None,
extract_gateway: object | None = None,
refine_gateway: object | None = None,
clarify_gateway: object | None = None,
) -> FastAPI:
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
from ww_api.main import create_app
from ww_api.services.project_deps import (
get_clarify_gateway,
get_job_repo,
get_project_repo,
get_refine_gateway,
@@ -85,6 +87,8 @@ def _app_with_overrides(
app.dependency_overrides[get_style_extract_gateway] = lambda: extract_gateway
if refine_gateway is not None:
app.dependency_overrides[get_refine_gateway] = lambda: refine_gateway
if clarify_gateway is not None:
app.dependency_overrides[get_clarify_gateway] = lambda: clarify_gateway
return app
@@ -394,3 +398,134 @@ async def test_refine_without_credentials_503() -> None:
assert resp.status_code == 503
assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE
assert session.commits == 0
# ---- POST /refine/clarify润色预检澄清----
def _need_clarification() -> object:
from ww_agents import ClarifyDecision, ClarifyOption, ClarifyQuestion
return ClarifyDecision(
need_clarification=True,
questions=[
ClarifyQuestion(
question="你想让这段更有张力,是指哪种方向?",
options=[
ClarifyOption(label="加快节奏", value="拆短句、压缩铺陈"),
ClarifyOption(label="加重冲突", value="强化人物对立"),
],
allow_free_text=True,
)
],
)
def _clear_decision() -> object:
from ww_agents import ClarifyDecision
return ClarifyDecision(
need_clarification=False,
verification="我会把被字句改成主动句,其余不动,对吗?",
)
@pytest.mark.asyncio
async def test_clarify_returns_need_clarification_and_commits() -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
session = FakeSession()
gateway = FakeReviewGateway(parsed=_need_clarification())
app = _app_with_overrides(project_repo=project_repo, session=session, clarify_gateway=gateway)
async with _client(app) as client:
resp = await client.post(
f"/projects/{pid}/chapters/1/refine/clarify",
json={"segment": "原始段落。", "instruction": "更有张力"},
)
assert resp.status_code == 200
body = resp.json()
assert body["need_clarification"] is True
assert len(body["questions"]) == 1
assert body["questions"][0]["options"][0]["label"] == "加快节奏"
assert body["questions"][0]["allow_free_text"] is True
# analyst 档 + 末尾 commit记账落库指令折进输入文本。
assert gateway.requests[0].tier == "analyst"
assert session.commits == 1
assert "更有张力" in str(gateway.requests[0].input)
@pytest.mark.asyncio
async def test_clarify_returns_clear_decision_read_only() -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
session = FakeSession()
gateway = FakeReviewGateway(parsed=_clear_decision())
app = _app_with_overrides(project_repo=project_repo, session=session, clarify_gateway=gateway)
async with _client(app) as client:
resp = await client.post(
f"/projects/{pid}/chapters/2/refine/clarify",
json={"segment": "把这句的被字句改成主动句。", "instruction": ""},
)
assert resp.status_code == 200
body = resp.json()
assert body["need_clarification"] is False
assert body["questions"] == []
assert body["verification"] is not None
# 只读不写库:无写侧 repo 参与,仅一次 commit 落网关 usage不变量 #3
assert session.commits == 1
assert session.rollbacks == 0
@pytest.mark.asyncio
async def test_clarify_unknown_project_404() -> None:
app = _app_with_overrides(
project_repo=FakeProjectRepo(),
clarify_gateway=FakeReviewGateway(parsed=_clear_decision()),
)
async with _client(app) as client:
resp = await client.post(
f"/projects/{uuid.uuid4()}/chapters/1/refine/clarify",
json={"segment": "原段", "instruction": "x"},
)
assert resp.status_code == 404
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
@pytest.mark.asyncio
async def test_clarify_empty_segment_422() -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
app = _app_with_overrides(
project_repo=project_repo, clarify_gateway=FakeReviewGateway(parsed=_clear_decision())
)
async with _client(app) as client:
resp = await client.post(f"/projects/{pid}/chapters/1/refine/clarify", json={"segment": ""})
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_clarify_without_credentials_503() -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
session = FakeSession()
async def _no_creds() -> object:
raise AppError(ErrorCode.LLM_UNAVAILABLE, "未配置凭据", {"provider": "deepseek"})
app = _app_with_overrides(project_repo=project_repo, session=session)
from ww_api.services.project_deps import get_clarify_gateway
app.dependency_overrides[get_clarify_gateway] = _no_creds
async with _client(app) as client:
resp = await client.post(
f"/projects/{pid}/chapters/1/refine/clarify", json={"segment": "原段"}
)
assert resp.status_code == 503
assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE
assert session.commits == 0

View File

@@ -33,8 +33,13 @@ class _FakeTemplateRepo:
self.rows[view.id] = view
return view
async def list_for_owner(self, owner_id: uuid.UUID) -> list[TemplateView]:
return list(self.rows.values())
async def list_for_owner(
self, owner_id: uuid.UUID, *, limit: int | None = None, offset: int = 0
) -> list[TemplateView]:
views = list(self.rows.values())
if limit is None:
return views
return views[offset : offset + limit]
async def delete(self, owner_id: uuid.UUID, template_id: uuid.UUID) -> bool:
if template_id in self.rows:

View File

@@ -32,7 +32,7 @@ from ww_core.domain.outline_write_repo import OutlineWriteView
from ww_core.domain.project_repo import ProjectCreate
from ww_core.domain.repositories import OutlineView
from ww_core.domain.rule_repo import RuleWriteView
from ww_core.domain.world_entity_repo import WorldEntityWriteView
from ww_core.domain.world_entity_repo import WorldEntityFields, WorldEntityWriteView
from ww_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage
from ww_shared import AppError, ErrorCode
@@ -75,6 +75,15 @@ class _FakeWorldWriteRepo:
self.rows.append({"type": type, "name": name, "rules": list(rules)})
return WorldEntityWriteView(id=uuid.uuid4(), type=type, name=name)
async def create_many(
self, project_id: uuid.UUID, entities: list[WorldEntityFields]
) -> list[WorldEntityWriteView]:
# 镜像 Sql批量插入保序返回逐条 View。
return [
await self.create(project_id, type=e.type, name=e.name, rules=list(e.rules))
for e in entities
]
class _FakeOutlineWriteRepo:
def __init__(self) -> None:
@@ -569,6 +578,30 @@ async def test_ingest_world_entities_no_conflict_writes_201() -> None:
assert session.commits == 1
@pytest.mark.asyncio
async def test_ingest_multiple_world_entities_all_land_in_one_batch() -> None:
# 批量入库:多条实体一次 create_many 全部落库created 保序,单次 commit。
repo = FakeProjectRepo()
pid = await _seed_project(repo)
gateway = _SchemaRoutingGateway({ContinuityReview: _no_conflicts()})
app, session, world_repo, _ = _make_app(project_repo=repo, gateway=gateway)
payload = {
"world_entities": [
{"type": "力量体系", "name": "吞噬系统", "rules": ["每日上限"]},
{"type": "势力", "name": "天机阁", "rules": ["中立"]},
{"type": "地点", "name": "无尽海", "rules": []},
]
}
async with _client(app) as client:
resp = await client.post(f"/projects/{pid}/skills/golden-finger/ingest", json=payload)
assert resp.status_code == 201
assert resp.json()["created"] == ["吞噬系统", "天机阁", "无尽海"]
assert [r["name"] for r in world_repo.rows] == ["吞噬系统", "天机阁", "无尽海"]
assert session.commits == 1
@pytest.mark.asyncio
async def test_ingest_world_entities_conflict_blocks_409() -> None:
repo = FakeProjectRepo()

View File

@@ -14,8 +14,13 @@ from ww_db import get_sessionmaker
from ww_shared import AppError, ErrorBody, ErrorCode, ErrorEnvelope
from ww_api.logging_config import configure_logging, get_logger
from ww_api.middleware import REQUEST_ID_HEADER, request_id_middleware
from ww_api.middleware import (
REQUEST_ID_HEADER,
body_size_limit_middleware,
request_id_middleware,
)
from ww_api.routers import (
ai_messages,
chain,
foreshadow,
generation,
@@ -74,6 +79,8 @@ def create_app() -> FastAPI:
allow_headers=["content-type", "authorization", REQUEST_ID_HEADER],
)
app.middleware("http")(request_id_middleware)
# 请求体大小守卫CR-H9在路由前拦截超大 body直接 413。
app.middleware("http")(body_size_limit_middleware)
@app.exception_handler(AppError)
async def _app_error_handler(request: Request, exc: AppError) -> JSONResponse:
@@ -117,6 +124,7 @@ def create_app() -> FastAPI:
app.include_router(rules.router)
app.include_router(style.router)
app.include_router(templates.router)
app.include_router(ai_messages.router)
app.include_router(generation.router)
app.include_router(generation.skills_router)
app.include_router(toolbox.router)

View File

@@ -12,10 +12,14 @@ from collections.abc import Awaitable, Callable
import structlog
from starlette.requests import Request
from starlette.responses import Response
from starlette.responses import JSONResponse, Response
from ww_shared import ErrorBody, ErrorCode, ErrorEnvelope
REQUEST_ID_HEADER = "x-request-id"
# 请求体大小上限2 MiB超过即 413防超大 body 撑爆内存/成本CR-H9
MAX_BODY_BYTES = 2 * 1024 * 1024
# 放行安全字符集(字母数字 . _ -1128 位):覆盖 uuid4().hex、常见 trace id 与客户端
# 自定义短 id含空白/控制字符/换行/注入序列/超长的一律丢弃改生成新 uuid——防止把未经
# 校验的客户端值原样写进日志/响应头(日志注入防护)。
@@ -38,3 +42,29 @@ async def request_id_middleware(
response = await call_next(request)
response.headers[REQUEST_ID_HEADER] = request_id
return response
async def body_size_limit_middleware(
request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
"""按 `Content-Length` 拦截超大请求体,超限直接 413CR-H9
用户中间件跑在 FastAPI 的 AppError 处理器**之外**,故必须自建错误信封响应(不能靠抛
AppError。分块编码无 `Content-Length`)无法在此拦截——单用户原型可接受的限制。
"""
content_length = request.headers.get("content-length")
if content_length is not None:
try:
size = int(content_length)
except ValueError:
size = 0 # 非法头防御性视作 0交由下游正常解析/报错。
if size > MAX_BODY_BYTES:
envelope = ErrorEnvelope(
error=ErrorBody(
code=ErrorCode.PAYLOAD_TOO_LARGE,
message="请求体过大",
request_id=_sanitize_request_id(request.headers.get(REQUEST_ID_HEADER)),
)
)
return JSONResponse(status_code=413, content=envelope.model_dump())
return await call_next(request)

View File

@@ -0,0 +1,37 @@
"""列表端点的分页参数(`limit`/`offset`,边界校验 + 合理默认)。
无界列表端点owner 维度projects / templates原来一次返回全部行行数随用户
数据线性增长。这里提供一个可复用的 FastAPI 依赖 `get_page`,把 `limit`/`offset`
在边界处校验(越界 → 422后交端点。默认返回第一页`offset=0` + `DEFAULT_PAGE_LIMIT`
不传参的老客户端行为不破坏;`limit` 有上限(`MAX_PAGE_LIMIT`)防一次性拉爆。
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Annotated
from fastapi import Depends, Query
# 合理默认 + 上限(单用户原型:默认页足够覆盖常见数据量;上限防一次拉全表)。
DEFAULT_PAGE_LIMIT = 50
MAX_PAGE_LIMIT = 200
@dataclass(frozen=True)
class Page:
"""一页的边界(不可变):`limit` 条数上限、`offset` 起始偏移。"""
limit: int
offset: int
def get_page(
limit: Annotated[int, Query(ge=1, le=MAX_PAGE_LIMIT)] = DEFAULT_PAGE_LIMIT,
offset: Annotated[int, Query(ge=0)] = 0,
) -> Page:
"""校验并封装分页参数(`limit∈[1,MAX]`、`offset≥0`;越界 FastAPI 返 422"""
return Page(limit=limit, offset=offset)
PageDep = Annotated[Page, Depends(get_page)]

View File

@@ -0,0 +1,114 @@
"""AI 对话聊天记录端点AC-2 / ai-chat-history-plan §3.3)。
作者↔AI 五类往复交换refine/rewrite/clarify/continue/generator落库供每章「AI 对话」
抽屉重放。**本端点是显式的记录写入**(前端在一轮交换结束后 POST 一批)——不是生成端点;
五个生成端点保持只读、逐字节不变(守不变量 #3。`ai_messages` 是侧记录assemble()/agent
永不读它(守 #1/#6
- POST /projects/{id}/ai-messages 一批 append201项目不存在 → 404
- GET /projects/{id}/ai-messages 列出newest-first`chapter_no` 过滤含项目级 NULL分页
提交边界:`AiMessageRepo.append` 只 flushPOST 端点写后 `commit()`(仿模板/规则写侧)。
GET 只读不 commit。
"""
from __future__ import annotations
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from ww_core.domain import AiMessageRepo, AiTurnRow, ProjectRepo
from ww_core.domain.ai_message_repo import AiMessageView as AiMessageRepoView
from ww_db import get_session
from ww_shared import AppError, ErrorCode
from ww_api.logging_config import get_logger
from ww_api.pagination import PageDep
from ww_api.schemas.ai_messages import (
AiKind,
AiMessageAppendRequest,
AiMessageListResponse,
AiMessageView,
)
from ww_api.services.credentials import STUB_OWNER_ID
from ww_api.services.project_deps import get_ai_message_repo, get_project_repo
log = get_logger("ww.api.ai_messages")
router = APIRouter(prefix="/projects", tags=["ai-messages"])
AiMessageRepoDep = Annotated[AiMessageRepo, Depends(get_ai_message_repo)]
ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
SessionDep = Annotated[AsyncSession, Depends(get_session)]
def _to_response_view(v: AiMessageRepoView) -> AiMessageView:
"""repo 视图 → API 视图(同字段镜像;避免路由碰 repo 视图类型泄漏)。"""
return AiMessageView(
id=v.id,
project_id=v.project_id,
chapter_no=v.chapter_no,
thread_id=v.thread_id,
seq=v.seq,
kind=v.kind,
tool_key=v.tool_key,
role=v.role,
content=v.content,
meta=v.meta,
created_at=v.created_at,
)
@router.post("/{project_id}/ai-messages", status_code=201)
async def append_ai_messages(
project_id: uuid.UUID,
body: AiMessageAppendRequest,
request: Request,
project_repo: ProjectRepoDep,
repo: AiMessageRepoDep,
session: SessionDep,
) -> AiMessageListResponse:
"""一批往复 bubble append201。项目不存在 → 404非法 kind/role/空/超上限 → 422。"""
request_id = getattr(request.state, "request_id", None)
project = await project_repo.get(STUB_OWNER_ID, project_id)
if project is None:
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
views = await repo.append(
project_id,
thread_id=body.thread_id,
chapter_no=body.chapter_no,
kind=body.kind,
tool_key=body.tool_key,
rows=[AiTurnRow(role=m.role, content=m.content, meta=m.meta) for m in body.messages],
)
await session.commit()
log.info(
"ai_messages_appended",
project_id=str(project_id),
request_id=request_id,
thread_id=str(body.thread_id),
kind=body.kind,
count=len(views),
)
return AiMessageListResponse(messages=[_to_response_view(v) for v in views])
@router.get("/{project_id}/ai-messages")
async def list_ai_messages(
project_id: uuid.UUID,
project_repo: ProjectRepoDep,
repo: AiMessageRepoDep,
page: PageDep,
chapter_no: Annotated[int | None, Query(ge=1)] = None,
kind: AiKind | None = None,
) -> AiMessageListResponse:
"""列出 AI 对话newest-first。`chapter_no` → 本章 项目级(NULL);缺省 → 仅项目级。"""
project = await project_repo.get(STUB_OWNER_ID, project_id)
if project is None:
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
views = await repo.list_for_chapter(
project_id, chapter_no, kind=kind, limit=page.limit, offset=page.offset
)
return AiMessageListResponse(messages=[_to_response_view(v) for v in views])

View File

@@ -33,6 +33,7 @@ from ww_agents import (
worldbuilder_spec,
)
from ww_core.domain import (
CharacterWriteFields,
CharacterWriteRepo,
ProjectRepo,
RuleWriteRepo,
@@ -372,10 +373,8 @@ async def ingest_characters(
rejected_tables=rejected,
)
created: list[str] = []
for card in allowed.get("characters", []):
view = await char_repo.create(
project_id,
fields = [
CharacterWriteFields(
name=card.name,
role=card.role,
traits=list(card.traits),
@@ -387,7 +386,10 @@ async def ingest_characters(
tags=list(card.tags),
relations=[r.model_dump() for r in card.relations],
)
created.append(view.name)
for card in allowed.get("characters", [])
]
# 批量 upsert一次 flush 落全部),保序返回逐卡 View。
created = [view.name for view in await char_repo.create_many(project_id, fields)]
# 提交边界:网关 ledgerprecheck+ 角色写侧均只 flush → 端点末尾一次 commit。
await session.commit()

View File

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

View File

@@ -17,6 +17,8 @@ token 仅以 Fernet 密文存 `provider_credentials.oauth_enc`。
from __future__ import annotations
import asyncio
import uuid
from collections.abc import AsyncIterator
from typing import Annotated, Any
import httpx
@@ -47,6 +49,7 @@ from ww_api.services.kimi_oauth import (
AuthorizationPending,
DeviceAuth,
SlowDown,
TokenSet,
decrypt_oauth_bundle,
encrypt_oauth_bundle,
poll_token,
@@ -70,53 +73,104 @@ _JOB_KIND_KIMI_OAUTH = "kimi_oauth"
_MAX_POLL_ATTEMPTS = 200
#: 请求/后台 HTTP 客户端超时(秒)。
_HTTP_TIMEOUT_SECONDS = 30.0
def _default_http_client() -> AsyncHttpClient:
return httpx.AsyncClient(timeout=30.0)
"""裸工厂:现建一个 httpx 客户端(**后台轮询**直接调用,自行在 finally 里 aclose"""
return httpx.AsyncClient(timeout=_HTTP_TIMEOUT_SECONDS)
def _make_poll_work(device: DeviceAuth) -> Any:
"""构造后台轮询工作闭包:循环 poll token 直到成功/过期/拒绝
async def _http_client() -> AsyncIterator[AsyncHttpClient]:
"""请求级 httpx 客户端**生成器依赖**:请求结束时经 `async with` 自动 acloseCR-H2
`work(session)` 自建 httpx 客户端 + 凭据 store用 `run_job` 传入的独立 session
按 `interval` 轮询 token → 成功则加密存 `oauth_enc` 并返回非密 job 结果 → 过期/拒绝则
抛 AppError`run_job` 置 job failed。**token 绝不进 job 结果/日志**。
普通函数依赖无 teardown返回的 client 永不关闭 → 每次 `/start` 泄漏一个连接。故请求路径
用此生成器依赖(后台路径仍用裸工厂 `_default_http_client`,自管生命周期)。
"""
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT_SECONDS) as http:
yield http
# SlowDown 退避每次增大的间隔量(秒)。
_SLOW_DOWN_STEP_SECONDS = 5
async def _poll_for_token(http: AsyncHttpClient, device: DeviceAuth) -> TokenSet:
"""设备授权轮询循环(**纯 HTTP无 DB 会话**CR-H1直到拿到 token 或超时。
`authorization_pending` → 继续;`slow_down` → 增大 interval成功 → 返回 `TokenSet`
次数耗尽 → 抛 `AppError`(视作过期,由调用方经 `run_job` 置 job failed
"""
interval = max(1, device.interval)
for _ in range(_MAX_POLL_ATTEMPTS):
await asyncio.sleep(interval)
try:
return await poll_token(http, device.device_code)
except AuthorizationPending:
continue
except SlowDown:
interval += _SLOW_DOWN_STEP_SECONDS
continue
raise AppError(
ErrorCode.LLM_UNAVAILABLE,
"Kimi 设备授权轮询超时",
{"provider": KIMI_CODE_PROVIDER},
)
def _make_persist_work(token: TokenSet) -> Any:
"""构造**最终持久化**工作闭包:只做「加密 token → upsert oauth 凭据」的一次短事务。
`run_job` 用独立 session 调用此 work轮询已在会话外完成故此处会话极短。
**明文 token 绝不进 job 结果/日志。**
"""
async def work(session: AsyncSession) -> dict[str, Any]:
enc_key = get_settings().credential_enc_key.get_secret_value()
store = SqlCredentialStore(session)
interval = max(1, device.interval)
http = _default_http_client()
try:
for _ in range(_MAX_POLL_ATTEMPTS):
await asyncio.sleep(interval)
try:
token = await poll_token(http, device.device_code)
except AuthorizationPending:
continue
except SlowDown:
interval += 5
continue
# 成功:加密 token 包入库auth_type=oauth。明文 token 不进结果/日志。
blob = encrypt_oauth_bundle(token, key=enc_key)
await store.upsert_oauth_credential(STUB_OWNER_ID, KIMI_CODE_PROVIDER, blob)
return {"connected": True, "provider": KIMI_CODE_PROVIDER}
# 轮询次数耗尽(视作过期)。
raise AppError(
ErrorCode.LLM_UNAVAILABLE,
"Kimi 设备授权轮询超时",
{"provider": KIMI_CODE_PROVIDER},
)
finally:
# `httpx.AsyncClient` 有 `aclose`(最小 Protocol 无;测试 fake 也实现了它)。
aclose = getattr(http, "aclose", None)
if aclose is not None:
await aclose()
blob = encrypt_oauth_bundle(token, key=enc_key)
await store.upsert_oauth_credential(STUB_OWNER_ID, KIMI_CODE_PROVIDER, blob)
return {"connected": True, "provider": KIMI_CODE_PROVIDER}
return work
def _raise_work(exc: Exception) -> Any:
"""构造只把 `exc` 重抛的 work轮询失败路径交由 `run_job` 分类脱敏并置 job failed"""
async def work(_session: AsyncSession) -> dict[str, Any]:
raise exc
return work
async def _run_kimi_oauth_poll(
session_factory: SessionFactory,
job_id: uuid.UUID,
device: DeviceAuth,
*,
request_id: str | None = None,
) -> None:
"""后台任务:**先在会话外**轮询 token再经 `run_job` 开短会话持久化CR-H1
轮询期间不占用任何 DB 会话(防连接池饥饿);成功 → 交由 `run_job` 落库并置 done
失败 → 把异常带进 `run_job`,走其 `_classify_job_error`/`_mark_failed` 脱敏置 failed。
"""
http = _default_http_client()
try:
token = await _poll_for_token(http, device)
work = _make_persist_work(token)
except Exception as exc: # noqa: BLE001 — 失败经 run_job 统一脱敏置 job failed不在此处冒泡。
work = _raise_work(exc)
finally:
# `httpx.AsyncClient` 有 `aclose`(最小 Protocol 无;测试 fake 也实现了它)。
aclose = getattr(http, "aclose", None)
if aclose is not None:
await aclose()
await run_job(session_factory, job_id, work, request_id=request_id)
@router.post("/start", status_code=202)
async def start_oauth(
request: Request,
@@ -125,7 +179,7 @@ async def start_oauth(
job_repo: JobRepoDep,
session: SessionDep,
session_factory: SessionFactoryDep,
http: Annotated[AsyncHttpClient, Depends(_default_http_client)],
http: Annotated[AsyncHttpClient, Depends(_http_client)],
) -> OAuthStartResponse:
"""发起 device authorization返回 202 + user_code/verification_uri后台轮询 token。
@@ -139,11 +193,12 @@ async def start_oauth(
job = await job_repo.create(None, _JOB_KIND_KIMI_OAUTH)
await session.commit() # job 行需在 202 前持久化(供前端立即轮询)。
# 轮询移出 DB 会话CR-H1后台先在会话外轮询 token仅最终持久化开一个短会话。
background_tasks.add_task(
run_job,
_run_kimi_oauth_poll,
session_factory,
job.id,
_make_poll_work(device),
device,
request_id=request_id,
)

View File

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

View File

@@ -20,6 +20,7 @@ from typing import Annotated, Any
from fastapi import APIRouter, BackgroundTasks, Depends, Request
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from ww_agents import ClarifyDecision, clarify_rewrite_spec
from ww_core.domain.chapter_repo import ChapterRepo
from ww_core.domain.digest_repo import DigestAppendRepo
from ww_core.domain.injection_repo import EntityRef, InjectionOverride, InjectionOverrideRepo
@@ -36,13 +37,16 @@ from ww_core.orchestrator import (
error_event,
normalize_deltas,
normalize_review,
run_clarify,
stream_chapter_draft,
stream_chapter_rewrite,
)
from ww_db import get_session
from ww_llm_gateway import Gateway
from ww_shared import AppError, ErrorCode, ErrorEnvelope
from ww_api.logging_config import get_logger
from ww_api.pagination import PageDep
from ww_api.schemas.injection import (
InjectionEntity,
InjectionEntityRef,
@@ -63,6 +67,8 @@ from ww_api.schemas.projects import (
ReviewHistoryItem,
ReviewHistoryResponse,
ReviewRequest,
RewriteClarifyRequest,
RewriteStreamRequest,
)
from ww_api.services.accept_service import (
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.project_deps import (
get_chapter_repo,
get_clarify_gateway,
get_digest_append_repo,
get_digest_gateway,
get_injection_repo,
@@ -98,12 +105,20 @@ _ACCEPT_ERRORS: dict[int | str, dict[str, Any]] = {
409: {"model": ErrorEnvelope, "description": "存在未裁决冲突"},
503: {"model": ErrorEnvelope, "description": "LLM 不可用"},
}
# SSE 端点:如实声明 200 的 media type 为 text/event-stream否则 OpenAPI 误标 json
_SSE_RESPONSE: dict[int | str, dict[str, Any]] = {
200: {
"description": "SSE 事件流(`event: <name>\\ndata: <json>`,见 memory/contracts.md",
"content": {"text/event-stream": {}},
}
}
ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
ChapterRepoDep = Annotated[ChapterRepo, Depends(get_chapter_repo)]
GatewayDep = Annotated[Gateway, Depends(get_writer_gateway)]
ReviewGatewayDep = Annotated[Gateway, Depends(get_review_gateway)]
DigestGatewayDep = Annotated[Gateway, Depends(get_digest_gateway)]
ClarifyGatewayDep = Annotated[Gateway, Depends(get_clarify_gateway)]
MemoryReposDep = Annotated[MemoryRepos, Depends(get_memory_repos)]
InjectionRepoDep = Annotated[InjectionOverrideRepo, Depends(get_injection_repo)]
ReviewRepoDep = Annotated[ReviewRepo, Depends(get_review_repo)]
@@ -137,8 +152,9 @@ async def create_project(body: ProjectCreateRequest, repo: ProjectRepoDep) -> Pr
@router.get("")
async def list_projects(repo: ProjectRepoDep) -> ProjectListResponse:
views = await repo.list_for_owner(STUB_OWNER_ID)
async def list_projects(repo: ProjectRepoDep, page: PageDep) -> ProjectListResponse:
"""作品列表(按 created_at 升序,分页)。`?limit=&offset=`,默认第一页。"""
views = await repo.list_for_owner(STUB_OWNER_ID, limit=page.limit, offset=page.offset)
return ProjectListResponse(projects=[_to_response(v) for v in views])
@@ -247,7 +263,7 @@ def _encode_sse(event: SseEvent) -> str:
return f"event: {event.event}\ndata: {payload}\n\n"
@router.post("/{project_id}/chapters/{chapter_no}/draft")
@router.post("/{project_id}/chapters/{chapter_no}/draft", responses=_SSE_RESPONSE)
async def stream_draft(
project_id: uuid.UUID,
chapter_no: int,
@@ -310,6 +326,138 @@ async def stream_draft(
)
@router.post("/{project_id}/chapters/{chapter_no}/rewrite", responses=_SSE_RESPONSE)
async def stream_rewrite(
project_id: uuid.UUID,
chapter_no: int,
request: Request,
repos: MemoryReposDep,
gateway: GatewayDep,
injection_repo: InjectionRepoDep,
project_repo: ProjectRepoDep,
session: Annotated[AsyncSession, Depends(get_session)],
body: RewriteStreamRequest,
) -> StreamingResponse:
"""整章再沟通/重写:组装记忆 + 当前草稿 + 作者意见 → 网关流 → 归一 SSE → event-stream。
HITL新版停前端/草稿,接受才落库(不变量 #3rewrite 是工具非写节点,不破坏章
纯函数性(不变量 #7。项目不存在 → 404触网关前 fail-fast同 draftQA C1
"""
if await project_repo.get(STUB_OWNER_ID, project_id) is None:
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
request_id = getattr(request.state, "request_id", None)
override = await injection_repo.get(project_id, chapter_no)
context = await assemble(repos, project_id, chapter_no, override=override)
log.info(
"rewrite_stream_start",
project_id=str(project_id),
chapter_no=chapter_no,
request_id=request_id,
stable_core_len=len(context.stable_core),
volatile_len=len(context.volatile),
prior_draft_len=len(body.prior_draft),
feedback_len=len(body.feedback),
)
deltas = stream_chapter_rewrite(
gateway,
stable_core=context.stable_core,
volatile=context.volatile,
prior_draft=body.prior_draft,
feedback=body.feedback,
user_id=STUB_OWNER_ID,
project_id=project_id,
)
async def _frames() -> AsyncIterator[str]:
async for event in normalize_deltas(deltas, request_id=request_id):
yield _encode_sse(event)
try:
await session.commit()
except Exception: # noqa: BLE001 — 流已发完commit 失败不能再改响应;至少记错误。
log.error("sse_commit_failed", request_id=request_id, endpoint="rewrite")
return StreamingResponse(
_frames(),
media_type="text/event-stream",
headers={"cache-control": "no-cache", "x-accel-buffering": "no"},
)
# 整章重写预检的草稿摘录上界:只喂 analyst 预检**开头一小段**供锚定,绝不整章入 LLM
# (守成本/上下文,且预检只需判意见含糊度、不需读全章)。
_REWRITE_CLARIFY_EXCERPT_MAX = 3000
def _build_rewrite_clarify_input(feedback: str, prior_draft: str | None) -> str:
"""组整章重写预检输入:作者章级意见 + 当前章草稿的有界摘录(分块标注,供教条区分)。
只取 `prior_draft` 开头 `_REWRITE_CLARIFY_EXCERPT_MAX` 字符作锚定摘录——预检不需读全章,
避免把 200k 整章喂给 analyst 预检(成本/上下文/脱敏)。无草稿时只喂意见(退化,仍可判含糊)。
"""
parts = [f"【作者的整章修改意见】\n{feedback}"]
if prior_draft:
excerpt = prior_draft[:_REWRITE_CLARIFY_EXCERPT_MAX]
parts.append(f"【当前章节草稿(摘录,仅供锚定)】\n{excerpt}")
return "\n\n".join(parts)
@router.post(
"/{project_id}/chapters/{chapter_no}/rewrite/clarify",
responses={
404: {"model": ErrorEnvelope, "description": "项目不存在"},
503: {"model": ErrorEnvelope, "description": "LLM 不可用"},
},
)
async def clarify_rewrite(
project_id: uuid.UUID,
chapter_no: int,
body: RewriteClarifyRequest,
request: Request,
project_repo: ProjectRepoDep,
gateway: ClarifyGatewayDep,
session: Annotated[AsyncSession, Depends(get_session)],
) -> ClarifyDecision:
"""整章重写预检澄清WFW-9 M2 路线A 两阶段之「问题」阶段):非流式 JSON只判不改。
镜像 refine 侧 clarify——只判「作者这条整章重写意见清不清楚」含糊则反问 1 问 + 给选项,
清楚则给确认语放行;**绝不改正文**(整章重写仍走既有 rewrite SSE 端点。analyst 档
(不变量 #2。只取 `prior_draft` 有界摘录喂预检(不整章入 LLM
项目不存在 → 404触网关前 fail-fast同 rewrite/draft无凭据 → 503dep 解析阶段拦下)。
**只读不写业务表**(不变量 #3末尾 `commit()` 让网关 usage_ledger 落库add-only否则
记账静默丢失,同 rewrite 纪律)。判别/校验失败在 `run_clarify` 内确定性回退
`need_clarification=false`(不阻塞整章重写主链路)。
"""
request_id = getattr(request.state, "request_id", None)
if await project_repo.get(STUB_OWNER_ID, project_id) is None:
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
context = _build_rewrite_clarify_input(body.feedback, body.prior_draft)
decision = await run_clarify(
clarify_rewrite_spec, # 不变量 #2analyst 档,不传 model
context=context,
gateway=gateway,
user_id=STUB_OWNER_ID,
project_id=project_id,
)
# 提交边界:只读不写业务表,但网关 ledger add-only → 端点末尾 commit否则记账丢失
await session.commit()
log.info(
"rewrite_clarify_done",
project_id=str(project_id),
chapter_no=chapter_no,
request_id=request_id,
feedback_len=len(body.feedback), # 脱敏:只记长度,不记正文(不变量/日志纪律)
need_clarification=decision.need_clarification,
question_count=len(decision.questions),
)
return decision
@router.put("/{project_id}/chapters/{chapter_no}/draft")
async def save_draft(
project_id: uuid.UUID,
@@ -391,7 +539,7 @@ async def _resolve_review_draft(
return saved.content
@router.post("/{project_id}/chapters/{chapter_no}/review")
@router.post("/{project_id}/chapters/{chapter_no}/review", responses=_SSE_RESPONSE)
async def review_chapter(
project_id: uuid.UUID,
chapter_no: int,

View File

@@ -22,10 +22,10 @@ from typing import Annotated, Any
from fastapi import APIRouter, BackgroundTasks, Depends, Request, Response
from sqlalchemy.ext.asyncio import AsyncSession
from ww_agents import refiner_spec, style_extract_spec
from ww_agents import ClarifyDecision, clarify_refine_spec, refiner_spec, style_extract_spec
from ww_core.domain import JobRepo, ProjectRepo, StyleFingerprintWriteRepo
from ww_core.domain.style_repo import SqlStyleFingerprintWriteRepo
from ww_core.orchestrator import run_style_extraction
from ww_core.orchestrator import run_clarify, run_style_extraction
from ww_db import get_session
from ww_llm_gateway import Gateway
from ww_llm_gateway.types import Block, LlmRequest, Scope
@@ -34,6 +34,7 @@ from ww_shared import AppError, ErrorCode, ErrorEnvelope
from ww_api.logging_config import get_logger
from ww_api.schemas.style import (
DimensionEntry,
RefineClarifyRequest,
RefineRequest,
RefineResponse,
StyleFingerprintResponse,
@@ -45,6 +46,7 @@ from ww_api.services.foreshadow_scan import SessionFactory
from ww_api.services.job_runner import run_job
from ww_api.services.project_deps import (
build_gateway_for_tier,
get_clarify_gateway,
get_job_repo,
get_project_repo,
get_refine_gateway,
@@ -62,6 +64,7 @@ JobRepoDep = Annotated[JobRepo, Depends(get_job_repo)]
StyleWriteRepoDep = Annotated[StyleFingerprintWriteRepo, Depends(get_style_write_repo)]
StyleExtractGatewayDep = Annotated[Gateway, Depends(get_style_extract_gateway)]
RefineGatewayDep = Annotated[Gateway, Depends(get_refine_gateway)]
ClarifyGatewayDep = Annotated[Gateway, Depends(get_clarify_gateway)]
SessionDep = Annotated[AsyncSession, Depends(get_session)]
SessionFactoryDep = Annotated[SessionFactory, Depends(get_session_factory)]
@@ -238,3 +241,59 @@ async def refine_segment(
has_instruction=body.instruction is not None,
)
return RefineResponse(original=body.segment, refined=resp.text)
@router.post(
"/{project_id}/chapters/{chapter_no}/refine/clarify",
responses={
404: {"model": ErrorEnvelope, "description": "项目不存在"},
503: {"model": ErrorEnvelope, "description": "LLM 不可用"},
},
)
async def clarify_refine(
project_id: uuid.UUID,
chapter_no: int,
body: RefineClarifyRequest,
request: Request,
project_repo: ProjectRepoDep,
gateway: ClarifyGatewayDep,
session: SessionDep,
) -> ClarifyDecision:
"""润色预检澄清WFW-9 M1 路线A 两阶段之「问题」阶段):非流式 JSON只判不改。
独立于既有 refine 端点——只判「作者这条再沟通意见清不清楚」,含糊则反问 1 问 + 给选项,
清楚则给确认语放行;**绝不改正文**(正文仍走 refine 端点。analyst 档(不变量 #2
项目不存在 → 404无凭据 → 503dep 解析阶段拦下)。**只读不写库**(不变量 #3
末尾 `commit()` 让网关 usage_ledger 落库add-only否则记账静默丢失同 refine 纪律)。
判别/校验失败在 `run_clarify` 内确定性回退 `need_clarification=false`(不阻塞润色)。
"""
request_id = getattr(request.state, "request_id", None)
project = await project_repo.get(STUB_OWNER_ID, project_id)
if project is None:
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
context = _build_refine_input(body.segment, body.instruction or None)
decision = await run_clarify(
clarify_refine_spec, # 不变量 #2analyst 档,不传 model
context=context,
gateway=gateway,
user_id=STUB_OWNER_ID,
project_id=project_id,
)
# 提交边界:只读不写业务表,但网关 ledger add-only → 端点末尾 commit否则记账丢失
await session.commit()
log.info(
"refine_clarify_done",
project_id=str(project_id),
chapter_no=chapter_no,
request_id=request_id,
segment_len=len(body.segment),
instruction_len=len(body.instruction),
need_clarification=decision.need_clarification,
question_count=len(decision.questions),
)
return decision

View File

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

View File

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

View File

@@ -0,0 +1,74 @@
"""AI 对话(聊天记录)端点的请求/响应 schemaAC-2 / ai-chat-history-plan §3.2)。
snake_case前端经 OpenAPI 生成 TS 类型消费(改字段 → 前端 `pnpm gen:api`)。
`Literal` 是 kind/role 的**枚举权威**DB 列自由 Text边界在此收窄非法 kind/role → 422。
批级字段thread_id/chapter_no/kind/tool_key提到请求顶层`messages` 是本批各条 bubble。
长文进 `content`(有手稿级上限),`meta` 有序列化上界兜底防绕过 content 上限。
"""
from __future__ import annotations
import json
import uuid
from datetime import datetime
from typing import Annotated, Any, Literal
from pydantic import BaseModel, Field, StringConstraints, field_validator
# kind/role 的枚举权威DB 列自由 Text加新 kind 改这里 + 零迁移)。
AiKind = Literal["refine", "rewrite", "clarify", "continue", "generator"]
AiRole = Literal["author", "ai"]
# content 上界 = 手稿上限rewrite 整章文本级命名常量非魔法数CR-H9
AI_MESSAGE_CONTENT_MAX = 200_000
# meta 序列化上界兜底:防长文绕过 content 上限塞进 meta。
_META_MAX_SERIALIZED = 50_000
# 一次 append 的最大条数(一次交换的多轮 bubble
_MAX_TURNS_PER_CALL = 50
class AiMessageInput(BaseModel):
"""一条待落库 bubble角色 + 人读文本 + 结构化 meta"""
role: AiRole
content: Annotated[str, StringConstraints(min_length=1, max_length=AI_MESSAGE_CONTENT_MAX)]
meta: dict[str, Any] = Field(default_factory=dict)
@field_validator("meta")
@classmethod
def _bound_meta(cls, v: dict[str, Any]) -> dict[str, Any]:
if len(json.dumps(v, ensure_ascii=False)) > _META_MAX_SERIALIZED:
raise ValueError("meta too large; put long text in content")
return v
class AiMessageAppendRequest(BaseModel):
"""POST /projects/{id}/ai-messages一次交换的一批 bubble批级字段提顶层"""
thread_id: uuid.UUID
chapter_no: Annotated[int, Field(ge=1)] | None = None # NULL = 项目级(工具箱)
kind: AiKind
tool_key: str | None = None
messages: Annotated[list[AiMessageInput], Field(min_length=1, max_length=_MAX_TURNS_PER_CALL)]
class AiMessageView(BaseModel):
"""聊天留痕视图append/list 回显snake_case镜像 repo view"""
id: uuid.UUID
project_id: uuid.UUID
chapter_no: int | None
thread_id: uuid.UUID
seq: int
kind: str
tool_key: str | None
role: str
content: str
meta: dict[str, Any] = Field(default_factory=dict)
created_at: datetime
class AiMessageListResponse(BaseModel):
"""append/list 信封:`messages` 复数键repo 约定,非 `items`)。"""
messages: list[AiMessageView]

View File

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

View File

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

View File

@@ -11,11 +11,16 @@ from typing import Any, Literal
from pydantic import BaseModel, Field
# 请求文本长度上界CR-H9防超长入参 DoS/成本)。标题=标签级;章正文=整章级。
_TITLE_MAX = 200
_CHAPTER_TEXT_MAX = 200_000
_DIRECTIVE_MAX = 20_000 # CR-H9 收尾:本章指令喂 LLM加上界正文级 body 中间件仍兜底)
class ProjectCreateRequest(BaseModel):
"""POST /projects立项向导字段owner_id 由后端补 stub不入参"""
title: str = Field(min_length=1)
title: str = Field(min_length=1, max_length=_TITLE_MAX)
genre: str | None = None
logline: str | None = None
premise: str | None = None
@@ -117,13 +122,44 @@ class DraftStreamRequest(BaseModel):
后端只入 volatile守缓存前缀不变量 #9。无 body 时退化为纯按大纲生成(向后兼容)。
"""
directive: str | None = None
directive: str | None = Field(default=None, max_length=_DIRECTIVE_MAX)
# 整章重写输入上界(守 DoS/成本CR-H9 方向意见短、整章草稿大复用章正文上界DRY
_REWRITE_FEEDBACK_MAX = 4000
_REWRITE_DRAFT_MAX = _CHAPTER_TEXT_MAX
class RewriteStreamRequest(BaseModel):
"""POST /projects/:id/chapters/:no/rewrite整章再沟通/重写。
`feedback` = 作者对本章的修改意见;`prior_draft` = 当前整章草稿(前端传入,据此 + 记忆
注入流式重写一版)。临时输入、不持久化、无迁移;只入 volatile守缓存前缀不变量 #9
新版停前端/草稿接受才落库HITL不变量 #3
"""
feedback: str = Field(min_length=1, max_length=_REWRITE_FEEDBACK_MAX)
prior_draft: str = Field(min_length=1, max_length=_REWRITE_DRAFT_MAX)
class RewriteClarifyRequest(BaseModel):
"""整章重写预检澄清:章级意见 + 可选当前草稿WFW-9 M2 路线A 两阶段之「问题」阶段)。
与 `RewriteStreamRequest` 独立——预检只判「作者这条章级意见清不清楚」,**不改正文**
(整章重写仍走既有 rewrite SSE 端点)。`feedback` = 作者对整章的修改意见(触发反问的场景,
可极短/含糊);`prior_draft` = 当前整章草稿(可选,仅供锚定;端点只取有界摘录喂 analyst 预检,
不整章入 LLM。带长度上界防超长入参。响应=`ClarifyDecision`(在 ww_agents端点直接返回
供前端 gen:api 生成强类型客户端)。
"""
feedback: str = Field(min_length=1, max_length=_REWRITE_FEEDBACK_MAX)
prior_draft: str | None = Field(default=None, max_length=_REWRITE_DRAFT_MAX)
class DraftSaveRequest(BaseModel):
"""PUT /projects/:id/chapters/:no/draft自动保存草稿正文。"""
text: str
text: str = Field(max_length=_CHAPTER_TEXT_MAX)
class DraftResponse(BaseModel):
@@ -162,7 +198,7 @@ class ReviewRequest(BaseModel):
不传 `draft` 时端点回退到已保存的草稿chapter_repo
"""
draft: str | None = None
draft: str | None = Field(default=None, max_length=_CHAPTER_TEXT_MAX)
class ReviewConflictView(BaseModel):
@@ -220,7 +256,9 @@ class ConflictDecision(BaseModel):
class AcceptRequest(BaseModel):
"""POST /projects/:id/chapters/:no/accept裁决清单 + 可能改过的终稿。"""
final_text: str = Field(min_length=1, description="作者裁决/改稿后的最终验收文本")
final_text: str = Field(
min_length=1, max_length=_CHAPTER_TEXT_MAX, description="作者裁决/改稿后的最终验收文本"
)
decisions: list[ConflictDecision] = Field(
default_factory=list, description="对最近审稿每个冲突的裁决每冲突必有其一R5"
)

View File

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

View File

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

View File

@@ -7,15 +7,26 @@ snake_case命名契约见 memory/gotchas。前端经 OpenAPI→TS 客
from __future__ import annotations
import uuid
from typing import Literal
from typing import Annotated, Literal
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, StringConstraints
# 请求文本长度上界CR-H9防超长入参空跑 LLM/DoS。选段/样本 20k 字符,样本列表 ≤20 条。
_SEGMENT_MAX = 20_000
_SAMPLE_MAX = 20_000
_SAMPLES_LIST_MAX = 20
# 预检澄清沿用相同选段上界DRY指令短。
_MAX_CLARIFY_SEGMENT_LEN = _SEGMENT_MAX
_MAX_CLARIFY_INSTRUCTION_LEN = 2000
class StyleLearnRequest(BaseModel):
"""学文风:上传样本正文(前端可读文件转文本)+ 模式(首学 / 更新)。"""
samples: list[str] = Field(min_length=1)
# 同时约束**列表条数**与**每条样本长度**(最大 DoS 面:多条超长正文)。
samples: list[Annotated[str, StringConstraints(max_length=_SAMPLE_MAX)]] = Field(
min_length=1, max_length=_SAMPLES_LIST_MAX
)
mode: Literal["create", "update"] = "create"
@@ -47,8 +58,9 @@ class StyleFingerprintResponse(BaseModel):
class RefineRequest(BaseModel):
"""回炉:重写选中段(可选改写指令)。"""
segment: str = Field(min_length=1)
instruction: str | None = None
segment: str = Field(min_length=1, max_length=_SEGMENT_MAX)
# CR-H9 收尾:再沟通意见喂 LLM加上界与 clarify instruction 同口径 2000
instruction: str | None = Field(default=None, max_length=_MAX_CLARIFY_INSTRUCTION_LEN)
class RefineResponse(BaseModel):
@@ -56,3 +68,15 @@ class RefineResponse(BaseModel):
original: str
refined: str
class RefineClarifyRequest(BaseModel):
"""润色预检澄清:选段 + 再沟通意见WFW-9 M1 路线A 两阶段之「问题」阶段)。
与 `RefineRequest` 独立——预检只判「意见清不清楚」,不改正文。`instruction` 为作者的
再沟通意见(可空/极短,正是触发反问的场景);带长度上界防超长入参。响应=`ClarifyDecision`
(在 ww_agents端点直接返回供前端 gen:api 生成强类型客户端)。
"""
segment: str = Field(min_length=1, max_length=_MAX_CLARIFY_SEGMENT_LEN)
instruction: str = Field(default="", max_length=_MAX_CLARIFY_INSTRUCTION_LEN)

View File

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

View File

@@ -50,6 +50,11 @@ log = structlog.get_logger(__name__)
# 链 job 的 kindjobs.kind 自由 Text 列零迁移复用§7
JOB_KIND_CHAIN = "chain"
# 每章在链图里的超级步数write→review→decide→accept用于算 recursion_limit 上界。
NODES_PER_CHAPTER = 4 # 每章 4 个超级步write→review→decide→accept
# START 边 + interrupt/resume 重入的余量(防御性冗余,非精确计数)。
RECURSION_LIMIT_HEADROOM = 10 # START 边 + interrupt/resume 重入余量
# 「按 session 建 digest 网关」缝accept 节点在自建短事务里需 light 档网关跑终稿提炼。
DigestGatewayBuilder = Callable[[Any], Awaitable[Gateway]]
@@ -156,13 +161,13 @@ def _chain_result(state: dict[str, Any], *, awaiting_chapter: int | None) -> dic
}
def _extract_awaiting_chapter(final: dict[str, Any]) -> int | None:
"""图返回值判 interrupt有 `__interrupt__` → 取暂停章号;否则 None跑完
def _extract_awaiting_chapter(interrupts: Sequence[Any]) -> int | None:
""" pending interrupts 判暂停章号:空 → None跑完否则取首个 interrupt 载荷的章号
interrupt 载荷 = `{"chapter_no": n}`(见链图 `_accept` 节点。LangGraph 把它放在
`final["__interrupt__"]`Interrupt 对象列表)。容错读 `.value`/dict 两种形。
interrupts 经**公开** `graph.aget_state(config).interrupts``StateSnapshot`)拿到,不再
反手 ainvoke 返回值里的私有 `__interrupt__` 键。载荷 = `{"chapter_no": n}`(见链图 `_accept`
节点)。容错读 `.value`/dict 两种形。
"""
interrupts = final.get("__interrupt__")
if not interrupts:
return None
first = interrupts[0]
@@ -207,7 +212,14 @@ async def run_chain_job(
try:
async with checkpointer_ctx() as checkpointer:
await _set_running(session_factory, job_id)
config: RunnableConfig = {"configurable": {"thread_id": str(job_id)}}
# 显式每链 recursion_limit 上界防御硬化CR-C2count 已被端点上限 MAX_CHAIN_COUNT
# 夹住,故 count*每章步数+余量 是安全且贴合的上界。**必须放 config 顶层**——放到
# `configurable` 下 langgraph 会静默忽略。单份 config 同时喂初始 run 与 resume 分支。
recursion_limit = count * NODES_PER_CHAPTER + RECURSION_LIMIT_HEADROOM
config: RunnableConfig = {
"configurable": {"thread_id": str(job_id)},
"recursion_limit": recursion_limit,
}
graph = build_chain_graph(
chain_gateway_builder,
session_factory=session_factory,
@@ -232,9 +244,11 @@ async def run_chain_job(
resume_value = [d.model_dump() for d in resume_decisions]
raw_final = await graph.ainvoke(Command(resume=resume_value), config=config)
final: dict[str, Any] = dict(raw_final)
awaiting_chapter = _extract_awaiting_chapter(final)
result = _chain_result(final, awaiting_chapter=awaiting_chapter)
# 经公开 `StateSnapshot.interrupts` 判 pending interrupt不反手私有 `__interrupt__`
# checkpointer 仍在 `checkpointer_ctx()` 上下文内aget_state 可读同一 thread 检查点。
snapshot = await graph.aget_state(config)
awaiting_chapter = _extract_awaiting_chapter(snapshot.interrupts)
result = _chain_result(dict(raw_final), awaiting_chapter=awaiting_chapter)
await _finish(session_factory, job_id, result, awaiting=awaiting_chapter is not None)
log.info(
"chain_job_settled",

View File

@@ -18,6 +18,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ww_config import get_settings
from ww_core.domain import ForeshadowLedgerRepo, SqlForeshadowLedgerRepo
from ww_core.domain.ai_message_repo import AiMessageRepo, SqlAiMessageRepo
from ww_core.domain.chapter_repo import ChapterRepo, SqlChapterRepo
from ww_core.domain.character_repo import CharacterWriteRepo, SqlCharacterWriteRepo
from ww_core.domain.digest_repo import DigestAppendRepo, SqlDigestAppendRepo
@@ -160,6 +161,16 @@ def get_rule_write_repo(
return SqlRuleWriteRepo(session)
def get_ai_message_repo(
session: Annotated[AsyncSession, Depends(get_session)],
) -> AiMessageRepo:
"""AI 对话留痕 repoPOST/GET /projects/{id}/ai-messagesappend 只 flush端点提交
测试经 `app.dependency_overrides[get_ai_message_repo]` 注入内存 fake。
"""
return SqlAiMessageRepo(session)
async def get_template_repo(
session: Annotated[AsyncSession, Depends(get_session)],
) -> TemplateRepo:
@@ -519,6 +530,18 @@ async def get_refine_gateway(
return await build_gateway_for_tier(session, store, "writer")
async def get_clarify_gateway(
session: Annotated[AsyncSession, Depends(get_session)],
) -> Gateway:
"""润色预检澄清analyst 档位)的可注入网关缝。
`POST .../refine/clarify` 在 dep 解析阶段构建网关 → 无凭据时这里抛 `LLM_UNAVAILABLE`
503进入端点前拦下。测试经 override 注 mock产 `ClarifyDecision`)。
"""
store = SqlCredentialStore(session)
return await build_gateway_for_tier(session, store, "analyst")
async def get_chain_gateway(
session: Annotated[AsyncSession, Depends(get_session)],
) -> Gateway:

View File

@@ -14,6 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from ww_config import get_settings
from ww_db import get_session
from ww_llm_gateway.adapters.base import Capabilities
from ww_llm_gateway.adapters.openai_compat import OpenAICompatAdapter
from ww_llm_gateway.factory import build_adapter
from ww_shared import AppError, ErrorCode
@@ -85,10 +86,17 @@ class GatewayProviderProbe:
# 经网关工厂构造适配器base_url 单点归网关 build_adapter不在此本地构造 AsyncOpenAI
adapter = build_adapter(provider, api_key=api_key, base_url=base_url)
# `_PROVIDER_BASE_URLS` 里的 api_key 型 provider 均映射到 OpenAI 兼容适配器(含
# kimi-code-key 子类);据此收窄类型,用公开 `probe_connection` 探测(不反手私有客户端)。
if not isinstance(adapter, OpenAICompatAdapter):
raise AppError(
ErrorCode.VALIDATION,
f"provider {provider} 不支持 api_key 连接测试",
{"provider": provider},
)
try:
# 最小探测:列模型即可验证 Key 有效(不消耗生成额度)。底层 AsyncOpenAI 由适配器持有
# (已知 provider 为 OpenAI 兼容,见 _PROVIDER_BASE_URLS读私有客户端属同仓既有约定。
await adapter._client.models.list() # type: ignore[attr-defined] # noqa: SLF001
# 最小探测:列模型即可验证 Key 有效(不消耗生成额度)。
await adapter.probe_connection()
except Exception as exc: # noqa: BLE001 — 任一失败都映射为 LLM 不可用
raise AppError(
ErrorCode.LLM_UNAVAILABLE,

View File

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

View File

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

View File

@@ -1,93 +0,0 @@
import Link from "next/link";
import {
BookOpen,
ClipboardCheck,
ListTree,
PenLine,
Sparkles,
type LucideIcon,
} from "lucide-react";
import { AiToolbarMoreMenu } from "@/components/AiToolbarMoreMenu";
import {
aiToolItems,
primaryAiToolItems,
secondaryAiToolItems,
} from "@/lib/nav/ai-tools";
import type { ActiveNav } from "@/lib/nav/items";
import { buttonClass } from "@/lib/ui/variants";
interface AiToolbarProps {
projectId: string;
activeNav?: ActiveNav;
}
// T4-a · 项目内页常驻 AI 工具条顶栏下UX §3/§5
// 桌面展示完整工具条;窄屏只保留写本章/审稿,把低频入口收进更多菜单。
export function AiToolbar({ projectId, activeNav }: AiToolbarProps) {
const primaryItems = primaryAiToolItems(projectId);
const secondaryItems = secondaryAiToolItems(projectId);
return (
<nav
aria-label="AI 工具条"
className="sticky top-16 z-20 flex h-12 items-center gap-2 border-b border-line bg-panel px-4 sm:px-6"
>
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden lg:hidden">
{primaryItems.map((item) => {
const isActive = item.key === activeNav;
const className = toolClassName(item.primary === true, isActive);
const Icon = toolIcon(item.key);
return (
<Link
key={item.href}
href={item.href}
aria-current={isActive ? "page" : undefined}
className={className}
>
<Icon className="h-4 w-4" aria-hidden="true" />
<span>{item.label}</span>
</Link>
);
})}
<AiToolbarMoreMenu items={secondaryItems} activeNav={activeNav} />
</div>
<div className="hidden items-center gap-2 overflow-x-auto whitespace-nowrap lg:flex">
{aiToolItems(projectId).map((item) => {
const isActive = item.key === activeNav;
const className = toolClassName(item.primary === true, isActive);
const Icon = toolIcon(item.key);
return (
<Link
key={item.href}
href={item.href}
aria-current={isActive ? "page" : undefined}
className={className}
>
<Icon className="h-4 w-4" aria-hidden="true" />
<span>{item.label}</span>
</Link>
);
})}
</div>
</nav>
);
}
function toolIcon(key: ActiveNav): LucideIcon {
if (key === "write") return PenLine;
if (key === "review") return ClipboardCheck;
if (key === "outline") return ListTree;
if (key === "codex") return BookOpen;
return Sparkles;
}
function toolClassName(isPrimary: boolean, isActive: boolean): string {
if (isActive) {
return buttonClass({ variant: "outline", size: "sm" });
}
if (isPrimary) {
return buttonClass({ variant: "primary", size: "sm" });
}
return buttonClass({ variant: "secondary", size: "sm" });
}

View File

@@ -1,95 +0,0 @@
"use client";
import Link from "next/link";
import { MoreHorizontal } from "lucide-react";
import { useEffect, useId, useRef, useState } from "react";
import type { AiToolItem } from "@/lib/nav/ai-tools";
import type { ActiveNav } from "@/lib/nav/items";
import { buttonClass, cn, focusRing } from "@/lib/ui/variants";
interface AiToolbarMoreMenuProps {
items: AiToolItem[];
activeNav?: ActiveNav;
}
export function AiToolbarMoreMenu({
items,
activeNav,
}: AiToolbarMoreMenuProps) {
const [open, setOpen] = useState(false);
const menuId = useId();
const rootRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent): void => {
if (event.key === "Escape") {
setOpen(false);
buttonRef.current?.focus();
}
};
const onPointerDown = (event: PointerEvent): void => {
const target = event.target;
if (target instanceof Node && !rootRef.current?.contains(target)) {
setOpen(false);
}
};
window.addEventListener("keydown", onKeyDown);
window.addEventListener("pointerdown", onPointerDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("pointerdown", onPointerDown);
};
}, [open]);
return (
<div ref={rootRef} className="relative">
<button
ref={buttonRef}
type="button"
aria-haspopup="true"
aria-expanded={open}
aria-controls={menuId}
onClick={() => setOpen((value) => !value)}
className={buttonClass({ variant: "secondary", size: "sm" })}
>
<MoreHorizontal className="h-4 w-4" aria-hidden="true" />
</button>
{open ? (
// 诚实的 Tab-only 折叠面板:普通导航链接,不冒充 ARIA menu无 roving tabindex/方向键)。
<nav
id={menuId}
aria-label="更多工具"
className="absolute right-0 z-30 mt-2 min-w-36 rounded border border-line bg-panel p-1 shadow-paper"
>
{items.map((item) => {
const active = item.key === activeNav;
return (
<Link
key={item.href}
href={item.href}
aria-current={active ? "page" : undefined}
onClick={() => setOpen(false)}
className={cn(
"block rounded px-3 py-2 text-sm transition-colors",
focusRing,
active
? "bg-[var(--color-cinnabar-wash)] text-cinnabar"
: "text-ink hover:bg-bg hover:text-cinnabar",
)}
>
{item.label}
</Link>
);
})}
</nav>
) : null}
</div>
);
}

View File

@@ -4,7 +4,6 @@ import { Settings } from "lucide-react";
import type { ActiveNav } from "@/lib/nav/items";
import { buttonClass, focusRing } from "@/lib/ui/variants";
import { AiToolbar } from "./AiToolbar";
import { CommandSearchButton } from "./command/CommandPaletteMount";
import { LeftNav } from "./LeftNav";
import { NavDrawer } from "./NavDrawer";
@@ -32,7 +31,7 @@ export function AppShell({
return (
<div
className="min-h-screen"
style={{ ["--chrome" as string]: projectId ? "7rem" : "4rem" }}
style={{ ["--chrome" as string]: "4rem" }}
>
<a
href="#main"
@@ -80,9 +79,6 @@ export function AppShell({
</Link>
</nav>
</header>
{projectId ? (
<AiToolbar projectId={projectId} activeNav={activeNav} />
) : null}
<div className="flex">
<LeftNav projectId={projectId} activeNav={activeNav} />
<main

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
"use client";
import { Fragment } from "react";
import Link from "next/link";
import {
Blocks,
@@ -19,9 +20,8 @@ import {
import { usePathname } from "next/navigation";
import {
GLOBAL_NAV_ITEMS,
isNavItemActive,
projectNavItems,
navSections,
type ActiveNav,
type NavItem,
} from "@/lib/nav/items";
@@ -36,35 +36,32 @@ interface NavItemsProps {
}
// 导航条目列表(桌面静态侧栏与移动抽屉共用,避免渲染漂移)。
// 项目内页把 10 项按 写作/资料库/审阅/更多 分组P2-1每组一个小标题全局组无标题直显于顶部。
export function NavItems({ projectId, activeNav, onNavigate }: NavItemsProps) {
const pathname = usePathname() ?? "/";
const scoped = projectId ? projectNavItems(projectId) : [];
const sections = navSections(projectId);
return (
<ul className="flex flex-col gap-1 px-2">
{GLOBAL_NAV_ITEMS.map((item) => (
<NavLink
key={item.label}
item={item}
active={isNavItemActive(item, { pathname, activeNav })}
onNavigate={onNavigate}
/>
))}
{scoped.length > 0 ? (
<li
className="mt-3 px-3 pb-1 text-xs font-semibold uppercase tracking-wide text-ink-soft/60"
aria-hidden="true"
>
</li>
) : null}
{scoped.map((item) => (
<NavLink
key={item.label}
item={item}
active={isNavItemActive(item, { pathname, activeNav })}
onNavigate={onNavigate}
/>
{sections.map((section) => (
<Fragment key={section.label ?? "__global"}>
{section.label !== null ? (
<li
className="mt-3 px-3 pb-1 text-xs font-semibold uppercase tracking-wide text-ink-soft/60"
aria-hidden="true"
>
{section.label}
</li>
) : null}
{section.items.map((item) => (
<NavLink
key={item.label}
item={item}
active={isNavItemActive(item, { pathname, activeNav })}
onNavigate={onNavigate}
/>
))}
</Fragment>
))}
</ul>
);

View File

@@ -58,10 +58,23 @@ export function ForeshadowCard({
<dl className="mt-2 space-y-0.5 text-xs text-ink-soft">
{typeof item.planted_at === "number" ? (
<div> {item.planted_at} </div>
<div className="flex gap-1">
<dt></dt>
<dd>{item.planted_at} </dd>
</div>
) : null}
{windowText ? (
<div className="flex gap-1">
<dt></dt>
<dd>{windowText}</dd>
</div>
) : null}
{item.importance ? (
<div className="flex gap-1">
<dt className="sr-only"></dt>
<dd>{item.importance}</dd>
</div>
) : null}
{windowText ? <div> {windowText}</div> : null}
{item.importance ? <div>{item.importance}</div> : null}
</dl>
{item.progress && item.progress.length > 0 ? (

View File

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

View File

@@ -0,0 +1,58 @@
"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { AppShell } from "@/components/AppShell";
import { TextArea } from "@/components/ui/TextArea";
import { useDeferredDraft } from "@/lib/start/useDeferredDraft";
// 敲入正文后停顿多久才懒建项目(落库)——在停顿点落库,快照即所见,避免打断连续输入。
const CREATE_DEBOUNCE_MS = 700;
// 进来即写:空白编辑器直达。敲入实质正文并停顿后懒建占位项目 + 落首章草稿,随即换入完整工作台。
// 空进空出不落库(从不敲字 → 从不 POST。落库逻辑在 useDeferredDraft已单测此处只管交互。
export function DraftComposer() {
const router = useRouter();
const { ensureCreated } = useDeferredDraft();
const [text, setText] = useState("");
const textRef = useRef("");
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const redirectingRef = useRef(false);
const tryCreate = async (): Promise<void> => {
if (redirectingRef.current) return;
const snapshot = textRef.current;
if (snapshot.trim().length === 0) return;
const id = await ensureCreated(snapshot);
if (id && !redirectingRef.current) {
redirectingRef.current = true;
router.replace(`/projects/${id}/write`);
}
};
const onChange = (value: string): void => {
setText(value);
textRef.current = value;
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => void tryCreate(), CREATE_DEBOUNCE_MS);
};
return (
<AppShell>
<div className="mx-auto flex min-h-[calc(100vh-var(--chrome,4rem))] max-w-3xl flex-col px-6 py-10 sm:px-8">
<p className="mb-4 text-sm text-ink-soft">
</p>
<TextArea
value={text}
onChange={(e) => onChange(e.target.value)}
className="min-h-[60vh] flex-1 resize-none text-lg leading-8"
placeholder="从这里开始写你的第一章……"
autoFocus
aria-label="正文"
/>
</div>
</AppShell>
);
}

View File

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

View File

@@ -1,6 +1,6 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { ArrowLeft, Database, Sparkles } from "lucide-react";
import { useToast } from "@/components/Toast";
@@ -23,13 +23,25 @@ import {
type PreviewItem,
} from "@/lib/toolbox/toolbox";
import { ingestTable, isSingleObjectIngest } from "@/lib/toolbox/ingest";
import { fieldSummary, renderPreviewText } from "@/lib/toolbox/aiSummary";
import { buildManuscriptExcerpt } from "@/lib/workbench/manuscriptExcerpt";
import { useGenerator } from "@/lib/toolbox/useGenerator";
import type { AiTurnInput } from "@/lib/workbench/useAiConversation";
import { TemplateFiller } from "./TemplateFiller";
interface GeneratorRunnerProps {
projectId: string;
tool: ToolDescriptorView;
onClose: () => void;
// 可选:把某条预览文本插入正文(编辑器内联工具箱场景)。工具箱页不传 → 不显示「插入正文」。
onInsertText?: (text: string) => void;
// 可选:作者【已写正文】。传入且当前工具有 brief 字段时,表单里出现「用当前正文」按钮,
// 点击把正文摘录填进 brief让内容感知型生成器如书名据正文反推。工具箱页不传 → 不显示。
manuscriptText?: string;
// 可选留痕:生成成功后 append 一批 [author(字段摘要), ai(预览文本)]。
// 内联工具箱传 conversation.appendMessages + 当前章号;工具箱页传 silent 直投 + chapterNo=null。
appendMessages?: (turn: AiTurnInput) => void;
logChapterNo?: number | null;
}
// 声明驱动的通用生成器(核心):按 descriptor.input_fields 渲染表单 → 调通用 generate →
@@ -39,6 +51,10 @@ export function GeneratorRunner({
projectId,
tool,
onClose,
onInsertText,
manuscriptText,
appendMessages,
logChapterNo,
}: GeneratorRunnerProps) {
const gen = useGenerator();
const toast = useToast();
@@ -47,6 +63,8 @@ export function GeneratorRunner({
);
// 入库勾选:预览行下标集合(与 gen.rawPreview 行对齐)。
const [selected, setSelected] = useState<Set<number>>(new Set());
// 本 runner 一次会话 = 一个 thread多次生成归此组。
const threadId = useRef(crypto.randomUUID()).current;
const setField = useCallback((name: string, value: string): void => {
setValues((prev) => ({ ...prev, [name]: value }));
@@ -64,6 +82,26 @@ export function GeneratorRunner({
[tool.input_fields, fillTarget],
);
// 当前工具是否有名为 brief 的输入字段——只有它才承接「据正文反推」。
const hasBriefField = useMemo(
() => (tool.input_fields ?? []).some((f) => f.name === "brief"),
[tool.input_fields],
);
// 有 brief 字段且拿到非空正文时,才提供「用当前正文」。
const canUseManuscript =
hasBriefField && (manuscriptText?.trim().length ?? 0) > 0;
const onUseManuscript = useCallback((): void => {
if (!manuscriptText) return;
const excerpt = buildManuscriptExcerpt(manuscriptText);
if (excerpt.length === 0) {
toast("当前还没有正文可供反推。", "error");
return;
}
setField("brief", excerpt);
toast("已按当前正文填入,可继续编辑后生成。", "success");
}, [manuscriptText, setField, toast]);
const onGenerate = useCallback(async (): Promise<void> => {
const missing = missingRequiredFields(tool.input_fields, values);
if (missing.length > 0) {
@@ -71,8 +109,36 @@ export function GeneratorRunner({
return;
}
setSelected(new Set());
await gen.generate(projectId, tool.key, buildGenerateRequest(values));
}, [gen, projectId, tool, values, toast]);
const artifact = await gen.generate(
projectId,
tool.key,
buildGenerateRequest(values),
);
// 留痕成功且有可读预览时author(字段摘要) + ai(渲染预览);空产物/错误路径不记。
if (artifact && appendMessages) {
const aiText = renderPreviewText(artifact.preview);
if (aiText.length > 0) {
appendMessages({
threadId,
kind: "generator",
toolKey: tool.key,
chapterNo: logChapterNo ?? null,
messages: [
{
role: "author",
content: fieldSummary(tool.input_fields ?? [], values) || tool.title,
meta: { tool_key: tool.key, input_fields: values },
},
{
role: "ai",
content: aiText,
meta: { tool_key: tool.key, output_kind: artifact.outputKind },
},
],
});
}
}
}, [gen, projectId, tool, values, toast, appendMessages, logChapterNo, threadId]);
const rows = useMemo(
() => previewRows(gen.rawPreview),
@@ -163,6 +229,22 @@ export function GeneratorRunner({
targetLabel={fillLabel}
onFill={setField}
/>
{canUseManuscript ? (
<div className="flex items-center justify-between gap-2 rounded border border-line bg-bg/50 p-2">
<span className="text-xs text-ink-soft">
</span>
<Button
type="button"
onClick={onUseManuscript}
variant="outline"
size="sm"
>
<Sparkles className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
) : null}
{(tool.input_fields ?? []).map((field) => (
<FormField
key={field.name}
@@ -202,6 +284,11 @@ export function GeneratorRunner({
selectable={canIngest && !singleObject}
checked={selected.has(i)}
onToggle={toggle}
onInsert={
onInsertText
? () => onInsertText(item.body ?? item.heading ?? "")
: undefined
}
/>
))}
</ul>
@@ -276,6 +363,8 @@ interface PreviewRowProps {
selectable: boolean;
checked: boolean;
onToggle: (index: number) => void;
// 可选:把本条插入正文(编辑器内联工具箱)。有可插入文本时才渲染按钮。
onInsert?: () => void;
}
// 统一预览行渲染heading + 次要字段 + 正文段),由 mapPreview 归一,跨工具复用。
@@ -285,7 +374,9 @@ function PreviewRow({
selectable,
checked,
onToggle,
onInsert,
}: PreviewRowProps) {
const canInsert = onInsert && (item.body || item.heading);
return (
<li className="flex gap-2 rounded border border-line bg-bg p-3 text-sm">
{selectable ? (
@@ -310,6 +401,17 @@ function PreviewRow({
{item.body ? (
<p className="whitespace-pre-wrap text-ink">{item.body}</p>
) : null}
{canInsert ? (
<Button
onClick={onInsert}
variant="outline"
size="sm"
className="mt-2"
>
<ArrowLeft className="h-4 w-4 rotate-90" aria-hidden="true" />
</Button>
) : null}
</div>
</li>
);

View File

@@ -9,6 +9,7 @@ import { EmptyState } from "@/components/ui/EmptyState";
import { PageHeader } from "@/components/ui/PageHeader";
import type { ProjectResponse, ToolDescriptorView } from "@/lib/api/types";
import { isLegacyTool, resolveLegacyRoute } from "@/lib/toolbox/toolbox";
import { useAiConversation } from "@/lib/workbench/useAiConversation";
import { ToolCard } from "./ToolCard";
import { GeneratorRunner } from "./GeneratorRunner";
@@ -27,6 +28,8 @@ export function ToolboxPage({
initialOpenKey,
}: ToolboxPageProps) {
const router = useRouter();
// 工具箱页无抽屉silent 直投——项目级(chapter_no=null)留痕,不 GET、失败不 toast。
const conversation = useAiConversation(project.id, 0, { silent: true });
const findOpenable = useCallback(
(key: string | undefined): ToolDescriptorView | null => {
@@ -76,6 +79,8 @@ export function ToolboxPage({
<GeneratorRunner
projectId={project.id}
tool={active}
appendMessages={conversation.appendMessages}
logChapterNo={null}
onClose={() => setActive(null)}
/>
) : sorted.length === 0 ? (

View File

@@ -0,0 +1,284 @@
"use client";
import { useEffect, useRef, type RefObject } from "react";
import { MessagesSquare, X } from "lucide-react";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { EmptyState } from "@/components/ui/EmptyState";
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 { overlayScrim } from "@/lib/ui/variants";
import type { AiMessageView } from "@/lib/api/types";
import {
acceptActionFor,
groupByThread,
kindBadge,
kindLabel,
partitionByScope,
type AcceptAction,
type AiThread,
} from "@/lib/workbench/aiConversation";
import { readSegment } from "@/lib/workbench/inlineConversation";
import type { UseAiConversation } from "@/lib/workbench/useAiConversation";
import { ConversationThread } from "./ConversationThread";
// AI 对话总开关:一键关闭即回滚(触发按钮据此隐藏),仿 CONTEXT_DRAWER_ENABLED。
export const AI_CONVERSATION_ENABLED = true;
interface AiConversationDrawerProps {
open: boolean;
onClose: () => void;
conversation: UseAiConversation;
currentChapterNo: number;
// 从历史再接受(复用编辑器既有 DRAFT-only HITL 回调);返回 true=成功则关抽屉。
onReplaceChapter: (content: string) => boolean;
onAppendDraft: (content: string) => boolean;
onRefillSegment: (segment: string, refined: string) => boolean;
triggerRef?: RefObject<HTMLElement | null>;
}
// 每章「对话记录」抽屉(只读往复日志,气泡视图):本章对话 + 项目级生成两段,作者右 / AI 左kind 徽标 + 线程归组。
// 结构克隆 ContextDrawer右侧 slide-over + overlayScrim + role=dialog + Esc + focus trap + scroll lock + 还原焦点。
// append-only 只读展示;对 ai 产出可「从历史再接受」——只回填编辑器草稿,不绕开验收事务(守不变量 #3
export function AiConversationDrawer({
open,
onClose,
conversation,
currentChapterNo,
onReplaceChapter,
onAppendDraft,
onRefillSegment,
triggerRef,
}: AiConversationDrawerProps) {
const panelRef = useRef<HTMLDivElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
useBodyScrollLock(open);
useRestoreFocus(open, triggerRef);
// 打开即懒加载Workbench 级 hookappend 早已可能触发过——已加载则空操作)。
useEffect(() => {
if (open) conversation.ensureLoaded();
}, [open, conversation]);
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;
const threads = groupByThread(conversation.messages);
const { chapterThreads, projectThreads } = partitionByScope(
threads,
currentChapterNo,
);
const summary = `本章 ${chapterThreads.length} 段对话,项目级 ${projectThreads.length} 项生成`;
// 再接受成功则关抽屉揭示改动后的编辑器;失败(如原选段漂移)保留抽屉与漂移提示。
const accept = (action: AcceptAction, m: AiMessageView): void => {
let ok = false;
if (action === "replace_chapter") ok = onReplaceChapter(m.content);
else if (action === "append_draft") ok = onAppendDraft(m.content);
else if (action === "refill_segment")
ok = onRefillSegment(readSegment(m.meta), m.content);
if (ok) onClose();
};
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 motion-safe:transition-transform"
>
<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">
<MessagesSquare
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>
<div
role="log"
aria-live="polite"
aria-relevant="additions"
aria-label={summary}
className="flex-1 overflow-auto overscroll-contain px-4 py-4"
>
<DrawerBody
status={conversation.status}
chapterThreads={chapterThreads}
projectThreads={projectThreads}
onRetry={conversation.reload}
onAccept={accept}
/>
</div>
</div>
</div>
);
}
interface DrawerBodyProps {
status: UseAiConversation["status"];
chapterThreads: AiThread[];
projectThreads: AiThread[];
onRetry: () => void;
onAccept: (action: AcceptAction, m: AiMessageView) => void;
}
function DrawerBody({
status,
chapterThreads,
projectThreads,
onRetry,
onAccept,
}: DrawerBodyProps) {
if (status === "loading" || status === "idle") {
return (
<div className="py-6 text-center">
<ThinkingIndicator label="加载中" className="text-sm text-ink-soft" />
</div>
);
}
if (status === "error") {
return (
<StatusNote variant="danger" title="加载失败">
<p>AI </p>
<Button
onClick={onRetry}
variant="secondary"
size="sm"
className="mt-2"
>
</Button>
</StatusNote>
);
}
if (chapterThreads.length === 0 && projectThreads.length === 0) {
return (
<EmptyState
icon={MessagesSquare}
title="这里留存你和 AI 的往复记录"
description="润色、整章重写、续写、工具箱生成的往复会自动留痕,重开也不丢。"
/>
);
}
return (
<div className="space-y-5">
<ThreadSection
heading="本章对话"
threads={chapterThreads}
onAccept={onAccept}
emptyText="本章还没有对话。"
/>
<ThreadSection
heading="项目级生成"
threads={projectThreads}
onAccept={onAccept}
emptyText="还没有项目级(工具箱)生成。"
/>
</div>
);
}
interface ThreadSectionProps {
heading: string;
threads: AiThread[];
onAccept: (action: AcceptAction, m: AiMessageView) => void;
emptyText: string;
}
function ThreadSection({
heading,
threads,
onAccept,
emptyText,
}: ThreadSectionProps) {
return (
<section>
<h3 className="mb-2 text-2xs font-medium uppercase tracking-wide text-ink-soft">
{heading}
</h3>
{threads.length === 0 ? (
<p className="text-xs text-ink-soft">{emptyText}</p>
) : (
<ul className="space-y-3">
{threads.map((thread) => (
<li key={thread.threadId}>
<ThreadBlock thread={thread} onAccept={onAccept} />
</li>
))}
</ul>
)}
</section>
);
}
interface ThreadBlockProps {
thread: AiThread;
onAccept: (action: AcceptAction, m: AiMessageView) => void;
}
function ThreadBlock({ thread, onAccept }: ThreadBlockProps) {
const action = acceptActionFor(thread);
const headAt = thread.messages[0]?.created_at ?? "";
return (
<div className="rounded border border-line bg-bg/50 p-3">
<div className="mb-2 flex items-center gap-2">
<Badge variant={kindBadge(thread.kind)}>{kindLabel(thread.kind)}</Badge>
{thread.toolKey ? (
<span className="font-mono text-2xs text-ink-soft">
{thread.toolKey}
</span>
) : null}
<span className="ml-auto font-mono text-2xs text-ink-soft">
{formatBubbleTime(headAt)}
</span>
</div>
<ConversationThread
messages={thread.messages}
action={action}
onAccept={onAccept}
/>
</div>
);
}
// 气泡时间戳:本地 HH:MM显示用与自动保存「保存于 HH:MM」风格一致
function formatBubbleTime(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}

View File

@@ -1,6 +1,5 @@
"use client";
import Link from "next/link";
import { Minus, Pin, PinOff, Plus, RotateCcw, Undo2, X } from "lucide-react";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
@@ -45,11 +44,11 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
return (
<div className="px-4">
<SectionHeader
title="本章助手"
title="本章参考"
action={
injection.saving ? (
<ThinkingIndicator
label="助手保存中"
label="保存中"
className="text-xs text-ink-soft"
/>
) : null
@@ -58,7 +57,7 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
<section className="mt-4">
<SectionHeader
title="本章注入(透明)"
title="AI 写这章会参考"
action={
data ? (
<RecentStepper
@@ -71,10 +70,10 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
/>
{injection.loading ? (
<p className="mt-2 text-xs text-ink-soft"></p>
<p className="mt-2 text-xs text-ink-soft"></p>
) : !data ? (
// 首次加载失败data 仍为空 → 满屏错误框 + 重试。
<StatusNote variant="danger" className="mt-2 text-xs" title="注入信息暂不可用">
<StatusNote variant="danger" className="mt-2 text-xs" title="参考信息暂不可用">
<p></p>
<button
type="button"
@@ -115,7 +114,7 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
{selected.length === 0 ? (
<p className="mt-2 rounded border border-dashed border-line bg-bg p-3 text-xs text-ink-soft">
/
AI /
</p>
) : (
<ul className="mt-2 space-y-2">
@@ -139,7 +138,7 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
{excluded.length > 0 ? (
<div className="mt-3">
<SectionHeader title="已排除(不注入" />
<SectionHeader title="已排除(本章不参考" />
<ul className="mt-1 space-y-1">
{excluded.map((ref) => (
<ExcludedRow
@@ -156,15 +155,9 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
<section className="mt-6">
<SectionHeader
title="四审"
description="一致性 / 伏笔 / 文风 / 节奏,在审稿逐条裁决。"
title="写完后检查"
description="写完这章AI 会把一致性、伏笔、文风、节奏各查一遍。到左栏或底栏的「审稿逐条。"
/>
<Link
href={`/projects/${projectId}/review?chapter=${chapterNo}`}
className={buttonClass({ variant: "outline", size: "sm", className: "mt-2" })}
>
稿
</Link>
</section>
</div>
);
@@ -194,7 +187,7 @@ function EntityRow({ entity, pinned, disabled, onTogglePin, onExclude }: EntityR
disabled={disabled}
aria-pressed={pinned}
aria-label={
pinned ? `取消置顶 ${entity.name}` : `置顶 ${entity.name}强制注入`
pinned ? `取消置顶 ${entity.name}` : `置顶 ${entity.name}一定参考`
}
onClick={() => onTogglePin(ref)}
className={pinned ? "text-cinnabar" : undefined}
@@ -209,7 +202,7 @@ function EntityRow({ entity, pinned, disabled, onTogglePin, onExclude }: EntityR
variant="ghost"
size="icon"
disabled={disabled}
aria-label={`排除 ${entity.name}不注入本章)`}
aria-label={`排除 ${entity.name}(本章不参考`}
onClick={() => onExclude(ref)}
>
<X className="h-4 w-4" aria-hidden="true" />

View File

@@ -1,6 +1,8 @@
import Link from "next/link";
import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { buildChapterEntries, type ChapterEntry } from "@/lib/workbench/chapter";
import { focusRing } from "@/lib/ui/variants";
interface ChapterListProps {
projectId: string;
@@ -9,36 +11,83 @@ interface ChapterListProps {
currentChapterNo: number;
}
// 左栏目录UX §6.3):列出大纲全部章节 + 当前章,点击切换写作章号(`?chapter=N`)。
// 桌面 aside 包裹;移动端经 Workbench 抽屉复用 ChapterListContent
interface DesktopChapterListProps extends ChapterListProps {
// 目录列收起态P2-2收起时只留一条带「展开」按钮的窄轨让宽给正文
collapsed: boolean;
onToggle: () => void;
}
// 左栏目录UX §6.3 / P2-2列出大纲全部章节 + 当前章,点击切换写作章号(`?chapter=N`)。
// 桌面 aside 包裹,可收起为窄轨;移动端经 Workbench 抽屉复用 ChapterListContent不带折叠
export function ChapterList({
projectId,
chapters,
currentChapterNo,
}: ChapterListProps) {
collapsed,
onToggle,
}: DesktopChapterListProps) {
if (collapsed) {
return (
<aside
className="hidden shrink-0 border-r border-line bg-panel py-4 xl:block"
aria-label="目录(已收起)"
>
<button
type="button"
onClick={onToggle}
aria-expanded={false}
aria-label="展开目录"
title="展开目录"
className={`mx-auto flex h-8 w-8 items-center justify-center rounded text-ink-soft transition-colors hover:bg-[var(--color-cinnabar-wash)] hover:text-cinnabar ${focusRing}`}
>
<PanelLeftOpen className="h-4 w-4" aria-hidden="true" />
</button>
</aside>
);
}
return (
<aside className="hidden border-r border-line bg-panel py-4 xl:block">
<ChapterListContent
projectId={projectId}
chapters={chapters}
currentChapterNo={currentChapterNo}
onCollapse={onToggle}
/>
</aside>
);
}
interface ChapterListContentProps extends ChapterListProps {
// 桌面列传入 → 目录标题旁显示「收起」按钮;移动抽屉不传(抽屉自带关闭)。
onCollapse?: () => void;
}
export function ChapterListContent({
projectId,
chapters,
currentChapterNo,
}: ChapterListProps) {
onCollapse,
}: ChapterListContentProps) {
const entries = buildChapterEntries(chapters, currentChapterNo);
const hasOutline = chapters.length > 0;
return (
<>
<h2 className="px-4 text-xs font-semibold uppercase tracking-wide text-ink-soft">
</h2>
<div className="flex items-center justify-between px-4">
<h2 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
</h2>
{onCollapse ? (
<button
type="button"
onClick={onCollapse}
aria-label="收起目录"
title="收起目录"
className={`flex h-6 w-6 items-center justify-center rounded text-ink-soft transition-colors hover:bg-[var(--color-cinnabar-wash)] hover:text-cinnabar ${focusRing}`}
>
<PanelLeftClose className="h-4 w-4" aria-hidden="true" />
</button>
) : null}
</div>
<ul className="mt-2">
{entries.map((entry) => {
const active = entry.no === currentChapterNo;

View File

@@ -0,0 +1,312 @@
"use client";
import { useEffect, useRef, 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 type { AiMessageView } from "@/lib/api/types";
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 { acceptActionFor, type AcceptAction } from "@/lib/workbench/aiConversation";
import { mergeInlineThreads, toPendingView } from "@/lib/workbench/inlineConversation";
import type { AiTurnMessage, UseAiConversation } from "@/lib/workbench/useAiConversation";
import { ChoiceChips } from "./ChoiceChips";
import { ConversationThread } from "./ConversationThread";
// 门控阈值:整章意见 trim 后短于此字数才自动预检反问(清晰意见不交往返税)。
const CLARIFY_MIN_CHARS = 10;
// 本面板内联气泡流展示的 kinds本轮 rewrite 往复 + 其 clarify 反问;后者持久化时并入 rewrite 批)。
const REWRITE_KINDS = ["rewrite", "clarify"] as const;
interface ChapterRewritePanelProps {
projectId: string;
chapterNo: number;
// 当前整章正文(首轮重写的 prior_draft
currentDraft: string;
// 接受某一版 → 替换整章正文HITL作者显式点选才落草稿气泡「再接受」亦复用此回调。
onAccept: (text: string) => void;
// Workbench 级持久化对话(本章留痕数据源 + fail-soft append
conversation: UseAiConversation;
onClose: () => void;
}
// 整章「再沟通 / 重写」结果卡(内联,非模态):作者给意见 → 意见含糊/极短时 AI 先反问给方向选项 →
// 就着当前整章 + 完整记忆注入流式重写一版。面板正文是一条内联对话气泡流(本章已落库 rewrite 往复 +
// 本轮进行中气泡实时汇入,流式 token 逐字进气泡),输入框在底部。接受某版才替换整章正文(不变量 #3
export function ChapterRewritePanel({
projectId,
chapterNo,
currentDraft,
onAccept,
conversation,
onClose,
}: ChapterRewritePanelProps) {
const { messages, status, ensureLoaded, appendMessages } = conversation;
const rewrite = useChapterRewrite();
const clarify = useRewriteClarify();
const [feedback, setFeedback] = useState("");
const [pendingFeedback, setPendingFeedback] = useState<string | null>(null);
// 本轮进行中(尚未落库)的作者/反问气泡;生成开始时置入、结束或失败时清空 → 交由已落库气泡接管。
const [liveTurn, setLiveTurn] = useState<AiTurnMessage[] | null>(null);
// 本面板一次会话 = 一个 thread多版迭代 + 澄清往复归此组,版本号本地累加。
const threadId = useRef(crypto.randomUUID()).current;
const versionNoRef = useRef(0);
const streamRef = useRef<HTMLDivElement>(null);
// 打开即懒加载本章留痕Workbench 级 hookappend 也会触发——已加载则空操作)。
useEffect(() => {
ensureLoaded();
}, [ensureLoaded]);
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;
// 折入澄清答案或原意见后流式重写结束后留痕clarifyTurns 非空时把缓冲 Q&A 一并落库。
const runSend = (fb: string, clarifyTurns: AiTurnMessage[] | null): void => {
clarify.reset();
setPendingFeedback(null);
setFeedback("");
// clarify 分支:作者答复 IS 本轮 author 输入;否则记 author(意见)。
const authorTurn: AiTurnMessage = { role: "author", content: fb };
setLiveTurn(clarifyTurns ?? [authorTurn]);
void (async () => {
const v = await rewrite.send(projectId, chapterNo, fb, priorDraft);
if (v) {
versionNoRef.current += 1;
const versionNo = versionNoRef.current;
const aiRow: AiTurnMessage = {
role: "ai",
content: v.text,
meta: { version_no: versionNo },
};
const rows = clarifyTurns
? [...clarifyTurns, aiRow]
: [
{ role: "author" as const, content: fb, meta: { version_no: versionNo } },
aiRow,
];
appendMessages({ threadId, kind: "rewrite", chapterNo, messages: rows });
}
setLiveTurn(null);
})();
};
// 「重写整章 / 再改一版」:意见含糊/极短(或 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, null);
};
// 作者答复澄清(点选 value 或自由输入)→ 缓冲 Q&A原意见→反问→答复+ 折进意见 → 流式重写。
const onClarifyAnswer = (answer: string): void => {
const q = clarify.decision?.questions[0];
const question = q?.question ?? "";
const base = pendingFeedback ?? feedback.trim();
const clarifyTurns: AiTurnMessage[] = [
{ role: "author", content: base },
{
role: "ai",
content: question,
meta: {
parent_kind: "rewrite",
options: (q?.options ?? []).map((o) => ({
label: o.label,
value: o.value,
})),
allow_free_text: q?.allowFreeText ?? false,
verification: clarify.decision?.verification ?? "",
},
},
{ role: "author", content: answer },
];
runSend(foldClarifications(base, [{ question, answer }]), clarifyTurns);
};
// 本轮进行中气泡liveTurn已定的作者/反问)或反问待答时的作者意见 + 流式产出的 ai 气泡(逐字更新)。
const pending: AiMessageView[] = [];
{
let seq = 0;
const add = (seed: Parameters<typeof toPendingView>[0]): void => {
pending.push(toPendingView(seed, { threadId, chapterNo, kind: "rewrite", seq: seq++ }));
};
if (liveTurn) {
liveTurn.forEach((m, i) =>
add({ localId: `pt-${threadId}-${i}`, role: m.role, content: m.content }),
);
} else if (asking) {
add({
localId: `pt-${threadId}-ask`,
role: "author",
content: pendingFeedback ?? feedback.trim(),
});
}
// 生成中(含 token 已到但流未结束)显示流式 ai 气泡;正文空 → 「生成中」指示。
const answerActive =
liveTurn !== null && (rewrite.isStreaming || rewrite.state.text.length > 0);
if (answerActive) {
add({
localId: `pt-${threadId}-answer`,
role: "ai",
content: rewrite.state.text,
streaming: rewrite.isStreaming,
});
}
}
const threads = mergeInlineThreads(messages, chapterNo, REWRITE_KINDS, pending);
const flatCount = threads.reduce((n, t) => n + t.messages.length, 0);
// 新气泡/流式 token 出现即滚到底(聊天视图)。
useEffect(() => {
const el = streamRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [flatCount, rewrite.state.text]);
const onBubbleAccept = (action: AcceptAction, m: AiMessageView): void => {
if (action === "replace_chapter") onAccept(m.content);
};
return (
<div className="max-h-[75vh] 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>
{/* 内联对话气泡流:本章 rewrite 往复(已落库)+ 本轮进行中气泡(流式逐字),作者右 / AI 左,滚动区。 */}
{flatCount > 0 || status === "error" ? (
<div
ref={streamRef}
role="log"
aria-live="polite"
aria-relevant="additions"
aria-label="整章重写对话"
className="mt-3 max-h-[45vh] space-y-3 overflow-auto overscroll-contain rounded border border-line bg-bg/40 p-3"
>
{status === "error" ? (
<StatusNote variant="danger" className="text-xs" title="历史对话加载失败">
<Button
onClick={conversation.reload}
variant="secondary"
size="sm"
className="mt-1"
>
</Button>
</StatusNote>
) : null}
{threads.map((thread) => (
<ConversationThread
key={thread.threadId}
messages={thread.messages}
action={acceptActionFor(thread)}
onAccept={onBubbleAccept}
/>
))}
</div>
) : null}
<div className="mt-3 space-y-2">
{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}
<label className="block">
<span className="sr-only"></span>
<TextArea
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
rows={2}
placeholder={
hasVersions
? "对这一版还想怎么改?(如「高潮再拉满」「删掉第 2 段闪回」)"
: "这一章哪里不满意?想怎么改?(意见太笼统时 AI 会先反问、给方向选项)"
}
/>
</label>
<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>
{hasVersions && !rewrite.isStreaming ? (
<Button
onClick={() => rewrite.latest && onAccept(rewrite.latest.text)}
variant="primary"
size="sm"
>
<Check className="h-4 w-4" aria-hidden="true" />
</Button>
) : null}
{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>
</div>
);
}

View File

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

View File

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

View File

@@ -0,0 +1,128 @@
"use client";
import { useCallback, 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";
import type { AiTurnInput } from "@/lib/workbench/useAiConversation";
interface ContinuePanelProps {
projectId: string;
chapterNo: number;
// 插入选中候选到正文(追加到章末)。
onInsert: (text: string) => void;
// 留痕:每条候选记一条 ai 气泡(续写无作者输入,故无 author 气泡)。
appendMessages: (turn: AiTurnInput) => void;
onClose: () => void;
}
// 续写结果卡(内联,非模态):打开即读该章前文续写一条候选;可「再来一个」累加多候选,
// 作者点某条「插入正文」才落回草稿HITL不静默写入
export function ContinuePanel({
projectId,
chapterNo,
onInsert,
appendMessages,
onClose,
}: ContinuePanelProps) {
const { status, candidates, generate } = useContinue();
const ranRef = useRef(false);
// 本面板一次会话 = 一个 thread多条候选归此组candidate_index 本地累加。
const threadId = useRef(crypto.randomUUID()).current;
const candidateIdxRef = useRef(0);
// 生成一条候选并成功时留痕ai-onlymeta 存候选序号。
const runGenerate = useCallback(async (): Promise<void> => {
const text = await generate(projectId, chapterNo);
if (!text) return;
const candidateIndex = candidateIdxRef.current;
candidateIdxRef.current += 1;
appendMessages({
threadId,
kind: "continue",
toolKey: "continue",
chapterNo,
messages: [
{ role: "ai", content: text, meta: { candidate_index: candidateIndex } },
],
});
}, [generate, projectId, chapterNo, appendMessages, threadId]);
useEffect(() => {
if (ranRef.current) return;
ranRef.current = true;
void runGenerate();
}, [runGenerate]);
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 runGenerate()}
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 runGenerate()}
disabled={busy}
variant="secondary"
size="sm"
>
<Plus className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,120 @@
"use client";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { Button } from "@/components/ui/Button";
import { cn } from "@/lib/ui/variants";
import type { AiMessageView } from "@/lib/api/types";
import { bubbleSide, type AcceptAction } from "@/lib/workbench/aiConversation";
import {
isPendingView,
isStreamingView,
} from "@/lib/workbench/inlineConversation";
// 一组对话气泡(作者右 / AI 左。抽屉与内联面板共用同一渲染外观完全一致DRY
// 对已落库的 ai 产出气泡挂「再接受」(复用编辑器既有 DRAFT-only HITL 回调,不绕验收事务);
// clarify 气泡与进行中(未落库/流式)气泡不挂。
export const ACCEPT_LABEL: Record<AcceptAction, string> = {
replace_chapter: "采用(替换整章)",
append_draft: "采用(加到章末)",
refill_segment: "采用(替换这段)",
};
interface ConversationThreadProps {
messages: readonly AiMessageView[];
// 本线程可执行的接受动作(据 thread.kind 推导null 时全部气泡不挂接受。
action: AcceptAction | null;
onAccept: (action: AcceptAction, m: AiMessageView) => void;
}
export function ConversationThread({
messages,
action,
onAccept,
}: ConversationThreadProps) {
return (
<div className="space-y-2">
{messages.map((m) => (
<Bubble key={m.id} message={m} action={action} onAccept={onAccept} />
))}
</div>
);
}
interface BubbleProps {
message: AiMessageView;
action: AcceptAction | null;
onAccept: (action: AcceptAction, m: AiMessageView) => void;
}
// 单条气泡作者右cinnabar-wash/ AI 左border+bgclarify ai 气泡额外渲染只读方向选项。
// 进行中气泡meta._pending不挂再接受流式且空正文时渲染「生成中」指示meta._streaming
function Bubble({ message, action, onAccept }: BubbleProps) {
const side = bubbleSide(message.role);
const options = message.kind === "clarify" ? readOptions(message.meta) : [];
const pending = isPendingView(message);
const showThinking = isStreamingView(message) && message.content.length === 0;
// 只有已落库 ai 产出气泡(左侧、非 clarify挂再接受进行中气泡仍在生成不挂。
const showAccept =
action !== null &&
side === "left" &&
message.kind !== "clarify" &&
!pending;
return (
<div
className={cn("flex", side === "right" ? "justify-end" : "justify-start")}
>
<div
className={cn(
"max-w-[85%] rounded border px-3 py-2",
side === "right"
? "border-cinnabar/30 bg-[var(--color-cinnabar-wash)]"
: "border-line bg-bg",
pending && "opacity-90",
)}
>
{showThinking ? (
<ThinkingIndicator label="生成中" className="text-sm text-cinnabar" />
) : (
<p className="max-h-56 overflow-y-auto whitespace-pre-wrap text-sm text-ink">
{message.content}
</p>
)}
{options.length > 0 ? (
<ul className="mt-2 flex flex-wrap gap-1.5">
{options.map((opt, i) => (
<li
key={i}
className="rounded border border-line bg-panel px-2 py-0.5 text-2xs text-ink-soft"
>
{opt}
</li>
))}
</ul>
) : null}
{showAccept && action ? (
<Button
onClick={() => onAccept(action, message)}
variant="secondary"
size="sm"
className="mt-2"
>
{ACCEPT_LABEL[action]}
</Button>
) : null}
</div>
</div>
);
}
// clarify ai 行 meta.options → 只读选项 label 列表(外部数据,逐项显式取值,不信任形状)。
function readOptions(meta: AiMessageView["meta"]): string[] {
const raw = (meta ?? {})["options"];
if (!Array.isArray(raw)) return [];
return raw
.map((o) =>
o && typeof o === "object" && "label" in o
? String((o as { label: unknown }).label ?? "")
: "",
)
.filter((label) => label.length > 0);
}

View File

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

View File

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

View File

@@ -0,0 +1,98 @@
"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";
import type { AiTurnInput } from "@/lib/workbench/useAiConversation";
interface InlineToolboxProps {
projectId: string;
// 当前章号内联工具箱生成留痕挂本章chapter_no=current
chapterNo: number;
// 把生成结果插入正文(追加到章末)。
onInsertText: (text: string) => void;
// 留痕:生成成功后 append 一批(透传给 GeneratorRunner
appendMessages: (turn: AiTurnInput) => void;
// 当前正文(编辑器 text。透传给 GeneratorRunner供 book-title 等据正文反推 brief。
manuscriptText?: string;
onClose: () => void;
}
// 编辑器内联工具箱(不离开写作界面):列出生成器(含金手指),就地生成结构化预览,
// 文本类产物点「插入正文」直接填入写作处,可入库类走既有 ingest。legacy 生成器(世界观/
// 人设/大纲)仍走各自更丰富的独立页,不塞进内联面板。
export function InlineToolbox({
projectId,
chapterNo,
onInsertText,
appendMessages,
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}
appendMessages={appendMessages}
logChapterNo={chapterNo}
onClose={() => setActive(null)}
onInsertText={(text) => {
if (text.trim().length > 0) onInsertText(text);
}}
/>
</div>
) : status === "loading" ? (
<div className="mt-3">
<ThinkingIndicator label="加载工具箱" className="text-xs text-cinnabar" />
</div>
) : status === "error" ? (
<StatusNote variant="danger" className="mt-3 text-xs" title="工具箱加载失败">
<p></p>
</StatusNote>
) : openable.length === 0 ? (
<p className="mt-3 text-xs text-ink-soft"></p>
) : (
<ul className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
{openable.map((tool) => (
<li key={tool.key}>
<button
type="button"
onClick={() => setActive(tool)}
className="w-full rounded border border-line bg-bg p-3 text-left transition-colors hover:border-cinnabar"
>
<p className="text-sm text-ink">{tool.title}</p>
<p className="mt-0.5 text-xs text-ink-soft">{tool.subtitle}</p>
</button>
</li>
))}
</ul>
)}
</div>
);
}

View File

@@ -0,0 +1,327 @@
"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 type { AiMessageView } from "@/lib/api/types";
import { useRefine } from "@/lib/workbench/useRefine";
import { useClarify } from "@/lib/workbench/useClarify";
import { foldClarifications, needsClarifyGate } from "@/lib/workbench/clarify";
import { acceptActionFor, type AcceptAction } from "@/lib/workbench/aiConversation";
import {
mergeInlineThreads,
readSegment,
toPendingView,
} from "@/lib/workbench/inlineConversation";
import type { AiTurnMessage, UseAiConversation } from "@/lib/workbench/useAiConversation";
import { ChoiceChips } from "./ChoiceChips";
import { ConversationThread } from "./ConversationThread";
// 门控阈值:再沟通意见 trim 后短于此字数才自动预检反问(清晰意见不交往返税)。
const CLARIFY_MIN_CHARS = 10;
// 本面板内联气泡流展示的 kinds本轮 refine 往复 + 其 clarify 反问;后者持久化时并入 refine 批)。
const REFINE_KINDS = ["refine", "clarify"] as const;
interface RefinePanelProps {
projectId: string;
chapterNo: number;
// 选中原文;打开即对其回炉一次。
original: string;
// 接受回填:把「当前选段」替换为该产出(调用方按选区坐标重锚落回草稿)。
onAccept: (refined: string) => void;
// 从气泡「回填选段」按该版存档的原选段meta.segment内容重锚回填drift-safe返回是否成功。
onReaccept: (segment: string, refined: string) => boolean;
// Workbench 级持久化对话(本章留痕数据源 + fail-soft append
conversation: UseAiConversation;
onClose: () => void;
}
// 润色 / 再沟通结果卡(内联,非模态):打开即润色选段;作者可「再沟通」追加意见迭代出新版。
// 面板正文是一条内联对话气泡流(本章已落库往复 + 本轮进行中气泡实时汇入),输入框在底部,读起来像聊天。
// 原文只读、生成不写库(不变量 #3accept 才动草稿。
export function RefinePanel({
projectId,
chapterNo,
original,
onAccept,
onReaccept,
conversation,
onClose,
}: RefinePanelProps) {
const { messages, status, ensureLoaded, appendMessages } = conversation;
const { status: refineStatus, versions, latest, refine, recommunicate } = useRefine();
const clarify = useClarify();
const [instruction, setInstruction] = useState("");
const [pendingInstruction, setPendingInstruction] = useState<string | null>(null);
// 本轮进行中(尚未落库)的作者/反问气泡;生成开始时置入、结束或失败时清空 → 交由已落库气泡接管。
const [liveTurn, setLiveTurn] = useState<AiTurnMessage[] | null>(null);
const ranRef = useRef(false);
// 本面板一次会话 = 一个 thread所有润色/再沟通/澄清往复归此组。版本号 repo 无关,本地累加。
const threadId = useRef(crypto.randomUUID()).current;
const versionNoRef = useRef(0);
const streamRef = useRef<HTMLDivElement>(null);
// 一版 ai 产出气泡content=产出meta 存版本号 + 原选段(每版都存,供任意版 re-accept 重锚)。
const aiRow = (refined: string): AiTurnMessage => {
versionNoRef.current += 1;
return {
role: "ai",
content: refined,
meta: { version_no: versionNoRef.current, segment: original },
};
};
// 打开即对选段回炉一次(仅一次,避免重复请求);成功后留痕 [author(原选段), ai(润色)]。
useEffect(() => {
if (ranRef.current) return;
ranRef.current = true;
ensureLoaded();
setLiveTurn([{ role: "author", content: original }]);
void (async () => {
const v = await refine(projectId, chapterNo, original);
if (v) {
appendMessages({
threadId,
kind: "refine",
chapterNo,
messages: [{ role: "author", content: original }, aiRow(v.refined)],
});
}
setLiveTurn(null);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId, chapterNo, original, refine, ensureLoaded, appendMessages]);
const busy = refineStatus === "refining";
const clarifying = clarify.status === "checking";
const firstQuestion = clarify.decision?.questions[0] ?? null;
const asking = clarify.status === "asking" && firstQuestion !== null;
// 折入澄清答案或原意见后回炉结束后留痕clarifyTurns 非空时把缓冲的 Q&A 一并落库。
const runRecommunicate = (
finalInstruction: string,
clarifyTurns: AiTurnMessage[] | null,
): void => {
clarify.reset();
setPendingInstruction(null);
setInstruction("");
// clarify 分支:作者答复 IS 本轮 author 输入,不再另加 author(意见) 气泡;否则记 author(意见)。
const authorTurn: AiTurnMessage = { role: "author", content: finalInstruction };
setLiveTurn(clarifyTurns ?? [authorTurn]);
void (async () => {
const v = await recommunicate(projectId, chapterNo, finalInstruction);
if (v) {
const rows = clarifyTurns
? [...clarifyTurns, aiRow(v.refined)]
: [authorTurn, aiRow(v.refined)];
appendMessages({ threadId, kind: "refine", chapterNo, messages: rows });
}
setLiveTurn(null);
})();
};
// 「再改一版」:意见含糊/极短(或 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, null);
};
// 作者答复澄清(点选 value 或自由输入)→ 缓冲 Q&A原意见→反问→答复+ 折进意见 → 回炉。
const onClarifyAnswer = (answer: string): void => {
const q = clarify.decision?.questions[0];
const question = q?.question ?? "";
const base = pendingInstruction ?? instruction.trim();
// 缓冲三条:原含糊意见(author) → AI 反问(ai, 选项进 meta) → 作者答复(author),随本轮产出一起落库。
const clarifyTurns: AiTurnMessage[] = [
{ role: "author", content: base },
{
role: "ai",
content: question,
meta: {
parent_kind: "refine",
options: (q?.options ?? []).map((o) => ({
label: o.label,
value: o.value,
})),
allow_free_text: q?.allowFreeText ?? false,
verification: clarify.decision?.verification ?? "",
},
},
{ role: "author", content: answer },
];
runRecommunicate(foldClarifications(base, [{ question, answer }]), clarifyTurns);
};
// 本轮进行中气泡liveTurn已定的作者/反问)+ 反问待答时的作者意见 + 生成中的 ai 气泡(空正文=思考)。
const pending: AiMessageView[] = [];
{
let seq = 0;
const add = (
seed: Parameters<typeof toPendingView>[0],
): void => {
pending.push(toPendingView(seed, { threadId, chapterNo, kind: "refine", seq: seq++ }));
};
if (liveTurn) {
liveTurn.forEach((m, i) =>
add({ localId: `pt-${threadId}-${i}`, role: m.role, content: m.content }),
);
} else if (asking) {
add({
localId: `pt-${threadId}-ask`,
role: "author",
content: pendingInstruction ?? instruction.trim(),
});
}
if (busy && liveTurn) {
add({ localId: `pt-${threadId}-answer`, role: "ai", content: "", streaming: true });
}
}
const threads = mergeInlineThreads(messages, chapterNo, REFINE_KINDS, pending);
const flatCount = threads.reduce((n, t) => n + t.messages.length, 0);
// 新气泡出现即滚到底聊天视图reduced-motion 下瞬时滚动即可,无动画。
useEffect(() => {
const el = streamRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [flatCount]);
const onBubbleAccept = (action: AcceptAction, m: AiMessageView): void => {
if (action === "refill_segment") onReaccept(readSegment(m.meta), m.content);
};
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>
{/* 内联对话气泡流:本章 refine 往复(已落库)+ 本轮进行中气泡,作者右 / AI 左,滚动区。 */}
<div
ref={streamRef}
role="log"
aria-live="polite"
aria-relevant="additions"
aria-label="润色对话"
className="mt-3 max-h-[40vh] space-y-3 overflow-auto overscroll-contain rounded border border-line bg-bg/40 p-3"
>
{status === "error" ? (
<StatusNote variant="danger" className="text-xs" title="历史对话加载失败">
<Button onClick={conversation.reload} variant="secondary" size="sm" className="mt-1">
</Button>
</StatusNote>
) : null}
{flatCount === 0 && status !== "error" && refineStatus !== "error" ? (
<p className="py-2 text-center text-xs text-ink-soft"></p>
) : (
threads.map((thread) => (
<ConversationThread
key={thread.threadId}
messages={thread.messages}
action={acceptActionFor(thread)}
onAccept={onBubbleAccept}
/>
))
)}
{refineStatus === "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}
</div>
<div className="mt-3 space-y-2">
{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}
<label className="block">
<span className="sr-only"></span>
<TextArea
value={instruction}
onChange={(e) => setInstruction(e.target.value)}
rows={2}
placeholder="再沟通:想怎么改?(意见太笼统时 AI 会先反问、给方向选项)"
/>
</label>
<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}
{versions.length > 0 ? (
<span className="ml-auto rounded bg-bg px-1.5 py-0.5 text-2xs tabular-nums text-ink-soft">
{versions.length}
</span>
) : null}
</div>
</div>
</div>
);
}

View File

@@ -3,20 +3,26 @@
import Link from "next/link";
import { useEffect, useId, useRef, useState, type RefObject } from "react";
import {
BookOpen,
BookMarked,
ChevronDown,
ClipboardCheck,
FileText,
Library,
HelpCircle,
MessagesSquare,
PanelLeft,
PanelRight,
PenLine,
RefreshCw,
Sparkles,
Square,
Wand2,
WrapText,
} from "lucide-react";
import { AppShell } from "@/components/AppShell";
import { Drawer } from "@/components/Drawer";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { useToast } from "@/components/Toast";
import { Button } from "@/components/ui/Button";
import { SectionHeader } from "@/components/ui/SectionHeader";
import { StatusNote } from "@/components/ui/StatusNote";
@@ -29,11 +35,31 @@ import {
WORKBENCH_CHAPTER_NO,
type ChapterEntry,
} from "@/lib/workbench/chapter";
import { composeDirective, STYLE_PRESETS } from "@/lib/workbench/directive";
import { buttonClass } from "@/lib/ui/variants";
import {
composeDirective,
PLOT_PRESETS,
STYLE_PRESETS,
type Preset,
} from "@/lib/workbench/directive";
import { applyRefinement } from "@/lib/workbench/refineApply";
import { buttonClass, focusRing } from "@/lib/ui/variants";
import { ChapterList, ChapterListContent } from "./ChapterList";
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 {
AiConversationDrawer,
AI_CONVERSATION_ENABLED,
} from "./AiConversationDrawer";
import { GenrePicker } from "./GenrePicker";
import { GENRES } from "@/lib/wizard/wizard";
import { useGenreGate, toGenreDirective } from "@/lib/workbench/useGenreGate";
import { useAiConversation } from "@/lib/workbench/useAiConversation";
import { useChapterListCollapse } from "@/lib/workbench/useChapterListCollapse";
interface WorkbenchProps {
project: ProjectResponse;
@@ -64,11 +90,32 @@ export function Workbench({
// 本章指令T4-b自由文本 + 风格预设 → 写本章时组装传入 draft 生成。
const [directive, setDirective] = useState("");
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 / P1-5首次写章的极轻 genre 采集门。项目无题材 → 正文上方常驻一条内联「题材」选择条
// (非弹窗),作者点一个题材再写;仅临时并入本次指令、不落库,避免无题材 slop且第一次发送不被打断。
const genreGate = useGenreGate(project.genre ?? null);
// WFW-6 上下文速查抽屉加法式、flag 灰度;与三张 AI 结果卡互不干扰。
const [contextOpen, setContextOpen] = useState(false);
// AC-3 AI 对话抽屉Workbench 级单实例append 即使抽屉关着也能触发flag 灰度。
const [conversationOpen, setConversationOpen] = useState(false);
const conversation = useAiConversation(project.id, chapterNo);
// P2-2桌面「目录」列可收起记忆状态收起时把宽让给正文窄轨留一个展开入口。
const chapterList = useChapterListCollapse();
const conversationTriggerRef = useRef<HTMLButtonElement>(null);
const toast = useToast();
const autosave = useAutosave(project.id, chapterNo, initialText);
const stream = useDraftStream();
const lastStreamText = useRef("");
const canRefine = selection !== null && selection.text.trim().length > 0;
const chapterTriggerRef = useRef<HTMLButtonElement>(null);
const assistantTriggerRef = useRef<HTMLButtonElement>(null);
const contextTriggerRef = useRef<HTMLButtonElement>(null);
// 流式 token 累积进编辑器(打字机);停止/结束后已生成部分留在草稿。
useEffect(() => {
@@ -85,12 +132,23 @@ export function Workbench({
}
}, [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 => {
void stream.start(
project.id,
chapterNo,
composeDirective(directive, presetIds),
);
// 无题材且未点选 → 引导先点上方常驻「题材」条再写HITL不静默生成无题材 slop
// 选择条一直可见,理想路径是发送前已选好,第一次「写本章」即顺滑流式、不被弹窗打断。
if (genreGate.needsGenre && genreGate.chosenGenre === null) {
toast("先在上方选个题材AI 写得更贴合、少套话。", "info");
return;
}
startWrite(genreGate.effectiveGenre);
};
const togglePreset = (id: string): void => {
@@ -104,6 +162,111 @@ export function Workbench({
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 结果卡(润色/续写/工具箱/整章重写)互斥,避免同时占位。
// 润色以选区为对象无选区时不静默失败给出可见提示引导先选段P0-4替代 hover-only title
const openRefine = (): void => {
if (!canRefine) {
toast("先在正文里选中一段再润色", "info");
return;
}
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);
};
// 从「AI 对话」历史再接受(复用 DRAFT-only HITL只回填编辑器草稿不绕开验收事务——不变量 #3
// 返回 true=成功则抽屉自关,露出改动后的编辑器 + 成功 toastrefine 找不到原选段返回 false + 漂移提示。
const onReplaceChapterFromHistory = (content: string): boolean => {
setText(content);
autosave.onChange(content);
toast("已替换整章。", "success");
return true;
};
const onAppendDraftFromHistory = (content: string): boolean => {
appendToDraft(content);
toast("已加到章末。", "success");
return true;
};
const onRefillSegmentFromHistory = (
segment: string,
refined: string,
): boolean => {
// 合成零区间强制 slice 校验失败 → 走 indexOf 按存档的原选段重锚;找不到则不盲替换。
const next = applyRefinement(text, segment, refined, { start: 0, end: 0 });
if (next === null) {
toast("正文已改动,找不到原选段,无法替换。", "error");
return false;
}
setText(next);
autosave.onChange(next);
toast("已替换这段。", "success");
return true;
};
// 字数统计非空白字符(与中文读者直觉一致:标点/空格不计入正文体量)。
const wordCount = text.replace(/\s+/g, "").length;
const closePanel = (): void => setMobilePanel(null);
@@ -132,11 +295,19 @@ export function Workbench({
projectId={project.id}
activeNav="write"
>
<div className="grid min-h-[calc(100vh-var(--chrome,4rem))] grid-cols-1 xl:h-[calc(100vh-var(--chrome,4rem))] xl:grid-cols-[12rem_1fr_18rem]">
<div
className={`grid min-h-[calc(100vh-var(--chrome,4rem))] grid-cols-1 xl:h-[calc(100vh-var(--chrome,4rem))] ${
chapterList.collapsed
? "xl:grid-cols-[2.75rem_1fr_18rem]"
: "xl:grid-cols-[12rem_1fr_18rem]"
}`}
>
<ChapterList
projectId={project.id}
chapters={chapters}
currentChapterNo={chapterNo}
collapsed={chapterList.collapsed}
onToggle={chapterList.toggle}
/>
<section className="flex min-w-0 flex-col bg-bg">
@@ -147,17 +318,85 @@ export function Workbench({
chapterTriggerRef={chapterTriggerRef}
assistantTriggerRef={assistantTriggerRef}
/>
{genreGate.needsGenre ? (
// 项目无题材正文上方常驻内联题材条非弹窗发送前选好即顺滑写章P1-5
<GenrePicker
genres={GENRES}
value={genreGate.chosenGenre}
onPick={genreGate.setChosenGenre}
/>
) : null}
<DirectivePanel
directive={directive}
onDirectiveChange={setDirective}
presetIds={presetIds}
onTogglePreset={togglePreset}
defaultOpen={initialText.trim().length === 0}
streaming={stream.isStreaming}
onWrite={onWrite}
onStop={stream.stop}
/>
<AiVerbsRow
streaming={stream.isStreaming}
onContinue={openContinue}
onRefineSelection={openRefine}
onRewrite={openRewrite}
onToolbox={openToolbox}
/>
{refineOpen && canRefine && selection ? (
<RefinePanel
projectId={project.id}
chapterNo={chapterNo}
original={selection.text}
onAccept={onAcceptRefine}
onReaccept={onRefillSegmentFromHistory}
conversation={conversation}
onClose={() => setRefineOpen(false)}
/>
) : null}
{continueOpen ? (
<ContinuePanel
projectId={project.id}
chapterNo={chapterNo}
onInsert={onInsertContinuation}
appendMessages={conversation.appendMessages}
onClose={() => setContinueOpen(false)}
/>
) : null}
{toolboxOpen ? (
<InlineToolbox
projectId={project.id}
chapterNo={chapterNo}
manuscriptText={text}
appendMessages={conversation.appendMessages}
onInsertText={(chunk) => {
appendToDraft(chunk);
setToolboxOpen(false);
}}
onClose={() => setToolboxOpen(false)}
/>
) : null}
{rewriteOpen ? (
<ChapterRewritePanel
projectId={project.id}
chapterNo={chapterNo}
currentDraft={text}
onAccept={onAcceptRewrite}
conversation={conversation}
onClose={() => setRewriteOpen(false)}
/>
) : null}
<div className="flex-1 overflow-auto px-6 py-8">
{chapters.length === 0 ? (
<StatusNote variant="info" className="mb-4">
</StatusNote>
) : null}
<Editor
value={text}
onChange={onEditorChange}
streaming={stream.isStreaming}
onSelectionChange={setSelection}
/>
</div>
<Toolbar
@@ -169,8 +408,11 @@ export function Workbench({
streaming={stream.isStreaming}
streamError={stream.state.error}
liveMessage={liveMessage}
onWrite={onWrite}
onStop={stream.stop}
onOpenContext={() => setContextOpen(true)}
contextTriggerRef={contextTriggerRef}
onOpenConversation={() => setConversationOpen(true)}
conversationTriggerRef={conversationTriggerRef}
/>
</section>
@@ -195,11 +437,33 @@ export function Workbench({
open={mobilePanel === "assistant"}
onClose={closePanel}
side="right"
label="本章助手"
label="本章参考"
triggerRef={assistantTriggerRef}
>
<AssistantContent projectId={project.id} chapterNo={chapterNo} />
</Drawer>
{/* WFW-6 上下文速查:视口无关的右侧 slide-over旧侧栏/全宽页路由保留、可回滚。 */}
<ContextDrawer
projectId={project.id}
open={contextOpen}
onClose={() => setContextOpen(false)}
triggerRef={contextTriggerRef}
/>
{/* AC-3 AI 对话:右侧 slide-over 气泡视图从历史再接受只回填草稿不绕验收事务。flag 灰度。 */}
{AI_CONVERSATION_ENABLED ? (
<AiConversationDrawer
open={conversationOpen}
onClose={() => setConversationOpen(false)}
conversation={conversation}
currentChapterNo={chapterNo}
onReplaceChapter={onReplaceChapterFromHistory}
onAppendDraft={onAppendDraftFromHistory}
onRefillSegment={onRefillSegmentFromHistory}
triggerRef={conversationTriggerRef}
/>
) : null}
</AppShell>
);
}
@@ -251,73 +515,280 @@ interface DirectivePanelProps {
onDirectiveChange: (value: string) => void;
presetIds: readonly string[];
onTogglePreset: (id: string) => void;
// 空章默认展开(新手最需要看到输入框的时机);满章默认收起以让出编辑视野。
defaultOpen: boolean;
// 发送键 = 写本章:把已选文风/剧情 + 自定义要求组装后逐字流式写进正文(不改成聊天气泡)。
streaming: boolean;
onWrite: () => void;
onStop: () => void;
}
// 本章指令面板(T4-b可折叠的 textarea + 风格快捷预设 chips
// 默认折叠(不抢编辑视野);指令覆盖/补充大纲节拍,写本章时组装传入生成
// 本章写作指令面板(P0-2 提升):正文正上方常驻的「和 AI 说话」输入条
// 标题「告诉 AI 这章想怎么写」+ HITL 微文案 +「?这是什么」常驻可见;空章默认展开露出输入框
// 发送键即「写本章」,沿用 composeDirective + 文风/剧情 chips + 流式打字机写进正文。
function DirectivePanel({
directive,
onDirectiveChange,
presetIds,
onTogglePreset,
defaultOpen,
streaming,
onWrite,
onStop,
}: DirectivePanelProps) {
const panelId = useId();
const [open, setOpen] = useState(defaultOpen);
const activeCount = presetIds.length + (directive.trim().length > 0 ? 1 : 0);
return (
<details className="group border-b border-line bg-panel px-4 py-3 sm:px-6">
<summary
className="flex cursor-pointer list-none items-center justify-between gap-3 text-sm text-ink-soft hover:text-cinnabar"
aria-controls={panelId}
>
<span className="flex items-center gap-2 font-serif text-base text-ink">
<section className="border-b border-line bg-panel px-4 py-3 sm:px-6">
<div className="flex items-center justify-between gap-3">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={panelId}
className={`flex min-w-0 cursor-pointer items-center gap-2 rounded font-serif text-base text-ink hover:text-cinnabar ${focusRing}`}
>
<ChevronDown
className="h-4 w-4 text-ink-soft transition-transform group-open:rotate-180"
className={`h-4 w-4 shrink-0 text-ink-soft transition-transform ${
open ? "rotate-180" : ""
}`}
aria-hidden="true"
/>
</span>
<span className="rounded border border-line bg-bg px-2 py-0.5 text-xs text-ink-soft">
{activeCount > 0 ? `${activeCount} 项指令` : "可选"}
</span>
</summary>
<div id={panelId} className="mt-3 space-y-3">
<SectionHeader
title="写作指令"
description="用于补充或覆盖本章大纲节拍,只影响本次生成。"
/>
<label className="block">
<span className="sr-only"></span>
<TextArea
value={directive}
onChange={(e) => onDirectiveChange(e.target.value)}
rows={2}
placeholder="本章想怎么写?(可选,覆盖/补充大纲节拍)"
/>
</label>
<div className="flex flex-wrap gap-2" role="group" aria-label="风格预设">
{STYLE_PRESETS.map((preset) => {
const active = presetIds.includes(preset.id);
return (
<Button
key={preset.id}
type="button"
aria-pressed={active}
onClick={() => onTogglePreset(preset.id)}
variant={active ? "outline" : "secondary"}
size="sm"
>
{preset.label}
</Button>
);
})}
<span className="truncate"> AI </span>
</button>
<div className="flex shrink-0 items-center gap-2">
{activeCount > 0 ? (
<span className="rounded border border-line bg-bg px-2 py-0.5 text-xs text-ink-soft">
{activeCount}
</span>
) : null}
{/* 常驻「发送键」= 写本章始终可见折叠时也在逐字流式写进正文。带可见范围提示P1-4。 */}
{streaming ? (
<Button onClick={onStop} variant="danger" size="sm">
<Square className="h-4 w-4" aria-hidden="true" />
</Button>
) : (
<Button onClick={onWrite} variant="primary" size="sm">
<PenLine className="h-4 w-4" aria-hidden="true" />
<span className="text-2xs font-normal text-panel/75">
·
</span>
</Button>
)}
<HitlHelp />
</div>
{activeCount > 0 ? (
<StatusNote variant="info">
</StatusNote>
) : null}
</div>
</details>
<p className="mt-1 text-xs text-ink-soft">
AI 稿
</p>
{open ? (
<div id={panelId} className="mt-3 space-y-3">
<SectionHeader
title="你的写作要求"
description="选文风、点剧情需求、写自己的要求,只影响本次生成。"
/>
<PresetGroup
label="文风"
presets={STYLE_PRESETS}
presetIds={presetIds}
onToggle={onTogglePreset}
/>
<PresetGroup
label="剧情需求"
presets={PLOT_PRESETS}
presetIds={presetIds}
onToggle={onTogglePreset}
/>
<label className="block">
<span className="mb-1 block text-sm text-ink-soft"></span>
<TextArea
value={directive}
onChange={(e) => onDirectiveChange(e.target.value)}
rows={2}
placeholder="写点要求AI 更懂你(留空则按大纲写)"
/>
</label>
{activeCount > 0 ? (
<StatusNote variant="info">
/
</StatusNote>
) : null}
</div>
) : null}
</section>
);
}
interface AiVerbsRowProps {
streaming: boolean;
onContinue: () => void;
onRefineSelection: () => void;
onRewrite: () => void;
onToolbox: () => void;
}
// 「或让 AI:」次级动作行P1-1常驻贴在中央输入条下方把续写/润色/整章重写/工具箱
// 归为「同一个 AI 的其它用法」act-on-chapter 与导航分家。每个动词带可见范围提示P1-4
// 生成中禁用(避免与流式写章并发),触发逻辑沿用 Workbench 既有 open* 回调(仅搬家、不改行为)。
function AiVerbsRow({
streaming,
onContinue,
onRefineSelection,
onRewrite,
onToolbox,
}: AiVerbsRowProps) {
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-line bg-panel px-4 py-2 sm:px-6">
<span className="text-xs text-ink-soft"> AI</span>
<VerbButton
icon={WrapText}
label="续写"
hint="接着往下写"
disabled={streaming}
onClick={onContinue}
/>
<VerbButton
icon={Wand2}
label="润色选段"
hint="选中段·打磨"
disabled={streaming}
onClick={onRefineSelection}
/>
<VerbButton
icon={RefreshCw}
label="整章重写"
hint="整章·基于现有重写"
disabled={streaming}
onClick={onRewrite}
/>
<VerbButton
icon={Sparkles}
label="工具箱"
disabled={streaming}
onClick={onToolbox}
/>
</div>
);
}
interface VerbButtonProps {
icon: typeof WrapText;
label: string;
// 可见(非 hover-only范围提示让作者点前就能预判对草稿的影响P1-4。工具箱无范围提示。
hint?: string;
disabled: boolean;
onClick: () => void;
}
function VerbButton({ icon: Icon, label, hint, disabled, onClick }: VerbButtonProps) {
return (
<Button onClick={onClick} disabled={disabled} variant="secondary" size="sm">
<Icon className="h-4 w-4" aria-hidden="true" />
{label}
{hint ? <span className="text-2xs text-ink-soft">{hint}</span> : null}
</Button>
);
}
// 「?这是什么」:常驻、可再发现的说明(非一次性 coach——解释「和 AI 说话」的闭环。
// 仿 AiToolbarMoreMenu按钮 + 轻量弹层Esc / 点击外部关闭,还原焦点。
function HitlHelp() {
const [open, setOpen] = useState(false);
const popId = useId();
const rootRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!open) return;
const onKeyDown = (e: KeyboardEvent): void => {
if (e.key === "Escape") {
setOpen(false);
buttonRef.current?.focus();
}
};
const onPointerDown = (e: PointerEvent): void => {
const target = e.target;
if (target instanceof Node && !rootRef.current?.contains(target)) {
setOpen(false);
}
};
window.addEventListener("keydown", onKeyDown);
window.addEventListener("pointerdown", onPointerDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("pointerdown", onPointerDown);
};
}, [open]);
return (
<div ref={rootRef} className="relative">
<button
ref={buttonRef}
type="button"
aria-haspopup="dialog"
aria-expanded={open}
aria-controls={popId}
onClick={() => setOpen((v) => !v)}
className={buttonClass({
variant: "ghost",
size: "sm",
className: "gap-1 text-xs text-ink-soft",
})}
>
<HelpCircle className="h-4 w-4" aria-hidden="true" />
</button>
{open ? (
<div
id={popId}
role="dialog"
aria-label="怎么和 AI 说话"
className="absolute right-0 z-30 mt-2 w-72 max-w-[80vw] rounded border border-line bg-panel p-3 text-sm text-ink-soft shadow-paper"
>
<p className="mb-1 font-serif text-sm text-ink"> AI </p>
<p>
AI 稿稿
稿
</p>
</div>
) : null}
</div>
);
}
interface PresetGroupProps {
label: string;
presets: readonly Preset[];
presetIds: readonly string[];
onToggle: (id: string) => void;
}
// 一组预设 chips文风 / 剧情需求):点选 toggle选中高亮。id 统一进 presetIds。
function PresetGroup({ label, presets, presetIds, onToggle }: PresetGroupProps) {
return (
<div>
<p className="mb-1 text-sm text-ink-soft">{label}</p>
<div className="flex flex-wrap gap-2" role="group" aria-label={label}>
{presets.map((preset) => {
const active = presetIds.includes(preset.id);
return (
<Button
key={preset.id}
type="button"
aria-pressed={active}
onClick={() => onToggle(preset.id)}
variant={active ? "outline" : "secondary"}
size="sm"
>
{preset.label}
</Button>
);
})}
</div>
</div>
);
}
@@ -346,6 +817,8 @@ function StreamErrorNote({
);
}
// 底栏瘦身P1-1act-on-chapter 动词与纯导航都已上移/移除,底栏只余
// 状态(字数/保存/播报)+ 流式时的「停」+ 唯一强调的「审稿」前进 CTA + 速查/对话记录抽屉入口。
interface ToolbarProps {
projectId: string;
chapterNo: number;
@@ -356,8 +829,14 @@ interface ToolbarProps {
streamError: { code: string; message: string } | null;
// 流式里程碑播报句(完成/停止/失败);仅在 role=status 区出现,不逐 token 刷新。
liveMessage: string;
onWrite: () => void;
// 流式时的「停」:底栏 sticky滚到长正文下方也够得着中央条会滚出视野
onStop: () => void;
// 打开上下文速查抽屉WFW-6
onOpenContext: () => void;
contextTriggerRef: RefObject<HTMLButtonElement | null>;
// 打开对话记录抽屉AC-3
onOpenConversation: () => void;
conversationTriggerRef: RefObject<HTMLButtonElement | null>;
}
function Toolbar({
@@ -369,8 +848,11 @@ function Toolbar({
streaming,
streamError,
liveMessage,
onWrite,
onStop,
onOpenContext,
contextTriggerRef,
onOpenConversation,
conversationTriggerRef,
}: ToolbarProps) {
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">
@@ -382,12 +864,7 @@ function Toolbar({
<Square className="h-4 w-4" aria-hidden="true" />
</Button>
) : (
<Button onClick={onWrite} variant="primary" size="sm">
<PenLine className="h-4 w-4" aria-hidden="true" />
</Button>
)}
) : null}
{streaming ? (
// ThinkingIndicator 自带 role=status开始时播报一次「生成中」
// 易变字数 aria-hidden仅供视觉不逐 token 打扰屏读。
@@ -409,24 +886,31 @@ function Toolbar({
<span className="sr-only" role="status" aria-live="polite">
{liveMessage}
</span>
<Link
href={`/projects/${projectId}/outline`}
className={buttonClass({ variant: "secondary", size: "sm" })}
>
<BookOpen className="h-4 w-4" aria-hidden="true" />
</Link>
<Link
href={`/projects/${projectId}/foreshadow`}
className={buttonClass({
variant: "secondary",
size: "sm",
className: "hidden sm:inline-flex",
})}
>
<Library className="h-4 w-4" aria-hidden="true" />
</Link>
{CONTEXT_DRAWER_ENABLED ? (
<Button
ref={contextTriggerRef}
onClick={onOpenContext}
variant="secondary"
size="sm"
title="打开上下文速查(设定库/大纲/伏笔/规则/文风)"
>
<BookMarked className="h-4 w-4" aria-hidden="true" />
</Button>
) : null}
{AI_CONVERSATION_ENABLED ? (
<Button
ref={conversationTriggerRef}
onClick={onOpenConversation}
variant="secondary"
size="sm"
title="打开对话记录(润色/重写/续写/工具箱往复留痕的只读日志)"
>
<MessagesSquare className="h-4 w-4" aria-hidden="true" />
</Button>
) : null}
{/* 唯一强调的「审稿」= 写→审→验收的前进 CTA导航去重后仅此一处强调左栏另有入口。 */}
<Link
href={`/projects/${projectId}/review?chapter=${chapterNo}`}
className={buttonClass({ variant: "outline", size: "sm" })}

View File

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

View File

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

View File

@@ -62,7 +62,10 @@ export interface paths {
path?: never;
cookie?: never;
};
/** List Projects */
/**
* List Projects
* @description 作品列表(按 created_at 升序,分页)。`?limit=&offset=`,默认第一页。
*/
get: operations["list_projects_projects_get"];
put?: never;
/** Create Project */
@@ -157,6 +160,58 @@ export interface paths {
patch?: never;
trace?: never;
};
"/projects/{project_id}/chapters/{chapter_no}/rewrite": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Stream Rewrite
* @description 整章再沟通/重写:组装记忆 + 当前草稿 + 作者意见 → 网关流 → 归一 SSE → event-stream。
*
* HITL新版停前端/草稿,接受才落库(不变量 #3rewrite 是工具非写节点,不破坏章
* 纯函数性(不变量 #7。项目不存在 → 404触网关前 fail-fast同 draftQA C1
*/
post: operations["stream_rewrite_projects__project_id__chapters__chapter_no__rewrite_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/projects/{project_id}/chapters/{chapter_no}/rewrite/clarify": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Clarify Rewrite
* @description 整章重写预检澄清WFW-9 M2 路线A 两阶段之「问题」阶段):非流式 JSON只判不改。
*
* 镜像 refine 侧 clarify——只判「作者这条整章重写意见清不清楚」含糊则反问 1 问 + 给选项,
* 清楚则给确认语放行;**绝不改正文**(整章重写仍走既有 rewrite SSE 端点。analyst 档
* (不变量 #2。只取 `prior_draft` 有界摘录喂预检(不整章入 LLM
*
* 项目不存在 → 404触网关前 fail-fast同 rewrite/draft无凭据 → 503dep 解析阶段拦下)。
* **只读不写业务表**(不变量 #3末尾 `commit()` 让网关 usage_ledger 落库add-only否则
* 记账静默丢失,同 rewrite 纪律)。判别/校验失败在 `run_clarify` 内确定性回退
* `need_clarification=false`(不阻塞整章重写主链路)。
*/
post: operations["clarify_rewrite_projects__project_id__chapters__chapter_no__rewrite_clarify_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/projects/{project_id}/chapters/{chapter_no}/review": {
parameters: {
query?: never;
@@ -406,6 +461,33 @@ export interface paths {
patch?: never;
trace?: never;
};
"/projects/{project_id}/chapters/{chapter_no}/refine/clarify": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Clarify Refine
* @description 润色预检澄清WFW-9 M1 路线A 两阶段之「问题」阶段):非流式 JSON只判不改。
*
* 独立于既有 refine 端点——只判「作者这条再沟通意见清不清楚」,含糊则反问 1 问 + 给选项,
* 清楚则给确认语放行;**绝不改正文**(正文仍走 refine 端点。analyst 档(不变量 #2
*
* 项目不存在 → 404无凭据 → 503dep 解析阶段拦下)。**只读不写库**(不变量 #3
* 末尾 `commit()` 让网关 usage_ledger 落库add-only否则记账静默丢失同 refine 纪律)。
* 判别/校验失败在 `run_clarify` 内确定性回退 `need_clarification=false`(不阻塞润色)。
*/
post: operations["clarify_refine_projects__project_id__chapters__chapter_no__refine_clarify_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/templates": {
parameters: {
query?: never;
@@ -415,7 +497,7 @@ export interface paths {
};
/**
* List Templates
* @description 列出当前用户stub的模板。
* @description 列出当前用户stub的模板(分页;`?limit=&offset=`,默认第一页)
*/
get: operations["list_templates_templates_get"];
put?: never;
@@ -450,6 +532,30 @@ export interface paths {
patch?: never;
trace?: never;
};
"/projects/{project_id}/ai-messages": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* List Ai Messages
* @description 列出 AI 对话newest-first。`chapter_no` → 本章 项目级(NULL);缺省 → 仅项目级。
*/
get: operations["list_ai_messages_projects__project_id__ai_messages_get"];
put?: never;
/**
* Append Ai Messages
* @description 一批往复 bubble append201。项目不存在 → 404非法 kind/role/空/超上限 → 422。
*/
post: operations["append_ai_messages_projects__project_id__ai_messages_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/projects/{project_id}/world/generate": {
parameters: {
query?: never;
@@ -845,6 +951,95 @@ export interface components {
*/
review_id?: string | null;
};
/**
* AiMessageAppendRequest
* @description POST /projects/{id}/ai-messages一次交换的一批 bubble批级字段提顶层
*/
AiMessageAppendRequest: {
/**
* Thread Id
* Format: uuid
*/
thread_id: string;
/** Chapter No */
chapter_no?: number | null;
/**
* Kind
* @enum {string}
*/
kind: "refine" | "rewrite" | "clarify" | "continue" | "generator";
/** Tool Key */
tool_key?: string | null;
/** Messages */
messages: components["schemas"]["AiMessageInput"][];
};
/**
* AiMessageInput
* @description 一条待落库 bubble角色 + 人读文本 + 结构化 meta
*/
AiMessageInput: {
/**
* Role
* @enum {string}
*/
role: "author" | "ai";
/** Content */
content: string;
/** Meta */
meta?: {
[key: string]: unknown;
};
};
/**
* AiMessageListResponse
* @description append/list 信封:`messages` 复数键repo 约定,非 `items`)。
*/
AiMessageListResponse: {
/** Messages */
messages: components["schemas"]["AiMessageView"][];
};
/**
* AiMessageView
* @description 聊天留痕视图append/list 回显snake_case镜像 repo view
*/
AiMessageView: {
/**
* Id
* Format: uuid
*/
id: string;
/**
* Project Id
* Format: uuid
*/
project_id: string;
/** Chapter No */
chapter_no: number | null;
/**
* Thread Id
* Format: uuid
*/
thread_id: string;
/** Seq */
seq: number;
/** Kind */
kind: string;
/** Tool Key */
tool_key: string | null;
/** Role */
role: string;
/** Content */
content: string;
/** Meta */
meta?: {
[key: string]: unknown;
};
/**
* Created At
* Format: date-time
*/
created_at: string;
};
/**
* CapabilitiesView
* @description 探测得到的能力矩阵(镜像网关 `Capabilities`)。
@@ -1086,6 +1281,81 @@ export interface components {
/** Note */
note?: string | null;
};
/**
* ClarifyDecision
* @description 润色预检澄清决策(结构化 LLM 输出 + API 响应)。
*
* 路线A 两阶段之「问题」阶段:仅判定「要不要反问」并给选项,**不改正文**(正文仍走
* 既有 refine 端点)。纯只读(`clarify_refine_spec.writes=()`,不变量 #3
*
* **全字段给默认值守解析韧性**(仿 `StyleDriftReview` 降级范式LLM 漏产/畸形时
* 降级为 `need_clarification=false`(不问、直接放行 refine绝不阻塞润色主链路。
*
* - `need_clarification=false` → `questions` 为空、可给一句 `verification` 确认语;
* - `need_clarification=true` → `questions` 恰 1 问v1 硬上限)、`verification` 可空。
*/
ClarifyDecision: {
/**
* Need Clarification
* @description 是否需要向作者反问澄清(含糊/多走法→true明确→false
* @default false
*/
need_clarification: boolean;
/**
* Questions
* @description 反问清单need_clarification=false 时为空v1 硬上限 1 问)
*/
questions?: components["schemas"]["ClarifyQuestion"][];
/**
* Verification
* @description 明确时的一句『我这样理解对吗』确认语need_clarification=false 时给,可空)
*/
verification?: string | null;
};
/**
* ClarifyOption
* @description 单个澄清选项:展示文案 + 选中回填值(锚定本段/本章的一种具体走法)。
*
* `value` = 作者选中后折进 refine instruction 字符串的内容(零迁移,不落库)。
* 凑不出具体可区分选项时退化为空 options`ClarifyQuestion.options=[]`),突出自由输入。
*/
ClarifyOption: {
/**
* Label
* @description 展示文案(作者看到的选项标签)
*/
label: string;
/**
* Value
* @description 选中后回填/折进 instruction 的具体走法内容
*/
value: string;
};
/**
* ClarifyQuestion
* @description 单条澄清反问:问题 + 24 个锚定选项 + 自由输入兜底。
*
* `options` 常规 24 个(锚定本段/本章的具体走法);凑不出具体可区分选项时留空列表,
* 退化为纯自由输入。`allow_free_text` 常驻 True——自由输入永远兜底作者可不选任一项
*/
ClarifyQuestion: {
/**
* Question
* @description 反问的澄清问题(一句话,指向本段的分歧点)
*/
question: string;
/**
* Options
* @description 24 个锚定本段/本章的具体走法选项;凑不出具体选项时为空列表
*/
options?: components["schemas"]["ClarifyOption"][];
/**
* Allow Free Text
* @description 是否允许自由输入(常驻兜底)
* @default true
*/
allow_free_text: boolean;
};
/**
* ConflictDecision
* @description 对最近一次审稿留痕里**某个冲突**(按其在 conflicts 列表的下标定位)的裁决。
@@ -1757,6 +2027,23 @@ export interface components {
/** Tier Routing */
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
* @description 回炉:重写选中段(可选改写指令)。
@@ -1868,6 +2155,36 @@ export interface components {
/** Draft */
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
* @description POST /projects/:id/rules新增一条规则。
@@ -2437,7 +2754,10 @@ export interface operations {
};
list_projects_projects_get: {
parameters: {
query?: never;
query?: {
limit?: number;
offset?: number;
};
header?: never;
path?: never;
cookie?: never;
@@ -2453,6 +2773,15 @@ export interface operations {
"application/json": components["schemas"]["ProjectListResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
create_project_projects_post: {
@@ -2707,13 +3036,14 @@ export interface operations {
};
};
responses: {
/** @description Successful Response */
/** @description SSE 事件流(`event: <name>\ndata: <json>`,见 memory/contracts.md */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
"text/event-stream": unknown;
};
};
/** @description Validation Error */
@@ -2727,6 +3057,97 @@ 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 SSE 事件流(`event: <name>\ndata: <json>`,见 memory/contracts.md */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
"text/event-stream": 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: {
parameters: {
query?: never;
@@ -2743,13 +3164,14 @@ export interface operations {
};
};
responses: {
/** @description Successful Response */
/** @description SSE 事件流(`event: <name>\ndata: <json>`,见 memory/contracts.md */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
"text/event-stream": unknown;
};
};
/** @description Validation Error */
@@ -3237,10 +3659,67 @@ export interface operations {
};
};
};
list_templates_templates_get: {
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: {
parameters: {
query?: {
limit?: number;
offset?: number;
};
header?: never;
path?: never;
cookie?: never;
};
@@ -3255,6 +3734,15 @@ export interface operations {
"application/json": components["schemas"]["TemplateResponse"][];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
create_template_templates_post: {
@@ -3319,6 +3807,77 @@ export interface operations {
};
};
};
list_ai_messages_projects__project_id__ai_messages_get: {
parameters: {
query?: {
chapter_no?: number | null;
kind?: ("refine" | "rewrite" | "clarify" | "continue" | "generator") | null;
limit?: number;
offset?: number;
};
header?: never;
path: {
project_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AiMessageListResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
append_ai_messages_projects__project_id__ai_messages_post: {
parameters: {
query?: never;
header?: never;
path: {
project_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["AiMessageAppendRequest"];
};
};
responses: {
/** @description Successful Response */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AiMessageListResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
generate_world_projects__project_id__world_generate_post: {
parameters: {
query?: never;

View File

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

View File

@@ -125,3 +125,11 @@ export type OutlineSceneIngestView =
// F1拆书入库产物themes/archetypes/structure/hooks → 项目 rules 条目)。
export type TeardownIngestView =
components["schemas"]["TeardownIngestView"];
// AC-3 AI 对话聊天记录作者↔AI 五类往复留痕的 append/list 契约AC-2 C3 扩snake_case
export type AiMessageView = components["schemas"]["AiMessageView"];
export type AiMessageInput = components["schemas"]["AiMessageInput"];
export type AiMessageAppendRequest =
components["schemas"]["AiMessageAppendRequest"];
export type AiMessageListResponse =
components["schemas"]["AiMessageListResponse"];

View File

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

View File

@@ -1,44 +0,0 @@
import { describe, expect, it } from "vitest";
import {
aiToolItems,
primaryAiToolItems,
secondaryAiToolItems,
} from "./ai-tools";
describe("aiToolItems", () => {
it("returns 5 items", () => {
expect(aiToolItems("p1")).toHaveLength(5);
});
it("leads with 写本章 (primary, key write)", () => {
const [first] = aiToolItems("p1");
expect(first?.label).toBe("写本章");
expect(first?.primary).toBe(true);
expect(first?.key).toBe("write");
});
it("scopes every href under the project path", () => {
for (const item of aiToolItems("abc")) {
expect(item.href.startsWith("/projects/abc/")).toBe(true);
}
});
it("maps 审稿 to the review page and key", () => {
const review = aiToolItems("p1").find((item) => item.label === "审稿");
expect(review?.href).toBe("/projects/p1/review");
expect(review?.key).toBe("review");
});
it("keeps high-frequency tools outside the mobile more menu", () => {
expect(primaryAiToolItems("p1").map((item) => item.key)).toEqual([
"write",
"review",
]);
expect(secondaryAiToolItems("p1").map((item) => item.key)).toEqual([
"outline",
"codex",
"toolbox",
]);
});
});

View File

@@ -1,35 +0,0 @@
// T4-a · 顶部 AI 工具条数据纯逻辑UX §3/§5
// 把高频 AI 动作(写本章/审稿/大纲/设定库/工具箱)做成常驻一排,根治「命令面板太隐蔽」。
// 只露已实现能力;新生成器(创作工具箱 B 组)落地后并入工具箱入口。
// 组件只渲染;条目与激活键集中在此,复用 ActiveNav 高亮规则。
import type { ActiveNav } from "./items";
export interface AiToolItem {
href: string;
label: string;
// 高亮键(与 AppShell activeNav 比对)。
key: ActiveNav;
// 主动作(写本章)朱砂强调,其余次级。
primary?: boolean;
}
// 项目内页 AI 工具条条目(按已实现能力露出)。
export function aiToolItems(projectId: string): AiToolItem[] {
const base = `/projects/${projectId}`;
return [
{ href: `${base}/write`, label: "写本章", key: "write", primary: true },
{ href: `${base}/review`, label: "审稿", key: "review" },
{ href: `${base}/outline`, label: "大纲", key: "outline" },
{ href: `${base}/codex`, label: "设定库", key: "codex" },
{ href: `${base}/toolbox`, label: "工具箱", key: "toolbox" },
];
}
export function primaryAiToolItems(projectId: string): AiToolItem[] {
return aiToolItems(projectId).slice(0, 2);
}
export function secondaryAiToolItems(projectId: string): AiToolItem[] {
return aiToolItems(projectId).slice(2);
}

View File

@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
GLOBAL_NAV_ITEMS,
isNavItemActive,
navSections,
projectNavItems,
type NavItem,
} from "./items";
@@ -32,6 +33,53 @@ describe("projectNavItems", () => {
});
});
describe("navSections", () => {
it("returns only the unlabeled global section outside a project", () => {
const sections = navSections();
expect(sections).toHaveLength(1);
expect(sections[0]?.label).toBeNull();
expect(sections[0]?.items).toEqual(GLOBAL_NAV_ITEMS);
});
it("groups project entries under the four labeled sections (P2-1)", () => {
const sections = navSections(PID);
// 全局组(无标题)+ 写作 / 资料库 / 审阅 / 更多。
expect(sections.map((s) => s.label)).toEqual([
null,
"写作",
"资料库",
"审阅",
"更多",
]);
const byLabel = (label: string): (string | undefined)[] =>
sections.find((s) => s.label === label)?.items.map((i) => i.key) ?? [];
expect(byLabel("写作")).toEqual(["write"]);
expect(byLabel("资料库")).toEqual([
"outline",
"codex",
"foreshadow",
"style",
]);
expect(byLabel("审阅")).toEqual(["review", "chains"]);
expect(byLabel("更多")).toEqual(["toolbox", "rules", "skills"]);
});
it("places every project entry exactly once (no dropped or duplicated links)", () => {
const sections = navSections(PID);
const grouped = sections
.filter((s) => s.label !== null)
.flatMap((s) => s.items.map((i) => i.key));
const flat = projectNavItems(PID).map((i) => i.key);
expect([...grouped].sort()).toEqual([...flat].sort());
expect(grouped).toHaveLength(flat.length);
});
});
describe("isNavItemActive", () => {
const write: NavItem = {
href: `/projects/${PID}/write`,

View File

@@ -46,6 +46,43 @@ export function projectNavItems(projectId: string): NavItem[] {
];
}
// 左栏一个分组:可选小标题 + 若干条目P2-1。全局组 label=null顶部直显、无小标题
// 项目组复用现有"本作"式小标题样式。分组只重排 + 加标题,链接/路由/激活键均不变(行为保持)。
export interface NavSection {
label: string | null;
items: readonly NavItem[];
}
// 项目级入口的语义分组P2-1把 10 个平铺项归为 写作/资料库/审阅/更多,便于扫读。
// 工具箱归入「更多」——原分组清单只列了 规则·技能库,收进"更多"以不丢任何入口
// (见 memory/decisions.md 2026-07-10 条)。
const PROJECT_GROUPS: readonly { label: string; keys: readonly ActiveNav[] }[] =
[
{ label: "写作", keys: ["write"] },
{ label: "资料库", keys: ["outline", "codex", "foreshadow", "style"] },
{ label: "审阅", keys: ["review", "chains"] },
{ label: "更多", keys: ["toolbox", "rules", "skills"] },
];
// 左导航分组(桌面侧栏 / 移动抽屉共用):全局组恒在顶部,项目内页再展开四个项目组。
export function navSections(projectId?: string): NavSection[] {
const global: NavSection = { label: null, items: GLOBAL_NAV_ITEMS };
if (projectId === undefined) return [global];
const byKey = new Map(projectNavItems(projectId).map((item) => [item.key, item]));
const projectSections = PROJECT_GROUPS.map((group) => ({
label: group.label,
items: group.keys.map((key) => {
const item = byKey.get(key);
if (item === undefined) {
throw new Error(`navSections: 缺少项目入口 ${key}`);
}
return item;
}),
}));
return [global, ...projectSections];
}
export interface NavActiveContext {
pathname: string;
activeNav?: ActiveNav;

View File

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

View File

@@ -6,15 +6,25 @@ export type FixOutcome =
| { status: "not-found"; text: string } // 有补丁但终稿里找不到原文(作者已改稿/漂移text 原样
| { status: "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 为字符串(容许空串=删除)。
export function hasApplicableFix(
original: string | null | undefined,
replacement: string | null | undefined,
): boolean {
// 类型谓词:命中即把 original/replacement 收窄为 string调用方无需强转。
export function hasApplicableFix(patch: LoosePatch): patch is ApplicablePatch {
return (
typeof original === "string" &&
original.length > 0 &&
typeof replacement === "string"
typeof patch.original === "string" &&
patch.original.length > 0 &&
typeof patch.replacement === "string"
);
}
@@ -25,18 +35,20 @@ export function applyConflictFix(
original: string | null | undefined,
replacement: string | null | undefined,
): FixOutcome {
if (!hasApplicableFix(original, replacement)) {
const patch: LoosePatch = { original, replacement };
if (!hasApplicableFix(patch)) {
return { status: "no-patch", text };
}
// hasApplicableFix 已收窄为 string。
const from = original as string;
const to = replacement as string;
const idx = text.indexOf(from);
// 谓词已把 patch 收窄为 ApplicablePatchoriginal/replacement 皆为 string。
const idx = text.indexOf(patch.original);
if (idx === -1) {
return { status: "not-found", text };
}
return {
status: "applied",
text: text.slice(0, idx) + to + text.slice(idx + from.length),
text:
text.slice(0, idx) +
patch.replacement +
text.slice(idx + patch.original.length),
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -85,7 +85,7 @@ export class SseFrameBuffer {
let idx: number;
// 块之间以空行(\n\n兼容 \r\n\r\n分隔。
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));
const evt = parseSseBlock(block);
if (evt) events.push(evt);

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import { api } from "@/lib/api/client";
import { useToast } from "@/components/Toast";
import { useJobPoll } from "@/lib/jobs/useJobPoll";
import { styleLearnResult } from "@/lib/jobs/job";
import { errorCode } from "@/lib/generation/cards";
import {
buildLearnRequest,
normalizeFingerprint,
@@ -27,12 +28,6 @@ export interface UseStyleLearn {
) => 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 展示指纹。
export function useStyleLearn(initial: Fingerprint | null): UseStyleLearn {
const [fingerprint, setFingerprint] = useState<Fingerprint | null>(initial);

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import type { ToolInputFieldView } from "@/lib/api/types";
import type { PreviewModel } from "./toolbox";
import { fieldSummary, renderPreviewText } from "./aiSummary";
const fields: ToolInputFieldView[] = [
{ name: "brief", label: "脑洞", type: "textarea", required: true, help: null },
{ name: "count", label: "数量", type: "number", required: false, help: null },
];
describe("fieldSummary", () => {
it("拼出「labelvalue」摘要跳过空值", () => {
const summary = fieldSummary(fields, { brief: "废柴逆袭", count: "" });
expect(summary).toBe("脑洞:废柴逆袭");
});
it("多字段用 · 连接", () => {
const summary = fieldSummary(fields, { brief: "扮猪吃虎", count: "3" });
expect(summary).toBe("脑洞:扮猪吃虎 · 数量3");
});
it("字段为空 / 无值 → 空串", () => {
expect(fieldSummary([], {})).toBe("");
expect(fieldSummary(fields, {})).toBe("");
});
});
describe("renderPreviewText", () => {
it("把预览项渲染成人读文本heading + 字段 + 正文)", () => {
const preview: PreviewModel = {
kind: "IdeaListResult",
items: [
{
heading: "废柴逆袭",
fields: [{ label: "钩子", value: "扮猪吃虎" }],
body: null,
},
{ heading: null, fields: [], body: "一段开篇正文。" },
],
};
const text = renderPreviewText(preview);
expect(text).toContain("废柴逆袭");
expect(text).toContain("钩子:扮猪吃虎");
expect(text).toContain("一段开篇正文。");
});
it("空预览 → 空串", () => {
expect(renderPreviewText({ kind: "x", items: [] })).toBe("");
});
});

View File

@@ -0,0 +1,33 @@
// 工具箱生成留痕的两个纯格式化助手AI 对话记录用):
// - fieldSummary把作者填写的输入字段拼成人读摘要author 气泡 content长文进 content 不进 meta
// - renderPreviewText把结构化预览渲染成人读文本ai 气泡 content不把原始产物塞进 meta
// 确定性纯函数,无 IO供 GeneratorRunner 结束后 append 一批往复。
import type { ToolInputFieldView } from "@/lib/api/types";
import type { FieldValues, PreviewModel } from "./toolbox";
// 输入字段 → 「labelvalue」摘要跳过空值多字段用 · 连接。
export function fieldSummary(
fields: readonly ToolInputFieldView[],
values: FieldValues,
): string {
return fields
.map((f) => ({ label: f.label, value: (values[f.name] ?? "").trim() }))
.filter((entry) => entry.value.length > 0)
.map((entry) => `${entry.label}${entry.value}`)
.join(" · ");
}
// 预览模型 → 人读文本:每项 = heading + 「labelvalue」次要字段 + 正文段,项间空行分隔。
export function renderPreviewText(preview: PreviewModel): string {
return preview.items
.map((item) => {
const lines: string[] = [];
if (item.heading) lines.push(item.heading);
for (const f of item.fields) lines.push(`${f.label}${f.value}`);
if (item.body) lines.push(item.body);
return lines.join("\n");
})
.filter((block) => block.length > 0)
.join("\n\n");
}

View File

@@ -55,8 +55,9 @@ describe("useGenerator", () => {
});
const { result } = renderHook(() => useGenerator());
let returned: unknown;
await act(async () => {
await result.current.generate("p1", "idea", { brief: "脑洞" });
returned = await result.current.generate("p1", "idea", { brief: "脑洞" });
});
expect(result.current.genStatus).toBe("preview");
@@ -65,6 +66,10 @@ describe("useGenerator", () => {
expect(result.current.preview?.kind).toBe("IdeaListResult");
expect(result.current.preview?.items[0]?.heading).toBe("废柴逆袭");
expect(toast).not.toHaveBeenCalled();
// 追加断言generate 返回本次产物(供 GeneratorRunner 结束后留痕)。
const artifact = returned as { outputKind: string; rawPreview: unknown };
expect(artifact.outputKind).toBe("IdeaListResult");
expect(artifact.rawPreview).toEqual(preview);
});
it("生成后端返回 errorgenStatus=error 且弹错误 toast", async () => {

View File

@@ -16,6 +16,13 @@ import type { ToolGenerateRequest } from "@/lib/api/types";
export type GenStatus = "idle" | "generating" | "preview" | "error";
export type IngestStatus = "idle" | "ingesting" | "conflict" | "done" | "error";
// 一次生成的产物(供调用方在结束后留痕;与 hook state 同源)。
export interface GeneratorArtifact {
preview: PreviewModel;
rawPreview: Record<string, unknown> | undefined;
outputKind: string;
}
export interface UseGenerator {
genStatus: GenStatus;
preview: PreviewModel | null;
@@ -30,7 +37,7 @@ export interface UseGenerator {
projectId: string,
toolKey: string,
body: ToolGenerateRequest,
) => Promise<void>;
) => Promise<GeneratorArtifact | null>;
// 入库选中行acknowledge 用于过 409已查看冲突后重发
ingest: (
projectId: string,
@@ -74,15 +81,22 @@ export function useGenerator(): UseGenerator {
if (error || !data) {
setGenStatus("error");
toast(generationErrorMessage(error), "error");
return;
return null;
}
setPreview(mapPreview(data.output_kind, data.preview));
const mapped = mapPreview(data.output_kind, data.preview);
setPreview(mapped);
setRawPreview(data.preview);
setOutputKind(data.output_kind);
setGenStatus("preview");
return {
preview: mapped,
rawPreview: data.preview,
outputKind: data.output_kind,
};
} catch {
setGenStatus("error");
toast("生成请求异常,请检查网络。", "error");
return null;
}
},
[toast],

View File

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

View File

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

View File

@@ -0,0 +1,135 @@
import { describe, expect, it } from "vitest";
import type { AiMessageView } from "@/lib/api/types";
import {
acceptActionFor,
bubbleSide,
groupByThread,
KIND_LABEL,
kindLabel,
partitionByScope,
} from "./aiConversation";
// AiMessageView 工厂:只填测试关心的字段,其余给稳定默认。
function view(partial: Partial<AiMessageView>): AiMessageView {
return {
id: partial.id ?? crypto.randomUUID(),
project_id: "p1",
chapter_no: partial.chapter_no ?? null,
thread_id: partial.thread_id ?? "t1",
seq: partial.seq ?? 0,
kind: partial.kind ?? "refine",
tool_key: partial.tool_key ?? null,
role: partial.role ?? "ai",
content: partial.content ?? "",
meta: partial.meta ?? {},
created_at: partial.created_at ?? "2026-07-09T00:00:00Z",
};
}
describe("kindLabel / KIND_LABEL", () => {
it("五类 kind 各有中文标签", () => {
expect(KIND_LABEL.refine).toBe("润色");
expect(KIND_LABEL.rewrite).toBe("整章重写");
expect(KIND_LABEL.clarify).toBe("反问澄清");
expect(KIND_LABEL.continue).toBe("续写");
expect(KIND_LABEL.generator).toBe("工具箱");
});
it("未知 kind 回退为原始串(不崩)", () => {
expect(kindLabel("mystery")).toBe("mystery");
expect(kindLabel("refine")).toBe("润色");
});
});
describe("bubbleSide", () => {
it("author 靠右、ai 靠左", () => {
expect(bubbleSide("author")).toBe("right");
expect(bubbleSide("ai")).toBe("left");
});
});
describe("groupByThread", () => {
it("按 thread_id 归组,返回每组 kind/toolKey/chapterNo", () => {
const msgs = [
view({ thread_id: "a", kind: "refine", chapter_no: 1, seq: 0 }),
view({ thread_id: "b", kind: "continue", tool_key: "continue", chapter_no: 1, seq: 0 }),
view({ thread_id: "a", kind: "refine", chapter_no: 1, seq: 1 }),
];
const threads = groupByThread(msgs);
const a = threads.find((t) => t.threadId === "a");
expect(a?.messages).toHaveLength(2);
expect(a?.kind).toBe("refine");
const b = threads.find((t) => t.threadId === "b");
expect(b?.toolKey).toBe("continue");
expect(b?.chapterNo).toBe(1);
});
it("同一批次 created_at 相同时,按 seq 升序排组内(不靠 created_at 单独定序)", () => {
// 故意乱序传入;同 created_at靠 seq 稳定定序。
const msgs = [
view({ thread_id: "a", seq: 2, content: "c2", created_at: "2026-07-09T00:00:00Z" }),
view({ thread_id: "a", seq: 0, content: "c0", created_at: "2026-07-09T00:00:00Z" }),
view({ thread_id: "a", seq: 1, content: "c1", created_at: "2026-07-09T00:00:00Z" }),
];
const thread = groupByThread(msgs)[0]!;
expect(thread.messages.map((m) => m.content)).toEqual(["c0", "c1", "c2"]);
});
it("跨批次靠 created_at 定序,组内新批 append 在后", () => {
const msgs = [
view({ thread_id: "a", seq: 0, content: "old", created_at: "2026-07-09T00:00:00Z" }),
view({ thread_id: "a", seq: 0, content: "new", created_at: "2026-07-09T00:05:00Z" }),
];
const thread = groupByThread(msgs)[0]!;
expect(thread.messages.map((m) => m.content)).toEqual(["old", "new"]);
});
it("线程按各自首条 (created_at, seq) 升序(老线程在前)", () => {
const msgs = [
view({ thread_id: "late", seq: 0, created_at: "2026-07-09T01:00:00Z" }),
view({ thread_id: "early", seq: 0, created_at: "2026-07-09T00:00:00Z" }),
];
const threads = groupByThread(msgs);
expect(threads.map((t) => t.threadId)).toEqual(["early", "late"]);
});
it("空输入 → 空数组", () => {
expect(groupByThread([])).toEqual([]);
});
});
describe("partitionByScope", () => {
it("本章线程与项目级(chapter_no=null)线程分区", () => {
const msgs = [
view({ thread_id: "chap", chapter_no: 3, seq: 0 }),
view({ thread_id: "proj", chapter_no: null, kind: "generator", seq: 0 }),
];
const threads = groupByThread(msgs);
const { chapterThreads, projectThreads } = partitionByScope(threads, 3);
expect(chapterThreads.map((t) => t.threadId)).toEqual(["chap"]);
expect(projectThreads.map((t) => t.threadId)).toEqual(["proj"]);
});
it("非本章号的章线程不落入本章分区(联合查询兜底)", () => {
const threads = groupByThread([
view({ thread_id: "other", chapter_no: 9, seq: 0 }),
]);
const { chapterThreads, projectThreads } = partitionByScope(threads, 3);
expect(chapterThreads).toHaveLength(0);
expect(projectThreads).toHaveLength(0);
});
});
describe("acceptActionFor", () => {
it("按 kind 映射接受动作clarify/未知无动作", () => {
const mk = (kind: string) =>
groupByThread([view({ thread_id: kind, kind, seq: 0 })])[0]!;
expect(acceptActionFor(mk("rewrite"))).toBe("replace_chapter");
expect(acceptActionFor(mk("continue"))).toBe("append_draft");
expect(acceptActionFor(mk("generator"))).toBe("append_draft");
expect(acceptActionFor(mk("refine"))).toBe("refill_segment");
expect(acceptActionFor(mk("clarify"))).toBeNull();
expect(acceptActionFor(mk("mystery"))).toBeNull();
});
});

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