diff --git a/PROGRESS.md b/PROGRESS.md index ac457b9..edc3bb4 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -208,6 +208,7 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移(无漂移,users st > 格式:`- [YYYY-MM-DD] @skill 完成/进展 Txx — 一句话结果 + 影响的契约/文件` +- [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** 把折叠 `
`「本章写作指令[可选]」原地提升为正文正上方**常驻输入条**(`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`,非一次性 coach,Esc/点击外部关闭)。守 §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 基线 + 10:E2E 6 + 围栏 4)。gotcha 已登记(ai_messages 真源围栏 = code-review BLOCKER)。仅动 `tests/` + `memory/gotchas.md` + `PROGRESS.md`,**未碰任何产品代码**。 diff --git a/apps/web/components/workbench/Workbench.tsx b/apps/web/components/workbench/Workbench.tsx index 8e48e09..f79b08b 100644 --- a/apps/web/components/workbench/Workbench.tsx +++ b/apps/web/components/workbench/Workbench.tsx @@ -4,12 +4,10 @@ import Link from "next/link"; import { useEffect, useId, useRef, useState, type RefObject } from "react"; import { BookMarked, - BookOpen, ChevronDown, ClipboardCheck, FileText, HelpCircle, - Library, MessagesSquare, PanelLeft, PanelRight, @@ -98,9 +96,9 @@ export function Workbench({ const [toolboxOpen, setToolboxOpen] = useState(false); // WFW-8:整章「再沟通/重写」结果卡开合(意见 → 流式重写一版 → 版本栈 → 接受替换整章)。 const [rewriteOpen, setRewriteOpen] = useState(false); - // WFW-7:首次写章的极轻 genre 采集门。项目无题材 → 写章前先点一个题材(仅临时并入本次指令,不落库)。 + // WFW-7 / P1-5:首次写章的极轻 genre 采集门。项目无题材 → 正文上方常驻一条内联「题材」选择条 + // (非弹窗),作者点一个题材再写;仅临时并入本次指令、不落库,避免无题材 slop,且第一次发送不被打断。 const genreGate = useGenreGate(project.genre ?? null); - const [genrePromptOpen, setGenrePromptOpen] = useState(false); // WFW-6 上下文速查抽屉:加法式、flag 灰度;与三张 AI 结果卡互不干扰。 const [contextOpen, setContextOpen] = useState(false); // AC-3 AI 对话抽屉:Workbench 级单实例(append 即使抽屉关着也能触发);flag 灰度。 @@ -141,21 +139,15 @@ export function Workbench({ }; const onWrite = (): void => { - // 无题材且未点选 → 先弹采集卡,选后再写(HITL:不静默生成无题材 slop)。 + // 无题材且未点选 → 引导先点上方常驻「题材」条再写(HITL:不静默生成无题材 slop)。 + // 选择条一直可见,理想路径是发送前已选好,第一次「写本章」即顺滑流式、不被弹窗打断。 if (genreGate.needsGenre && genreGate.chosenGenre === null) { - setGenrePromptOpen(true); + toast("先在上方选个题材,AI 写得更贴合、少套话。", "info"); return; } startWrite(genreGate.effectiveGenre); }; - // 采集卡点题材:记录本次题材并立刻开写。用 genre 直传绕开 setState 异步。 - const onPickGenre = (genre: string): void => { - genreGate.setChosenGenre(genre); - setGenrePromptOpen(false); - startWrite(genre); - }; - const togglePreset = (id: string): void => { setPresetIds((prev) => prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id], @@ -252,7 +244,7 @@ export function Workbench({ const onAppendDraftFromHistory = (content: string): boolean => { appendToDraft(content); - toast("已插入正文。", "success"); + toast("已加到章末。", "success"); return true; }; @@ -263,12 +255,12 @@ export function Workbench({ // 合成零区间强制 slice 校验失败 → 走 indexOf 按存档的原选段重锚;找不到则不盲替换。 const next = applyRefinement(text, segment, refined, { start: 0, end: 0 }); if (next === null) { - toast("正文已改动,找不到原选段,无法回填。", "error"); + toast("正文已改动,找不到原选段,无法替换。", "error"); return false; } setText(next); autosave.onChange(next); - toast("已回填选段。", "success"); + toast("已替换这段。", "success"); return true; }; @@ -315,12 +307,12 @@ export function Workbench({ chapterTriggerRef={chapterTriggerRef} assistantTriggerRef={assistantTriggerRef} /> - {genrePromptOpen ? ( + {genreGate.needsGenre ? ( + // 项目无题材:正文上方常驻内联题材条(非弹窗),发送前选好即顺滑写章(P1-5)。 setGenrePromptOpen(false)} + onPick={genreGate.setChosenGenre} /> ) : null} + {refineOpen && canRefine && selection ? ( setContextOpen(true)} contextTriggerRef={contextTriggerRef} onOpenConversation={() => setConversationOpen(true)} @@ -432,7 +426,7 @@ export function Workbench({ open={mobilePanel === "assistant"} onClose={closePanel} side="right" - label="本章助手" + label="本章参考" triggerRef={assistantTriggerRef} > @@ -558,6 +552,21 @@ function DirectivePanel({ {activeCount} 项指令 ) : null} + {/* 常驻「发送键」= 写本章:始终可见(折叠时也在),逐字流式写进正文。带可见范围提示(P1-4)。 */} + {streaming ? ( + + ) : ( + + )} @@ -591,19 +600,6 @@ function DirectivePanel({ placeholder="写点要求,AI 更懂你(留空则按大纲写)" /> -
- {streaming ? ( - - ) : ( - - )} -
{activeCount > 0 ? ( 写本章时会把已选文风/剧情预设和你的要求合并进本次生成请求。 @@ -615,6 +611,77 @@ function DirectivePanel({ ); } +interface AiVerbsRowProps { + streaming: boolean; + onContinue: () => void; + onRefineSelection: () => void; + onRewrite: () => void; + onToolbox: () => void; +} + +// 「或让 AI:」次级动作行(P1-1):常驻贴在中央输入条下方,把续写/润色/整章重写/工具箱 +// 归为「同一个 AI 的其它用法」(act-on-chapter 与导航分家)。每个动词带可见范围提示(P1-4)。 +// 生成中禁用(避免与流式写章并发),触发逻辑沿用 Workbench 既有 open* 回调(仅搬家、不改行为)。 +function AiVerbsRow({ + streaming, + onContinue, + onRefineSelection, + onRewrite, + onToolbox, +}: AiVerbsRowProps) { + return ( +
+ 或让 AI: + + + + +
+ ); +} + +interface VerbButtonProps { + icon: typeof WrapText; + label: string; + // 可见(非 hover-only)范围提示:让作者点前就能预判对草稿的影响(P1-4)。工具箱无范围提示。 + hint?: string; + disabled: boolean; + onClick: () => void; +} + +function VerbButton({ icon: Icon, label, hint, disabled, onClick }: VerbButtonProps) { + return ( + + ); +} + // 「?这是什么」:常驻、可再发现的说明(非一次性 coach)——解释「和 AI 说话」的闭环。 // 仿 AiToolbarMoreMenu:按钮 + 轻量弹层,Esc / 点击外部关闭,还原焦点。 function HitlHelp() { @@ -739,6 +806,8 @@ function StreamErrorNote({ ); } +// 底栏瘦身(P1-1):act-on-chapter 动词与纯导航都已上移/移除,底栏只余 +// 状态(字数/保存/播报)+ 流式时的「停」+ 唯一强调的「审稿」前进 CTA + 速查/对话记录抽屉入口。 interface ToolbarProps { projectId: string; chapterNo: number; @@ -749,20 +818,12 @@ interface ToolbarProps { streamError: { code: string; message: string } | null; // 流式里程碑播报句(完成/停止/失败);仅在 role=status 区出现,不逐 token 刷新。 liveMessage: string; - onWrite: () => void; + // 流式时的「停」:底栏 sticky,滚到长正文下方也够得着(中央条会滚出视野)。 onStop: () => void; - // 选区级润色:常驻可点;无选区时点击给提示(P0-4),有选区打开润色/再沟通结果卡。 - onRefineSelection: () => void; - // 续写:读本章前文续写候选。 - onContinue: () => void; - // 工具箱:编辑器内联调用生成器,结果回填正文。 - onToolbox: () => void; - // 整章再沟通/重写(WFW-8)。 - onRewrite: () => void; // 打开上下文速查抽屉(WFW-6)。 onOpenContext: () => void; contextTriggerRef: RefObject; - // 打开 AI 对话抽屉(AC-3)。 + // 打开对话记录抽屉(AC-3)。 onOpenConversation: () => void; conversationTriggerRef: RefObject; } @@ -776,12 +837,7 @@ function Toolbar({ streaming, streamError, liveMessage, - onWrite, onStop, - onRefineSelection, - onContinue, - onToolbox, - onRewrite, onOpenContext, contextTriggerRef, onOpenConversation, @@ -797,35 +853,6 @@ function Toolbar({