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)。
This commit is contained in:
Yaojia Wang
2026-07-10 17:43:59 +02:00
parent 61a6931bb1
commit ceae24740a
3 changed files with 173 additions and 61 deletions

View File

@@ -208,6 +208,7 @@ 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 改造 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-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] @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] @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 围栏。

View File

@@ -41,7 +41,7 @@ interface AiConversationDrawerProps {
triggerRef?: RefObject<HTMLElement | null>; triggerRef?: RefObject<HTMLElement | null>;
} }
// 每章「AI 对话」抽屉(对话气泡视图):本章对话 + 项目级生成两段,作者右 / AI 左kind 徽标 + 线程归组。 // 每章「对话记录」抽屉(只读往复日志,气泡视图):本章对话 + 项目级生成两段,作者右 / AI 左kind 徽标 + 线程归组。
// 结构克隆 ContextDrawer右侧 slide-over + overlayScrim + role=dialog + Esc + focus trap + scroll lock + 还原焦点。 // 结构克隆 ContextDrawer右侧 slide-over + overlayScrim + role=dialog + Esc + focus trap + scroll lock + 还原焦点。
// append-only 只读展示;对 ai 产出可「从历史再接受」——只回填编辑器草稿,不绕开验收事务(守不变量 #3 // append-only 只读展示;对 ai 产出可「从历史再接受」——只回填编辑器草稿,不绕开验收事务(守不变量 #3
export function AiConversationDrawer({ export function AiConversationDrawer({
@@ -103,7 +103,7 @@ export function AiConversationDrawer({
ref={panelRef} ref={panelRef}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label="AI 对话" aria-label="对话记录"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => { onKeyDown={(e) => {
if (panelRef.current) handleTabTrap(panelRef.current, e); if (panelRef.current) handleTabTrap(panelRef.current, e);
@@ -116,12 +116,12 @@ export function AiConversationDrawer({
className="h-4 w-4 text-cinnabar" className="h-4 w-4 text-cinnabar"
aria-hidden="true" aria-hidden="true"
/> />
AI
</span> </span>
<Button <Button
ref={closeRef} ref={closeRef}
onClick={onClose} onClick={onClose}
aria-label="关闭 AI 对话" aria-label="关闭对话记录"
variant="ghost" variant="ghost"
size="icon" size="icon"
> >
@@ -190,7 +190,7 @@ function DrawerBody({
return ( return (
<EmptyState <EmptyState
icon={MessagesSquare} icon={MessagesSquare}
title="还没有 AI 对话" title="这里留存你和 AI 的往复记录"
description="润色、整章重写、续写、工具箱生成的往复会自动留痕,重开也不丢。" description="润色、整章重写、续写、工具箱生成的往复会自动留痕,重开也不丢。"
/> />
); );

View File

@@ -8,6 +8,7 @@ import {
ChevronDown, ChevronDown,
ClipboardCheck, ClipboardCheck,
FileText, FileText,
HelpCircle,
Library, Library,
MessagesSquare, MessagesSquare,
PanelLeft, PanelLeft,
@@ -43,7 +44,7 @@ import {
type Preset, type Preset,
} from "@/lib/workbench/directive"; } from "@/lib/workbench/directive";
import { applyRefinement } from "@/lib/workbench/refineApply"; import { applyRefinement } from "@/lib/workbench/refineApply";
import { buttonClass } from "@/lib/ui/variants"; import { buttonClass, focusRing } from "@/lib/ui/variants";
import { ChapterList, ChapterListContent } from "./ChapterList"; import { ChapterList, ChapterListContent } from "./ChapterList";
import { ChapterAssistant, AssistantContent } from "./ChapterAssistant"; import { ChapterAssistant, AssistantContent } from "./ChapterAssistant";
import { Editor, type EditorSelection } from "./Editor"; import { Editor, type EditorSelection } from "./Editor";
@@ -185,7 +186,12 @@ export function Workbench({
}; };
// 四张 AI 结果卡(润色/续写/工具箱/整章重写)互斥,避免同时占位。 // 四张 AI 结果卡(润色/续写/工具箱/整章重写)互斥,避免同时占位。
// 润色以选区为对象无选区时不静默失败给出可见提示引导先选段P0-4替代 hover-only title
const openRefine = (): void => { const openRefine = (): void => {
if (!canRefine) {
toast("先在正文里选中一段再润色", "info");
return;
}
setContinueOpen(false); setContinueOpen(false);
setToolboxOpen(false); setToolboxOpen(false);
setRewriteOpen(false); setRewriteOpen(false);
@@ -322,6 +328,10 @@ export function Workbench({
onDirectiveChange={setDirective} onDirectiveChange={setDirective}
presetIds={presetIds} presetIds={presetIds}
onTogglePreset={togglePreset} onTogglePreset={togglePreset}
defaultOpen={initialText.trim().length === 0}
streaming={stream.isStreaming}
onWrite={onWrite}
onStop={stream.stop}
/> />
{refineOpen && canRefine && selection ? ( {refineOpen && canRefine && selection ? (
<RefinePanel <RefinePanel
@@ -390,7 +400,6 @@ export function Workbench({
liveMessage={liveMessage} liveMessage={liveMessage}
onWrite={onWrite} onWrite={onWrite}
onStop={stream.stop} onStop={stream.stop}
canRefine={canRefine}
onRefineSelection={openRefine} onRefineSelection={openRefine}
onContinue={openContinue} onContinue={openContinue}
onToolbox={openToolbox} onToolbox={openToolbox}
@@ -501,68 +510,174 @@ interface DirectivePanelProps {
onDirectiveChange: (value: string) => void; onDirectiveChange: (value: string) => void;
presetIds: readonly string[]; presetIds: readonly string[];
onTogglePreset: (id: string) => void; onTogglePreset: (id: string) => void;
// 空章默认展开(新手最需要看到输入框的时机);满章默认收起以让出编辑视野。
defaultOpen: boolean;
// 发送键 = 写本章:把已选文风/剧情 + 自定义要求组装后逐字流式写进正文(不改成聊天气泡)。
streaming: boolean;
onWrite: () => void;
onStop: () => void;
} }
// 本章指令面板(T4-b可折叠的 textarea + 风格快捷预设 chips // 本章写作指令面板(P0-2 提升):正文正上方常驻的「和 AI 说话」输入条
// 默认折叠(不抢编辑视野);指令覆盖/补充大纲节拍,写本章时组装传入生成 // 标题「告诉 AI 这章想怎么写」+ HITL 微文案 +「?这是什么」常驻可见;空章默认展开露出输入框
// 发送键即「写本章」,沿用 composeDirective + 文风/剧情 chips + 流式打字机写进正文。
function DirectivePanel({ function DirectivePanel({
directive, directive,
onDirectiveChange, onDirectiveChange,
presetIds, presetIds,
onTogglePreset, onTogglePreset,
defaultOpen,
streaming,
onWrite,
onStop,
}: DirectivePanelProps) { }: DirectivePanelProps) {
const panelId = useId(); const panelId = useId();
const [open, setOpen] = useState(defaultOpen);
const activeCount = presetIds.length + (directive.trim().length > 0 ? 1 : 0); const activeCount = presetIds.length + (directive.trim().length > 0 ? 1 : 0);
return ( return (
<details className="group border-b border-line bg-panel px-4 py-3 sm:px-6"> <section className="border-b border-line bg-panel px-4 py-3 sm:px-6">
<summary <div className="flex items-center justify-between gap-3">
className="flex cursor-pointer list-none items-center justify-between gap-3 text-sm text-ink-soft hover:text-cinnabar" <button
aria-controls={panelId} type="button"
> onClick={() => setOpen((v) => !v)}
<span className="flex items-center gap-2 font-serif text-base text-ink"> aria-expanded={open}
aria-controls={panelId}
className={`flex min-w-0 cursor-pointer items-center gap-2 rounded font-serif text-base text-ink hover:text-cinnabar ${focusRing}`}
>
<ChevronDown <ChevronDown
className="h-4 w-4 text-ink-soft transition-transform group-open:rotate-180" className={`h-4 w-4 shrink-0 text-ink-soft transition-transform ${
open ? "rotate-180" : ""
}`}
aria-hidden="true" aria-hidden="true"
/> />
<span className="truncate"> AI </span>
</span> </button>
<span className="rounded border border-line bg-bg px-2 py-0.5 text-xs text-ink-soft"> <div className="flex shrink-0 items-center gap-2">
{activeCount > 0 ? `${activeCount} 项指令` : "可选"} {activeCount > 0 ? (
</span> <span className="rounded border border-line bg-bg px-2 py-0.5 text-xs text-ink-soft">
</summary> {activeCount}
<div id={panelId} className="mt-3 space-y-3"> </span>
<SectionHeader ) : null}
title="AI 写作诉求" <HitlHelp />
description="选文风、点剧情需求、写自己的要求,只影响本次生成。" </div>
/>
<PresetGroup
label="文风"
presets={STYLE_PRESETS}
presetIds={presetIds}
onToggle={onTogglePreset}
/>
<PresetGroup
label="剧情需求"
presets={PLOT_PRESETS}
presetIds={presetIds}
onToggle={onTogglePreset}
/>
<label className="block">
<span className="mb-1 block text-sm text-ink-soft"></span>
<TextArea
value={directive}
onChange={(e) => onDirectiveChange(e.target.value)}
rows={2}
placeholder="本章想怎么写?(可选,覆盖/补充大纲节拍)"
/>
</label>
{activeCount > 0 ? (
<StatusNote variant="info">
/
</StatusNote>
) : null}
</div> </div>
</details> <p className="mt-1 text-xs text-ink-soft">
AI 稿
</p>
{open ? (
<div id={panelId} className="mt-3 space-y-3">
<SectionHeader
title="你的写作要求"
description="选文风、点剧情需求、写自己的要求,只影响本次生成。"
/>
<PresetGroup
label="文风"
presets={STYLE_PRESETS}
presetIds={presetIds}
onToggle={onTogglePreset}
/>
<PresetGroup
label="剧情需求"
presets={PLOT_PRESETS}
presetIds={presetIds}
onToggle={onTogglePreset}
/>
<label className="block">
<span className="mb-1 block text-sm text-ink-soft"></span>
<TextArea
value={directive}
onChange={(e) => onDirectiveChange(e.target.value)}
rows={2}
placeholder="写点要求AI 更懂你(留空则按大纲写)"
/>
</label>
<div className="flex flex-wrap items-center gap-3">
{streaming ? (
<Button onClick={onStop} variant="danger" size="sm">
<Square className="h-4 w-4" aria-hidden="true" />
</Button>
) : (
<Button onClick={onWrite} variant="primary" size="sm">
<PenLine className="h-4 w-4" aria-hidden="true" />
</Button>
)}
</div>
{activeCount > 0 ? (
<StatusNote variant="info">
/
</StatusNote>
) : null}
</div>
) : null}
</section>
);
}
// 「?这是什么」:常驻、可再发现的说明(非一次性 coach——解释「和 AI 说话」的闭环。
// 仿 AiToolbarMoreMenu按钮 + 轻量弹层Esc / 点击外部关闭,还原焦点。
function HitlHelp() {
const [open, setOpen] = useState(false);
const popId = useId();
const rootRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!open) return;
const onKeyDown = (e: KeyboardEvent): void => {
if (e.key === "Escape") {
setOpen(false);
buttonRef.current?.focus();
}
};
const onPointerDown = (e: PointerEvent): void => {
const target = e.target;
if (target instanceof Node && !rootRef.current?.contains(target)) {
setOpen(false);
}
};
window.addEventListener("keydown", onKeyDown);
window.addEventListener("pointerdown", onPointerDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("pointerdown", onPointerDown);
};
}, [open]);
return (
<div ref={rootRef} className="relative">
<button
ref={buttonRef}
type="button"
aria-haspopup="dialog"
aria-expanded={open}
aria-controls={popId}
onClick={() => setOpen((v) => !v)}
className={buttonClass({
variant: "ghost",
size: "sm",
className: "gap-1 text-xs text-ink-soft",
})}
>
<HelpCircle className="h-4 w-4" aria-hidden="true" />
</button>
{open ? (
<div
id={popId}
role="dialog"
aria-label="怎么和 AI 说话"
className="absolute right-0 z-30 mt-2 w-72 max-w-[80vw] rounded border border-line bg-panel p-3 text-sm text-ink-soft shadow-paper"
>
<p className="mb-1 font-serif text-sm text-ink"> AI </p>
<p>
AI 稿稿
稿
</p>
</div>
) : null}
</div>
); );
} }
@@ -636,8 +751,7 @@ interface ToolbarProps {
liveMessage: string; liveMessage: string;
onWrite: () => void; onWrite: () => void;
onStop: () => void; onStop: () => void;
// 选区级润色:有非空选区时可用;点击打开润色/再沟通结果卡。 // 选区级润色:常驻可点无选区时点击给提示P0-4有选区打开润色/再沟通结果卡。
canRefine: boolean;
onRefineSelection: () => void; onRefineSelection: () => void;
// 续写:读本章前文续写候选。 // 续写:读本章前文续写候选。
onContinue: () => void; onContinue: () => void;
@@ -664,7 +778,6 @@ function Toolbar({
liveMessage, liveMessage,
onWrite, onWrite,
onStop, onStop,
canRefine,
onRefineSelection, onRefineSelection,
onContinue, onContinue,
onToolbox, onToolbox,
@@ -698,10 +811,8 @@ function Toolbar({
</Button> </Button>
<Button <Button
onClick={onRefineSelection} onClick={onRefineSelection}
disabled={!canRefine}
variant="secondary" variant="secondary"
size="sm" size="sm"
title={canRefine ? undefined : "先在正文里选中一段再润色"}
> >
<Wand2 className="h-4 w-4" aria-hidden="true" /> <Wand2 className="h-4 w-4" aria-hidden="true" />
@@ -755,10 +866,10 @@ function Toolbar({
onClick={onOpenConversation} onClick={onOpenConversation}
variant="secondary" variant="secondary"
size="sm" size="sm"
title="打开 AI 对话(润色/重写/续写/工具箱往复留痕)" title="打开对话记录(润色/重写/续写/工具箱往复留痕的只读日志"
> >
<MessagesSquare className="h-4 w-4" aria-hidden="true" /> <MessagesSquare className="h-4 w-4" aria-hidden="true" />
AI
</Button> </Button>
) : null} ) : null}
<Link <Link