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

2
.gitignore vendored
View File

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

View File

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

View File

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

View File

@@ -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 面。 > **背景**[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。 > **整改 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-C1 🔴 升级 Next.js 修 RCE + React 补丁 | | @frontend | — | `f4c2c4e` next 15.1.3→**15.5.20**registry backport 线,避 16.x 大迁移、react/react-dom 19.0.0→**19.2.7**、eslint-config-next/@types 同步;`pnpm install` 重锁 + `pnpm build` 绿;`pnpm audit` next/react/react-dom 无 high/critical`lib/security/dependencyFloors.ts` 版本下界守卫。**⚠️ 运维待办(用户执行)**CVE 号无法对真库核验(升级后 `pnpm audit`/GitHub advisory 复核)+ 升级后轮换全部应用密钥 |
| CR-C2 🔴 多章链设 `recursion_limit` | | @backend | — | `services/chain_runner.py:210` 默认 25 supersteps、每章 4 节点→超 ~6 章即 `GraphRecursionError` 整 job 失败,「批量产一卷(K章)」在目标规模不可用。`config``recursion_limit=(last-start+1)*4+10`;补超默认上限的 mock-gateway 回归测试 | | CR-C2 🔴 多章链设 `recursion_limit` | | @backend | — | `ed0a679` **前提证伪**langgraph 1.2.5 默认上限=10007非 25、count≤50→~200 步,**无实活崩溃**;仍加显式**顶层** `recursion_limit=count*4+10`(防御硬化);测断言 config 键(行为「跑长链完成」测会 vacuous |
| CR-H1 🟠 Kimi OAuth 轮询不占 DB 会话 | | @backend | — | `services/job_runner.py:75`+`routers/kimi_oauth.py:85` 轮询全程(最坏 >200s占连接池连接、饿死其他请求轮询循环无需 DB仅最终 `upsert_oauth_credential` 开短会话 | | CR-H1 🟠 Kimi OAuth 轮询不占 DB 会话 | | @backend | — | `ca8fbc8` 轮询移出会话:`_poll_for_token`(无会话)+`_run_kimi_oauth_poll`,仅最终 persist 走 run_job 短会话;`job_runner` 未动(与 style_learn 共享) |
| CR-H2 🟠 修 `httpx.AsyncClient` 泄漏 | | @backend | — | `routers/kimi_oauth.py:73` `Depends(_default_http_client)` 非 generator 依赖、永不 `aclose()`;改 `async def _http_client(): async with httpx.AsyncClient(...) as http: yield http` | | CR-H2 🟠 修 `httpx.AsyncClient` 泄漏 | | @backend | — | `5dd336b`+`26f10d0` 请求级依赖改 `_http_client` 生成器(`async with``aclose`);两处测 override 跟随改,堵住 E2E 误触真网 |
| CR-H3 🟠 Anthropic 结构化输出记真实 usage | | @llm | — | `adapters/anthropic.py:161` 硬编码 `usage=0``usage_ledger` 全记 0 成本、腐蚀计费(违 ARCH §4.8);用 `create_with_completion`原始响应 usage | | CR-H3 🟠 Anthropic 结构化输出记真实 usage | | @llm | — | `cb8fddf` `create_with_completion` `raw.usage`,弃硬编码 0修计费腐蚀 |
| CR-H4 🟠 `request_id` 贯通网关日志 | | @llm | — | `gateway.py:183` 所有 `llm_call`/`llm_provider_failed` 无 request_id`LlmRequest``request_id` 字段并从 API 层透传(违 ARCH §9.3 端到端追踪不变量) | | CR-H4 🟠 `request_id` 贯通网关日志 | | @llm | — | `51fe9f4`+`e08b01a` LlmRequest 加可选 `request_id` + **条件透传**(仅非 None否则覆盖 contextvars 反退化 sync/SSE 追踪);`llm_call`+两处 `llm_provider_failed` 全锁测。契约 C1-扩 |
| CR-H5 🟠 `thinking` 字段:实现或删除 | | @llm | — | `types.py:41` 声明但所有 adapter 忽略,`thinking=True` 静默无效、无报错;按 YAGNI 删除或实现转发 + 测试 | | CR-H5 🟠 `thinking` 字段:实现或删除 | | @llm | — | `afeac51` 删死字段 `LlmRequest.thinking`YAGNI`Capabilities.thinking`(异名活字段);回写 ARCH/contracts |
| CR-H6 🟠 流式路径加 per-provider 重试 | | @llm | — | `gateway.py:268` 流式仅 1 次尝试,首 token 前一次瞬时 429 烧光 fallback 链(与非流式 `_complete_with_retry` 对称);用 `_retrying()` 包裹首 token 前尝试或显式文档化 | | CR-H6 🟠 流式路径加 per-provider 重试 | | @llm | — | `8a2b1b0` `_stream_with_retry` 对称 run仅首 token 前重试mid-stream 失败照抛(不变量 §4.5 |
| CR-H7 🟠 `TOOLBOX` 注册表不可变 | | @backend | — | `packages/skills/ww_skills/toolbox_registry.py:70` 裸 dict 可变;仿同库 `SPECS` `Final[Mapping]=MappingProxyType(...)`(违不可变不变量 | | CR-H7 🟠 `TOOLBOX` 注册表不可变 | | @backend | — | `25d48b7` `Final[Mapping]=MappingProxyType(_TOOLBOX_BY_KEY)`(仿 SPECS |
| CR-H8 🟠 `.env.example` 移除可用 Fernet key | | @devops | — | 已确认提交真实可用 key注释「可直接用于本地」换非功能占位符 + 突出轮换提示;联动 CR-C1 轮换 | | CR-H8 🟠 `.env.example` 移除可用 Fernet key | | @devops | — | `baa41d3` 换不可用占位符Fernet 拒绝→照抄即启动失败)+ 轮换提示。**⚠️ 真 key 仍在 git 历史,曾用该 key 的环境须轮换(用户)** |
| CR-H9 🟠 API 文本输入加 `max_length` | | @backend | — | `schemas/{providers,generation,projects}.py` `api_key`/`brief`/`final_text`/`segment``min_length=1`DoS/成本失控加上限api_key≤512、brief≤1e4、final_text≤2e5+ ASGI 请求体大小限 | | CR-H9 🟠 API 文本输入加 `max_length` | | @backend | — | `e1a24a2`+`d6b53d0` 全请求文本字段加上限api_key≤512/brief≤1e4/final_text≤2e5/segment≤2e4/directive≤2e4/instruction≤2e3…+ **2 MiB body 中间件 413 兜底** + `PAYLOAD_TOO_LARGE`。契约 C3`gen:api` 无漂移 |
| CR-H10 🟠 前端 `errorCode` 去重 | | @frontend | — | `lib/style/{useRefine,useStyleLearn}.ts` 各自重复定义,统一 import 自 `lib/generation/cards.ts:132`DRY 漂移隐患) | | CR-H10 🟠 前端 `errorCode` 去重 | | @frontend | — | `5665cc9` `lib/style/{useRefine,useStyleLearn}` 统一 import 自 `generation/cards` |
| CR-H11 🟠 Drawer 关闭还原焦点 | | @frontend | — | `components/Drawer.tsx:41` 关闭后焦点掉到 body违 WCAG 2.4.3);传 `triggerRef`、关闭时 `.focus()` | | CR-H11 🟠 Drawer 关闭还原焦点 | | @frontend | — | `6e2b478` 行为本分支早已修;提取 `lib/a11y/useRestoreFocus` + renderHook 回归测试 + DRYDrawer/ContextDrawer 共用) |
| CR-H12 🟠 RefineView 取消在途 refine | | @frontend | — | `components/style/RefineView.tsx:33` 无 AbortController段切换时旧响应可覆盖新响应`useRefine` 暴露 abort 在 effect cleanup 调用,去 blanket exhaustive-deps disable | | CR-H12 🟠 RefineView 取消在途 refine | | @frontend | — | `5c02792` useRefine 暴露 `abort`setState-free+ AbortController + aborted 守卫弃迟到响应RefineView cleanup 调用 + 真 deps |
| CR-M1 🟡 后端架构整改批 | | @backend | — | `outline.py:162` volume 下推 DB 过滤;`generation.py:326`/`toolbox.py:431` N+1 INSERT→`bulk_create``jobs.py:28` 裸 ORM→repo`provider_deps.py:91` 去私有 `_client`(加 `probe_connection()`);列表端点加分页。⚠️`assemble.py:155` 8 串行查询:**AsyncSession 不支持同一 session 并发,`gather` 须每查询独立 session**,不可照搬 | | CR-M1 🟡 后端架构整改批 | ✅ 大部 | @backend | — | `c596a8d` outline volume 下推 DB WHEREProtocol/Sql/fake 加 keyword `volume``bf3dabe` 角色/世界观入库 N+1→`create_many`(世界观 add_all角色一次 IN 查+一次 flush 保 upsert 幂等/批内去重;单条 create 薄封装);`1392830` jobs 读端点裸 ORM→JobRepo`0892ff6` projects/templates 列表加分页(`ww_api.pagination` limit∈[1,200]/offset≥0越界 422下推 LIMIT/OFFSET。**item#4 provider_deps `_client`→已由 @llm 交付**`832ab7d` `OpenAICompatAdapter.probe_connection()` 公开探测 + provider_deps 改调,删私有 `_client` 反手 + `type:ignore`/`noqa:SLF001`isinstance 收窄兜非 OpenAI 兼容)。**item#6 assemble 并行→SKIP**8 查询共享同一注入 AsyncSession`gather` 会腐蚀ledger 警告属实);安全化须把 session 工厂穿透 assemble + 8 repo 各独立 session + 并发正确性测,对单用户几 ms 收益不值该回归风险 |
| CR-M2 🟡 网关/adapters 整改批 | | @llm | — | `gateway.py:90` `is_open()` 副作用违 CQS`pricing.py` 未知 model 静默记 0 → 加 warningadapter `str(exc)` 入异常消息可能带 vendor 响应体→只留类名+status | | CR-M2 🟡 网关/adapters 整改批 | ✅ 大部 | @llm | — | `32451a9` pricing 未知 model 发 `pricing_unknown_model` warning成本仍 0、照常记账`9439b97` 三 adapter 瞬时错误消息经 `provider_error_summary(exc)` 只留类名+status不泄 vendor 响应体)。**item `is_open()` CQS→SKIP**:冷却过读时半开清态是**故意的惰性转移**(唯一转半开点),拆 CQS 需两处调用方各先发命令、漏发则熔断永不复位(更差),风格分不值动熔断语义 |
| CR-M3 🟡 LangGraph 链健壮性 | | @llm | — | `chain_runner.py:124` accept commit 后 `run_overdue_scan` 幂等且失败无法 resume→部分进度空洞幂等化或纳入事务`chain_runner.py:159` 改用公开 `get_state().next` 替私有 `__interrupt__``chain/nodes.py:213` 链内 4 审串行→并行/复用 review 子图 | | CR-M3 🟡 LangGraph 链健壮性 | ✅ 大部 | @llm | — | `ec91c8f` runner 改用公开 `graph.aget_state(config).interrupts` 判暂停(弃私有 `__interrupt__`SpyGraph 测同步补 aget_state 透传);`6a739ca` review 节点五审 `asyncio.gather` 并行扇出(复用主图 add-only 记账并发范式 T3.8collect 仍一次落库;并发峰值测 + 五审落齐测)。**item `run_overdue_scan` 幂等→SKIP**`scan_overdue` 本已幂等(单条 `UPDATE...RETURNING` + `status NOT IN [CLOSED,OVERDUE]` + commit-only-if-changed原子无部分空洞且下一章扫描 current+1 自愈漏标;再动会触碰验收事务边界,单用户零收益 |
| CR-M4 🟡 前端 key/a11y/cleanup 批 | | @frontend | — | 可编辑列表去 index-key`CharacterGenerator.tsx:135`/`GeneratorRunner.tsx:178`/`ForeshadowCard.tsx:68`SSE `KNOWN_EVENTS``stop`(核对 `memory/contracts.md`ARIA 补全AppShell nav label / NavDrawer aria-controls / ForeshadowCard `<dl>` 结构);ProjectWizard 步骤焦点`ReviewReport.tsx:301` setTimeout 卸载清理 | | CR-M4 🟡 前端 key/a11y/cleanup 批 | | @frontend | — | `47d5558` ARIANavDrawer `aria-controls` + Drawer `id` + ForeshadowCard 语义化 `<dl>/<dt>/<dd>`+ `125908e` ReviewReport `flashMark` setTimeout 卸载清理ref 存句柄)。**核实为无需改/已具备**index-key三处均 replace-all/append-only 静态展示、无天然稳定 idindex 安全;卡名可编辑作 key 更糟)、`stop`(经查**非**服务端 SSE 帧、是客户端 AbortController 动作加则处理不存在事件、AppShell nav label`<nav aria-label>` 已有)、ProjectWizard 步骤焦点`stepHeadingRef`+首帧守卫 useEffect 已实现) |
| CR-M5 🟡 前端 TS 边界类型收紧 | | @frontend | — | `lib/api/server.ts:34` `parseJsonBody` 加最小结构校验;`useAccept.ts:60` `ApiErrorEnvelope` 从生成 schema 派生;`applyFix.ts` `hasApplicableFix` 改类型谓词`as string` | | CR-M5 🟡 前端 TS 边界类型收紧 | | @frontend | — | `d80d41a` `ApiErrorEnvelope` 改由生成 schema`components.schemas.ErrorEnvelope`)派生防漂移 + `9cf4915` `hasApplicableFix`对象参类型谓词、消除 `applyConflictFix` 两处 `as string` + `16f2eae` `parseJsonBody`(结构校验 HEAD 已具备,补非对象/非 JSON 守卫分支测试) |
| CR-L1 🟢 LOW 清理批 | | @backend·@llm·@frontend | — | `chain.py:256` 静默回退加 warningSSE 端点补 OpenAPI `text/event-stream` 声明;冗余 `as`/`.valueOf()` cast 清理;测试脚本 `print`→logging | | CR-L1 🟢 LOW 清理批 | | @backend·@llm·@frontend | — | **@backend**`49e3cb6` SSE draft/rewrite/review 补 OpenAPI `text/event-stream` 声明(共享 `_SSE_RESPONSE``print`→logging **无需改**owned 目录唯一 `print(``export_openapi.py` CLI stdout前端 gen:api 管道消费,改则断管道)。**@llm**`c9ffada` 续写链前一章无已验收终稿发 `chain_prior_accepted_missing` warning。**@frontend**`6414542` 去冗余 `as`/`.valueOf()` cast`stream/sse.ts` idx.valueOf、`jobs/job.ts` `dict[error] as string`;保留 load-bearing 的 CSS-var 键/变量键强转) |
| CR-D1 🟡 抬高后端依赖下界(可选升级评估) | | @devops | — | `pyproject.toml` 下界过旧`langgraph>=0.2.40`/`anthropic>=0.34`),抬到接近 lockfile 实锁版本保可复现;后端实锁版本均当前。可选评估 vitest 3.x / tailwind 4.x | | CR-D1 🟡 抬高后端依赖下界(可选升级评估) | | @devops | — | 8 个 pyproject 后端运行时下界抬到 lockfile 主次版本`langgraph>=1.2`/`anthropic>=0.111`/`openai>=2.43`/`pydantic>=2.13`/`fastapi>=0.137`/`cryptography>=49` 等,每条 ≤ 实锁);`uv.lock` **纯元数据刷新**(仅 `requires-dist` specifier零 pinned 版本变动);`uv lock --check` 绿、pytest **971**、ruff 干净。root dev 工具下界 + 前端 vitest/tailwind 升级评估未纳入(超范围) |
> **[2026-07-08] CRITICAL + HIGH 全部 ✅ 落地**14 项 + 4 项低危收尾),多 agent 并行开发(@llm/@backend/@devops/@frontend 四簇串行整合,各 TDD 红→绿、逐项补回归测试)。**门禁全绿**:后端 ruff/format 干净 · mypy **229** · alembic **无漂移** · pytest **934**;前端 lint/tsc 干净 · vitest **669** · build OK · coverage **95.41%**。**14 项对抗式复核 0 problem**skeptic 逐条实证 test 非 vacuous。落地含 3 处前提证伪C2 非实活崩溃/H4 sync-SSE 已由 contextvars 贯通/H11 行为本分支已修)。
>
> **[2026-07-08] MEDIUM/LOWCR-M1..M5 / L1 / D1亦全部清理 ✅**(四簇串行整合):明确取舍——高价值低风险项落地、风险>价值或前提已陈旧的子项**有据 SKIP/NOOP**assemble 并行 gather / breaker is_open CQS / overdue-scan 已幂等 / index-key 安全 / `stop` 非服务端帧 / 已具备的 nav-label、wizard 焦点)。**门禁全绿**:后端 mypy **232** · alembic 无漂移 · pytest **971**;前端 vitest **681** · build · coverage **95.45%**。附带修复:一 agent 误提交的非源文件(`.coverage`/`AGENTS.md`/`example_skills`)已撤销还原为未跟踪(`1bda024`)。
>
> **维度小结**:后端 FastAPI 架构 0C/2H/7M·Python 规范 0C/4H/6M·LangGraph 1C/2H/5M·前端 React 0C/2H/9M·前端 TS 0C/1H/8L·安全 0C/2H/4M·依赖 1C。后端依赖全部当前大版本线前端唯一关键缺口=Next RCE。 > **维度小结**:后端 FastAPI 架构 0C/2H/7M·Python 规范 0C/4H/6M·LangGraph 1C/2H/5M·前端 React 0C/2H/9M·前端 TS 0C/1H/8L·安全 0C/2H/4M·依赖 1C。后端依赖全部当前大版本线前端唯一关键缺口=Next RCE。
>
> **[2026-07-08] CR-M1 + CR-L1 后端片 ✅ 落地**@backend6 提交 `c596a8d`/`bf3dabe`/`1392830`/`0892ff6`/`49e3cb6`各行为改动先写红测再实现。CR-M1 4/6 完成volume 下推 · N+1→create_many · jobs→JobRepo · 列表分页item#4去 `_client`)与 item#6assemble 并行SKIP理由见 CR-M1 行CR-L1 后端片完成SSE OpenAPIprint 属误报)。**门禁全绿**:后端 ruff/format 干净 · mypy **230** · pytest **960 passed**(较基线 955 +5 新测volume 下推 1 + 分页 2 + 批量入库 2· 无模型改动故免 alembic。新增 5 测无回归,既有 upsert 幂等/冲突 gate + m5 真 pg E2E 全绿。
>
> **[请求 → @llm]** CR-M1 item#4`apps/api/ww_api/services/provider_deps.py:91` 需去掉对 adapter 私有 `_client` 的探测(`adapter._client.models.list()`。owning 类 `OpenAICompatAdapter`/`ProviderAdapter``packages/llm_gateway/ww_llm_gateway/adapters/`)在 @llm 域——请在 adapter 上加一个公开 `async probe_connection()`(内部最小请求验 Key如 `models.list()`@backend 改调该公开方法即可删私有属性访问 + `type: ignore`/`noqa: SLF001`。行为保持不变。
---
## 进行中写作工作台重构writing-first· 分支 `feat/writing-first-workspace`
> **背景**[2026-07-07] 用户对写作体验提 8 点反馈,产品主张升级为 **writing-first 工作台**——进来即写、所有工具/AI 动作从编辑器内按需调用并回填正文、元数据由内容渐进生成。多 agent 工作流扎根代码→Web+GitHub 调研→三方设计→四镜头对抗评审→定稿)产出 DEV_PLAN`scratchpad/writing-first-workspace-DEVPLAN.md`**ephemeral 未入库**;灵感/取舍见 `memory/selectable-write-prompts.md`)。
> **已拍板决策**:进来即写=**延迟落库**(首次击键才建占位项目 + 列表过滤空稿);可选提示词 MVP=**纯前端** directive→volatile不改 `DraftStreamRequest` 契约、不动 `write_craft.md` 字节 → 金标准零回归);预设 MVP=先只上内置;续写=多候选卡点选插入。
> **纪律**:全程 TDD 小步提交,回填全守 HITLAI 绝不 auto-replace点选/接受才落草稿);`/refine`、`/rewrite` 只读不写库(不变量 #3。**门禁全绿**:前端 vitest **678** / lint / tsc / build / coverage **95.36%**;后端 ruff / mypy **229** / pytest **955**(含 WFW-9 clarify E2E/ alembic 无漂移。**WFW-0~9 全部交付**WFW-5/6/7 多 agent 并行 → `3dd7d28`WFW-8 整章重写跨栈 → `6ed9c89`+`542c2ab`**WFW-9 三段全交付** M1 `1652ad9`+`e30c933` / M2 `4a09f27`..`8e29b33` / M3 `ea9a9d9`+`a703a90`,契约 C-Rewrite+C-Clarify 见 `memory/contracts.md`)。**未 push、未开 PR**(等用户示意)。
| 任务 | 状态 | 负责 | 依赖 | 反馈# | 备注 |
|---|---|---|---|---|---|
| WFW-0 进来即写(延迟落库) | ✅ | @frontend | — | #5 | `8389572` `/write`(DraftComposer) + `lib/start/useDeferredDraft`(懒建占位项目+种首章草稿,双重幂等,空进空出不落库) + 落地页 CTA「直接开始写」主/「先立项」辅 |
| WFW-1 润色 / 再沟通 | ✅ | @frontend | — | #1#2 | `2f0b841` `useRefine`(同步/refine,版本栈,再沟通=上一版产出+新意见再回炉) + `refineApply`(接受回填按内容重锚,原文变则提示重选) + RefinePanel 内联卡 + Editor 上报选区 |
| WFW-2 续写(多候选) | ✅ | @frontend | WFW-0 | #2 | `9195cf3` `useContinue`(continue 生成器 with_prior_chapter 读前文,多候选累加) + ContinuePanel 候选卡点选插入章末 + flush 后读最新草稿 |
| WFW-3 内联工具箱 + 金手指露出 | ✅ | @frontend | — | #4#8 | `04f4785` `useToolboxTools`(客户端拉描述符) + InlineToolbox(列非 legacy 生成器含金手指) + `GeneratorRunner``onInsertText`(文本产物插入正文) |
| WFW-4 AI 写作三槽(文风/剧情/自定义) | ✅ | @frontend | — | #3 | `44b0b8a` `directive.ts``PLOT_PRESETS` + `composeDirective` 合并;DirectivePanel 拆文风/剧情两组 chips + 自定义。纯前端零契约,`write_craft.md` 字节不变 |
| WFW-5 内容感知书名 | ✅ | @frontend | — | #6 | `3dd7d28` 纯前端零后端 MVP`buildManuscriptExcerpt`(首尾摘录+硬上界) + GeneratorRunner「用当前正文」按钮把摘录填 briefbook-title 据正文反推。**后续更佳版**:后端富化 book-title.md + `with_manuscript` 策略(未做) |
| WFW-6 侧栏 IA 收敛ContextDrawer | ✅ | @frontend | — | #7 | `3dd7d28` 加法式 ContextDrawer 右侧速查抽屉 + 底栏「速查」按钮;设定库/大纲**完整只读速查**,伏笔/规则/文风**摘要+跳全宽页**(后续可补真实数据)。`CONTEXT_DRAWER_ENABLED` 一键回滚、旧侧栏/全宽页路由全保留 |
| WFW-7 首次 AI 写作前极轻 genre 采集 + 无大纲降级 | ✅ | @frontend | WFW-0 | — | `3dd7d28` 无题材首次写章前弹 GenrePicker(临时并入本次 directive 不落库) + chapters 空时「尚无大纲,将按设定自由生成」提示,杜绝无题材 slop |
| WFW-8 整章级「再沟通/重写」(对话式迭代) | ✅ | @backend·@llm·@frontend | — | 新#1 | `6ed9c89`(后端)+`542c2ab`(前端)。后端 SSE `POST .../rewrite``rewrite.md` 教条 + `rewrite_node` + `RewriteStreamRequest`,复用 assemble 记忆注入,只读不写库、工具非写节点、守 #3/#7/#9);前端 `useChapterRewrite`(版本栈)+`ChapterRewritePanel`+底栏「整章重写」:给意见→流式重写整章→多轮迭代→接受某版才替换正文(HITL)。契约 C-Rewrite。用户反馈「整章不满意要跟 AI 交流」 |
| WFW-9 M1 AI 反问澄清refine 侧路线A 两阶段) | ✅ | @backend·@llm·@frontend | — | 新#2 | `1652ad9`(后端)+`e30c933`(前端)。多agent并行(后端整片‖ChoiceChips)+主线集成。非流式预检 `POST .../refine/clarify``ClarifyDecision`(结构化,analyst,只读,失败回退放行)+clarify_refine spec/教条(SPECS#24);既有 refine 未改。前端 `useClarify`+`ChoiceChips`+`clarify.ts`(foldClarifications/门控)+RefinePanel 门控接线:意见<10字或点帮我理清方向」→AI反问选项折答案进instruction走既有refine契约 C-Clarify。**M1 refine 侧完成M2/M3 见下两行** |
| WFW-9 M2 AI 反问澄清rewrite 整章重写路径 | | @backend·@llm·@frontend | | #2 | `4a09f27`+`6fd6375`(后端)+`9b32743`+`4b1088a`+`8e29b33`(前端) agent 串行整合新非流式预检 `POST .../rewrite/clarify``ClarifyDecision`(复用 M1 schema/`run_clarify`)+ `clarify_rewrite` spec/教条(章级,SPECS 2425,prompt_hashes +1)+`RewriteClarifyRequest{feedback,prior_draft?}`(仅取有界 3000 字摘录喂 analyst,不整章入 LLM);只读不写库失败放行前端 `useRewriteClarify`+`toVM/PROCEED` 提取入共享 `clarify.ts`(DRY)+`ChapterRewritePanel` 门控(镜像 RefinePanel):意见<10字或帮我理清方向」→反问折进 feedback走既有 rewrite SSE契约 C-Clarify ;gen:api 纳入新端点 |
| WFW-9 M3 澄清 UX 打磨 + E2E | | @frontend·@qa | | | `ea9a9d9`(a11y):refine/rewrite 两面板 AI 反问块加 `role=status` 向读屏动态播报(WCAG 4.1.3 Status Messages)。`a703a90`(E2E):`tests/test_wfw9_clarify_e2e.py` 7 用例真 pg+mock 网关零 token——refine/rewrite 含糊反问 / 清晰放行 / 失败开路(parsed=None→200 need_clarification=false,不 500) / 只读不写业务表但记 usage_ledger / 404无产品 bug。**深度视觉/交互 QA(真起前端浏览)为人工验收事项** |
| WFW-9 M2 AI 反问澄清rewrite 路线A 两阶段 | 后端 | @llm·@backend | WFW-8·WFW-9 M1 | #2 | `4a09f27`(llm)+`6fd6375`(后端)。镜像 M1非流式预检 `POST .../rewrite/clarify``RewriteClarifyRequest{feedback(必给),prior_draft(可选)}``ClarifyDecision`(复用同一 schema,analyst,只读,失败回退放行)+`clarify_rewrite_spec`/教条 `clarify_rewrite.md`(segment整章,SPECS#25);既有 `.../rewrite` SSE 未改端点只喂 prior_draft 开头 3000 字摘录(不整章入 LLM)、日志脱敏契约 C-Clarify()。门禁绿:后端 ruff/format·mypy229·pytest948·alembic 无漂移。**前端仍欠 `pnpm gen:api`+M2 UI 接线(仿 RefinePanel 门控 ChapterRewritePanel) + M3 UX/E2E 待办** |
| WFW-9 E2E两阶段 AI 反问澄清refine+rewrite | | @qa | WFW-9 M1·M2 | #2 | `tests/test_wfw9_clarify_e2e.py` 7 用例真 pg + mock 网关provider/adapter M2/T6 范式 tokenrefine 含糊反问1问 / refine 明确放行rewrite 含糊反问 / rewrite 明确放行失败开放(网关产非 ClarifyDecision仍200 need_clarification=false 500 且仍记账)、只读(seed chapter 字节一致+ review/digest ) usage_ledger 真落 analyst 一行404 信封响应绝不回灌原文摘录无产品 bug门禁绿:ruff/format·mypy 本文件净·pytest 955 passed |
--- ---
@@ -161,6 +208,20 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移无漂移users st
> 格式:`- [YYYY-MM-DD] @skill 完成/进展 Txx — 一句话结果 + 影响的契约/文件` > 格式:`- [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] @backend/@llm/@frontend**✅ 灵感⑦ 群像按定位配比·缩减版 + §0 归属纠偏develop 直提)**。角色群像生成支持「按定位配比」下发。**契约变更** `CharacterGenerateRequest``role_mix:dict[str,int]|None`——`model_validator(mode="after")` 定义与既有 `count` 的关系在场时校验非空、每项≥1、总和≤`MAX_GENERATE_COUNT`=12并把 `count` 归一为各值之和(构造期归一化,忽略客户端 count缺省退回单一 `role`+`count`。**§0 归属纠偏**`build_character_gen_context`+`run_character_gen``ww_core/orchestrator/generation_node.py`)确为 **@llm 域**——加末位默认 `role_mix=None` 关键字参,在场时注入「角色定位配比」块(保插入序、确定性、无时间戳,守 #6/#7 assemble 纯函数;覆盖单一 role 行);`routers/generation.py` 穿 `role_mix=body.role_mix``character-gen.md` 加「按配比严格分配 role、各定位之和=总数」纪律 → **regen `prompt_hashes.json`**(仅 character-gen 哈希变,**不进 SPECS→不 bump 计数**)。**前端** `cards.ts``sanitizeRoleMix`(去空/去重/clamp≥1/按 12 截断)+`roleMixTotal`+`buildCharacterGenerateRequest` 第 4 参 roleMix`useCharacterGen` `CharacterGenInput.roleMix` 穿参;新 `RoleMixEditor.tsx` + `CharacterGenerator` SegmentedControl 单一/配比双模。**TDD**schema 校验 7 例总和≤12/空 dict/非正值拒绝)+ context 注入 2 例 + run 穿参 1 例 + 前端 cards 构造/clamp 5 例 + hook renderHook 1 例。**门禁全绿**:后端 ruff/format 干净 · mypy **222** · pytest **858**+11· 无迁移(未动模型,未跑 alembic前端 gen:api/lint/typecheck 干净 · vitest **543**+6· coverage 95%cards.ts 100%)· build OK。记 contracts C3。守不变量 #2/#6/#7/#9。**无 DB 迁移**。
- [2026-07-06] @llm/@db/@backend/@frontend**✅ 灵感③ 人物塑造 advisory 审develop 直提)**。写→审链新增第五审「人物塑造」:**advisory 单维——仅建议、不阻断验收**(不进 conflicts、不碰 `assert_conflicts_resolved`、**不入 `REVIEW_RESERVED_NAMES`**D2只做人物塑造、不做氛围氛围噪声大折进 style/pace。**新 SPEC** `characterization_spec``tier="analyst"` 不写 model`reads=("characters",)` 以人物卡 motive/appearance 为客观锚点,`writes=()` 只读,`prompts/characterization.md` **显式忽略文本长度与辞藻华丽度**、引用逐字片段+置信度对抗 LLM-judge 冗长/辞藻偏见)+ schema `CharacterizationReview{issues:[…character/aspect/where/quote/diagnosis/suggestion/confidence]}`(全字段默认值守解析韧性)+ 注册 `SCHEMA_CATALOG["characterization"]`**计数三处 22→23**`specs.py` assert / `test_prompt_loader.py` len / `schema_catalog.py` docstring+ **regen `prompt_hashes.json`**(仅加一条,无其余漂移)。**DB** `chapter_reviews` 加 1 列 `characterization`**JSONB nullable**,迁移 `f6a7b8c9d0e1`nullable 免 backfill。**契约变更** `ReviewHistoryItem`+`ReviewView``characterization`;接线链 `graph.py REVIEW_SPECS` 末位追加 + `chain/nodes.py ReviewRecordRepo` + `collect.py {CHARACTERIZATION/extract_characterization/ReviewRecorder/collect_reviews}` + `review_repo.py {Protocol/SqlReviewRepo/_to_view}` + `sse.py {EVENT_CHARACTERIZATION/characterization_event/_section_result_events}`normalize 按 sorted 键自动纳入,字典序排最前不打乱既有四审断言)。**前端** `lib/review/{sse.ts,history.ts,useReviewStream.ts}` + 新 `CharacterizationPanel.tsx`advisory 只展示、**无裁决 UI**、按置信度降序 + 低置信度折叠控注意力预算)+ `ReviewReport.tsx` 挂面板 + 双 seed。**门禁全绿**:后端 ruff/format 干净 · mypy **221** · pytest **848**+核心 char 审 12 + agents spec 11 + api accept 2 等)· alembic 无漂移;前端 gen:api/lint/typecheck 干净 · vitest **536**+10 含 `test_characterization_does_not_block_accept` 对应前端 reducer/normalize/parse· coverage 95% · build OK。记 contracts C3+C4。守不变量 #2/#3/#9。依赖 ⑧motive/appearance 已入 CharacterCard 契约,作客观锚点)。 - [2026-07-06] @llm/@db/@backend/@frontend**✅ 灵感③ 人物塑造 advisory 审develop 直提)**。写→审链新增第五审「人物塑造」:**advisory 单维——仅建议、不阻断验收**(不进 conflicts、不碰 `assert_conflicts_resolved`、**不入 `REVIEW_RESERVED_NAMES`**D2只做人物塑造、不做氛围氛围噪声大折进 style/pace。**新 SPEC** `characterization_spec``tier="analyst"` 不写 model`reads=("characters",)` 以人物卡 motive/appearance 为客观锚点,`writes=()` 只读,`prompts/characterization.md` **显式忽略文本长度与辞藻华丽度**、引用逐字片段+置信度对抗 LLM-judge 冗长/辞藻偏见)+ schema `CharacterizationReview{issues:[…character/aspect/where/quote/diagnosis/suggestion/confidence]}`(全字段默认值守解析韧性)+ 注册 `SCHEMA_CATALOG["characterization"]`**计数三处 22→23**`specs.py` assert / `test_prompt_loader.py` len / `schema_catalog.py` docstring+ **regen `prompt_hashes.json`**(仅加一条,无其余漂移)。**DB** `chapter_reviews` 加 1 列 `characterization`**JSONB nullable**,迁移 `f6a7b8c9d0e1`nullable 免 backfill。**契约变更** `ReviewHistoryItem`+`ReviewView``characterization`;接线链 `graph.py REVIEW_SPECS` 末位追加 + `chain/nodes.py ReviewRecordRepo` + `collect.py {CHARACTERIZATION/extract_characterization/ReviewRecorder/collect_reviews}` + `review_repo.py {Protocol/SqlReviewRepo/_to_view}` + `sse.py {EVENT_CHARACTERIZATION/characterization_event/_section_result_events}`normalize 按 sorted 键自动纳入,字典序排最前不打乱既有四审断言)。**前端** `lib/review/{sse.ts,history.ts,useReviewStream.ts}` + 新 `CharacterizationPanel.tsx`advisory 只展示、**无裁决 UI**、按置信度降序 + 低置信度折叠控注意力预算)+ `ReviewReport.tsx` 挂面板 + 双 seed。**门禁全绿**:后端 ruff/format 干净 · mypy **221** · pytest **848**+核心 char 审 12 + agents spec 11 + api accept 2 等)· alembic 无漂移;前端 gen:api/lint/typecheck 干净 · vitest **536**+10 含 `test_characterization_does_not_block_accept` 对应前端 reducer/normalize/parse· coverage 95% · build OK。记 contracts C3+C4。守不变量 #2/#3/#9。依赖 ⑧motive/appearance 已入 CharacterCard 契约,作客观锚点)。
@@ -245,3 +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-28] @backend/@frontend**UI Phase G 后端依赖补齐**`ProjectResponse` 增加 `updated_at` / `pending_review_count``chapters``updated_at` 迁移 `8c1d2e3f4a5b``SqlProjectRepo` 聚合项目自身、章节、审稿时间作为最近编辑,并按草稿是否晚于最近 accepted/review 统计待审稿章数。前端 `pnpm gen:api` 后项目库默认按最近编辑排序,新增“待审稿”筛选,作品卡显示最近编辑与待审稿徽标。契约记 `memory/contracts.md` C3 扩UI 计划记 `docs/design/ui-improvement-plan.md` 0.16。
- [2026-06-29] @frontend/@qa**UI review follow-up 修复完成**:补 `lib/review/chapterNav.ts` 使新增章导航测试可运行;`ReviewSectionPanel` 改为本地 open 状态,避免用户折叠后重渲染强制弹开;`ReviewReport.hasReport` 纳入 clean review新增 `tests/test_project_metadata_e2e.py` 用真实 DB 覆盖项目最近编辑/待审稿统计口径。验证:前端 lint/typecheck/vitest **490 passed**/build后端 ruff/format/mypy、pytest **763 passed**;浏览器抽样 review/settings 无 console error/横向溢出。 - [2026-06-29] @frontend/@qa**UI review follow-up 修复完成**:补 `lib/review/chapterNav.ts` 使新增章导航测试可运行;`ReviewSectionPanel` 改为本地 open 状态,避免用户折叠后重渲染强制弹开;`ReviewReport.hasReport` 纳入 clean review新增 `tests/test_project_metadata_e2e.py` 用真实 DB 覆盖项目最近编辑/待审稿统计口径。验证:前端 lint/typecheck/vitest **490 passed**/build后端 ruff/format/mypy、pytest **763 passed**;浏览器抽样 review/settings 无 console error/横向溢出。
- [2026-07-06] @frontend**✅ 灵感⑥ 关系图谱(缩减版)+ D4 完成**(纯前端,无后端契约/迁移/gen:api。新增 `lib/characters/graphLayout.ts`(纯逻辑:`roleStyle` role→分组配色关键词映射、`buildGraphNodes`/`buildGraphEdges`(kind→标签`DEFAULT_RELATION_LABEL` 命名常量)、`countDanglingRelations``layoutGraph`(@dagrejs/dagre 一次性确定性初始坐标、中心→左上、不可变)、`buildRelationshipGraph` 端到端)——**仅用现有 `CharacterCardView.relations={name,kind,note}`**:边按 name-join、kind 作中文标签、节点按 role 上色,无 closeness/阵营(数据不存在,不硬造);悬空边(引用未入库角色)跳过并计数。新增 `components/characters/RelationshipGraph.tsx``'use client'`React Flow @xyflow/reactCodexPage 经 `dynamic(ssr:false)` 挂载于人物 tab显式容器高度 h-[28rem] + `import '@xyflow/react/dist/style.css'` + prefers-reduced-motion 关 fitView 动画 + 纸感 CSS 变量配色 + 悬空计数提示)。依赖:`@xyflow/react@^12.11.2`+`@dagrejs/dagre@^3.0.0`(见 contracts.md 2026-07-06。TDDgraphLayout **+22 测**role 映射/节点去空重名/kind 标签+默认/悬空跳过/自指跳过/无向去重/边 id 唯一/悬空计数/布局确定性+不可变/端到端);组件走人工/E2E。前端门禁全绿lint 干净 · typecheck 干净 · **vitest 565 passed** · **coverage lib 95.4%graphLayout.ts 97.6%** · **build OK**codex 路由 7.58 kBReact Flow 懒加载独立 chunk。注`pnpm build` 门禁与运行中的 `next dev`(3001) 共享 `.next` → 触发 dev 服务临时 500stale chunk非代码缺陷、dev watcher 下次重编自愈,未重启服务。 - [2026-07-06] @frontend**✅ 灵感⑥ 关系图谱(缩减版)+ D4 完成**(纯前端,无后端契约/迁移/gen:api。新增 `lib/characters/graphLayout.ts`(纯逻辑:`roleStyle` role→分组配色关键词映射、`buildGraphNodes`/`buildGraphEdges`(kind→标签`DEFAULT_RELATION_LABEL` 命名常量)、`countDanglingRelations``layoutGraph`(@dagrejs/dagre 一次性确定性初始坐标、中心→左上、不可变)、`buildRelationshipGraph` 端到端)——**仅用现有 `CharacterCardView.relations={name,kind,note}`**:边按 name-join、kind 作中文标签、节点按 role 上色,无 closeness/阵营(数据不存在,不硬造);悬空边(引用未入库角色)跳过并计数。新增 `components/characters/RelationshipGraph.tsx``'use client'`React Flow @xyflow/reactCodexPage 经 `dynamic(ssr:false)` 挂载于人物 tab显式容器高度 h-[28rem] + `import '@xyflow/react/dist/style.css'` + prefers-reduced-motion 关 fitView 动画 + 纸感 CSS 变量配色 + 悬空计数提示)。依赖:`@xyflow/react@^12.11.2`+`@dagrejs/dagre@^3.0.0`(见 contracts.md 2026-07-06。TDDgraphLayout **+22 测**role 映射/节点去空重名/kind 标签+默认/悬空跳过/自指跳过/无向去重/边 id 唯一/悬空计数/布局确定性+不可变/端到端);组件走人工/E2E。前端门禁全绿lint 干净 · typecheck 干净 · **vitest 565 passed** · **coverage lib 95.4%graphLayout.ts 97.6%** · **build OK**codex 路由 7.58 kBReact Flow 懒加载独立 chunk。注`pnpm build` 门禁与运行中的 `next dev`(3001) 共享 `.next` → 触发 dev 服务临时 500stale chunk非代码缺陷、dev watcher 下次重编自愈,未重启服务。
- [2026-07-08] @frontend**CR-M4/M5/L1 前端评审收尾MEDIUM/LOW 打磨,仅 apps/web无后端契约/gen:api**。DONE①a11y——`NavDrawer` 汉堡按钮补 `aria-controls`(新增 `Drawer` 可选 `id` prop 指向面板,默认 `nav-drawer`)、`ForeshadowCard``<dl>``<div>` 行改真 `<dt>/<dd>` 结构可视文案不变importance 用 sr-only 标签);②`ReviewReport.flashMark``setTimeout` 句柄入 ref + 卸载 useEffect 清理(防定时器泄漏,提 `FLASH_DURATION_MS` 常量);③`useAccept` 手写 `ApiErrorEnvelope` 改由生成 schema 派生 `components["schemas"]["ErrorEnvelope"]`(端点专属 details 就地收窄,行为不变);④`applyFix.hasApplicableFix` 重写为对象入参**类型谓词** `patch is ApplicablePatch`,消除 `applyConflictFix` 里两处 `as string`(含收窄单测);⑤`server.parseJsonBody` 已具边界校验,补两条守卫分支测试(非对象体/非 JSON 体);⑥去冗余 cast`stream/sse.ts` `idx.valueOf()``idx``jobs/job.ts` `dict["error"] as string`typeof 守卫后 TS 自动收窄。SKIP/NOOP前提已过时/本就安全):`ProjectWizard` 换步聚焦标题已实现;`AppShell`/`LeftNav` 两处 `<nav>` 均已带 `aria-label``KNOWN_EVENTS``stop`——`stop` 是客户端 abort 动作非服务端 SSE 帧(对照 contracts.md 草稿 token/done/error、续审 section/conflict/foreshadow/pace/style/characterization/done/error 均无 stop加入会为不存在的事件建处理`CharacterGenerator`/`GeneratorRunner`/`ForeshadowCard.progress` 的 index-key——三处均为整表替换/追加式/静态展示列表(无逐项重排或删除,`CharacterCardItem`/`PreviewRow` 为无内部状态受控组件,选择/编辑均按 index 且随新预览整体重置index 作 key 安全且无天然稳定 id加合成 UUID 需重做 index 选择/编辑态属过度改造。门禁全绿lint 干净 · typecheck 干净 · **vitest 681 passed**+3· **coverage lib 95.45%**exit 0· **build OK**

View File

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

View File

@@ -12,4 +12,6 @@ EXPOSE 8000
# 存活探测:镜像无 curl用 stdlib urllib 打 /health非 200/连接失败会抛异常,退出非零)。 # 存活探测:镜像无 curl用 stdlib urllib 打 /health非 200/连接失败会抛异常,退出非零)。
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ 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 ["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" version = "0.0.0"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"fastapi>=0.111", "fastapi>=0.137",
"uvicorn[standard]>=0.30", "uvicorn[standard]>=0.49",
"structlog>=24.1", "structlog>=26.1",
"cryptography>=43", "cryptography>=49",
"ww-shared", "ww-shared",
"ww-config", "ww-config",
"ww-db", "ww-db",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,6 +18,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ww_config import get_settings from ww_config import get_settings
from ww_core.domain import ForeshadowLedgerRepo, SqlForeshadowLedgerRepo 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.chapter_repo import ChapterRepo, SqlChapterRepo
from ww_core.domain.character_repo import CharacterWriteRepo, SqlCharacterWriteRepo from ww_core.domain.character_repo import CharacterWriteRepo, SqlCharacterWriteRepo
from ww_core.domain.digest_repo import DigestAppendRepo, SqlDigestAppendRepo from ww_core.domain.digest_repo import DigestAppendRepo, SqlDigestAppendRepo
@@ -160,6 +161,16 @@ def get_rule_write_repo(
return SqlRuleWriteRepo(session) 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( async def get_template_repo(
session: Annotated[AsyncSession, Depends(get_session)], session: Annotated[AsyncSession, Depends(get_session)],
) -> TemplateRepo: ) -> TemplateRepo:
@@ -519,6 +530,18 @@ async def get_refine_gateway(
return await build_gateway_for_tier(session, store, "writer") return await build_gateway_for_tier(session, store, "writer")
async def get_clarify_gateway(
session: Annotated[AsyncSession, Depends(get_session)],
) -> Gateway:
"""润色预检澄清analyst 档位)的可注入网关缝。
`POST .../refine/clarify` 在 dep 解析阶段构建网关 → 无凭据时这里抛 `LLM_UNAVAILABLE`
503进入端点前拦下。测试经 override 注 mock产 `ClarifyDecision`)。
"""
store = SqlCredentialStore(session)
return await build_gateway_for_tier(session, store, "analyst")
async def get_chain_gateway( async def get_chain_gateway(
session: Annotated[AsyncSession, Depends(get_session)], session: Annotated[AsyncSession, Depends(get_session)],
) -> Gateway: ) -> Gateway:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ import { EmptyState } from "@/components/ui/EmptyState";
import { PageHeader } from "@/components/ui/PageHeader"; import { PageHeader } from "@/components/ui/PageHeader";
import type { ProjectResponse, ToolDescriptorView } from "@/lib/api/types"; import type { ProjectResponse, ToolDescriptorView } from "@/lib/api/types";
import { isLegacyTool, resolveLegacyRoute } from "@/lib/toolbox/toolbox"; import { isLegacyTool, resolveLegacyRoute } from "@/lib/toolbox/toolbox";
import { useAiConversation } from "@/lib/workbench/useAiConversation";
import { ToolCard } from "./ToolCard"; import { ToolCard } from "./ToolCard";
import { GeneratorRunner } from "./GeneratorRunner"; import { GeneratorRunner } from "./GeneratorRunner";
@@ -27,6 +28,8 @@ export function ToolboxPage({
initialOpenKey, initialOpenKey,
}: ToolboxPageProps) { }: ToolboxPageProps) {
const router = useRouter(); const router = useRouter();
// 工具箱页无抽屉silent 直投——项目级(chapter_no=null)留痕,不 GET、失败不 toast。
const conversation = useAiConversation(project.id, 0, { silent: true });
const findOpenable = useCallback( const findOpenable = useCallback(
(key: string | undefined): ToolDescriptorView | null => { (key: string | undefined): ToolDescriptorView | null => {
@@ -76,6 +79,8 @@ export function ToolboxPage({
<GeneratorRunner <GeneratorRunner
projectId={project.id} projectId={project.id}
tool={active} tool={active}
appendMessages={conversation.appendMessages}
logChapterNo={null}
onClose={() => setActive(null)} onClose={() => setActive(null)}
/> />
) : sorted.length === 0 ? ( ) : 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"; "use client";
import Link from "next/link";
import { Minus, Pin, PinOff, Plus, RotateCcw, Undo2, X } from "lucide-react"; import { Minus, Pin, PinOff, Plus, RotateCcw, Undo2, X } from "lucide-react";
import { ThinkingIndicator } from "@/components/ThinkingIndicator"; import { ThinkingIndicator } from "@/components/ThinkingIndicator";
@@ -45,11 +44,11 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
return ( return (
<div className="px-4"> <div className="px-4">
<SectionHeader <SectionHeader
title="本章助手" title="本章参考"
action={ action={
injection.saving ? ( injection.saving ? (
<ThinkingIndicator <ThinkingIndicator
label="助手保存中" label="保存中"
className="text-xs text-ink-soft" className="text-xs text-ink-soft"
/> />
) : null ) : null
@@ -58,7 +57,7 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
<section className="mt-4"> <section className="mt-4">
<SectionHeader <SectionHeader
title="本章注入(透明)" title="AI 写这章会参考"
action={ action={
data ? ( data ? (
<RecentStepper <RecentStepper
@@ -71,10 +70,10 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
/> />
{injection.loading ? ( {injection.loading ? (
<p className="mt-2 text-xs text-ink-soft"></p> <p className="mt-2 text-xs text-ink-soft"></p>
) : !data ? ( ) : !data ? (
// 首次加载失败data 仍为空 → 满屏错误框 + 重试。 // 首次加载失败data 仍为空 → 满屏错误框 + 重试。
<StatusNote variant="danger" className="mt-2 text-xs" title="注入信息暂不可用"> <StatusNote variant="danger" className="mt-2 text-xs" title="参考信息暂不可用">
<p></p> <p></p>
<button <button
type="button" type="button"
@@ -115,7 +114,7 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
{selected.length === 0 ? ( {selected.length === 0 ? (
<p className="mt-2 rounded border border-dashed border-line bg-bg p-3 text-xs text-ink-soft"> <p className="mt-2 rounded border border-dashed border-line bg-bg p-3 text-xs text-ink-soft">
/ AI /
</p> </p>
) : ( ) : (
<ul className="mt-2 space-y-2"> <ul className="mt-2 space-y-2">
@@ -139,7 +138,7 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
{excluded.length > 0 ? ( {excluded.length > 0 ? (
<div className="mt-3"> <div className="mt-3">
<SectionHeader title="已排除(不注入" /> <SectionHeader title="已排除(本章不参考" />
<ul className="mt-1 space-y-1"> <ul className="mt-1 space-y-1">
{excluded.map((ref) => ( {excluded.map((ref) => (
<ExcludedRow <ExcludedRow
@@ -156,15 +155,9 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
<section className="mt-6"> <section className="mt-6">
<SectionHeader <SectionHeader
title="四审" title="写完后检查"
description="一致性 / 伏笔 / 文风 / 节奏,在审稿逐条裁决。" description="写完这章AI 会把一致性、伏笔、文风、节奏各查一遍。到左栏或底栏的「审稿逐条。"
/> />
<Link
href={`/projects/${projectId}/review?chapter=${chapterNo}`}
className={buttonClass({ variant: "outline", size: "sm", className: "mt-2" })}
>
稿
</Link>
</section> </section>
</div> </div>
); );
@@ -194,7 +187,7 @@ function EntityRow({ entity, pinned, disabled, onTogglePin, onExclude }: EntityR
disabled={disabled} disabled={disabled}
aria-pressed={pinned} aria-pressed={pinned}
aria-label={ aria-label={
pinned ? `取消置顶 ${entity.name}` : `置顶 ${entity.name}强制注入` pinned ? `取消置顶 ${entity.name}` : `置顶 ${entity.name}一定参考`
} }
onClick={() => onTogglePin(ref)} onClick={() => onTogglePin(ref)}
className={pinned ? "text-cinnabar" : undefined} className={pinned ? "text-cinnabar" : undefined}
@@ -209,7 +202,7 @@ function EntityRow({ entity, pinned, disabled, onTogglePin, onExclude }: EntityR
variant="ghost" variant="ghost"
size="icon" size="icon"
disabled={disabled} disabled={disabled}
aria-label={`排除 ${entity.name}不注入本章)`} aria-label={`排除 ${entity.name}(本章不参考`}
onClick={() => onExclude(ref)} onClick={() => onExclude(ref)}
> >
<X className="h-4 w-4" aria-hidden="true" /> <X className="h-4 w-4" aria-hidden="true" />

View File

@@ -1,6 +1,8 @@
import Link from "next/link"; import Link from "next/link";
import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { buildChapterEntries, type ChapterEntry } from "@/lib/workbench/chapter"; import { buildChapterEntries, type ChapterEntry } from "@/lib/workbench/chapter";
import { focusRing } from "@/lib/ui/variants";
interface ChapterListProps { interface ChapterListProps {
projectId: string; projectId: string;
@@ -9,36 +11,83 @@ interface ChapterListProps {
currentChapterNo: number; currentChapterNo: number;
} }
// 左栏目录UX §6.3):列出大纲全部章节 + 当前章,点击切换写作章号(`?chapter=N`)。 interface DesktopChapterListProps extends ChapterListProps {
// 桌面 aside 包裹;移动端经 Workbench 抽屉复用 ChapterListContent // 目录列收起态P2-2收起时只留一条带「展开」按钮的窄轨让宽给正文
collapsed: boolean;
onToggle: () => void;
}
// 左栏目录UX §6.3 / P2-2列出大纲全部章节 + 当前章,点击切换写作章号(`?chapter=N`)。
// 桌面 aside 包裹,可收起为窄轨;移动端经 Workbench 抽屉复用 ChapterListContent不带折叠
export function ChapterList({ export function ChapterList({
projectId, projectId,
chapters, chapters,
currentChapterNo, 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 ( return (
<aside className="hidden border-r border-line bg-panel py-4 xl:block"> <aside className="hidden border-r border-line bg-panel py-4 xl:block">
<ChapterListContent <ChapterListContent
projectId={projectId} projectId={projectId}
chapters={chapters} chapters={chapters}
currentChapterNo={currentChapterNo} currentChapterNo={currentChapterNo}
onCollapse={onToggle}
/> />
</aside> </aside>
); );
} }
interface ChapterListContentProps extends ChapterListProps {
// 桌面列传入 → 目录标题旁显示「收起」按钮;移动抽屉不传(抽屉自带关闭)。
onCollapse?: () => void;
}
export function ChapterListContent({ export function ChapterListContent({
projectId, projectId,
chapters, chapters,
currentChapterNo, currentChapterNo,
}: ChapterListProps) { onCollapse,
}: ChapterListContentProps) {
const entries = buildChapterEntries(chapters, currentChapterNo); const entries = buildChapterEntries(chapters, currentChapterNo);
const hasOutline = chapters.length > 0; const hasOutline = chapters.length > 0;
return ( return (
<> <>
<h2 className="px-4 text-xs font-semibold uppercase tracking-wide text-ink-soft"> <div className="flex items-center justify-between px-4">
<h2 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
</h2>
</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"> <ul className="mt-2">
{entries.map((entry) => { {entries.map((entry) => {
const active = entry.no === currentChapterNo; 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"; import { cn, focusRing, proseBody } from "@/lib/ui/variants";
export interface EditorSelection {
start: number;
end: number;
text: string;
}
interface EditorProps { interface EditorProps {
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
streaming: boolean; streaming: boolean;
// 选区变化上报(供「润色选段」等选区级 AI 动作取材。空选区也会上报start===end
onSelectionChange?: (selection: EditorSelection) => void;
} }
// 中栏正文编辑器宋体、720px 居中、行高 1.9UX §2.3 / §6.3)。 // 中栏正文编辑器宋体、720px 居中、行高 1.9UX §2.3 / §6.3)。
// 流式中显示朱砂光标提示prefers-reduced-motion 下不闪烁,见 globals.css // 流式中显示朱砂光标提示prefers-reduced-motion 下不闪烁,见 globals.css
export function Editor({ value, onChange, streaming }: EditorProps) { export function Editor({
value,
onChange,
streaming,
onSelectionChange,
}: EditorProps) {
const ref = useRef<HTMLTextAreaElement>(null); const ref = useRef<HTMLTextAreaElement>(null);
const reportSelection = (el: HTMLTextAreaElement): void => {
if (!onSelectionChange) return;
const start = el.selectionStart;
const end = el.selectionEnd;
onSelectionChange({ start, end, text: value.slice(start, end) });
};
// 自适应高度textarea 高度=内容高度overflow-hidden 不内部滚动),由外层写作区 // 自适应高度textarea 高度=内容高度overflow-hidden 不内部滚动),由外层写作区
// 单一滚动条统管。修复正文出现嵌套滚动条 + 定高 60vh 不铺满展示区的怪象。 // 单一滚动条统管。修复正文出现嵌套滚动条 + 定高 60vh 不铺满展示区的怪象。
// min-h-[60vh] 仍作空稿时的最小可写高度(内容更长时按内容增高、外层滚动)。 // min-h-[60vh] 仍作空稿时的最小可写高度(内容更长时按内容增高、外层滚动)。
@@ -35,6 +55,7 @@ export function Editor({ value, onChange, streaming }: EditorProps) {
id="chapter-editor" id="chapter-editor"
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
onSelect={(e) => reportSelection(e.currentTarget)}
readOnly={streaming} readOnly={streaming}
aria-busy={streaming} aria-busy={streaming}
placeholder="夜色如墨……点击「写本章」让 AI 起草,或直接在此书写。" placeholder="夜色如墨……点击「写本章」让 AI 起草,或直接在此书写。"

View File

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

View File

@@ -0,0 +1,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 Link from "next/link";
import { useEffect, useId, useRef, useState, type RefObject } from "react"; import { useEffect, useId, useRef, useState, type RefObject } from "react";
import { import {
BookOpen, BookMarked,
ChevronDown, ChevronDown,
ClipboardCheck, ClipboardCheck,
FileText, FileText,
Library, HelpCircle,
MessagesSquare,
PanelLeft, PanelLeft,
PanelRight, PanelRight,
PenLine, PenLine,
RefreshCw,
Sparkles,
Square, Square,
Wand2,
WrapText,
} from "lucide-react"; } from "lucide-react";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { Drawer } from "@/components/Drawer"; import { Drawer } from "@/components/Drawer";
import { ThinkingIndicator } from "@/components/ThinkingIndicator"; import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { useToast } from "@/components/Toast";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { SectionHeader } from "@/components/ui/SectionHeader"; import { SectionHeader } from "@/components/ui/SectionHeader";
import { StatusNote } from "@/components/ui/StatusNote"; import { StatusNote } from "@/components/ui/StatusNote";
@@ -29,11 +35,31 @@ import {
WORKBENCH_CHAPTER_NO, WORKBENCH_CHAPTER_NO,
type ChapterEntry, type ChapterEntry,
} from "@/lib/workbench/chapter"; } from "@/lib/workbench/chapter";
import { composeDirective, STYLE_PRESETS } from "@/lib/workbench/directive"; import {
import { buttonClass } from "@/lib/ui/variants"; 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 { ChapterList, ChapterListContent } from "./ChapterList";
import { ChapterAssistant, AssistantContent } from "./ChapterAssistant"; import { ChapterAssistant, AssistantContent } from "./ChapterAssistant";
import { Editor } from "./Editor"; import { Editor, type EditorSelection } from "./Editor";
import { RefinePanel } from "./RefinePanel";
import { ContinuePanel } from "./ContinuePanel";
import { InlineToolbox } from "./InlineToolbox";
import { ChapterRewritePanel } from "./ChapterRewritePanel";
import { ContextDrawer, CONTEXT_DRAWER_ENABLED } from "./ContextDrawer";
import {
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 { interface WorkbenchProps {
project: ProjectResponse; project: ProjectResponse;
@@ -64,11 +90,32 @@ export function Workbench({
// 本章指令T4-b自由文本 + 风格预设 → 写本章时组装传入 draft 生成。 // 本章指令T4-b自由文本 + 风格预设 → 写本章时组装传入 draft 生成。
const [directive, setDirective] = useState(""); const [directive, setDirective] = useState("");
const [presetIds, setPresetIds] = useState<string[]>([]); const [presetIds, setPresetIds] = useState<string[]>([]);
// 选区级 AI 动作(润色/再沟通):编辑器上报的最新选区 + 结果卡开合。
const [selection, setSelection] = useState<EditorSelection | null>(null);
const [refineOpen, setRefineOpen] = useState(false);
const [continueOpen, setContinueOpen] = useState(false);
const [toolboxOpen, setToolboxOpen] = useState(false);
// WFW-8整章「再沟通/重写」结果卡开合(意见 → 流式重写一版 → 版本栈 → 接受替换整章)。
const [rewriteOpen, setRewriteOpen] = useState(false);
// WFW-7 / 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 autosave = useAutosave(project.id, chapterNo, initialText);
const stream = useDraftStream(); const stream = useDraftStream();
const lastStreamText = useRef(""); const lastStreamText = useRef("");
const canRefine = selection !== null && selection.text.trim().length > 0;
const chapterTriggerRef = useRef<HTMLButtonElement>(null); const chapterTriggerRef = useRef<HTMLButtonElement>(null);
const assistantTriggerRef = useRef<HTMLButtonElement>(null); const assistantTriggerRef = useRef<HTMLButtonElement>(null);
const contextTriggerRef = useRef<HTMLButtonElement>(null);
// 流式 token 累积进编辑器(打字机);停止/结束后已生成部分留在草稿。 // 流式 token 累积进编辑器(打字机);停止/结束后已生成部分留在草稿。
useEffect(() => { useEffect(() => {
@@ -85,12 +132,23 @@ export function Workbench({
} }
}, [stream.state.phase, stream.state.text, autosave]); }, [stream.state.phase, stream.state.text, autosave]);
// 题材 + 三槽指令合并为一条写章指令题材前置effectiveGenre 无 → 仅原指令。
const startWrite = (effectiveGenre: string | null): void => {
const base = composeDirective(directive, presetIds);
const merged = [toGenreDirective(effectiveGenre), base]
.filter((part) => part.length > 0)
.join("");
void stream.start(project.id, chapterNo, merged);
};
const onWrite = (): void => { const onWrite = (): void => {
void stream.start( // 无题材且未点选 → 引导先点上方常驻「题材」条再写HITL不静默生成无题材 slop
project.id, // 选择条一直可见,理想路径是发送前已选好,第一次「写本章」即顺滑流式、不被弹窗打断。
chapterNo, if (genreGate.needsGenre && genreGate.chosenGenre === null) {
composeDirective(directive, presetIds), toast("先在上方选个题材AI 写得更贴合、少套话。", "info");
); return;
}
startWrite(genreGate.effectiveGenre);
}; };
const togglePreset = (id: string): void => { const togglePreset = (id: string): void => {
@@ -104,6 +162,111 @@ export function Workbench({
autosave.onChange(value); autosave.onChange(value);
}; };
// 接受润色/再沟通产出:按内容重锚把选段替换为产出并落草稿;原文已变则提示重选(不盲替换)。
const onAcceptRefine = (refined: string): void => {
if (selection) {
const next = applyRefinement(text, selection.text, refined, {
start: selection.start,
end: selection.end,
});
if (next === null) {
toast("正文已改动,找不到原选段,请重新选择后再润色。", "error");
} else {
setText(next);
autosave.onChange(next);
}
}
setRefineOpen(false);
setSelection(null);
};
// 四张 AI 结果卡(润色/续写/工具箱/整章重写)互斥,避免同时占位。
// 润色以选区为对象无选区时不静默失败给出可见提示引导先选段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 wordCount = text.replace(/\s+/g, "").length;
const closePanel = (): void => setMobilePanel(null); const closePanel = (): void => setMobilePanel(null);
@@ -132,11 +295,19 @@ export function Workbench({
projectId={project.id} projectId={project.id}
activeNav="write" 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 <ChapterList
projectId={project.id} projectId={project.id}
chapters={chapters} chapters={chapters}
currentChapterNo={chapterNo} currentChapterNo={chapterNo}
collapsed={chapterList.collapsed}
onToggle={chapterList.toggle}
/> />
<section className="flex min-w-0 flex-col bg-bg"> <section className="flex min-w-0 flex-col bg-bg">
@@ -147,17 +318,85 @@ export function Workbench({
chapterTriggerRef={chapterTriggerRef} chapterTriggerRef={chapterTriggerRef}
assistantTriggerRef={assistantTriggerRef} assistantTriggerRef={assistantTriggerRef}
/> />
{genreGate.needsGenre ? (
// 项目无题材正文上方常驻内联题材条非弹窗发送前选好即顺滑写章P1-5
<GenrePicker
genres={GENRES}
value={genreGate.chosenGenre}
onPick={genreGate.setChosenGenre}
/>
) : null}
<DirectivePanel <DirectivePanel
directive={directive} directive={directive}
onDirectiveChange={setDirective} onDirectiveChange={setDirective}
presetIds={presetIds} presetIds={presetIds}
onTogglePreset={togglePreset} 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"> <div className="flex-1 overflow-auto px-6 py-8">
{chapters.length === 0 ? (
<StatusNote variant="info" className="mb-4">
</StatusNote>
) : null}
<Editor <Editor
value={text} value={text}
onChange={onEditorChange} onChange={onEditorChange}
streaming={stream.isStreaming} streaming={stream.isStreaming}
onSelectionChange={setSelection}
/> />
</div> </div>
<Toolbar <Toolbar
@@ -169,8 +408,11 @@ export function Workbench({
streaming={stream.isStreaming} streaming={stream.isStreaming}
streamError={stream.state.error} streamError={stream.state.error}
liveMessage={liveMessage} liveMessage={liveMessage}
onWrite={onWrite}
onStop={stream.stop} onStop={stream.stop}
onOpenContext={() => setContextOpen(true)}
contextTriggerRef={contextTriggerRef}
onOpenConversation={() => setConversationOpen(true)}
conversationTriggerRef={conversationTriggerRef}
/> />
</section> </section>
@@ -195,11 +437,33 @@ export function Workbench({
open={mobilePanel === "assistant"} open={mobilePanel === "assistant"}
onClose={closePanel} onClose={closePanel}
side="right" side="right"
label="本章助手" label="本章参考"
triggerRef={assistantTriggerRef} triggerRef={assistantTriggerRef}
> >
<AssistantContent projectId={project.id} chapterNo={chapterNo} /> <AssistantContent projectId={project.id} chapterNo={chapterNo} />
</Drawer> </Drawer>
{/* WFW-6 上下文速查:视口无关的右侧 slide-over旧侧栏/全宽页路由保留、可回滚。 */}
<ContextDrawer
projectId={project.id}
open={contextOpen}
onClose={() => setContextOpen(false)}
triggerRef={contextTriggerRef}
/>
{/* 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> </AppShell>
); );
} }
@@ -251,73 +515,280 @@ interface DirectivePanelProps {
onDirectiveChange: (value: string) => void; onDirectiveChange: (value: string) => void;
presetIds: readonly string[]; presetIds: readonly string[];
onTogglePreset: (id: string) => void; 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({ function DirectivePanel({
directive, directive,
onDirectiveChange, onDirectiveChange,
presetIds, presetIds,
onTogglePreset, onTogglePreset,
defaultOpen,
streaming,
onWrite,
onStop,
}: DirectivePanelProps) { }: DirectivePanelProps) {
const panelId = useId(); const panelId = useId();
const [open, setOpen] = useState(defaultOpen);
const activeCount = presetIds.length + (directive.trim().length > 0 ? 1 : 0); const activeCount = presetIds.length + (directive.trim().length > 0 ? 1 : 0);
return ( return (
<details className="group border-b border-line bg-panel px-4 py-3 sm:px-6"> <section className="border-b border-line bg-panel px-4 py-3 sm:px-6">
<summary <div className="flex items-center justify-between gap-3">
className="flex cursor-pointer list-none items-center justify-between gap-3 text-sm text-ink-soft hover:text-cinnabar" <button
aria-controls={panelId} type="button"
> onClick={() => setOpen((v) => !v)}
<span className="flex items-center gap-2 font-serif text-base text-ink"> 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 <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" aria-hidden="true"
/> />
<span className="truncate"> AI </span>
</span> </button>
<span className="rounded border border-line bg-bg px-2 py-0.5 text-xs text-ink-soft"> <div className="flex shrink-0 items-center gap-2">
{activeCount > 0 ? `${activeCount} 项指令` : "可选"} {activeCount > 0 ? (
</span> <span className="rounded border border-line bg-bg px-2 py-0.5 text-xs text-ink-soft">
</summary> {activeCount}
<div id={panelId} className="mt-3 space-y-3"> </span>
<SectionHeader ) : null}
title="写作指令" {/* 常驻「发送键」= 写本章始终可见折叠时也在逐字流式写进正文。带可见范围提示P1-4。 */}
description="用于补充或覆盖本章大纲节拍,只影响本次生成。" {streaming ? (
/> <Button onClick={onStop} variant="danger" size="sm">
<label className="block"> <Square className="h-4 w-4" aria-hidden="true" />
<span className="sr-only"></span>
<TextArea </Button>
value={directive} ) : (
onChange={(e) => onDirectiveChange(e.target.value)} <Button onClick={onWrite} variant="primary" size="sm">
rows={2} <PenLine className="h-4 w-4" aria-hidden="true" />
placeholder="本章想怎么写?(可选,覆盖/补充大纲节拍)"
/> <span className="text-2xs font-normal text-panel/75">
</label> ·
<div className="flex flex-wrap gap-2" role="group" aria-label="风格预设"> </span>
{STYLE_PRESETS.map((preset) => { </Button>
const active = presetIds.includes(preset.id); )}
return ( <HitlHelp />
<Button
key={preset.id}
type="button"
aria-pressed={active}
onClick={() => onTogglePreset(preset.id)}
variant={active ? "outline" : "secondary"}
size="sm"
>
{preset.label}
</Button>
);
})}
</div> </div>
{activeCount > 0 ? (
<StatusNote variant="info">
</StatusNote>
) : null}
</div> </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 { interface ToolbarProps {
projectId: string; projectId: string;
chapterNo: number; chapterNo: number;
@@ -356,8 +829,14 @@ interface ToolbarProps {
streamError: { code: string; message: string } | null; streamError: { code: string; message: string } | null;
// 流式里程碑播报句(完成/停止/失败);仅在 role=status 区出现,不逐 token 刷新。 // 流式里程碑播报句(完成/停止/失败);仅在 role=status 区出现,不逐 token 刷新。
liveMessage: string; liveMessage: string;
onWrite: () => void; // 流式时的「停」:底栏 sticky滚到长正文下方也够得着中央条会滚出视野
onStop: () => void; onStop: () => void;
// 打开上下文速查抽屉WFW-6
onOpenContext: () => void;
contextTriggerRef: RefObject<HTMLButtonElement | null>;
// 打开对话记录抽屉AC-3
onOpenConversation: () => void;
conversationTriggerRef: RefObject<HTMLButtonElement | null>;
} }
function Toolbar({ function Toolbar({
@@ -369,8 +848,11 @@ function Toolbar({
streaming, streaming,
streamError, streamError,
liveMessage, liveMessage,
onWrite,
onStop, onStop,
onOpenContext,
contextTriggerRef,
onOpenConversation,
conversationTriggerRef,
}: ToolbarProps) { }: ToolbarProps) {
return ( return (
<div className="sticky bottom-0 z-10 border-t border-line bg-panel/95 px-4 py-2 backdrop-blur sm:px-6 sm:py-3"> <div className="sticky bottom-0 z-10 border-t border-line bg-panel/95 px-4 py-2 backdrop-blur sm:px-6 sm:py-3">
@@ -382,12 +864,7 @@ function Toolbar({
<Square className="h-4 w-4" aria-hidden="true" /> <Square className="h-4 w-4" aria-hidden="true" />
</Button> </Button>
) : ( ) : null}
<Button onClick={onWrite} variant="primary" size="sm">
<PenLine className="h-4 w-4" aria-hidden="true" />
</Button>
)}
{streaming ? ( {streaming ? (
// ThinkingIndicator 自带 role=status开始时播报一次「生成中」 // ThinkingIndicator 自带 role=status开始时播报一次「生成中」
// 易变字数 aria-hidden仅供视觉不逐 token 打扰屏读。 // 易变字数 aria-hidden仅供视觉不逐 token 打扰屏读。
@@ -409,24 +886,31 @@ function Toolbar({
<span className="sr-only" role="status" aria-live="polite"> <span className="sr-only" role="status" aria-live="polite">
{liveMessage} {liveMessage}
</span> </span>
<Link {CONTEXT_DRAWER_ENABLED ? (
href={`/projects/${projectId}/outline`} <Button
className={buttonClass({ variant: "secondary", size: "sm" })} ref={contextTriggerRef}
> onClick={onOpenContext}
<BookOpen className="h-4 w-4" aria-hidden="true" /> variant="secondary"
size="sm"
</Link> title="打开上下文速查(设定库/大纲/伏笔/规则/文风)"
<Link >
href={`/projects/${projectId}/foreshadow`} <BookMarked className="h-4 w-4" aria-hidden="true" />
className={buttonClass({
variant: "secondary", </Button>
size: "sm", ) : null}
className: "hidden sm:inline-flex", {AI_CONVERSATION_ENABLED ? (
})} <Button
> ref={conversationTriggerRef}
<Library className="h-4 w-4" aria-hidden="true" /> onClick={onOpenConversation}
variant="secondary"
</Link> size="sm"
title="打开对话记录(润色/重写/续写/工具箱往复留痕的只读日志)"
>
<MessagesSquare className="h-4 w-4" aria-hidden="true" />
</Button>
) : null}
{/* 唯一强调的「审稿」= 写→审→验收的前进 CTA导航去重后仅此一处强调左栏另有入口。 */}
<Link <Link
href={`/projects/${projectId}/review?chapter=${chapterNo}`} href={`/projects/${projectId}/review?chapter=${chapterNo}`}
className={buttonClass({ variant: "outline", size: "sm" })} 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; path?: never;
cookie?: never; cookie?: never;
}; };
/** List Projects */ /**
* List Projects
* @description 作品列表(按 created_at 升序,分页)。`?limit=&offset=`,默认第一页。
*/
get: operations["list_projects_projects_get"]; get: operations["list_projects_projects_get"];
put?: never; put?: never;
/** Create Project */ /** Create Project */
@@ -157,6 +160,58 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/projects/{project_id}/chapters/{chapter_no}/rewrite": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Stream Rewrite
* @description 整章再沟通/重写:组装记忆 + 当前草稿 + 作者意见 → 网关流 → 归一 SSE → event-stream。
*
* HITL新版停前端/草稿,接受才落库(不变量 #3rewrite 是工具非写节点,不破坏章
* 纯函数性(不变量 #7。项目不存在 → 404触网关前 fail-fast同 draftQA C1
*/
post: operations["stream_rewrite_projects__project_id__chapters__chapter_no__rewrite_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/projects/{project_id}/chapters/{chapter_no}/rewrite/clarify": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Clarify Rewrite
* @description 整章重写预检澄清WFW-9 M2 路线A 两阶段之「问题」阶段):非流式 JSON只判不改。
*
* 镜像 refine 侧 clarify——只判「作者这条整章重写意见清不清楚」含糊则反问 1 问 + 给选项,
* 清楚则给确认语放行;**绝不改正文**(整章重写仍走既有 rewrite SSE 端点。analyst 档
* (不变量 #2。只取 `prior_draft` 有界摘录喂预检(不整章入 LLM
*
* 项目不存在 → 404触网关前 fail-fast同 rewrite/draft无凭据 → 503dep 解析阶段拦下)。
* **只读不写业务表**(不变量 #3末尾 `commit()` 让网关 usage_ledger 落库add-only否则
* 记账静默丢失,同 rewrite 纪律)。判别/校验失败在 `run_clarify` 内确定性回退
* `need_clarification=false`(不阻塞整章重写主链路)。
*/
post: operations["clarify_rewrite_projects__project_id__chapters__chapter_no__rewrite_clarify_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/projects/{project_id}/chapters/{chapter_no}/review": { "/projects/{project_id}/chapters/{chapter_no}/review": {
parameters: { parameters: {
query?: never; query?: never;
@@ -406,6 +461,33 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/projects/{project_id}/chapters/{chapter_no}/refine/clarify": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Clarify Refine
* @description 润色预检澄清WFW-9 M1 路线A 两阶段之「问题」阶段):非流式 JSON只判不改。
*
* 独立于既有 refine 端点——只判「作者这条再沟通意见清不清楚」,含糊则反问 1 问 + 给选项,
* 清楚则给确认语放行;**绝不改正文**(正文仍走 refine 端点。analyst 档(不变量 #2
*
* 项目不存在 → 404无凭据 → 503dep 解析阶段拦下)。**只读不写库**(不变量 #3
* 末尾 `commit()` 让网关 usage_ledger 落库add-only否则记账静默丢失同 refine 纪律)。
* 判别/校验失败在 `run_clarify` 内确定性回退 `need_clarification=false`(不阻塞润色)。
*/
post: operations["clarify_refine_projects__project_id__chapters__chapter_no__refine_clarify_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/templates": { "/templates": {
parameters: { parameters: {
query?: never; query?: never;
@@ -415,7 +497,7 @@ export interface paths {
}; };
/** /**
* List Templates * List Templates
* @description 列出当前用户stub的模板。 * @description 列出当前用户stub的模板(分页;`?limit=&offset=`,默认第一页)
*/ */
get: operations["list_templates_templates_get"]; get: operations["list_templates_templates_get"];
put?: never; put?: never;
@@ -450,6 +532,30 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/projects/{project_id}/world/generate": {
parameters: { parameters: {
query?: never; query?: never;
@@ -845,6 +951,95 @@ export interface components {
*/ */
review_id?: string | null; 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 * CapabilitiesView
* @description 探测得到的能力矩阵(镜像网关 `Capabilities`)。 * @description 探测得到的能力矩阵(镜像网关 `Capabilities`)。
@@ -1086,6 +1281,81 @@ export interface components {
/** Note */ /** Note */
note?: string | null; note?: string | null;
}; };
/**
* ClarifyDecision
* @description 润色预检澄清决策(结构化 LLM 输出 + API 响应)。
*
* 路线A 两阶段之「问题」阶段:仅判定「要不要反问」并给选项,**不改正文**(正文仍走
* 既有 refine 端点)。纯只读(`clarify_refine_spec.writes=()`,不变量 #3
*
* **全字段给默认值守解析韧性**(仿 `StyleDriftReview` 降级范式LLM 漏产/畸形时
* 降级为 `need_clarification=false`(不问、直接放行 refine绝不阻塞润色主链路。
*
* - `need_clarification=false` → `questions` 为空、可给一句 `verification` 确认语;
* - `need_clarification=true` → `questions` 恰 1 问v1 硬上限)、`verification` 可空。
*/
ClarifyDecision: {
/**
* Need Clarification
* @description 是否需要向作者反问澄清(含糊/多走法→true明确→false
* @default false
*/
need_clarification: boolean;
/**
* Questions
* @description 反问清单need_clarification=false 时为空v1 硬上限 1 问)
*/
questions?: components["schemas"]["ClarifyQuestion"][];
/**
* Verification
* @description 明确时的一句『我这样理解对吗』确认语need_clarification=false 时给,可空)
*/
verification?: string | null;
};
/**
* ClarifyOption
* @description 单个澄清选项:展示文案 + 选中回填值(锚定本段/本章的一种具体走法)。
*
* `value` = 作者选中后折进 refine instruction 字符串的内容(零迁移,不落库)。
* 凑不出具体可区分选项时退化为空 options`ClarifyQuestion.options=[]`),突出自由输入。
*/
ClarifyOption: {
/**
* Label
* @description 展示文案(作者看到的选项标签)
*/
label: string;
/**
* Value
* @description 选中后回填/折进 instruction 的具体走法内容
*/
value: string;
};
/**
* ClarifyQuestion
* @description 单条澄清反问:问题 + 24 个锚定选项 + 自由输入兜底。
*
* `options` 常规 24 个(锚定本段/本章的具体走法);凑不出具体可区分选项时留空列表,
* 退化为纯自由输入。`allow_free_text` 常驻 True——自由输入永远兜底作者可不选任一项
*/
ClarifyQuestion: {
/**
* Question
* @description 反问的澄清问题(一句话,指向本段的分歧点)
*/
question: string;
/**
* Options
* @description 24 个锚定本段/本章的具体走法选项;凑不出具体选项时为空列表
*/
options?: components["schemas"]["ClarifyOption"][];
/**
* Allow Free Text
* @description 是否允许自由输入(常驻兜底)
* @default true
*/
allow_free_text: boolean;
};
/** /**
* ConflictDecision * ConflictDecision
* @description 对最近一次审稿留痕里**某个冲突**(按其在 conflicts 列表的下标定位)的裁决。 * @description 对最近一次审稿留痕里**某个冲突**(按其在 conflicts 列表的下标定位)的裁决。
@@ -1757,6 +2027,23 @@ export interface components {
/** Tier Routing */ /** Tier Routing */
tier_routing?: components["schemas"]["TierRoutingInput"][]; tier_routing?: components["schemas"]["TierRoutingInput"][];
}; };
/**
* RefineClarifyRequest
* @description 润色预检澄清:选段 + 再沟通意见WFW-9 M1 路线A 两阶段之「问题」阶段)。
*
* 与 `RefineRequest` 独立——预检只判「意见清不清楚」,不改正文。`instruction` 为作者的
* 再沟通意见(可空/极短,正是触发反问的场景);带长度上界防超长入参。响应=`ClarifyDecision`
* (在 ww_agents端点直接返回供前端 gen:api 生成强类型客户端)。
*/
RefineClarifyRequest: {
/** Segment */
segment: string;
/**
* Instruction
* @default
*/
instruction: string;
};
/** /**
* RefineRequest * RefineRequest
* @description 回炉:重写选中段(可选改写指令)。 * @description 回炉:重写选中段(可选改写指令)。
@@ -1868,6 +2155,36 @@ export interface components {
/** Draft */ /** Draft */
draft?: string | null; draft?: string | null;
}; };
/**
* RewriteClarifyRequest
* @description 整章重写预检澄清:章级意见 + 可选当前草稿WFW-9 M2 路线A 两阶段之「问题」阶段)。
*
* 与 `RewriteStreamRequest` 独立——预检只判「作者这条章级意见清不清楚」,**不改正文**
* (整章重写仍走既有 rewrite SSE 端点)。`feedback` = 作者对整章的修改意见(触发反问的场景,
* 可极短/含糊);`prior_draft` = 当前整章草稿(可选,仅供锚定;端点只取有界摘录喂 analyst 预检,
* 不整章入 LLM。带长度上界防超长入参。响应=`ClarifyDecision`(在 ww_agents端点直接返回
* 供前端 gen:api 生成强类型客户端)。
*/
RewriteClarifyRequest: {
/** Feedback */
feedback: string;
/** Prior Draft */
prior_draft?: string | null;
};
/**
* RewriteStreamRequest
* @description POST /projects/:id/chapters/:no/rewrite整章再沟通/重写。
*
* `feedback` = 作者对本章的修改意见;`prior_draft` = 当前整章草稿(前端传入,据此 + 记忆
* 注入流式重写一版)。临时输入、不持久化、无迁移;只入 volatile守缓存前缀不变量 #9
* 新版停前端/草稿接受才落库HITL不变量 #3
*/
RewriteStreamRequest: {
/** Feedback */
feedback: string;
/** Prior Draft */
prior_draft: string;
};
/** /**
* RuleCreateRequest * RuleCreateRequest
* @description POST /projects/:id/rules新增一条规则。 * @description POST /projects/:id/rules新增一条规则。
@@ -2437,7 +2754,10 @@ export interface operations {
}; };
list_projects_projects_get: { list_projects_projects_get: {
parameters: { parameters: {
query?: never; query?: {
limit?: number;
offset?: number;
};
header?: never; header?: never;
path?: never; path?: never;
cookie?: never; cookie?: never;
@@ -2453,6 +2773,15 @@ export interface operations {
"application/json": components["schemas"]["ProjectListResponse"]; "application/json": components["schemas"]["ProjectListResponse"];
}; };
}; };
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
}; };
}; };
create_project_projects_post: { create_project_projects_post: {
@@ -2707,13 +3036,14 @@ export interface operations {
}; };
}; };
responses: { responses: {
/** @description Successful Response */ /** @description SSE 事件流(`event: <name>\ndata: <json>`,见 memory/contracts.md */
200: { 200: {
headers: { headers: {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": unknown; "application/json": unknown;
"text/event-stream": unknown;
}; };
}; };
/** @description Validation Error */ /** @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: { review_chapter_projects__project_id__chapters__chapter_no__review_post: {
parameters: { parameters: {
query?: never; query?: never;
@@ -2743,13 +3164,14 @@ export interface operations {
}; };
}; };
responses: { responses: {
/** @description Successful Response */ /** @description SSE 事件流(`event: <name>\ndata: <json>`,见 memory/contracts.md */
200: { 200: {
headers: { headers: {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": unknown; "application/json": unknown;
"text/event-stream": unknown;
}; };
}; };
/** @description Validation Error */ /** @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: { parameters: {
query?: never; query?: never;
header?: 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; path?: never;
cookie?: never; cookie?: never;
}; };
@@ -3255,6 +3734,15 @@ export interface operations {
"application/json": components["schemas"]["TemplateResponse"][]; "application/json": components["schemas"]["TemplateResponse"][];
}; };
}; };
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
}; };
}; };
create_template_templates_post: { 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: { generate_world_projects__project_id__world_generate_post: {
parameters: { parameters: {
query?: never; query?: never;

View File

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

View File

@@ -125,3 +125,11 @@ export type OutlineSceneIngestView =
// F1拆书入库产物themes/archetypes/structure/hooks → 项目 rules 条目)。 // F1拆书入库产物themes/archetypes/structure/hooks → 项目 rules 条目)。
export type TeardownIngestView = export type TeardownIngestView =
components["schemas"]["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"]), status: asStatus(dict["status"]),
progress: asProgress(dict["progress"]), progress: asProgress(dict["progress"]),
result: asResult(dict["result"]), result: asResult(dict["result"]),
error: typeof dict["error"] === "string" ? (dict["error"] as string) : null, error: typeof dict["error"] === "string" ? dict["error"] : null,
}; };
} }

View File

@@ -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 { import {
GLOBAL_NAV_ITEMS, GLOBAL_NAV_ITEMS,
isNavItemActive, isNavItemActive,
navSections,
projectNavItems, projectNavItems,
type NavItem, type NavItem,
} from "./items"; } 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", () => { describe("isNavItemActive", () => {
const write: NavItem = { const write: NavItem = {
href: `/projects/${PID}/write`, 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 { export interface NavActiveContext {
pathname: string; pathname: string;
activeNav?: ActiveNav; activeNav?: ActiveNav;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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()); const { result } = renderHook(() => useGenerator());
let returned: unknown;
await act(async () => { await act(async () => {
await result.current.generate("p1", "idea", { brief: "脑洞" }); returned = await result.current.generate("p1", "idea", { brief: "脑洞" });
}); });
expect(result.current.genStatus).toBe("preview"); expect(result.current.genStatus).toBe("preview");
@@ -65,6 +66,10 @@ describe("useGenerator", () => {
expect(result.current.preview?.kind).toBe("IdeaListResult"); expect(result.current.preview?.kind).toBe("IdeaListResult");
expect(result.current.preview?.items[0]?.heading).toBe("废柴逆袭"); expect(result.current.preview?.items[0]?.heading).toBe("废柴逆袭");
expect(toast).not.toHaveBeenCalled(); 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 () => { 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 GenStatus = "idle" | "generating" | "preview" | "error";
export type IngestStatus = "idle" | "ingesting" | "conflict" | "done" | "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 { export interface UseGenerator {
genStatus: GenStatus; genStatus: GenStatus;
preview: PreviewModel | null; preview: PreviewModel | null;
@@ -30,7 +37,7 @@ export interface UseGenerator {
projectId: string, projectId: string,
toolKey: string, toolKey: string,
body: ToolGenerateRequest, body: ToolGenerateRequest,
) => Promise<void>; ) => Promise<GeneratorArtifact | null>;
// 入库选中行acknowledge 用于过 409已查看冲突后重发 // 入库选中行acknowledge 用于过 409已查看冲突后重发
ingest: ( ingest: (
projectId: string, projectId: string,
@@ -74,15 +81,22 @@ export function useGenerator(): UseGenerator {
if (error || !data) { if (error || !data) {
setGenStatus("error"); setGenStatus("error");
toast(generationErrorMessage(error), "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); setRawPreview(data.preview);
setOutputKind(data.output_kind); setOutputKind(data.output_kind);
setGenStatus("preview"); setGenStatus("preview");
return {
preview: mapped,
rawPreview: data.preview,
outputKind: data.output_kind,
};
} catch { } catch {
setGenStatus("error"); setGenStatus("error");
toast("生成请求异常,请检查网络。", "error"); toast("生成请求异常,请检查网络。", "error");
return null;
} }
}, },
[toast], [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