Compare commits

...

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

View File

@@ -34,8 +34,8 @@ jobs:
run: | run: |
uv run alembic upgrade head uv run alembic upgrade head
uv run alembic check uv run alembic check
- name: pytest - name: pytest + coverage gate (>=80%)
run: uv run pytest -q run: uv run pytest -q --cov --cov-report=term-missing --cov-fail-under=80
# 打包冒烟:构建 ww-agents wheel → 装进裸 venv → import ww_agents 并断言 SPECS。 # 打包冒烟:构建 ww-agents wheel → 装进裸 venv → import ww_agents 并断言 SPECS。
# 证明 prompts/*.md 随 wheel 分发(源码树 pytest 测不出此漏带——见 # 证明 prompts/*.md 随 wheel 分发(源码树 pytest 测不出此漏带——见
@@ -85,6 +85,6 @@ jobs:
- name: typecheck - name: typecheck
working-directory: apps/web working-directory: apps/web
run: pnpm typecheck run: pnpm typecheck
- name: test - name: test + coverage gate (>=80%)
working-directory: apps/web 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 # os
.DS_Store .DS_Store
.gstack/ .gstack/
# coverage artifact不入库覆盖率跑生成误提交由 CR 批 c596a8d 撤销)
.coverage

View File

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

View File

@@ -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`. - **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). - **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 已落地) ## Toolchain (Phase 0 已落地)
工具**uv**(Python workspace4 members: `apps/api` + `packages/{shared,config,db}`) + **pnpm**(前端`apps/web`)。Python 3.12+Node 22 工具**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` 装到 `~/.local/bin`命令前 `export PATH="$HOME/.local/bin:$PATH"`
- **依赖安装**`uv sync`后端仓库根`pnpm install`前端`cd apps/web`)。 - **依赖安装**`uv sync`后端仓库根`pnpm install`前端`cd apps/web`)。
- **本地起服务**`docker compose up`pg + api + web。仅起库`docker compose up -d pg`。裸跑 API`uv run uvicorn ww_api.main:app --reload` - **本地起服务宿主端口 +1000 防冲突web 4000 / api 9000 / pg 6432容器内仍 3000/8000/5432**`docker compose up`pg + api + web端口已 +1000)。仅起库`docker compose up -d pg`宿主 6432)。裸跑API `uv run uvicorn ww_api.main:app --port 9000 --reload`读根 `.env`pg 6432 / CORS 放行 4000前端 `cd apps/web && pnpm dev``next dev -p 4000` `apps/web/.env.local``NEXT_PUBLIC_API_BASE=http://localhost:9000`)。`.env`/`.env.local` 为本机文件gitignored)。
- **迁移**`uv run alembic upgrade head`改模型后 `uv run alembic revision --autogenerate -m "..."`漂移校验 `uv run alembic check` pg 在跑)。 - **迁移**`uv run alembic upgrade head`改模型后 `uv run alembic revision --autogenerate -m "..."`漂移校验 `uv run alembic check` pg 在跑)。
- **后端门禁**`uv run ruff check .` · `uv run ruff format .` · `uv run mypy packages apps` · `uv run pytest -q`。单测:`uv run pytest path::test -q` - **后端门禁**`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` - **前端门禁**`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`)。 - **重生成 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`)。 - **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)。 - **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`。 > **背景**对标竞品「创作工具箱」——把每个「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。证实「加生成器 = 加一份声明」这条路。 > **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 — 一句话结果 + 影响的契约/文件` > 格式:`- [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] @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-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 竖井。 - [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-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-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-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 pyproject.toml uv.lock* ./
COPY packages ./packages COPY packages ./packages
COPY apps/api ./apps/api 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 RUN uv sync --no-dev --frozen || uv sync --no-dev
EXPOSE 8000 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" version = "0.0.0"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"fastapi>=0.111", "fastapi>=0.137",
"uvicorn[standard]>=0.30", "uvicorn[standard]>=0.49",
"structlog>=24.1", "structlog>=26.1",
"cryptography>=43", "cryptography>=49",
"ww-shared", "ww-shared",
"ww-config", "ww-config",
"ww-db", "ww-db",

View File

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

View File

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

View File

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

View File

@@ -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,7 +28,7 @@ from ww_agents import (
WorldEntityCard, WorldEntityCard,
WorldGenResult, WorldGenResult,
) )
from ww_core.domain.character_repo import CharacterWriteView from ww_core.domain.character_repo import CharacterWriteFields, CharacterWriteView
from ww_core.domain.project_repo import ProjectCreate from ww_core.domain.project_repo import ProjectCreate
from ww_core.domain.rule_repo import RuleListItemView from ww_core.domain.rule_repo import RuleListItemView
from ww_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage from ww_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage
@@ -75,6 +75,8 @@ class _FakeCharacterWriteRepo:
name: str, name: str,
role: str, role: str,
traits: list[str], traits: list[str],
appearance: str,
motive: str,
backstory: str, backstory: str,
arc: str, arc: str,
speech_tics: list[str], speech_tics: list[str],
@@ -91,6 +93,8 @@ class _FakeCharacterWriteRepo:
{ {
"role": role, "role": role,
"traits": list(traits), "traits": list(traits),
"appearance": appearance,
"motive": motive,
"arc": arc, "arc": arc,
"speech_tics": list(speech_tics), "speech_tics": list(speech_tics),
"tags": list(tags), "tags": list(tags),
@@ -106,6 +110,8 @@ class _FakeCharacterWriteRepo:
"name": name, "name": name,
"role": role, "role": role,
"traits": list(traits), "traits": list(traits),
"appearance": appearance,
"motive": motive,
"arc": arc, "arc": arc,
"speech_tics": list(speech_tics), "speech_tics": list(speech_tics),
"tags": list(tags), "tags": list(tags),
@@ -114,6 +120,27 @@ class _FakeCharacterWriteRepo:
) )
return CharacterWriteView(id=row_id, name=name, role=role) return CharacterWriteView(id=row_id, name=name, role=role)
async def create_many(
self, project_id: uuid.UUID, cards: list[CharacterWriteFields]
) -> list[CharacterWriteView]:
# 镜像 Sql逐卡 upsert含批内重名去重保序返回逐卡 View。
return [
await self.create(
project_id,
name=c.name,
role=c.role,
traits=list(c.traits),
appearance=c.appearance,
motive=c.motive,
backstory=c.backstory,
arc=c.arc,
speech_tics=list(c.speech_tics),
tags=list(c.tags),
relations=[dict(r) for r in c.relations],
)
for c in cards
]
class _FakeRulesReadRepo: class _FakeRulesReadRepo:
"""实现规则 repo 的读侧 `list_for_project`(带 id供 list_rules 端点)。""" """实现规则 repo 的读侧 `list_for_project`(带 id供 list_rules 端点)。"""
@@ -346,6 +373,39 @@ async def test_ingest_characters_no_conflict_writes_and_201() -> None:
assert session.commits == 1 assert session.commits == 1
@pytest.mark.asyncio
async def test_ingest_multiple_characters_all_land_in_one_batch() -> None:
# 批量入库:多张卡一次 create_many 全部落库created 保序,单次 commit。
repo = FakeProjectRepo()
pid = await _seed_project(repo)
gateway = _SchemaRoutingGateway({ContinuityReview: _no_conflicts()})
char_repo = _FakeCharacterWriteRepo()
app, session = _make_app(project_repo=repo, gateway=gateway, char_repo=char_repo)
def _card(name: str) -> dict[str, Any]:
return {
"name": name,
"role": "配角",
"traits": [],
"backstory": "x",
"arc": "y",
"speech_tics": [],
"tags": [],
"relations": [],
}
async with _client(app) as client:
resp = await client.post(
f"/projects/{pid}/characters",
json={"cards": [_card(""), _card(""), _card("")]},
)
assert resp.status_code == 201
assert resp.json()["created"] == ["", "", ""]
assert [r["name"] for r in char_repo.rows] == ["", "", ""]
assert session.commits == 1
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_ingest_characters_with_conflict_blocks_409() -> None: async def test_ingest_characters_with_conflict_blocks_409() -> None:
repo = FakeProjectRepo() repo = FakeProjectRepo()
@@ -463,6 +523,62 @@ async def test_ingest_relations_persist_on_write() -> None:
assert char_repo.rows[0]["relations"] == relations 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: def test_relations_from_jsonb_parses_and_skips_dirty() -> None:
# Arrange混入脏条目非 dict / 缺 name / 缺 kind # Arrange混入脏条目非 dict / 缺 name / 缺 kind
from ww_api.routers.generation import _relations_from_jsonb from ww_api.routers.generation import _relations_from_jsonb
@@ -572,6 +688,8 @@ def _codex_memory() -> Any:
name="叶寒", name="叶寒",
role="主角", role="主角",
traits={"items": ["腹黑", "护短"]}, traits={"items": ["腹黑", "护短"]},
appearance="剑眉星目,一袭青衫",
motive="为母复仇、登临帝位",
backstory="孤儿出身", backstory="孤儿出身",
arc={"text": "从孤儿到帝王"}, arc={"text": "从孤儿到帝王"},
speech_tics={"items": [""]}, speech_tics={"items": [""]},
@@ -668,6 +786,9 @@ async def test_list_characters_unpacks_jsonb_to_api_shape() -> None:
assert card["speech_tics"] == [""] assert card["speech_tics"] == [""]
assert card["arc"] == "从孤儿到帝王" assert card["arc"] == "从孤儿到帝王"
assert card["tags"] == ["天才"] assert card["tags"] == ["天才"]
# ⑧ 穿线:读端点暴露 appearance/motiveText 列直落,不形变)。
assert card["appearance"] == "剑眉星目,一袭青衫"
assert card["motive"] == "为母复仇、登临帝位"
@pytest.mark.asyncio @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: async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None:
return self._rows.get(chapter_no) return self._rows.get(chapter_no)
async def list_for_project(self, project_id: uuid.UUID) -> list[OutlineView]: async def list_for_project(
return [self._rows[k] for k in sorted(self._rows)] self, project_id: uuid.UUID, *, volume: int | None = None
) -> list[OutlineView]:
rows = [self._rows[k] for k in sorted(self._rows)]
return [v for v in rows if volume is None or v.volume == volume]
class _CharRepo: class _CharRepo:

View File

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

View File

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

View File

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

View File

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

View File

