Compare commits

...

158 Commits

Author SHA1 Message Date
Yaojia Wang
64732b0d1b Merge branch 'feat/review-chapter-select-and-foreshadow' into develop
审稿选章(列章端点 + 全章下拉 + 默认最近章)
+ 伏笔主动提醒(写作台本章伏笔)与 AI 写作时自动应用(写章上下文注入到期/逾期伏笔 + 提示词指令)
+ 宽屏留白优化(作品库 max-w-7xl 四列 / 正文 768px)
+ 主题切换水合报错修复
+ AI 工作中动效强化(构思占位 / 流式进度条 / 波动三点 / 转圈)
门禁:前端 vitest 749 · lint/typecheck/build ✓;后端 pytest 1032 · ruff/mypy ✓。
2026-07-12 19:47:12 +02:00
Yaojia Wang
1afeb2cdb5 feat(ui): 强化 AI 工作中动效——构思占位/流式进度条/波动三点/转圈
- 动效基元:ai-dot 波动(三点竖跳)、ai-stream 不确定进度条、ai-breathe 呼吸微光(均带 reduced-motion 回退)
- ThinkingIndicator 升级为明显波动;新增 Spinner / StreamingBar / GenerationSkeleton 原子件;Button 加 loading 态
- 写作台:首 token 前正文区『AI 正在构思本章…』占位(补最大缺口,不再空白像卡死)+ 流式顶部进度条
- 整章重写流式进度条;续写/润色忙碌骨架 + 触发键转圈;生成器/角色/世界观改用 Button loading
2026-07-12 19:32:18 +02:00
Yaojia Wang
9bc0e1f2e6 fix(ui): 修复主题切换按钮的水合不一致(夜读模式刷新报错)
- 首帧 mode 用默认值(与服务端一致)→ 消除 SSR/客户端 aria-label/图标不一致
- 图标改由 CSS 按 <html data-theme> 显隐(两个图标始终在 DOM)→ 无首帧闪烁、无结构差异
- 挂载后 useEffect 以 localStorage 校正 mode → label 恢复正确文案
- 去掉会锁死服务端陈旧 label 的 suppressHydrationWarning
2026-07-12 19:02:27 +02:00
Yaojia Wang
40cc3fc9e3 style(ui): 收窄宽屏留白——作品库加宽到 max-w-7xl + xl 四列,写作正文 720→768px
- PageContainer wide 档 max-w-6xl→max-w-7xl;作品库改用 wide 档
- 作品库卡片网格 lg 三列基础上加 xl:grid-cols-4,宽屏铺满少留白
- 写作台正文测量 720→768px(约 42 中文字/行,仍舒适),收窄两侧空档
2026-07-12 18:39:01 +02:00
Yaojia Wang
b46afd4f8c feat(foreshadow): 写章上下文注入到期/逾期伏笔 + AI 顺势回收指令
- assemble 富渲染伏笔行:补『期望回收 X-Y章』+『(已逾期!)』标记(仍在 volatile 缓存段,守不变量 #9)
- 新增 foreshadow_inject 纯函数:除大纲显式窗口外,按当前章自动纳入
  『回收窗口内 / 已逾期且未 CLOSED』的伏笔(确定性、无向量,守不变量 #6)
- write_craft.md 增克制指令『到点顺势收,别为收而收』;foreshadow.md 描述对齐新注入格式
- MemoryRepos 加可选 foreshadow_ledger(照 review 先例,None 优雅降级)
- 金标准仅 foreshadow spec hash 变更;后端全绿 pytest 1032 passed
2026-07-12 18:13:14 +02:00
Yaojia Wang
b810a3fa3c feat(foreshadow): 写作台本章伏笔主动提醒(前端)
- 新增 chapterForeshadow 纯分类:按当前章分 可回收/待回收/已逾期(启用闲置的 isWindowApproaching)
- useChapterForeshadow hook(复用 GET /foreshadow,切章仅重分类不重拉)
- 右栏「本章参考」+ ContextDrawer 伏笔页新增「本章伏笔」实时清单,
  分色展示 + 跳看板链接 + 空态;标注『按已验收进度』
2026-07-12 17:53:00 +02:00
Yaojia Wang
9aa0cddeaf feat(review): 审稿选章可用——列章端点 + 选章下拉全章可见 + 默认最近章
- 后端新增 GET /projects/{id}/chapters(列真实存在的章 + has_draft/accepted/reviewed_at),TDD 10 测
- gen:api 重生成 TS 客户端(ChapterListItem)
- 选章下拉去掉 hidden(手机/窄屏也可切章);选项以真实章为准 + 大纲结构,
  无草稿章置灰(仅当前章例外),覆盖『写过但不在大纲』的章
- 无 ?chapter 时默认落到最近一个有草稿的章,而非硬编码第 1 章
2026-07-12 17:52:40 +02:00
Yaojia Wang
5f28491ad6 Merge branch 'feat/ui-uplift-claude-designmd' into develop
UI 提升对标 Claude DESIGN.md(P1 地基 / P2 组件 / P3 页面 / P4 无障碍,共 33 项)
+ 应用内帮助页 /help(功能介绍 + 新手上手,已按代码实现核验)
+ README 更新。前端门禁全绿(vitest 732 · lint/typecheck/build ✓)。
2026-07-12 04:59:14 +02:00
Yaojia Wang
4a334f79cc docs(readme): 补沉浸式写作体验(双主题/专注/⌘K/帮助页)+ 校正角色卡字段
- 新增功能总览一条:纸感+夜读双主题、专注模式、⌘K 命令面板、应用内帮助页 /help
- 角色卡去掉不存在的『目标』字段,改为按 Character 模型实有的 性格/背景(motive/traits/backstory/arc/speech_tics)
2026-07-12 04:55:16 +02:00
Yaojia Wang
54d3a532af fix(help): 按代码实现校正帮助页描述(多 agent 逐条核验)
- 大纲:卷→章→场景 改为 卷→章→节拍;场景仅细纲生成器产物并入 beats
- 设定库人物卡:动机/目标 改为 动机+人物弧光(无独立 goal 字段)
- 多章链:批量推进+联审 改为 循环写章→四审→验收、遇冲突暂停裁决(去夸大)
- 工具箱:改为十多个生成器实名列举 + 部分入库部分预览(世界观/人设/大纲实为跳转入口)
- 模板库:改为提示词模板填入生成器(原描述『立项一键套用』有误,与立项流程无关)
- 规则:补『全局/题材/文风/本作』四级具名
- 立项步骤:措辞收敛(立项立卖点,设定/大纲为后续步骤)
2026-07-11 18:51:08 +02:00
Yaojia Wang
ac40f48e05 feat(help): 新增应用内帮助页 /help(功能介绍 + 新手上手)
- 暖深色英雄 + 快速上手三步 + 三个核心理念 + 功能一览(写作/资料库/审阅/工具全局)
  + 顺手小技巧 + 常见问题,全部用设计系统组件与语义 token
- 内容对齐 PRODUCT_SPEC:立项→设定→大纲→写章→四审→验收、四审四维、多提供商网关、单一真相源
- 顶栏新增「帮助」入口(FileQuestion → /help)
2026-07-11 18:40:35 +02:00
Yaojia Wang
f4bea7f26d feat(ui): 弹层进出场过渡 + 首屏暖深色英雄(P2-6/P4-6)
- useMountTransition hook(延迟卸载播放退场,尊重 reduced-motion)+ vitest
- Drawer 淡入+滑入进出场;focus/scroll-lock 仍由 open 驱动、focus 键 shouldRender 防丢首焦
- Toast 入场 toast-in 动画(motion-safe)
- P4-6 首屏零作品用 callout 暖深色编辑部英雄(对标 cta-band-dark,不干扰工作库)
  新增 callout/on-callout/on-callout-soft 双主题成对 token
2026-07-11 14:04:14 +02:00
Yaojia Wang
355a2d11cd feat(ui): Checkbox/Radio 原子件 + 3 处原生控件采用(P2-5)
- checkboxClass/radioClass(accent-cinnabar + 统一焦点环)+ 同名测试
- Checkbox/Radio 原子件:无 label 只渲染受控原生 input 便于就地替换
- 采用:ChainStarter 链类型单选、GeneratorRunner 入库勾选、CharacterCardItem 角色勾选
2026-07-11 14:04:01 +02:00
Yaojia Wang
45025c36bf feat(ui): P4 无障碍与打磨收尾
- P4-1 对比度:muted-soft 提到 WCAG AA 小字(≥4.5:1)于两主题各面
- P4-3 ConflictCard 原文/建议加 Minus/Plus 图标 + sr-only 标签(不靠颜色)
- P4-4 动效:3 处 chevron transition-transform 加 motion-safe 门控
- P4-5 命令面板 ⌘K 键帽提示 + 键盘操作读数 + 空态引导 + mono 快捷键
- P2-8 SegmentedControl 升级 radiogroup(role=radio/aria-checked + 方向键 roving)
2026-07-11 08:12:36 +02:00
Yaojia Wang
5674158707 feat(ui): 工作台精修 + 拆分(P3-3..7)
- Workbench.tsx 937→467 行,抽出 AiToolbar/WorkbenchToolbar/MobileContextBar
- P3-3 AI 工具条分组留白 + 眉题 + 主 CTA 凸显
- P3-4 专注写作模式(useFocusMode hook+vitest,隐藏侧栏加宽正文)+ 左双栏面色区分
- P3-5 右栏本章参考小标题去衬线(font-sans) + 空态引导(无标题保 h2→h3 大纲)
- P3-6 底栏信息/操作/状态三区 + TOC 当前章高亮;TOC 状态点因无每章状态数据跳过
- P3-7 展示层流式跟随滚动(不触碰 SSE 逻辑,尊重 reduced-motion)
- AppShell 加可选 hideNav(加法式,默认 false)
2026-07-11 08:04:56 +02:00
Yaojia Wang
67e30a6863 feat(ui): P3 页面级应用(作品库卡片/空态/加载/设置/次级页)
- 作品库卡片重设计:书脊封面字块 + 衬线书名 + 一句话故事(缺失给占位) +
  元信息行(题材徽章/StatusDot 待审/mono 时间),Card tone=card 浮起消塌陷
- EmptyState 中性化 + inline/card/bare 变体 + eyebrow(向后兼容)
- loading 骨架化(Skeleton 拼作品库结构,motion-safe)
- 设置页拆分 ProvidersSettings 489→198 + 5 个子组件,凭据点用 StatusDot
- 次级页(向导/模板/codex/大纲)排版字阶与圆角 token 收敛
2026-07-11 07:36:32 +02:00
Yaojia Wang
d3e9ae972f feat(ui): 新增 PageContainer 统一页容器(P3-1)+ 作品库采用 2026-07-11 07:26:54 +02:00
Yaojia Wang
83ba47ab8f feat(ui): 共享组件补齐——Card 面色分层 + Button link/lg + 4 原子件
- Card 新增 tone(panel/card/soft)/flat/interactive;cardClass 兼容旧字符串签名
- Button 新增 link(coral 文字链) 变体与 lg 尺寸
- 新增原子件 Eyebrow(统一眉题) / StatusDot(状态点·读屏可见) /
  Skeleton(motion-safe 占位) / Meter(进度条·progressbar)
- PageHeader/SectionHeader 用 display/title 字阶 + Eyebrow 眉题
2026-07-11 07:24:08 +02:00
Yaojia Wang
fd6551b0ba feat(ui): 稳健跨 OS 衬线兜底栈 + 双主题 token 奇偶校验
- 决策:不自托管 CJK webfont(数 MB + 构建期外拉,违反 CSP 友好/首屏)
  改用跨 OS 系统衬线栈,保证中文标题可靠命中衬线,零 webfont 字节
- 新增 themeParity 测试:paper/night 两块 --color-* 必须成对,防漏配破面
2026-07-11 07:18:23 +02:00
Yaojia Wang
b9f9134748 feat(ui): 补齐分层设计 token 并迁移共享变体(对标 Claude DESIGN.md)
- 面色阶 surface-soft/card/strong + 文字 5 级 body-strong/body/muted-soft
- 边框 line-soft、coral 状态 cinnabar-active/disabled(按压变深 + 奶油化禁用)
- 圆角刻度 xs..xl/full:按钮 rounded-md、卡片 rounded-lg、徽章 pill
- 排版档 display/title/caption/eyebrow/prose(衬线负字距)
- 动效 token duration-fast/base + ease-standard,transitionUi 常量统一过渡
- 焦点环统一 cinnabar/35;双主题成对定义
2026-07-11 07:16:00 +02:00
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
Yaojia Wang
72d7111581 fix(devops): 生产 compose 收紧——api/web 仅绑 127.0.0.1(nginx 唯一入口,basic auth 不可绕)、pg 不发布宿主端口、web 构建默认走 npmmirror 2026-07-07 14:16:52 +02:00
Yaojia Wang
03895cc0d4 fix(devops): web Dockerfile 补 mkdir public,修复无 public 目录时容器构建失败 2026-07-07 13:59:21 +02:00
Yaojia Wang
5464539a91 fix(devops): 修复容器化部署面缺口,docker compose 可干净构建
消除 DEPLOYMENT.md §1.5 列出的 6 个「照抄即部署」缺口(仅动部署面文件,不改应用源码):

1. apps/api/Dockerfile 补 `COPY alembic.ini ./`,容器内可直接 alembic upgrade head。
2. base compose api 注入 CREDENTIAL_ENC_KEY(${VAR:-dev 占位},自动读根 .env 覆盖)+
   APP_ENV/LOG_JSON/CORS_ORIGINS,无 .env 也能起,不再因缺 Fernet key 启动失败。
3. base compose web 设 API_BASE_INTERNAL=http://api:8000,RSC 直连 api 容器。
4. web Dockerfile 加 ARG/ENV NEXT_PUBLIC_API_BASE(默认 localhost:8000),compose
   web.build.args 传入,构建期可注入公网 API 基址。
5. api/web Dockerfile 内置 HEALTHCHECK(api 用 urllib 打 /health、web 用 node 打 /)。
6. 新增 docker-compose.prod.yml 覆盖层做生产加固:pg 强口令 ${POSTGRES_PASSWORD}、
   pg 仅绑 127.0.0.1、APP_ENV=prod、LOG_JSON=true、密钥/连接串/CORS/公网 API 全从
   env 且必填(:? 缺失拒起)、restart: unless-stopped。base 保持 dev 便利不破坏。

附带修复:web Dockerfile 在 install 前补 COPY pnpm-workspace.yaml,使容器内 install
看到 minimumReleaseAgeExclude/onlyBuiltDependencies,避免误拒 @xyflow 等近期发布依赖;
新增 .dockerignore(根 + apps/web)排除宿主 node_modules/.next/.venv 等,避免平台原生
二进制经 COPY . . 覆盖容器版导致 next build 失败,并加速构建上下文、保证可复现。

验证:docker compose config(base)与 prod 合并 config 均通过;docker compose build
两镜像均成功(api 378MB / web 296MB);自定义 NEXT_PUBLIC_API_BASE 已确认内联进前端
bundle。DEPLOYMENT.md §1.5 回写为「已修复」并补 §2.6 生产 env 清单与正确部署命令。
2026-07-07 05:01:44 +02:00
Yaojia Wang
6b08fbe123 docs: 新增 README + 服务器部署指南(含 compose 部署面缺口与安全红线) 2026-07-06 21:57:57 +02:00
Yaojia Wang
51bfb6e320 test(qa): craft 成稿质量前后对照评估回路(灵感 F2)
固定 3 题材金标准输入(仙侠/都市/悬疑,各含最小 spec+world+outline 及开篇章),
经真实 assemble()+build_write_request() 组装 craft-on / craft-off 两版写章请求:

- dry 模式(默认,零成本、免真实 LLM,可进 CI):断言 craft 教条块、genre 片段、
  开篇黄金三章 marker 确实按 fixture 注入正确内容,且 craft-off 基线不含这三者;
  并把两版将发送给模型的 prompt 并排 dump 供人眼看。这些断言即 tests/test_craft_eval.py,
  是 craft/审查注入路径的回归门禁。
- --live 模式(真实付费 LLM,绝不进 CI):经 gateway 各生成一章 + analyst 五维裁判,
  凭据读 DB、路由走 tier_routing,与生产写章同一网关路径。

入口 `uv run python -m tests.craft_eval [--live]`;用法/成本/门禁说明见 README.md。
2026-07-06 17:57:31 +02:00
Yaojia Wang
3d2a8b8cee feat(web): 角色关系图谱(灵感⑥缩减版/D4)
设定库人物 tab 新增 React Flow 关系图谱,仅用现有角色读端点数据
(CharacterRelationView={name,kind,note}):边按 name-join、kind 作中文
标签、节点按 role 上色,无 closeness/阵营(数据不存在,不硬造)。

- lib/characters/graphLayout.ts:纯逻辑(role→分组配色关键词映射 / 节点
  去空重名 / kind→标签+DEFAULT_RELATION_LABEL / 悬空边跳过并计数 / 自指
  跳过 / 互指无向去重 / @dagrejs/dagre 一次性确定性初始坐标、中心→左上、
  不可变更新)+22 单测。
- components/characters/RelationshipGraph.tsx:'use client',经 CodexPage
  dynamic(ssr:false) 挂载;显式容器高度 + import RF CSS + prefers-reduced-
  motion 关 fitView 动画 + 纸感 CSS 变量配色 + 悬空计数提示。
- 依赖:@xyflow/react@^12.11.2 + @dagrejs/dagre@^3.0.0。

无后端契约变更/迁移/gen:api。门禁:lint/typecheck 干净、vitest 565、
coverage lib 95.4%(graphLayout 97.6%)、build OK。
2026-07-06 17:38:58 +02:00
Yaojia Wang
7babb4854b feat(web): 角色群像按定位配比生成(role_mix 缩减版)
- 契约: CharacterGenerateRequest 加 role_mix{定位:数量},model_validator 归一
  count=各值之和(每项≥1、总和≤12),缺省退回单一 role+count
- @llm: build_character_gen_context/run_character_gen 加 role_mix 参并注入配比块
  (确定性、无时间戳,守 assemble 纯函数);router 穿参
- character-gen.md 加"按配比严格分配 role"纪律 + regen prompt_hashes 金标准
- 前端: sanitizeRoleMix/roleMixTotal + buildCharacterGenerateRequest 第4参 +
  useCharacterGen 穿参 + RoleMixEditor + CharacterGenerator 单一/配比双模
2026-07-06 17:24:16 +02:00
Yaojia Wang
72b3c81146 feat(review): 人物塑造 advisory 审(第五审,仅建议不阻断验收)
写→审链新增第五审「人物塑造」(灵感③/D2):advisory 单维——仅建议、不阻断
验收(不进 conflicts、不碰 assert_conflicts_resolved、不入 REVIEW_RESERVED_NAMES);
只做人物塑造、不做氛围。

- SPEC characterization_spec(analyst,reads=("characters",),writes=())+
  prompts/characterization.md(只读、显式忽略文本长度与辞藻华丽度、引用逐字片段+置信度)
- schema CharacterizationReview{issues[...]}(全字段默认值守解析韧性)+ 注册 catalog
- 计数三处 22→23 + regen prompt_hashes.json(仅加一条)
- DB chapter_reviews 加 characterization JSONB nullable 列(迁移 f6a7b8c9d0e1)
- 接线:graph REVIEW_SPECS 追加 · chain ReviewRecordRepo · collect(extract/record) ·
  review_repo(Protocol/Sql/_to_view/ReviewView) · sse(event/factory/section) · normalize 自动纳入
- 契约 ReviewHistoryItem 加 characterization(gen:api 已重生成)
- 前端 sse/history/useReviewStream + CharacterizationPanel(只展示无裁决 UI、置信度降序+低置信折叠)+ ReviewReport 挂面板
- 测试 test_characterization_does_not_block_accept + 计数/golden + collect/normalize + 前端 reducer/normalize/parse

守不变量 #2/#3/#9;依赖 ⑧(motive/appearance 作客观锚点)。
2026-07-06 17:08:38 +02:00
Yaojia Wang
2a7a865e98 feat(core): continuity 冲突裁决反哺——上一已验收章保留裁决入本章 volatile 前瞻约束
灵感②/D3 窄版 v1:assemble 读上一已验收章的 continuity 冲突作者裁决
(decisions.items 按 conflict_index join 回 conflicts 取正文),仅保留
verdict∈{accept,manual}、去重、按 conflict_index 排序、有界 top-3,
用确定性字符串模板改写成前瞻正向约束,注入本章 volatile「上一章冲突提醒」。

- 绝不调 LLM、绝不反哺 advisory(style/pace/characterization 无作者裁决 oracle)——守不变量 #3/#4。
- 只入 volatile、永不进 stable_core 缓存前缀(依赖上一章裁决、每章易变)——守 #9。
- assemble 仍是确定性纯函数、字节稳定、可缓存——守 #6/#7。
- decisions 非空=已验收(只在验收事务写入);未验收上一章不反哺——守风险#5。
- MemoryRepos 末位加 review: ReviewRepo | None = None(默认 None→优雅降级不反哺),
  docstring 8→9;sql_memory_repos 工厂补 review=SqlReviewRepo(既有构造点靠默认无需感知)——守风险#6。
2026-07-06 16:38:06 +02:00
Yaojia Wang
02d19019f6 feat(web): AI 立项方案生成+预填(种子门控 + 按字段接受)
向导阶段(project 未建)一键推演结构化书级蓝图预填立项向导。

- 新 SPEC project_plan_spec(analyst 档,纯预览 reads/writes=())+ schema
  ProjectPlanResult(书名候选 + 时空背景/叙事结构/故事核心/结局设计/基调,
  全字段默认值守解析韧性)+ 注册 SCHEMA_CATALOG;计数三处 21→22 + regen golden。
- 端点 POST /skills/project-plan/generate(不带 project 前缀,绕开 toolbox 404):
  请求体序列化向导草稿为 project_context,复用 build_brief_context + run_generator;
  种子门控(缺 genre/logline→422);请求 schema 加 max_length 上界。
- run_generator/_build_request 的 project_id 放宽 uuid.UUID|None(向导无 project)。
- 前端 wizard.ts 加 mapPlanResultToWizardForm(错配字段折进 premise 不静默丢)+
  applyPlanPatch(仅回填空字段、保护已编辑)+ useProjectPlan hook + PlanAssistant。
2026-07-06 16:26:08 +02:00
Yaojia Wang
821ace5989 feat(web): 立项新增 基调/结局/视角 三字段(书级常量入 stable_core 缓存前缀)
灵感计划 §3④ + §4 D 枚举决策:
- projects 表加 tone/ending_type/narrative_pov(Text nullable、无 CHECK,同 genre/structure;
  迁移 e5f6a7b8c9d0 nullable 免 backfill)。
- ProjectCreateRequest/ProjectResponse + domain ProjectCreate/ProjectView 全链路透传(snake_case)。
- 完整 stable_core 链路:ProjectSpecView + SqlProjectSpecRepo.spec + _build_spec_section 渲染进
  「作品蓝本」块——书级常量随书稳定,字节稳定守缓存前缀不变量 #9,绝不入 volatile。
- 前端 wizard.ts 三字段 + 预设数组 TONES/ENDING_TYPES/NARRATIVE_POVS + ProjectWizard 控件与确认页回显。
- 契约变更已 pnpm gen:api + memory/contracts.md 登记。
2026-07-06 15:59:49 +02:00
Yaojia Wang
491d9342ef merge: appearance/motive 全栈穿线 (P1 流B) 2026-07-06 14:38:17 +02:00
Yaojia Wang
acde715515 feat(character): appearance/motive 全栈穿线 + motive/traits 口径重切
打通 appearance/motive 从生成→schema→契约→落库→前端整条穿线
(DB 列早在初始迁移、此前一直 NULL,无新迁移),并原子重切人物
内驱口径。

- @llm: CharacterCard/character-gen.md 加 appearance/motive(给默认值守
  解析韧性),重切——动机唯一落 motive、traits 只留核心/表层/阴影三层、
  arc 引用 motive;regen prompt_hashes.json 金标准。
- @backend: CharacterCardView + CharacterWriteRepo/SqlCharacterWriteRepo
  (create + update backfill 分支)+ CharacterWriteFields + 路由双向形变
  与 _existing_characters 带上两字段。
- @frontend: pnpm gen:api 重生成 TS 客户端;CharacterCardItem 展示/编辑
  两字段。
- 测试: schema 默认值/解析、动机不双写重切回归、card_to_view 双向、
  ingest 透传、list 暴露、SqlCharacterWriteRepo create/backfill 真 pg 集成。
2026-07-06 14:36:11 +02:00
Yaojia Wang
452bd56d95 feat(agents,core,config): 写章 craft 教条块 + genre-aware 片段 + 开篇黄金三章特判
- 新增 write_craft.md 品类无关写章 craft 内核(网文语域/show-don't-tell/对话 voice/
  反注水/结构性文字质感,灵活原则非穷举清单)+ load_craft_doctrine()(import 期读盘,
  不入 SPECS/金标准)
- build_write_request 把 craft 教条作为最前的 cache=True system 块注入(craft→stable_core,
  守缓存前缀 #9,tier=writer 不写 model)
- 新增 packages/config GENRE_CRAFT 题材片段表,assemble 按 project.genre 注入 stable_core;
  ProjectSpecView + SqlProjectSpecRepo 透传 genre
- 开篇前 3 章在 volatile 注入黄金三章提示(含章号→每章易变,不进缓存前缀)
- 修正 toolbox_registry 注释:11 条 → 15 条(legacy 3 + 新 8 + Scope B 4)
2026-07-06 14:29:43 +02:00
Yaojia Wang
6854dac98f feat(web): 命令面板 combobox+可见入口 + sticky 导航壳 + 焦点环统一 + 抽屉锁滚动/关闭按钮 + 主题首帧修复 + 设置徽标/datalist 2026-06-30 08:56:23 +02:00
Yaojia Wang
164e98887e fix(web): 链相位语义色/失败可重试 + 模板填入中文标签/删除可撤销/工具卡文案 + 模板库 PageHeader 2026-06-30 08:56:23 +02:00
Yaojia Wang
6bb3559e25 fix(web): 规则删除按操作隔离 busy(deletingId/adding) + 全空规则页折叠为单一引导 2026-06-30 08:56:23 +02:00
Yaojia Wang
9d67f237a2 feat(web): 生成器 ThinkingIndicator/空结果态/数量校验 + 文风页 PageHeader + Codex 分段控件 + RefineView role 修正 2026-06-30 08:56:23 +02:00
Yaojia Wang
68606b84dc fix(web): 大纲生成只替换目标卷+重排确认+窗口徽标用lucide + 伏笔看板中文化/单一登记入口/Field表单 2026-06-30 08:56:23 +02:00
Yaojia Wang
f0f925b195 fix(web): 审稿面板去重标题/去英文 slug + 伏笔待登记数实时递减 + 移动端决策栏 + 对比度修复 2026-06-30 08:56:23 +02:00
Yaojia Wang
69030eacc3 fix(web): 写作台流式/保存 live region + 图标按钮换 lucide 无障碍 + 三栏改 xl 断点 + 手稿字号统一 2026-06-30 08:56:23 +02:00
Yaojia Wang
e82aff7067 feat(web): 立项向导确认步/故事结构分段控件/书名必填提示 + 作品卡键盘焦点可见 + 作品库分段切换 2026-06-30 08:56:22 +02:00
Yaojia Wang
a22a16c9c4 feat(web): 前端共享基础(focusRing/proseBody/text-2xs + useBodyScrollLock + Toast撤销 + error/not-found/loading路由边界) 2026-06-30 08:35:52 +02:00
Yaojia Wang
69f3c2a29b fix(web): 技能页 bg-paper/删除虚构创作中徽标/设置页 try-finally/文风页移动端定高 grid 2026-06-29 16:52:43 +02:00
Yaojia Wang
d7fcb8a329 refactor(web): 采用 Button/Card 原语替换多处手搓样式 2026-06-29 16:52:43 +02:00
Yaojia Wang
47eb42f274 fix(web): 后端不可用统一用 BackendDownNotice 替代 404/手搓提示 2026-06-29 16:52:43 +02:00
Yaojia Wang
f8cabc92e6 feat(web): 世界观预览补「仅预览」说明与按卡复制 2026-06-29 16:52:43 +02:00
Yaojia Wang
ffe9132f7d fix(web): 大纲错误透传 FriendlyError, 不再向用户暴露内部 code 2026-06-29 16:52:43 +02:00
Yaojia Wang
4afbbe0fe7 fix(web): 注入面板保存失败不再清空已加载实体并支持重试 2026-06-29 16:52:43 +02:00
Yaojia Wang
ab3f9e17b1 fix(web): 审稿页支持切换章节并打通验收后审下一章 2026-06-29 16:52:43 +02:00
Yaojia Wang
a2849bf6b7 feat(web): Field 标签自动关联控件 + 全局壳无障碍(skip link/设置可访问名/抽屉焦点归还) 2026-06-29 16:52:43 +02:00
Yaojia Wang
dd80b6815d fix: address ui review follow-ups 2026-06-29 12:06:04 +02:00
Yaojia Wang
90a66437d7 feat: improve app ui and project metadata 2026-06-28 07:31:20 +02:00
Yaojia Wang
3bd464d400 docs: 登记 QA-CR 全项目 code review 整改批(21 项任务表) 2026-06-27 06:35:55 +02:00
Yaojia Wang
a6f5d085e5 test: 接入 jsdom 测试栈, 把 15 个 React hooks 纳入 80% 覆盖率门禁
新增 jsdom + @testing-library/react, 为 lib/** 全部 use*.ts hooks 写 renderHook 单测:
生成/CRUD(world/character/generator/outline/accept/foreshadow/rules)、
SSE流(draftStream/reviewStream)、定时轮询(autosave/jobPoll/kimiOauth)、
文风与注入(refine/styleLearn/injection)。覆盖率 mock api 客户端+Toast, 不打真实 LLM。
vitest.config.ts 去掉 use*.ts 排除; 前端覆盖率 63%→95%。CLAUDE.md 同步说明。
2026-06-27 06:21:42 +02:00
Yaojia Wang
a02c6b6e4f test: 接入覆盖率工具并在 CI 强制 ≥80% 门禁
后端: 加 pytest-cov + [tool.coverage] 配置, pytest --cov-fail-under=80 (基线~88%)。
前端: 加 @vitest/coverage-v8, vitest.config.ts 设 80% 阈值, 新增 test:coverage 脚本 (基线~93%)。
前端覆盖范围限定 lib/** 纯逻辑层; hooks(use*.ts)待 jsdom 测试栈接入后纳入。
CI backend/frontend job 改跑覆盖率门禁命令。CLAUDE.md 同步 DoD 与 Toolchain。
2026-06-26 21:11:57 +02:00
Yaojia Wang
e9477d84d8 docs: 增加每次编码的硬门禁(Definition of Done)与提交纪律
明确 TDD/≥80%覆盖/三类自动化测试必跑/全门禁绿/合规/每次commit/禁署名尾注;
如实标注覆盖率工具尚未接入并列出补齐待办。
2026-06-26 21:03:35 +02:00
Yaojia Wang
01257b8952 merge: 实施 4 组设计型 QA 项(规则删除/codex 角色/大纲卷过滤/文风回炉锚点) 2026-06-25 12:53:03 +02:00
Yaojia Wang
bf39f50b2f fix(qa): 实施 4 组设计型 QA 项——规则删除/codex 角色/大纲卷过滤/文风回炉锚点
#5 规则 DELETE + id:暴露 RuleView.id(PK);新增 DELETE /projects/{id}/rules/{rule_id}
  (项目/规则不存在→404,成功→204,按 (id,project_id) 限定);rule_repo 加
  list_for_project/delete;RulesPage 每条加删除(乐观删+回滚+toast)。assemble 侧
  RuleView(缓存前缀)不动,列表另立 RuleListItemView。
#7 codex 角色 relations:写侧本已持久化、读端点 _existing_characters 硬编码 []。
  加 _relations_from_jsonb 解析 {name,kind,note},CodexPage 渲染关系 chip。
#8 角色入库幂等:SqlCharacterWriteRepo.create 改 (project_id,name) app 层 upsert——
  重复入库改更新而非插入;不加 UNIQUE/迁移(线上已有重复行会让约束迁移失败)。
#1 大纲卷过滤:GET /outline 支持可选 ?volume(无参=全部,向后兼容);OutlineEditor
  加「查看:全部/卷N」筛选,与生成目标卷解耦。
H3/#9 文风回炉锚点:StyleDriftSegment 加 text(逐字命中段),style.md 指示审稿输出;
  前端按内容锚点定位回炉目标(idx 仅排序),命中失败 → 提示「无法定位该段」而非
  静默 no-op。style golden fixture 已重生成。

契约变更已 pnpm gen:api(RuleView.id / DELETE rules / outline ?volume)。无迁移
(alembic 无漂移)。门禁绿:ruff/mypy(210)/alembic/pytest 760 · 前端 tsc/lint/vitest 329。
2026-06-25 12:53:03 +02:00
Yaojia Wang
e60eff7aa1 merge: 前端错误文案 friendlyFromApiError(QA #2/#11,去通用 toast) 2026-06-24 18:01:15 +02:00
Yaojia Wang
9a623688e8 fix(qa): 前端错误文案——按 API 信封 surface 真实错因,去掉通用 toast
新增 friendlyFromApiError(apiError):兼容业务信封 {error:{code,message}}(ARCH §7.1)
与 FastAPI 422 {detail:[...]},422 映射为友好 VALIDATION 文案(不泄露技术细节)。
接入三处此前吞掉错因、永显通用文案的调用点:
- useOutline(QA #2):原 env.error.code 漏读 422 detail,永显「大纲生成失败」。
- ChainPage / ChainAdjudication(QA #11):原 friendlyError(undefined) 永显通用 toast,
  现 surface 真实 code(如 LLM_UNAVAILABLE→去设置 provider / CONFLICT→提示裁决)。
+ messages.test.ts 加 3 例(业务码带动作 / 422→VALIDATION / 未知形兜底)。
门禁绿:vitest / tsc / eslint 干净。
2026-06-24 18:01:15 +02:00
Yaojia Wang
f4f01aeca9 merge: 修 3 个 QA MEDIUM(规则 GET 404 / 空白 content 422 / 未知 tier 422) 2026-06-24 17:51:03 +02:00
Yaojia Wang
3868f80502 fix(qa): 修 3 个 QA MEDIUM——规则/档位路由校验收紧
- 规则 GET 不存在 project → 404(原返误导性空 200,掩盖坏 id):list_rules 加
  项目存在校验(仿 C1/H2)。
- 规则 content 纯空白 → 422(原 strip 前校验 min_length,空白行入库成垃圾):
  改 StringConstraints(strip_whitespace=True, min_length=1)。
- 档位路由 tier 限定 writer/analyst/light(Tier Literal)→ 未知档位 422
  (原接受任意字符串)。

回归测试:test_generation(GET rules 404)/ test_rules(空白 content 422)/
test_settings_providers(未知 tier 422)。门禁绿:ruff/format/mypy(210)/pytest。
2026-06-24 17:51:02 +02:00
Yaojia Wang
7a40c7fbb5 merge: 修 QA C1/H1/H2(写章/规则项目校验 404 + 立项向导字段覆盖) 2026-06-24 17:17:35 +02:00
Yaojia Wang
2fe3bedfba fix(qa): 修 QA C1/H1/H2——写章/规则缺项目校验 + 立项向导字段覆盖
C1 (CRITICAL) stream_draft:对不存在 project 流式写章先 fail-fast 404,
  否则非法 id 静默烧一次付费/限流 LLM 调用并返 200。在触网关前查 project_repo.get。
H2 (HIGH) create_rule:给不存在 project 加规则原 FK 违例逃逸成 500 → 改为入库前
  校验项目存在返 404(仿 chain/_require_project)。
H1 (HIGH) ProjectWizard:第3步「立意」与第4步「主角/金手指」原共用 form.premise
  互相覆盖丢数据 → 新增独立 form.protagonist,toCreateRequest 合并两段进 premise
  (M1 projects 表仍只有 premise,不编造 API)。

回归测试:
- test_projects.py:stream_draft 不存在 project → 404 且网关零调用;已有 draft
  用例改 seed 真项目。
- test_rules.py:create_rule 不存在 project → 404 不写库;已有用例 seed 真项目。
- wizard.test.ts:premise+protagonist 合并不互相覆盖(2 例)。
门禁绿:ruff/format clean · mypy 210 · pytest 749 · 前端 tsc/lint/vitest 干净。
2026-06-24 17:17:35 +02:00
Yaojia Wang
a4ef250fc9 fix(review): 验收冲突显示与闸门对齐 — 验收报 CONFLICT_UNRESOLVED 时回拉最新审稿并回灌冲突;未审稿不再误显「可直接验收」 2026-06-24 16:17:54 +02:00
Yaojia Wang
d0c301349c merge: fix 多章链 checkpointer 连接串剥离驱动后缀(ProgrammingError 修复 + 回归测试) 2026-06-24 13:03:58 +02:00
Yaojia Wang
b5002a9864 fix(chain): checkpointer 连接串剥离 SQLAlchemy 驱动后缀
多章链 job 一发起即 failed(exc_type=ProgrammingError)。根因:runtime
checkpointer 工厂把 settings 的 sync URL `postgresql+psycopg://…` 原样喂给
`AsyncPostgresSaver.from_conn_string`,psycopg 不识别 `+psycopg` 方言段 →
`ProgrammingError: missing "=" …`,链在打开 saver 时即崩。

E2E 用 MemorySaver 覆盖 get_checkpointer_factory,从不走真 conn string,故此
路径无回归保护、bug 未被测出(迁移 d3e4f5a6b7c8 里 `_psycopg_conn_string` 已
正确剥离,runtime 缝漏了同款处理)。

修复:chain_deps 加 `_libpq_conn_string()`,剥离 `+psycopg`/`+asyncpg` 还原为
标准 libpq 串后再交 from_conn_string。+ 单测 test_chain_deps.py(3 参数化用例)。
已直连真 pg 复验:saver.aget OK,ProgrammingError 消除。
2026-06-24 12:40:40 +02:00
425 changed files with 35342 additions and 2778 deletions

19
.dockerignore Normal file
View File

@@ -0,0 +1,19 @@
# api 镜像构建上下文context=仓库根。api Dockerfile 仅选择性 COPY
# pyproject.toml/uv.lock/packages/apps/api/alembic.ini但整个仓库会作为构建上下文发给
# daemon——排除大体积/无关目录,加速上传并避免宿主产物泄漏进镜像。
.git
.venv
**/node_modules
**/.next
**/__pycache__
**/*.pyc
.pytest_cache
.mypy_cache
.ruff_cache
htmlcov
.coverage
**/*.tsbuildinfo
.env
.env.local
.DS_Store
example_skills

View File

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

View File

@@ -34,8 +34,8 @@ jobs:
run: |
uv run alembic upgrade head
uv run alembic check
- name: pytest
run: uv run pytest -q
- name: pytest + coverage gate (>=80%)
run: uv run pytest -q --cov --cov-report=term-missing --cov-fail-under=80
# 打包冒烟:构建 ww-agents wheel → 装进裸 venv → import ww_agents 并断言 SPECS。
# 证明 prompts/*.md 随 wheel 分发(源码树 pytest 测不出此漏带——见
@@ -85,6 +85,6 @@ jobs:
- name: typecheck
working-directory: apps/web
run: pnpm typecheck
- name: test
- name: test + coverage gate (>=80%)
working-directory: apps/web
run: pnpm test
run: pnpm test:coverage

2
.gitignore vendored
View File

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

View File

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

View File

@@ -89,16 +89,48 @@ Aligns `ARCHITECTURE.md §9.3`. This is how every failure gets diagnosed.
- **Errors** logged with full context at the boundary, mapped to the error envelope (`ARCHITECTURE §7.1` is the source of truth for envelope shape + error codes). Frontend logs client errors with the same `request_id`.
- **Redact**: never log API keys; truncate/hash full prompts and manuscript text (log lengths/hashes, not the novel).
### Definition of Done (每次编码完成的硬门禁) — 不通过不算完成
每一次代码改动(无论多小)在声明「完成」前,**必须**按顺序全部通过以下门禁。任一项失败就回去修,不得跳过、不得只跑部分、不得仅靠肉眼判断。
1. **TDD 已遵守** — 先写失败测试RED→ 实现到通过GREEN→ 重构REFACTOR。新增/修改的逻辑都有对应测试;改 bug 先写能复现的失败测试。见上文 §TDD。
2. **测试覆盖率 ≥ 80%(已工具化 + CI 强制)** — 改动涉及的模块覆盖率不得低于 80%;新代码不许拉低整体覆盖率。跑命令核对真实数字,不要估:
- 后端:`uv run pytest -q --cov --cov-report=term-missing --cov-fail-under=80`(低于 80% 直接非零退出。当前基线 ~88%)。配置在根 `pyproject.toml``[tool.coverage.*]`
- 前端:`cd apps/web && pnpm test:coverage``vitest.config.ts` 设 lines/functions/branches/statements 阈值 80。当前基线 ~95%)。
- **前端覆盖率范围 = `lib/**`(纯逻辑 + React hooks**hooks(`use*.ts`)用 jsdom + `@testing-library/react``renderHook` 单测(测试文件首行 `// @vitest-environment jsdom`mock `@/lib/api/client` + `@/components/Toast`,参见 `lib/generation/useWorldGen.test.ts`)。组件(.tsx)仍由 E2E/人工验收覆盖,不在 vitest 范围(写新 hook 时必须同步补 `renderHook` 测试)。
3. **跑完三类自动化测试**(改动触及的那侧必跑,跨栈改动两侧都跑):
- **后端 Unit + Integration**`uv run pytest -q`(需要 pg 的集成测试先 `docker compose up -d pg`)。
- **前端自动化测试**`cd apps/web && pnpm test`vitest
- **E2E**(动到 write→review→accept 主链路时):跑相关 E2E 流程mock gateway
- LLM 一律 mock**绝不在测试/CI 命中真实 LLAPI**(见上文 §TDD
4. **全部门禁绿**(与 §Toolchain、CI 一致):
- 后端:`uv run ruff check .` · `uv run ruff format .` · `uv run mypy packages apps` · `uv run pytest -q` · 动过模型时 `uv run alembic check` 无漂移。
- 前端:`cd apps/web && pnpm lint` · `pnpm typecheck` · `pnpm test` · `pnpm build`;动过后端 schema 先 `pnpm gen:api` 再校验。
5. **符合架构与编程规范** — 不违反上文 §Architectural invariants、§Locked tech stack、§Python/§Frontend/§LangGraph 实践以及全局编码风格不可变更新、KISS/DRY/YAGNI、文件 <800 函数 <50 显式错误处理边界校验无硬编码密钥)。安全敏感改动auth/输入处理/DB 查询/外部 API/加密 §code-review 触发安全自查
6. **提交(每次完成都 commit** 见下文 §Commit discipline
7. **回写状态/文档** §Per-task workflow 更新 `PROGRESS.md`若实现暴露 spec 缺口回写对应 spec §Conventions)。
> 报告要诚实:测试失败就如实说明并贴输出;跳过了哪一步要讲明;只有真正跑过且全绿,才说「完成」。
### Commit discipline (提交纪律) — 每次完成即提交
- **每次完成一个可工作的改动就 commit**小步提交一次一个聚焦改动不要把多个无关改动堆进一个提交
- **提交前** Definition of Done 的门禁15必须已全绿——不提交跑不过测试或 lint/类型不干净的代码
- **提交信息格式**遵循 Conventional Commits —— `<type>: <description>`type feat/fix/refactor/docs/test/chore/perf/ci中文描述与本仓库现有提交风格一致需要时附 body 说明为什么」。
- **提交信息禁止任何署名/归属尾注** **不得包含 `Co-Authored-By:` 或任何 `Generated with` / AI 署名行**全局已在 `~/.claude/settings.json` 关闭 attribution此处再次明确覆盖任何默认加尾注的行为)。
- **分支**不在 `main` 上直接提交 §Git Workflow 在特性分支工作`develop` 为当前集成分支
- **push / PR 仅在用户明确要求时**进行PR body 同样不加 AI 署名
## Toolchain (Phase 0 已落地)
工具**uv**(Python workspace4 members: `apps/api` + `packages/{shared,config,db}`) + **pnpm**(前端`apps/web`)。Python 3.12+Node 22
- **环境变量**`uv` 装到 `~/.local/bin`命令前 `export PATH="$HOME/.local/bin:$PATH"`
- **依赖安装**`uv sync`后端仓库根`pnpm install`前端`cd apps/web`)。
- **本地起服务**`docker compose up`pg + api + web。仅起库`docker compose up -d pg`。裸跑 API`uv run uvicorn ww_api.main:app --reload`
- **本地起服务宿主端口 +1000 防冲突web 4000 / api 9000 / pg 6432容器内仍 3000/8000/5432**`docker compose up`pg + api + web端口已 +1000)。仅起库`docker compose up -d pg`宿主 6432)。裸跑API `uv run uvicorn ww_api.main:app --port 9000 --reload`读根 `.env`pg 6432 / CORS 放行 4000前端 `cd apps/web && pnpm dev``next dev -p 4000` `apps/web/.env.local``NEXT_PUBLIC_API_BASE=http://localhost:9000`)。`.env`/`.env.local` 为本机文件gitignored)。
- **迁移**`uv run alembic upgrade head`改模型后 `uv run alembic revision --autogenerate -m "..."`漂移校验 `uv run alembic check` pg 在跑)。
- **后端门禁**`uv run ruff check .` · `uv run ruff format .` · `uv run mypy packages apps` · `uv run pytest -q`。单测:`uv run pytest path::test -q`
- **前端门禁**`cd apps/web``pnpm lint` · `pnpm typecheck` · `pnpm test`vitest· `pnpm build`
- **后端门禁**`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/**`)。
- **重生成 TS 客户端**改后端 schema `cd apps/web && pnpm gen:api`离线`uv run python -m ww_api.export_openapi` `openapi-typescript` 生成 `lib/api/schema.d.ts`)。
- **pnpm 配置** `apps/web/pnpm-workspace.yaml`pnpm 11 不再读 package.json/.npmrc)—— `onlyBuiltDependencies` 白名单 + `verifyDepsBeforeRun: false` `memory/gotchas.md`)。
- **CI**`.github/workflows/ci.yml`backend job pg service ruff/mypy/alembic/pytestfrontend job gen:api/lint/typecheck/test)。

457
DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,457 @@
# 部署指南DEPLOYMENT
> 本文是把「AI 辅助中文网文写作工作流」部署到服务器的实操指南。
> 所有命令/端口/服务名均以仓库当前 `docker-compose.yml`、两份 `Dockerfile`、
> `.env.example`、`packages/config/ww_config/settings.py`、`apps/api/ww_api/main.py`
> 为准。**遇到与你手上 compose 不一致处,一律以实际 `docker-compose.yml` 为准。**
>
> ⚠️ **重要前提:本产品当前是「单用户原型」,没有任何认证 / 多租户**`users`/`owner_id`
> 是桩)。这直接决定了安全部署的红线——见 [§6 安全红线](#6-安全红线单用户原型的硬约束)。
> **不要在没有网络层保护的情况下把它裸暴露到公网。**
---
## 1. 架构与前置条件
### 1.1 三个组件
部署单元由三个容器组成(服务名即 `docker-compose.yml` 中的 key
| 服务名 | 角色 | 镜像来源 | 容器端口 | compose 发布端口 | 健康检查 |
|---|---|---|---|---|---|
| `pg` | Postgres 16 数据库(唯一真源) | `postgres:16-alpine`(拉取) | 5432 | `5432:5432` | ✅ `pg_isready -U writer`interval 5s / timeout 3s / retries 10 |
| `api` | FastAPI 后端async + SSE + LangGraph 编排) | 由 `apps/api/Dockerfile` 构建,构建上下文 = **仓库根 `.`** | 8000 | `8000:8000` | ✅ Dockerfile `HEALTHCHECK``python urllib``/health`interval 30s / timeout 5s / start-period 20s / retries 3 |
| `web` | Next.js 前端standalone 产物) | 由 `apps/web/Dockerfile` 构建,构建上下文 = **`./apps/web`** | 3000 | `3000:3000` | ✅ Dockerfile `HEALTHCHECK``node http``/`interval 30s / timeout 5s / start-period 20s / retries 3 |
依赖关系compose `depends_on`
- `api` 依赖 `pg`,且**等 pg 健康**后才起(`condition: service_healthy`)。
- `web` 依赖 `api`,但**只是启动顺序**(无 `condition`——web 可能先于 api 就绪,属可容忍的运行期竞态。
数据持久化:命名卷 `pgdata` 挂到 `pg``/var/lib/postgresql/data`。**备份/迁移数据 = 备份这个卷**(见 §7
### 1.2 镜像怎么来
两个应用镜像都从源码 `Dockerfile` **本地构建**(仓库未发布预构建镜像):
- **`apps/api/Dockerfile`**`python:3.12-slim` + 从 `ghcr.io/astral-sh/uv` 拷入 `uv``COPY pyproject.toml uv.lock* / packages / apps/api``uv sync --no-dev --frozen`(失败回退非 frozen`CMD uv run uvicorn ww_api.main:app --host 0.0.0.0 --port 8000`
- **`apps/web/Dockerfile`**多阶段。build 阶段 `node:22-slim` + corepack(pnpm)`pnpm install --frozen-lockfile``pnpm build`Next `output: "standalone"`,见 `apps/web/next.config.mjs`run 阶段只拷 `.next/standalone` + `.next/static` + `public``CMD node server.js`
> web 镜像在构建期消费**已提交的** OpenAPI TS 客户端 `apps/web/lib/api/schema.d.ts`(该文件在 git 里,仅 `openapi.json` 被 gitignore。因此 web 构建**不需要**联网跑 `pnpm gen:api`。**但**:后端 schema 改动后,必须先 `cd apps/web && pnpm gen:api` 重生成并提交 `schema.d.ts`,再构建 web 镜像,否则前后端契约漂移。
### 1.3 需要的环境变量(逐项)
变量来源 = `packages/config/ww_config/settings.py`(后端)+ `apps/web/lib/api/config.ts`(前端)。
**后端api 容器):**
| 变量 | 是否必填 | 默认值 | 说明 |
|---|---|---|---|
| `DATABASE_URL` | 建议显式设 | `postgresql+asyncpg://writer:writer@localhost:5432/writer` | **异步**连接串asyncpg。运行时 + `alembic upgrade head` 都读它(`packages/db/migrations/env.py` 用它建 async engine 跑迁移)。容器内主机名用 compose 服务名 `pg`。 |
| `DATABASE_URL_SYNC` | 建议显式设 | `postgresql+psycopg://writer:writer@localhost:5432/writer` | **同步**连接串psycopg`.env.example` 与 compose 均定义;供同步工具链使用。 |
| `APP_ENV` | 否 | `dev` | 环境名。生产设 `prod`。 |
| `LOG_JSON` | 否 | `false` | 结构化日志是否输出 JSON。**生产设 `true`**structlog JSON便于采集。 |
| `CREDENTIAL_ENC_KEY` | **✅ 必填** | 空(`SecretStr("")` | provider 凭据对称加密密钥Fernet, urlsafe-base64 32B。**api 启动即校验**`main._lifespan``_fernet(...)`):缺失/非法 → **进程直接启动失败**,不带病接流量。生产**必须**用新密钥,见 §2.3。 |
| `CORS_ORIGINS` | 生产强烈建议 | `["http://localhost:3000","http://127.0.0.1:3000"]` | 浏览器跨源白名单,逗号分隔覆盖。**启动断言不含通配 `*`**(与 `allow_credentials=True` 不兼容,`main.create_app``assert "*" not in cors_origins`)。生产填前端真实来源,如 `https://writer.example.com`。 |
**前端web 容器):**
| 变量 | 是否必填 | 默认值(`config.ts` 回退) | 说明 |
|---|---|---|---|
| `NEXT_PUBLIC_API_BASE` | 生产建议 | `http://localhost:8000` | **浏览器端**访问 API 的基址。⚠️ Next.js 的 `NEXT_PUBLIC_*` 在**构建期内联**进客户端 bundle——只在运行期设它**不会改**已构建产物。web `Dockerfile` 现声明 `ARG NEXT_PUBLIC_API_BASE`(默认 `http://localhost:8000`base/prod compose 的 `web.build.args` 均传入它;生产在**构建期**用 `docker compose build` 注入前端可达的公网 API 地址(通常反代后的 `https://writer.example.com` 之类)。 |
| `API_BASE_INTERNAL` | 容器编排建议 | 回退到 `NEXT_PUBLIC_API_BASE` | **服务端组件(RSC)**取数用的内网基址。base/prod compose 的 `web.environment` 已设为 `http://api:8000`compose 服务名RSC 直连 api 容器。 |
### 1.4 LLM provider key 不进环境变量
**不要**把任何 LLM 供应商密钥DeepSeek/Kimi/OpenAI/Anthropic/Qwen/GLM/Gemini 等)写进 env 或 compose。
本产品的 provider 凭据是**部署后在应用「设置」页录入**,由后端用 `CREDENTIAL_ENC_KEY`Fernet
**加密后存进 Postgres**`provider_credentials` 表 / `settings_providers` 路由)。
Kimi Code 走 OAuth 订阅授权(`kimi_oauth` 路由),同样落库。
推论:`CREDENTIAL_ENC_KEY` 是解开所有已存凭据的唯一钥匙——**丢了它 = 库里所有 provider 凭据不可解密**,只能重新录入。务必备份保管(见 §2.3 / §6
### 1.5 部署面缺口——已修复(现可照抄部署)
此前审阅 Dockerfile/compose 发现的 6 个「照抄即部署」缺口**均已在部署面文件消除**(不改应用源码)。现状如下:
1. **api 镜像已拷 `alembic.ini`** ✅:`apps/api/Dockerfile``COPY apps/api` 之后新增 `COPY alembic.ini ./`(迁移目录 `packages/db/migrations``COPY packages` 进镜像)。因此可直接在 api 容器内跑 `docker compose run --rm api uv run alembic upgrade head`§2.4)。
2. **compose `api` 已提供 `CREDENTIAL_ENC_KEY`**base `docker-compose.yml``api.environment` 现注入 `CREDENTIAL_ENC_KEY``${CREDENTIAL_ENC_KEY:-<dev 占位>}`compose 自动读仓库根 `.env` 覆盖该默认)、`APP_ENV`/`LOG_JSON`/`CORS_ORIGINS`。**base compose 现可直接 `up` 起 api**(无 `.env` 也走 dev 占位 key 起来;生产用 §2.2 覆盖文件强制真值)。
3. **compose `web` 已设 `API_BASE_INTERNAL`**base 的 `web.environment``API_BASE_INTERNAL=http://api:8000`RSC 服务端取数直连 api 容器。
4. **`NEXT_PUBLIC_API_BASE` 支持构建期注入** ✅web `Dockerfile``pnpm build` 前声明 `ARG NEXT_PUBLIC_API_BASE=http://localhost:8000` + `ENV`base/prod compose 的 `web.build.args` 传入它。生产 `docker compose -f ... -f docker-compose.prod.yml build` 即把公网 API 基址内联进 bundle。
5. **api/web 已内置 HEALTHCHECK** ✅:`apps/api/Dockerfile` 用 stdlib `urllib``/health``apps/web/Dockerfile``node http``/`(均 interval 30s / timeout 5s / start-period 20s / retries 3镜像无 curl 故用运行时自带解释器)。编排/反代可直接读容器健康态。
6. **base 弱口令/裸暴露——生产用覆盖层加固**base `docker-compose.yml` 保持 dev 便利pg `writer/writer``5432:5432``APP_ENV=dev`);新增 **`docker-compose.prod.yml`** 覆盖层做生产加固——pg 强口令 `${POSTGRES_PASSWORD}``:?` 缺失即拒起、pg 仅绑 `127.0.0.1:5432``APP_ENV=prod``LOG_JSON=true``CREDENTIAL_ENC_KEY`/`DATABASE_URL*`/`CORS_ORIGINS`/`NEXT_PUBLIC_API_BASE` 全从 env 且必填、`restart: unless-stopped`
> 构建面同时补了两个 `.dockerignore`(仓库根 + `apps/web`):排除宿主 `node_modules`/`.next`/`.venv`/`.git`/缓存,避免宿主平台原生二进制(如 macOS 的 next-swc经 `COPY . .` 覆盖容器内 Linux 版导致 `pnpm build` 失败,并加速构建上下文传输、保证可复现。
>
> 校验:`docker compose config`base与 `docker compose -f docker-compose.yml -f docker-compose.prod.yml config`prod 合并)均通过;`docker compose build` 两镜像均成功构建。
---
## 2. 方案 A单机 docker compose推荐起步
适合:单台服务器 / VPS起步验证。前提服务器已装 Docker含 Docker Compose v2 插件)。
### 2.1 装 Docker + 拉代码
```bash
# 服务器上(以 Ubuntu 为例)安装 Docker Engine + compose 插件
curl -fsSL https://get.docker.com | sh
docker --version && docker compose version # 确认 compose v2 可用
# 拉代码
git clone <你的仓库地址> writer-work-flow
cd writer-work-flow
git checkout main # 或你的发布分支
```
### 2.2 准备生产配置(覆盖 base compose 的 dev 缺省)
base `docker-compose.yml` 是**开发导向**pg 弱口令 `writer/writer``5432` 对外发布、`APP_ENV=dev`
`CREDENTIAL_ENC_KEY` 用 dev 占位默认)。生产加固的**覆盖文件 `docker-compose.prod.yml` 已随仓库提供**(无需手写),
与 base 叠加使用即可。它把安全项全部改为**从宿主 env 强制注入**(缺失即拒绝启动):
- `pg``POSTGRES_PASSWORD=${POSTGRES_PASSWORD}` 强口令、端口仅绑 `127.0.0.1:5432``restart: unless-stopped`
- `api``APP_ENV=prod``LOG_JSON=true``CREDENTIAL_ENC_KEY`/`CORS_ORIGINS`/`DATABASE_URL*`(用同一强口令)全从 env。
- `web``NEXT_PUBLIC_API_BASE`(构建期 build arg + 运行期 env指向公网 API、`API_BASE_INTERNAL=http://api:8000`
你只需在**仓库根**准备一个 `.env`compose 自动读取做 `${...}` 插值;**chmod 600、勿入库**,已被 `.gitignore` 忽略):
```bash
# .envcompose 变量插值用chmod 600勿入库
POSTGRES_PASSWORD=<强随机口令>
CREDENTIAL_ENC_KEY=<§2.3 生成的 Fernet key>
CORS_ORIGINS=https://writer.example.com # 前端真实来源,逗号分隔;禁通配 *
NEXT_PUBLIC_API_BASE=https://writer.example.com # 浏览器可达的公网 API经反代
```
> prod 覆盖层对 `POSTGRES_PASSWORD`/`CREDENTIAL_ENC_KEY`/`CORS_ORIGINS`/`NEXT_PUBLIC_API_BASE` 用
> `${VAR:?...}`(必填)语义——**任一缺失,`docker compose ... config`/`up` 直接报错拒起**,从设计上杜绝
> 「带 base 弱口令/占位 key 上生产」。完整清单见 [§2.6 生产 env 清单](#26-生产-env-清单)。
### 2.3 生成并妥善保管 CREDENTIAL_ENC_KEY
⚠️ **这是最关键的一步**。用 Fernet 生成一把新密钥(`.env.example` 里那把是开发占位,**生产禁用**
```bash
# 任一装了 cryptography 的环境(也可用仓库 uv 环境)
uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# 或纯 pythonpython -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
```
把输出的一行密钥填进 §2.2 的 `.env``CREDENTIAL_ENC_KEY`,并**离线备份**(密码管理器/密钥库)。
> **丢失 = 灾难**:所有已录入的 provider 凭据(存在 Postgres 里、用它加密)将**永久不可解密**
> 只能清空后重新录入。轮换密钥需先用旧钥解密全部凭据、再用新钥重加密(当前无自动轮换脚本,
> 见 §6。**切勿**复用 `.env.example` 里的占位 key那等于凭据未加密。
### 2.4 构建镜像 + 跑迁移 + 起服务
```bash
# 在仓库根,带上覆盖文件
export COMPOSE="docker compose -f docker-compose.yml -f docker-compose.prod.yml"
# 1) 构建两个应用镜像
$COMPOSE build
# 2) 先起数据库
$COMPOSE up -d pg
# 等 pg 健康compose 已配 healthcheck
$COMPOSE ps # 看 pg 是否 healthy
# 3) 跑数据库迁移(含 LangGraph checkpointer 建表)—— 见下方两种方式
```
**迁移的关键事实**`alembic upgrade head` 一条命令会做**两件事**——
(a) 建/升级 16 张业务表;(b) 迁移 `d3e4f5a6b7c8_langgraph_checkpoint_setup` 会调用 LangGraph 的
`checkpointer.setup()``checkpoints/checkpoint_blobs/checkpoint_writes/checkpoint_migrations`
四张检查点表。**checkpointer 的建表只在迁移里发生,绝不在应用运行时**(符合 CLAUDE.md「LangGraph」纪律
所以**部署时必须先把迁移升到 head应用才能正常写章/审稿/续跑**。
api 镜像现已随包 `alembic.ini`§1.5 已修复 1**推荐直接在 api 容器里跑迁移**(无需宿主装 uv
**方式一(推荐)——在 api 容器里跑迁移:**
```bash
$COMPOSE run --rm api uv run alembic upgrade head
$COMPOSE run --rm api uv run alembic check # 可选:确认无漂移
```
> 该临时容器与 api 服务共享同一 env含 `DATABASE_URL*`/`CREDENTIAL_ENC_KEY`),直连 compose 内网的 `pg`,无需暴露端口。
**方式二(备选)——从宿主机 checkout 跑迁移**(宿主已装 uv 时可用prod 覆盖层把 pg 绑到 `127.0.0.1:5432`
```bash
# 宿主机装 uvcurl -LsSf https://astral.sh/uv/install.sh | sh; export PATH="$HOME/.local/bin:$PATH"
cd writer-work-flow && uv sync
DATABASE_URL="postgresql+asyncpg://writer:${POSTGRES_PASSWORD}@127.0.0.1:5432/writer" \
DATABASE_URL_SYNC="postgresql+psycopg://writer:${POSTGRES_PASSWORD}@127.0.0.1:5432/writer" \
CREDENTIAL_ENC_KEY="${CREDENTIAL_ENC_KEY}" \
uv run alembic upgrade head
```
**4) 起全部服务:**
```bash
$COMPOSE up -d
$COMPOSE ps # api/web 应为 running注意它们无 healthcheck看不到 healthy
```
### 2.5 健康检查
```bash
# API根 + health 端点(均返回 200 JSON
curl -f http://127.0.0.1:8000/ # {"service":"ww-api","status":"ok"}
curl -f http://127.0.0.1:8000/health # {"status":"ok"}
# Web首页
curl -fI http://127.0.0.1:3000/
# 看日志api 启动会校验 CREDENTIAL_ENC_KEY、seed 单用户桩、reap 僵尸 job
$COMPOSE logs -f api
```
若 api 启动即退出,最常见原因是 `CREDENTIAL_ENC_KEY` 非法base compose 已注入 dev 默认;
生产覆盖层要求经 env 提供合法 Fernet key见 §2.3)——日志会显示 CredentialKeyError 相关堆栈。
部署完成后,**打开前端「设置」页录入 LLM provider 凭据**§1.4),产品才能真正调用模型。
### 2.6 生产 env 清单
生产用 `docker-compose.prod.yml` 覆盖层,以下变量必须在**仓库根 `.env`**compose 自动读取做 `${...}` 插值;
`chmod 600`、勿入库)或宿主环境变量提供。带 `:?` 者缺失即 `config`/`up` **报错拒起**
| 变量 | 必填 | 消费方 | 说明 |
|---|:---:|---|---|
| `POSTGRES_PASSWORD` | ✅ | pg + api(`DATABASE_URL*`) | pg 强随机口令,替换 base 弱口令 `writer/writer`。api 连接串复用它。 |
| `CREDENTIAL_ENC_KEY` | ✅ | api | Fernet key§2.3 新生成,**勿用 `.env.example` 占位值**。api 启动即校验;解开全部已存 provider 凭据的唯一钥匙,须离线备份。 |
| `CORS_ORIGINS` | ✅ | api | 浏览器跨源白名单,逗号分隔,**禁通配 `*`**(如 `https://writer.example.com`)。 |
| `NEXT_PUBLIC_API_BASE` | ✅ | web**构建期**内联 + 运行期) | 浏览器可达的公网 API 基址(经反代)。改它必须**重新 `docker compose ... build web`**(构建期内联进 bundle。 |
| `APP_ENV` | 覆盖层已固定 `prod` | api | 无需手设prod 层写死)。 |
| `LOG_JSON` | 覆盖层已固定 `true` | api | 无需手设prod 层写死)。 |
| `API_BASE_INTERNAL` | 覆盖层已固定 `http://api:8000` | web(RSC) | 无需手设prod 层写死)。 |
> LLM provider keyDeepSeek/Kimi/OpenAI…**永不进 env**——部署后在应用「设置」页录入、加密落库§1.4)。
生成 `.env` 骨架(示例):
```bash
cat > .env <<'EOF'
POSTGRES_PASSWORD=<强随机口令>
CREDENTIAL_ENC_KEY=<uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" 的输出>
CORS_ORIGINS=https://writer.example.com
NEXT_PUBLIC_API_BASE=https://writer.example.com
EOF
chmod 600 .env
```
---
## 3. 反向代理 + HTTPS
生产应在应用前放一层反代做 TLS 终止、单一入口、并(配合 §6做访问控制。
拓扑:浏览器 → 反代(443) → `web:3000`(页面/RSC`api:8000``/api` 或独立子域)。
> 注意 `NEXT_PUBLIC_API_BASE` 的构建期内联:浏览器直接打 API 的地址在**构建 web 镜像时**就定死了。
> web `Dockerfile` 已暴露 `ARG NEXT_PUBLIC_API_BASE`、prod compose 的 `web.build.args` 会传入它——改这个基址后**必须重新 `docker compose ... build web`** 才生效(重启不改已构建 bundle
> 若走「同源 `/api` 前缀反代」,构建时把 `NEXT_PUBLIC_API_BASE` 设为该前缀若走「api 独立子域」(如 `api.example.com`),同理构建期注入。
### 3.1 Caddy推荐自动证书
```caddy
# Caddyfile —— 自动申请/续签 Let's Encrypt 证书
writer.example.com {
# 后端 API同源 /api/* 反代到 api 容器(去掉 /api 前缀按你的前端约定调整)
handle_path /api/* {
reverse_proxy api:8000
}
# 其余流量到 Next 前端
reverse_proxy web:3000
# SSE 长连接:关掉缓冲,避免审稿/写章流被缓存卡住
reverse_proxy api:8000 {
flush_interval -1
}
}
```
将 Caddy 也放进 compose与 api/web 同网络,用服务名访问),暴露 80/443。
### 3.2 Nginx手动 / certbot 证书)
```nginx
server {
listen 443 ssl;
server_name writer.example.com;
ssl_certificate /etc/letsencrypt/live/writer.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/writer.example.com/privkey.pem;
# 前端
location / {
proxy_pass http://web:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 后端 API含 SSE写章/四审流)
location /api/ {
proxy_pass http://api:8000/;
proxy_set_header Host $host;
proxy_http_version 1.1;
proxy_set_header Connection ""; # SSE保持长连接
proxy_buffering off; # SSE关缓冲事件即时下发
proxy_read_timeout 3600s; # 长任务/流式容忍
}
}
```
> 无论 Caddy/Nginx**SSE 必须关代理缓冲**(写章、四审都是事件流),否则前端进度条会卡住。
---
## 4. 方案 BPaaS / 托管平台
容器化已就绪(两个 Dockerfile + standalone 前端),可托管到 PaaS。共性**用托管 Postgres**
在平台面板设 env尤其 `CREDENTIAL_ENC_KEY``CORS_ORIGINS``DATABASE_URL*`
部署后照 §2.4 跑一次 `alembic upgrade head`。provider key 仍在应用设置页录入(不进平台 env
### 4.1 Fly.io
- 两个 app`fly launch` 分别为 api`apps/api/Dockerfile`,构建上下文=仓库根)与 web`apps/web/Dockerfile`,上下文=`apps/web`)。
- Postgres 用 `fly postgres create`(或外部托管 PG`DATABASE_URL`/`DATABASE_URL_SYNC` 设成其连接串(注意驱动前缀 `+asyncpg`/`+psycopg`Fly 给的是原生 `postgres://`,需自行加前缀)。
- `fly secrets set CREDENTIAL_ENC_KEY=... CORS_ORIGINS=... POSTGRES_PASSWORD=...`
- 迁移:镜像已含 `alembic.ini`§1.5 已修复 1可用 Fly `[deploy] release_command = "uv run alembic upgrade head"` 在每次发布前自动跑迁移,或 `fly ssh console` 手动跑。
- web 的 `NEXT_PUBLIC_API_BASE` 在**构建期**注入Fly build args → Dockerfile `ARG NEXT_PUBLIC_API_BASE`,已暴露)。
- api 内部端口 8000、web 内部端口 3000`fly.toml``internal_port` 对应。
### 4.2 Render
- 两个 Web ServiceDocker 运行时),分别指向两个 Dockerfileweb 的 Docker build context 设 `apps/web`api 设仓库根。
- Postgres 用 Render Managed Postgres把内部连接串填进 api 的 `DATABASE_URL*`(加 `+asyncpg`/`+psycopg` 前缀)。
- 在 api service 的 Environment 填 `CREDENTIAL_ENC_KEY`/`CORS_ORIGINS`/`APP_ENV=prod`/`LOG_JSON=true`
- 迁移:用 Render 的 **Pre-Deploy Command**`uv run alembic upgrade head`(镜像已含 `alembic.ini`§1.5 已修复 1
- web 与 api 的互通用 Render 的内部地址;`API_BASE_INTERNAL` 指向 api 内部 URL`NEXT_PUBLIC_API_BASE`(构建期 build arg指向 api 公网 URL。
> 两个平台的共同注意点:`NEXT_PUBLIC_API_BASE` 仍是**构建期内联**(改它须重构 web 镜像);`alembic.ini` 已随镜像,迁移可在容器/release 命令里跑。
> HEALTHCHECK 已内建于镜像,平台存活探测可直接复用或按平台惯例另配 `/health`api/`/`web
---
## 5. 迁移与升级流程
### 5.1 常规升级
```bash
export COMPOSE="docker compose -f docker-compose.yml -f docker-compose.prod.yml"
git pull # 拉新代码(含新迁移 / schema.d.ts
$COMPOSE build # 重建 api/web 镜像
# 先升迁移到 head含新增业务表 + 可能的 checkpointer 变更),再滚动重启(见 §2.4
$COMPOSE run --rm api uv run alembic upgrade head # 在 api 容器内跑迁移(镜像已含 alembic.ini
$COMPOSE up -d # 用新镜像重启 api/webpg 不动)
# 冒烟
curl -f http://127.0.0.1:8000/health && curl -fI http://127.0.0.1:3000/
```
顺序要点:**先迁移、后换应用镜像**——新代码通常假设 schema 已到 head。若后端 schema 有变,
记得升级前已在开发侧 `pnpm gen:api` 重生成并提交了 `apps/web/lib/api/schema.d.ts`§1.2
否则 web 构建/类型会漂移。
### 5.2 回滚
```bash
# 应用回滚:切回上一个 tag/commit 重建重启
git checkout <上一个可用 tag>
$COMPOSE build && $COMPOSE up -d
# 数据库回滚(谨慎):按需降级一版
$COMPOSE run --rm api uv run alembic downgrade -1
```
> ⚠️ DB 降级可能丢列/丢表数据,且 `d3e4f5a6b7c8` 的 `downgrade()` 会 `DROP` 四张 checkpoint 表
> (在跑的写章/链任务会丢控制流状态)。**回滚前先备份**§7.3)。业务表回滚同理不可逆,优先「向前修复」。
---
## 6. 安全红线(单用户原型的硬约束)
⚠️ **本产品没有认证、没有多租户、没有登录**`users`/`owner_id` 是桩,见 CLAUDE.md「Prototype scope」
任何拿到 URL 的人都能读写全部小说数据、录入/触发用花你钱的 LLM 凭据。因此:
1. **绝不裸暴露到公网。** 至少满足其一:
- 只在 **VPN / 内网 / SSH 隧道**内访问;
- 反代前加 **IP 白名单****HTTP Basic Auth**Caddy `basic_auth` / Nginx `auth_basic`
- 或**先补一层认证**再上公网(当前无,属产品级改造)。
2. **Postgres 不对公网开放。** base compose 把 `pg` 发布到 `5432:5432`(全网卡)。生产**必须**改成
仅回环 `127.0.0.1:5432:5432`§2.2)或删掉端口映射只走 compose 内网。**改掉 base 里的弱口令 `writer/writer`**。
3. **CORS 显式白名单,禁通配。** `CORS_ORIGINS` 必须是精确来源;后端已在启动断言不含 `*`
`allow_credentials=True``*` 会被浏览器拒且语义危险)。别为图省事放宽。
4. **`CREDENTIAL_ENC_KEY` 的密钥管理与轮换:**
- 用 §2.3 生成的**新** Fernet key绝不用 `.env.example` 占位值;
- 存进密钥库 / 平台 secrets`.env` `chmod 600` 且**不入库**
- **备份它**(丢了 = 所有 provider 凭据不可解密);
- 轮换需「旧钥解密全部凭据 → 新钥重加密」(当前无自动脚本,轮换前规划停机 + 备份)。
5. **provider key 只走应用设置页**(加密落库),永不写进 env/compose/日志§1.4)。
6. **日志脱敏已内建**structlog 结构化日志按 CLAUDE.md「Logging」纪律**不记全 prompt/正文**(记长度/哈希)、
**不记 API key**。保持这一约束,别在自建反代/中间件里回退成明文全量日志。
7. **HTTPS 强制**§3别让凭据/正文走明文。
---
## 7. 可观测性与运维
### 7.1 健康端点
- `GET /``{"service":"ww-api","status":"ok"}`
- `GET /health``{"status":"ok"}`
两者均无鉴权、返回 200可作反代/编排的存活探测目标。
api/web 的 Dockerfile **已内置** `HEALTHCHECK`§1.5 已修复 5api 用 stdlib `urllib``/health`、web 用 `node http``/`——`docker compose ps` 直接显示 `healthy`,反代/平台亦可复用。
### 7.2 结构化日志
- 后端 structlog**生产设 `LOG_JSON=true`** 输出 JSON便于日志系统采集/检索。
- 每个请求带 `request_id`(贯穿 assemble→write→四审→accept 一条链),也进错误响应信封——
用户报错时可用它端到端 grep。
- 采集:`docker compose logs`,或把容器 stdout 接到你的日志栈Loki/ELK/云日志)。
### 7.3 备份 Postgres含凭据库
数据全在 `pgdata` 卷 + Postgres 里,**包含加密后的 provider 凭据**。定期逻辑备份:
```bash
export COMPOSE="docker compose -f docker-compose.yml -f docker-compose.prod.yml"
# 逻辑备份(推荐)
$COMPOSE exec pg pg_dump -U writer -d writer -Fc > backup_$(date +%F).dump
# 恢复
$COMPOSE exec -T pg pg_restore -U writer -d writer --clean < backup_YYYY-MM-DD.dump
```
> **备份数据库还不够**`CREDENTIAL_ENC_KEY` 要**单独异地备份**——DB 里的凭据是密文,
> 没有这把钥匙备份也解不出§2.3 / §6。两者分开保管、都要备。
### 7.4 成本账本LLM 调用)
每次 LLM 调用都按 `ARCHITECTURE §4.8` 的字段集记结构化日志provider/model/tier/token 数/耗时/`request_id` 等),
喂成本账本。运维时用这些日志:
- 核对每章/每次生成的 token 与花费;
- 排查慢/失败调用(网关的路由/回退/熔断也在此可见);
-`request_id` 关联「一次写章」的整条链。
采集方式同 §7.2JSON 日志接入日志栈后按字段聚合)。
---
## 附:核心事实速查
- **服务/端口**`pg`(5432, 唯一健康检查 `pg_isready -U writer`) · `api`(8000) · `web`(3000)。
- **镜像**均本地构建api 上下文=仓库根web 上下文=`apps/web`standalone
- **必填 env**`CREDENTIAL_ENC_KEY`(启动即校验,缺失/非法则 api 起不来)。
- **迁移**`alembic upgrade head` 同时建业务表 + LangGraph checkpointer 表checkpointer 只在迁移建表,不在运行时。
- **provider key**:不进 env部署后在设置页录入、加密落库。
- **部署面缺口已全部修复**§1.5api 镜像含 `alembic.ini`base compose `api` 注入 `CREDENTIAL_ENC_KEY`/`APP_ENV`/`LOG_JSON`/`CORS_ORIGINS``web``API_BASE_INTERNAL` + `NEXT_PUBLIC_API_BASE` build argapi/web 内置 HEALTHCHECK生产加固走 `docker-compose.prod.yml` 覆盖层(强口令/回环 pg/prod env/`restart`)。
- **部署命令**`docker compose build`dev`docker compose -f docker-compose.yml -f docker-compose.prod.yml build`prod`... run --rm api uv run alembic upgrade head``... up -d`
- **红线**:单用户无认证 → 不裸暴露公网pg 不对外CORS 禁 `*``CREDENTIAL_ENC_KEY` 备份+轮换。

View File

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

160
README.md Normal file
View File

@@ -0,0 +1,160 @@
# 网文创作工作流Writer Work Flow
> AI 辅助的**中文网文写作工作流**,以 Web 应用形态交付。核心理念:**把写长篇当软件工程做**——先立架构(世界观 / 角色 / 大纲),对着规格逐章生成,每章写完即测(多维审查),全程维护单一真相源(数据库)。
作者不再让 AI「一口气瞎写十万字」而是像写代码一样需求立项→ 架构(设定库)→ 建模(角色卡)→ 接口(大纲)→ 实现(逐章生成)→ 测试(写完即审)→ 集成(验收更新全局状态)→ 回归全书一致性。AI 是「灵感增幅器 + 质检员」,作者始终掌控创意。
---
## 功能总览
- **引导式立项** — 一句灵感走向连载:选题材 / 基调 / 结局走向 / 叙事视角AI 起草世界观、主角、金手指、总纲方案,一键建库。
- **设定库Codex** — 结构化的单一真相源:
- 角色卡(外貌 / 动机 / 性格 / 背景 / 口癖 / 性格弧光 / 人物关系图)
- 世界观(力量体系 / 势力 / 地理 / 词条)
- **大纲 / 细纲** — AI 排分卷分章骨架,逐层细化到场景清单,并提示伏笔回收窗口。
- **写章SSE 流式)** — 注入设定 + 文风指纹,按 genre-aware 的网文写作教条(黄金三章、章末钩子、爽点密度)流式产出本章草稿。
- **五审(写完即审)** — 一致性 / 伏笔 / 文风 / 节奏 四审 + 人物塑造advisory 建议性)。审查器**只读**,不静默改稿。
- **验收事务HITL** — 人在环中:作者裁决冲突后,唯一写入路径提交;未解决冲突阻断验收。章节摘要从**最终已验收文本**抽取,绝不污染真相源。
- **创作工具箱15 个生成器)** — 声明式 skill 框架,「加生成器 = 加一份声明」:脑洞 / 书名 / 简介 / 取名 / 金手指 / 词条 / 黄金开篇 / 细纲 / 续写 / 扩写 / 降 AI / 拆书,外加世界观 / 角色 / 大纲。
- **模板库** — 复用与沉淀常用创作模板。
- **多 Provider LLM 网关** — 路由 / 回退 / 熔断的自建薄网关Agent 只声明能力 tier网关按配置映射到 provider+model支持 Kimi Code 订阅 OAuthdevice flow
- **沉浸式写作体验** — 纸感暖色 + 夜读双主题(`data-theme` 切换)、写作台专注模式(隐藏侧栏加宽正文)、`⌘K` 命令面板(跨页跳转 / 执行命令)、应用内帮助页 [`/help`](http://localhost:4000/help)(功能介绍 + 新手上手。UI 对标编辑部式暖奶油设计语言,全程尊重 `prefers-reduced-motion` 与 WCAG AA。
---
## 技术栈(已锁定)
| 层 | 选型 |
|---|---|
| 前端 | **Next.js + TypeScript**(仅 UI经 OpenAPI 生成的 TS 客户端调后端,无手写共享类型) |
| 后端 | **Python + FastAPI**asyncSSE |
| 编排 | **LangGraph**(写→审→验收 图Postgres checkpointer 可恢复) |
| LLM 访问 | 自建**薄网关**,覆盖 `anthropic` + `openai`baseURL 兼容 DeepSeek/Kimi/Qwen/GLM+ `google-genai` |
| 结构化输出 | **Pydantic + instructor** |
| ORM / 迁移 | **SQLAlchemy 2.0async+ Alembic** |
| 存储 | **Postgres**(原型不启用 pgvector |
| 长任务 | FastAPI BackgroundTasks + `jobs` 表(原型不引入独立队列) |
工具链:**uv**Python workspace8 个成员)+ **pnpm**前端。Python 3.12+Node 22。
---
## 快速开始
前置Python 3.12+ · Node 22 · [uv](https://docs.astral.sh/uv/) · pnpm · Docker
> **本地端口(宿主已 +1000防与其他项目冲突**:前端 **http://localhost:4000** · 后端 **http://localhost:9000**API 文档 `/docs`)· Postgres **localhost:6432**。容器内部端口不变3000 / 8000 / 5432只改宿主发布端口要改回默认各减 1000 即可。接线:前端 `apps/web/.env.local` 的 `NEXT_PUBLIC_API_BASE=http://localhost:9000` 指向后端;后端 `.env` 的 `CORS_ORIGINS` 放行 `http://localhost:4000`、`DATABASE_URL` 指向 `localhost:6432`。
```bash
# 1. 克隆后安装后端依赖仓库根uv workspace
uv sync
# 2. 安装前端依赖
cd apps/web && pnpm install && cd ../..
# 3. 配置环境变量CREDENTIAL_ENC_KEY 为必填的凭据加密 Fernet key启动即校验
cp .env.example .env
# 生产请重新生成:
# uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# 4. 起 Postgres 并跑迁移
docker compose up -d pg
uv run alembic upgrade head
# 5. 起后端 APIhttp://localhost:9000文档 /docs— 宿主端口 +1000
uv run uvicorn ww_api.main:app --port 9000 --reload
# 6. 起前端另开终端http://localhost:4000— pnpm dev 已内置 -p 4000
cd apps/web && pnpm dev
```
或一键起全套pg + api + web宿主端口均已 +1000
```bash
docker compose up # web → :4000 · api → :9000 · pg → :6432
```
> **LLM API Key 不放 `.env`**:在应用设置页 **`/settings/providers`** 录入,密文存于数据库凭据库(用 `CREDENTIAL_ENC_KEY` 加密)。`.env` 里只有那把加密主密钥本身。
---
## 工具链 & 门禁
改动声明「完成」前须全绿TDD 先红后绿,覆盖率 ≥ 80%)。
**后端**(仓库根):
```bash
uv run ruff check . # lint
uv run ruff format . # format
uv run mypy packages apps # 类型
uv run pytest -q # 单元 + 集成(需 pgdocker compose up -d pg
uv run alembic check # 改过模型后校验无迁移漂移
uv run pytest -q --cov --cov-report=term-missing --cov-fail-under=80 # 覆盖率门禁
```
**前端**`apps/web`
```bash
pnpm lint
pnpm typecheck
pnpm test # vitest范围 lib/**:纯逻辑 + hooks
pnpm build
pnpm test:coverage # 覆盖率门禁(阈值 80%
```
**改过后端 schema** 后重生成 TS 客户端:
```bash
cd apps/web && pnpm gen:api
```
CI 见 `.github/workflows/ci.yml`backend job 带 pg service 跑 ruff/mypy/alembic/pytestfrontend job 跑 gen:api/lint/typecheck/test
---
## 项目结构
```
apps/
api/ FastAPI 应用:路由 / service / 请求响应 schema / SSE 端点
web/ Next.js 前端Server Components 读视图 + Client 编辑器/流式交互
packages/
shared/ 跨栈 Pydantic 契约 schema前端经 OpenAPI 消费)
config/ 应用配置Settings环境变量边界
db/ SQLAlchemy 2.0 模型 / Alembic 迁移 / Repository
llm_gateway/ 多 provider 网关:路由 / 回退 / 熔断 / adapter / 计费台账
agents/ 声明式 AgentSpec + 外置 promptAgent 只声明能力 tier
core/ domain 领域逻辑 · memory 记忆真相源(确定性选择注入)· orchestrator LangGraph 图
skills/ 工具箱注册表 + skill 注册(声明驱动的生成器)
```
---
## 文档地图
四份 spec 分层,每层细化上一层;实现暴露的规格缺口须回写对应 spec。
| 文档 | 回答什么 | 何时读 |
|---|---|---|
| [`PRODUCT_SPEC.md`](./PRODUCT_SPEC.md) | 是什么 & 为什么 —— 问题、功能(含 P0/P1/P2 优先级、数据模型、端点、Agent | 理解范围或某功能 |
| [`UX_SPEC.md`](./UX_SPEC.md) | 长什么样 —— 信息架构、用户流、页面线框、组件、视觉 token纸感 / warm-cream 主题) | 做任何 UI |
| [`ARCHITECTURE.md`](./ARCHITECTURE.md) | 怎么实现 —— 完整 DDL、网关内部、编排引擎、API 契约、横切关注点 | 实现后端 / 数据 / 网关 |
| [`DEV_PLAN.md`](./DEV_PLAN.md) | 按什么顺序 —— Phase 05每个任务标注学科 skill | 挑下一个任务 |
| [`PROGRESS.md`](./PROGRESS.md) | 交付到哪了 —— 权威状态台账 + 变更日志 | 开工前必读 |
冲突裁决:更具体 / 更靠后的文档为准(`ARCHITECTURE` > `UX_SPEC`/`DEV_PLAN` > `PRODUCT_SPEC`)。协作与开发规范见 [`CLAUDE.md`](./CLAUDE.md)。
---
## 测试与原型边界
- **测试**TDD 强制(先写失败测试);三层——单元(一个节点/函数/Repositorymock IO→ 集成FastAPI 端点 + DB + mock 网关)→ E2E写→审→验收mock 网关)。**所有测试一律 mock LLM绝不命中真实 LLM API**。覆盖率门禁 ≥ 80%。
原型作用域,以下**明确不做**(勿误用于生产):
- **单用户,无 auth / 多租户** —— `users` / `owner_id` 为占位桩。
- **无向量检索 / pgvector** —— 记忆注入是确定性按需选择(显式点名 + 主要角色 + 最近 N 章),非向量搜索(向量检索列 P2
- **无独立任务队列** —— 长任务走 FastAPI BackgroundTasks + `jobs` 表。
- 不做模型微调、内容分发 / 发布平台对接、移动原生 App。

View File

@@ -4,6 +4,14 @@ WORKDIR /app
COPY pyproject.toml uv.lock* ./
COPY packages ./packages
COPY apps/api ./apps/api
# alembic.ini 在仓库根,随 build context 拷入镜像,供容器内 `alembic upgrade head`(迁移目录
# packages/db/migrations 已随 `COPY packages` 进镜像;缺 alembic.ini 迁移会找不到配置而失败)。
COPY alembic.ini ./
RUN uv sync --no-dev --frozen || uv sync --no-dev
EXPOSE 8000
CMD ["uv", "run", "uvicorn", "ww_api.main:app", "--host", "0.0.0.0", "--port", "8000"]
# 存活探测:镜像无 curl用 stdlib urllib 打 /health非 200/连接失败会抛异常,退出非零)。
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health').read()"]
# --no-sync用构建期已烤好的 --no-dev venv 直接起,禁止启动时重新同步(否则 `uv run` 会在
# 运行时按默认组重装依赖、连 ruff/mypy 等 dev 工具都现场下载,慢且需运行时联网、每次启动重来)。
CMD ["uv", "run", "--no-sync", "uvicorn", "ww_api.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

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

View File

@@ -9,6 +9,7 @@ from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from datetime import UTC, datetime
from typing import Any
from pydantic import BaseModel
@@ -41,6 +42,7 @@ class FakeProjectRepo:
def __init__(self) -> None:
self.rows: dict[uuid.UUID, tuple[uuid.UUID, ProjectView]] = {}
self.now = datetime(2026, 6, 28, tzinfo=UTC)
async def create(self, owner_id: uuid.UUID, data: ProjectCreate) -> ProjectView:
pid = uuid.uuid4()
@@ -53,12 +55,21 @@ class FakeProjectRepo:
theme=data.theme,
selling_points=list(data.selling_points),
structure=data.structure,
tone=data.tone,
ending_type=data.ending_type,
narrative_pov=data.narrative_pov,
updated_at=self.now,
)
self.rows[pid] = (owner_id, view)
return view
async def list_for_owner(self, owner_id: uuid.UUID) -> list[ProjectView]:
return [v for (o, v) in self.rows.values() if o == owner_id]
async def list_for_owner(
self, owner_id: uuid.UUID, *, limit: int | None = None, offset: int = 0
) -> list[ProjectView]:
views = [v for (o, v) in self.rows.values() if o == owner_id]
if limit is None:
return views
return views[offset : offset + limit]
async def get(self, owner_id: uuid.UUID, project_id: uuid.UUID) -> ProjectView | None:
entry = self.rows.get(project_id)
@@ -145,6 +156,7 @@ class FakeReviewRepo:
foreshadow_sug: list[dict[str, Any]] | None = None,
style: dict[str, Any] | None = None,
pace: dict[str, Any] | None = None,
characterization: dict[str, Any] | None = None,
health_score: int | None = None,
) -> ReviewView:
view = ReviewView(
@@ -156,6 +168,7 @@ class FakeReviewRepo:
foreshadow_sug=[dict(s) for s in (foreshadow_sug or [])],
style=style,
pace=pace,
characterization=characterization,
health_score=health_score,
)
# 新→旧:插到列表头部。
@@ -350,8 +363,14 @@ class FakeOutlineReadRepo:
async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None:
return self.rows.get((project_id, chapter_no))
async def list_for_project(self, project_id: uuid.UUID) -> list[OutlineView]:
views = [v for (p, _), v in self.rows.items() if p == project_id]
async def list_for_project(
self, project_id: uuid.UUID, *, volume: int | None = None
) -> list[OutlineView]:
views = [
v
for (p, _), v in self.rows.items()
if p == project_id and (volume is None or v.volume == volume)
]
return sorted(views, key=lambda v: v.chapter_no)

View File

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

View File

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

View File

@@ -0,0 +1,41 @@
"""chain_deps._libpq_conn_string 单测——剥离 SQLAlchemy 驱动后缀(回归)。
bugruntime checkpointer 工厂曾把 `postgresql+psycopg://…`settings 的 sync URL
原样喂给 `AsyncPostgresSaver.from_conn_string`psycopg 不识别 `+psycopg` 方言段 →
`ProgrammingError: missing "=" …` → 多章链 job 直接 failed。E2E 用 MemorySaver 覆盖,
从不走真 conn string故此路径无回归保护。本用例锁住「剥离后缀」语义。
"""
from __future__ import annotations
import pytest
from ww_api.services import chain_deps
class _StubSettings:
def __init__(self, url: str) -> None:
self.database_url_sync = url
@pytest.mark.parametrize(
("raw", "expected"),
[
(
"postgresql+psycopg://writer:writer@localhost:5432/writer",
"postgresql://writer:writer@localhost:5432/writer",
),
("postgresql+asyncpg://u:p@h:5432/db", "postgresql://u:p@h:5432/db"),
("postgresql://u:p@h:5432/db", "postgresql://u:p@h:5432/db"),
],
)
def test_libpq_conn_string_strips_driver_suffix(
monkeypatch: pytest.MonkeyPatch, raw: str, expected: str
) -> None:
# Arrange
monkeypatch.setattr(chain_deps, "get_settings", lambda: _StubSettings(raw))
# Act
result = chain_deps._libpq_conn_string()
# Assert
assert result == expected
assert "+psycopg" not in result
assert "+asyncpg" not in result

View File

@@ -0,0 +1,213 @@
"""GET /projects/:id/chapters 列章端点 + 纯聚合逻辑测试(审稿选章枚举)。
分两层,与本仓「真 PG 行为交 tests/ E2E、单测保持确定性」纪律一致
- **纯聚合** `build_chapter_list`:喂造好的 chapter/review 行,断言 has_draft/accepted/
reviewed_at、去重、按章号升序、空正文草稿不计这是端点真正的业务逻辑脱库直测
- **端点集成**FastAPI client + 覆盖 `get_chapter_lister` 为 Fake同 test_projects.py 风格),
断言 404、空项目 []、字段透传、升序;网关无关(本端点不触 LLM
"""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
import httpx
import pytest
from cryptography.fernet import Fernet
from fakes_projects import FakeProjectRepo
from ww_api.services.chapter_list import (
ChapterRow,
ReviewRow,
build_chapter_list,
)
from ww_api.services.credentials import STUB_OWNER_ID
from ww_core.domain.project_repo import ProjectView
from ww_shared import ErrorCode
# ---- 纯聚合逻辑build_chapter_list----
def _dt(day: int) -> datetime:
return datetime(2026, 7, day, tzinfo=UTC)
def test_build_chapter_list_marks_draft_accepted_and_reviewed() -> None:
# 三态各出现:草稿章(1) / 已验收章(2) / 已审章(3, 有 review 时间)。
chapter_rows = [
ChapterRow(chapter_no=1, status="draft", content="草稿正文"),
ChapterRow(chapter_no=2, status="draft", content="待验收"),
ChapterRow(chapter_no=2, status="accepted", content="终稿"),
ChapterRow(chapter_no=3, status="draft", content="被审的草稿"),
]
review_rows = [ReviewRow(chapter_no=3, created_at=_dt(5))]
items = build_chapter_list(chapter_rows, review_rows)
by_no = {it.chapter_no: it for it in items}
assert by_no[1].has_draft is True
assert by_no[1].accepted is False
assert by_no[1].reviewed_at is None
assert by_no[2].accepted is True
assert by_no[2].has_draft is True
assert by_no[3].reviewed_at == _dt(5)
def test_build_chapter_list_empty_returns_empty() -> None:
assert build_chapter_list([], []) == []
def test_build_chapter_list_sorted_ascending_and_deduped() -> None:
# 乱序 + 同章多版本行 → 去重 + 章号升序。
chapter_rows = [
ChapterRow(chapter_no=3, status="draft", content="c3"),
ChapterRow(chapter_no=1, status="draft", content="c1"),
ChapterRow(chapter_no=1, status="accepted", content="c1a"),
ChapterRow(chapter_no=2, status="accepted", content="c2"),
]
items = build_chapter_list(chapter_rows, [])
assert [it.chapter_no for it in items] == [1, 2, 3]
def test_build_chapter_list_reviewed_at_is_latest() -> None:
# 同章多次审稿 → reviewed_at 取最近一次。
chapter_rows = [ChapterRow(chapter_no=1, status="draft", content="x")]
review_rows = [
ReviewRow(chapter_no=1, created_at=_dt(3)),
ReviewRow(chapter_no=1, created_at=_dt(9)),
ReviewRow(chapter_no=1, created_at=_dt(6)),
]
items = build_chapter_list(chapter_rows, review_rows)
assert items[0].reviewed_at == _dt(9)
def test_build_chapter_list_empty_content_draft_not_marked() -> None:
# 空/纯空白正文的草稿行不算「有草稿正文」(与 GET .../draft 语义一致)。
chapter_rows = [
ChapterRow(chapter_no=1, status="draft", content=" "),
ChapterRow(chapter_no=1, status="draft", content=None),
]
items = build_chapter_list(chapter_rows, [])
assert items[0].chapter_no == 1
assert items[0].has_draft is False
def test_build_chapter_list_review_only_chapter_appears() -> None:
# 有审稿但无 chapters 行(作者以 body.draft 送审未存草稿)→ 该章仍枚举、标已审。
items = build_chapter_list([], [ReviewRow(chapter_no=7, created_at=_dt(2))])
assert len(items) == 1
assert items[0].chapter_no == 7
assert items[0].has_draft is False
assert items[0].accepted is False
assert items[0].reviewed_at == _dt(2)
# ---- 端点集成FastAPI client + Fake lister----
class _FakeChapterLister:
"""实现 ChapterLister Protocol 的内存版:回放预置的列章结果。"""
def __init__(self, items_by_project: dict[uuid.UUID, list[object]] | None = None) -> None:
self.items_by_project = items_by_project or {}
async def list_chapters(self, project_id: uuid.UUID) -> list[object]:
return self.items_by_project.get(project_id, [])
def _make_client(
*,
project_repo: FakeProjectRepo,
lister: _FakeChapterLister,
) -> httpx.AsyncClient:
import os
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
from ww_api.main import create_app
from ww_api.services.project_deps import get_chapter_lister, get_project_repo
app = create_app()
app.dependency_overrides[get_project_repo] = lambda: project_repo
app.dependency_overrides[get_chapter_lister] = lambda: lister
transport = httpx.ASGITransport(app=app)
return httpx.AsyncClient(transport=transport, base_url="http://test")
def _seed_project(project_repo: FakeProjectRepo) -> uuid.UUID:
pid = uuid.uuid4()
project_repo.rows[pid] = (STUB_OWNER_ID, ProjectView(id=pid, title="测试"))
return pid
@pytest.mark.asyncio
async def test_list_chapters_returns_items_with_fields() -> None:
from ww_api.schemas.projects import ChapterListItem
project_repo = FakeProjectRepo()
pid = _seed_project(project_repo)
lister = _FakeChapterLister(
{
pid: [
ChapterListItem(chapter_no=1, has_draft=True, accepted=False, reviewed_at=None),
ChapterListItem(chapter_no=2, has_draft=True, accepted=True, reviewed_at=_dt(4)),
]
}
)
client = _make_client(project_repo=project_repo, lister=lister)
async with client:
resp = await client.get(f"/projects/{pid}/chapters")
assert resp.status_code == 200
body = resp.json()
assert isinstance(body, list)
assert body[0] == {
"chapter_no": 1,
"has_draft": True,
"accepted": False,
"reviewed_at": None,
}
assert body[1]["chapter_no"] == 2
assert body[1]["accepted"] is True
assert body[1]["reviewed_at"].startswith("2026-07-04")
@pytest.mark.asyncio
async def test_list_chapters_empty_project_returns_empty_list() -> None:
project_repo = FakeProjectRepo()
pid = _seed_project(project_repo)
client = _make_client(project_repo=project_repo, lister=_FakeChapterLister())
async with client:
resp = await client.get(f"/projects/{pid}/chapters")
assert resp.status_code == 200
assert resp.json() == []
@pytest.mark.asyncio
async def test_list_chapters_unknown_project_404() -> None:
client = _make_client(project_repo=FakeProjectRepo(), lister=_FakeChapterLister())
async with client:
resp = await client.get(f"/projects/{uuid.uuid4()}/chapters")
assert resp.status_code == 404
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
@pytest.mark.asyncio
async def test_list_chapters_ascending_order() -> None:
from ww_api.schemas.projects import ChapterListItem
project_repo = FakeProjectRepo()
pid = _seed_project(project_repo)
lister = _FakeChapterLister(
{
pid: [
ChapterListItem(chapter_no=1, has_draft=True, accepted=False, reviewed_at=None),
ChapterListItem(chapter_no=2, has_draft=False, accepted=True, reviewed_at=None),
ChapterListItem(chapter_no=5, has_draft=True, accepted=False, reviewed_at=None),
]
}
)
client = _make_client(project_repo=project_repo, lister=lister)
async with client:
resp = await client.get(f"/projects/{pid}/chapters")
assert resp.status_code == 200
assert [it["chapter_no"] for it in resp.json()] == [1, 2, 5]

View File

@@ -28,9 +28,9 @@ from ww_agents import (
WorldEntityCard,
WorldGenResult,
)
from ww_core.domain.character_repo import CharacterWriteView
from ww_core.domain.character_repo import CharacterWriteFields, CharacterWriteView
from ww_core.domain.project_repo import ProjectCreate
from ww_core.domain.repositories import RuleView
from ww_core.domain.rule_repo import RuleListItemView
from ww_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage
from ww_shared import AppError, ErrorCode
from ww_skills import SkillRegistry
@@ -75,32 +75,80 @@ class _FakeCharacterWriteRepo:
name: str,
role: str,
traits: list[str],
appearance: str,
motive: str,
backstory: str,
arc: str,
speech_tics: list[str],
tags: list[Any],
relations: list[dict[str, Any]],
) -> CharacterWriteView:
self.rows.append(
# 幂等:按 (project_id, name) upsert——同名更新既有行而非插重复镜像 Sql 实现)。
existing = next(
(r for r in self.rows if r["project_id"] == project_id and r["name"] == name),
None,
)
if existing is not None:
existing.update(
{
"project_id": project_id,
"name": name,
"role": role,
"traits": list(traits),
"appearance": appearance,
"motive": motive,
"arc": arc,
"speech_tics": list(speech_tics),
"tags": list(tags),
"relations": [dict(r) for r in relations],
}
)
return CharacterWriteView(id=uuid.uuid4(), name=name, role=role)
return CharacterWriteView(id=existing["id"], name=name, role=role)
row_id = uuid.uuid4()
self.rows.append(
{
"id": row_id,
"project_id": project_id,
"name": name,
"role": role,
"traits": list(traits),
"appearance": appearance,
"motive": motive,
"arc": arc,
"speech_tics": list(speech_tics),
"tags": list(tags),
"relations": [dict(r) for r in relations],
}
)
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:
def __init__(self, rules: list[RuleView] | None = None) -> None:
"""实现规则 repo 的读侧 `list_for_project`(带 id供 list_rules 端点)。"""
def __init__(self, rules: list[RuleListItemView] | None = None) -> None:
self._rules = rules or []
async def all_for_project(self, project_id: uuid.UUID) -> list[RuleView]:
async def list_for_project(self, project_id: uuid.UUID) -> list[RuleListItemView]:
return list(self._rules)
@@ -170,7 +218,7 @@ def _make_app(
get_memory_repos,
get_precheck_gateway,
get_project_repo,
get_rules_read_repo,
get_rule_write_repo,
get_skill_registry,
get_worldbuilder_gateway,
)
@@ -190,7 +238,7 @@ def _make_app(
(lambda: memory) if memory is not None else _empty_memory_repos
)
app.dependency_overrides[get_character_write_repo] = lambda: char_repo
app.dependency_overrides[get_rules_read_repo] = lambda: rules_repo
app.dependency_overrides[get_rule_write_repo] = lambda: rules_repo
app.dependency_overrides[get_skill_registry] = lambda: registry
app.dependency_overrides[get_session] = lambda: session
gw = _raise_no_creds if no_creds else (lambda: gateway)
@@ -210,6 +258,16 @@ def _client(app: Any) -> httpx.AsyncClient:
return httpx.AsyncClient(transport=transport, base_url="http://test")
@pytest.mark.asyncio
async def test_list_rules_unknown_project_returns_404() -> None:
# QA MEDIUM 回归GET 规则列表对不存在 project 返 404原返误导性空 200掩盖坏 id
app, _ = _make_app(project_repo=FakeProjectRepo(), gateway=object())
async with _client(app) as client:
resp = await client.get(f"/projects/{uuid.uuid4()}/rules")
assert resp.status_code == 404
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
# ---- 世界观生成 ----
@@ -315,6 +373,39 @@ async def test_ingest_characters_no_conflict_writes_and_201() -> None:
assert session.commits == 1
@pytest.mark.asyncio
async def test_ingest_multiple_characters_all_land_in_one_batch() -> None:
# 批量入库:多张卡一次 create_many 全部落库created 保序,单次 commit。
repo = FakeProjectRepo()
pid = await _seed_project(repo)
gateway = _SchemaRoutingGateway({ContinuityReview: _no_conflicts()})
char_repo = _FakeCharacterWriteRepo()
app, session = _make_app(project_repo=repo, gateway=gateway, char_repo=char_repo)
def _card(name: str) -> dict[str, Any]:
return {
"name": name,
"role": "配角",
"traits": [],
"backstory": "x",
"arc": "y",
"speech_tics": [],
"tags": [],
"relations": [],
}
async with _client(app) as client:
resp = await client.post(
f"/projects/{pid}/characters",
json={"cards": [_card(""), _card(""), _card("")]},
)
assert resp.status_code == 201
assert resp.json()["created"] == ["", "", ""]
assert [r["name"] for r in char_repo.rows] == ["", "", ""]
assert session.commits == 1
@pytest.mark.asyncio
async def test_ingest_characters_with_conflict_blocks_409() -> None:
repo = FakeProjectRepo()
@@ -360,6 +451,174 @@ async def test_ingest_characters_acknowledged_conflict_writes() -> None:
assert len(char_repo.rows) == 1
@pytest.mark.asyncio
async def test_ingest_same_name_twice_updates_not_duplicates() -> None:
# Arrange同一项目、同名角色入库两次第二次字段不同。
repo = FakeProjectRepo()
pid = await _seed_project(repo)
gateway = _SchemaRoutingGateway({ContinuityReview: _no_conflicts()})
char_repo = _FakeCharacterWriteRepo()
app, _ = _make_app(project_repo=repo, gateway=gateway, char_repo=char_repo)
def _payload(role: str, trait: str) -> dict[str, Any]:
return {
"cards": [
{
"name": "叶寒",
"role": role,
"traits": [trait],
"backstory": "孤儿",
"arc": "成长",
"speech_tics": [],
"tags": [],
"relations": [],
}
]
}
async with _client(app) as client:
# Act先入主角/腹黑,再以同名入对手/隐忍。
first = await client.post(f"/projects/{pid}/characters", json=_payload("主角", "腹黑"))
second = await client.post(f"/projects/{pid}/characters", json=_payload("对手", "隐忍"))
# Assert仍是一行按 name upsert字段被更新而非新增重复。
assert first.status_code == 201
assert second.status_code == 201
assert len(char_repo.rows) == 1
assert char_repo.rows[0]["role"] == "对手"
assert char_repo.rows[0]["traits"] == ["隐忍"]
@pytest.mark.asyncio
async def test_ingest_relations_persist_on_write() -> None:
# Arrange入库带关系网的角色卡。
repo = FakeProjectRepo()
pid = await _seed_project(repo)
gateway = _SchemaRoutingGateway({ContinuityReview: _no_conflicts()})
char_repo = _FakeCharacterWriteRepo()
app, _ = _make_app(project_repo=repo, gateway=gateway, char_repo=char_repo)
relations = [{"name": "苏离", "kind": "宿敌", "note": "灭门之仇"}]
async with _client(app) as client:
resp = await client.post(
f"/projects/{pid}/characters",
json={
"cards": [
{
"name": "叶寒",
"role": "主角",
"traits": ["腹黑"],
"backstory": "孤儿",
"arc": "成长",
"speech_tics": [],
"tags": [],
"relations": relations,
}
]
},
)
# Assertrelations 持久化(写侧未丢)。
assert resp.status_code == 201
assert char_repo.rows[0]["relations"] == relations
@pytest.mark.asyncio
async def test_ingest_persists_appearance_and_motive() -> None:
# ⑧ 穿线:入库端点须把 appearance/motive 透传给写侧 repo否则新列永远 NULL
repo = FakeProjectRepo()
pid = await _seed_project(repo)
gateway = _SchemaRoutingGateway({ContinuityReview: _no_conflicts()})
char_repo = _FakeCharacterWriteRepo()
app, _ = _make_app(project_repo=repo, gateway=gateway, char_repo=char_repo)
async with _client(app) as client:
resp = await client.post(
f"/projects/{pid}/characters",
json={
"cards": [
{
"name": "苏璃",
"role": "CP",
"traits": ["清冷毒舌"],
"appearance": "银发赤瞳,玄色劲装",
"motive": "替亡兄查清族灭真相",
"backstory": "玄霜阁遗孤",
"arc": "由敌转盟",
"speech_tics": [],
"tags": [],
"relations": [],
}
]
},
)
assert resp.status_code == 201
assert char_repo.rows[0]["appearance"] == "银发赤瞳,玄色劲装"
assert char_repo.rows[0]["motive"] == "替亡兄查清族灭真相"
def test_card_to_view_roundtrips_new_fields() -> None:
# ⑧ 穿线schema↔view 双向形变须带上 appearance/motive否则前后端静默丢字段
from ww_api.routers.generation import _card_to_view, _view_to_card
card = CharacterCard(
name="苏璃",
role="CP",
traits=["清冷毒舌"],
appearance="银发赤瞳,玄色劲装",
motive="替亡兄查清族灭真相",
backstory="玄霜阁遗孤",
arc="由敌转盟",
)
view = _card_to_view(card)
assert view.appearance == "银发赤瞳,玄色劲装"
assert view.motive == "替亡兄查清族灭真相"
back = _view_to_card(view)
assert back.appearance == card.appearance
assert back.motive == card.motive
def test_relations_from_jsonb_parses_and_skips_dirty() -> None:
# Arrange混入脏条目非 dict / 缺 name / 缺 kind
from ww_api.routers.generation import _relations_from_jsonb
raw = [
{"name": "苏离", "kind": "宿敌", "note": "灭门之仇"},
{"name": "无类型"}, # 缺 kind → 跳过
{"kind": "无名"}, # 缺 name → 跳过
"not-a-dict", # 非 dict → 跳过
{"name": "墨白", "kind": "师徒"}, # note 可缺
]
# Act
out = _relations_from_jsonb(raw)
# Assert只保留有效两条note 缺为 None。
assert [(r.name, r.kind, r.note) for r in out] == [
("苏离", "宿敌", "灭门之仇"),
("墨白", "师徒", None),
]
@pytest.mark.asyncio
async def test_list_characters_includes_relations() -> None:
# Arrange读侧 memory view 带 relationsJSONB list
repo = FakeProjectRepo()
pid = await _seed_project(repo)
app, _ = _make_app(
project_repo=repo,
gateway=_SchemaRoutingGateway({}),
memory=_codex_memory_with_relations(),
)
async with _client(app) as client:
resp = await client.get(f"/projects/{pid}/characters")
# Assert读端点不再丢 relations修 #7
assert resp.status_code == 200
card = resp.json()["characters"][0]
assert card["relations"] == [{"name": "苏离", "kind": "宿敌", "note": "灭门之仇"}]
# ---- 读端点 ----
@@ -367,10 +626,11 @@ async def test_ingest_characters_acknowledged_conflict_writes() -> None:
async def test_list_rules_returns_rules() -> None:
repo = FakeProjectRepo()
pid = await _seed_project(repo)
rid1, rid2 = uuid.uuid4(), uuid.uuid4()
rules_repo = _FakeRulesReadRepo(
[
RuleView(level="project", content="主角不复活"),
RuleView(level="global", content="无脏话"),
RuleListItemView(id=rid1, level="project", content="主角不复活"),
RuleListItemView(id=rid2, level="global", content="无脏话"),
]
)
gateway = _SchemaRoutingGateway({})
@@ -382,6 +642,8 @@ async def test_list_rules_returns_rules() -> None:
assert resp.status_code == 200
rules = resp.json()["rules"]
assert [r["content"] for r in rules] == ["主角不复活", "无脏话"]
# 列表带稳定 id前端删除 handle
assert [r["id"] for r in rules] == [str(rid1), str(rid2)]
@pytest.mark.asyncio
@@ -426,6 +688,8 @@ def _codex_memory() -> Any:
name="叶寒",
role="主角",
traits={"items": ["腹黑", "护短"]},
appearance="剑眉星目,一袭青衫",
motive="为母复仇、登临帝位",
backstory="孤儿出身",
arc={"text": "从孤儿到帝王"},
speech_tics={"items": [""]},
@@ -456,6 +720,53 @@ def _codex_memory() -> Any:
)
def _codex_memory_with_relations() -> Any:
"""MemoryRepos角色行携 relationsJSONB list验读端点还原关系网修 #7"""
from test_projects import (
_EmptyDigestRepo,
_EmptyForeshadowRepo,
_EmptyOutlineRepo,
_EmptyRulesRepo,
_EmptyStyleRepo,
_StubProjectSpecRepo,
)
from ww_core.domain.repositories import (
CharacterView,
MemoryRepos,
WorldEntityView,
)
class _CharRepo:
async def list_for_project(self, project_id: uuid.UUID) -> list[CharacterView]:
return [
CharacterView(
name="叶寒",
role="主角",
traits={"items": ["腹黑"]},
backstory="孤儿出身",
arc={"text": "成长"},
speech_tics={"items": []},
tags=[],
relations=[{"name": "苏离", "kind": "宿敌", "note": "灭门之仇"}],
)
]
class _WorldRepo:
async def list_for_project(self, project_id: uuid.UUID) -> list[WorldEntityView]:
return []
return MemoryRepos(
outline=_EmptyOutlineRepo(),
character=_CharRepo(),
world_entity=_WorldRepo(),
digest=_EmptyDigestRepo(),
foreshadow=_EmptyForeshadowRepo(),
style=_EmptyStyleRepo(),
rules=_EmptyRulesRepo(),
project=_StubProjectSpecRepo(),
)
@pytest.mark.asyncio
async def test_list_characters_unpacks_jsonb_to_api_shape() -> None:
repo = FakeProjectRepo()
@@ -475,6 +786,9 @@ async def test_list_characters_unpacks_jsonb_to_api_shape() -> None:
assert card["speech_tics"] == [""]
assert card["arc"] == "从孤儿到帝王"
assert card["tags"] == ["天才"]
# ⑧ 穿线:读端点暴露 appearance/motiveText 列直落,不形变)。
assert card["appearance"] == "剑眉星目,一袭青衫"
assert card["motive"] == "为母复仇、登临帝位"
@pytest.mark.asyncio

View File

@@ -0,0 +1,51 @@
"""CharacterGenerateRequest.role_mix 校验单测(⑦ 群像按定位配比)。
纯 pydantic 校验,无 DB/网关。契约role_mix 在场时 count 取各值之和,
每项数量须 ≥ 1、总和须 ≤ MAX_GENERATE_COUNT。
"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from ww_api.schemas.generation import MAX_GENERATE_COUNT, CharacterGenerateRequest
def test_role_mix_absent_keeps_explicit_count() -> None:
req = CharacterGenerateRequest(brief="剑修", count=3)
assert req.role_mix is None
assert req.count == 3
def test_role_mix_sets_count_to_sum() -> None:
req = CharacterGenerateRequest(brief="群像", role_mix={"主角": 1, "对手": 2, "配角": 3})
assert req.count == 6
def test_role_mix_overrides_provided_count() -> None:
# role_mix 在场时 count 取各值之和,忽略客户端显式传入的 count
req = CharacterGenerateRequest(brief="群像", count=1, role_mix={"主角": 2, "对手": 2})
assert req.count == 4
def test_role_mix_total_at_max_allowed() -> None:
req = CharacterGenerateRequest(brief="群像", role_mix={"主角": MAX_GENERATE_COUNT})
assert req.count == MAX_GENERATE_COUNT
def test_role_mix_sum_over_max_rejected() -> None:
with pytest.raises(ValidationError):
# 6 + 7 = 13 > 12
CharacterGenerateRequest(brief="群像", role_mix={"主角": 6, "对手": 7})
def test_role_mix_empty_dict_rejected() -> None:
with pytest.raises(ValidationError):
CharacterGenerateRequest(brief="群像", role_mix={})
def test_role_mix_nonpositive_value_rejected() -> None:
with pytest.raises(ValidationError):
CharacterGenerateRequest(brief="群像", role_mix={"主角": 0})
with pytest.raises(ValidationError):
CharacterGenerateRequest(brief="群像", role_mix={"主角": -1})

View File

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

View File

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

View File

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

View File

@@ -239,6 +239,50 @@ async def test_get_outline_returns_persisted_chapters_in_order_with_unpacked_bea
assert body["chapters"][1]["beats"] == ["冲突升级"]
@pytest.mark.asyncio
async def test_get_outline_with_volume_filters_to_that_volume() -> None:
# ?volume=N 只返回该卷章节;无该参数返回全部(向后兼容)。
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
read_repo = FakeOutlineReadRepo()
read_repo.add_chapter(pid, volume=1, chapter_no=1, beats=["卷一·开篇"])
read_repo.add_chapter(pid, volume=1, chapter_no=2, beats=["卷一·冲突"])
read_repo.add_chapter(pid, volume=2, chapter_no=3, beats=["卷二·新篇"])
client = _make_read_client(project_repo=project_repo, outline_read_repo=read_repo)
async with client:
all_resp = await client.get(f"/projects/{pid}/outline")
vol2_resp = await client.get(f"/projects/{pid}/outline?volume=2")
# 不带 volume → 全部三章。
assert all_resp.status_code == 200
assert [c["no"] for c in all_resp.json()["chapters"]] == [1, 2, 3]
# ?volume=2 → 仅卷二的第 3 章。
assert vol2_resp.status_code == 200
vol2_chapters = vol2_resp.json()["chapters"]
assert [c["no"] for c in vol2_chapters] == [3]
assert all(c["volume"] == 2 for c in vol2_chapters)
@pytest.mark.asyncio
async def test_outline_repo_volume_filter_pushed_into_query() -> None:
# 卷过滤下推到 repoDB WHERE而非端点 Python 侧过滤:直接调 repo 断言只回该卷。
repo = FakeOutlineReadRepo()
pid = uuid.uuid4()
repo.add_chapter(pid, volume=1, chapter_no=1, beats=["卷一·1"])
repo.add_chapter(pid, volume=1, chapter_no=2, beats=["卷一·2"])
repo.add_chapter(pid, volume=2, chapter_no=3, beats=["卷二·3"])
vol1 = await repo.list_for_project(pid, volume=1)
vol2 = await repo.list_for_project(pid, volume=2)
all_rows = await repo.list_for_project(pid)
assert [v.chapter_no for v in vol1] == [1, 2]
assert all(v.volume == 1 for v in vol1)
assert [v.chapter_no for v in vol2] == [3]
assert [v.chapter_no for v in all_rows] == [1, 2, 3]
@pytest.mark.asyncio
async def test_get_outline_returns_empty_list_when_no_outline() -> None:
project_repo = FakeProjectRepo()

View File

@@ -0,0 +1,197 @@
"""AI 立项方案生成端点测试(灵感⑤;内存替身,无 DB/无网络)。
覆盖 `POST /skills/project-plan/generate`(不带 project 前缀):
- 无既有 project 也能生成(向导阶段 project 未建)→ 200 结构化预览 + 落 ledger。
- 种子门控:缺 genre / logline → 422不触达网关、不 commit。
- 无凭据 → 503。
- 向导草稿序列化进网关 context种子 + 已填字段透传)。
LLM 一律 mock绝不联网。
"""
from __future__ import annotations
import os
from typing import Any
import httpx
import pytest
from cryptography.fernet import Fernet
from fakes_projects import FakeSession
from ww_agents import ProjectPlanResult
from ww_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage
from ww_shared import AppError, ErrorCode
class _CaptureGateway:
"""记录最近一次 req.input验证向导草稿进了 context返固定 ProjectPlanResult。"""
def __init__(self, parsed: ProjectPlanResult) -> None:
self._parsed = parsed
self.last_input: str | None = None
self.calls = 0
async def run(self, req: LlmRequest) -> LlmResponse:
self.calls += 1
self.last_input = req.input if isinstance(req.input, str) else str(req.input)
return LlmResponse(
text=self._parsed.model_dump_json(),
parsed=self._parsed,
usage=Usage(
provider="fake",
model="fake",
input_tokens=1,
output_tokens=1,
cost_minor=0,
currency="USD",
),
served_by=ServedBy(provider="fake", model="fake"),
)
def _plan() -> ProjectPlanResult:
return ProjectPlanResult(
title_candidates=["逐光而行", "剑试九霄"],
setting="上古仙门世界",
narrative_structure="黄金三章立钩 → 升级",
story_core="废柴逆袭封神的代价",
ending_design="归隐收束(正剧)",
tone="热血",
)
def _make_app(
*,
gateway: Any,
session: FakeSession | None = None,
no_creds: bool = False,
) -> tuple[Any, FakeSession]:
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_project_plan_gateway
from ww_db import get_session
session = session or FakeSession()
async def _ok() -> Any:
return gateway
async def _no_creds() -> Any:
raise AppError(ErrorCode.LLM_UNAVAILABLE, "未配置凭据", {"provider": "deepseek"})
app = create_app()
app.dependency_overrides[get_project_plan_gateway] = _no_creds if no_creds else _ok
app.dependency_overrides[get_session] = lambda: session
return app, session
def _client(app: Any) -> 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_generate_without_existing_project() -> None:
# 向导阶段 project 未建:无 project 前缀端点直接生成,返结构化方案 + 落 ledger。
gateway = _CaptureGateway(_plan())
app, session = _make_app(gateway=gateway)
async with _client(app) as client:
resp = await client.post(
"/skills/project-plan/generate",
json={"genre": "仙侠", "logline": "废柴少年觉醒禁忌血脉,逆势封神。"},
)
assert resp.status_code == 200
body = resp.json()
assert body["title_candidates"] == ["逐光而行", "剑试九霄"]
assert body["story_core"] == "废柴逆袭封神的代价"
assert body["ending_design"].endswith("(正剧)")
assert body["tone"] == "热血"
assert session.commits == 1 # 预览不写业务表,但落 ledger
@pytest.mark.asyncio
async def test_generate_serializes_wizard_draft_into_context() -> None:
# 种子 + 已填字段序列化进网关 context有则贴合
gateway = _CaptureGateway(_plan())
app, _ = _make_app(gateway=gateway)
async with _client(app) as client:
resp = await client.post(
"/skills/project-plan/generate",
json={
"genre": "都市",
"logline": "外卖骑手觉醒读心术。",
"title": "听风者",
"tone": "轻松",
"selling_points": ["逆袭", "系统流"],
"brief": "偏悬疑向",
},
)
assert resp.status_code == 200
assert gateway.last_input is not None
assert "题材:都市" in gateway.last_input
assert "一句话故事:外卖骑手觉醒读心术。" in gateway.last_input
assert "暂定书名:听风者" in gateway.last_input
assert "核心卖点:逆袭、系统流" in gateway.last_input
assert "偏悬疑向" in gateway.last_input # brief 进 context
@pytest.mark.asyncio
async def test_requires_seed_missing_logline_422() -> None:
# 种子门控:缺 logline → 422不触达网关、不 commit。
gateway = _CaptureGateway(_plan())
app, session = _make_app(gateway=gateway)
async with _client(app) as client:
resp = await client.post("/skills/project-plan/generate", json={"genre": "仙侠"})
assert resp.status_code == 422
assert resp.json()["error"]["code"] == ErrorCode.VALIDATION
assert gateway.calls == 0
assert session.commits == 0
@pytest.mark.asyncio
async def test_requires_seed_missing_genre_422() -> None:
gateway = _CaptureGateway(_plan())
app, session = _make_app(gateway=gateway)
async with _client(app) as client:
resp = await client.post("/skills/project-plan/generate", json={"logline": "一句话故事。"})
assert resp.status_code == 422
assert gateway.calls == 0
assert session.commits == 0
@pytest.mark.asyncio
async def test_requires_seed_blank_strings_422() -> None:
# 纯空白种子也拒strip 后为空)。
gateway = _CaptureGateway(_plan())
app, session = _make_app(gateway=gateway)
async with _client(app) as client:
resp = await client.post(
"/skills/project-plan/generate", json={"genre": " ", "logline": " "}
)
assert resp.status_code == 422
assert gateway.calls == 0
assert session.commits == 0
@pytest.mark.asyncio
async def test_generate_no_credentials_503() -> None:
app, session = _make_app(gateway=object(), no_creds=True)
async with _client(app) as client:
resp = await client.post(
"/skills/project-plan/generate",
json={"genre": "仙侠", "logline": "一句话故事。"},
)
assert resp.status_code == 503
assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE
assert session.commits == 0

View File

@@ -10,7 +10,16 @@ import uuid
import httpx
import pytest
from cryptography.fernet import Fernet
from fakes_projects import FakeChapterRepo, FakeProjectRepo, FakeWriterGateway
from fakes_projects import (
FakeChapterRepo,
FakeProjectRepo,
FakeReviewGateway,
FakeSession,
FakeWriterGateway,
)
from fastapi import FastAPI
from ww_api.services.credentials import STUB_OWNER_ID
from ww_core.domain.project_repo import ProjectView
from ww_core.domain.repositories import (
CharacterView,
DigestView,
@@ -29,7 +38,9 @@ class _EmptyOutlineRepo:
async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None:
return None
async def list_for_project(self, project_id: uuid.UUID) -> list[OutlineView]:
async def list_for_project(
self, project_id: uuid.UUID, *, volume: int | None = None
) -> list[OutlineView]:
return []
@@ -122,6 +133,16 @@ def _make_client(
return client, project_repo, chapter_repo, gateway
def _seed_project(project_repo: FakeProjectRepo) -> uuid.UUID:
"""Seed 一个属于 STUB_OWNER_ID 的项目,返回其 pid。
stream_draft 现先校验项目存在QA C1流式用例须用已存在项目否则 404。
"""
pid = uuid.uuid4()
project_repo.rows[pid] = (STUB_OWNER_ID, ProjectView(id=pid, title="测试"))
return pid
@pytest.mark.asyncio
async def test_create_project_returns_201() -> None:
client, _, _, _ = _make_client()
@@ -138,6 +159,54 @@ async def test_create_project_returns_201() -> None:
assert uuid.UUID(body["id"])
@pytest.mark.asyncio
async def test_create_project_persists_tone_ending_pov() -> None:
# ④ 立项新增基调/结局/视角:请求 → repo 落库 → 响应回显snake_case 契约)。
client, repo, _, _ = _make_client()
async with client:
resp = await client.post(
"/projects",
json={
"title": "基调作品",
"tone": "热血",
"ending_type": "HE",
"narrative_pov": "第一人称",
},
)
assert resp.status_code == 201
body = resp.json()
assert body["tone"] == "热血"
assert body["ending_type"] == "HE"
assert body["narrative_pov"] == "第一人称"
(_owner, view) = next(iter(repo.rows.values()))
assert view.tone == "热血"
assert view.ending_type == "HE"
assert view.narrative_pov == "第一人称"
@pytest.mark.asyncio
async def test_get_project_exposes_tone_ending_pov() -> None:
client, _, _, _ = _make_client()
async with client:
created = (
await client.post(
"/projects",
json={
"title": "",
"tone": "沉重",
"ending_type": "BE",
"narrative_pov": "多视角",
},
)
).json()
resp = await client.get(f"/projects/{created['id']}")
assert resp.status_code == 200
body = resp.json()
assert body["tone"] == "沉重"
assert body["ending_type"] == "BE"
assert body["narrative_pov"] == "多视角"
@pytest.mark.asyncio
async def test_list_projects() -> None:
client, repo, _, _ = _make_client()
@@ -146,8 +215,38 @@ async def test_list_projects() -> None:
await client.post("/projects", json={"title": ""})
resp = await client.get("/projects")
assert resp.status_code == 200
titles = {p["title"] for p in resp.json()["projects"]}
projects = resp.json()["projects"]
titles = {p["title"] for p in projects}
assert titles == {"", ""}
assert all("updated_at" in p 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
@@ -157,7 +256,10 @@ async def test_get_project_detail() -> None:
created = (await client.post("/projects", json={"title": "详情"})).json()
resp = await client.get(f"/projects/{created['id']}")
assert resp.status_code == 200
assert resp.json()["title"] == "详情"
body = resp.json()
assert body["title"] == "详情"
assert "updated_at" in body
assert body["pending_review_count"] == 0
@pytest.mark.asyncio
@@ -180,8 +282,8 @@ async def test_create_project_rejects_blank_title() -> None:
@pytest.mark.asyncio
async def test_draft_stream_yields_sse_tokens_and_done() -> None:
gateway = FakeWriterGateway(chunks=["阿福", "走进门。"])
client, _, _, _ = _make_client(gateway=gateway)
pid = uuid.uuid4()
client, project_repo, _, _ = _make_client(gateway=gateway)
pid = _seed_project(project_repo)
async with client:
resp = await client.post(f"/projects/{pid}/chapters/1/draft")
assert resp.status_code == 200
@@ -200,8 +302,8 @@ async def test_draft_stream_yields_sse_tokens_and_done() -> None:
@pytest.mark.asyncio
async def test_draft_stream_threads_directive_into_volatile() -> None:
gateway = FakeWriterGateway(chunks=["正文"])
client, _, _, _ = _make_client(gateway=gateway)
pid = uuid.uuid4()
client, project_repo, _, _ = _make_client(gateway=gateway)
pid = _seed_project(project_repo)
async with client:
resp = await client.post(
f"/projects/{pid}/chapters/1/draft", json={"directive": "多写战斗"}
@@ -215,8 +317,8 @@ async def test_draft_stream_threads_directive_into_volatile() -> None:
@pytest.mark.asyncio
async def test_draft_stream_backward_compatible_without_body() -> None:
gateway = FakeWriterGateway(chunks=["正文"])
client, _, _, _ = _make_client(gateway=gateway)
pid = uuid.uuid4()
client, project_repo, _, _ = _make_client(gateway=gateway)
pid = _seed_project(project_repo)
async with client:
resp = await client.post(f"/projects/{pid}/chapters/1/draft")
assert resp.status_code == 200
@@ -229,8 +331,8 @@ async def test_draft_stream_maps_error_to_sse_error_event() -> None:
from ww_shared import AppError
gateway = FakeWriterGateway(chunks=["半段"], error=AppError(ErrorCode.LLM_UNAVAILABLE, "boom"))
client, _, _, _ = _make_client(gateway=gateway)
pid = uuid.uuid4()
client, project_repo, _, _ = _make_client(gateway=gateway)
pid = _seed_project(project_repo)
async with client:
resp = await client.post(f"/projects/{pid}/chapters/1/draft")
assert resp.status_code == 200
@@ -240,6 +342,18 @@ async def test_draft_stream_maps_error_to_sse_error_event() -> None:
assert ErrorCode.LLM_UNAVAILABLE in text
@pytest.mark.asyncio
async def test_draft_stream_unknown_project_returns_404_without_calling_gateway() -> None:
# QA C1 回归:对不存在的 project 流式写章必须 404且绝不触网关不烧 LLM 调用)。
gateway = FakeWriterGateway(chunks=["不该被生成"])
client, _project_repo, _, _ = _make_client(gateway=gateway)
async with client:
resp = await client.post(f"/projects/{uuid.uuid4()}/chapters/1/draft")
assert resp.status_code == 404
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
assert len(gateway.requests) == 0 # 未触达网关
@pytest.mark.asyncio
async def test_put_draft_is_idempotent() -> None:
chapter_repo = FakeChapterRepo()
@@ -283,3 +397,191 @@ async def test_get_draft_404_when_no_draft() -> None:
resp = await client.get(f"/projects/{pid}/chapters/9/draft")
assert resp.status_code == 404
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
# ---- POST /rewrite/clarify整章重写预检澄清WFW-9 M2----
# 摘录上界外的哨兵:喂超长 prior_draft断言超界正文不进网关 input不喂全章、不泄漏
_LEAK_SENTINEL = "泄漏哨兵段落禁止外泄"
def _need_clarification_decision() -> object:
from ww_agents import ClarifyDecision, ClarifyOption, ClarifyQuestion
return ClarifyDecision(
need_clarification=True,
questions=[
ClarifyQuestion(
question="你说本章节奏太慢,是想往哪个方向重写整章?",
options=[
ClarifyOption(label="砍支线压缩篇幅", value="删掉次要支线,压缩铺陈直入主线"),
ClarifyOption(label="提前引爆冲突", value="把本章高潮冲突提前到前半段"),
],
allow_free_text=True,
)
],
)
def _clear_rewrite_decision() -> object:
from ww_agents import ClarifyDecision
return ClarifyDecision(
need_clarification=False,
verification="我会把本章高潮提前、删掉开头回忆插叙,直接进冲突,对吗?",
)
def _clarify_app(
*,
project_repo: FakeProjectRepo,
session: FakeSession | None = None,
clarify_gateway: object | None = None,
) -> FastAPI:
"""构造只挂 rewrite/clarify 端点依赖的 appproject_repo + session + clarify 网关)。"""
import os
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
from ww_api.main import create_app
from ww_api.services.project_deps import get_clarify_gateway, get_project_repo
from ww_db import get_session
app = create_app()
app.dependency_overrides[get_project_repo] = lambda: project_repo
app.dependency_overrides[get_session] = lambda: session or FakeSession()
if clarify_gateway is not None:
app.dependency_overrides[get_clarify_gateway] = lambda: clarify_gateway
return app
def _clarify_client(app: FastAPI) -> httpx.AsyncClient:
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
return httpx.AsyncClient(transport=transport, base_url="http://test")
@pytest.mark.asyncio
async def test_rewrite_clarify_vague_feedback_asks_one_question_and_commits() -> None:
# (a) 含糊章级意见 → need_clarification True + 恰 1 问analyst 档 + 末尾 commit 落账。
project_repo = FakeProjectRepo()
pid = _seed_project(project_repo)
session = FakeSession()
gateway = FakeReviewGateway(parsed=_need_clarification_decision())
app = _clarify_app(project_repo=project_repo, session=session, clarify_gateway=gateway)
async with _clarify_client(app) as client:
resp = await client.post(
f"/projects/{pid}/chapters/3/rewrite/clarify",
json={"feedback": "节奏太慢", "prior_draft": "本章开头……"},
)
assert resp.status_code == 200
body = resp.json()
assert body["need_clarification"] is True
assert len(body["questions"]) == 1
assert body["questions"][0]["options"][0]["label"] == "砍支线压缩篇幅"
assert body["questions"][0]["allow_free_text"] is True
# 不变量 #2analyst 档;意见折进网关 input末尾一次 commit网关 usage_ledger 落库)。
assert gateway.requests[0].tier == "analyst"
assert "节奏太慢" in str(gateway.requests[0].input)
assert session.commits == 1
@pytest.mark.asyncio
async def test_rewrite_clarify_clear_feedback_proceeds_read_only() -> None:
# (b) 明确意见 → need_clarification False + verification只读、无回滚。
project_repo = FakeProjectRepo()
pid = _seed_project(project_repo)
session = FakeSession()
gateway = FakeReviewGateway(parsed=_clear_rewrite_decision())
app = _clarify_app(project_repo=project_repo, session=session, clarify_gateway=gateway)
async with _clarify_client(app) as client:
resp = await client.post(
f"/projects/{pid}/chapters/3/rewrite/clarify",
json={"feedback": "把本章高潮提前到前半段,删掉开头回忆插叙。"},
)
assert resp.status_code == 200
body = resp.json()
assert body["need_clarification"] is False
assert body["questions"] == []
assert body["verification"] is not None
# 只读不写业务表:仅一次 commit 落网关 usage无回滚不变量 #3
assert session.commits == 1
assert session.rollbacks == 0
@pytest.mark.asyncio
async def test_rewrite_clarify_unknown_project_404() -> None:
# (c) 项目不存在 → 404触网关前 fail-fast
app = _clarify_app(
project_repo=FakeProjectRepo(),
clarify_gateway=FakeReviewGateway(parsed=_clear_rewrite_decision()),
)
async with _clarify_client(app) as client:
resp = await client.post(
f"/projects/{uuid.uuid4()}/chapters/1/rewrite/clarify",
json={"feedback": "节奏太慢"},
)
assert resp.status_code == 404
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
@pytest.mark.asyncio
async def test_rewrite_clarify_fails_open_on_wrong_parsed() -> None:
# (d) 网关返回非 ClarifyDecision → 端点仍 200 + need_clarification False韧性回退非 500
from ww_agents import WorldGenResult
project_repo = FakeProjectRepo()
pid = _seed_project(project_repo)
session = FakeSession()
gateway = FakeReviewGateway(parsed=WorldGenResult(entities=[]))
app = _clarify_app(project_repo=project_repo, session=session, clarify_gateway=gateway)
async with _clarify_client(app) as client:
resp = await client.post(
f"/projects/{pid}/chapters/3/rewrite/clarify",
json={"feedback": "重写一遍"},
)
assert resp.status_code == 200
assert resp.json()["need_clarification"] is False
@pytest.mark.asyncio
async def test_rewrite_clarify_empty_feedback_422() -> None:
project_repo = FakeProjectRepo()
pid = _seed_project(project_repo)
app = _clarify_app(
project_repo=project_repo,
clarify_gateway=FakeReviewGateway(parsed=_clear_rewrite_decision()),
)
async with _clarify_client(app) as client:
resp = await client.post(
f"/projects/{pid}/chapters/1/rewrite/clarify", json={"feedback": ""}
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_rewrite_clarify_bounds_excerpt_and_never_leaks_manuscript() -> None:
# 有界摘录:超长 prior_draft 的越界正文不进网关 input响应也绝不回灌正文脱敏
project_repo = FakeProjectRepo()
pid = _seed_project(project_repo)
session = FakeSession()
gateway = FakeReviewGateway(parsed=_clear_rewrite_decision())
app = _clarify_app(project_repo=project_repo, session=session, clarify_gateway=gateway)
# 哨兵放在远超摘录上界处10k 填充 + 哨兵),确保被截断在摘录外。
prior_draft = ("" * 10_000) + _LEAK_SENTINEL
async with _clarify_client(app) as client:
resp = await client.post(
f"/projects/{pid}/chapters/3/rewrite/clarify",
json={"feedback": "重写本章", "prior_draft": prior_draft},
)
assert resp.status_code == 200
# 越界正文既不进 LLM 输入,也不出现在响应体(正文只喂有界摘录、绝不回灌)。
assert _LEAK_SENTINEL not in str(gateway.requests[0].input)
assert _LEAK_SENTINEL not in resp.text

View File

@@ -209,6 +209,70 @@ async def test_accept_promotes_appends_digest_records_decisions() -> None:
assert session.commits == 1
@pytest.mark.asyncio
async def test_characterization_does_not_block_accept() -> None:
"""灵感③ advisory人物塑造问题零 continuity 冲突不阻断验收D2
留痕带 characterization issues 但 conflicts=[] → 验收 gate 只读 continuity conflicts
直通晋升characterization 从不进 conflicts、从不产 decisions。
"""
from ww_api.services.digest_extraction import ChapterDigestFacts
review_repo = FakeReviewRepo()
pid = uuid.uuid4()
# 零 continuity 冲突,但有 advisory 人物塑造问题。
await review_repo.record(
pid,
1,
conflicts=[],
characterization={
"issues": [
{
"character": "沈砚",
"aspect": "动机一致性",
"where": "第3段",
"quote": "他忽然转身就走。",
"diagnosis": "与既定护妹动机断裂",
"suggestion": "退缩扣回护妹动机",
"confidence": 0.82,
}
]
},
)
digest_gw = FakeReviewGateway(parsed=ChapterDigestFacts(summary="s"))
client, chapter_repo, _, digest_repo, session = _make_client(
review_repo=review_repo, digest_gateway=digest_gw
)
async with client:
resp = await client.post(
f"/projects/{pid}/chapters/1/accept",
# 无需对人物塑造做任何裁决advisory 无 conflict 通道)。
json={"final_text": "作者终稿正文。", "decisions": []},
)
assert resp.status_code == 200
assert resp.json()["accepted_version"] == 1
assert session.commits == 1
# characterization 留痕不产 decisionsadvisory晋升照常。
accepted = await chapter_repo.latest_accepted(pid, 1)
assert accepted is not None
@pytest.mark.asyncio
async def test_characterization_surfaces_in_review_history() -> None:
"""列 characterization 经 GET .../reviews 透传到历史项(前端面板 seed 用)。"""
review_repo = FakeReviewRepo()
pid = uuid.uuid4()
await review_repo.record(
pid, 1, conflicts=[], characterization={"issues": [{"character": "沈砚"}]}
)
client, _, _, _, _ = _make_client(review_repo=review_repo)
async with client:
resp = await client.get(f"/projects/{pid}/chapters/1/reviews")
assert resp.status_code == 200
row = resp.json()["reviews"][0]
assert row["characterization"]["issues"][0]["character"] == "沈砚"
@pytest.mark.asyncio
async def test_accept_with_no_review_succeeds() -> None:
"""无审稿留痕(零冲突)→ gate 直通,仍晋升 + 提炼 digest。"""

View File

@@ -3,7 +3,8 @@
覆盖:
- 201 + 回显 level/content + 端点 commit
- 非法 level → 422Pydantic Literal 校验FastAPI 422
- 空 content → 422
- 空 content → 422
- 项目不存在 → 404QA H2 回归:此前 FK 违例逃逸成 500
"""
from __future__ import annotations
@@ -13,8 +14,10 @@ import uuid
import httpx
import pytest
from cryptography.fernet import Fernet
from fakes_projects import FakeSession
from ww_core.domain.rule_repo import RuleWriteView
from fakes_projects import FakeProjectRepo, FakeSession
from ww_api.services.credentials import STUB_OWNER_ID
from ww_core.domain.project_repo import ProjectView
from ww_core.domain.rule_repo import RuleListItemView, RuleWriteView
class _FakeRuleWriteRepo:
@@ -22,34 +25,56 @@ class _FakeRuleWriteRepo:
self.rows: list[RuleWriteView] = []
async def create(self, project_id: uuid.UUID, *, level: str, content: str) -> RuleWriteView:
view = RuleWriteView(project_id=project_id, level=level, content=content)
view = RuleWriteView(id=uuid.uuid4(), project_id=project_id, level=level, content=content)
self.rows.append(view)
return view
async def list_for_project(self, project_id: uuid.UUID) -> list[RuleListItemView]:
return [
RuleListItemView(id=r.id, level=r.level, content=r.content)
for r in self.rows
if r.project_id in (project_id, None)
]
def _make_client() -> tuple[httpx.AsyncClient, _FakeRuleWriteRepo, FakeSession]:
async def delete(self, project_id: uuid.UUID, rule_id: uuid.UUID) -> bool:
before = len(self.rows)
self.rows = [r for r in self.rows if not (r.id == rule_id and r.project_id == project_id)]
return len(self.rows) < before
def _make_client() -> tuple[
httpx.AsyncClient, _FakeRuleWriteRepo, FakeSession, FakeProjectRepo, uuid.UUID
]:
"""构建测试 client并 seed 一个属于 STUB_OWNER_ID 的项目;返回其 pid。
create_rule 现在先校验项目存在QA H2故必须 override get_project_repo 并 seed
否则正常用例会 404。返回的 pid 是已存在项目;未 seed 的随机 pid 即"不存在"
"""
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_rule_write_repo
from ww_api.services.project_deps import get_project_repo, get_rule_write_repo
from ww_db import get_session
repo = _FakeRuleWriteRepo()
session = FakeSession()
project_repo = FakeProjectRepo()
pid = uuid.uuid4()
project_repo.rows[pid] = (STUB_OWNER_ID, ProjectView(id=pid, title="作品"))
app = create_app()
app.dependency_overrides[get_rule_write_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")
return client, repo, session
return client, repo, session, project_repo, pid
@pytest.mark.asyncio
async def test_create_rule_returns_201_and_commits() -> None:
client, repo, session = _make_client()
pid = uuid.uuid4()
client, repo, session, _project_repo, pid = _make_client()
async with client:
resp = await client.post(
f"/projects/{pid}/rules",
@@ -59,6 +84,8 @@ async def test_create_rule_returns_201_and_commits() -> None:
body = resp.json()
assert body["level"] == "project"
assert body["content"] == "主角不许中途复活"
# 创建回显带 id前端删除 handle
assert body["id"] == str(repo.rows[0].id)
assert session.commits == 1
assert len(repo.rows) == 1
assert repo.rows[0].project_id == pid
@@ -66,8 +93,7 @@ async def test_create_rule_returns_201_and_commits() -> None:
@pytest.mark.asyncio
async def test_create_rule_invalid_level_returns_422() -> None:
client, _repo, _session = _make_client()
pid = uuid.uuid4()
client, _repo, _session, _project_repo, pid = _make_client()
async with client:
resp = await client.post(
f"/projects/{pid}/rules",
@@ -78,11 +104,71 @@ async def test_create_rule_invalid_level_returns_422() -> None:
@pytest.mark.asyncio
async def test_create_rule_empty_content_returns_422() -> None:
client, _repo, _session = _make_client()
pid = uuid.uuid4()
client, _repo, _session, _project_repo, pid = _make_client()
async with client:
resp = await client.post(
f"/projects/{pid}/rules",
json={"level": "global", "content": ""},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_create_rule_whitespace_content_returns_422() -> None:
# QA MEDIUM 回归:纯空白 contentstrip 后为空)应 422不可入库成垃圾行。
client, repo, _session, _project_repo, pid = _make_client()
async with client:
resp = await client.post(
f"/projects/{pid}/rules",
json={"level": "project", "content": " "},
)
assert resp.status_code == 422
assert len(repo.rows) == 0
@pytest.mark.asyncio
async def test_create_rule_unknown_project_returns_404() -> None:
# QA H2 回归:给不存在的 project 加规则应 404不是 500 的 FK 违例逃逸)。
client, repo, _session, _project_repo, _pid = _make_client()
async with client:
resp = await client.post(
f"/projects/{uuid.uuid4()}/rules",
json={"level": "project", "content": "x"},
)
assert resp.status_code == 404
assert resp.json()["error"]["code"] == "NOT_FOUND"
assert len(repo.rows) == 0 # 未触达写库
@pytest.mark.asyncio
async def test_delete_rule_returns_204_and_commits() -> None:
# 作者显式删一条本作品规则(不变量 #3204 + 端点提交 + 行被移除。
client, repo, session, _project_repo, pid = _make_client()
seeded = await repo.create(pid, level="project", content="待删规则")
async with client:
resp = await client.delete(f"/projects/{pid}/rules/{seeded.id}")
assert resp.status_code == 204
assert session.commits == 1
assert len(repo.rows) == 0
@pytest.mark.asyncio
async def test_delete_rule_unknown_rule_returns_404() -> None:
# 未知 rule_id → 404不提交删不到行
client, repo, session, _project_repo, pid = _make_client()
async with client:
resp = await client.delete(f"/projects/{pid}/rules/{uuid.uuid4()}")
assert resp.status_code == 404
assert resp.json()["error"]["code"] == "NOT_FOUND"
assert session.commits == 0
@pytest.mark.asyncio
async def test_delete_rule_unknown_project_returns_404() -> None:
# 项目不存在 → 404先于 rule 查校验),不触达删除。
client, repo, session, _project_repo, _pid = _make_client()
async with client:
resp = await client.delete(f"/projects/{uuid.uuid4()}/rules/{uuid.uuid4()}")
assert resp.status_code == 404
assert resp.json()["error"]["code"] == "NOT_FOUND"
assert session.commits == 0

View File

@@ -91,3 +91,16 @@ def test_put_validation_rejects_blank_provider(client: TestClient) -> None:
json={"credentials": [{"provider": "", "api_key": "sk-x"}]},
)
assert resp.status_code == 422
def test_put_validation_rejects_unknown_tier(client: TestClient) -> None:
# QA MEDIUM 回归tier 限定 writer/analyst/light未知档位 → 422原接受任意字符串
resp = client.put(
"/settings/providers",
json={
"tier_routing": [
{"tier": "bogus", "provider": "deepseek", "model": "x", "fallback": []}
]
},
)
assert resp.status_code == 422

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,25 +19,31 @@ ledger否则 usage 静默丢失,同 draft/review 纪律)。无凭据 →
from __future__ import annotations
import uuid
from typing import Annotated
from typing import Annotated, Any
from fastapi import APIRouter, Depends, Request
from sqlalchemy.ext.asyncio import AsyncSession
from ww_agents import (
CharacterCard,
CharacterRelation,
ProjectPlanResult,
character_gen_spec,
continuity_spec,
project_plan_spec,
worldbuilder_spec,
)
from ww_core.domain import (
CharacterWriteFields,
CharacterWriteRepo,
ProjectRepo,
RuleWriteRepo,
)
from ww_core.domain.repositories import MemoryRepos, RulesRepo
from ww_core.domain.repositories import MemoryRepos
from ww_core.orchestrator import (
build_brief_context,
precheck_generated_cards,
run_character_gen,
run_generator,
run_worldbuilder,
)
from ww_db import get_session
@@ -63,6 +69,7 @@ from ww_api.schemas.generation import (
WorldGenerateRequest,
WorldGenPreviewResponse,
)
from ww_api.schemas.projects import ProjectPlanGenerateRequest, ProjectPlanView
from ww_api.schemas.rules import RuleView
from ww_api.services.credentials import STUB_OWNER_ID
from ww_api.services.project_deps import (
@@ -70,8 +77,9 @@ from ww_api.services.project_deps import (
get_character_write_repo,
get_memory_repos,
get_precheck_gateway,
get_project_plan_gateway,
get_project_repo,
get_rules_read_repo,
get_rule_write_repo,
get_skill_registry,
get_worldbuilder_gateway,
)
@@ -84,11 +92,12 @@ skills_router = APIRouter(prefix="/skills", tags=["skills"])
ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
MemoryReposDep = Annotated[MemoryRepos, Depends(get_memory_repos)]
CharacterWriteRepoDep = Annotated[CharacterWriteRepo, Depends(get_character_write_repo)]
RulesReadRepoDep = Annotated[RulesRepo, Depends(get_rules_read_repo)]
RuleRepoDep = Annotated[RuleWriteRepo, Depends(get_rule_write_repo)]
SkillRegistryDep = Annotated[SkillRegistry, Depends(get_skill_registry)]
WorldGatewayDep = Annotated[Gateway, Depends(get_worldbuilder_gateway)]
CharacterGatewayDep = Annotated[Gateway, Depends(get_character_gen_gateway)]
PrecheckGatewayDep = Annotated[Gateway, Depends(get_precheck_gateway)]
ProjectPlanGatewayDep = Annotated[Gateway, Depends(get_project_plan_gateway)]
SessionDep = Annotated[AsyncSession, Depends(get_session)]
@@ -104,6 +113,46 @@ def _project_context(title: str, genre: str | None, premise: str | None, theme:
return "\n".join(lines)
def _plan_seed_context(body: ProjectPlanGenerateRequest) -> str:
"""把立项向导草稿序列化为作品种子(喂 project-plan仿 `_project_context`)。
确定性、无时间戳/UUID只拼非空字段有则贴合、无则不臆造
genre + logline 由端点种子门控保证非空。
"""
lines: list[str] = []
label_fields: list[tuple[str, str | None]] = [
("暂定书名", body.title),
("题材", body.genre),
("一句话故事", body.logline),
("立意", body.premise),
("主题", body.theme),
("故事结构", body.structure),
("基调", body.tone),
("结局取向", body.ending_type),
("叙事视角", body.narrative_pov),
]
for label, value in label_fields:
text = (value or "").strip()
if text:
lines.append(f"{label}{text}")
points = [p.strip() for p in body.selling_points if p.strip()]
if points:
lines.append(f"核心卖点:{''.join(points)}")
return "\n".join(lines)
def _plan_to_view(plan: ProjectPlanResult) -> ProjectPlanView:
"""ww_agents.ProjectPlanResult → API ProjectPlanView字段同名直传"""
return ProjectPlanView(
title_candidates=list(plan.title_candidates),
setting=plan.setting,
narrative_structure=plan.narrative_structure,
story_core=plan.story_core,
ending_design=plan.ending_design,
tone=plan.tone,
)
def _render_characters_context(cards: list[CharacterCard]) -> str:
"""已有角色简表(喂 character-gen 防雷同 + precheck 真相源)。确定性、保序。"""
if not cards:
@@ -111,10 +160,28 @@ def _render_characters_context(cards: list[CharacterCard]) -> str:
return "\n".join(f"- {c.name}{c.role}{''.join(c.traits) or '(未列)'}" for c in cards)
def _relations_from_jsonb(raw: list[Any]) -> list[CharacterRelation]:
"""`characters.relations` JSONB list每条 {name, kind, note?})→ schema CharacterRelation。
跳过缺 name/kind 的脏条目(历史/外部数据不可信,守输入校验边界)。
"""
out: list[CharacterRelation] = []
for item in raw or []:
if not isinstance(item, dict):
continue
name = item.get("name")
kind = item.get("kind")
if not name or not kind:
continue
out.append(CharacterRelation(name=str(name), kind=str(kind), note=item.get("note")))
return out
async def _existing_characters(memory: MemoryRepos, project_id: uuid.UUID) -> list[CharacterCard]:
"""把已有角色读侧视图转成 `CharacterCard`(喂防雷同 / precheck
"""把已有角色读侧视图转成 `CharacterCard`(喂防雷同 / precheck / 设定库读端点)。
DB JSONB dict 列 → schema list/str 反向解包(与写侧形变互逆;缺则空/占位)。
`relations` 从 JSONB list 还原(设定库 Codex 需展示关系网precheck 不读此字段,无害)。
"""
views = await memory.character.list_for_project(project_id)
cards: list[CharacterCard] = []
@@ -129,11 +196,13 @@ async def _existing_characters(memory: MemoryRepos, project_id: uuid.UUID) -> li
name=v.name,
role=v.role or "",
traits=traits,
appearance=v.appearance or "",
motive=v.motive or "",
backstory=v.backstory or "",
arc=arc or "",
speech_tics=tics,
tags=list(v.tags or []),
relations=[],
relations=_relations_from_jsonb(list(v.relations or [])),
)
)
return cards
@@ -225,6 +294,7 @@ async def generate_characters(
gateway=gateway,
user_id=STUB_OWNER_ID,
project_id=project_id,
role_mix=body.role_mix,
)
await session.commit()
log.info(
@@ -303,20 +373,23 @@ async def ingest_characters(
rejected_tables=rejected,
)
created: list[str] = []
for card in allowed.get("characters", []):
view = await char_repo.create(
project_id,
fields = [
CharacterWriteFields(
name=card.name,
role=card.role,
traits=list(card.traits),
appearance=card.appearance,
motive=card.motive,
backstory=card.backstory,
arc=card.arc,
speech_tics=list(card.speech_tics),
tags=list(card.tags),
relations=[r.model_dump() for r in card.relations],
)
created.append(view.name)
for card in allowed.get("characters", [])
]
# 批量 upsert一次 flush 落全部),保序返回逐卡 View。
created = [view.name for view in await char_repo.create_many(project_id, fields)]
# 提交边界:网关 ledgerprecheck+ 角色写侧均只 flush → 端点末尾一次 commit。
await session.commit()
@@ -377,11 +450,16 @@ async def list_world_entities(
@router.get("/{project_id}/rules")
async def list_rules(
project_id: uuid.UUID,
repo: RulesReadRepoDep,
repo: RuleRepoDep,
project_repo: ProjectRepoDep,
) -> RuleListResponse:
"""规则列表(按读侧顺序)。规则页用"""
rules = await repo.all_for_project(project_id)
return RuleListResponse(rules=[RuleView(level=r.level, content=r.content) for r in rules])
"""规则列表(带 id供前端删除 handle。项目不存在 → 404QA MEDIUM此前返误导性空 200"""
if await project_repo.get(STUB_OWNER_ID, project_id) is None:
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
rules = await repo.list_for_project(project_id)
return RuleListResponse(
rules=[RuleView(id=r.id, level=r.level, content=r.content) for r in rules]
)
@skills_router.get("")
@@ -402,6 +480,53 @@ async def list_skills(registry: SkillRegistryDep) -> SkillListResponse:
return SkillListResponse(skills=skills)
# ---- AI 立项方案生成(灵感⑤;不带 project 前缀,立项前 project 未建)----
@skills_router.post("/project-plan/generate")
async def generate_project_plan(
body: ProjectPlanGenerateRequest,
request: Request,
gateway: ProjectPlanGatewayDep,
session: SessionDep,
) -> ProjectPlanView:
"""生成结构化立项方案预览向导阶段project 未建,不入库)。
**不带 project 前缀**——绕开 toolbox 的 `project_repo.get→404`(立项前无 project
**种子门控**:缺 genre 或 logline → 422空向导不出方案防 slop。无凭据 → 503。
"""
request_id = getattr(request.state, "request_id", None)
genre = (body.genre or "").strip()
logline = (body.logline or "").strip()
if not genre or not logline:
raise AppError(
ErrorCode.VALIDATION,
"生成立项方案至少需要「题材」与「一句话故事logline」两个种子",
{"has_genre": bool(genre), "has_logline": bool(logline)},
)
project_context = _plan_seed_context(body)
context = build_brief_context(brief=body.brief or "", project_context=project_context)
parsed = await run_generator(
project_plan_spec,
context=context,
gateway=gateway,
user_id=STUB_OWNER_ID,
project_id=None, # 向导阶段 project 未建usage_ledger.project_id 可空
)
# 提交边界:预览不写业务表,但网关 ledger 只 flush → 末尾 commit 落 usage否则丢失
await session.commit()
if not isinstance(parsed, ProjectPlanResult):
# run_generator 已按 output_schema 校验;防御性收窄(不应触达)。
raise AppError(ErrorCode.INTERNAL, "立项方案生成返回类型异常")
log.info(
"project_plan_generate_done",
request_id=request_id,
title_count=len(parsed.title_candidates),
)
return _plan_to_view(parsed)
# ---- schema <-> ww_agents 卡转换 ----
@@ -410,6 +535,8 @@ def _card_to_view(card: CharacterCard) -> CharacterCardView:
name=card.name,
role=card.role,
traits=list(card.traits),
appearance=card.appearance,
motive=card.motive,
backstory=card.backstory,
arc=card.arc,
speech_tics=list(card.speech_tics),
@@ -425,6 +552,8 @@ def _view_to_card(view: CharacterCardView) -> CharacterCard:
name=view.name,
role=view.role,
traits=list(view.traits),
appearance=view.appearance,
motive=view.motive,
backstory=view.backstory,
arc=view.arc,
speech_tics=list(view.speech_tics),

View File

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

View File

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

View File

@@ -143,10 +143,12 @@ async def get_outline(
request: Request,
project_repo: ProjectRepoDep,
outline_repo: OutlineReadRepoDep,
volume: int | None = None,
) -> OutlineResponse:
"""读取已持久化的大纲(逐章,按 chapter_no 升序)。
项目不存在 → 404项目存在但尚无大纲 → 200 空列表(非 404页面初次访问的常态)。
可选 `?volume=N`:只返回该卷的章节(前端按卷切换);不传 → 全部章节(向后兼容)。
项目不存在 → 404项目存在但尚无大纲或该卷无章节→ 200 空列表(非 404
DB `outline.beats` 是 JSONB `{"beats": [...]}` → 解包成裸 `list[str]`(与 POST 响应同形,
前端 OpenAPI 类型对齐)。读侧复用 C5 assemble 的 `OutlineRepo`,不写库。
"""
@@ -156,7 +158,7 @@ async def get_outline(
if project is None:
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
views = await outline_repo.list_for_project(project_id)
views = await outline_repo.list_for_project(project_id, volume=volume)
chapters = [
OutlineChapterView(
no=view.chapter_no,
@@ -171,6 +173,7 @@ async def get_outline(
"outline_read",
project_id=str(project_id),
request_id=request_id,
volume=volume,
chapter_count=len(chapters),
)
return OutlineResponse(chapters=chapters)

View File

@@ -20,6 +20,7 @@ from typing import Annotated, Any
from fastapi import APIRouter, BackgroundTasks, Depends, Request
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from ww_agents import ClarifyDecision, clarify_rewrite_spec
from ww_core.domain.chapter_repo import ChapterRepo
from ww_core.domain.digest_repo import DigestAppendRepo
from ww_core.domain.injection_repo import EntityRef, InjectionOverride, InjectionOverrideRepo
@@ -36,13 +37,16 @@ from ww_core.orchestrator import (
error_event,
normalize_deltas,
normalize_review,
run_clarify,
stream_chapter_draft,
stream_chapter_rewrite,
)
from ww_db import get_session
from ww_llm_gateway import Gateway
from ww_shared import AppError, ErrorCode, ErrorEnvelope
from ww_api.logging_config import get_logger
from ww_api.pagination import PageDep
from ww_api.schemas.injection import (
InjectionEntity,
InjectionEntityRef,
@@ -52,6 +56,7 @@ from ww_api.schemas.injection import (
from ww_api.schemas.projects import (
AcceptRequest,
AcceptResponse,
ChapterListItem,
DraftResponse,
DraftSaveRequest,
DraftStreamRequest,
@@ -63,17 +68,22 @@ from ww_api.schemas.projects import (
ReviewHistoryItem,
ReviewHistoryResponse,
ReviewRequest,
RewriteClarifyRequest,
RewriteStreamRequest,
)
from ww_api.services.accept_service import (
AcceptOutcome,
assert_conflicts_resolved,
run_accept_transaction,
)
from ww_api.services.chapter_list import ChapterLister
from ww_api.services.credentials import STUB_OWNER_ID
from ww_api.services.digest_extraction import extract_digest_facts
from ww_api.services.foreshadow_scan import SessionFactory, run_overdue_scan
from ww_api.services.project_deps import (
get_chapter_lister,
get_chapter_repo,
get_clarify_gateway,
get_digest_append_repo,
get_digest_gateway,
get_injection_repo,
@@ -98,12 +108,21 @@ _ACCEPT_ERRORS: dict[int | str, dict[str, Any]] = {
409: {"model": ErrorEnvelope, "description": "存在未裁决冲突"},
503: {"model": ErrorEnvelope, "description": "LLM 不可用"},
}
# SSE 端点:如实声明 200 的 media type 为 text/event-stream否则 OpenAPI 误标 json
_SSE_RESPONSE: dict[int | str, dict[str, Any]] = {
200: {
"description": "SSE 事件流(`event: <name>\\ndata: <json>`,见 memory/contracts.md",
"content": {"text/event-stream": {}},
}
}
ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
ChapterRepoDep = Annotated[ChapterRepo, Depends(get_chapter_repo)]
ChapterListerDep = Annotated[ChapterLister, Depends(get_chapter_lister)]
GatewayDep = Annotated[Gateway, Depends(get_writer_gateway)]
ReviewGatewayDep = Annotated[Gateway, Depends(get_review_gateway)]
DigestGatewayDep = Annotated[Gateway, Depends(get_digest_gateway)]
ClarifyGatewayDep = Annotated[Gateway, Depends(get_clarify_gateway)]
MemoryReposDep = Annotated[MemoryRepos, Depends(get_memory_repos)]
InjectionRepoDep = Annotated[InjectionOverrideRepo, Depends(get_injection_repo)]
ReviewRepoDep = Annotated[ReviewRepo, Depends(get_review_repo)]
@@ -127,6 +146,9 @@ async def create_project(body: ProjectCreateRequest, repo: ProjectRepoDep) -> Pr
theme=body.theme,
selling_points=body.selling_points,
structure=body.structure,
tone=body.tone,
ending_type=body.ending_type,
narrative_pov=body.narrative_pov,
),
)
log.info("project_created", project_id=str(view.id), title_len=len(view.title))
@@ -134,8 +156,9 @@ async def create_project(body: ProjectCreateRequest, repo: ProjectRepoDep) -> Pr
@router.get("")
async def list_projects(repo: ProjectRepoDep) -> ProjectListResponse:
views = await repo.list_for_owner(STUB_OWNER_ID)
async def list_projects(repo: ProjectRepoDep, page: PageDep) -> ProjectListResponse:
"""作品列表(按 created_at 升序,分页)。`?limit=&offset=`,默认第一页。"""
views = await repo.list_for_owner(STUB_OWNER_ID, limit=page.limit, offset=page.offset)
return ProjectListResponse(projects=[_to_response(v) for v in views])
@@ -147,6 +170,28 @@ async def get_project(project_id: uuid.UUID, repo: ProjectRepoDep) -> ProjectRes
return _to_response(view)
@router.get("/{project_id}/chapters", responses=_NOT_FOUND)
async def list_chapters(
project_id: uuid.UUID,
project_repo: ProjectRepoDep,
lister: ChapterListerDep,
) -> list[ChapterListItem]:
"""列出该项目「真实存在的章」+ 可审/已审标记(审稿页选章下拉枚举)。
来源 `chapters`(草稿/accepted+ `chapter_reviews`,按章号去重升序。只读、不触 LLM。
项目不存在 → 404同其它端点owner 走 project_repo stub 校验)。
"""
if await project_repo.get(STUB_OWNER_ID, project_id) is None:
raise AppError(ErrorCode.NOT_FOUND, f"project {project_id} not found")
items = await lister.list_chapters(project_id)
log.info(
"chapters_listed",
project_id=str(project_id),
chapter_count=len(items),
)
return items
async def _injection_response(
repos: MemoryRepos,
project_id: uuid.UUID,
@@ -244,7 +289,7 @@ def _encode_sse(event: SseEvent) -> str:
return f"event: {event.event}\ndata: {payload}\n\n"
@router.post("/{project_id}/chapters/{chapter_no}/draft")
@router.post("/{project_id}/chapters/{chapter_no}/draft", responses=_SSE_RESPONSE)
async def stream_draft(
project_id: uuid.UUID,
chapter_no: int,
@@ -252,6 +297,7 @@ async def stream_draft(
repos: MemoryReposDep,
gateway: GatewayDep,
injection_repo: InjectionRepoDep,
project_repo: ProjectRepoDep,
session: Annotated[AsyncSession, Depends(get_session)],
body: DraftStreamRequest | None = None,
) -> StreamingResponse:
@@ -259,7 +305,12 @@ async def stream_draft(
`body.directive`可选T4-b是临时本章指令直通 assemble→volatile不持久化
无 body 的旧调用方仍可用(向后兼容)。
项目不存在 → 404在触网关前 fail-fast否则非法 project_id 会静默烧一次付费/限流的
LLM 调用并返回 200QA 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)
directive = body.directive if body else None
override = await injection_repo.get(project_id, chapter_no)
@@ -301,6 +352,138 @@ async def stream_draft(
)
@router.post("/{project_id}/chapters/{chapter_no}/rewrite", responses=_SSE_RESPONSE)
async def stream_rewrite(
project_id: uuid.UUID,
chapter_no: int,
request: Request,
repos: MemoryReposDep,
gateway: GatewayDep,
injection_repo: InjectionRepoDep,
project_repo: ProjectRepoDep,
session: Annotated[AsyncSession, Depends(get_session)],
body: RewriteStreamRequest,
) -> StreamingResponse:
"""整章再沟通/重写:组装记忆 + 当前草稿 + 作者意见 → 网关流 → 归一 SSE → event-stream。
HITL新版停前端/草稿,接受才落库(不变量 #3rewrite 是工具非写节点,不破坏章
纯函数性(不变量 #7。项目不存在 → 404触网关前 fail-fast同 draftQA C1
"""
if await project_repo.get(STUB_OWNER_ID, project_id) is None:
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
request_id = getattr(request.state, "request_id", None)
override = await injection_repo.get(project_id, chapter_no)
context = await assemble(repos, project_id, chapter_no, override=override)
log.info(
"rewrite_stream_start",
project_id=str(project_id),
chapter_no=chapter_no,
request_id=request_id,
stable_core_len=len(context.stable_core),
volatile_len=len(context.volatile),
prior_draft_len=len(body.prior_draft),
feedback_len=len(body.feedback),
)
deltas = stream_chapter_rewrite(
gateway,
stable_core=context.stable_core,
volatile=context.volatile,
prior_draft=body.prior_draft,
feedback=body.feedback,
user_id=STUB_OWNER_ID,
project_id=project_id,
)
async def _frames() -> AsyncIterator[str]:
async for event in normalize_deltas(deltas, request_id=request_id):
yield _encode_sse(event)
try:
await session.commit()
except Exception: # noqa: BLE001 — 流已发完commit 失败不能再改响应;至少记错误。
log.error("sse_commit_failed", request_id=request_id, endpoint="rewrite")
return StreamingResponse(
_frames(),
media_type="text/event-stream",
headers={"cache-control": "no-cache", "x-accel-buffering": "no"},
)
# 整章重写预检的草稿摘录上界:只喂 analyst 预检**开头一小段**供锚定,绝不整章入 LLM
# (守成本/上下文,且预检只需判意见含糊度、不需读全章)。
_REWRITE_CLARIFY_EXCERPT_MAX = 3000
def _build_rewrite_clarify_input(feedback: str, prior_draft: str | None) -> str:
"""组整章重写预检输入:作者章级意见 + 当前章草稿的有界摘录(分块标注,供教条区分)。
只取 `prior_draft` 开头 `_REWRITE_CLARIFY_EXCERPT_MAX` 字符作锚定摘录——预检不需读全章,
避免把 200k 整章喂给 analyst 预检(成本/上下文/脱敏)。无草稿时只喂意见(退化,仍可判含糊)。
"""
parts = [f"【作者的整章修改意见】\n{feedback}"]
if prior_draft:
excerpt = prior_draft[:_REWRITE_CLARIFY_EXCERPT_MAX]
parts.append(f"【当前章节草稿(摘录,仅供锚定)】\n{excerpt}")
return "\n\n".join(parts)
@router.post(
"/{project_id}/chapters/{chapter_no}/rewrite/clarify",
responses={
404: {"model": ErrorEnvelope, "description": "项目不存在"},
503: {"model": ErrorEnvelope, "description": "LLM 不可用"},
},
)
async def clarify_rewrite(
project_id: uuid.UUID,
chapter_no: int,
body: RewriteClarifyRequest,
request: Request,
project_repo: ProjectRepoDep,
gateway: ClarifyGatewayDep,
session: Annotated[AsyncSession, Depends(get_session)],
) -> ClarifyDecision:
"""整章重写预检澄清WFW-9 M2 路线A 两阶段之「问题」阶段):非流式 JSON只判不改。
镜像 refine 侧 clarify——只判「作者这条整章重写意见清不清楚」含糊则反问 1 问 + 给选项,
清楚则给确认语放行;**绝不改正文**(整章重写仍走既有 rewrite SSE 端点。analyst 档
(不变量 #2。只取 `prior_draft` 有界摘录喂预检(不整章入 LLM
项目不存在 → 404触网关前 fail-fast同 rewrite/draft无凭据 → 503dep 解析阶段拦下)。
**只读不写业务表**(不变量 #3末尾 `commit()` 让网关 usage_ledger 落库add-only否则
记账静默丢失,同 rewrite 纪律)。判别/校验失败在 `run_clarify` 内确定性回退
`need_clarification=false`(不阻塞整章重写主链路)。
"""
request_id = getattr(request.state, "request_id", None)
if await project_repo.get(STUB_OWNER_ID, project_id) is None:
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
context = _build_rewrite_clarify_input(body.feedback, body.prior_draft)
decision = await run_clarify(
clarify_rewrite_spec, # 不变量 #2analyst 档,不传 model
context=context,
gateway=gateway,
user_id=STUB_OWNER_ID,
project_id=project_id,
)
# 提交边界:只读不写业务表,但网关 ledger add-only → 端点末尾 commit否则记账丢失
await session.commit()
log.info(
"rewrite_clarify_done",
project_id=str(project_id),
chapter_no=chapter_no,
request_id=request_id,
feedback_len=len(body.feedback), # 脱敏:只记长度,不记正文(不变量/日志纪律)
need_clarification=decision.need_clarification,
question_count=len(decision.questions),
)
return decision
@router.put("/{project_id}/chapters/{chapter_no}/draft")
async def save_draft(
project_id: uuid.UUID,
@@ -382,7 +565,7 @@ async def _resolve_review_draft(
return saved.content
@router.post("/{project_id}/chapters/{chapter_no}/review")
@router.post("/{project_id}/chapters/{chapter_no}/review", responses=_SSE_RESPONSE)
async def review_chapter(
project_id: uuid.UUID,
chapter_no: int,
@@ -479,6 +662,7 @@ async def list_reviews(
foreshadow_sug=v.foreshadow_sug,
style=v.style,
pace=v.pace,
characterization=v.characterization,
health_score=v.health_score,
decisions=v.decisions,
)

View File

@@ -15,20 +15,24 @@ from __future__ import annotations
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from fastapi import APIRouter, Depends, Request, Response
from sqlalchemy.ext.asyncio import AsyncSession
from ww_core.domain import RuleWriteRepo
from ww_core.domain.project_repo import ProjectRepo
from ww_db import get_session
from ww_shared import AppError, ErrorCode
from ww_api.logging_config import get_logger
from ww_api.schemas.rules import RuleCreateRequest, RuleView
from ww_api.services.project_deps import get_rule_write_repo
from ww_api.services.credentials import STUB_OWNER_ID
from ww_api.services.project_deps import get_project_repo, get_rule_write_repo
log = get_logger("ww.api.rules")
router = APIRouter(prefix="/projects", tags=["rules"])
RuleWriteRepoDep = Annotated[RuleWriteRepo, Depends(get_rule_write_repo)]
ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
SessionDep = Annotated[AsyncSession, Depends(get_session)]
@@ -38,10 +42,16 @@ async def create_rule(
body: RuleCreateRequest,
request: Request,
repo: RuleWriteRepoDep,
project_repo: ProjectRepoDep,
session: SessionDep,
) -> RuleView:
"""新增一条规则201。非法 level / 空 content → FastAPI 422。"""
"""新增一条规则201。非法 level / 空 content → FastAPI 422;项目不存在 → 404
项目存在性须在 insert 前校验:否则 FK 违例会逃逸成 500QA H2而非干净的 404。
"""
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}")
view = await repo.create(project_id, level=body.level, content=body.content)
await session.commit()
log.info(
@@ -50,4 +60,34 @@ async def create_rule(
request_id=request_id,
level=body.level,
)
return RuleView(level=view.level, content=view.content)
return RuleView(id=view.id, level=view.level, content=view.content)
@router.delete("/{project_id}/rules/{rule_id}", status_code=204)
async def delete_rule(
project_id: uuid.UUID,
rule_id: uuid.UUID,
request: Request,
repo: RuleWriteRepoDep,
project_repo: ProjectRepoDep,
session: SessionDep,
) -> Response:
"""删除一条本作品规则204。项目不存在 → 404规则不存在 / 不属于该项目 → 404。
删规则是**作者显式动作**(不变量 #3规则增删不经 AI 静默写库。repo.delete 只 flush
端点提交;删不到行(未知 id / 跨项目)→ 不提交、抛 404。
"""
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}")
deleted = await repo.delete(project_id, rule_id)
if not deleted:
raise AppError(ErrorCode.NOT_FOUND, f"rule not found: {rule_id}")
await session.commit()
log.info(
"rule_deleted",
project_id=str(project_id),
rule_id=str(rule_id),
request_id=request_id,
)
return Response(status_code=204)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,13 +12,16 @@ snake_case前端经 OpenAPI 生成 TS 类型消费。改字段 → 前端必
from __future__ import annotations
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator
from ww_api.schemas.rules import RuleView
# 批量生成数量上限(防一次性巨量调用;原型保守值)。
MAX_GENERATE_COUNT = 12
# 一句话需求文本上界CR-H9防超长入参撑爆上下文/成本)。
_BRIEF_MAX = 10_000
# ---- 世界观生成(预览)----
@@ -26,7 +29,9 @@ MAX_GENERATE_COUNT = 12
class WorldGenerateRequest(BaseModel):
"""POST /projects/:id/world/generate据需求生成世界观实体预览"""
brief: str = Field(min_length=1, description="世界观生成需求(题材/设定方向/约束)")
brief: str = Field(
min_length=1, max_length=_BRIEF_MAX, description="世界观生成需求(题材/设定方向/约束)"
)
class WorldEntityCardView(BaseModel):
@@ -47,11 +52,40 @@ class WorldGenPreviewResponse(BaseModel):
class CharacterGenerateRequest(BaseModel):
"""POST /projects/:id/characters/generate据需求生成角色卡预览群像防雷同"""
"""POST /projects/:id/characters/generate据需求生成角色卡预览群像防雷同
brief: str = Field(min_length=1, description="角色生成需求")
count: int = Field(default=1, ge=1, le=MAX_GENERATE_COUNT, description="生成数量")
`role_mix`(⑦ 群像按定位配比)与 `count` 的关系:`role_mix` 在场时 `count` 取各值之和
(忽略客户端显式 count每项数量须 ≥ 1、总和须 ≤ `MAX_GENERATE_COUNT`;缺省则退回
单一 `role` + `count` 语义。
"""
brief: str = Field(min_length=1, max_length=_BRIEF_MAX, description="角色生成需求")
count: int = Field(
default=1,
ge=1,
le=MAX_GENERATE_COUNT,
description="生成数量role_mix 在场时取各值之和)",
)
role: str | None = Field(default=None, description="角色定位(主角/CP/对手/导师/工具人 等)")
role_mix: dict[str, int] | None = Field(
default=None,
description="按定位配比生成 {定位: 数量};在场时 count=各值之和每项≥1、总和≤12",
)
@model_validator(mode="after")
def _reconcile_role_mix(self) -> CharacterGenerateRequest:
"""配比在场时校验并把 count 定为各值之和(构造期归一化,非外部对象变更)。"""
if self.role_mix is None:
return self
if not self.role_mix:
raise ValueError("role_mix 不能为空对象")
if any(n < 1 for n in self.role_mix.values()):
raise ValueError("role_mix 每个定位的数量须 ≥ 1")
total = sum(self.role_mix.values())
if total > MAX_GENERATE_COUNT:
raise ValueError(f"role_mix 数量总和须 ≤ {MAX_GENERATE_COUNT}")
self.count = total
return self
class CharacterRelationView(BaseModel):
@@ -67,7 +101,9 @@ class CharacterCardView(BaseModel):
name: str = Field(description="角色名")
role: str = Field(description="角色定位")
traits: list[str] = Field(default_factory=list, description="性格特质清单")
traits: list[str] = Field(default_factory=list, description="性格特质清单(性格三层)")
appearance: str = Field(default="", description="外貌形象(缺则空串)")
motive: str = Field(default="", description="核心动机/欲望/恐惧(缺则空串)")
backstory: str = Field(description="背景故事")
arc: str = Field(description="人物弧光(一句话)")
speech_tics: list[str] = Field(default_factory=list, description="口癖/语言风格")

View File

@@ -6,21 +6,31 @@ snake_case前端经 OpenAPI 生成 TS 类型消费。改字段 → 前端必
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, Field
# 请求文本长度上界CR-H9防超长入参 DoS/成本)。标题=标签级;章正文=整章级。
_TITLE_MAX = 200
_CHAPTER_TEXT_MAX = 200_000
_DIRECTIVE_MAX = 20_000 # CR-H9 收尾:本章指令喂 LLM加上界正文级 body 中间件仍兜底)
class ProjectCreateRequest(BaseModel):
"""POST /projects立项向导字段owner_id 由后端补 stub不入参"""
title: str = Field(min_length=1)
title: str = Field(min_length=1, max_length=_TITLE_MAX)
genre: str | None = None
logline: str | None = None
premise: str | None = None
theme: str | None = None
selling_points: list[str] = Field(default_factory=list)
structure: str | None = None
# 立项书级常量灵感④——free text + 前端预设,无 LiteralLiteral 只留 Verdict
tone: str | None = None
ending_type: str | None = None
narrative_pov: str | None = None
class ProjectResponse(BaseModel):
@@ -34,6 +44,11 @@ class ProjectResponse(BaseModel):
theme: str | None = None
selling_points: list[str] = Field(default_factory=list)
structure: str | None = None
tone: str | None = None
ending_type: str | None = None
narrative_pov: str | None = None
updated_at: datetime | None = Field(default=None, description="项目最近更新时间")
pending_review_count: int = Field(default=0, ge=0, description="待审稿章节数")
class ProjectListResponse(BaseModel):
@@ -42,6 +57,64 @@ class ProjectListResponse(BaseModel):
projects: list[ProjectResponse] = Field(default_factory=list)
# ---- AI 立项方案生成(灵感⑤;向导阶段 project 未建,纯预览不写库)----
# 请求字段长度上界CR-H9防超长向导草稿撑爆上下文/成本)。短字段=标签级,长字段=段落级。
_PLAN_SHORT_MAX = 200
_PLAN_LONG_MAX = 4000
_PLAN_LIST_MAX = 20
class ProjectPlanGenerateRequest(BaseModel):
"""POST /skills/project-plan/generate向导草稿种子不带 project 前缀,立项前 project 未建)。
**种子门控**`genre` + `logline` 为必给种子(端点校验非空,否则 422——空向导不出方案
防 slop。其余字段有则贴合、无则不臆造序列化进 project_context仿 `_project_context`)。
全字段带 `max_length` 上界CR-H9`brief` = 作者一句话方向(可空)。
"""
genre: str | None = Field(default=None, max_length=_PLAN_SHORT_MAX, description="题材(种子)")
logline: str | None = Field(
default=None, max_length=_PLAN_LONG_MAX, description="一句话故事(种子)"
)
title: str | None = Field(
default=None, max_length=_PLAN_SHORT_MAX, description="暂定书名(可空)"
)
premise: str | None = Field(default=None, max_length=_PLAN_LONG_MAX, description="立意(可空)")
theme: str | None = Field(default=None, max_length=_PLAN_SHORT_MAX, description="主题(可空)")
structure: str | None = Field(
default=None, max_length=_PLAN_SHORT_MAX, description="故事结构(可空)"
)
tone: str | None = Field(default=None, max_length=_PLAN_SHORT_MAX, description="基调(可空)")
ending_type: str | None = Field(
default=None, max_length=_PLAN_SHORT_MAX, description="结局取向(可空)"
)
narrative_pov: str | None = Field(
default=None, max_length=_PLAN_SHORT_MAX, description="叙事视角(可空)"
)
selling_points: list[str] = Field(
default_factory=list, max_length=_PLAN_LIST_MAX, description="核心卖点(可空)"
)
# `str | None`(非 `str=""`codegen 渲染为可选字段前端种子请求可省略brief 本就可空)。
brief: str | None = Field(
default=None, max_length=_PLAN_LONG_MAX, description="作者一句话方向/需求(可空)"
)
class ProjectPlanView(BaseModel):
"""立项方案生成结果(贴 ww_agents.ProjectPlanResult结构化预览不入库
全字段有默认值(守解析韧性)——前端按字段回填立项向导,只填非空交集、不覆盖作者已编辑值。
"""
title_candidates: list[str] = Field(default_factory=list, description="书名候选清单")
setting: str = Field(default="", description="时空背景")
narrative_structure: str = Field(default="", description="叙事结构")
story_core: str = Field(default="", description="故事核心")
ending_design: str = Field(default="", description="结局设计")
tone: str = Field(default="", description="基调")
class DraftStreamRequest(BaseModel):
"""POST /projects/:id/chapters/:no/draft本章生成的可选输入T4-b
@@ -49,13 +122,44 @@ class DraftStreamRequest(BaseModel):
后端只入 volatile守缓存前缀不变量 #9。无 body 时退化为纯按大纲生成(向后兼容)。
"""
directive: str | None = None
directive: str | None = Field(default=None, max_length=_DIRECTIVE_MAX)
# 整章重写输入上界(守 DoS/成本CR-H9 方向意见短、整章草稿大复用章正文上界DRY
_REWRITE_FEEDBACK_MAX = 4000
_REWRITE_DRAFT_MAX = _CHAPTER_TEXT_MAX
class RewriteStreamRequest(BaseModel):
"""POST /projects/:id/chapters/:no/rewrite整章再沟通/重写。
`feedback` = 作者对本章的修改意见;`prior_draft` = 当前整章草稿(前端传入,据此 + 记忆
注入流式重写一版)。临时输入、不持久化、无迁移;只入 volatile守缓存前缀不变量 #9
新版停前端/草稿接受才落库HITL不变量 #3
"""
feedback: str = Field(min_length=1, max_length=_REWRITE_FEEDBACK_MAX)
prior_draft: str = Field(min_length=1, max_length=_REWRITE_DRAFT_MAX)
class RewriteClarifyRequest(BaseModel):
"""整章重写预检澄清:章级意见 + 可选当前草稿WFW-9 M2 路线A 两阶段之「问题」阶段)。
与 `RewriteStreamRequest` 独立——预检只判「作者这条章级意见清不清楚」,**不改正文**
(整章重写仍走既有 rewrite SSE 端点)。`feedback` = 作者对整章的修改意见(触发反问的场景,
可极短/含糊);`prior_draft` = 当前整章草稿(可选,仅供锚定;端点只取有界摘录喂 analyst 预检,
不整章入 LLM。带长度上界防超长入参。响应=`ClarifyDecision`(在 ww_agents端点直接返回
供前端 gen:api 生成强类型客户端)。
"""
feedback: str = Field(min_length=1, max_length=_REWRITE_FEEDBACK_MAX)
prior_draft: str | None = Field(default=None, max_length=_REWRITE_DRAFT_MAX)
class DraftSaveRequest(BaseModel):
"""PUT /projects/:id/chapters/:no/draft自动保存草稿正文。"""
text: str
text: str = Field(max_length=_CHAPTER_TEXT_MAX)
class DraftResponse(BaseModel):
@@ -85,6 +189,23 @@ class DraftView(BaseModel):
length: int
# ---- 列章(审稿选章枚举)----
class ChapterListItem(BaseModel):
"""GET /projects/:id/chapters 单项:一个真实存在的章 + 可审/已审标记snake_case
数据来源为 `chapters`(草稿/accepted 版本)与 `chapter_reviews`,按章号去重升序。
`has_draft`=有非空草稿正文(可审);`accepted`=已有 accepted 版本;
`reviewed_at`=最近一次审稿时间(无则 null
"""
chapter_no: int
has_draft: bool = Field(description="是否有非空草稿正文(可审)")
accepted: bool = Field(description="是否已验收(存在 accepted 版本)")
reviewed_at: datetime | None = Field(default=None, description="最近一次审稿时间;未审为 null")
# ---- 审稿T2.5----
@@ -94,7 +215,7 @@ class ReviewRequest(BaseModel):
不传 `draft` 时端点回退到已保存的草稿chapter_repo
"""
draft: str | None = None
draft: str | None = Field(default=None, max_length=_CHAPTER_TEXT_MAX)
class ReviewConflictView(BaseModel):
@@ -123,6 +244,8 @@ class ReviewHistoryItem(BaseModel):
foreshadow_sug: list[dict[str, Any]] = Field(default_factory=list)
style: dict[str, Any] | None = None
pace: dict[str, Any] | None = None
# 灵感③ advisory 人物塑造审留痕issues 列表 dict仅建议、不阻断验收。
characterization: dict[str, Any] | None = None
health_score: int | None = None
decisions: dict[str, Any] | None = None
@@ -150,7 +273,9 @@ class ConflictDecision(BaseModel):
class AcceptRequest(BaseModel):
"""POST /projects/:id/chapters/:no/accept裁决清单 + 可能改过的终稿。"""
final_text: str = Field(min_length=1, description="作者裁决/改稿后的最终验收文本")
final_text: str = Field(
min_length=1, max_length=_CHAPTER_TEXT_MAX, description="作者裁决/改稿后的最终验收文本"
)
decisions: list[ConflictDecision] = Field(
default_factory=list, description="对最近审稿每个冲突的裁决每冲突必有其一R5"
)

View File

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

View File

@@ -7,22 +7,35 @@ snake_case前端经 OpenAPI 生成 TS 类型消费。改字段 → 前端必
from __future__ import annotations
from typing import Literal
import uuid
from typing import Annotated, Literal
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, StringConstraints
RuleLevel = Literal["global", "genre", "style", "project"]
# 规则正文长度上界CR-H9防超长入参 DoS/成本)。
_RULE_CONTENT_MAX = 10_000
# 先 strip 再校验长度:纯空白内容(" "strip 后为空 → 422QA MEDIUM此前被接受入库
RuleContent = Annotated[
str, StringConstraints(strip_whitespace=True, min_length=1, max_length=_RULE_CONTENT_MAX)
]
class RuleCreateRequest(BaseModel):
"""POST /projects/:id/rules新增一条规则。"""
level: RuleLevel = Field(description="规则级别global/genre/style/project越具体越优先")
content: str = Field(min_length=1, description="规则正文")
content: RuleContent = Field(description="规则正文(首尾空白会被裁剪,不可全空白)")
class RuleView(BaseModel):
"""规则视图创建后回显snake_case"""
"""规则视图(创建后回显 + 列表项snake_case
`id` 是该规则行的稳定主键——前端规则页用它作删除 handleDELETE /rules/{rule_id})。
"""
id: uuid.UUID
level: str
content: str

View File

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

View File

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

View File

@@ -28,6 +28,18 @@ if TYPE_CHECKING:
CheckpointerFactory = Callable[[], AbstractAsyncContextManager[Any]]
def _libpq_conn_string() -> str:
"""从 settings 的 sync URL 派生 psycopg 的 libpq conn string。
`database_url_sync` 带 SQLAlchemy 驱动后缀(``postgresql+psycopg://…``),但
`AsyncPostgresSaver.from_conn_string` 直接交给 psycopg 解析——psycopg 不识别
``+psycopg``/``+asyncpg`` 方言段,会抛 ``ProgrammingError: missing "=" …``。
故剥离后缀还原为标准 libpq 串(``postgresql://…``)。与迁移 `d3e4f5a6b7c8`
的 `_psycopg_conn_string()` 同义。
"""
return get_settings().database_url_sync.replace("+psycopg", "").replace("+asyncpg", "")
def get_checkpointer_factory() -> CheckpointerFactory:
"""运行时 checkpointer 工厂:建连 `DATABASE_URL` 的 `AsyncPostgresSaver` 上下文。
@@ -38,7 +50,8 @@ def get_checkpointer_factory() -> CheckpointerFactory:
def _factory() -> AbstractAsyncContextManager[BaseCheckpointSaver[Any]]:
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
# langgraph 的 Postgres saver 走 psycopg(同步驱动串)用 sync 串建连接。
return AsyncPostgresSaver.from_conn_string(get_settings().database_url_sync)
# langgraph 的 Postgres saver 走 psycopg(同步驱动串)须剥离 SQLAlchemy 驱动
# 后缀(+psycopg否则 psycopg 解析连接串报 ProgrammingError。
return AsyncPostgresSaver.from_conn_string(_libpq_conn_string())
return _factory

View File

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

View File

@@ -0,0 +1,109 @@
"""列章只读服务:枚举项目「真实存在的章」+ 可审/已审标记(审稿选章下拉用)。
数据来源:`chapters`(草稿/accepted 版本)+ `chapter_reviews`。核心业务逻辑是把这两张表
的行**聚合**为「按章号去重、升序」的列章视图,抽成纯函数 `build_chapter_list` 以便脱库直测
(真 PG 行为交 tests/ E2E符合本仓单测确定性纪律。`SqlChapterLister` 只负责取行 + 调聚合。
不变量:只读、不写库、不触 LLM按 project_id 隔离owner 校验在端点走 project_repo同其它端点
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass
from datetime import datetime
from typing import Protocol
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ww_core.domain.chapter_repo import ACCEPTED_STATUS, DRAFT_STATUS
from ww_db.models import Chapter, ChapterReview
from ww_api.schemas.projects import ChapterListItem
@dataclass(frozen=True)
class ChapterRow:
"""`chapters` 行的最小读投影(聚合所需字段)。"""
chapter_no: int
status: str
content: str | None
@dataclass(frozen=True)
class ReviewRow:
"""`chapter_reviews` 行的最小读投影(章号 + 审稿时间)。"""
chapter_no: int
created_at: datetime
def build_chapter_list(
chapter_rows: list[ChapterRow], review_rows: list[ReviewRow]
) -> list[ChapterListItem]:
"""把 chapters/reviews 行聚合为按章号去重、升序的列章视图(纯函数,无副作用)。
- `has_draft`:该章存在 status='draft' 且正文非空白的行(与 GET .../draft「空草稿=无」一致)。
- `accepted`:该章存在 status='accepted' 行。
- `reviewed_at`:该章审稿行 created_at 的最大值(无审稿则 None
- 章号来自两表并集(含仅审稿未存草稿的章),去重后升序。
"""
has_draft: dict[int, bool] = {}
accepted: dict[int, bool] = {}
reviewed_at: dict[int, datetime] = {}
for row in chapter_rows:
if row.status == DRAFT_STATUS and row.content is not None and row.content.strip():
has_draft[row.chapter_no] = True
if row.status == ACCEPTED_STATUS:
accepted[row.chapter_no] = True
for review in review_rows:
current = reviewed_at.get(review.chapter_no)
if current is None or review.created_at > current:
reviewed_at[review.chapter_no] = review.created_at
chapter_nos = sorted({r.chapter_no for r in chapter_rows} | {r.chapter_no for r in review_rows})
return [
ChapterListItem(
chapter_no=no,
has_draft=has_draft.get(no, False),
accepted=accepted.get(no, False),
reviewed_at=reviewed_at.get(no),
)
for no in chapter_nos
]
class ChapterLister(Protocol):
"""列章只读接口(按 project_id 隔离)——端点依赖此协议,测试可注入 fake。"""
async def list_chapters(self, project_id: uuid.UUID) -> list[ChapterListItem]: ...
class SqlChapterLister:
"""SQLAlchemy 实现:两条只读查询取行 → 交纯聚合。单项目章数有界,聚合放 Python 侧KISS"""
def __init__(self, session: AsyncSession) -> None:
self._s = session
async def list_chapters(self, project_id: uuid.UUID) -> list[ChapterListItem]:
chapter_result = await self._s.execute(
select(Chapter.chapter_no, Chapter.status, Chapter.content).where(
Chapter.project_id == project_id
)
)
review_result = await self._s.execute(
select(ChapterReview.chapter_no, ChapterReview.created_at).where(
ChapterReview.project_id == project_id
)
)
chapter_rows = [
ChapterRow(chapter_no=r.chapter_no, status=r.status, content=r.content)
for r in chapter_result.all()
]
review_rows = [
ReviewRow(chapter_no=r.chapter_no, created_at=r.created_at) for r in review_result.all()
]
return build_chapter_list(chapter_rows, review_rows)

View File

@@ -18,6 +18,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ww_config import get_settings
from ww_core.domain import ForeshadowLedgerRepo, SqlForeshadowLedgerRepo
from ww_core.domain.ai_message_repo import AiMessageRepo, SqlAiMessageRepo
from ww_core.domain.chapter_repo import ChapterRepo, SqlChapterRepo
from ww_core.domain.character_repo import CharacterWriteRepo, SqlCharacterWriteRepo
from ww_core.domain.digest_repo import DigestAppendRepo, SqlDigestAppendRepo
@@ -52,6 +53,7 @@ from ww_api.security.credentials import (
CredentialKeyError,
decrypt_api_key,
)
from ww_api.services.chapter_list import ChapterLister, SqlChapterLister
from ww_api.services.credentials import (
AUTH_TYPE_OAUTH,
STUB_OWNER_ID,
@@ -98,6 +100,13 @@ def get_chapter_repo(
return SqlChapterRepo(session)
def get_chapter_lister(
session: Annotated[AsyncSession, Depends(get_session)],
) -> ChapterLister:
"""列章只读服务GET /projects/:id/chapters。只读、不写库测试注入 fake lister。"""
return SqlChapterLister(session)
def get_memory_repos(
session: Annotated[AsyncSession, Depends(get_session)],
) -> MemoryRepos:
@@ -160,6 +169,16 @@ def get_rule_write_repo(
return SqlRuleWriteRepo(session)
def get_ai_message_repo(
session: Annotated[AsyncSession, Depends(get_session)],
) -> AiMessageRepo:
"""AI 对话留痕 repoPOST/GET /projects/{id}/ai-messagesappend 只 flush端点提交
测试经 `app.dependency_overrides[get_ai_message_repo]` 注入内存 fake。
"""
return SqlAiMessageRepo(session)
async def get_template_repo(
session: Annotated[AsyncSession, Depends(get_session)],
) -> TemplateRepo:
@@ -268,6 +287,14 @@ async def get_precheck_gateway(
return await build_gateway_for_tier(session, store, "analyst")
async def get_project_plan_gateway(
session: Annotated[AsyncSession, Depends(get_session)],
) -> Gateway:
"""AI 立项方案生成analyst 档位)的可注入网关缝。测试注 mock产 ProjectPlanResult"""
store = SqlCredentialStore(session)
return await build_gateway_for_tier(session, store, "analyst")
def get_session_factory() -> SessionFactory:
"""验收后到期扫描的**独立 session 工厂**缝。
@@ -511,6 +538,18 @@ async def get_refine_gateway(
return await build_gateway_for_tier(session, store, "writer")
async def get_clarify_gateway(
session: Annotated[AsyncSession, Depends(get_session)],
) -> Gateway:
"""润色预检澄清analyst 档位)的可注入网关缝。
`POST .../refine/clarify` 在 dep 解析阶段构建网关 → 无凭据时这里抛 `LLM_UNAVAILABLE`
503进入端点前拦下。测试经 override 注 mock产 `ClarifyDecision`)。
"""
store = SqlCredentialStore(session)
return await build_gateway_for_tier(session, store, "analyst")
async def get_chain_gateway(
session: Annotated[AsyncSession, Depends(get_session)],
) -> Gateway:

View File

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

13
apps/web/.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
# web 镜像构建上下文context=./apps/web。排除宿主产物避免 `COPY . .` 把宿主
# node_modules含平台原生 next-swc/esbuild 二进制)覆盖容器内 `pnpm install` 的 Linux 版,
# 导致 `pnpm build` 加载原生绑定失败。同时排除构建产物/日志加速上下文传输、保证可复现。
node_modules
.next
.turbo
npm-debug.log*
pnpm-debug.log*
.env
.env.local
.DS_Store
coverage
*.tsbuildinfo

1
apps/web/.gitignore vendored
View File

@@ -2,3 +2,4 @@
/.next
/lib/api/openapi.json
next-env.d.ts
.gstack/

View File

@@ -1,10 +1,30 @@
FROM node:22-slim AS build
RUN corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml* ./
RUN pnpm install --frozen-lockfile || pnpm install
# npm/corepack registry可经 build arg 覆盖):部分区域 registry.npmjs.org 不可达,部署时指向镜像
# (如 registry.npmmirror.com整源镜像 npmjs 且保留 integrity --frozen-lockfile 兼容)。
ARG NPM_REGISTRY=https://registry.npmjs.org
ENV npm_config_registry=$NPM_REGISTRY
# corepack 默认硬编码从 npmjs 下载 pnpm 本体;此处改指同一镜像。镜像不提供 npm 签名密钥,
# 关掉签名校验(下载的 pnpm tarball 仍按内容哈希校验,安装的依赖仍按 lockfile integrity 校验)。
ENV COREPACK_NPM_REGISTRY=$NPM_REGISTRY
ENV COREPACK_INTEGRITY_KEYS=0
RUN corepack enable
# pnpm-workspace.yaml 必须在 install 前就位:它携带 minimumReleaseAgeExclude豁免新发布包的
# 供应链策略、onlyBuiltDependencies/allowBuilds构建脚本白名单。缺它则容器内 install 看不到
# 这些策略,会误拒近期发布的依赖(如 @xyflow/*)而失败——与宿主行为不一致。
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml ./
# pnpm 11 不读 npm_config_registry env须用 --registry 显式指定镜像lockfile v9 仅存 integrity
# 不含绝对 URL故换源不破坏 --frozen-lockfile
RUN pnpm install --frozen-lockfile --registry="$NPM_REGISTRY" || pnpm install --registry="$NPM_REGISTRY"
COPY . .
# NEXT_PUBLIC_* 在 Next 构建期内联进浏览器 bundle——必须在 `pnpm build` 前用 build ARG 注入。
# 默认 http://localhost:8000 供 dev生产经 compose build args 传公网 API 基址。
ARG NEXT_PUBLIC_API_BASE=http://localhost:8000
ENV NEXT_PUBLIC_API_BASE=$NEXT_PUBLIC_API_BASE
RUN pnpm build
# 本项目 public/ 为空目录(不入 gitgit archive 构建上下文里不存在);确保它存在,
# 否则下方 run 阶段 `COPY --from=build /app/public` 会因源缺失而失败。
RUN mkdir -p /app/public
FROM node:22-slim AS run
WORKDIR /app
@@ -12,4 +32,7 @@ COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
EXPOSE 3000
# 存活探测node 打首页run 阶段无 curl5xx/连接失败退出非零。
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD ["node", "-e", "require('http').get('http://127.0.0.1:3000/',r=>process.exit(r.statusCode<500?0:1)).on('error',()=>process.exit(1))"]
CMD ["node", "server.js"]

37
apps/web/app/error.tsx Normal file
View File

@@ -0,0 +1,37 @@
"use client";
import Link from "next/link";
import { TriangleAlert } from "lucide-react";
import { EmptyState } from "@/components/ui/EmptyState";
import { Button } from "@/components/ui/Button";
import { buttonClass } from "@/lib/ui/variants";
interface ErrorBoundaryProps {
error: Error & { digest?: string };
reset: () => void;
}
export default function Error({ reset }: ErrorBoundaryProps) {
return (
<main className="flex min-h-screen items-center justify-center bg-bg px-6 py-16">
<div className="w-full max-w-md">
<EmptyState
icon={TriangleAlert}
title="出错了"
description="刚才的操作没能完成。可以重试,或先回到作品库。"
action={
<div className="flex items-center justify-center gap-2">
<Button variant="primary" onClick={reset}>
</Button>
<Link href="/" className={buttonClass({ variant: "secondary" })}>
</Link>
</div>
}
/>
</div>
</main>
);
}

View File

@@ -3,18 +3,88 @@
@tailwind utilities;
/* 纸感设计 tokenUX_SPEC §2.1 */
:root {
:root,
[data-theme="paper"] {
/* 面色阶:底 → 分隔带 → 内容卡(比底暗一步)→ 选中强调 → 浮起面板 */
--color-bg: #f5f1e8;
--color-surface-soft: #efe9dc;
--color-surface-card: #ece4d3;
--color-surface-strong: #e4d9c4;
--color-panel: #fbf8f1;
/* 文字色阶5 级):标题 → 强调段 → 正文 → 次级 → 说明/细则 */
--color-ink: #2b2620;
--color-body-strong: #3a342b;
--color-body: #4a4234;
--color-ink-soft: #6b6356;
/* muted-soft 达 WCAG AA 小字≥4.5:1于 bg/card/panel/soft 各面P4-1。 */
--color-muted-soft: #6f6556;
/* 边框:主边框 + 同带内更弱分隔 */
--color-line: #e5ddcd;
--color-line-soft: #ece4d5;
/* 朱砂 coral主色 + 按压变深 + 奶油化禁用 + 淡底 */
--color-cinnabar: #a23b2e;
--color-cinnabar-active: #8a2f24;
--color-cinnabar-disabled: #d9cdbd;
--color-cinnabar-wash: #a23b2e14;
--color-conflict: #b5543a;
--color-overdue: #c8893a;
--color-pass: #5a6b4f;
--color-info: #4a5a6b;
--color-conflict-mark: #b5543a26;
--color-conflict-mark-strong: #b5543a8c;
/* 暖深色 callout仅用于英雄/CTA 时刻(对标 DESIGN.md cta-band-dark非内容底。 */
--color-callout: #201d18;
--color-on-callout: #f2ede2;
--color-on-callout-soft: #b8ae9c;
--shadow-paper: #2b26200f;
color-scheme: light;
}
/* 动效 token与主题无关单处定义夜读模式不覆盖 */
:root {
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--dur-fast: 120ms;
--dur-base: 180ms;
}
[data-theme="night"] {
/* 面色阶:底 → 分隔带 → 浮起面板 → 内容卡(暗面向上抬)→ 选中强调 */
--color-bg: #181614;
--color-surface-soft: #1e1b17;
--color-surface-card: #2a2620;
--color-surface-strong: #332e26;
--color-panel: #221f1b;
/* 文字色阶5 级) */
--color-ink: #efe6d7;
--color-body-strong: #ddd2c0;
--color-body: #cdc0ab;
--color-ink-soft: #b9ab96;
/* muted-soft 达 AA 小字≥4.5:1于夜读 bg/card/soft 各面P4-1。 */
--color-muted-soft: #9a8e7c;
/* 边框 */
--color-line: #3c352c;
--color-line-soft: #2f2a23;
/* 朱砂 coral */
--color-cinnabar: #e07866;
--color-cinnabar-active: #c85f4d;
--color-cinnabar-disabled: #3c352c;
--color-cinnabar-wash: #e0786620;
--color-conflict: #ef8a72;
--color-overdue: #d8a65d;
--color-pass: #95b47d;
--color-info: #8ca8ca;
--color-conflict-mark: #ef8a7230;
--color-conflict-mark-strong: #ef8a7290;
/* 夜读下 callout 为更深的凹陷暖井,仍与 bg 拉开层次。 */
--color-callout: #100e0c;
--color-on-callout: #efe6d7;
--color-on-callout-soft: #a89d8a;
--shadow-paper: #00000045;
color-scheme: dark;
}
html {
background: var(--color-bg);
}
body {
@@ -22,6 +92,11 @@ body {
color: var(--color-ink);
}
::selection {
background: var(--color-cinnabar-wash);
color: var(--color-ink);
}
/* 流式打字机光标:朱砂闪烁;尊重 prefers-reduced-motionUX §10。 */
.typewriter-cursor {
animation: typewriter-blink 1s step-end infinite;
@@ -42,7 +117,7 @@ body {
/* 终稿正文冲突原文持续高亮朱砂淡底点锚点定位时闪烁一下UX §8.3)。 */
.draft-mark {
background-color: #b5543a26; /* --color-conflict @ ~15% */
background-color: var(--color-conflict-mark);
border-radius: 2px;
}
.draft-mark-flash {
@@ -52,14 +127,118 @@ body {
@keyframes draft-mark-flash {
0%,
100% {
background-color: #b5543a26;
background-color: var(--color-conflict-mark);
}
30% {
background-color: #b5543a8c; /* --color-conflict @ ~55% */
background-color: var(--color-conflict-mark-strong);
}
}
/* 主题切换图标:显隐由 data-theme 决定(而非 JS 状态),两个图标始终在 DOM 里,
服务端/客户端首帧结构一致——既避免水合不一致,又无首帧闪烁。 */
.theme-icon-moon {
display: inline-block;
}
.theme-icon-sun {
display: none;
}
[data-theme="night"] .theme-icon-moon {
display: none;
}
[data-theme="night"] .theme-icon-sun {
display: inline-block;
}
/* Toast 入场:淡入 + 轻微上移(尊重 prefers-reduced-motion见下。 */
.toast-enter {
animation: toast-in var(--dur-base) var(--ease-standard);
}
@keyframes toast-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* AI 工作中·三点波动(比 animate-pulse 更明显:竖向弹跳 + 亮度,供 ThinkingIndicator 用)。 */
.ai-dot {
animation: ai-dot 1.2s ease-in-out infinite;
}
@keyframes ai-dot {
0%,
80%,
100% {
transform: translateY(0);
opacity: 0.4;
}
40% {
transform: translateY(-3px);
opacity: 1;
}
}
/* AI 流式/处理中·不确定进度条(细条来回扫,明确传达"正在工作")。 */
.ai-stream-track {
position: relative;
overflow: hidden;
background: var(--color-cinnabar-wash);
}
.ai-stream-track::after {
content: "";
position: absolute;
inset-block: 0;
left: -40%;
width: 40%;
border-radius: 9999px;
background: var(--color-cinnabar);
animation: ai-stream 1.4s ease-in-out infinite;
}
@keyframes ai-stream {
0% {
left: -40%;
width: 35%;
}
50% {
width: 55%;
}
100% {
left: 100%;
width: 35%;
}
}
/* AI 构思中·占位微光(正文区首 token 前的呼吸感,比骨架更"活")。 */
.ai-breathe {
animation: ai-breathe 1.8s ease-in-out infinite;
}
@keyframes ai-breathe {
0%,
100% {
opacity: 0.55;
}
50% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.ai-dot {
animation: none;
opacity: 0.85;
}
.ai-stream-track::after {
animation: none;
left: 0;
width: 100%;
opacity: 0.35;
}
.ai-breathe {
animation: none;
}
.typewriter-cursor {
animation: none;
}
@@ -70,4 +249,7 @@ body {
animation: none;
outline: 2px solid var(--color-conflict);
}
.toast-enter {
animation: none;
}
}

298
apps/web/app/help/page.tsx Normal file
View File

@@ -0,0 +1,298 @@
import Link from "next/link";
import type { LucideIcon } from "lucide-react";
import {
BookMarked,
BookOpen,
Blocks,
CheckCircle2,
ClipboardCheck,
FileText,
Flag,
LayoutGrid,
ListTree,
Moon,
PanelLeftClose,
PenLine,
Route,
Search,
Settings,
ShieldCheck,
Sparkles,
} from "lucide-react";
import { AppShell } from "@/components/AppShell";
import { Badge } from "@/components/ui/Badge";
import { Card } from "@/components/ui/Card";
import { Eyebrow } from "@/components/ui/Eyebrow";
import { PageContainer } from "@/components/ui/PageContainer";
import { buttonClass } from "@/lib/ui/variants";
// 帮助中心:功能介绍 + 新手上手静态内容Server Component
interface Step {
icon: LucideIcon;
title: string;
body: string;
}
const STEPS: Step[] = [
{
icon: PenLine,
title: "立项,或直接开始写",
body: "从一句灵感开始:「先立项」立好核心卖点与一句话故事,再到设定库、大纲把架构搭起来;或「直接开始写」落笔即成书,边写边补设定。",
},
{
icon: Sparkles,
title: "让 AI 写这章",
body: "在写作台告诉 AI 这章想怎么写,它对着大纲给出草稿(流式出稿)。你可以续写接着写、润色选段、或整章重写——原文永远你说了算。",
},
{
icon: ClipboardCheck,
title: "四审,然后验收",
body: "写完点「审稿」AI 从一致性 / 伏笔 / 文风 / 节奏四个维度逐条检查、就地标注冲突。你裁决后验收,系统更新真相源与伏笔账本。",
},
];
interface Principle {
title: string;
body: string;
}
const PRINCIPLES: Principle[] = [
{
title: "把写小说当软件工程",
body: "先立架构(世界观 + 人物 + 大纲),再对着规格逐章生成,每章写完即测,全程维护单一真相源。不让 AI 一口气瞎写十万字。",
},
{
title: "单一真相源 · 记忆不衰减",
body: "设定、状态、伏笔集中记账AI 写每一章都按需读取相关记忆(确定性选择,非一次性塞满上下文),对抗长篇遗忘。",
},
{
title: "你始终掌控",
body: "AI 是灵感增幅器 + 质检员,不是全自动黑箱。四审只读、只给建议,任何写入都要你裁决采纳,不会静默覆盖你的原文。",
},
];
interface Feature {
icon: LucideIcon;
name: string;
desc: string;
}
interface FeatureGroup {
label: string;
features: Feature[];
}
const FEATURE_GROUPS: FeatureGroup[] = [
{
label: "写作",
features: [
{
icon: PenLine,
name: "写作台",
desc: "对着大纲逐章生成、流式出稿;续写接着写、润色选段、整章重写,随时进入专注模式沉浸写作。",
},
],
},
{
label: "资料库",
features: [
{ icon: ListTree, name: "大纲", desc: "卷 → 章 → 节拍逐层拆解;可用「细纲生成器」把某章展开为场景,入库时并入该章节拍。" },
{ icon: BookMarked, name: "设定库", desc: "世界观圣经 + 人物卡(动机、人物弧光,像接口约束行为)+ 关系图谱。" },
{ icon: Flag, name: "伏笔", desc: "「埋设 → 回收」的显式账本,逾期未收进「已逾期」泳道并标记,不再漏坑。" },
{ icon: FileText, name: "文风", desc: "抽取你的文风指纹并注入生成,抵抗机翻腔与风格漂移。" },
],
},
{
label: "审阅",
features: [
{
icon: ClipboardCheck,
name: "审稿 · 四审",
desc: "每章写完即审:一致性 / 伏笔 / 文风 / 节奏,冲突就地标注、逐条裁决后验收。",
},
{ icon: Route, name: "多章链", desc: "从指定章起循环「写章 → 四审 → 验收」批量推进,遇未决冲突暂停等你裁决再续跑。" },
],
},
{
label: "工具与全局",
features: [
{ icon: Blocks, name: "工具箱", desc: "十多个生成器:脑洞 / 书名 / 简介 / 金手指 / 词条 / 黄金开篇 / 细纲 / 拆书等;部分可一键入库,部分为预览产出。" },
{ icon: ShieldCheck, name: "规则", desc: "沉淀你的创作规则,分全局 / 题材 / 文风 / 本作四级,约束 AI 少犯同类错。" },
{ icon: LayoutGrid, name: "模板库", desc: "可复用的提示词模板,在工具箱生成器里一键填入需求 / 原文。" },
{ icon: Settings, name: "设置", desc: "写手 / 分析 / 轻量三档模型路由 + API Key / Kimi OAuth。" },
],
},
];
interface Tip {
icon: LucideIcon;
title: string;
body: string;
}
const TIPS: Tip[] = [
{ icon: Search, title: "命令面板 ⌘K", body: "任意页面按 ⌘K 快速跳转页面或执行命令。" },
{ icon: PanelLeftClose, title: "专注写作模式", body: "写作台底栏点「专注」,隐藏侧栏、加宽正文,沉浸写作。" },
{ icon: Moon, title: "夜读模式", body: "顶栏月亮图标切换暖色夜读,长时间写作更护眼。" },
{ icon: CheckCircle2, title: "原文永远你说了算", body: "AI 给的是草稿,采不采用由你定,绝不静默覆盖你的正文。" },
];
interface Faq {
q: string;
a: string;
}
const FAQS: Faq[] = [
{
q: "AI 会自动改我的正文吗?",
a: "不会。四审是只读的、只给建议;任何写入都要你在验收环节裁决,冲突未解决会阻止验收。",
},
{
q: "用的是哪个大模型?",
a: "后端是多提供商网关,写手 / 分析 / 轻量三档可各自路由Claude / DeepSeek / Kimi / GPT / Gemini / 通义 / GLM 等,可回退),在「设置 → 模型与提供商」里配。",
},
{
q: "写到几十万字会不会记忆衰减?",
a: "靠单一真相源 + 按需记忆注入:写每章只挑相关的设定、角色与最近若干章,而不是把全书塞进上下文。",
},
{
q: "我的数据在哪里?",
a: "本地 Postgres单用户原型设定/正文/审稿结果都以数据库为唯一真相源,记忆追加保留历史、不就地覆盖。",
},
];
function SectionTitle({ eyebrow, title }: { eyebrow: string; title: string }) {
return (
<div className="mb-5">
<Eyebrow className="mb-1.5">{eyebrow}</Eyebrow>
<h2 className="font-serif text-display-sm text-ink">{title}</h2>
</div>
);
}
function IconChip({ icon: Icon }: { icon: LucideIcon }) {
return (
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-[var(--color-cinnabar-wash)] text-cinnabar">
<Icon className="h-4 w-4" aria-hidden="true" />
</span>
);
}
export default function HelpPage() {
return (
<AppShell>
<PageContainer>
{/* 英雄:暖深色编辑部条(对标 DESIGN.md cta-band-dark。 */}
<section className="mb-12 rounded-xl border border-line/50 bg-callout px-8 py-12 shadow-paper sm:px-10">
<Eyebrow className="text-on-callout-soft"> · 使</Eyebrow>
<h1 className="mt-3 max-w-2xl font-serif text-display-md text-on-callout">
</h1>
<p className="mt-4 max-w-2xl text-body leading-7 text-on-callout-soft">
</p>
<div className="mt-6 flex flex-wrap gap-2">
<Link href="/write" className={buttonClass({ variant: "primary" })}>
<PenLine className="h-4 w-4" aria-hidden="true" />
</Link>
<Link href="/projects/new" className={buttonClass({ variant: "secondary" })}>
</Link>
</div>
</section>
{/* 新手三步 */}
<section className="mb-14">
<SectionTitle eyebrow="快速上手" title="三步写出第一章" />
<ol className="grid gap-4 sm:grid-cols-3">
{STEPS.map((step, i) => (
<li key={step.title}>
<Card tone="card" className="flex h-full flex-col gap-3 p-5">
<div className="flex items-center gap-3">
<IconChip icon={step.icon} />
<span className="font-mono text-caption text-muted-soft">
{i + 1}
</span>
</div>
<h3 className="font-serif text-title-md text-ink">{step.title}</h3>
<p className="text-caption leading-6 text-ink-soft">{step.body}</p>
</Card>
</li>
))}
</ol>
</section>
{/* 核心理念 */}
<section className="mb-14">
<SectionTitle eyebrow="为什么这样设计" title="三个核心理念" />
<div className="grid gap-4 sm:grid-cols-3">
{PRINCIPLES.map((p) => (
<Card key={p.title} tone="soft" flat className="p-5">
<h3 className="font-serif text-title-md text-ink">{p.title}</h3>
<p className="mt-2 text-caption leading-6 text-ink-soft">{p.body}</p>
</Card>
))}
</div>
</section>
{/* 功能一览 */}
<section className="mb-14">
<SectionTitle eyebrow="功能介绍" title="每个板块是做什么的" />
<div className="space-y-8">
{FEATURE_GROUPS.map((group) => (
<div key={group.label}>
<h3 className="mb-3 flex items-center gap-2 font-sans text-title-md text-ink">
{group.label}
<Badge variant="neutral">{group.features.length}</Badge>
</h3>
<div className="grid gap-4 sm:grid-cols-2">
{group.features.map((f) => (
<Card key={f.name} className="flex gap-3 p-4">
<IconChip icon={f.icon} />
<div className="min-w-0">
<p className="font-serif text-title-md text-ink">{f.name}</p>
<p className="mt-1 text-caption leading-6 text-ink-soft">{f.desc}</p>
</div>
</Card>
))}
</div>
</div>
))}
</div>
</section>
{/* 小技巧 */}
<section className="mb-14">
<SectionTitle eyebrow="顺手小技巧" title="用得更顺" />
<div className="grid gap-4 sm:grid-cols-2">
{TIPS.map((t) => (
<div key={t.title} className="flex gap-3">
<IconChip icon={t.icon} />
<div className="min-w-0">
<p className="font-medium text-ink">{t.title}</p>
<p className="mt-0.5 text-caption leading-6 text-ink-soft">{t.body}</p>
</div>
</div>
))}
</div>
</section>
{/* 常见问题 */}
<section>
<SectionTitle eyebrow="常见问题" title="你可能想问" />
<dl className="space-y-3">
{FAQS.map((f) => (
<Card key={f.q} as="div" className="p-5">
<dt className="font-serif text-title-md text-ink">{f.q}</dt>
<dd className="mt-2 text-caption leading-6 text-ink-soft">{f.a}</dd>
</Card>
))}
</dl>
</section>
</PageContainer>
</AppShell>
);
}

View File

@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import "./globals.css";
import { CommandPaletteMount } from "@/components/command/CommandPaletteMount";
import { ThemeScript } from "@/components/ThemeScript";
import { ToastProvider } from "@/components/Toast";
export const metadata: Metadata = {
@@ -19,6 +20,7 @@ export default function RootLayout({
return (
<html lang="zh-CN" suppressHydrationWarning>
<body className="font-sans antialiased" suppressHydrationWarning>
<ThemeScript />
<ToastProvider>
{children}
<CommandPaletteMount />

79
apps/web/app/loading.tsx Normal file
View File

@@ -0,0 +1,79 @@
import { Card } from "@/components/ui/Card";
import { PageContainer } from "@/components/ui/PageContainer";
import { Skeleton } from "@/components/ui/Skeleton";
// 全站加载骨架:与作品库结构近似(标题条 + 工具条 + 卡片网格),
// motion-safe 脉冲,尊重 prefers-reduced-motion。
export default function Loading() {
return (
<main className="min-h-screen bg-bg">
<PageContainer>
<div role="status" aria-label="加载中" className="space-y-8">
<span className="sr-only"></span>
<HeaderSkeleton />
<ToolbarSkeleton />
<GridSkeleton />
</div>
</PageContainer>
</main>
);
}
// 标题区:大标题条 + 说明条 + 右侧两枚按钮位。
function HeaderSkeleton() {
return (
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-3">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-72 max-w-full" />
</div>
<div className="flex gap-2">
<Skeleton className="h-9 w-28" />
<Skeleton className="h-9 w-24" />
</div>
</div>
);
}
// 工具条:搜索框 + 三枚控件位。
function ToolbarSkeleton() {
return (
<Card flat className="grid gap-3 p-3 md:grid-cols-[1fr_auto_auto_auto]">
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-28" />
<Skeleton className="h-9 w-28" />
<Skeleton className="h-9 w-20" />
</Card>
);
}
// 卡片网格6 张与作品卡等高的占位卡。
function GridSkeleton() {
return (
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<CardSkeleton key={i} />
))}
</div>
);
}
// 单张作品卡骨架:标题 + 徽章 + 三行简介 + 底部时间。
function CardSkeleton() {
return (
<Card flat className="flex h-full min-h-[176px] flex-col p-6">
<div className="mb-4 flex items-start justify-between gap-3">
<div className="min-w-0 flex-1 space-y-3">
<Skeleton className="h-6 w-3/4" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
<Skeleton className="h-8 w-8 shrink-0" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
</div>
<Skeleton className="mt-auto h-3 w-28" />
</Card>
);
}

View File

@@ -0,0 +1,24 @@
import Link from "next/link";
import { FileQuestion } from "lucide-react";
import { EmptyState } from "@/components/ui/EmptyState";
import { buttonClass } from "@/lib/ui/variants";
export default function NotFound() {
return (
<main className="flex min-h-screen items-center justify-center bg-bg px-6 py-16">
<div className="w-full max-w-md">
<EmptyState
icon={FileQuestion}
title="页面不存在"
description="你要找的内容可能已被移动或从未存在。回到作品库继续创作吧。"
action={
<Link href="/" className={buttonClass({ variant: "primary" })}>
</Link>
}
/>
</div>
</main>
);
}

View File

@@ -1,9 +1,14 @@
import Link from "next/link";
import { BookOpen, PenLine, Plus } from "lucide-react";
import { AppShell } from "@/components/AppShell";
import { ProjectCard } from "@/components/ProjectCard";
import { BackendDownNotice } from "@/components/BackendDownNotice";
import { ProjectLibrary } from "@/components/projects/ProjectLibrary";
import { PageContainer } from "@/components/ui/PageContainer";
import { PageHeader } from "@/components/ui/PageHeader";
import { fetchProjects } from "@/lib/api/server";
import type { ProjectResponse } from "@/lib/api/types";
import { buttonClass } from "@/lib/ui/variants";
// 作品库Dashboard 入口UX §6.1。Server Component 读取。
export default async function DashboardPage() {
@@ -18,63 +23,63 @@ export default async function DashboardPage() {
return (
<AppShell>
<div className="mx-auto max-w-5xl px-8 py-10">
<div className="mb-8 flex items-center justify-between">
<h1 className="font-serif text-3xl text-ink"></h1>
<PageContainer width="wide">
<PageHeader
title="我的作品"
description="从一个灵感进入正文、设定、审稿与验收闭环。"
actions={
<div className="flex flex-wrap items-center gap-2">
<Link
href="/write"
className={buttonClass({ variant: "primary" })}
>
<PenLine className="h-4 w-4" aria-hidden="true" />
</Link>
<Link
href="/projects/new"
className="rounded bg-cinnabar px-4 py-2 text-sm text-panel hover:opacity-90"
className={buttonClass({ variant: "secondary" })}
>
<Plus className="h-4 w-4" aria-hidden="true" />
</Link>
</div>
}
/>
{loadError ? (
<p className="rounded border border-conflict bg-panel p-6 text-conflict">
API
</p>
<BackendDownNotice />
) : projects.length === 0 ? (
<EmptyState />
) : (
<ul className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{projects.map((p) => (
<li key={p.id}>
<ProjectCard project={p} />
</li>
))}
<li>
<NewProjectCard />
</li>
</ul>
)}
// 首次进入的暖深色编辑部英雄(对标 DESIGN.md cta-band-dark仅零作品时出现不干扰工作库
<div className="rounded-xl border border-line/50 bg-callout px-8 py-14 text-center shadow-paper sm:px-12 sm:py-20">
<BookOpen className="mx-auto h-9 w-9 text-cinnabar" aria-hidden="true" />
<h2 className="mt-5 font-serif text-display-sm text-on-callout">
</h2>
<p className="mx-auto mt-3 max-w-md text-body leading-7 text-on-callout-soft">
</p>
<div className="mt-7 flex flex-wrap items-center justify-center gap-2">
<Link
href="/write"
className={buttonClass({ variant: "primary", size: "lg" })}
>
<PenLine className="h-4 w-4" aria-hidden="true" />
</Link>
<Link
href="/projects/new"
className={buttonClass({ variant: "secondary", size: "lg" })}
>
<Plus className="h-4 w-4" aria-hidden="true" />
</Link>
</div>
</div>
) : (
<ProjectLibrary projects={projects} />
)}
</PageContainer>
</AppShell>
);
}
function EmptyState() {
return (
<div className="flex flex-col items-center gap-4 rounded border border-line bg-panel py-20 text-center">
<p className="font-serif text-2xl text-ink"></p>
<p className="text-ink-soft"></p>
<Link
href="/projects/new"
className="rounded bg-cinnabar px-5 py-2 text-sm text-panel hover:opacity-90"
>
</Link>
</div>
);
}
function NewProjectCard() {
return (
<Link
href="/projects/new"
className="flex h-full min-h-[160px] flex-col items-center justify-center rounded border border-dashed border-line bg-panel text-center hover:border-cinnabar"
>
<span className="font-serif text-xl text-cinnabar"> </span>
<span className="mt-2 text-sm text-ink-soft"></span>
</Link>
);
}

View File

@@ -1,5 +1,4 @@
import { notFound } from "next/navigation";
import { BackendDownNotice } from "@/components/BackendDownNotice";
import { CodexPage } from "@/components/codex/CodexPage";
import {
fetchCharacters,
@@ -37,6 +36,6 @@ export default async function CodexRoutePage({
/>
);
} catch {
notFound();
return <BackendDownNotice className="m-6" />;
}
}

View File

@@ -1,7 +1,9 @@
import { notFound } from "next/navigation";
import { BackendDownNotice } from "@/components/BackendDownNotice";
import { ForeshadowBoard } from "@/components/foreshadow/ForeshadowBoard";
import { fetchForeshadow, fetchProject } from "@/lib/api/server";
import type { ForeshadowView, ProjectResponse } from "@/lib/api/types";
interface PageProps {
params: Promise<{ id: string }>;
@@ -11,13 +13,30 @@ interface PageProps {
// ForeshadowBoardClient按 status 分四泳道并承载登记/状态变更交互。
export default async function ForeshadowPage({ params }: PageProps) {
const { id } = await params;
// 仅当项目确实不存在404才判 notFound后端不可用等其它失败降级为「后端不可达」提示
// 不把整页变成「找不到页面」。
let project: ProjectResponse;
try {
const project = await fetchProject(id);
const board = await fetchForeshadow(id);
return (
<ForeshadowBoard project={project} initialItems={board.foreshadow ?? []} />
);
} catch {
notFound();
project = await fetchProject(id);
} catch (err) {
if (isNotFoundError(err)) notFound();
return <BackendDownNotice className="m-6" />;
}
// 伏笔看板GET .../foreshadow。瞬时错误降级为空看板不阻塞进页。
let initialItems: ForeshadowView[] = [];
try {
const board = await fetchForeshadow(id);
initialItems = board.foreshadow ?? [];
} catch {
initialItems = [];
}
return <ForeshadowBoard project={project} initialItems={initialItems} />;
}
// fetchProject 抛错信息形如「请求失败 404: ...」;据此区分「项目不存在」与「后端不可用」。
function isNotFoundError(err: unknown): boolean {
return err instanceof Error && err.message.includes("请求失败 404");
}

View File

@@ -1,7 +1,9 @@
import { notFound } from "next/navigation";
import { BackendDownNotice } from "@/components/BackendDownNotice";
import { OutlineEditor } from "@/components/outline/OutlineEditor";
import { fetchOutline, fetchProject } from "@/lib/api/server";
import type { ProjectResponse } from "@/lib/api/types";
interface PageProps {
params: Promise<{ id: string }>;
@@ -12,13 +14,22 @@ interface PageProps {
// 仍按需触发 POST .../outline 生成/覆盖所显章节。
export default async function OutlinePage({ params }: PageProps) {
const { id } = await params;
// 仅当项目确实不存在404才判 notFound后端不可用等其它失败降级为「后端不可达」提示
// 不把整页变成「找不到页面」。次级拉取(大纲)的瞬时错误已在 fetchOutline 内降级为空。
let project: ProjectResponse;
try {
const project = await fetchProject(id);
const initialChapters = await fetchOutline(id);
return (
<OutlineEditor project={project} initialChapters={initialChapters} />
);
} catch {
notFound();
project = await fetchProject(id);
} catch (err) {
if (isNotFoundError(err)) notFound();
return <BackendDownNotice className="m-6" />;
}
const initialChapters = await fetchOutline(id);
return <OutlineEditor project={project} initialChapters={initialChapters} />;
}
// fetchProject 抛错信息形如「请求失败 404: ...」;据此区分「项目不存在」与「后端不可用」。
function isNotFoundError(err: unknown): boolean {
return err instanceof Error && err.message.includes("请求失败 404");
}

View File

@@ -1,38 +1,89 @@
import { notFound } from "next/navigation";
import { ReviewReport } from "@/components/review/ReviewReport";
import { fetchDraft, fetchProject, fetchReviews } from "@/lib/api/server";
import {
fetchChapters,
fetchDraft,
fetchOutline,
fetchProject,
fetchReviews,
} from "@/lib/api/server";
import type { ProjectResponse } from "@/lib/api/types";
import {
buildReviewChapterOptions,
defaultReviewChapter,
} from "@/lib/review/chapterNav";
import { latestReview } from "@/lib/review/history";
import type { ChapterEntry } from "@/lib/workbench/chapter";
interface PageProps {
params: Promise<{ id: string }>;
searchParams: Promise<{ chapter?: string }>;
}
const DEFAULT_CHAPTER_NO = 1;
// 审稿报告页UX §6.4。Server Component 取项目 + 审稿留痕历史(新→旧);
// ReviewReportClient承载 SSE 重审 / 裁决 / 验收交互。
export default async function ReviewPage({ params, searchParams }: PageProps) {
const { id } = await params;
const { chapter } = await searchParams;
const chapterNo = parsePositiveInt(chapter) ?? DEFAULT_CHAPTER_NO;
// 仅当项目确实取不到时才判 404审稿/草稿/大纲等次级拉取的瞬时错误不应把整页变成「找不到」。
let project: ProjectResponse;
try {
const project = await fetchProject(id);
const history = await fetchReviews(id, chapterNo);
// 终稿初值取已存草稿GET .../draft404→null→"");否则 final_text 为空 → accept 422。
const draft = await fetchDraft(id, chapterNo);
return (
<ReviewReport
project={project}
chapterNo={chapterNo}
initialReview={latestReview(history.reviews)}
initialDraft={draft?.content ?? ""}
/>
);
project = await fetchProject(id);
} catch {
notFound();
}
// 真实存在的章(含可审/已审标记,错误→[]):供选章枚举 + 决定默认章。
const chapterList = await fetchChapters(id);
// 无 ?chapter 时默认落到最近一个有草稿的章,而非硬编码第 1 章。
const chapterNo = parsePositiveInt(chapter) ?? defaultReviewChapter(chapterList);
// 审稿留痕GET .../reviews失败→空历史
let initialReview;
try {
const history = await fetchReviews(id, chapterNo);
initialReview = latestReview(history.reviews);
} catch {
initialReview = undefined;
}
// 终稿初值取已存草稿GET .../draft404→null→"");否则 final_text 为空 → accept 422。
let initialDraft = "";
try {
const draft = await fetchDraft(id, chapterNo);
initialDraft = draft?.content ?? "";
} catch {
initialDraft = "";
}
// 大纲章节作章节导航目录fetchOutline 已对空/错误降级为 []);首个节拍当短标题。
const chapters: ChapterEntry[] = (await fetchOutline(id)).map((c) => ({
no: c.no,
title: c.beats?.[0],
}));
// 选章下拉选项:以真实章为准(覆盖「写过但不在大纲」的章),大纲标题补短名,标注可审/已审。
const chapterOptions = buildReviewChapterOptions(
chapterList,
chapters,
chapterNo,
);
return (
// key=章号:客户端切章(?chapter=N 变化)时强制重挂载,用新章审稿/草稿刷新状态,
// 避免 ReviewReport 内 useState 基线沿用上一章。
<ReviewReport
key={chapterNo}
project={project}
chapterNo={chapterNo}
initialReview={initialReview}
initialDraft={initialDraft}
chapters={chapters}
chapterOptions={chapterOptions}
/>
);
}
function parsePositiveInt(raw: string | undefined): number | null {

View File

@@ -1,5 +1,4 @@
import { notFound } from "next/navigation";
import { BackendDownNotice } from "@/components/BackendDownNotice";
import { ToolboxPage } from "@/components/toolbox/ToolboxPage";
import { fetchProject, fetchToolbox } from "@/lib/api/server";
@@ -29,6 +28,6 @@ export default async function ToolboxRoutePage({
/>
);
} catch {
notFound();
return <BackendDownNotice className="m-6" />;
}
}

View File

@@ -1,5 +1,7 @@
import { AppShell } from "@/components/AppShell";
import { BackendDownNotice } from "@/components/BackendDownNotice";
import { ProvidersSettings } from "@/components/settings/ProvidersSettings";
import { PageHeader } from "@/components/ui/PageHeader";
import { fetchKimiOauthStatus, fetchProviders } from "@/lib/api/server";
import type { ProvidersResponse } from "@/lib/api/types";
@@ -27,11 +29,13 @@ export default async function ProvidersSettingsPage() {
return (
<AppShell title="设置 模型与提供商">
<div className="mx-auto max-w-3xl px-8 py-10">
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-8 sm:py-10">
<PageHeader
title="模型与提供商"
description="配置写手、分析、轻量三类能力档位的路由,并管理 API Key 与 Kimi Code OAuth。"
/>
{loadError ? (
<p className="rounded border border-conflict bg-panel p-6 text-conflict">
API
</p>
<BackendDownNotice />
) : (
<ProvidersSettings initial={initial} kimiOauth={kimiOauth} />
)}

View File

@@ -1,14 +1,23 @@
import { AppShell } from "@/components/AppShell";
import { TemplatesManager } from "@/components/templates/TemplatesManager";
import { fetchTemplates } from "@/lib/api/server";
import type { TemplateResponse } from "@/lib/api/types";
import { PageHeader } from "@/components/ui/PageHeader";
import { StatusNote } from "@/components/ui/StatusNote";
import { fetchTemplates, fetchToolbox } from "@/lib/api/server";
import type { TemplateResponse, ToolDescriptorView } from "@/lib/api/types";
// 提示词/模板库F3全局单用户本地版。Server Component 预取列表CRUD 在客户端组件。
// 提示词/模板库F3全局单用户本地版。Server Component 预取列表 + 工具描述符(供「关联工具」下拉);
// CRUD 在客户端组件。fetchToolbox 任何错误内部降级为空列表,不阻塞进页。
export default async function TemplatesPage() {
let initial: TemplateResponse[] = [];
let tools: ToolDescriptorView[] = [];
let loadError = false;
try {
initial = await fetchTemplates();
const [templates, toolbox] = await Promise.all([
fetchTemplates(),
fetchToolbox(),
]);
initial = templates;
tools = toolbox.tools ?? [];
} catch {
loadError = true;
}
@@ -16,15 +25,17 @@ export default async function TemplatesPage() {
return (
<AppShell title="提示词 / 模板库">
<div className="mx-auto max-w-3xl px-8 py-10">
<p className="mb-6 text-xs text-ink-soft">
brief /
</p>
<PageHeader
title="提示词 / 模板库"
eyebrow="prompt templates"
description="保存常用提示词,复用时一键填入生成器的需求 / 原文输入。仅本地、单用户,不做分享。"
/>
{loadError ? (
<p className="rounded border border-conflict bg-panel p-6 text-conflict">
API
</p>
<StatusNote variant="danger" title="无法连接后端服务">
<p> API </p>
</StatusNote>
) : (
<TemplatesManager initial={initial} />
<TemplatesManager initial={initial} tools={tools} />
)}
</div>
</AppShell>

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,48 +0,0 @@
import Link from "next/link";
import { aiToolItems } from "@/lib/nav/ai-tools";
import type { ActiveNav } from "@/lib/nav/items";
interface AiToolbarProps {
projectId: string;
activeNav?: ActiveNav;
}
// T4-a · 项目内页常驻 AI 工具条顶栏下UX §3/§5
// 纯链接服务端组件:写本章=朱砂主动作,其余次级;激活项朱砂高亮。窄屏横向滚动。
export function AiToolbar({ projectId, activeNav }: AiToolbarProps) {
return (
<nav
aria-label="AI 工具条"
className="flex h-12 items-center gap-2 overflow-x-auto whitespace-nowrap border-b border-line bg-panel px-4 sm:px-6"
>
{aiToolItems(projectId).map((item) => {
const isActive = item.key === activeNav;
const className = toolClassName(item.primary === true, isActive);
return (
<Link
key={item.href}
href={item.href}
aria-current={isActive ? "page" : undefined}
className={className}
>
<span aria-hidden="true">{item.glyph}</span>
<span>{item.label}</span>
</Link>
);
})}
</nav>
);
}
function toolClassName(isPrimary: boolean, isActive: boolean): string {
const base =
"flex items-center gap-1.5 rounded border px-3 py-1.5 text-sm transition-colors";
if (isActive) {
return `${base} border-cinnabar text-cinnabar`;
}
if (isPrimary) {
return `${base} border-cinnabar bg-cinnabar text-panel hover:border-cinnabar`;
}
return `${base} border-line text-ink-soft hover:border-cinnabar hover:text-cinnabar`;
}

View File

@@ -1,10 +1,13 @@
import Link from "next/link";
import type { ReactNode } from "react";
import { FileQuestion, Settings } from "lucide-react";
import type { ActiveNav } from "@/lib/nav/items";
import { AiToolbar } from "./AiToolbar";
import { buttonClass, focusRing } from "@/lib/ui/variants";
import { CommandSearchButton } from "./command/CommandPaletteMount";
import { LeftNav } from "./LeftNav";
import { NavDrawer } from "./NavDrawer";
import { ThemeToggle } from "./ThemeToggle";
interface AppShellProps {
children: ReactNode;
@@ -15,6 +18,9 @@ interface AppShellProps {
projectId?: string;
// 当前激活的项目级入口键(用于高亮)。
activeNav?: ActiveNav;
// 专注写作模式P3-4隐藏桌面左侧图标导航以加宽正文顶栏汉堡/命令面板仍可达导航。
// 缺省 false → 其余页面外观不变(加法式可选项)。
hideNav?: boolean;
}
// 全局外壳:顶栏(64px) + 左导航 + 主区UX §5.1)。
@@ -24,42 +30,79 @@ export function AppShell({
subtitle,
projectId,
activeNav,
hideNav = false,
}: AppShellProps) {
return (
<div
className="min-h-screen"
style={{ ["--chrome" as string]: projectId ? "7rem" : "4rem" }}
style={{ ["--chrome" as string]: "4rem" }}
>
<header className="flex h-16 items-center gap-4 border-b border-line bg-panel px-4 sm:px-6">
<a
href="#main"
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:rounded focus:border focus:border-line focus:bg-panel focus:px-3 focus:py-2 focus:text-sm focus:text-ink focus:shadow-paper"
>
</a>
<header className="sticky top-0 z-30 flex h-16 items-center gap-4 border-b border-line bg-panel px-4 sm:px-6">
<NavDrawer projectId={projectId} activeNav={activeNav} />
<Link
href="/"
className="font-serif text-xl text-cinnabar"
className={`shrink-0 whitespace-nowrap rounded font-serif text-xl text-cinnabar ${focusRing}`}
aria-label="返回作品库"
>
</Link>
{title ? (
<span className="font-serif text-lg text-ink">{title}</span>
<span className="min-w-0 truncate font-serif text-lg text-ink">
{title}
</span>
) : null}
{subtitle ? (
<span className="font-mono text-xs text-ink-soft">{subtitle}</span>
<span className="hidden shrink-0 font-mono text-xs text-ink-soft sm:inline">
{subtitle}
</span>
) : null}
<nav className="ml-auto flex items-center gap-4 text-sm">
<nav
aria-label="快捷操作"
className="ml-auto flex shrink-0 items-center gap-2 text-sm"
>
<CommandSearchButton />
<ThemeToggle />
<Link
href="/help"
aria-label="帮助"
title="帮助"
className={buttonClass({
variant: "ghost",
size: "sm",
className: "border-transparent",
})}
>
<FileQuestion className="h-4 w-4" aria-hidden="true" />
<span className="hidden sm:inline"></span>
</Link>
<Link
href="/settings/providers"
className="text-ink-soft hover:text-cinnabar"
aria-label="设置"
title="设置"
className={buttonClass({
variant: "ghost",
size: "sm",
className: "border-transparent",
})}
>
<Settings className="h-4 w-4" aria-hidden="true" />
<span className="hidden sm:inline"></span>
</Link>
</nav>
</header>
{projectId ? (
<AiToolbar projectId={projectId} activeNav={activeNav} />
) : null}
<div className="flex">
<LeftNav projectId={projectId} activeNav={activeNav} />
<main className="min-h-[calc(100vh-var(--chrome,4rem))] flex-1 bg-bg">
{hideNav ? null : <LeftNav projectId={projectId} activeNav={activeNav} />}
<main
id="main"
tabIndex={-1}
className="min-h-[calc(100vh-var(--chrome,4rem))] min-w-0 flex-1 bg-bg"
>
{children}
</main>
</div>

View File

@@ -0,0 +1,19 @@
import { StatusNote } from "./ui/StatusNote";
interface BackendDownNoticeProps {
className?: string;
}
// 后端不可达时的统一提示(多个页面 backend-down 分支复用)。
export function BackendDownNotice({ className }: BackendDownNoticeProps) {
return (
<StatusNote
variant="danger"
title="无法连接后端服务"
role="alert"
className={className}
>
API
</StatusNote>
);
}

View File

@@ -1,8 +1,17 @@
"use client";
import { useEffect, useRef, type ReactNode } from "react";
import { useEffect, useRef, type ReactNode, type RefObject } from "react";
import { X } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { handleTabTrap } from "@/lib/a11y/focusTrap";
import { useRestoreFocus } from "@/lib/a11y/useRestoreFocus";
import { useBodyScrollLock } from "@/lib/ui/useBodyScrollLock";
import { useMountTransition } from "@/lib/ui/useMountTransition";
import { overlayScrim } from "@/lib/ui/variants";
// 抽屉进出场时长(与 --dur-base 一致)。
const DRAWER_MS = 180;
interface DrawerProps {
open: boolean;
@@ -11,22 +20,35 @@ interface DrawerProps {
side?: "left" | "right";
// 无障碍标签role=dialog
label: string;
// 抽屉面板 id供触发按钮 aria-controls 指向(无障碍关联)。
id?: string;
triggerRef?: RefObject<HTMLElement | null>;
children: ReactNode;
}
// 移动端侧滑抽屉(<lg遮罩点击 / Esc 关闭 + 打开聚焦面板)。
// 移动端侧滑抽屉(<lg遮罩点击 / Esc 关闭 + 打开聚焦具名关闭按钮)。
// 复用命令面板的 a11y 模式CommandPalette桌面用静态布局不渲染本抽屉。
export function Drawer({
open,
onClose,
side = "left",
label,
id,
triggerRef,
children,
}: DrawerProps) {
const panelRef = useRef<HTMLDivElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const { shouldRender, isVisible } = useMountTransition(open, DRAWER_MS);
useBodyScrollLock(open);
useRestoreFocus(open, triggerRef);
useEffect(() => {
if (!open) return;
// 面板真正挂载shouldRender且处于打开态时才装监听/落焦——
// 因进出场延迟了卸载,若仅依赖 open 会错过挂载帧导致首焦点丢失。
if (!open || !shouldRender) return;
const onKey = (e: KeyboardEvent): void => {
if (e.key === "Escape") {
e.preventDefault();
@@ -34,19 +56,31 @@ export function Drawer({
}
};
window.addEventListener("keydown", onKey);
panelRef.current?.focus();
// 焦点陷阱首焦点落在具名关闭按钮上(而非整个面板)。
closeRef.current?.focus();
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
}, [open, shouldRender, onClose]);
if (!open) return null;
if (!shouldRender) return null;
const sideClass = side === "left" ? "left-0 border-r" : "right-0 border-l";
const slideClass =
side === "left"
? isVisible
? "translate-x-0"
: "-translate-x-full"
: isVisible
? "translate-x-0"
: "translate-x-full";
return (
<div className="fixed inset-0 z-50 bg-black/30 lg:hidden" onClick={onClose}>
<div
className={`${overlayScrim} lg:hidden motion-safe:transition-opacity motion-safe:duration-base ${isVisible ? "opacity-100" : "opacity-0"}`}
onClick={onClose}
>
<div
ref={panelRef}
tabIndex={-1}
id={id}
role="dialog"
aria-modal="true"
aria-label={label}
@@ -55,8 +89,19 @@ export function Drawer({
// focus trapTab/Shift+Tab 在抽屉内循环不逃逸到背景WCAG 2.1.2)。
if (panelRef.current) handleTabTrap(panelRef.current, e);
}}
className={`absolute top-0 h-full w-64 max-w-[80vw] overflow-auto border-line bg-panel py-4 shadow-paper outline-none ${sideClass}`}
className={`absolute top-0 h-full w-64 max-w-[80vw] overflow-auto overscroll-contain border-line bg-panel py-4 shadow-paper outline-none motion-safe:transition-transform motion-safe:duration-base ease-standard ${sideClass} ${slideClass}`}
>
<div className="mb-2 flex justify-end px-2">
<Button
ref={closeRef}
onClick={onClose}
aria-label={`关闭${label}`}
variant="ghost"
size="icon"
>
<X className="h-5 w-5" aria-hidden="true" />
</Button>
</div>
{children}
</div>
</div>

View File

@@ -11,7 +11,7 @@ interface LeftNavProps {
export function LeftNav({ projectId, activeNav }: LeftNavProps) {
return (
<nav
className="hidden w-44 shrink-0 border-r border-line bg-panel py-4 lg:block"
className="sticky top-[var(--chrome)] hidden max-h-[calc(100vh-var(--chrome))] w-44 shrink-0 self-start overflow-y-auto border-r border-line bg-panel py-4 lg:block"
aria-label="主导航"
>
<NavItems projectId={projectId} activeNav={activeNav} />

View File

@@ -1,8 +1,12 @@
"use client";
import { useState } from "react";
import { useRef, useState } from "react";
import { Menu, Search } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { openCommandPalette } from "@/components/command/CommandPalette";
import type { ActiveNav } from "@/lib/nav/items";
import { focusRing } from "@/lib/ui/variants";
import { Drawer } from "./Drawer";
import { NavItems } from "./NavItems";
@@ -11,29 +15,54 @@ interface NavDrawerProps {
activeNav?: ActiveNav;
}
// 抽屉面板 id供汉堡按钮 aria-controls 关联(无障碍)。
const DRAWER_ID = "nav-drawer";
// 移动端导航:汉堡按钮 + 左侧抽屉(仅 <lg桌面用 LeftNav 静态侧栏。UX §5.1。
export function NavDrawer({ projectId, activeNav }: NavDrawerProps) {
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const openSearch = (): void => {
setOpen(false);
openCommandPalette();
};
return (
<>
<button
type="button"
<Button
ref={triggerRef}
onClick={() => setOpen(true)}
aria-label="打开导航"
aria-expanded={open}
className="-ml-1 rounded p-2 text-ink hover:text-cinnabar lg:hidden"
aria-controls={DRAWER_ID}
variant="ghost"
size="icon"
className="-ml-2 lg:hidden"
>
<span aria-hidden="true" className="block text-lg leading-none">
</span>
</button>
<Menu className="h-5 w-5" aria-hidden="true" />
</Button>
<Drawer
open={open}
onClose={() => setOpen(false)}
side="left"
label="主导航"
id={DRAWER_ID}
triggerRef={triggerRef}
>
<div className="px-2 pb-2">
<button
type="button"
onClick={openSearch}
className={`flex w-full items-center gap-2 rounded border border-line bg-bg px-3 py-2 text-sm text-ink-soft transition-colors hover:text-cinnabar ${focusRing}`}
>
<Search className="h-4 w-4 shrink-0" aria-hidden="true" />
<kbd className="ml-auto rounded border border-line bg-panel px-1 font-mono text-2xs">
K
</kbd>
</button>
</div>
<NavItems
projectId={projectId}
activeNav={activeNav}

View File

@@ -1,15 +1,31 @@
"use client";
import { Fragment } from "react";
import Link from "next/link";
import {
Blocks,
BookOpen,
Boxes,
ClipboardCheck,
Flag,
LayoutTemplate,
ListTree,
Palette,
PenLine,
Settings,
Sparkles,
Workflow,
type LucideIcon,
} from "lucide-react";
import { usePathname } from "next/navigation";
import {
GLOBAL_NAV_ITEMS,
isNavItemActive,
projectNavItems,
navSections,
type ActiveNav,
type NavItem,
} from "@/lib/nav/items";
import { focusRing } from "@/lib/ui/variants";
interface NavItemsProps {
// 项目内页传 projectId → 展开项目级入口(大纲/伏笔/审稿UX §3.1)。
@@ -20,29 +36,24 @@ interface NavItemsProps {
}
// 导航条目列表(桌面静态侧栏与移动抽屉共用,避免渲染漂移)。
// 项目内页把 10 项按 写作/资料库/审阅/更多 分组P2-1每组一个小标题全局组无标题直显于顶部。
export function NavItems({ projectId, activeNav, onNavigate }: NavItemsProps) {
const pathname = usePathname() ?? "/";
const scoped = projectId ? projectNavItems(projectId) : [];
const sections = navSections(projectId);
return (
<ul className="flex flex-col gap-1 px-2">
{GLOBAL_NAV_ITEMS.map((item) => (
<NavLink
key={item.label}
item={item}
active={isNavItemActive(item, { pathname, activeNav })}
onNavigate={onNavigate}
/>
))}
{scoped.length > 0 ? (
{sections.map((section) => (
<Fragment key={section.label ?? "__global"}>
{section.label !== null ? (
<li
className="mt-3 px-3 pb-1 text-xs font-semibold uppercase tracking-wide text-ink-soft/60"
aria-hidden="true"
>
{section.label}
</li>
) : null}
{scoped.map((item) => (
{section.items.map((item) => (
<NavLink
key={item.label}
item={item}
@@ -50,10 +61,28 @@ export function NavItems({ projectId, activeNav, onNavigate }: NavItemsProps) {
onNavigate={onNavigate}
/>
))}
</Fragment>
))}
</ul>
);
}
function navIcon(item: NavItem): LucideIcon {
if (item.key === "write") return PenLine;
if (item.key === "outline") return ListTree;
if (item.key === "foreshadow") return Flag;
if (item.key === "review") return ClipboardCheck;
if (item.key === "chains") return Workflow;
if (item.key === "style") return Palette;
if (item.key === "codex") return BookOpen;
if (item.key === "toolbox") return Sparkles;
if (item.key === "rules") return Boxes;
if (item.key === "skills") return Blocks;
if (item.href === "/templates") return LayoutTemplate;
if (item.href === "/settings/providers") return Settings;
return BookOpen;
}
function NavLink({
item,
active,
@@ -63,18 +92,20 @@ function NavLink({
active: boolean;
onNavigate?: () => void;
}) {
const Icon = navIcon(item);
return (
<li>
<Link
href={item.href}
onClick={onNavigate}
aria-current={active ? "page" : undefined}
className={`flex items-center gap-2 rounded px-3 py-2 text-sm ${
className={`flex items-center gap-2 rounded px-3 py-2 text-sm transition-colors ${focusRing} ${
active
? "border-l-2 border-cinnabar bg-[var(--color-cinnabar-wash)] text-cinnabar"
: "text-ink hover:bg-[var(--color-cinnabar-wash)]"
: "border-l-2 border-transparent text-ink hover:bg-[var(--color-cinnabar-wash)] hover:text-cinnabar"
}`}
>
<Icon className="h-4 w-4 shrink-0" aria-hidden="true" />
{item.label}
</Link>
</li>

View File

@@ -1,26 +1,94 @@
import Link from "next/link";
import { BookOpen, ChevronRight, Clock3 } from "lucide-react";
import { Badge } from "@/components/ui/Badge";
import type { ProjectResponse } from "@/lib/api/types";
import {
formatProjectUpdatedAt,
pendingReviewCount,
} from "@/lib/projects/projects";
import { cardClass, focusRing } from "@/lib/ui/variants";
interface ProjectCardProps {
project: ProjectResponse;
compact?: boolean;
}
// 作品卡UX §6.1):书名衬线大字、题材、一句话故事。
// M1 无字数/章数/待办统计端点 → 暂不展示该徽标(避免编造 API
export function ProjectCard({ project }: ProjectCardProps) {
export function ProjectCard({ project, compact = false }: ProjectCardProps) {
const pendingCount = pendingReviewCount(project);
const updatedLabel = formatProjectUpdatedAt(project.updated_at);
if (compact) {
return (
<Link
href={`/projects/${project.id}/write`}
className="flex h-full min-h-[160px] flex-col rounded border border-line bg-panel p-6 shadow-paper hover:border-cinnabar"
className={cardClass(
`group flex items-center gap-3 p-3 transition-colors hover:border-cinnabar ${focusRing}`,
)}
>
<h2 className="font-serif text-2xl text-ink">{project.title}</h2>
{project.genre ? (
<p className="mt-2 text-sm text-ink-soft">{project.genre}</p>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded bg-[var(--color-cinnabar-wash)] text-cinnabar">
<BookOpen className="h-4 w-4" aria-hidden="true" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-serif text-base text-ink">
{project.title || "未命名作品"}
</span>
<span className="mt-1 flex items-center gap-2 text-xs text-ink-soft">
{project.genre ? <Badge>{project.genre}</Badge> : null}
{pendingCount > 0 ? (
<Badge variant="warning">{pendingCount} </Badge>
) : null}
<span className="truncate">{project.logline ?? "暂无一句话故事"}</span>
</span>
<span className="mt-1 block font-mono text-[11px] text-ink-soft">
{updatedLabel}
</span>
</span>
<ChevronRight
className="h-4 w-4 shrink-0 text-ink-soft transition-colors group-hover:text-cinnabar"
aria-hidden="true"
/>
</Link>
);
}
return (
<Link
href={`/projects/${project.id}/write`}
className={cardClass(
`group flex h-full min-h-[176px] flex-col p-6 transition-colors hover:border-cinnabar ${focusRing}`,
)}
>
<div className="mb-4 flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="truncate font-serif text-2xl text-ink">
{project.title || "未命名作品"}
</h2>
<div className="mt-2 flex flex-wrap gap-2">
{project.genre ? <Badge>{project.genre}</Badge> : null}
{pendingCount > 0 ? (
<Badge variant="warning">{pendingCount} </Badge>
) : null}
</div>
</div>
<span className="rounded border border-line bg-bg p-2 text-ink-soft transition-colors group-hover:border-cinnabar group-hover:text-cinnabar">
<BookOpen className="h-4 w-4" aria-hidden="true" />
</span>
</div>
{project.logline ? (
<p className="mt-3 line-clamp-3 text-sm text-ink">{project.logline}</p>
<p className="line-clamp-3 text-sm leading-6 text-ink">
{project.logline}
</p>
) : null}
<span className="mt-3 flex items-center gap-1 font-mono text-[11px] text-ink-soft">
<Clock3 className="h-3.5 w-3.5" aria-hidden="true" />
{updatedLabel}
</span>
<span className="mt-auto flex items-center gap-1 pt-4 text-xs text-cinnabar opacity-0 motion-safe:transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">
<ChevronRight className="h-3.5 w-3.5" aria-hidden="true" />
</span>
</Link>
);
}

View File

@@ -1,31 +1,43 @@
"use client";
import { ArrowLeft, ArrowRight, Check, CheckCircle2, Sparkles } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { api } from "@/lib/api/client";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { useToast } from "@/components/Toast";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Field } from "@/components/ui/Field";
import { SegmentedControl } from "@/components/ui/SegmentedControl";
import { Select } from "@/components/ui/Select";
import { TextArea } from "@/components/ui/TextArea";
import { TextInput } from "@/components/ui/TextInput";
import { api } from "@/lib/api/client";
import { buttonClass } from "@/lib/ui/variants";
import type { ProjectPlanView } from "@/lib/api/types";
import { useProjectPlan } from "@/lib/wizard/useProjectPlan";
import {
ENDING_TYPES,
GENRES,
NARRATIVE_POVS,
SELLING_POINT_PRESETS,
STEP_TITLES,
STRUCTURES,
TONES,
WIZARD_STEPS,
applyPlanPatch,
canAdvance,
canGeneratePlan,
canSubmit,
clampStep,
emptyWizardForm,
mapPlanResultToWizardForm,
toCreateRequest,
toPlanSeedRequest,
type WizardForm,
} from "@/lib/wizard/wizard";
const STEP_TITLES = [
"书名 + 题材",
"一句话故事 + 卖点 + 结构",
"立意 / 总纲",
"主角 / 金手指",
"基础世界观",
];
// 立项向导UX §6.2。Client Component收集字段 → POST /projects → 进工作台。
export function ProjectWizard() {
const router = useRouter();
@@ -34,6 +46,17 @@ export function ProjectWizard() {
const [form, setForm] = useState<WizardForm>(emptyWizardForm);
const [submitting, setSubmitting] = useState(false);
// 换步后把焦点移到新步标题,方便键盘/读屏用户感知步骤变化;首帧不抢第 1 步输入框的 autoFocus。
const stepHeadingRef = useRef<HTMLParagraphElement>(null);
const isFirstRender = useRef(true);
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
stepHeadingRef.current?.focus();
}, [step]);
const update = (patch: Partial<WizardForm>): void =>
setForm((prev) => ({ ...prev, ...patch }));
@@ -45,6 +68,21 @@ export function ProjectWizard() {
: [...prev.sellingPoints, point],
}));
// AI 立项方案按字段回填:只填当前为空的字段(保护作者已编辑内容,不 bulk 覆盖)。
const applyPlan = (plan: ProjectPlanView): void => {
const patch = mapPlanResultToWizardForm(plan);
const filled = (Object.keys(patch) as Array<keyof WizardForm>).filter(
(k) => typeof form[k] === "string" && (form[k] as string).trim() === "",
);
setForm((prev) => applyPlanPatch(prev, patch));
toast(
filled.length > 0
? `已回填 ${filled.length} 个空字段(已编辑内容不覆盖)`
: "向导相关字段均已填写,未覆盖任何内容",
filled.length > 0 ? "success" : "info",
);
};
const goBack = (): void => setStep((s) => clampStep(s - 1));
const goNext = (): void => setStep((s) => clampStep(s + 1));
@@ -69,14 +107,23 @@ export function ProjectWizard() {
const isLast = step === WIZARD_STEPS;
return (
<div className="mx-auto max-w-2xl rounded border border-line bg-panel p-8 shadow-paper">
<div className="mx-auto max-w-2xl rounded-lg border border-line bg-panel p-6 shadow-paper sm:p-8">
<div className="mb-6 flex items-center justify-between">
<h1 className="font-serif text-2xl text-ink"></h1>
<h1 className="font-serif text-display-sm text-ink"></h1>
<StepDots current={step} />
</div>
<p className="mb-4 text-sm text-ink-soft">
{step}/{WIZARD_STEPS} · {STEP_TITLES[step - 1]}
<div className="mb-4">
<Badge variant="accent">
{step}/{WIZARD_STEPS}
</Badge>
<p
ref={stepHeadingRef}
tabIndex={-1}
className="mt-2 text-sm text-ink-soft focus-visible:outline-none"
>
{STEP_TITLES[step - 1]}
</p>
</div>
<div className="min-h-[220px]">
{step === 1 && <StepBasics form={form} update={update} />}
@@ -85,40 +132,37 @@ export function ProjectWizard() {
form={form}
update={update}
toggleSellingPoint={toggleSellingPoint}
onApplyPlan={applyPlan}
/>
)}
{step === 3 && <StepPremise form={form} update={update} />}
{step === 4 && <StepProtagonist form={form} update={update} />}
{step === 5 && <StepWorld />}
{step === 5 && <StepConfirm form={form} />}
</div>
<div className="mt-8 flex items-center justify-between">
<button
type="button"
onClick={goBack}
disabled={step === 1}
className="rounded border border-line px-4 py-2 text-sm text-ink disabled:opacity-40"
>
</button>
<Button onClick={goBack} disabled={step === 1} variant="secondary">
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
</Button>
{isLast ? (
<button
type="button"
<Button
onClick={submit}
disabled={submitting || !canSubmit(form)}
className="rounded bg-cinnabar px-5 py-2 text-sm text-panel disabled:opacity-40"
variant="primary"
>
{submitting ? "创建中…" : "完成立项 →"}
</button>
<CheckCircle2 className="h-4 w-4" aria-hidden="true" />
{submitting ? "创建中…" : "完成立项"}
</Button>
) : (
<button
type="button"
<Button
onClick={goNext}
disabled={!canAdvance(step, form)}
className="rounded bg-cinnabar px-5 py-2 text-sm text-panel disabled:opacity-40"
variant="primary"
>
</button>
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</Button>
)}
</div>
</div>
@@ -145,30 +189,16 @@ interface StepProps {
update: (patch: Partial<WizardForm>) => void;
}
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<label className="mb-4 block">
<span className="mb-1 block text-sm text-ink-soft">{label}</span>
{children}
</label>
);
}
const inputCls =
"w-full rounded border border-line bg-bg px-3 py-2 text-ink focus:border-cinnabar focus:outline-none";
function StepBasics({ form, update }: StepProps) {
const titleMissing = form.title.trim().length === 0;
return (
<div>
<Field label="书名(必填)">
<input
className={inputCls}
<Field
label="书名"
required
help={titleMissing ? "请先填写书名才能继续" : undefined}
>
<TextInput
value={form.title}
onChange={(e) => update({ title: e.target.value })}
placeholder="例:逐光而行"
@@ -176,8 +206,7 @@ function StepBasics({ form, update }: StepProps) {
/>
</Field>
<Field label="题材">
<select
className={inputCls}
<Select
value={form.genre}
onChange={(e) => update({ genre: e.target.value })}
>
@@ -187,63 +216,140 @@ function StepBasics({ form, update }: StepProps) {
{g}
</option>
))}
</select>
</Select>
</Field>
</div>
);
}
// AI 立项方案助手(灵感⑤):种子(题材+logline就绪后一键生成结构化方案 → 预览 → 按字段回填。
function PlanAssistant({
form,
onApplyPlan,
}: {
form: WizardForm;
onApplyPlan: (plan: ProjectPlanView) => void;
}) {
const { status, plan, generate } = useProjectPlan();
const ready = canGeneratePlan(form);
const generating = status === "generating";
return (
<div className="my-4 rounded-md border border-line bg-bg p-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="flex items-center gap-1.5 text-sm text-ink">
<Sparkles className="h-4 w-4 text-cinnabar" aria-hidden="true" />
AI
</p>
<p className="mt-0.5 text-xs text-ink-soft">
{ready
? "根据题材 + 一句话故事推演书级蓝图,按字段回填(不覆盖已填内容)。"
: "填好「题材」(上一步)与「一句话故事」后即可生成。"}
</p>
</div>
<Button
onClick={() => void generate(toPlanSeedRequest(form))}
disabled={!ready || generating}
variant="secondary"
>
{generating ? (
<ThinkingIndicator label="生成中" />
) : (
<>
<Sparkles className="h-4 w-4" aria-hidden="true" />
</>
)}
</Button>
</div>
{status === "done" && plan && (
<div className="mt-3 border-t border-line pt-3">
<dl className="space-y-1.5 text-sm">
{plan.title_candidates && plan.title_candidates.length > 0 && (
<PlanRow label="书名候选" value={plan.title_candidates.join("、")} />
)}
<PlanRow label="时空背景" value={plan.setting} />
<PlanRow label="叙事结构" value={plan.narrative_structure} />
<PlanRow label="故事核心" value={plan.story_core} />
<PlanRow label="结局设计" value={plan.ending_design} />
<PlanRow label="基调" value={plan.tone} />
</dl>
<div className="mt-3 flex justify-end">
<Button onClick={() => onApplyPlan(plan)} variant="primary">
<Check className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
</div>
)}
</div>
);
}
function PlanRow({ label, value }: { label: string; value: string }) {
if (!value.trim()) return null;
return (
<div className="flex gap-3">
<dt className="w-16 shrink-0 text-ink-soft">{label}</dt>
<dd className="min-w-0 flex-1 whitespace-pre-wrap text-ink">{value}</dd>
</div>
);
}
function StepStory({
form,
update,
toggleSellingPoint,
}: StepProps & { toggleSellingPoint: (p: string) => void }) {
onApplyPlan,
}: StepProps & {
toggleSellingPoint: (p: string) => void;
onApplyPlan: (plan: ProjectPlanView) => void;
}) {
return (
<div>
<Field label="一句话故事logline">
<textarea
className={`${inputCls} h-20 resize-none`}
<TextArea
className="h-20 resize-none"
value={form.logline}
onChange={(e) => update({ logline: e.target.value })}
placeholder="废柴少年觉醒禁忌血脉,在仙门倾轧中逆势封神。"
/>
</Field>
<PlanAssistant form={form} onApplyPlan={onApplyPlan} />
<Field label="故事结构">
<div className="flex gap-2">
{STRUCTURES.map((s) => (
<button
type="button"
key={s}
onClick={() => update({ structure: s })}
className={`rounded border px-3 py-1.5 text-sm ${
form.structure === s
? "border-cinnabar bg-[var(--color-cinnabar-wash)] text-cinnabar"
: "border-line text-ink"
}`}
>
{s}
</button>
))}
</div>
<SegmentedControl
ariaLabel="故事结构"
className="flex-wrap"
value={form.structure}
onChange={(structure) => update({ structure })}
options={STRUCTURES.map((s) => ({ value: s, label: s }))}
/>
</Field>
<fieldset>
<legend className="mb-1 block text-sm text-ink-soft"></legend>
<div className="flex flex-wrap gap-2">
{SELLING_POINT_PRESETS.map((p) => (
{SELLING_POINT_PRESETS.map((p) => {
const selected = form.sellingPoints.includes(p);
return (
<button
type="button"
key={p}
aria-pressed={form.sellingPoints.includes(p)}
aria-pressed={selected}
onClick={() => toggleSellingPoint(p)}
className={`rounded border px-3 py-1.5 text-sm ${
form.sellingPoints.includes(p)
? "border-cinnabar bg-[var(--color-cinnabar-wash)] text-cinnabar"
: "border-line text-ink"
}`}
className={buttonClass({
variant: selected ? "primary" : "secondary",
size: "sm",
})}
>
{selected ? (
<Check className="h-3.5 w-3.5" aria-hidden="true" />
) : null}
{p}
</button>
))}
);
})}
</div>
</fieldset>
</div>
@@ -254,37 +360,68 @@ function StepPremise({ form, update }: StepProps) {
return (
<div>
<Field label="立意premise">
<textarea
className={`${inputCls} h-20 resize-none`}
<TextArea
className="h-20 resize-none"
value={form.premise}
onChange={(e) => update({ premise: e.target.value })}
placeholder="故事的核心命题与前提。"
/>
</Field>
<Field label="主题theme">
<input
className={inputCls}
<TextInput
value={form.theme}
onChange={(e) => update({ theme: e.target.value })}
placeholder="例:抗争与代价"
/>
</Field>
<Field label="基调">
<Select
value={form.tone}
onChange={(e) => update({ tone: e.target.value })}
>
<option value=""></option>
{TONES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</Select>
</Field>
<Field label="叙事视角">
<SegmentedControl
ariaLabel="叙事视角"
className="flex-wrap"
value={form.narrativePov}
onChange={(narrativePov) => update({ narrativePov })}
options={NARRATIVE_POVS.map((p) => ({ value: p, label: p }))}
/>
</Field>
<Field label="结局取向">
<SegmentedControl
ariaLabel="结局取向"
className="flex-wrap"
value={form.endingType}
onChange={(endingType) => update({ endingType })}
options={ENDING_TYPES.map((e) => ({ value: e, label: e }))}
/>
</Field>
</div>
);
}
function StepProtagonist({ form, update }: StepProps) {
// M1 projects 表无独立主角/金手指字段;先并入立意/总纲文本,避免编造 API
// M1 projects 表无独立主角/金手指字段;提交时由 toCreateRequest 合并进 premise与立意各占一段
// 必须绑定独立的 form.protagonist不能复用 form.premise否则与第 3 步立意互相覆盖QA H1
return (
<div>
<p className="mb-3 text-sm text-ink-soft">
M1
</p>
<Field label="主角 / 金手指概要">
<textarea
className={`${inputCls} h-28 resize-none`}
value={form.premise}
onChange={(e) => update({ premise: e.target.value })}
<TextArea
className="h-28 resize-none"
value={form.protagonist}
onChange={(e) => update({ protagonist: e.target.value })}
placeholder="主角设定、金手指来源与限制……"
/>
</Field>
@@ -292,10 +429,34 @@ function StepProtagonist({ form, update }: StepProps) {
);
}
function StepWorld() {
// 第 5 步:确认/概览——回显已填字段供复核后提交(取代旧的空「世界观后续里程碑」说明)。
function StepConfirm({ form }: { form: WizardForm }) {
const rows: Array<{ label: string; value: string }> = [
{ label: "书名", value: form.title.trim() || "未填写" },
{ label: "一句话故事", value: form.logline.trim() || "未填写" },
{ label: "题材", value: form.genre || "未选择" },
{ label: "故事结构", value: form.structure || "未选择" },
{
label: "核心卖点",
value: form.sellingPoints.length > 0 ? form.sellingPoints.join("、") : "未选择",
},
{ label: "基调", value: form.tone || "未选择" },
{ label: "叙事视角", value: form.narrativePov || "未选择" },
{ label: "结局取向", value: form.endingType || "未选择" },
];
return (
<div className="rounded border border-dashed border-line bg-bg p-6 text-sm text-ink-soft">
<div>
<p className="mb-3 text-sm text-ink-soft">
</p>
<dl className="divide-y divide-line rounded-md border border-line bg-bg">
{rows.map((row) => (
<div key={row.label} className="flex gap-4 px-4 py-2.5 text-sm">
<dt className="w-20 shrink-0 text-ink-soft">{row.label}</dt>
<dd className="min-w-0 flex-1 text-ink">{row.value}</dd>
</div>
))}
</dl>
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { themeBootstrapScript } from "@/lib/ui/theme";
export function ThemeScript() {
return (
<script
dangerouslySetInnerHTML={{ __html: themeBootstrapScript() }}
suppressHydrationWarning
/>
);
}

View File

@@ -0,0 +1,74 @@
"use client";
import { useEffect, useState } from "react";
import { Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/Button";
import {
DEFAULT_THEME_MODE,
THEME_STORAGE_KEY,
nextThemeMode,
normalizeThemeMode,
themeModeLabel,
themeToggleLabel,
type ThemeMode,
} from "@/lib/ui/theme";
function applyThemeMode(mode: ThemeMode) {
document.documentElement.dataset.theme = mode;
}
function readStoredThemeMode(): ThemeMode {
try {
return normalizeThemeMode(window.localStorage.getItem(THEME_STORAGE_KEY));
} catch {
return DEFAULT_THEME_MODE;
}
}
function writeStoredThemeMode(mode: ThemeMode) {
try {
window.localStorage.setItem(THEME_STORAGE_KEY, mode);
} catch {
// 存储不可用时仍允许本次页面会话切换主题。
}
}
export function ThemeToggle() {
// 首帧用默认模式(与服务端一致)避免水合不一致;挂载后以 localStorage 校正。
// 可见的图标由 CSS 按 <html data-theme> 决定ThemeScript 已在水合前写入),故无首帧闪烁。
const [mode, setMode] = useState<ThemeMode>(DEFAULT_THEME_MODE);
useEffect(() => {
// 水合后以 localStorage 为权威源校正dataset 已由 ThemeScript 同样依据写入,通常一致)。
const initial = readStoredThemeMode();
setMode(initial);
applyThemeMode(initial);
}, []);
const toggle = () => {
setMode((current) => {
const next = nextThemeMode(current);
writeStoredThemeMode(next);
applyThemeMode(next);
return next;
});
};
// 图标显隐交给 CSS按 data-theme两个图标始终渲染 → 首帧结构与服务端一致、无闪烁。
// 首帧 label 用默认模式(与服务端一致),挂载后随 mode 校正为正确文案。
return (
<Button
onClick={toggle}
variant="ghost"
size="icon"
aria-label={themeToggleLabel(mode)}
title={themeToggleLabel(mode)}
className="border-transparent"
>
<Moon className="theme-icon-moon h-4 w-4" aria-hidden="true" />
<Sun className="theme-icon-sun h-4 w-4" aria-hidden="true" />
<span className="sr-only">{themeModeLabel(mode)}</span>
</Button>
);
}

View File

@@ -17,12 +17,12 @@ export function ThinkingIndicator({
role="status"
aria-live="polite"
>
<span className="inline-flex gap-0.5" aria-hidden="true">
<span className="inline-flex gap-1" aria-hidden="true">
{[0, 1, 2].map((i) => (
<span
key={i}
className="h-1.5 w-1.5 rounded-full bg-current opacity-70 motion-safe:animate-pulse"
style={{ animationDelay: `${i * 200}ms` }}
className="ai-dot h-2 w-2 rounded-full bg-current"
style={{ animationDelay: `${i * 150}ms` }}
/>
))}
</span>

View File

@@ -11,19 +11,51 @@ import {
type ReactNode,
} from "react";
import { buttonClass } from "@/lib/ui/variants";
type ToastKind = "info" | "error" | "success";
interface ToastAction {
label: string;
onClick: () => void;
}
interface ToastOptions {
action?: ToastAction;
durationMs?: number;
}
interface ToastItem {
id: number;
message: string;
kind: ToastKind;
action?: ToastAction;
}
type ShowToast = (message: string, kind?: ToastKind) => void;
type ShowToast = (
message: string,
kind?: ToastKind,
options?: ToastOptions,
) => void;
const ToastContext = createContext<ShowToast | null>(null);
// 普通提示 4s 自动消失;错误更需被看到,默认延长到 7s。
const TOAST_TTL_MS = 4000;
const ERROR_TOAST_TTL_MS = 7000;
// 屏读前缀:让无视觉的用户也能区分提示性质。
const SR_PREFIX: Record<ToastKind, string> = {
error: "错误:",
success: "成功:",
info: "",
};
function toastShellClass(kind: ToastKind): string {
if (kind === "error") return "border-conflict bg-panel text-conflict";
if (kind === "success") return "border-pass bg-panel text-pass";
return "border-line bg-panel text-ink";
}
export function ToastProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<ToastItem[]>([]);
@@ -32,13 +64,20 @@ export function ToastProvider({ children }: { children: ReactNode }) {
// 在途定时器集合:卸载时统一清理,避免 setState-after-unmount 泄漏。
const timersRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
const show = useCallback<ShowToast>((message, kind = "info") => {
const dismiss = useCallback((id: number) => {
setItems((prev) => prev.filter((t) => t.id !== id));
}, []);
const show = useCallback<ShowToast>((message, kind = "info", options) => {
const id = nextIdRef.current++;
setItems((prev) => [...prev, { id, message, kind }]);
setItems((prev) => [...prev, { id, message, kind, action: options?.action }]);
const ttl =
options?.durationMs ??
(kind === "error" ? ERROR_TOAST_TTL_MS : TOAST_TTL_MS);
const timer = setTimeout(() => {
timersRef.current.delete(timer);
setItems((prev) => prev.filter((t) => t.id !== id));
}, TOAST_TTL_MS);
}, ttl);
timersRef.current.add(timer);
}, []);
@@ -53,28 +92,46 @@ export function ToastProvider({ children }: { children: ReactNode }) {
const value = useMemo(() => show, [show]);
const errorItems = items.filter((t) => t.kind === "error");
const politeItems = items.filter((t) => t.kind !== "error");
const renderItem = (t: ToastItem) => (
<div
key={t.id}
className={`toast-enter pointer-events-auto flex items-center gap-3 rounded-md border px-4 py-2 text-sm shadow-paper ${toastShellClass(
t.kind,
)}`}
>
<span className="min-w-0">
<span className="sr-only">{SR_PREFIX[t.kind]}</span>
{t.message}
</span>
{t.action ? (
<button
type="button"
className={buttonClass({ variant: "ghost", size: "sm" })}
onClick={() => {
t.action?.onClick();
dismiss(t.id);
}}
>
{t.action.label}
</button>
) : null}
</div>
);
return (
<ToastContext.Provider value={value}>
{children}
<div
className="pointer-events-none fixed bottom-6 right-6 z-50 flex flex-col gap-2"
aria-live="polite"
role="status"
>
{items.map((t) => (
<div
key={t.id}
className={`pointer-events-auto rounded border px-4 py-2 text-sm shadow-paper ${
t.kind === "error"
? "border-conflict bg-panel text-conflict"
: t.kind === "success"
? "border-pass bg-panel text-pass"
: "border-line bg-panel text-ink"
}`}
>
{t.message}
{/* error 用 assertive 立即播报;其余 info/success 用 polite 礼貌播报。两区共用同一锚点容器避免重叠。 */}
<div className="pointer-events-none fixed bottom-6 right-6 z-50 flex flex-col gap-2">
<div className="flex flex-col gap-2" aria-live="assertive" role="alert">
{errorItems.map(renderItem)}
</div>
<div className="flex flex-col gap-2" aria-live="polite" role="status">
{politeItems.map(renderItem)}
</div>
))}
</div>
</ToastContext.Provider>
);

View File

@@ -4,9 +4,11 @@ import { useCallback, useEffect, useState } from "react";
import { ConflictCard } from "@/components/review/ConflictCard";
import { useToast } from "@/components/Toast";
import { Button } from "@/components/ui/Button";
import { StatusNote } from "@/components/ui/StatusNote";
import { api } from "@/lib/api/client";
import { buildResumeRequest } from "@/lib/chain/chain";
import { friendlyError } from "@/lib/errors/messages";
import { friendlyFromApiError } from "@/lib/errors/messages";
import type { ReviewConflict } from "@/lib/review/sse";
import {
allResolved,
@@ -45,10 +47,9 @@ export function ChainAdjudication({
const [drafts, setDrafts] = useState<DecisionDraft[]>([]);
const [resuming, setResuming] = useState(false);
useEffect(() => {
let active = true;
// 拉取 awaiting 章最近审稿冲突。抽成可复用回调,供进场 effect 与「重新加载」按钮共用。
const load = useCallback(async (): Promise<boolean> => {
setLoadState("loading");
void (async () => {
try {
const { data, error } = await api.GET(
"/projects/{project_id}/chapters/{chapter_no}/reviews",
@@ -58,23 +59,32 @@ export function ChainAdjudication({
},
},
);
if (!active) return;
if (error || !data) {
setLoadState("error");
return;
return false;
}
const next = normalizeConflicts(latestReview(data.reviews));
setConflicts(next);
setDrafts(emptyDecisions(next.length));
setLoadState("ready");
return true;
} catch {
if (active) setLoadState("error");
setLoadState("error");
return false;
}
}, [projectId, chapterNo]);
useEffect(() => {
let active = true;
void (async () => {
// 进场加载:组件已卸载则丢弃结果(避免 setState-after-unmount
if (!active) return;
await load();
})();
return () => {
active = false;
};
}, [projectId, chapterNo]);
}, [load]);
const onVerdict = useCallback((index: number, verdict: Verdict): void => {
setDrafts((prev) => setVerdict(prev, index, verdict));
@@ -99,7 +109,7 @@ export function ChainAdjudication({
},
);
if (error) {
toast(friendlyError(undefined).text, "error");
toast(friendlyFromApiError(error).text, "error");
setResuming(false);
return;
}
@@ -115,9 +125,17 @@ export function ChainAdjudication({
}
if (loadState === "error") {
return (
<p className="text-sm text-conflict">
{chapterNo} 稿
</p>
<StatusNote variant="danger" title={`加载第 ${chapterNo} 章审稿冲突失败`}>
<p></p>
<Button
variant="outline"
size="sm"
onClick={() => void load()}
className="mt-3"
>
</Button>
</StatusNote>
);
}
@@ -143,6 +161,7 @@ export function ChainAdjudication({
<ConflictCard
key={i}
index={i}
total={conflicts.length}
conflict={conflict}
draft={draft}
missing={false}
@@ -158,14 +177,14 @@ export function ChainAdjudication({
</ul>
)}
<button
type="button"
<Button
variant="primary"
onClick={() => void onResume()}
disabled={resuming || !allResolved(drafts)}
className="self-start rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50"
className="self-start"
>
{resuming ? "续跑中…" : "裁决完成,续跑"}
</button>
</Button>
{pending.length > 0 ? (
<p className="text-xs text-conflict">
{pending.length}

View File

@@ -4,9 +4,11 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { useToast } from "@/components/Toast";
import { Button } from "@/components/ui/Button";
import { StatusNote } from "@/components/ui/StatusNote";
import { api } from "@/lib/api/client";
import { chainPhase, chainResultView, type ChainKind } from "@/lib/chain/chain";
import { friendlyError } from "@/lib/errors/messages";
import { friendlyFromApiError } from "@/lib/errors/messages";
import { useJobPoll } from "@/lib/jobs/useJobPoll";
import type { ProjectResponse } from "@/lib/api/types";
import { ChainAdjudication } from "./ChainAdjudication";
@@ -58,7 +60,7 @@ export function ChainPage({ project }: ChainPageProps) {
},
);
if (error || !data) {
toast(friendlyError(undefined).text, "error");
toast(friendlyFromApiError(error).text, "error");
return;
}
setJobId(data.job_id);
@@ -77,6 +79,11 @@ export function ChainPage({ project }: ChainPageProps) {
if (jobId) poll.poll(jobId);
}, [jobId, poll]);
// 链失败重试:重启轮询同一 job覆盖轮询/网络抖动这类瞬时失败;图从检查点续)。
const onRetry = useCallback((): void => {
if (jobId) poll.poll(jobId);
}, [jobId, poll]);
const showProgress = jobId !== null && result !== null;
// 链运行中/等待裁决时禁止重复发起。
const busy =
@@ -112,13 +119,25 @@ export function ChainPage({ project }: ChainPageProps) {
) : null}
{phase === "failed" ? (
<p className="text-sm text-conflict">
{poll.error ?? "链运行失败,请重试。"}
</p>
<StatusNote variant="danger" title="链运行失败">
<p>{poll.error ?? "请稍后重试。"}</p>
{jobId !== null ? (
<Button
variant="outline"
size="sm"
onClick={onRetry}
className="mt-3"
>
</Button>
) : null}
</StatusNote>
) : null}
{phase === "completed" ? (
<p className="text-sm text-pass"></p>
<StatusNote variant="success" title="本链已全部跑完">
<p></p>
</StatusNote>
) : null}
</div>
</AppShell>

View File

@@ -1,5 +1,8 @@
"use client";
import { Badge } from "@/components/ui/Badge";
import { Card } from "@/components/ui/Card";
import type { BadgeVariant } from "@/lib/ui/variants";
import type { ChainPhase, ChainResultView } from "@/lib/chain/chain";
interface ChainProgressProps {
@@ -15,32 +18,40 @@ const PHASE_LABEL: Record<ChainPhase, string> = {
failed: "已失败",
};
// 相位 → 语义色:失败用危险色而非朱砂品牌色,运行/等待/完成各取信息/警示/成功。
const PHASE_VARIANT: Record<ChainPhase, BadgeVariant> = {
running: "info",
awaiting: "warning",
completed: "success",
failed: "danger",
};
// 链进度视图:相位徽标 + 进度条 + 已写章号清单token/正文绝不展示result 只含元数据)。
export function ChainProgress({ phase, progress, result }: ChainProgressProps) {
return (
<section
className="flex flex-col gap-3 rounded border border-line bg-panel p-5"
<Card
as="section"
className="flex flex-col gap-3 p-5"
aria-label="链进度"
>
<div className="flex items-center justify-between gap-3">
<h2 className="font-serif text-lg text-ink"></h2>
<span
className="rounded bg-cinnabar/10 px-2 py-0.5 text-xs text-cinnabar"
aria-live="polite"
>
<Badge variant={PHASE_VARIANT[phase]} aria-live="polite">
{PHASE_LABEL[phase]}
</span>
</Badge>
</div>
<div
className="h-2 w-full overflow-hidden rounded bg-bg"
role="progressbar"
aria-label="链写章进度"
aria-valuenow={progress}
aria-valuemin={0}
aria-valuemax={100}
aria-valuetext={`已完成 ${result.written.length}`}
>
<div
className="h-full bg-cinnabar transition-all"
className="h-full bg-cinnabar motion-safe:transition-all"
style={{ width: `${progress}%` }}
/>
</div>
@@ -58,6 +69,6 @@ export function ChainProgress({ phase, progress, result }: ChainProgressProps) {
{result.awaitingChapter}
</p>
) : null}
</section>
</Card>
);
}

View File

@@ -1,7 +1,13 @@
"use client";
import { useState } from "react";
import { Play } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { Field } from "@/components/ui/Field";
import { Radio } from "@/components/ui/Radio";
import { SectionHeader } from "@/components/ui/SectionHeader";
import { TextInput } from "@/components/ui/TextInput";
import { CHAIN_KINDS, type ChainKind } from "@/lib/chain/chain";
interface ChainStarterProps {
@@ -41,12 +47,10 @@ export function ChainStarter({ onStart, disabled }: ChainStarterProps) {
if (valid && !disabled) onStart(chainKey, startNo, countNo);
}}
>
<div>
<h2 className="font-serif text-lg text-ink"></h2>
<p className="mt-1 text-sm text-ink-soft">
</p>
</div>
<SectionHeader
title="连续写多章"
description="从指定章起循环「写章 → 四审 → 验收」;遇未决冲突会暂停等你裁决再续跑。"
/>
<fieldset className="flex flex-col gap-2">
<legend className="text-sm text-ink-soft"></legend>
<div className="flex flex-wrap gap-4">
@@ -55,8 +59,7 @@ export function ChainStarter({ onStart, disabled }: ChainStarterProps) {
key={kind.key}
className="flex items-start gap-2 text-sm text-ink"
>
<input
type="radio"
<Radio
name="chain-kind"
value={kind.key}
checked={chainKey === kind.key}
@@ -75,37 +78,35 @@ export function ChainStarter({ onStart, disabled }: ChainStarterProps) {
</div>
</fieldset>
<div className="flex flex-wrap gap-4">
<label className="block text-sm text-ink-soft">
<input
<Field label="起始章号" className="w-32">
<TextInput
type="number"
min={1}
value={start}
onChange={(e) => setStart(e.target.value)}
className="mt-1 w-32 rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
aria-label="起始章号"
/>
</label>
<label className="block text-sm text-ink-soft">
1..{MAX_COUNT}
<input
</Field>
<Field label={`连续章数1..${MAX_COUNT}`} className="w-40">
<TextInput
type="number"
min={1}
max={MAX_COUNT}
value={count}
onChange={(e) => setCount(e.target.value)}
className="mt-1 w-32 rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
aria-label="连续章数"
/>
</label>
</Field>
</div>
<button
<Button
type="submit"
disabled={disabled || !valid}
className="self-start rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50"
variant="primary"
className="self-start"
>
<Play className="h-4 w-4" aria-hidden="true" />
{disabled ? "运行中…" : "发起多章链"}
</button>
</Button>
</form>
);
}

View File

@@ -0,0 +1,149 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import {
Background,
BackgroundVariant,
Controls,
ReactFlow,
type Edge,
type Node,
} from "@xyflow/react";
import { Users } from "lucide-react";
// React Flow 样式必须显式引入(组件经 dynamic ssr:false 加载,样式随该 chunk 打包)。
import "@xyflow/react/dist/style.css";
import { EmptyState } from "@/components/ui/EmptyState";
import {
buildRelationshipGraph,
NODE_WIDTH,
type GraphNode,
} from "@/lib/characters/graphLayout";
import type { CharacterCardView } from "@/lib/api/types";
interface RelationshipGraphProps {
characters: CharacterCardView[];
}
// 纸感节点样式:面板底 + 细线框 + 左侧按分组配色的强调条(色值走主题 CSS 变量)。
function paperNodeStyle(colorVar: string): React.CSSProperties {
return {
width: NODE_WIDTH,
padding: "8px 10px",
borderRadius: 6,
background: "var(--color-panel)",
color: "var(--color-ink)",
border: "1px solid var(--color-line)",
borderLeft: `4px solid var(${colorVar})`,
boxShadow: "0 1px 3px var(--shadow-paper)",
fontSize: 12,
textAlign: "left",
};
}
// 节点内容:角色名(宋体)+ 定位副标题。
function NodeLabel({ name, role }: { name: string; role: string }) {
return (
<div className="min-w-0">
<p className="truncate font-serif text-sm text-ink">{name}</p>
{role ? <p className="truncate text-2xs text-ink-soft">{role}</p> : null}
</div>
);
}
function toReactFlowNodes(nodes: GraphNode[]): Node[] {
return nodes.map((node) => ({
id: node.id,
position: node.position,
data: { label: <NodeLabel name={node.data.label} role={node.data.role} /> },
style: paperNodeStyle(node.data.colorVar),
// 只读可视化:可拖动重排,不可新建连线。
connectable: false,
}));
}
function toReactFlowEdges(
edges: { id: string; source: string; target: string; label: string }[],
): Edge[] {
return edges.map((edge) => ({
id: edge.id,
source: edge.source,
target: edge.target,
label: edge.label,
labelStyle: { fill: "var(--color-ink-soft)", fontSize: 11 },
labelBgStyle: { fill: "var(--color-bg)", fillOpacity: 0.9 },
labelBgPadding: [4, 2] as [number, number],
labelBgBorderRadius: 4,
style: { stroke: "var(--color-line)", strokeWidth: 1.5 },
}));
}
// 尊重 prefers-reduced-motion减动偏好下关闭 fitView 平滑动画与边流动动画。
function usePrefersReducedMotion(): boolean {
const [reduced, setReduced] = useState(false);
useEffect(() => {
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
const update = () => setReduced(media.matches);
update();
media.addEventListener("change", update);
return () => media.removeEventListener("change", update);
}, []);
return reduced;
}
// 角色关系图谱(灵感⑥ 缩减版 / D4只用现有 relations 数据,节点按 role 上色、
// 边按 name-join、kind 作中文标签。悬空边(引用未入库角色)已在 graphLayout 略过并计数。
export function RelationshipGraph({ characters }: RelationshipGraphProps) {
const reducedMotion = usePrefersReducedMotion();
const graph = useMemo(
() => buildRelationshipGraph(characters),
[characters],
);
const nodes = useMemo(() => toReactFlowNodes(graph.nodes), [graph.nodes]);
const edges = useMemo(() => toReactFlowEdges(graph.edges), [graph.edges]);
if (graph.nodes.length === 0) {
return (
<EmptyState
icon={Users}
title="暂无可视化的人物"
description="先入库角色并登记彼此关系,这里会绘制出关系图谱。"
/>
);
}
return (
<div className="flex flex-col gap-2">
{/* React Flow 必须有显式尺寸的容器,否则画布高度为 0。 */}
<div className="h-[28rem] w-full overflow-hidden rounded border border-line bg-bg">
<ReactFlow
nodes={nodes}
edges={edges}
fitView
fitViewOptions={{ padding: 0.2, duration: reducedMotion ? 0 : 400 }}
nodesConnectable={false}
edgesFocusable={false}
proOptions={{ hideAttribution: false }}
minZoom={0.2}
maxZoom={1.75}
defaultEdgeOptions={{ animated: false }}
>
<Background
variant={BackgroundVariant.Dots}
gap={20}
size={1}
color="var(--color-line)"
/>
<Controls showInteractive={false} />
</ReactFlow>
</div>
{graph.danglingCount > 0 ? (
<p className="text-2xs text-ink-soft">
{graph.danglingCount}
</p>
) : null}
</div>
);
}

View File

@@ -1,10 +1,18 @@
"use client";
import { useMemo, useState } from "react";
import dynamic from "next/dynamic";
import { Clock3, Globe2, Share2, Sparkles, UserRound } from "lucide-react";
import { AppShell } from "@/components/AppShell";
import { CharacterGenerator } from "@/components/generation/CharacterGenerator";
import { WorldGenerator } from "@/components/generation/WorldGenerator";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { EmptyState } from "@/components/ui/EmptyState";
import { PageHeader } from "@/components/ui/PageHeader";
import { SegmentedControl } from "@/components/ui/SegmentedControl";
import type {
CharacterCardView,
ProjectResponse,
@@ -32,6 +40,23 @@ const TABS: { key: CodexTab; label: string }[] = [
{ key: "timeline", label: "时间线" },
];
// 关系图谱React Flow只在客户端渲染ssr:false 绕开 canvas/window 依赖,
// 并把 React Flow 及其样式拆到独立 chunk进人物 tab 才加载。
const RelationshipGraph = dynamic(
() =>
import("@/components/characters/RelationshipGraph").then(
(mod) => mod.RelationshipGraph,
),
{
ssr: false,
loading: () => (
<div className="flex h-[28rem] items-center justify-center rounded-lg border border-line bg-bg text-sm text-ink-soft">
</div>
),
},
);
// 设定库 CodexUX §6.5):管理人物 / 世界观 / 时间线。
// 初始列表来自后端读端点(跨会话全量真源);本会话新入库的角色卡再合并补显,
// 故刷新后不再为空、且不与真源重复(按 name 去重,见 mergeCharacterCards
@@ -60,49 +85,92 @@ export function CodexPage({
activeNav="codex"
>
<div className="flex h-[calc(100vh-var(--chrome,4rem))] flex-col p-4">
<div className="mb-4 flex items-center gap-2" role="tablist">
{TABS.map((t) => (
<button
key={t.key}
type="button"
role="tab"
aria-selected={tab === t.key}
onClick={() => setTab(t.key)}
className={`rounded px-3 py-1.5 text-sm ${
tab === t.key
? "border-b-2 border-cinnabar text-cinnabar"
: "text-ink-soft hover:text-ink"
}`}
>
{t.label}
</button>
))}
</div>
<PageHeader
title="设定库"
description="人物、世界观与时间线共同构成写作时的真相源。AI 生成内容也要先预览、再确认入库。"
/>
<SegmentedControl
className="mb-4 w-fit"
ariaLabel="设定库分区"
value={tab}
onChange={setTab}
options={TABS.map((t) => ({ value: t.key, label: t.label }))}
/>
<div className="min-h-0 flex-1 overflow-auto">
{tab === "characters" ? (
<div className="flex flex-col gap-4">
<section className="rounded border border-line bg-panel p-3">
<h3 className="mb-2 font-serif text-sm text-ink">
<Card as="section" className="p-4">
<h3 className="mb-3 font-serif text-title-md text-ink">
{characters.length}
</h3>
{characters.length > 0 ? (
<ul className="flex flex-wrap gap-2">
<ul className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{characters.map((c, i) => (
<li
key={`${c.name}-${i}`}
className="rounded bg-bg px-2 py-1 text-xs text-ink-soft"
className="rounded-lg border border-line bg-bg p-3 text-sm"
>
{c.name}{c.role}
<div className="mb-2 flex items-center gap-2">
<span className="flex h-8 w-8 items-center justify-center rounded-md bg-[var(--color-cinnabar-wash)] text-cinnabar">
<UserRound className="h-4 w-4" aria-hidden="true" />
</span>
<div className="min-w-0">
<p className="truncate font-serif text-base text-ink">
{c.name}
</p>
<p className="text-xs text-ink-soft">{c.role}</p>
</div>
</div>
{c.relations && c.relations.length > 0 ? (
<ul className="mt-1 flex flex-wrap gap-1">
{c.relations.map((r, j) => (
<li
key={`${r.name}-${r.kind}-${j}`}
className="rounded-full bg-panel px-2 py-0.5 text-2xs"
title={r.note ?? undefined}
>
{r.kind} · {r.name}
</li>
))}
</ul>
) : null}
</li>
))}
</ul>
) : (
<p className="text-xs text-ink-soft">
</p>
<EmptyState
icon={UserRound}
title="暂无入库人物"
description="先生成或手动整理主角、配角与关系,再让写作上下文稳定引用。"
action={
<Button
onClick={() =>
document
.getElementById("character-generator")
?.scrollIntoView({ block: "center" })
}
variant="primary"
size="sm"
>
<Sparkles className="h-4 w-4" aria-hidden="true" />
</Button>
}
className="bg-bg/50"
/>
)}
</section>
</Card>
{characters.length > 0 ? (
<Card as="section" className="p-4">
<h3 className="mb-3 flex items-center gap-2 font-serif text-title-md text-ink">
<Share2 className="h-4 w-4 text-cinnabar" aria-hidden="true" />
</h3>
<RelationshipGraph characters={characters} />
</Card>
) : null}
<div id="character-generator">
<CharacterGenerator
projectId={project.id}
onIngested={(_, cards) =>
@@ -110,12 +178,13 @@ export function CodexPage({
}
/>
</div>
</div>
) : null}
{tab === "world" ? (
<div className="flex flex-col gap-4">
<section className="rounded border border-line bg-panel p-3">
<h3 className="mb-2 font-serif text-sm text-ink">
<Card as="section" className="p-4">
<h3 className="mb-3 font-serif text-title-md text-ink">
{initialWorldEntities.length}
</h3>
{initialWorldEntities.length > 0 ? (
@@ -123,15 +192,19 @@ export function CodexPage({
{initialWorldEntities.map((entity, i) => (
<article
key={`${entity.name}-${i}`}
className="rounded border border-line bg-bg p-3 text-sm"
className="rounded-lg border border-line bg-bg p-3 text-sm"
>
<header className="mb-2 flex items-center gap-2">
<Globe2
className="h-4 w-4 text-cinnabar"
aria-hidden="true"
/>
<span className="font-serif text-base text-ink">
{entity.name}
</span>
<span className="rounded bg-panel px-2 py-0.5 text-xs text-ink-soft">
<Badge>
{entity.type}
</span>
</Badge>
</header>
{worldEntityRules(entity).length > 0 ? (
<ul className="flex list-disc flex-col gap-1 pl-5 text-ink-soft">
@@ -146,19 +219,40 @@ export function CodexPage({
))}
</div>
) : (
<p className="text-xs text-ink-soft">
</p>
<EmptyState
icon={Globe2}
title="暂无入库世界观"
description="先沉淀硬规则、地理、势力与能力边界,减少后续章节设定漂移。"
action={
<Button
onClick={() =>
document
.getElementById("world-generator")
?.scrollIntoView({ block: "center" })
}
variant="primary"
size="sm"
>
<Sparkles className="h-4 w-4" aria-hidden="true" />
</Button>
}
className="bg-bg/50"
/>
)}
</section>
</Card>
<div id="world-generator">
<WorldGenerator projectId={project.id} />
</div>
</div>
) : null}
{tab === "timeline" ? (
<div className="rounded border border-dashed border-line bg-panel p-6 text-center text-sm text-ink-soft">
线P2 +
</div>
<EmptyState
icon={Clock3}
title="时间线尚未启用"
description="时间线会从章节摘要、伏笔窗口和验收记录派生,后续可用于全书一致性扫描。"
/>
) : null}
</div>
</div>

View File

@@ -1,6 +1,12 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import { useRouter } from "next/navigation";
import {
@@ -13,6 +19,61 @@ import {
} from "@/lib/command/palette";
import { handleTabTrap } from "@/lib/a11y/focusTrap";
import { api } from "@/lib/api/client";
import { useBodyScrollLock } from "@/lib/ui/useBodyScrollLock";
import { focusRing, overlayScrim } from "@/lib/ui/variants";
// 命令面板开关的极简外部 store⌘K 与可见入口(顶栏按钮 / 移动抽屉)共享同一开态。
// 用 useSyncExternalStore 订阅,避免把 open 提升到 RootLayout context 造成全树重渲染。
let paletteOpen = false;
const paletteListeners = new Set<() => void>();
function emitPalette(): void {
for (const listener of paletteListeners) listener();
}
export function setCommandPaletteOpen(next: boolean): void {
if (paletteOpen === next) return;
paletteOpen = next;
emitPalette();
}
export function openCommandPalette(): void {
setCommandPaletteOpen(true);
}
export function toggleCommandPalette(): void {
setCommandPaletteOpen(!paletteOpen);
}
function subscribePalette(callback: () => void): () => void {
paletteListeners.add(callback);
return () => paletteListeners.delete(callback);
}
function getPaletteSnapshot(): boolean {
return paletteOpen;
}
// SSR 快照恒为 false命令面板永不在服务端渲染开态避免水合不一致。
export function useCommandPaletteOpen(): boolean {
return useSyncExternalStore(
subscribePalette,
getPaletteSnapshot,
() => false,
);
}
const LIST_ID = "command-list";
const optionId = (cmd: Command): string => `command-option-${cmd.id}`;
// 统一的键帽kbd样式暖纸感描边 + 等宽小字,与 NavDrawer 的 ⌘K 提示一致。
function KeyCap({ children }: { children: React.ReactNode }) {
return (
<kbd className="rounded border border-line bg-panel px-1 font-mono text-2xs text-ink-soft">
{children}
</kbd>
);
}
// 从 pathname 抽取当前 projectId/projects/<id>/...)。
function projectIdFromPath(pathname: string): string | null {
@@ -25,14 +86,17 @@ interface CommandPaletteProps {
}
// 命令面板⌘KUX §7键盘触发的快速导航/动作。
// 焦点管理打开自动聚焦输入框、Esc 关闭、上下选择、回车执行)+ a11yrole=dialog/listbox
// WAI-ARIA combobox/listboxinput role=comboboxaria-expanded/controls/activedescendant
// 列表 role=listbox每项稳定 id + role=option。焦点管理打开聚焦输入框、关闭归还触发元素。
export function CommandPalette({ pathname }: CommandPaletteProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const open = useCommandPaletteOpen();
const [query, setQuery] = useState("");
const [highlight, setHighlight] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const dialogRef = useRef<HTMLDivElement>(null);
// 打开前的焦点元素关闭时归还焦点WCAG 2.4.3)。
const restoreFocusRef = useRef<HTMLElement | null>(null);
// 工具箱生成器(命令面板发现性):首次打开惰性拉取一次,失败静默降级为空。
const [tools, setTools] = useState<ToolCommandInfo[]>([]);
const toolsLoadedRef = useRef(false);
@@ -51,7 +115,7 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
);
const close = useCallback((): void => {
setOpen(false);
setCommandPaletteOpen(false);
setQuery("");
setHighlight(0);
}, []);
@@ -65,21 +129,29 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
[close, router],
);
useBodyScrollLock(open);
// ⌘K / Ctrl+K 全局开关。
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setOpen((prev) => !prev);
toggleCommandPalette();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
// 打开时聚焦输入框;切查询重置高亮
// 打开时记住先前焦点并聚焦输入框;关闭后把焦点归还触发元素
useEffect(() => {
if (open) inputRef.current?.focus();
if (open) {
restoreFocusRef.current = document.activeElement as HTMLElement | null;
inputRef.current?.focus();
return;
}
restoreFocusRef.current?.focus();
restoreFocusRef.current = null;
}, [open]);
// 首次打开(项目内)惰性拉工具箱生成器列表,供生成 action-gen-<key> 命令。
@@ -110,6 +182,8 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
if (!open) return null;
const activeCmd = results[highlight];
const onInputKey = (e: React.KeyboardEvent): void => {
if (e.key === "Escape") {
e.preventDefault();
@@ -128,7 +202,7 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
return (
<div
className="fixed inset-0 z-[60] flex items-start justify-center bg-black/30 pt-[15vh] motion-safe:transition-opacity"
className={`${overlayScrim} flex items-start justify-center pt-[15vh] motion-safe:transition-opacity`}
onClick={close}
>
<div
@@ -136,35 +210,53 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
role="dialog"
aria-modal="true"
aria-label="命令面板"
className="w-full max-w-lg overflow-hidden rounded-lg border border-line bg-panel shadow-paper"
className="w-full max-w-lg overflow-hidden rounded border border-line bg-panel shadow-paper"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
// focus trapTab/Shift+Tab 在对话框内循环不逃逸到背景WCAG 2.1.2)。
if (dialogRef.current) handleTabTrap(dialogRef.current, e);
}}
>
<div className="relative border-b border-line">
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onInputKey}
placeholder="搜索命令或页面…(写本章 / 审稿 / 生成角色 / 跳转伏笔 / 搜设定)"
role="combobox"
aria-label="命令搜索"
aria-controls="command-list"
className="w-full border-b border-line bg-bg px-4 py-3 text-sm text-ink outline-none"
aria-expanded={true}
aria-controls={LIST_ID}
aria-activedescendant={activeCmd ? optionId(activeCmd) : undefined}
aria-autocomplete="list"
className={`w-full bg-bg py-3 pl-4 pr-12 text-sm text-ink ${focusRing}`}
/>
{/* 触发快捷键读数:随面板常驻,强化 ⌘K 的可发现性(纯装饰,不入 Tab 序)。 */}
<span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2">
<KeyCap>K</KeyCap>
</span>
</div>
<ul
id="command-list"
id={LIST_ID}
role="listbox"
aria-label="命令列表"
className="max-h-80 overflow-auto py-1"
className="max-h-80 overflow-auto py-1 overscroll-contain"
>
{results.length === 0 ? (
<li className="px-4 py-3 text-sm text-ink-soft"></li>
<li className="px-4 py-6 text-center">
<p className="text-sm text-ink-soft">
{query.trim()}
</p>
<p className="mt-1 text-caption text-muted-soft">
稿
</p>
</li>
) : (
results.map((cmd, i) => (
<li
key={cmd.id}
id={optionId(cmd)}
role="option"
aria-selected={i === highlight}
onMouseEnter={() => setHighlight(i)}
@@ -176,11 +268,30 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
}`}
>
<span>{cmd.title}</span>
<span className="text-xs text-ink-soft">{cmd.group}</span>
<span className="text-caption text-ink-soft">{cmd.group}</span>
</li>
))
)}
</ul>
{/* 键盘操作读数:↑↓ 选择 · ↵ 打开 · esc 关闭(常驻引导,纯装饰)。 */}
<div
aria-hidden="true"
className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-line px-4 py-2 text-caption text-muted-soft"
>
<span className="flex items-center gap-1">
<KeyCap></KeyCap>
<KeyCap></KeyCap>
</span>
<span className="flex items-center gap-1">
<KeyCap></KeyCap>
</span>
<span className="flex items-center gap-1">
<KeyCap>esc</KeyCap>
</span>
</div>
</div>
</div>
);

View File

@@ -1,8 +1,10 @@
"use client";
import { Search } from "lucide-react";
import { usePathname } from "next/navigation";
import { CommandPalette } from "./CommandPalette";
import { buttonClass } from "@/lib/ui/variants";
import { CommandPalette, openCommandPalette } from "./CommandPalette";
// 全局挂载命令面板⌘K读当前 pathname 注入项目上下文。
// 放在 RootLayout 内,对所有页面生效。
@@ -10,3 +12,27 @@ export function CommandPaletteMount() {
const pathname = usePathname();
return <CommandPalette pathname={pathname ?? "/"} />;
}
// 命令面板的可见入口(顶栏常驻):让不知道 ⌘K 的用户也能发现搜索。
// 与 ⌘K 触发同一外部 store移动端折叠为图标。
export function CommandSearchButton() {
return (
<button
type="button"
onClick={() => openCommandPalette()}
aria-label="搜索命令或页面"
title="搜索命令或页面⌘K"
className={buttonClass({
variant: "ghost",
size: "sm",
className: "border-transparent",
})}
>
<Search className="h-4 w-4" aria-hidden="true" />
<span className="hidden sm:inline"></span>
<kbd className="hidden rounded border border-line bg-bg px-1 font-mono text-2xs text-ink-soft sm:inline">
K
</kbd>
</button>
);
}

View File

@@ -1,14 +1,23 @@
"use client";
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { Flag, Plus } from "lucide-react";
import { AppShell } from "@/components/AppShell";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { EmptyState } from "@/components/ui/EmptyState";
import { PageHeader } from "@/components/ui/PageHeader";
import type { ForeshadowView, ProjectResponse } from "@/lib/api/types";
import {
LANES,
LANE_LABELS,
countByStatus,
groupByStatus,
type ForeshadowStatus,
} from "@/lib/foreshadow/board";
import type { BadgeVariant } from "@/lib/ui/variants";
import { useForeshadow } from "@/lib/foreshadow/useForeshadow";
import { KanbanColumn } from "./KanbanColumn";
import { RegisterForm } from "./RegisterForm";
@@ -25,7 +34,9 @@ export function ForeshadowBoard({
initialItems,
}: ForeshadowBoardProps) {
const { items, busy, register, transition } = useForeshadow(initialItems);
const [registerOpen, setRegisterOpen] = useState(false);
const lanes = useMemo(() => groupByStatus(items), [items]);
const counts = useMemo(() => countByStatus(items), [items]);
const onTransition = (
code: string,
@@ -46,16 +57,58 @@ export function ForeshadowBoard({
projectId={project.id}
activeNav="foreshadow"
>
<div className="flex h-[calc(100vh-var(--chrome,4rem))] flex-col p-4">
<div className="mb-4 flex items-start justify-between gap-4">
<h1 className="font-serif text-lg text-ink"></h1>
<div className="flex min-h-[calc(100vh-var(--chrome,4rem))] flex-col p-4 xl:h-[calc(100vh-var(--chrome,4rem))] xl:min-h-0">
<PageHeader
title="伏笔看板"
description="跟踪每条伏笔从埋设、推进到回收的状态,逾期项会单独提醒。"
actions={
// registerOpen 时隐藏头部入口,避免与展开的表单重复(全局只一个 RegisterForm
registerOpen ? undefined : (
<Button
onClick={() => setRegisterOpen(true)}
variant="primary"
size="sm"
>
<Plus className="h-4 w-4" aria-hidden="true" />
</Button>
)
}
/>
{registerOpen ? (
<div className="mb-4">
<RegisterForm
projectId={project.id}
busy={busy}
onRegister={register}
open={registerOpen}
onOpenChange={setRegisterOpen}
/>
</div>
<div className="grid min-h-0 flex-1 grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
) : null}
{items.length === 0 ? (
<EmptyState
icon={Flag}
title="还没有伏笔"
description="登记第一条伏笔后,这里会按待推进、推进中、已回收、已逾期四种状态自动分栏。"
action={
registerOpen ? undefined : (
<Button
onClick={() => setRegisterOpen(true)}
variant="primary"
size="sm"
>
<Plus className="h-4 w-4" aria-hidden="true" />
</Button>
)
}
className="mt-6"
/>
) : (
<>
<ForeshadowBoardSummary counts={counts} />
<div className="grid flex-none grid-cols-1 gap-3 md:grid-cols-2 xl:min-h-0 xl:flex-1 xl:grid-cols-4">
{LANES.map((status) => (
<KanbanColumn
key={status}
@@ -66,7 +119,45 @@ export function ForeshadowBoard({
/>
))}
</div>
</>
)}
</div>
</AppShell>
);
}
const SUMMARY_VARIANTS: Record<ForeshadowStatus, BadgeVariant> = {
OPEN: "accent",
PARTIAL: "info",
CLOSED: "success",
OVERDUE: "warning",
};
function ForeshadowBoardSummary({
counts,
}: {
counts: Record<ForeshadowStatus, number>;
}) {
const total = LANES.reduce((sum, lane) => sum + counts[lane], 0);
return (
<section
aria-label="伏笔状态概览"
className="mb-3 grid grid-cols-2 gap-2 sm:grid-cols-4"
>
{LANES.map((status) => (
<Card key={status} data-status={status} className="px-3 py-2">
<div className="flex items-center justify-between gap-2">
<Badge variant={SUMMARY_VARIANTS[status]}>
{LANE_LABELS[status]}
</Badge>
<span className="font-mono text-sm text-ink">{counts[status]}</span>
</div>
<p className="mt-1 text-2xs text-ink-soft">
{" "}
{total > 0 ? `${Math.round((counts[status] / total) * 100)}%` : "0%"}
</p>
</Card>
))}
</section>
);
}

View File

@@ -1,9 +1,13 @@
"use client";
import { AlertTriangle } from "lucide-react";
import { useState } from "react";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { TextInput } from "@/components/ui/TextInput";
import type { ForeshadowView } from "@/lib/api/types";
import type { ForeshadowStatus } from "@/lib/foreshadow/board";
import { LANE_LABELS, type ForeshadowStatus } from "@/lib/foreshadow/board";
interface ForeshadowCardProps {
item: ForeshadowView;
@@ -44,19 +48,33 @@ export function ForeshadowCard({
<div className="flex items-baseline gap-2">
<span className="font-mono text-xs text-cinnabar">{item.code}</span>
{overdue ? (
<span className="text-xs text-overdue" aria-label="逾期">
</span>
<Badge variant="warning" aria-label="逾期">
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
</Badge>
) : null}
</div>
<p className="mt-0.5 font-serif text-sm text-ink">{item.title}</p>
<dl className="mt-2 space-y-0.5 text-xs text-ink-soft">
{typeof item.planted_at === "number" ? (
<div> {item.planted_at} </div>
<div className="flex gap-1">
<dt></dt>
<dd>{item.planted_at} </dd>
</div>
) : null}
{windowText ? (
<div className="flex gap-1">
<dt></dt>
<dd>{windowText}</dd>
</div>
) : null}
{item.importance ? (
<div className="flex gap-1">
<dt className="sr-only"></dt>
<dd>{item.importance}</dd>
</div>
) : null}
{windowText ? <div> {windowText}</div> : null}
{item.importance ? <div>{item.importance}</div> : null}
</dl>
{item.progress && item.progress.length > 0 ? (
@@ -72,15 +90,16 @@ export function ForeshadowCard({
{transitions.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{transitions.map((to) => (
<button
<Button
key={to}
type="button"
disabled={busy}
onClick={() => onTransition(item.code, to, "")}
className="rounded border border-line px-2 py-0.5 text-xs text-ink hover:border-cinnabar hover:text-cinnabar disabled:opacity-40"
variant="secondary"
size="sm"
className="px-2 py-0.5"
>
{to === "CLOSED" ? "回收" : to}
</button>
{to === "CLOSED" ? "回收" : LANE_LABELS[to]}
</Button>
))}
</div>
) : null}
@@ -89,25 +108,27 @@ export function ForeshadowCard({
<label htmlFor={`prog-${item.code}`} className="sr-only">
{item.code}
</label>
<input
<TextInput
id={`prog-${item.code}`}
type="text"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="加进展23章发光"
className="min-w-0 flex-1 rounded border border-line bg-bg px-2 py-0.5 text-xs text-ink focus:border-cinnabar focus:outline-none"
controlSize="sm"
className="min-w-0 flex-1 py-0.5"
/>
<button
type="button"
<Button
disabled={busy || note.trim().length === 0}
onClick={() => {
onTransition(item.code, null, note.trim());
setNote("");
}}
className="rounded border border-line px-2 py-0.5 text-xs text-ink hover:border-cinnabar disabled:opacity-40"
variant="secondary"
size="sm"
className="px-2 py-0.5"
>
</button>
</Button>
</div>
) : null}
</div>

View File

@@ -1,7 +1,8 @@
"use client";
import type { ForeshadowView } from "@/lib/api/types";
import type { ForeshadowStatus } from "@/lib/foreshadow/board";
import { LANE_LABELS, type ForeshadowStatus } from "@/lib/foreshadow/board";
import { Badge } from "@/components/ui/Badge";
import { ForeshadowCard } from "./ForeshadowCard";
interface KanbanColumnProps {
@@ -25,23 +26,29 @@ export function KanbanColumn({
const overdue = status === "OVERDUE";
return (
<section
data-status={status}
className="flex min-w-0 flex-col rounded border border-line bg-bg"
aria-label={`${status} 泳道`}
aria-label={`${LANE_LABELS[status]} 泳道`}
>
<header
className={`flex items-center justify-between rounded-t border-b px-3 py-2 ${
overdue
? "border-overdue bg-overdue/10 text-overdue"
? "border-overdue bg-overdue/10 text-ink"
: "border-line bg-panel text-ink-soft"
}`}
>
<span className="font-mono text-xs font-semibold">
{overdue ? "⚠ " : ""}
{status}
<span className="flex items-center gap-2">
{overdue ? (
<Badge variant="warning">{LANE_LABELS[status]}</Badge>
) : (
<span className="text-sm font-medium text-ink">
{LANE_LABELS[status]}
</span>
)}
</span>
<span className="font-mono text-xs">{items.length}</span>
</header>
<ul className="flex-1 space-y-2 overflow-auto p-2">
<ul className="space-y-2 p-2 xl:flex-1 xl:overflow-auto">
{items.length === 0 ? (
<li className="px-1 py-3 text-xs text-ink-soft/60"></li>
) : (

View File

@@ -1,13 +1,21 @@
"use client";
import { useState } from "react";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { Field } from "@/components/ui/Field";
import { TextArea } from "@/components/ui/TextArea";
import { TextInput } from "@/components/ui/TextInput";
import { cardClass } from "@/lib/ui/variants";
import type { RegisterInput } from "@/lib/foreshadow/board";
interface RegisterFormProps {
projectId: string;
busy: boolean;
onRegister: (input: RegisterInput) => Promise<boolean>;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
const EMPTY = {
@@ -25,9 +33,16 @@ export function RegisterForm({
projectId,
busy,
onRegister,
open: controlledOpen,
onOpenChange,
}: RegisterFormProps) {
const [open, setOpen] = useState(false);
const [localOpen, setLocalOpen] = useState(false);
const [form, setForm] = useState({ ...EMPTY });
const open = controlledOpen ?? localOpen;
const setOpen = (next: boolean): void => {
if (controlledOpen === undefined) setLocalOpen(next);
onOpenChange?.(next);
};
const set = (key: keyof typeof EMPTY, value: string): void =>
setForm((prev) => ({ ...prev, [key]: value }));
@@ -51,18 +66,16 @@ export function RegisterForm({
if (!open) {
return (
<button
type="button"
onClick={() => setOpen(true)}
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel hover:opacity-90"
>
</button>
<Button onClick={() => setOpen(true)} variant="primary" size="sm">
<Plus className="h-4 w-4" aria-hidden="true" />
</Button>
);
}
const canSubmit =
form.code.trim().length > 0 && form.title.trim().length > 0 && !busy;
const missingRequired =
form.code.trim().length === 0 || form.title.trim().length === 0;
const canSubmit = !missingRequired && !busy;
return (
<form
@@ -70,113 +83,77 @@ export function RegisterForm({
e.preventDefault();
void submit();
}}
className="rounded border border-line bg-panel p-4"
className={cardClass("p-4")}
>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<Field label="代号 *" id="f-code">
<input
id="f-code"
<Field label="代号" required>
<TextInput
value={form.code}
onChange={(e) => set("code", e.target.value)}
className={inputCls}
/>
</Field>
<Field label="标题 *" id="f-title" span2>
<input
id="f-title"
<Field label="标题" required className="col-span-2">
<TextInput
value={form.title}
onChange={(e) => set("title", e.target.value)}
className={inputCls}
/>
</Field>
<Field label="埋设章" id="f-planted">
<input
id="f-planted"
<Field label="埋设章">
<TextInput
inputMode="numeric"
value={form.plantedAt}
onChange={(e) => set("plantedAt", e.target.value)}
className={inputCls}
/>
</Field>
<Field label="回收窗口起" id="f-from">
<input
id="f-from"
<Field label="回收窗口起">
<TextInput
inputMode="numeric"
value={form.expectedCloseFrom}
onChange={(e) => set("expectedCloseFrom", e.target.value)}
className={inputCls}
/>
</Field>
<Field label="回收窗口止" id="f-to">
<input
id="f-to"
<Field label="回收窗口止">
<TextInput
inputMode="numeric"
value={form.expectedCloseTo}
onChange={(e) => set("expectedCloseTo", e.target.value)}
className={inputCls}
/>
</Field>
<Field label="重要度" id="f-imp">
<input
id="f-imp"
<Field label="重要度">
<TextInput
value={form.importance}
onChange={(e) => set("importance", e.target.value)}
placeholder="主线/支线"
className={inputCls}
/>
</Field>
<Field label="线索内容" id="f-content" span3>
<input
id="f-content"
<Field label="线索内容" className="col-span-2 sm:col-span-3">
<TextArea
rows={2}
value={form.content}
onChange={(e) => set("content", e.target.value)}
className={inputCls}
/>
</Field>
</div>
<div className="mt-3 flex items-center gap-2">
<button
<Button
type="submit"
disabled={!canSubmit}
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel hover:opacity-90 disabled:opacity-40"
variant="primary"
size="sm"
>
{busy ? "登记中…" : "登记"}
</button>
<button
type="button"
onClick={() => setOpen(false)}
className="rounded border border-line px-3 py-1.5 text-sm text-ink-soft hover:border-cinnabar"
>
</Button>
<Button onClick={() => setOpen(false)} variant="secondary" size="sm">
</button>
</Button>
{missingRequired && !busy ? (
<span className="text-xs text-ink-soft"></span>
) : null}
</div>
</form>
);
}
const inputCls =
"w-full rounded border border-line bg-bg px-2 py-1 text-sm text-ink focus:border-cinnabar focus:outline-none";
interface FieldProps {
label: string;
id: string;
span2?: boolean;
span3?: boolean;
children: React.ReactNode;
}
function Field({ label, id, span2, span3, children }: FieldProps) {
const span = span3 ? "col-span-2 sm:col-span-3" : span2 ? "col-span-2" : "";
return (
<div className={span}>
<label htmlFor={id} className="block text-xs text-ink-soft">
{label}
</label>
<div className="mt-0.5">{children}</div>
</div>
);
}
function toInt(raw: string): number | null {
const trimmed = raw.trim();
if (trimmed.length === 0) return null;

View File

@@ -1,5 +1,7 @@
"use client";
import { Checkbox } from "@/components/ui/Checkbox";
import { TextInput } from "@/components/ui/TextInput";
import type { CharacterCardView } from "@/lib/api/types";
interface CharacterCardItemProps {
@@ -24,19 +26,18 @@ export function CharacterCardItem({
}`}
>
<header className="mb-2 flex items-center gap-2">
<input
type="checkbox"
<Checkbox
checked={selected}
onChange={onToggle}
aria-label={`选择角色 ${card.name}`}
className="size-4 accent-cinnabar"
/>
{onEdit ? (
<input
<TextInput
value={card.name}
onChange={(e) => onEdit({ name: e.target.value })}
aria-label="角色名"
className="flex-1 rounded border border-line bg-bg px-2 py-1 font-serif text-base text-ink"
controlSize="sm"
className="min-w-0 flex-1 font-serif text-base"
/>
) : (
<span className="flex-1 font-serif text-base text-ink">{card.name}</span>
@@ -53,6 +54,40 @@ export function CharacterCardItem({
</p>
) : null}
{onEdit || card.appearance ? (
<div className="mb-1 flex items-start gap-1 text-ink-soft">
<span className="shrink-0 text-ink"></span>
{onEdit ? (
<TextInput
value={card.appearance ?? ""}
onChange={(e) => onEdit({ appearance: e.target.value })}
aria-label="外貌形象"
controlSize="sm"
className="min-w-0 flex-1"
/>
) : (
<span>{card.appearance}</span>
)}
</div>
) : null}
{onEdit || card.motive ? (
<div className="mb-1 flex items-start gap-1 text-ink-soft">
<span className="shrink-0 text-ink"></span>
{onEdit ? (
<TextInput
value={card.motive ?? ""}
onChange={(e) => onEdit({ motive: e.target.value })}
aria-label="核心动机"
controlSize="sm"
className="min-w-0 flex-1"
/>
) : (
<span>{card.motive}</span>
)}
</div>
) : null}
<p className="mb-1 text-ink-soft">
<span className="text-ink"></span>
{card.backstory}
@@ -61,11 +96,12 @@ export function CharacterCardItem({
<div className="mb-1 flex items-start gap-1 text-ink-soft">
<span className="shrink-0 text-ink"></span>
{onEdit ? (
<input
<TextInput
value={card.arc}
onChange={(e) => onEdit({ arc: e.target.value })}
aria-label="人物弧光"
className="flex-1 rounded border border-line bg-bg px-2 py-1 text-ink"
controlSize="sm"
className="min-w-0 flex-1"
/>
) : (
<span>{card.arc}</span>

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