@@ -209,6 +209,70 @@ async def test_accept_promotes_appends_digest_records_decisions() -> None:
assert session.commits == 1 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 @pytest.mark.asyncio
async def test_accept_with_no_review_succeeds() -> None: async def test_accept_with_no_review_succeeds() -> None:
"""无审稿留痕(零冲突)→ gate 直通,仍晋升 + 提炼 digest。""" """无审稿留痕(零冲突)→ gate 直通,仍晋升 + 提炼 digest。"""

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -26,19 +26,24 @@ from sqlalchemy.ext.asyncio import AsyncSession
from ww_agents import ( from ww_agents import (
CharacterCard, CharacterCard,
CharacterRelation, CharacterRelation,
ProjectPlanResult,
character_gen_spec, character_gen_spec,
continuity_spec, continuity_spec,
project_plan_spec,
worldbuilder_spec, worldbuilder_spec,
) )
from ww_core.domain import ( from ww_core.domain import (
CharacterWriteFields,
CharacterWriteRepo, CharacterWriteRepo,
ProjectRepo, ProjectRepo,
RuleWriteRepo, RuleWriteRepo,
) )
from ww_core.domain.repositories import MemoryRepos from ww_core.domain.repositories import MemoryRepos
from ww_core.orchestrator import ( from ww_core.orchestrator import (
build_brief_context,
precheck_generated_cards, precheck_generated_cards,
run_character_gen, run_character_gen,
run_generator,
run_worldbuilder, run_worldbuilder,
) )
from ww_db import get_session from ww_db import get_session
@@ -64,6 +69,7 @@ from ww_api.schemas.generation import (
WorldGenerateRequest, WorldGenerateRequest,
WorldGenPreviewResponse, WorldGenPreviewResponse,
) )
from ww_api.schemas.projects import ProjectPlanGenerateRequest, ProjectPlanView
from ww_api.schemas.rules import RuleView from ww_api.schemas.rules import RuleView
from ww_api.services.credentials import STUB_OWNER_ID from ww_api.services.credentials import STUB_OWNER_ID
from ww_api.services.project_deps import ( from ww_api.services.project_deps import (
@@ -71,6 +77,7 @@ from ww_api.services.project_deps import (
get_character_write_repo, get_character_write_repo,
get_memory_repos, get_memory_repos,
get_precheck_gateway, get_precheck_gateway,
get_project_plan_gateway,
get_project_repo, get_project_repo,
get_rule_write_repo, get_rule_write_repo,
get_skill_registry, get_skill_registry,
@@ -90,6 +97,7 @@ SkillRegistryDep = Annotated[SkillRegistry, Depends(get_skill_registry)]
WorldGatewayDep = Annotated[Gateway, Depends(get_worldbuilder_gateway)] WorldGatewayDep = Annotated[Gateway, Depends(get_worldbuilder_gateway)]
CharacterGatewayDep = Annotated[Gateway, Depends(get_character_gen_gateway)] CharacterGatewayDep = Annotated[Gateway, Depends(get_character_gen_gateway)]
PrecheckGatewayDep = Annotated[Gateway, Depends(get_precheck_gateway)] PrecheckGatewayDep = Annotated[Gateway, Depends(get_precheck_gateway)]
ProjectPlanGatewayDep = Annotated[Gateway, Depends(get_project_plan_gateway)]
SessionDep = Annotated[AsyncSession, Depends(get_session)] SessionDep = Annotated[AsyncSession, Depends(get_session)]
@@ -105,6 +113,46 @@ def _project_context(title: str, genre: str | None, premise: str | None, theme:
return "\n".join(lines) 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: def _render_characters_context(cards: list[CharacterCard]) -> str:
"""已有角色简表(喂 character-gen 防雷同 + precheck 真相源)。确定性、保序。""" """已有角色简表(喂 character-gen 防雷同 + precheck 真相源)。确定性、保序。"""
if not cards: if not cards:
@@ -148,6 +196,8 @@ async def _existing_characters(memory: MemoryRepos, project_id: uuid.UUID) -> li
name=v.name, name=v.name,
role=v.role or "", role=v.role or "",
traits=traits, traits=traits,
appearance=v.appearance or "",
motive=v.motive or "",
backstory=v.backstory or "", backstory=v.backstory or "",
arc=arc or "", arc=arc or "",
speech_tics=tics, speech_tics=tics,
@@ -244,6 +294,7 @@ async def generate_characters(
gateway=gateway, gateway=gateway,
user_id=STUB_OWNER_ID, user_id=STUB_OWNER_ID,
project_id=project_id, project_id=project_id,
role_mix=body.role_mix,
) )
await session.commit() await session.commit()
log.info( log.info(
@@ -322,20 +373,23 @@ async def ingest_characters(
rejected_tables=rejected, rejected_tables=rejected,
) )
created: list[str] = [] fields = [
for card in allowed.get("characters", []): CharacterWriteFields(
view = await char_repo.create(
project_id,
name=card.name, name=card.name,
role=card.role, role=card.role,
traits=list(card.traits), traits=list(card.traits),
appearance=card.appearance,
motive=card.motive,
backstory=card.backstory, backstory=card.backstory,
arc=card.arc, arc=card.arc,
speech_tics=list(card.speech_tics), speech_tics=list(card.speech_tics),
tags=list(card.tags), tags=list(card.tags),
relations=[r.model_dump() for r in card.relations], relations=[r.model_dump() for r in card.relations],
) )
created.append(view.name) for card in allowed.get("characters", [])
]
# 批量 upsert一次 flush 落全部),保序返回逐卡 View。
created = [view.name for view in await char_repo.create_many(project_id, fields)]
# 提交边界:网关 ledgerprecheck+ 角色写侧均只 flush → 端点末尾一次 commit。 # 提交边界:网关 ledgerprecheck+ 角色写侧均只 flush → 端点末尾一次 commit。
await session.commit() await session.commit()
@@ -426,6 +480,53 @@ async def list_skills(registry: SkillRegistryDep) -> SkillListResponse:
return SkillListResponse(skills=skills) 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 卡转换 ---- # ---- schema <-> ww_agents 卡转换 ----
@@ -434,6 +535,8 @@ def _card_to_view(card: CharacterCard) -> CharacterCardView:
name=card.name, name=card.name,
role=card.role, role=card.role,
traits=list(card.traits), traits=list(card.traits),
appearance=card.appearance,
motive=card.motive,
backstory=card.backstory, backstory=card.backstory,
arc=card.arc, arc=card.arc,
speech_tics=list(card.speech_tics), speech_tics=list(card.speech_tics),
@@ -449,6 +552,8 @@ def _view_to_card(view: CharacterCardView) -> CharacterCard:
name=view.name, name=view.name,
role=view.role, role=view.role,
traits=list(view.traits), traits=list(view.traits),
appearance=view.appearance,
motive=view.motive,
backstory=view.backstory, backstory=view.backstory,
arc=view.arc, arc=view.arc,
speech_tics=list(view.speech_tics), speech_tics=list(view.speech_tics),

View File

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

View File

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

View File

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

View File

@@ -20,6 +20,7 @@ from typing import Annotated, Any
from fastapi import APIRouter, BackgroundTasks, Depends, Request from fastapi import APIRouter, BackgroundTasks, Depends, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ww_agents import ClarifyDecision, clarify_rewrite_spec
from ww_core.domain.chapter_repo import ChapterRepo from ww_core.domain.chapter_repo import ChapterRepo
from ww_core.domain.digest_repo import DigestAppendRepo from ww_core.domain.digest_repo import DigestAppendRepo
from ww_core.domain.injection_repo import EntityRef, InjectionOverride, InjectionOverrideRepo from ww_core.domain.injection_repo import EntityRef, InjectionOverride, InjectionOverrideRepo
@@ -36,13 +37,16 @@ from ww_core.orchestrator import (
error_event, error_event,
normalize_deltas, normalize_deltas,
normalize_review, normalize_review,
run_clarify,
stream_chapter_draft, stream_chapter_draft,
stream_chapter_rewrite,
) )
from ww_db import get_session from ww_db import get_session
from ww_llm_gateway import Gateway from ww_llm_gateway import Gateway
from ww_shared import AppError, ErrorCode, ErrorEnvelope from ww_shared import AppError, ErrorCode, ErrorEnvelope
from ww_api.logging_config import get_logger from ww_api.logging_config import get_logger
from ww_api.pagination import PageDep
from ww_api.schemas.injection import ( from ww_api.schemas.injection import (
InjectionEntity, InjectionEntity,
InjectionEntityRef, InjectionEntityRef,
@@ -52,6 +56,7 @@ from ww_api.schemas.injection import (
from ww_api.schemas.projects import ( from ww_api.schemas.projects import (
AcceptRequest, AcceptRequest,
AcceptResponse, AcceptResponse,
ChapterListItem,
DraftResponse, DraftResponse,
DraftSaveRequest, DraftSaveRequest,
DraftStreamRequest, DraftStreamRequest,
@@ -63,17 +68,22 @@ from ww_api.schemas.projects import (
ReviewHistoryItem, ReviewHistoryItem,
ReviewHistoryResponse, ReviewHistoryResponse,
ReviewRequest, ReviewRequest,
RewriteClarifyRequest,
RewriteStreamRequest,
) )
from ww_api.services.accept_service import ( from ww_api.services.accept_service import (
AcceptOutcome, AcceptOutcome,
assert_conflicts_resolved, assert_conflicts_resolved,
run_accept_transaction, run_accept_transaction,
) )
from ww_api.services.chapter_list import ChapterLister
from ww_api.services.credentials import STUB_OWNER_ID from ww_api.services.credentials import STUB_OWNER_ID
from ww_api.services.digest_extraction import extract_digest_facts from ww_api.services.digest_extraction import extract_digest_facts
from ww_api.services.foreshadow_scan import SessionFactory, run_overdue_scan from ww_api.services.foreshadow_scan import SessionFactory, run_overdue_scan
from ww_api.services.project_deps import ( from ww_api.services.project_deps import (
get_chapter_lister,
get_chapter_repo, get_chapter_repo,
get_clarify_gateway,
get_digest_append_repo, get_digest_append_repo,
get_digest_gateway, get_digest_gateway,
get_injection_repo, get_injection_repo,
@@ -98,12 +108,21 @@ _ACCEPT_ERRORS: dict[int | str, dict[str, Any]] = {
409: {"model": ErrorEnvelope, "description": "存在未裁决冲突"}, 409: {"model": ErrorEnvelope, "description": "存在未裁决冲突"},
503: {"model": ErrorEnvelope, "description": "LLM 不可用"}, 503: {"model": ErrorEnvelope, "description": "LLM 不可用"},
} }
# SSE 端点:如实声明 200 的 media type 为 text/event-stream否则 OpenAPI 误标 json
_SSE_RESPONSE: dict[int | str, dict[str, Any]] = {
200: {
"description": "SSE 事件流(`event: <name>\\ndata: <json>`,见 memory/contracts.md",
"content": {"text/event-stream": {}},
}
}
ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)] ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
ChapterRepoDep = Annotated[ChapterRepo, Depends(get_chapter_repo)] ChapterRepoDep = Annotated[ChapterRepo, Depends(get_chapter_repo)]
ChapterListerDep = Annotated[ChapterLister, Depends(get_chapter_lister)]
GatewayDep = Annotated[Gateway, Depends(get_writer_gateway)] GatewayDep = Annotated[Gateway, Depends(get_writer_gateway)]
ReviewGatewayDep = Annotated[Gateway, Depends(get_review_gateway)] ReviewGatewayDep = Annotated[Gateway, Depends(get_review_gateway)]
DigestGatewayDep = Annotated[Gateway, Depends(get_digest_gateway)] DigestGatewayDep = Annotated[Gateway, Depends(get_digest_gateway)]
ClarifyGatewayDep = Annotated[Gateway, Depends(get_clarify_gateway)]
MemoryReposDep = Annotated[MemoryRepos, Depends(get_memory_repos)] MemoryReposDep = Annotated[MemoryRepos, Depends(get_memory_repos)]
InjectionRepoDep = Annotated[InjectionOverrideRepo, Depends(get_injection_repo)] InjectionRepoDep = Annotated[InjectionOverrideRepo, Depends(get_injection_repo)]
ReviewRepoDep = Annotated[ReviewRepo, Depends(get_review_repo)] ReviewRepoDep = Annotated[ReviewRepo, Depends(get_review_repo)]
@@ -127,6 +146,9 @@ async def create_project(body: ProjectCreateRequest, repo: ProjectRepoDep) -> Pr
theme=body.theme, theme=body.theme,
selling_points=body.selling_points, selling_points=body.selling_points,
structure=body.structure, 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)) 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("") @router.get("")
async def list_projects(repo: ProjectRepoDep) -> ProjectListResponse: async def list_projects(repo: ProjectRepoDep, page: PageDep) -> ProjectListResponse:
views = await repo.list_for_owner(STUB_OWNER_ID) """作品列表(按 created_at 升序,分页)。`?limit=&offset=`,默认第一页。"""
views = await repo.list_for_owner(STUB_OWNER_ID, limit=page.limit, offset=page.offset)
return ProjectListResponse(projects=[_to_response(v) for v in views]) return ProjectListResponse(projects=[_to_response(v) for v in views])
@@ -147,6 +170,28 @@ async def get_project(project_id: uuid.UUID, repo: ProjectRepoDep) -> ProjectRes
return _to_response(view) 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( async def _injection_response(
repos: MemoryRepos, repos: MemoryRepos,
project_id: uuid.UUID, project_id: uuid.UUID,
@@ -244,7 +289,7 @@ def _encode_sse(event: SseEvent) -> str:
return f"event: {event.event}\ndata: {payload}\n\n" return f"event: {event.event}\ndata: {payload}\n\n"
@router.post("/{project_id}/chapters/{chapter_no}/draft") @router.post("/{project_id}/chapters/{chapter_no}/draft", responses=_SSE_RESPONSE)
async def stream_draft( async def stream_draft(
project_id: uuid.UUID, project_id: uuid.UUID,
chapter_no: int, chapter_no: int,
@@ -307,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") @router.put("/{project_id}/chapters/{chapter_no}/draft")
async def save_draft( async def save_draft(
project_id: uuid.UUID, project_id: uuid.UUID,
@@ -388,7 +565,7 @@ async def _resolve_review_draft(
return saved.content return saved.content
@router.post("/{project_id}/chapters/{chapter_no}/review") @router.post("/{project_id}/chapters/{chapter_no}/review", responses=_SSE_RESPONSE)
async def review_chapter( async def review_chapter(
project_id: uuid.UUID, project_id: uuid.UUID,
chapter_no: int, chapter_no: int,
@@ -485,6 +662,7 @@ async def list_reviews(
foreshadow_sug=v.foreshadow_sug, foreshadow_sug=v.foreshadow_sug,
style=v.style, style=v.style,
pace=v.pace, pace=v.pace,
characterization=v.characterization,
health_score=v.health_score, health_score=v.health_score,
decisions=v.decisions, decisions=v.decisions,
) )

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,13 +12,16 @@ snake_case前端经 OpenAPI 生成 TS 类型消费。改字段 → 前端必
from __future__ import annotations from __future__ import annotations
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, model_validator
from ww_api.schemas.rules import RuleView from ww_api.schemas.rules import RuleView
# 批量生成数量上限(防一次性巨量调用;原型保守值)。 # 批量生成数量上限(防一次性巨量调用;原型保守值)。
MAX_GENERATE_COUNT = 12 MAX_GENERATE_COUNT = 12
# 一句话需求文本上界CR-H9防超长入参撑爆上下文/成本)。
_BRIEF_MAX = 10_000
# ---- 世界观生成(预览)---- # ---- 世界观生成(预览)----
@@ -26,7 +29,9 @@ MAX_GENERATE_COUNT = 12
class WorldGenerateRequest(BaseModel): class WorldGenerateRequest(BaseModel):
"""POST /projects/:id/world/generate据需求生成世界观实体预览""" """POST /projects/:id/world/generate据需求生成世界观实体预览"""
brief: str = Field(min_length=1, description="世界观生成需求(题材/设定方向/约束)") brief: str = Field(
min_length=1, max_length=_BRIEF_MAX, description="世界观生成需求(题材/设定方向/约束)"
)
class WorldEntityCardView(BaseModel): class WorldEntityCardView(BaseModel):
@@ -47,11 +52,40 @@ class WorldGenPreviewResponse(BaseModel):
class CharacterGenerateRequest(BaseModel): class CharacterGenerateRequest(BaseModel):
"""POST /projects/:id/characters/generate据需求生成角色卡预览群像防雷同""" """POST /projects/:id/characters/generate据需求生成角色卡预览群像防雷同
brief: str = Field(min_length=1, description="角色生成需求") `role_mix`(⑦ 群像按定位配比)与 `count` 的关系:`role_mix` 在场时 `count` 取各值之和
count: int = Field(default=1, ge=1, le=MAX_GENERATE_COUNT, description="生成数量") (忽略客户端显式 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: 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): class CharacterRelationView(BaseModel):
@@ -67,7 +101,9 @@ class CharacterCardView(BaseModel):
name: str = Field(description="角色名") name: str = Field(description="角色名")
role: 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="背景故事") backstory: str = Field(description="背景故事")
arc: str = Field(description="人物弧光(一句话)") arc: str = Field(description="人物弧光(一句话)")
speech_tics: list[str] = Field(default_factory=list, description="口癖/语言风格") speech_tics: list[str] = Field(default_factory=list, description="口癖/语言风格")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -1,10 +1,30 @@
FROM node:22-slim AS build FROM node:22-slim AS build
RUN corepack enable
WORKDIR /app WORKDIR /app
COPY package.json pnpm-lock.yaml* ./ # npm/corepack registry可经 build arg 覆盖):部分区域 registry.npmjs.org 不可达,部署时指向镜像
RUN pnpm install --frozen-lockfile || pnpm install # (如 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 . . 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 RUN pnpm build
# 本项目 public/ 为空目录(不入 gitgit archive 构建上下文里不存在);确保它存在,
# 否则下方 run 阶段 `COPY --from=build /app/public` 会因源缺失而失败。
RUN mkdir -p /app/public
FROM node:22-slim AS run FROM node:22-slim AS run
WORKDIR /app WORKDIR /app
@@ -12,4 +32,7 @@ COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public COPY --from=build /app/public ./public
EXPOSE 3000 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"] 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; @tailwind utilities;
/* 纸感设计 tokenUX_SPEC §2.1 */ /* 纸感设计 tokenUX_SPEC §2.1 */
:root { :root,
[data-theme="paper"] {
/* 面色阶:底 → 分隔带 → 内容卡(比底暗一步)→ 选中强调 → 浮起面板 */
--color-bg: #f5f1e8; --color-bg: #f5f1e8;
--color-surface-soft: #efe9dc;
--color-surface-card: #ece4d3;
--color-surface-strong: #e4d9c4;
--color-panel: #fbf8f1; --color-panel: #fbf8f1;
/* 文字色阶5 级):标题 → 强调段 → 正文 → 次级 → 说明/细则 */
--color-ink: #2b2620; --color-ink: #2b2620;
--color-body-strong: #3a342b;
--color-body: #4a4234;
--color-ink-soft: #6b6356; --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: #e5ddcd;
--color-line-soft: #ece4d5;
/* 朱砂 coral主色 + 按压变深 + 奶油化禁用 + 淡底 */
--color-cinnabar: #a23b2e; --color-cinnabar: #a23b2e;
--color-cinnabar-active: #8a2f24;
--color-cinnabar-disabled: #d9cdbd;
--color-cinnabar-wash: #a23b2e14; --color-cinnabar-wash: #a23b2e14;
--color-conflict: #b5543a; --color-conflict: #b5543a;
--color-overdue: #c8893a; --color-overdue: #c8893a;
--color-pass: #5a6b4f; --color-pass: #5a6b4f;
--color-info: #4a5a6b; --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 { body {
@@ -22,6 +92,11 @@ body {
color: var(--color-ink); color: var(--color-ink);
} }
::selection {
background: var(--color-cinnabar-wash);
color: var(--color-ink);
}
/* 流式打字机光标:朱砂闪烁;尊重 prefers-reduced-motionUX §10。 */ /* 流式打字机光标:朱砂闪烁;尊重 prefers-reduced-motionUX §10。 */
.typewriter-cursor { .typewriter-cursor {
animation: typewriter-blink 1s step-end infinite; animation: typewriter-blink 1s step-end infinite;
@@ -42,7 +117,7 @@ body {
/* 终稿正文冲突原文持续高亮朱砂淡底点锚点定位时闪烁一下UX §8.3)。 */ /* 终稿正文冲突原文持续高亮朱砂淡底点锚点定位时闪烁一下UX §8.3)。 */
.draft-mark { .draft-mark {
background-color: #b5543a26; /* --color-conflict @ ~15% */ background-color: var(--color-conflict-mark);
border-radius: 2px; border-radius: 2px;
} }
.draft-mark-flash { .draft-mark-flash {
@@ -52,14 +127,118 @@ body {
@keyframes draft-mark-flash { @keyframes draft-mark-flash {
0%, 0%,
100% { 100% {
background-color: #b5543a26; background-color: var(--color-conflict-mark);
} }
30% { 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) { @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 { .typewriter-cursor {
animation: none; animation: none;
} }
@@ -70,4 +249,7 @@ body {
animation: none; animation: none;
outline: 2px solid var(--color-conflict); 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 "./globals.css";
import { CommandPaletteMount } from "@/components/command/CommandPaletteMount"; import { CommandPaletteMount } from "@/components/command/CommandPaletteMount";
import { ThemeScript } from "@/components/ThemeScript";
import { ToastProvider } from "@/components/Toast"; import { ToastProvider } from "@/components/Toast";
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -19,6 +20,7 @@ export default function RootLayout({
return ( return (
<html lang="zh-CN" suppressHydrationWarning> <html lang="zh-CN" suppressHydrationWarning>
<body className="font-sans antialiased" suppressHydrationWarning> <body className="font-sans antialiased" suppressHydrationWarning>
<ThemeScript />
<ToastProvider> <ToastProvider>
{children} {children}
<CommandPaletteMount /> <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 Link from "next/link";
import { BookOpen, PenLine, Plus } from "lucide-react";
import { AppShell } from "@/components/AppShell"; 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 { fetchProjects } from "@/lib/api/server";
import type { ProjectResponse } from "@/lib/api/types"; import type { ProjectResponse } from "@/lib/api/types";
import { buttonClass } from "@/lib/ui/variants";
// 作品库Dashboard 入口UX §6.1。Server Component 读取。 // 作品库Dashboard 入口UX §6.1。Server Component 读取。
export default async function DashboardPage() { export default async function DashboardPage() {
@@ -18,63 +23,63 @@ export default async function DashboardPage() {
return ( return (
<AppShell> <AppShell>
<div className="mx-auto max-w-5xl px-8 py-10"> <PageContainer width="wide">
<div className="mb-8 flex items-center justify-between"> <PageHeader
<h1 className="font-serif text-3xl text-ink"></h1> 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 <Link
href="/projects/new" 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> </Link>
</div> </div>
}
/>
{loadError ? ( {loadError ? (
<p className="rounded border border-conflict bg-panel p-6 text-conflict"> <BackendDownNotice />
API
</p>
) : projects.length === 0 ? ( ) : projects.length === 0 ? (
<EmptyState /> // 首次进入的暖深色编辑部英雄(对标 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">
<ul className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3"> <BookOpen className="mx-auto h-9 w-9 text-cinnabar" aria-hidden="true" />
{projects.map((p) => ( <h2 className="mt-5 font-serif text-display-sm text-on-callout">
<li key={p.id}>
<ProjectCard project={p} /> </h2>
</li> <p className="mx-auto mt-3 max-w-md text-body leading-7 text-on-callout-soft">
))}
<li> </p>
<NewProjectCard /> <div className="mt-7 flex flex-wrap items-center justify-center gap-2">
</li> <Link
</ul> 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>
</div>
) : (
<ProjectLibrary projects={projects} />
)}
</PageContainer>
</AppShell> </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 { CodexPage } from "@/components/codex/CodexPage";
import { import {
fetchCharacters, fetchCharacters,
@@ -37,6 +36,6 @@ export default async function CodexRoutePage({
/> />
); );
} catch { } catch {
notFound(); return <BackendDownNotice className="m-6" />;
} }
} }

View File

@@ -1,7 +1,9 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { BackendDownNotice } from "@/components/BackendDownNotice";
import { ForeshadowBoard } from "@/components/foreshadow/ForeshadowBoard"; import { ForeshadowBoard } from "@/components/foreshadow/ForeshadowBoard";
import { fetchForeshadow, fetchProject } from "@/lib/api/server"; import { fetchForeshadow, fetchProject } from "@/lib/api/server";
import type { ForeshadowView, ProjectResponse } from "@/lib/api/types";
interface PageProps { interface PageProps {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@@ -11,13 +13,30 @@ interface PageProps {
// ForeshadowBoardClient按 status 分四泳道并承载登记/状态变更交互。 // ForeshadowBoardClient按 status 分四泳道并承载登记/状态变更交互。
export default async function ForeshadowPage({ params }: PageProps) { export default async function ForeshadowPage({ params }: PageProps) {
const { id } = await params; const { id } = await params;
// 仅当项目确实不存在404才判 notFound后端不可用等其它失败降级为「后端不可达」提示
// 不把整页变成「找不到页面」。
let project: ProjectResponse;
try { try {
const project = await fetchProject(id); project = await fetchProject(id);
const board = await fetchForeshadow(id); } catch (err) {
return ( if (isNotFoundError(err)) notFound();
<ForeshadowBoard project={project} initialItems={board.foreshadow ?? []} /> return <BackendDownNotice className="m-6" />;
);
} catch {
notFound();
} }
// 伏笔看板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 { notFound } from "next/navigation";
import { BackendDownNotice } from "@/components/BackendDownNotice";
import { OutlineEditor } from "@/components/outline/OutlineEditor"; import { OutlineEditor } from "@/components/outline/OutlineEditor";
import { fetchOutline, fetchProject } from "@/lib/api/server"; import { fetchOutline, fetchProject } from "@/lib/api/server";
import type { ProjectResponse } from "@/lib/api/types";
interface PageProps { interface PageProps {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@@ -12,13 +14,22 @@ interface PageProps {
// 仍按需触发 POST .../outline 生成/覆盖所显章节。 // 仍按需触发 POST .../outline 生成/覆盖所显章节。
export default async function OutlinePage({ params }: PageProps) { export default async function OutlinePage({ params }: PageProps) {
const { id } = await params; const { id } = await params;
// 仅当项目确实不存在404才判 notFound后端不可用等其它失败降级为「后端不可达」提示
// 不把整页变成「找不到页面」。次级拉取(大纲)的瞬时错误已在 fetchOutline 内降级为空。
let project: ProjectResponse;
try { try {
const project = await fetchProject(id); project = await fetchProject(id);
const initialChapters = await fetchOutline(id); } catch (err) {
return ( if (isNotFoundError(err)) notFound();
<OutlineEditor project={project} initialChapters={initialChapters} /> return <BackendDownNotice className="m-6" />;
);
} catch {
notFound();
} }
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 { notFound } from "next/navigation";
import { ReviewReport } from "@/components/review/ReviewReport"; 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 { latestReview } from "@/lib/review/history";
import type { ChapterEntry } from "@/lib/workbench/chapter";
interface PageProps { interface PageProps {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
searchParams: Promise<{ chapter?: string }>; searchParams: Promise<{ chapter?: string }>;
} }
const DEFAULT_CHAPTER_NO = 1;
// 审稿报告页UX §6.4。Server Component 取项目 + 审稿留痕历史(新→旧); // 审稿报告页UX §6.4。Server Component 取项目 + 审稿留痕历史(新→旧);
// ReviewReportClient承载 SSE 重审 / 裁决 / 验收交互。 // ReviewReportClient承载 SSE 重审 / 裁决 / 验收交互。
export default async function ReviewPage({ params, searchParams }: PageProps) { export default async function ReviewPage({ params, searchParams }: PageProps) {
const { id } = await params; const { id } = await params;
const { chapter } = await searchParams; const { chapter } = await searchParams;
const chapterNo = parsePositiveInt(chapter) ?? DEFAULT_CHAPTER_NO;
// 仅当项目确实取不到时才判 404审稿/草稿/大纲等次级拉取的瞬时错误不应把整页变成「找不到」。
let project: ProjectResponse;
try { try {
const project = await fetchProject(id); 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 ?? ""}
/>
);
} catch { } catch {
notFound(); 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 { 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 { ToolboxPage } from "@/components/toolbox/ToolboxPage";
import { fetchProject, fetchToolbox } from "@/lib/api/server"; import { fetchProject, fetchToolbox } from "@/lib/api/server";
@@ -29,6 +28,6 @@ export default async function ToolboxRoutePage({
/> />
); );
} catch { } catch {
notFound(); return <BackendDownNotice className="m-6" />;
} }
} }

View File

@@ -1,5 +1,7 @@
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { BackendDownNotice } from "@/components/BackendDownNotice";
import { ProvidersSettings } from "@/components/settings/ProvidersSettings"; import { ProvidersSettings } from "@/components/settings/ProvidersSettings";
import { PageHeader } from "@/components/ui/PageHeader";
import { fetchKimiOauthStatus, fetchProviders } from "@/lib/api/server"; import { fetchKimiOauthStatus, fetchProviders } from "@/lib/api/server";
import type { ProvidersResponse } from "@/lib/api/types"; import type { ProvidersResponse } from "@/lib/api/types";
@@ -27,11 +29,13 @@ export default async function ProvidersSettingsPage() {
return ( return (
<AppShell title="设置 模型与提供商"> <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 ? ( {loadError ? (
<p className="rounded border border-conflict bg-panel p-6 text-conflict"> <BackendDownNotice />
API
</p>
) : ( ) : (
<ProvidersSettings initial={initial} kimiOauth={kimiOauth} /> <ProvidersSettings initial={initial} kimiOauth={kimiOauth} />
)} )}

View File

@@ -1,14 +1,23 @@
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { TemplatesManager } from "@/components/templates/TemplatesManager"; import { TemplatesManager } from "@/components/templates/TemplatesManager";
import { fetchTemplates } from "@/lib/api/server"; import { PageHeader } from "@/components/ui/PageHeader";
import type { TemplateResponse } from "@/lib/api/types"; 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() { export default async function TemplatesPage() {
let initial: TemplateResponse[] = []; let initial: TemplateResponse[] = [];
let tools: ToolDescriptorView[] = [];
let loadError = false; let loadError = false;
try { try {
initial = await fetchTemplates(); const [templates, toolbox] = await Promise.all([
fetchTemplates(),
fetchToolbox(),
]);
initial = templates;
tools = toolbox.tools ?? [];
} catch { } catch {
loadError = true; loadError = true;
} }
@@ -16,15 +25,17 @@ export default async function TemplatesPage() {
return ( return (
<AppShell title="提示词 / 模板库"> <AppShell title="提示词 / 模板库">
<div className="mx-auto max-w-3xl px-8 py-10"> <div className="mx-auto max-w-3xl px-8 py-10">
<p className="mb-6 text-xs text-ink-soft"> <PageHeader
brief / title="提示词 / 模板库"
</p> eyebrow="prompt templates"
description="保存常用提示词,复用时一键填入生成器的需求 / 原文输入。仅本地、单用户,不做分享。"
/>
{loadError ? ( {loadError ? (
<p className="rounded border border-conflict bg-panel p-6 text-conflict"> <StatusNote variant="danger" title="无法连接后端服务">
API <p> API </p>
</p> </StatusNote>
) : ( ) : (
<TemplatesManager initial={initial} /> <TemplatesManager initial={initial} tools={tools} />
)} )}
</div> </div>
</AppShell> </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 Link from "next/link";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { FileQuestion, Settings } from "lucide-react";
import type { ActiveNav } from "@/lib/nav/items"; 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 { LeftNav } from "./LeftNav";
import { NavDrawer } from "./NavDrawer"; import { NavDrawer } from "./NavDrawer";
import { ThemeToggle } from "./ThemeToggle";
interface AppShellProps { interface AppShellProps {
children: ReactNode; children: ReactNode;
@@ -15,6 +18,9 @@ interface AppShellProps {
projectId?: string; projectId?: string;
// 当前激活的项目级入口键(用于高亮)。 // 当前激活的项目级入口键(用于高亮)。
activeNav?: ActiveNav; activeNav?: ActiveNav;
// 专注写作模式P3-4隐藏桌面左侧图标导航以加宽正文顶栏汉堡/命令面板仍可达导航。
// 缺省 false → 其余页面外观不变(加法式可选项)。
hideNav?: boolean;
} }
// 全局外壳:顶栏(64px) + 左导航 + 主区UX §5.1)。 // 全局外壳:顶栏(64px) + 左导航 + 主区UX §5.1)。
@@ -24,42 +30,79 @@ export function AppShell({
subtitle, subtitle,
projectId, projectId,
activeNav, activeNav,
hideNav = false,
}: AppShellProps) { }: AppShellProps) {
return ( return (
<div <div
className="min-h-screen" 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} /> <NavDrawer projectId={projectId} activeNav={activeNav} />
<Link <Link
href="/" href="/"
className="font-serif text-xl text-cinnabar" className={`shrink-0 whitespace-nowrap rounded font-serif text-xl text-cinnabar ${focusRing}`}
aria-label="返回作品库" aria-label="返回作品库"
> >
</Link> </Link>
{title ? ( {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} ) : null}
{subtitle ? ( {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} ) : 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 <Link
href="/settings/providers" 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> </Link>
</nav> </nav>
</header> </header>
{projectId ? (
<AiToolbar projectId={projectId} activeNav={activeNav} />
) : null}
<div className="flex"> <div className="flex">
<LeftNav projectId={projectId} activeNav={activeNav} /> {hideNav ? null : <LeftNav projectId={projectId} activeNav={activeNav} />}
<main className="min-h-[calc(100vh-var(--chrome,4rem))] flex-1 bg-bg"> <main
id="main"
tabIndex={-1}
className="min-h-[calc(100vh-var(--chrome,4rem))] min-w-0 flex-1 bg-bg"
>
{children} {children}
</main> </main>
</div> </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"; "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 { 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 { interface DrawerProps {
open: boolean; open: boolean;
@@ -11,22 +20,35 @@ interface DrawerProps {
side?: "left" | "right"; side?: "left" | "right";
// 无障碍标签role=dialog // 无障碍标签role=dialog
label: string; label: string;
// 抽屉面板 id供触发按钮 aria-controls 指向(无障碍关联)。
id?: string;
triggerRef?: RefObject<HTMLElement | null>;
children: ReactNode; children: ReactNode;
} }
// 移动端侧滑抽屉(<lg遮罩点击 / Esc 关闭 + 打开聚焦面板)。 // 移动端侧滑抽屉(<lg遮罩点击 / Esc 关闭 + 打开聚焦具名关闭按钮)。
// 复用命令面板的 a11y 模式CommandPalette桌面用静态布局不渲染本抽屉。 // 复用命令面板的 a11y 模式CommandPalette桌面用静态布局不渲染本抽屉。
export function Drawer({ export function Drawer({
open, open,
onClose, onClose,
side = "left", side = "left",
label, label,
id,
triggerRef,
children, children,
}: DrawerProps) { }: DrawerProps) {
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const { shouldRender, isVisible } = useMountTransition(open, DRAWER_MS);
useBodyScrollLock(open);
useRestoreFocus(open, triggerRef);
useEffect(() => { useEffect(() => {
if (!open) return; // 面板真正挂载shouldRender且处于打开态时才装监听/落焦——
// 因进出场延迟了卸载,若仅依赖 open 会错过挂载帧导致首焦点丢失。
if (!open || !shouldRender) return;
const onKey = (e: KeyboardEvent): void => { const onKey = (e: KeyboardEvent): void => {
if (e.key === "Escape") { if (e.key === "Escape") {
e.preventDefault(); e.preventDefault();
@@ -34,19 +56,31 @@ export function Drawer({
} }
}; };
window.addEventListener("keydown", onKey); window.addEventListener("keydown", onKey);
panelRef.current?.focus(); // 焦点陷阱首焦点落在具名关闭按钮上(而非整个面板)。
closeRef.current?.focus();
return () => window.removeEventListener("keydown", onKey); 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 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 ( 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 <div
ref={panelRef} ref={panelRef}
tabIndex={-1} id={id}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={label} aria-label={label}
@@ -55,8 +89,19 @@ export function Drawer({
// focus trapTab/Shift+Tab 在抽屉内循环不逃逸到背景WCAG 2.1.2)。 // focus trapTab/Shift+Tab 在抽屉内循环不逃逸到背景WCAG 2.1.2)。
if (panelRef.current) handleTabTrap(panelRef.current, e); 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} {children}
</div> </div>
</div> </div>

View File

@@ -11,7 +11,7 @@ interface LeftNavProps {
export function LeftNav({ projectId, activeNav }: LeftNavProps) { export function LeftNav({ projectId, activeNav }: LeftNavProps) {
return ( return (
<nav <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="主导航" aria-label="主导航"
> >
<NavItems projectId={projectId} activeNav={activeNav} /> <NavItems projectId={projectId} activeNav={activeNav} />

View File

@@ -1,8 +1,12 @@
"use client"; "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 type { ActiveNav } from "@/lib/nav/items";
import { focusRing } from "@/lib/ui/variants";
import { Drawer } from "./Drawer"; import { Drawer } from "./Drawer";
import { NavItems } from "./NavItems"; import { NavItems } from "./NavItems";
@@ -11,29 +15,54 @@ interface NavDrawerProps {
activeNav?: ActiveNav; activeNav?: ActiveNav;
} }
// 抽屉面板 id供汉堡按钮 aria-controls 关联(无障碍)。
const DRAWER_ID = "nav-drawer";
// 移动端导航:汉堡按钮 + 左侧抽屉(仅 <lg桌面用 LeftNav 静态侧栏。UX §5.1。 // 移动端导航:汉堡按钮 + 左侧抽屉(仅 <lg桌面用 LeftNav 静态侧栏。UX §5.1。
export function NavDrawer({ projectId, activeNav }: NavDrawerProps) { export function NavDrawer({ projectId, activeNav }: NavDrawerProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const openSearch = (): void => {
setOpen(false);
openCommandPalette();
};
return ( return (
<> <>
<button <Button
type="button" ref={triggerRef}
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
aria-label="打开导航" aria-label="打开导航"
aria-expanded={open} 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"> <Menu className="h-5 w-5" aria-hidden="true" />
</Button>
</span>
</button>
<Drawer <Drawer
open={open} open={open}
onClose={() => setOpen(false)} onClose={() => setOpen(false)}
side="left" side="left"
label="主导航" 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 <NavItems
projectId={projectId} projectId={projectId}
activeNav={activeNav} activeNav={activeNav}

View File

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

View File

@@ -1,26 +1,94 @@
import Link from "next/link"; 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 type { ProjectResponse } from "@/lib/api/types";
import {
formatProjectUpdatedAt,
pendingReviewCount,
} from "@/lib/projects/projects";
import { cardClass, focusRing } from "@/lib/ui/variants";
interface ProjectCardProps { interface ProjectCardProps {
project: ProjectResponse; project: ProjectResponse;
compact?: boolean;
} }
// 作品卡UX §6.1):书名衬线大字、题材、一句话故事。 // 作品卡UX §6.1):书名衬线大字、题材、一句话故事。
// M1 无字数/章数/待办统计端点 → 暂不展示该徽标(避免编造 API // 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 ( return (
<Link <Link
href={`/projects/${project.id}/write`} 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> <span className="flex h-9 w-9 shrink-0 items-center justify-center rounded bg-[var(--color-cinnabar-wash)] text-cinnabar">
{project.genre ? ( <BookOpen className="h-4 w-4" aria-hidden="true" />
<p className="mt-2 text-sm text-ink-soft">{project.genre}</p> </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} ) : 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 ? ( {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} ) : 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> </Link>
); );
} }

View File

@@ -1,31 +1,43 @@
"use client"; "use client";
import { ArrowLeft, ArrowRight, Check, CheckCircle2, Sparkles } from "lucide-react";
import { useRouter } from "next/navigation"; 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 { 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 { import {
ENDING_TYPES,
GENRES, GENRES,
NARRATIVE_POVS,
SELLING_POINT_PRESETS, SELLING_POINT_PRESETS,
STEP_TITLES,
STRUCTURES, STRUCTURES,
TONES,
WIZARD_STEPS, WIZARD_STEPS,
applyPlanPatch,
canAdvance, canAdvance,
canGeneratePlan,
canSubmit, canSubmit,
clampStep, clampStep,
emptyWizardForm, emptyWizardForm,
mapPlanResultToWizardForm,
toCreateRequest, toCreateRequest,
toPlanSeedRequest,
type WizardForm, type WizardForm,
} from "@/lib/wizard/wizard"; } from "@/lib/wizard/wizard";
const STEP_TITLES = [
"书名 + 题材",
"一句话故事 + 卖点 + 结构",
"立意 / 总纲",
"主角 / 金手指",
"基础世界观",
];
// 立项向导UX §6.2。Client Component收集字段 → POST /projects → 进工作台。 // 立项向导UX §6.2。Client Component收集字段 → POST /projects → 进工作台。
export function ProjectWizard() { export function ProjectWizard() {
const router = useRouter(); const router = useRouter();
@@ -34,6 +46,17 @@ export function ProjectWizard() {
const [form, setForm] = useState<WizardForm>(emptyWizardForm); const [form, setForm] = useState<WizardForm>(emptyWizardForm);
const [submitting, setSubmitting] = useState(false); 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 => const update = (patch: Partial<WizardForm>): void =>
setForm((prev) => ({ ...prev, ...patch })); setForm((prev) => ({ ...prev, ...patch }));
@@ -45,6 +68,21 @@ export function ProjectWizard() {
: [...prev.sellingPoints, point], : [...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 goBack = (): void => setStep((s) => clampStep(s - 1));
const goNext = (): 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; const isLast = step === WIZARD_STEPS;
return ( 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"> <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} /> <StepDots current={step} />
</div> </div>
<p className="mb-4 text-sm text-ink-soft"> <div className="mb-4">
{step}/{WIZARD_STEPS} · {STEP_TITLES[step - 1]} <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> </p>
</div>
<div className="min-h-[220px]"> <div className="min-h-[220px]">
{step === 1 && <StepBasics form={form} update={update} />} {step === 1 && <StepBasics form={form} update={update} />}
@@ -85,40 +132,37 @@ export function ProjectWizard() {
form={form} form={form}
update={update} update={update}
toggleSellingPoint={toggleSellingPoint} toggleSellingPoint={toggleSellingPoint}
onApplyPlan={applyPlan}
/> />
)} )}
{step === 3 && <StepPremise form={form} update={update} />} {step === 3 && <StepPremise form={form} update={update} />}
{step === 4 && <StepProtagonist form={form} update={update} />} {step === 4 && <StepProtagonist form={form} update={update} />}
{step === 5 && <StepWorld />} {step === 5 && <StepConfirm form={form} />}
</div> </div>
<div className="mt-8 flex items-center justify-between"> <div className="mt-8 flex items-center justify-between">
<button <Button onClick={goBack} disabled={step === 1} variant="secondary">
type="button" <ArrowLeft className="h-4 w-4" aria-hidden="true" />
onClick={goBack}
disabled={step === 1} </Button>
className="rounded border border-line px-4 py-2 text-sm text-ink disabled:opacity-40"
>
</button>
{isLast ? ( {isLast ? (
<button <Button
type="button"
onClick={submit} onClick={submit}
disabled={submitting || !canSubmit(form)} disabled={submitting || !canSubmit(form)}
className="rounded bg-cinnabar px-5 py-2 text-sm text-panel disabled:opacity-40" variant="primary"
> >
{submitting ? "创建中…" : "完成立项 →"} <CheckCircle2 className="h-4 w-4" aria-hidden="true" />
</button> {submitting ? "创建中…" : "完成立项"}
</Button>
) : ( ) : (
<button <Button
type="button"
onClick={goNext} onClick={goNext}
disabled={!canAdvance(step, form)} 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>
</div> </div>
@@ -145,30 +189,16 @@ interface StepProps {
update: (patch: Partial<WizardForm>) => void; 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) { function StepBasics({ form, update }: StepProps) {
const titleMissing = form.title.trim().length === 0;
return ( return (
<div> <div>
<Field label="书名(必填)"> <Field
<input label="书名"
className={inputCls} required
help={titleMissing ? "请先填写书名才能继续" : undefined}
>
<TextInput
value={form.title} value={form.title}
onChange={(e) => update({ title: e.target.value })} onChange={(e) => update({ title: e.target.value })}
placeholder="例:逐光而行" placeholder="例:逐光而行"
@@ -176,8 +206,7 @@ function StepBasics({ form, update }: StepProps) {
/> />
</Field> </Field>
<Field label="题材"> <Field label="题材">
<select <Select
className={inputCls}
value={form.genre} value={form.genre}
onChange={(e) => update({ genre: e.target.value })} onChange={(e) => update({ genre: e.target.value })}
> >
@@ -187,63 +216,140 @@ function StepBasics({ form, update }: StepProps) {
{g} {g}
</option> </option>
))} ))}
</select> </Select>
</Field> </Field>
</div> </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({ function StepStory({
form, form,
update, update,
toggleSellingPoint, toggleSellingPoint,
}: StepProps & { toggleSellingPoint: (p: string) => void }) { onApplyPlan,
}: StepProps & {
toggleSellingPoint: (p: string) => void;
onApplyPlan: (plan: ProjectPlanView) => void;
}) {
return ( return (
<div> <div>
<Field label="一句话故事logline"> <Field label="一句话故事logline">
<textarea <TextArea
className={`${inputCls} h-20 resize-none`} className="h-20 resize-none"
value={form.logline} value={form.logline}
onChange={(e) => update({ logline: e.target.value })} onChange={(e) => update({ logline: e.target.value })}
placeholder="废柴少年觉醒禁忌血脉,在仙门倾轧中逆势封神。" placeholder="废柴少年觉醒禁忌血脉,在仙门倾轧中逆势封神。"
/> />
</Field> </Field>
<PlanAssistant form={form} onApplyPlan={onApplyPlan} />
<Field label="故事结构"> <Field label="故事结构">
<div className="flex gap-2"> <SegmentedControl
{STRUCTURES.map((s) => ( ariaLabel="故事结构"
<button className="flex-wrap"
type="button" value={form.structure}
key={s} onChange={(structure) => update({ structure })}
onClick={() => update({ structure: s })} options={STRUCTURES.map((s) => ({ value: s, label: 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>
</Field> </Field>
<fieldset> <fieldset>
<legend className="mb-1 block text-sm text-ink-soft"></legend> <legend className="mb-1 block text-sm text-ink-soft"></legend>
<div className="flex flex-wrap gap-2"> <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 <button
type="button" type="button"
key={p} key={p}
aria-pressed={form.sellingPoints.includes(p)} aria-pressed={selected}
onClick={() => toggleSellingPoint(p)} onClick={() => toggleSellingPoint(p)}
className={`rounded border px-3 py-1.5 text-sm ${ className={buttonClass({
form.sellingPoints.includes(p) variant: selected ? "primary" : "secondary",
? "border-cinnabar bg-[var(--color-cinnabar-wash)] text-cinnabar" size: "sm",
: "border-line text-ink" })}
}`}
> >
{selected ? (
<Check className="h-3.5 w-3.5" aria-hidden="true" />
) : null}
{p} {p}
</button> </button>
))} );
})}
</div> </div>
</fieldset> </fieldset>
</div> </div>
@@ -254,21 +360,51 @@ function StepPremise({ form, update }: StepProps) {
return ( return (
<div> <div>
<Field label="立意premise"> <Field label="立意premise">
<textarea <TextArea
className={`${inputCls} h-20 resize-none`} className="h-20 resize-none"
value={form.premise} value={form.premise}
onChange={(e) => update({ premise: e.target.value })} onChange={(e) => update({ premise: e.target.value })}
placeholder="故事的核心命题与前提。" placeholder="故事的核心命题与前提。"
/> />
</Field> </Field>
<Field label="主题theme"> <Field label="主题theme">
<input <TextInput
className={inputCls}
value={form.theme} value={form.theme}
onChange={(e) => update({ theme: e.target.value })} onChange={(e) => update({ theme: e.target.value })}
placeholder="例:抗争与代价" placeholder="例:抗争与代价"
/> />
</Field> </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> </div>
); );
} }
@@ -282,8 +418,8 @@ function StepProtagonist({ form, update }: StepProps) {
M1 M1
</p> </p>
<Field label="主角 / 金手指概要"> <Field label="主角 / 金手指概要">
<textarea <TextArea
className={`${inputCls} h-28 resize-none`} className="h-28 resize-none"
value={form.protagonist} value={form.protagonist}
onChange={(e) => update({ protagonist: e.target.value })} onChange={(e) => update({ protagonist: e.target.value })}
placeholder="主角设定、金手指来源与限制……" placeholder="主角设定、金手指来源与限制……"
@@ -293,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 ( 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> </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" role="status"
aria-live="polite" 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) => ( {[0, 1, 2].map((i) => (
<span <span
key={i} key={i}
className="h-1.5 w-1.5 rounded-full bg-current opacity-70 motion-safe:animate-pulse" className="ai-dot h-2 w-2 rounded-full bg-current"
style={{ animationDelay: `${i * 200}ms` }} style={{ animationDelay: `${i * 150}ms` }}
/> />
))} ))}
</span> </span>

View File

@@ -11,19 +11,51 @@ import {
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { buttonClass } from "@/lib/ui/variants";
type ToastKind = "info" | "error" | "success"; type ToastKind = "info" | "error" | "success";
interface ToastAction {
label: string;
onClick: () => void;
}
interface ToastOptions {
action?: ToastAction;
durationMs?: number;
}
interface ToastItem { interface ToastItem {
id: number; id: number;
message: string; message: string;
kind: ToastKind; 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); const ToastContext = createContext<ShowToast | null>(null);
// 普通提示 4s 自动消失;错误更需被看到,默认延长到 7s。
const TOAST_TTL_MS = 4000; 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 }) { export function ToastProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<ToastItem[]>([]); const [items, setItems] = useState<ToastItem[]>([]);
@@ -32,13 +64,20 @@ export function ToastProvider({ children }: { children: ReactNode }) {
// 在途定时器集合:卸载时统一清理,避免 setState-after-unmount 泄漏。 // 在途定时器集合:卸载时统一清理,避免 setState-after-unmount 泄漏。
const timersRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set()); 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++; 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(() => { const timer = setTimeout(() => {
timersRef.current.delete(timer); timersRef.current.delete(timer);
setItems((prev) => prev.filter((t) => t.id !== id)); setItems((prev) => prev.filter((t) => t.id !== id));
}, TOAST_TTL_MS); }, ttl);
timersRef.current.add(timer); timersRef.current.add(timer);
}, []); }, []);
@@ -53,28 +92,46 @@ export function ToastProvider({ children }: { children: ReactNode }) {
const value = useMemo(() => show, [show]); 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 ( return (
<ToastContext.Provider value={value}> <ToastContext.Provider value={value}>
{children} {children}
<div {/* error 用 assertive 立即播报;其余 info/success 用 polite 礼貌播报。两区共用同一锚点容器避免重叠。 */}
className="pointer-events-none fixed bottom-6 right-6 z-50 flex flex-col gap-2" <div className="pointer-events-none fixed bottom-6 right-6 z-50 flex flex-col gap-2">
aria-live="polite" <div className="flex flex-col gap-2" aria-live="assertive" role="alert">
role="status" {errorItems.map(renderItem)}
> </div>
{items.map((t) => ( <div className="flex flex-col gap-2" aria-live="polite" role="status">
<div {politeItems.map(renderItem)}
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}
</div> </div>
))}
</div> </div>
</ToastContext.Provider> </ToastContext.Provider>
); );

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,13 @@
"use client"; "use client";
import { useState } from "react"; 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"; import { CHAIN_KINDS, type ChainKind } from "@/lib/chain/chain";
interface ChainStarterProps { interface ChainStarterProps {
@@ -41,12 +47,10 @@ export function ChainStarter({ onStart, disabled }: ChainStarterProps) {
if (valid && !disabled) onStart(chainKey, startNo, countNo); if (valid && !disabled) onStart(chainKey, startNo, countNo);
}} }}
> >
<div> <SectionHeader
<h2 className="font-serif text-lg text-ink"></h2> title="连续写多章"
<p className="mt-1 text-sm text-ink-soft"> description="从指定章起循环「写章 → 四审 → 验收」;遇未决冲突会暂停等你裁决再续跑。"
/>
</p>
</div>
<fieldset className="flex flex-col gap-2"> <fieldset className="flex flex-col gap-2">
<legend className="text-sm text-ink-soft"></legend> <legend className="text-sm text-ink-soft"></legend>
<div className="flex flex-wrap gap-4"> <div className="flex flex-wrap gap-4">
@@ -55,8 +59,7 @@ export function ChainStarter({ onStart, disabled }: ChainStarterProps) {
key={kind.key} key={kind.key}
className="flex items-start gap-2 text-sm text-ink" className="flex items-start gap-2 text-sm text-ink"
> >
<input <Radio
type="radio"
name="chain-kind" name="chain-kind"
value={kind.key} value={kind.key}
checked={chainKey === kind.key} checked={chainKey === kind.key}
@@ -75,37 +78,35 @@ export function ChainStarter({ onStart, disabled }: ChainStarterProps) {
</div> </div>
</fieldset> </fieldset>
<div className="flex flex-wrap gap-4"> <div className="flex flex-wrap gap-4">
<label className="block text-sm text-ink-soft"> <Field label="起始章号" className="w-32">
<TextInput
<input
type="number" type="number"
min={1} min={1}
value={start} value={start}
onChange={(e) => setStart(e.target.value)} 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="起始章号" aria-label="起始章号"
/> />
</label> </Field>
<label className="block text-sm text-ink-soft"> <Field label={`连续章数1..${MAX_COUNT}`} className="w-40">
1..{MAX_COUNT} <TextInput
<input
type="number" type="number"
min={1} min={1}
max={MAX_COUNT} max={MAX_COUNT}
value={count} value={count}
onChange={(e) => setCount(e.target.value)} 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="连续章数" aria-label="连续章数"
/> />
</label> </Field>
</div> </div>
<button <Button
type="submit" type="submit"
disabled={disabled || !valid} 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 ? "运行中…" : "发起多章链"} {disabled ? "运行中…" : "发起多章链"}
</button> </Button>
</form> </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"; "use client";
import { useMemo, useState } from "react"; 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 { AppShell } from "@/components/AppShell";
import { CharacterGenerator } from "@/components/generation/CharacterGenerator"; import { CharacterGenerator } from "@/components/generation/CharacterGenerator";
import { WorldGenerator } from "@/components/generation/WorldGenerator"; 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 { import type {
CharacterCardView, CharacterCardView,
ProjectResponse, ProjectResponse,
@@ -32,6 +40,23 @@ const TABS: { key: CodexTab; label: string }[] = [
{ key: "timeline", label: "时间线" }, { 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):管理人物 / 世界观 / 时间线。 // 设定库 CodexUX §6.5):管理人物 / 世界观 / 时间线。
// 初始列表来自后端读端点(跨会话全量真源);本会话新入库的角色卡再合并补显, // 初始列表来自后端读端点(跨会话全量真源);本会话新入库的角色卡再合并补显,
// 故刷新后不再为空、且不与真源重复(按 name 去重,见 mergeCharacterCards // 故刷新后不再为空、且不与真源重复(按 name 去重,见 mergeCharacterCards
@@ -60,48 +85,49 @@ export function CodexPage({
activeNav="codex" activeNav="codex"
> >
<div className="flex h-[calc(100vh-var(--chrome,4rem))] flex-col p-4"> <div className="flex h-[calc(100vh-var(--chrome,4rem))] flex-col p-4">
<div className="mb-4 flex items-center gap-2" role="tablist"> <PageHeader
{TABS.map((t) => ( title="设定库"
<button description="人物、世界观与时间线共同构成写作时的真相源。AI 生成内容也要先预览、再确认入库。"
key={t.key} />
type="button" <SegmentedControl
role="tab" className="mb-4 w-fit"
aria-selected={tab === t.key} ariaLabel="设定库分区"
onClick={() => setTab(t.key)} value={tab}
className={`rounded px-3 py-1.5 text-sm ${ onChange={setTab}
tab === t.key options={TABS.map((t) => ({ value: t.key, label: t.label }))}
? "border-b-2 border-cinnabar text-cinnabar" />
: "text-ink-soft hover:text-ink"
}`}
>
{t.label}
</button>
))}
</div>
<div className="min-h-0 flex-1 overflow-auto"> <div className="min-h-0 flex-1 overflow-auto">
{tab === "characters" ? ( {tab === "characters" ? (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<section className="rounded border border-line bg-panel p-3"> <Card as="section" className="p-4">
<h3 className="mb-2 font-serif text-sm text-ink"> <h3 className="mb-3 font-serif text-title-md text-ink">
{characters.length} {characters.length}
</h3> </h3>
{characters.length > 0 ? ( {characters.length > 0 ? (
<ul className="flex flex-col gap-2"> <ul className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{characters.map((c, i) => ( {characters.map((c, i) => (
<li <li
key={`${c.name}-${i}`} key={`${c.name}-${i}`}
className="rounded bg-bg px-2 py-1.5 text-xs text-ink-soft" className="rounded-lg border border-line bg-bg p-3 text-sm"
> >
<span className="text-ink"> <div className="mb-2 flex items-center gap-2">
{c.name}{c.role} <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> </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 ? ( {c.relations && c.relations.length > 0 ? (
<ul className="mt-1 flex flex-wrap gap-1"> <ul className="mt-1 flex flex-wrap gap-1">
{c.relations.map((r, j) => ( {c.relations.map((r, j) => (
<li <li
key={`${r.name}-${r.kind}-${j}`} key={`${r.name}-${r.kind}-${j}`}
className="rounded bg-panel px-1.5 py-0.5 text-[11px]" className="rounded-full bg-panel px-2 py-0.5 text-2xs"
title={r.note ?? undefined} title={r.note ?? undefined}
> >
{r.kind} · {r.name} {r.kind} · {r.name}
@@ -113,11 +139,38 @@ export function CodexPage({
))} ))}
</ul> </ul>
) : ( ) : (
<p className="text-xs text-ink-soft"> <EmptyState
icon={UserRound}
</p> 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 <CharacterGenerator
projectId={project.id} projectId={project.id}
onIngested={(_, cards) => onIngested={(_, cards) =>
@@ -125,12 +178,13 @@ export function CodexPage({
} }
/> />
</div> </div>
</div>
) : null} ) : null}
{tab === "world" ? ( {tab === "world" ? (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<section className="rounded border border-line bg-panel p-3"> <Card as="section" className="p-4">
<h3 className="mb-2 font-serif text-sm text-ink"> <h3 className="mb-3 font-serif text-title-md text-ink">
{initialWorldEntities.length} {initialWorldEntities.length}
</h3> </h3>
{initialWorldEntities.length > 0 ? ( {initialWorldEntities.length > 0 ? (
@@ -138,15 +192,19 @@ export function CodexPage({
{initialWorldEntities.map((entity, i) => ( {initialWorldEntities.map((entity, i) => (
<article <article
key={`${entity.name}-${i}`} 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"> <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"> <span className="font-serif text-base text-ink">
{entity.name} {entity.name}
</span> </span>
<span className="rounded bg-panel px-2 py-0.5 text-xs text-ink-soft"> <Badge>
{entity.type} {entity.type}
</span> </Badge>
</header> </header>
{worldEntityRules(entity).length > 0 ? ( {worldEntityRules(entity).length > 0 ? (
<ul className="flex list-disc flex-col gap-1 pl-5 text-ink-soft"> <ul className="flex list-disc flex-col gap-1 pl-5 text-ink-soft">
@@ -161,19 +219,40 @@ export function CodexPage({
))} ))}
</div> </div>
) : ( ) : (
<p className="text-xs text-ink-soft"> <EmptyState
icon={Globe2}
</p> 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} /> <WorldGenerator projectId={project.id} />
</div> </div>
</div>
) : null} ) : null}
{tab === "timeline" ? ( {tab === "timeline" ? (
<div className="rounded border border-dashed border-line bg-panel p-6 text-center text-sm text-ink-soft"> <EmptyState
线P2 + icon={Clock3}
</div> title="时间线尚未启用"
description="时间线会从章节摘要、伏笔窗口和验收记录派生,后续可用于全书一致性扫描。"
/>
) : null} ) : null}
</div> </div>
</div> </div>

View File

@@ -1,6 +1,12 @@
"use client"; "use client";
import {
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import {
@@ -13,6 +19,61 @@ import {
} from "@/lib/command/palette"; } from "@/lib/command/palette";
import { handleTabTrap } from "@/lib/a11y/focusTrap"; import { handleTabTrap } from "@/lib/a11y/focusTrap";
import { api } from "@/lib/api/client"; 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>/...)。 // 从 pathname 抽取当前 projectId/projects/<id>/...)。
function projectIdFromPath(pathname: string): string | null { function projectIdFromPath(pathname: string): string | null {
@@ -25,14 +86,17 @@ interface CommandPaletteProps {
} }
// 命令面板⌘KUX §7键盘触发的快速导航/动作。 // 命令面板⌘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) { export function CommandPalette({ pathname }: CommandPaletteProps) {
const router = useRouter(); const router = useRouter();
const [open, setOpen] = useState(false); const open = useCommandPaletteOpen();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [highlight, setHighlight] = useState(0); const [highlight, setHighlight] = useState(0);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const dialogRef = useRef<HTMLDivElement>(null); const dialogRef = useRef<HTMLDivElement>(null);
// 打开前的焦点元素关闭时归还焦点WCAG 2.4.3)。
const restoreFocusRef = useRef<HTMLElement | null>(null);
// 工具箱生成器(命令面板发现性):首次打开惰性拉取一次,失败静默降级为空。 // 工具箱生成器(命令面板发现性):首次打开惰性拉取一次,失败静默降级为空。
const [tools, setTools] = useState<ToolCommandInfo[]>([]); const [tools, setTools] = useState<ToolCommandInfo[]>([]);
const toolsLoadedRef = useRef(false); const toolsLoadedRef = useRef(false);
@@ -51,7 +115,7 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
); );
const close = useCallback((): void => { const close = useCallback((): void => {
setOpen(false); setCommandPaletteOpen(false);
setQuery(""); setQuery("");
setHighlight(0); setHighlight(0);
}, []); }, []);
@@ -65,21 +129,29 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
[close, router], [close, router],
); );
useBodyScrollLock(open);
// ⌘K / Ctrl+K 全局开关。 // ⌘K / Ctrl+K 全局开关。
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent): void => { const onKey = (e: KeyboardEvent): void => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault(); e.preventDefault();
setOpen((prev) => !prev); toggleCommandPalette();
} }
}; };
window.addEventListener("keydown", onKey); window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey);
}, []); }, []);
// 打开时聚焦输入框;切查询重置高亮 // 打开时记住先前焦点并聚焦输入框;关闭后把焦点归还触发元素
useEffect(() => { 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]); }, [open]);
// 首次打开(项目内)惰性拉工具箱生成器列表,供生成 action-gen-<key> 命令。 // 首次打开(项目内)惰性拉工具箱生成器列表,供生成 action-gen-<key> 命令。
@@ -110,6 +182,8 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
if (!open) return null; if (!open) return null;
const activeCmd = results[highlight];
const onInputKey = (e: React.KeyboardEvent): void => { const onInputKey = (e: React.KeyboardEvent): void => {
if (e.key === "Escape") { if (e.key === "Escape") {
e.preventDefault(); e.preventDefault();
@@ -128,7 +202,7 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
return ( return (
<div <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} onClick={close}
> >
<div <div
@@ -136,35 +210,53 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label="命令面板" 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()} onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => { onKeyDown={(e) => {
// focus trapTab/Shift+Tab 在对话框内循环不逃逸到背景WCAG 2.1.2)。 // focus trapTab/Shift+Tab 在对话框内循环不逃逸到背景WCAG 2.1.2)。
if (dialogRef.current) handleTabTrap(dialogRef.current, e); if (dialogRef.current) handleTabTrap(dialogRef.current, e);
}} }}
> >
<div className="relative border-b border-line">
<input <input
ref={inputRef} ref={inputRef}
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
onKeyDown={onInputKey} onKeyDown={onInputKey}
placeholder="搜索命令或页面…(写本章 / 审稿 / 生成角色 / 跳转伏笔 / 搜设定)" placeholder="搜索命令或页面…(写本章 / 审稿 / 生成角色 / 跳转伏笔 / 搜设定)"
role="combobox"
aria-label="命令搜索" aria-label="命令搜索"
aria-controls="command-list" aria-expanded={true}
className="w-full border-b border-line bg-bg px-4 py-3 text-sm text-ink outline-none" 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 <ul
id="command-list" id={LIST_ID}
role="listbox" role="listbox"
aria-label="命令列表" aria-label="命令列表"
className="max-h-80 overflow-auto py-1" className="max-h-80 overflow-auto py-1 overscroll-contain"
> >
{results.length === 0 ? ( {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) => ( results.map((cmd, i) => (
<li <li
key={cmd.id} key={cmd.id}
id={optionId(cmd)}
role="option" role="option"
aria-selected={i === highlight} aria-selected={i === highlight}
onMouseEnter={() => setHighlight(i)} onMouseEnter={() => setHighlight(i)}
@@ -176,11 +268,30 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
}`} }`}
> >
<span>{cmd.title}</span> <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> </li>
)) ))
)} )}
</ul> </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>
</div> </div>
); );

View File

@@ -1,8 +1,10 @@
"use client"; "use client";
import { Search } from "lucide-react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { CommandPalette } from "./CommandPalette"; import { buttonClass } from "@/lib/ui/variants";
import { CommandPalette, openCommandPalette } from "./CommandPalette";
// 全局挂载命令面板⌘K读当前 pathname 注入项目上下文。 // 全局挂载命令面板⌘K读当前 pathname 注入项目上下文。
// 放在 RootLayout 内,对所有页面生效。 // 放在 RootLayout 内,对所有页面生效。
@@ -10,3 +12,27 @@ export function CommandPaletteMount() {
const pathname = usePathname(); const pathname = usePathname();
return <CommandPalette pathname={pathname ?? "/"} />; 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"; "use client";
import { useMemo } from "react"; import { useMemo, useState } from "react";
import { Flag, Plus } from "lucide-react";
import { AppShell } from "@/components/AppShell"; 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 type { ForeshadowView, ProjectResponse } from "@/lib/api/types";
import { import {
LANES, LANES,
LANE_LABELS,
countByStatus,
groupByStatus, groupByStatus,
type ForeshadowStatus, type ForeshadowStatus,
} from "@/lib/foreshadow/board"; } from "@/lib/foreshadow/board";
import type { BadgeVariant } from "@/lib/ui/variants";
import { useForeshadow } from "@/lib/foreshadow/useForeshadow"; import { useForeshadow } from "@/lib/foreshadow/useForeshadow";
import { KanbanColumn } from "./KanbanColumn"; import { KanbanColumn } from "./KanbanColumn";
import { RegisterForm } from "./RegisterForm"; import { RegisterForm } from "./RegisterForm";
@@ -25,7 +34,9 @@ export function ForeshadowBoard({
initialItems, initialItems,
}: ForeshadowBoardProps) { }: ForeshadowBoardProps) {
const { items, busy, register, transition } = useForeshadow(initialItems); const { items, busy, register, transition } = useForeshadow(initialItems);
const [registerOpen, setRegisterOpen] = useState(false);
const lanes = useMemo(() => groupByStatus(items), [items]); const lanes = useMemo(() => groupByStatus(items), [items]);
const counts = useMemo(() => countByStatus(items), [items]);
const onTransition = ( const onTransition = (
code: string, code: string,
@@ -46,16 +57,58 @@ export function ForeshadowBoard({
projectId={project.id} projectId={project.id}
activeNav="foreshadow" activeNav="foreshadow"
> >
<div className="flex h-[calc(100vh-var(--chrome,4rem))] flex-col p-4"> <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">
<div className="mb-4 flex items-start justify-between gap-4"> <PageHeader
<h1 className="font-serif text-lg text-ink"></h1> 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 <RegisterForm
projectId={project.id} projectId={project.id}
busy={busy} busy={busy}
onRegister={register} onRegister={register}
open={registerOpen}
onOpenChange={setRegisterOpen}
/> />
</div> </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) => ( {LANES.map((status) => (
<KanbanColumn <KanbanColumn
key={status} key={status}
@@ -66,7 +119,45 @@ export function ForeshadowBoard({
/> />
))} ))}
</div> </div>
</>
)}
</div> </div>
</AppShell> </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"; "use client";
import { AlertTriangle } from "lucide-react";
import { useState } from "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 { ForeshadowView } from "@/lib/api/types";
import type { ForeshadowStatus } from "@/lib/foreshadow/board"; import { LANE_LABELS, type ForeshadowStatus } from "@/lib/foreshadow/board";
interface ForeshadowCardProps { interface ForeshadowCardProps {
item: ForeshadowView; item: ForeshadowView;
@@ -44,19 +48,33 @@ export function ForeshadowCard({
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2">
<span className="font-mono text-xs text-cinnabar">{item.code}</span> <span className="font-mono text-xs text-cinnabar">{item.code}</span>
{overdue ? ( {overdue ? (
<span className="text-xs text-overdue" aria-label="逾期"> <Badge variant="warning" aria-label="逾期">
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
</span>
</Badge>
) : null} ) : null}
</div> </div>
<p className="mt-0.5 font-serif text-sm text-ink">{item.title}</p> <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"> <dl className="mt-2 space-y-0.5 text-xs text-ink-soft">
{typeof item.planted_at === "number" ? ( {typeof item.planted_at === "number" ? (
<div> {item.planted_at} </div> <div className="flex gap-1">
<dt></dt>
<dd>{item.planted_at} </dd>
</div>
) : null}
{windowText ? (
<div className="flex gap-1">
<dt></dt>
<dd>{windowText}</dd>
</div>
) : null}
{item.importance ? (
<div className="flex gap-1">
<dt className="sr-only"></dt>
<dd>{item.importance}</dd>
</div>
) : null} ) : null}
{windowText ? <div> {windowText}</div> : null}
{item.importance ? <div>{item.importance}</div> : null}
</dl> </dl>
{item.progress && item.progress.length > 0 ? ( {item.progress && item.progress.length > 0 ? (
@@ -72,15 +90,16 @@ export function ForeshadowCard({
{transitions.length > 0 ? ( {transitions.length > 0 ? (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{transitions.map((to) => ( {transitions.map((to) => (
<button <Button
key={to} key={to}
type="button"
disabled={busy} disabled={busy}
onClick={() => onTransition(item.code, to, "")} 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} {to === "CLOSED" ? "回收" : LANE_LABELS[to]}
</button> </Button>
))} ))}
</div> </div>
) : null} ) : null}
@@ -89,25 +108,27 @@ export function ForeshadowCard({
<label htmlFor={`prog-${item.code}`} className="sr-only"> <label htmlFor={`prog-${item.code}`} className="sr-only">
{item.code} {item.code}
</label> </label>
<input <TextInput
id={`prog-${item.code}`} id={`prog-${item.code}`}
type="text" type="text"
value={note} value={note}
onChange={(e) => setNote(e.target.value)} onChange={(e) => setNote(e.target.value)}
placeholder="加进展23章发光" 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 <Button
type="button"
disabled={busy || note.trim().length === 0} disabled={busy || note.trim().length === 0}
onClick={() => { onClick={() => {
onTransition(item.code, null, note.trim()); onTransition(item.code, null, note.trim());
setNote(""); 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> </div>
) : null} ) : null}
</div> </div>

View File

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

View File

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

View File

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

View File

@@ -1,16 +1,40 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { Database, Sparkles } from "lucide-react";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { Button } from "@/components/ui/Button";
import { Field } from "@/components/ui/Field";
import { SectionHeader } from "@/components/ui/SectionHeader";
import { SegmentedControl } from "@/components/ui/SegmentedControl";
import { StatusNote } from "@/components/ui/StatusNote";
import { TextInput } from "@/components/ui/TextInput";
import type { CharacterCardView } from "@/lib/api/types"; import type { CharacterCardView } from "@/lib/api/types";
import { import {
clampCharacterCount,
MAX_CHARACTER_COUNT, MAX_CHARACTER_COUNT,
MIN_CHARACTER_COUNT, MIN_CHARACTER_COUNT,
roleMixTotal,
sanitizeRoleMix,
updateCard, updateCard,
} from "@/lib/generation/cards"; } from "@/lib/generation/cards";
import { useCharacterGen } from "@/lib/generation/useCharacterGen"; import { useCharacterGen } from "@/lib/generation/useCharacterGen";
import { CharacterCardItem } from "./CharacterCardItem"; import { CharacterCardItem } from "./CharacterCardItem";
import { ConflictAdjudication } from "./ConflictAdjudication"; import { ConflictAdjudication } from "./ConflictAdjudication";
import { RoleMixEditor, type RoleMixRow } from "./RoleMixEditor";
type GenMode = "single" | "mix";
const MODE_OPTIONS: Array<{ value: GenMode; label: string }> = [
{ value: "single", label: "单一定位" },
{ value: "mix", label: "按定位配比" },
];
const DEFAULT_MIX_ROWS: RoleMixRow[] = [
{ role: "主角", count: 1 },
{ role: "对手", count: 1 },
];
interface CharacterGeneratorProps { interface CharacterGeneratorProps {
projectId: string; projectId: string;
@@ -27,6 +51,10 @@ export function CharacterGenerator({
const [brief, setBrief] = useState(""); const [brief, setBrief] = useState("");
const [count, setCount] = useState(MIN_CHARACTER_COUNT); const [count, setCount] = useState(MIN_CHARACTER_COUNT);
const [role, setRole] = useState(""); const [role, setRole] = useState("");
const [mode, setMode] = useState<GenMode>("single");
const [mixRows, setMixRows] = useState<RoleMixRow[]>(DEFAULT_MIX_ROWS);
// 配比模式下的有效合计sanitize 后,已按 MAX 截断)——门控生成按钮。
const mixTotal = roleMixTotal(sanitizeRoleMix(mixRows));
// 编辑态卡片(预览返回后拷贝进本地,允许就地改)。 // 编辑态卡片(预览返回后拷贝进本地,允许就地改)。
const [drafts, setDrafts] = useState<CharacterCardView[]>([]); const [drafts, setDrafts] = useState<CharacterCardView[]>([]);
const [selected, setSelected] = useState<Set<number>>(new Set()); const [selected, setSelected] = useState<Set<number>>(new Set());
@@ -38,8 +66,17 @@ export function CharacterGenerator({
}, []); }, []);
const onGenerate = useCallback(async () => { const onGenerate = useCallback(async () => {
if (mode === "mix") {
await gen.generate({
projectId,
brief,
count,
roleMix: sanitizeRoleMix(mixRows),
});
return;
}
await gen.generate({ projectId, brief, count, role }); await gen.generate({ projectId, brief, count, role });
}, [gen, projectId, brief, count, role]); }, [gen, projectId, brief, count, role, mode, mixRows]);
// gen.cards 变化(新预览)→ 重新同步本地草稿。 // gen.cards 变化(新预览)→ 重新同步本地草稿。
const previewKey = gen.cards.map((c) => c.name).join("|"); const previewKey = gen.cards.map((c) => c.name).join("|");
@@ -80,47 +117,79 @@ export function CharacterGenerator({
return ( return (
<section className="flex flex-col gap-4" aria-label="角色生成器"> <section className="flex flex-col gap-4" aria-label="角色生成器">
<div className="rounded border border-line bg-panel p-4"> <div className="rounded border border-line bg-panel p-4">
<h2 className="mb-3 font-serif text-base text-ink"></h2> <SectionHeader title="角色生成器" className="mb-3" />
<label className="mb-2 block text-sm text-ink-soft"> <Field label="一句话需求" className="mb-3">
<TextInput
<input
value={brief} value={brief}
onChange={(e) => setBrief(e.target.value)} onChange={(e) => setBrief(e.target.value)}
placeholder="如:一个亦正亦邪的剑修,背负灭门血仇" placeholder="如:一个亦正亦邪的剑修,背负灭门血仇"
className="mt-1 w-full rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
/> />
</label> </Field>
<div className="flex flex-wrap items-end gap-3"> <div className="mb-3">
<label className="text-sm text-ink-soft"> <SegmentedControl
options={MODE_OPTIONS}
<input value={mode}
onChange={setMode}
ariaLabel="生成模式"
/>
</div>
{mode === "mix" ? (
<Field
label="角色定位配比"
className="mb-3"
help="每个定位产出对应数量,合计即总张数"
>
<RoleMixEditor rows={mixRows} onChange={setMixRows} />
</Field>
) : (
<div className="mb-3 flex flex-wrap items-end gap-3">
<Field
label="数量"
className="w-28"
help={`${MIN_CHARACTER_COUNT}${MAX_CHARACTER_COUNT}`}
>
<TextInput
type="number" type="number"
min={MIN_CHARACTER_COUNT} min={MIN_CHARACTER_COUNT}
max={MAX_CHARACTER_COUNT} max={MAX_CHARACTER_COUNT}
value={count} value={count}
onChange={(e) => setCount(Number(e.target.value))} onChange={(e) =>
className="mt-1 block w-20 rounded border border-line bg-bg px-2 py-1.5 text-sm text-ink" setCount(clampCharacterCount(Number(e.target.value)))
}
/> />
</label> </Field>
<label className="flex-1 text-sm text-ink-soft"> <Field label="定位(可选)" className="min-w-[12rem] flex-1">
<TextInput
<input
value={role} value={role}
onChange={(e) => setRole(e.target.value)} onChange={(e) => setRole(e.target.value)}
placeholder="主角 / CP / 对手 / 导师 / 工具人" placeholder="主角 / CP / 对手 / 导师 / 工具人"
className="mt-1 block w-full rounded border border-line bg-bg px-2 py-1.5 text-sm text-ink"
/> />
</label> </Field>
<button </div>
type="button" )}
<Button
onClick={() => void onGenerate()} onClick={() => void onGenerate()}
disabled={generating || brief.trim().length === 0} loading={generating}
className="rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50" disabled={
brief.trim().length === 0 ||
(mode === "mix"
? mixTotal < MIN_CHARACTER_COUNT
: count < MIN_CHARACTER_COUNT || count > MAX_CHARACTER_COUNT)
}
variant="primary"
> >
{generating ? "生成中…" : "生成"} <Sparkles className="h-4 w-4" aria-hidden="true" />
</button>
</Button>
</div> </div>
{generating ? (
<div className="rounded border border-line bg-panel/50 p-4 text-cinnabar">
<ThinkingIndicator label="生成中" />
</div> </div>
) : null}
{gen.genStatus === "preview" && drafts.length > 0 ? ( {gen.genStatus === "preview" && drafts.length > 0 ? (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
@@ -151,20 +220,20 @@ export function CharacterGenerator({
onCancel={() => gen.reset()} onCancel={() => gen.reset()}
/> />
) : ( ) : (
<button <Button
type="button"
onClick={() => void doIngest(false)} onClick={() => void doIngest(false)}
disabled={ingesting || selectedCards.length === 0} disabled={ingesting || selectedCards.length === 0}
className="self-start rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50" variant="primary"
> >
<Database className="h-4 w-4" aria-hidden="true" />
{ingesting ? "入库中…" : `入库选中 ${selectedCards.length}`} {ingesting ? "入库中…" : `入库选中 ${selectedCards.length}`}
</button> </Button>
)} )}
</div> </div>
) : null} ) : null}
{gen.ingestStatus === "done" && gen.created.length > 0 ? ( {gen.ingestStatus === "done" && gen.created.length > 0 ? (
<p className="text-sm text-pass">{gen.created.join("、")}</p> <StatusNote variant="success">{gen.created.join("、")}</StatusNote>
) : null} ) : null}
</section> </section>
); );

View File

@@ -1,5 +1,7 @@
"use client"; "use client";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { Button } from "@/components/ui/Button";
import type { IngestConflicts } from "@/lib/generation/cards"; import type { IngestConflicts } from "@/lib/generation/cards";
interface ConflictAdjudicationProps { interface ConflictAdjudicationProps {
@@ -56,23 +58,28 @@ export function ConflictAdjudication({
))} ))}
</ul> </ul>
<div className="flex gap-2"> <div className="flex gap-2">
<button <Button
type="button" type="button"
variant="danger"
onClick={onAcknowledge} onClick={onAcknowledge}
disabled={busy} disabled={busy}
className="rounded bg-conflict px-3 py-1.5 text-sm text-white disabled:opacity-50"
> >
</button> </Button>
<button <Button
type="button" type="button"
variant="secondary"
onClick={onCancel} onClick={onCancel}
disabled={busy} disabled={busy}
className="rounded border border-line px-3 py-1.5 text-sm text-ink disabled:opacity-50"
> >
</button> </Button>
</div> </div>
{busy ? (
<div className="mt-3 text-cinnabar">
<ThinkingIndicator label="入库中" />
</div>
) : null}
</div> </div>
); );
} }

View File

@@ -0,0 +1,82 @@
"use client";
import { Plus, X } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { TextInput } from "@/components/ui/TextInput";
import {
MAX_CHARACTER_COUNT,
MIN_CHARACTER_COUNT,
roleMixTotal,
sanitizeRoleMix,
} from "@/lib/generation/cards";
// 单行配比:某定位 + 该定位要生成的数量。
export interface RoleMixRow {
role: string;
count: number;
}
interface RoleMixEditorProps {
rows: RoleMixRow[];
onChange: (rows: RoleMixRow[]) => void;
}
// 群像按定位配比编辑器(⑦):一组「定位 + 数量」行;合计封顶 MAX_CHARACTER_COUNT。
// 不可变更新——每次编辑返回新数组,不改入参。
export function RoleMixEditor({ rows, onChange }: RoleMixEditorProps) {
// 有效合计 = sanitize 后的总数(已按 MAX 截断),即实际会生成的张数。
const effectiveTotal = roleMixTotal(sanitizeRoleMix(rows));
const updateRow = (index: number, patch: Partial<RoleMixRow>): void => {
onChange(rows.map((r, i) => (i === index ? { ...r, ...patch } : r)));
};
const addRow = (): void => onChange([...rows, { role: "", count: 1 }]);
const removeRow = (index: number): void =>
onChange(rows.filter((_, i) => i !== index));
return (
<div className="flex flex-col gap-2" aria-label="角色定位配比">
{rows.map((row, i) => (
<div key={i} className="flex items-center gap-2">
<TextInput
value={row.role}
onChange={(e) => updateRow(i, { role: e.target.value })}
placeholder="定位:主角 / 对手 / 配角"
aria-label={`${i + 1} 行定位`}
className="min-w-[10rem] flex-1"
/>
<TextInput
type="number"
min={MIN_CHARACTER_COUNT}
max={MAX_CHARACTER_COUNT}
value={row.count}
onChange={(e) =>
updateRow(i, { count: Math.round(Number(e.target.value)) })
}
aria-label={`${i + 1} 行数量`}
className="w-20"
/>
<Button
variant="ghost"
size="icon"
onClick={() => removeRow(i)}
disabled={rows.length <= 1}
aria-label={`删除第 ${i + 1}`}
>
<X className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
))}
<div className="flex items-center justify-between">
<Button variant="outline" size="sm" onClick={addRow}>
<Plus className="h-4 w-4" aria-hidden="true" />
</Button>
<span className="text-xs text-ink-soft">
{effectiveTotal} / {MAX_CHARACTER_COUNT}
</span>
</div>
</div>
);
}

View File

@@ -1,9 +1,20 @@
"use client"; "use client";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { Copy, Globe2, RotateCcw, Sparkles } from "lucide-react";
import { useToast } from "@/components/Toast";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { Button } from "@/components/ui/Button";
import { EmptyState } from "@/components/ui/EmptyState";
import { Field } from "@/components/ui/Field";
import { SectionHeader } from "@/components/ui/SectionHeader";
import { StatusNote } from "@/components/ui/StatusNote";
import { TextArea } from "@/components/ui/TextArea";
import { worldEntityRules } from "@/lib/generation/cards"; import { worldEntityRules } from "@/lib/generation/cards";
import type { WorldEntityCardView } from "@/lib/api/types";
import { useWorldGen } from "@/lib/generation/useWorldGen"; import { useWorldGen } from "@/lib/generation/useWorldGen";
import { cardClass } from "@/lib/ui/variants";
interface WorldGeneratorProps { interface WorldGeneratorProps {
projectId: string; projectId: string;
@@ -11,46 +22,112 @@ interface WorldGeneratorProps {
// 世界观设计器UX §6.5):一句话需求 → 预览实体卡type/name/rules // 世界观设计器UX §6.5):一句话需求 → 预览实体卡type/name/rules
// 本期世界观入库端点未建(仅预览);预览结果供作者参考/复制。 // 本期世界观入库端点未建(仅预览);预览结果供作者参考/复制。
// 复制文案:名称 + 硬规则正文(供作者粘贴到「规则」或「设定库」)。
function entityClipboardText(entity: WorldEntityCardView): string {
const rules = worldEntityRules(entity);
const body = rules.length > 0 ? rules.join("\n") : "(无显式硬规则)";
return `${entity.name}${entity.type}\n${body}`;
}
export function WorldGenerator({ projectId }: WorldGeneratorProps) { export function WorldGenerator({ projectId }: WorldGeneratorProps) {
const gen = useWorldGen(); const gen = useWorldGen();
const toast = useToast();
const [brief, setBrief] = useState(""); const [brief, setBrief] = useState("");
const onGenerate = useCallback(async () => { const onGenerate = useCallback(async () => {
await gen.generate(projectId, brief); await gen.generate(projectId, brief);
}, [gen, projectId, brief]); }, [gen, projectId, brief]);
const onCopy = useCallback(
async (entity: WorldEntityCardView): Promise<void> => {
try {
await navigator.clipboard.writeText(entityClipboardText(entity));
toast("已复制到剪贴板。", "success");
} catch {
toast("复制失败,请手动选择文本。", "error");
}
},
[toast],
);
const generating = gen.status === "generating"; const generating = gen.status === "generating";
return ( return (
<section className="flex flex-col gap-4" aria-label="世界观设计器"> <section className="flex flex-col gap-4" aria-label="世界观设计器">
<div className="rounded border border-line bg-panel p-4"> <div className="rounded border border-line bg-panel p-4">
<h2 className="mb-3 font-serif text-base text-ink"></h2> <SectionHeader title="世界观设计器" className="mb-3" />
<label className="mb-3 block text-sm text-ink-soft"> <Field label="设定方向" className="mb-3">
<TextArea
<textarea
value={brief} value={brief}
onChange={(e) => setBrief(e.target.value)} onChange={(e) => setBrief(e.target.value)}
placeholder="如:东方修真世界,灵气复苏,宗门林立,强者寿元有限" placeholder="如:东方修真世界,灵气复苏,宗门林立,强者寿元有限"
rows={3} rows={3}
className="mt-1 w-full resize-y rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
/> />
</label> </Field>
<button <Button
type="button"
onClick={() => void onGenerate()} onClick={() => void onGenerate()}
disabled={generating || brief.trim().length === 0} loading={generating}
className="rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50" disabled={brief.trim().length === 0}
variant="primary"
> >
{generating ? "生成中…" : "生成世界观"} <Sparkles className="h-4 w-4" aria-hidden="true" />
</button>
</Button>
</div> </div>
{generating ? (
<div className="rounded border border-line bg-panel/50 p-4 text-cinnabar">
<ThinkingIndicator label="生成中" />
</div>
) : null}
{gen.status === "error" ? (
<StatusNote variant="danger">
<span className="flex flex-wrap items-center gap-3">
<span></span>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => void onGenerate()}
>
<RotateCcw className="h-4 w-4" aria-hidden="true" />
</Button>
</span>
</StatusNote>
) : null}
{gen.status === "done" && gen.entities.length === 0 ? (
<EmptyState
icon={Globe2}
title="本次未生成可用实体"
description="可换一个更具体的设定方向,或补充世界的硬规则与边界后再试。"
action={
<Button
type="button"
variant="primary"
size="sm"
onClick={() => void onGenerate()}
disabled={brief.trim().length === 0}
>
<Sparkles className="h-4 w-4" aria-hidden="true" />
</Button>
}
/>
) : null}
{gen.status === "done" && gen.entities.length > 0 ? ( {gen.status === "done" && gen.entities.length > 0 ? (
<div className="flex flex-col gap-3">
<StatusNote variant="info">
</StatusNote>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2"> <div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{gen.entities.map((entity, i) => ( {gen.entities.map((entity, i) => (
<article <article
key={`${entity.name}-${i}`} key={`${entity.name}-${i}`}
className="rounded border border-line bg-panel p-3 text-sm" className={cardClass("p-3 text-sm")}
> >
<header className="mb-2 flex items-center gap-2"> <header className="mb-2 flex items-center gap-2">
<span className="font-serif text-base text-ink"> <span className="font-serif text-base text-ink">
@@ -59,6 +136,17 @@ export function WorldGenerator({ projectId }: WorldGeneratorProps) {
<span className="rounded bg-bg px-2 py-0.5 text-xs text-ink-soft"> <span className="rounded bg-bg px-2 py-0.5 text-xs text-ink-soft">
{entity.type} {entity.type}
</span> </span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => void onCopy(entity)}
className="ml-auto"
aria-label={`复制 ${entity.name}`}
>
<Copy className="h-4 w-4" aria-hidden="true" />
</Button>
</header> </header>
{worldEntityRules(entity).length > 0 ? ( {worldEntityRules(entity).length > 0 ? (
<ul className="flex list-disc flex-col gap-1 pl-5 text-ink-soft"> <ul className="flex list-disc flex-col gap-1 pl-5 text-ink-soft">
@@ -72,6 +160,7 @@ export function WorldGenerator({ projectId }: WorldGeneratorProps) {
</article> </article>
))} ))}
</div> </div>
</div>
) : null} ) : null}
</section> </section>
); );

View File

@@ -1,19 +1,22 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { Flag, PenLine } from "lucide-react";
import { Badge } from "@/components/ui/Badge";
import type { OutlineChapterView } from "@/lib/api/types"; import type { OutlineChapterView } from "@/lib/api/types";
import { import {
isCloseWindow, isCloseWindow,
windowBadgeLabel, windowBadgeLabel,
} from "@/lib/outline/outline"; } from "@/lib/outline/outline";
import { buttonClass } from "@/lib/ui/variants";
interface OutlineChapterRowProps { interface OutlineChapterRowProps {
chapter: OutlineChapterView; chapter: OutlineChapterView;
projectId: string; projectId: string;
} }
// 单章大纲行UX §6.7):章号 + beats + 伏笔窗口徽标(+ 「写此章」入口。 // 单章大纲行UX §6.7):章号 + beats + 伏笔窗口徽标(lucide Flag 图标+ 「写此章」入口。
// 接近回收窗口的徽标用琥珀 + 「可回收」提示a11y图标 + 文案,不单靠色)。 // 接近回收窗口的徽标用琥珀 + 「可回收」提示a11y图标 + 文案,不单靠色)。
export function OutlineChapterRow({ chapter, projectId }: OutlineChapterRowProps) { export function OutlineChapterRow({ chapter, projectId }: OutlineChapterRowProps) {
const windows = chapter.foreshadow_windows ?? []; const windows = chapter.foreshadow_windows ?? [];
@@ -25,31 +28,29 @@ export function OutlineChapterRow({ chapter, projectId }: OutlineChapterRowProps
</span> </span>
<Link <Link
href={`/projects/${projectId}/write?chapter=${chapter.no}`} href={`/projects/${projectId}/write?chapter=${chapter.no}`}
className="rounded border border-line px-1.5 py-0.5 text-xs text-ink-soft hover:border-cinnabar hover:text-cinnabar" className={buttonClass({ variant: "secondary", size: "sm" })}
> >
<PenLine className="h-3.5 w-3.5" aria-hidden="true" />
</Link> </Link>
{windows.map((w) => { {windows.map((w) => {
const close = isCloseWindow(chapter, w); const close = isCloseWindow(chapter, w);
return ( return (
<span <Badge
key={w.code} key={w.code}
variant={close ? "warning" : "accent"}
title={close ? "进入回收窗口,建议本章安排回收" : undefined} title={close ? "进入回收窗口,建议本章安排回收" : undefined}
className={`rounded px-1.5 py-0.5 text-xs ${
close
? "bg-overdue/15 text-overdue"
: "bg-[var(--color-cinnabar-wash)] text-cinnabar"
}`}
> >
<Flag className="h-4 w-4" aria-hidden="true" />
{windowBadgeLabel(w)} {windowBadgeLabel(w)}
{close ? " · 可回收" : ""} {close ? " · 可回收" : ""}
</span> </Badge>
); );
})} })}
</div> </div>
{chapter.beats && chapter.beats.length > 0 ? ( {chapter.beats && chapter.beats.length > 0 ? (
<p className="mt-1 text-sm text-ink"> <p className="mt-1 text-sm text-ink">
<span className="text-ink-soft">beats: </span> <span className="text-ink-soft"></span>
{chapter.beats.join(" / ")} {chapter.beats.join(" / ")}
</p> </p>
) : ( ) : (

View File

@@ -1,8 +1,15 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { ListTree, Sparkles } from "lucide-react";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { Button } from "@/components/ui/Button";
import { EmptyState } from "@/components/ui/EmptyState";
import { PageHeader } from "@/components/ui/PageHeader";
import { Select } from "@/components/ui/Select";
import { StatusNote } from "@/components/ui/StatusNote";
import { TextInput } from "@/components/ui/TextInput";
import type { OutlineChapterView, ProjectResponse } from "@/lib/api/types"; import type { OutlineChapterView, ProjectResponse } from "@/lib/api/types";
import { import {
distinctVolumes, distinctVolumes,
@@ -25,15 +32,52 @@ export function OutlineEditor({
initialChapters, initialChapters,
}: OutlineEditorProps) { }: OutlineEditorProps) {
const { chapters, status, error, generate } = useOutline(initialChapters); const { chapters, status, error, generate } = useOutline(initialChapters);
// 生成目标卷与「正在看的卷」共用一个心智:选具体卷即把生成目标设为该卷。
const [volume, setVolume] = useState(1); const [volume, setVolume] = useState(1);
// 视图卷过滤("全部" 或某卷)——只影响展示,与生成的目标卷 `volume` 解耦。
const [viewVolume, setViewVolume] = useState<ViewVolume>("all"); const [viewVolume, setViewVolume] = useState<ViewVolume>("all");
// 「重新排大纲」破坏性覆盖的两段式确认(仅目标卷已有章节时出现)。
const [confirming, setConfirming] = useState(false);
const availableVolumes = useMemo(() => distinctVolumes(chapters), [chapters]); const availableVolumes = useMemo(() => distinctVolumes(chapters), [chapters]);
const volumes = useMemo( const volumes = useMemo(
() => groupByVolume(filterByViewVolume(chapters, viewVolume)), () => groupByVolume(filterByViewVolume(chapters, viewVolume)),
[chapters, viewVolume], [chapters, viewVolume],
); );
const generating = status === "generating"; const generating = status === "generating";
// 目标卷已有章节 → 生成是破坏性替换,文案/确认都要变。
const targetHasChapters = useMemo(
() => filterByViewVolume(chapters, volume).length > 0,
[chapters, volume],
);
// 选具体卷时把生成目标对齐到所看卷,消除「看卷 A、却生成卷 B」的易混。
const onViewVolumeChange = (next: ViewVolume): void => {
setViewVolume(next);
setConfirming(false);
if (next !== "all") setVolume(next);
};
const onVolumeChange = (next: number): void => {
setVolume(Math.max(1, next || 1));
setConfirming(false);
};
const runGenerate = (): void => {
setConfirming(false);
void generate(project.id, volume);
};
// 安全卷直接生成;破坏性卷先进入两段式确认。
const onGenerateClick = (): void => {
if (targetHasChapters) {
setConfirming(true);
return;
}
runGenerate();
};
const generateLabel = generating
? "排大纲中…"
: `${targetHasChapters ? "重新排大纲" : "AI 排大纲"} · 卷 ${volume}`;
return ( return (
<AppShell <AppShell
@@ -43,20 +87,28 @@ export function OutlineEditor({
activeNav="outline" activeNav="outline"
> >
<div className="flex h-[calc(100vh-var(--chrome,4rem))] flex-col p-6"> <div className="flex h-[calc(100vh-var(--chrome,4rem))] flex-col p-6">
<div className="mb-4 flex items-center gap-3"> <PageHeader
<h1 className="font-serif text-lg text-ink"></h1> title="大纲"
description="按卷组织章节节拍,写作台会把当前章节拍注入生成上下文。"
/>
{/* 卷/视图/生成控件独立成全宽工具栏行(窄屏自动换行,不用脆弱的 ml-auto。 */}
<div className="mb-4 flex flex-wrap items-center gap-2">
{availableVolumes.length > 0 ? ( {availableVolumes.length > 0 ? (
<label htmlFor="view-vol" className="ml-auto text-xs text-ink-soft"> <label
htmlFor="view-vol"
className="flex items-center gap-1 text-xs text-ink-soft"
>
<select <Select
id="view-vol" id="view-vol"
value={viewVolume === "all" ? "all" : String(viewVolume)} value={viewVolume === "all" ? "all" : String(viewVolume)}
onChange={(e) => onChange={(e) =>
setViewVolume( onViewVolumeChange(
e.target.value === "all" ? "all" : Number(e.target.value), e.target.value === "all" ? "all" : Number(e.target.value),
) )
} }
className="ml-1 rounded border border-line bg-bg px-2 py-1 text-sm text-ink focus:border-cinnabar focus:outline-none" controlSize="sm"
> >
<option value="all"></option> <option value="all"></option>
{availableVolumes.map((v) => ( {availableVolumes.map((v) => (
@@ -64,60 +116,108 @@ export function OutlineEditor({
{v} {v}
</option> </option>
))} ))}
</select> </Select>
</label> </label>
) : null} ) : null}
{availableVolumes.length > 0 ? (
<span className="ml-1 h-4 w-px bg-line" aria-hidden="true" />
) : null}
<label <label
htmlFor="vol" htmlFor="vol"
className={`text-xs text-ink-soft${availableVolumes.length > 0 ? "" : " ml-auto"}`} className="flex items-center gap-1 text-xs text-ink-soft"
> >
</label> <TextInput
<input
id="vol" id="vol"
inputMode="numeric" inputMode="numeric"
value={volume} value={volume}
onChange={(e) => setVolume(Math.max(1, Number(e.target.value) || 1))} onChange={(e) => onVolumeChange(Number(e.target.value))}
className="w-16 rounded border border-line bg-bg px-2 py-1 text-sm text-ink focus:border-cinnabar focus:outline-none" controlSize="sm"
className="w-16"
/> />
<button </label>
type="button"
<Button
disabled={generating} disabled={generating}
onClick={() => void generate(project.id, volume)} onClick={onGenerateClick}
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel hover:opacity-90 disabled:opacity-40" variant="primary"
> >
{generating ? "排大纲中…" : "✦ AI 排大纲"} <Sparkles className="h-4 w-4" aria-hidden="true" />
</button> {generateLabel}
</Button>
</div> </div>
{confirming ? (
<StatusNote
variant="warning"
title={`${volume} 已有章节`}
className="mb-4"
>
<p> {volume} </p>
<div className="mt-2 flex flex-wrap gap-2">
<Button onClick={runGenerate} variant="danger" size="sm">
{volume}
</Button>
<Button
onClick={() => setConfirming(false)}
variant="secondary"
size="sm"
>
</Button>
</div>
</StatusNote>
) : null}
{error ? ( {error ? (
<p className="mb-4 text-sm text-conflict"> <StatusNote variant="danger" title="大纲生成失败" className="mb-4">
{error.code}{error.message} {error.text}
{error.code === "LLM_UNAVAILABLE" ? ( {error.actionHref ? (
<> <>
{" "} {" "}
<a <a
href="/settings/providers" href={error.actionHref}
className="underline hover:text-cinnabar" className="underline hover:text-cinnabar"
> >
{error.actionLabel}
</a> </a>
</> </>
) : null} ) : null}
</p> </StatusNote>
) : null} ) : null}
<div className="min-h-0 flex-1 overflow-auto"> <div className="min-h-0 flex-1 overflow-auto">
{volumes.length === 0 ? ( {volumes.length === 0 ? (
<p className="rounded border border-dashed border-line p-6 text-sm text-ink-soft"> <EmptyState
{chapters.length > 0 && viewVolume !== "all" icon={ListTree}
? `${viewVolume} 暂无大纲。切到「全部」查看其它卷,或点「✦ AI 排大纲」为本卷生成。` title={
: "暂无大纲。点「✦ AI 排大纲」生成逐章节拍与伏笔窗口。"} chapters.length > 0 && viewVolume !== "all"
</p> ? `${viewVolume} 暂无大纲`
: "暂无大纲"
}
description={
chapters.length > 0 && viewVolume !== "all"
? "切到「全部」查看其它卷,或为当前卷生成章节节拍。"
: "点击「AI 排大纲」生成逐章节拍与伏笔窗口,再回到写作台逐章推进。"
}
action={
<Button
disabled={generating}
onClick={onGenerateClick}
variant="primary"
size="sm"
>
<Sparkles className="h-4 w-4" aria-hidden="true" />
{generateLabel}
</Button>
}
/>
) : ( ) : (
volumes.map((group) => ( volumes.map((group) => (
<section key={group.volume} className="mb-6"> <section key={group.volume} className="mb-6">
<h2 className="mb-2 font-serif text-base text-ink"> <h2 className="mb-3 font-serif text-title-md text-ink">
{group.volume} {group.volume}
</h2> </h2>
<ul className="space-y-1"> <ul className="space-y-1">

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