diff --git a/apps/web/components/workbench/AiConversationDrawer.tsx b/apps/web/components/workbench/AiConversationDrawer.tsx index 3573554..354785a 100644 --- a/apps/web/components/workbench/AiConversationDrawer.tsx +++ b/apps/web/components/workbench/AiConversationDrawer.tsx @@ -11,11 +11,10 @@ import { StatusNote } from "@/components/ui/StatusNote"; import { handleTabTrap } from "@/lib/a11y/focusTrap"; import { useRestoreFocus } from "@/lib/a11y/useRestoreFocus"; import { useBodyScrollLock } from "@/lib/ui/useBodyScrollLock"; -import { cn, overlayScrim } from "@/lib/ui/variants"; +import { overlayScrim } from "@/lib/ui/variants"; import type { AiMessageView } from "@/lib/api/types"; import { acceptActionFor, - bubbleSide, groupByThread, kindBadge, kindLabel, @@ -23,7 +22,9 @@ import { type AcceptAction, type AiThread, } from "@/lib/workbench/aiConversation"; +import { readSegment } from "@/lib/workbench/inlineConversation"; import type { UseAiConversation } from "@/lib/workbench/useAiConversation"; +import { ConversationThread } from "./ConversationThread"; // AI 对话总开关:一键关闭即回滚(触发按钮据此隐藏),仿 CONTEXT_DRAWER_ENABLED。 export const AI_CONVERSATION_ENABLED = true; @@ -266,92 +267,15 @@ function ThreadBlock({ thread, onAccept }: ThreadBlockProps) { {formatBubbleTime(headAt)} -
- {thread.messages.map((m) => ( - - ))} -
+ ); } -interface BubbleProps { - message: AiMessageView; - action: AcceptAction | null; - onAccept: (action: AcceptAction, m: AiMessageView) => void; -} - -// 单条气泡:作者右(cinnabar-wash)/ AI 左(border+bg);clarify ai 气泡额外渲染只读方向选项。 -function Bubble({ message, action, onAccept }: BubbleProps) { - const side = bubbleSide(message.role); - const options = message.kind === "clarify" ? readOptions(message.meta) : []; - // 只有 ai 产出气泡(左侧)挂再接受按钮;作者气泡与 clarify 不挂。 - const showAccept = action !== null && side === "left" && message.kind !== "clarify"; - return ( -
-
-

- {message.content} -

- {options.length > 0 ? ( -
    - {options.map((opt, i) => ( -
  • - {opt} -
  • - ))} -
- ) : null} - {showAccept && action ? ( - - ) : null} -
-
- ); -} - -const ACCEPT_LABEL: Record = { - replace_chapter: "接受这版(替换整章)", - append_draft: "插入正文(章末)", - refill_segment: "回填选段", -}; - -// meta.segment(refine ai 行每版都存原选段)——供「回填选段」按内容重锚。 -function readSegment(meta: AiMessageView["meta"]): string { - const v = (meta ?? {})["segment"]; - return typeof v === "string" ? v : ""; -} - -// clarify ai 行 meta.options → 只读选项 label 列表(外部数据,逐项显式取值,不信任形状)。 -function readOptions(meta: AiMessageView["meta"]): string[] { - const raw = (meta ?? {})["options"]; - if (!Array.isArray(raw)) return []; - return raw - .map((o) => - o && typeof o === "object" && "label" in o - ? String((o as { label: unknown }).label ?? "") - : "", - ) - .filter((label) => label.length > 0); -} - // 气泡时间戳:本地 HH:MM(显示用,与自动保存「保存于 HH:MM」风格一致)。 function formatBubbleTime(iso: string): string { const d = new Date(iso); diff --git a/apps/web/components/workbench/ConversationThread.tsx b/apps/web/components/workbench/ConversationThread.tsx new file mode 100644 index 0000000..60987b3 --- /dev/null +++ b/apps/web/components/workbench/ConversationThread.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { ThinkingIndicator } from "@/components/ThinkingIndicator"; +import { Button } from "@/components/ui/Button"; +import { cn } from "@/lib/ui/variants"; +import type { AiMessageView } from "@/lib/api/types"; +import { bubbleSide, type AcceptAction } from "@/lib/workbench/aiConversation"; +import { + isPendingView, + isStreamingView, +} from "@/lib/workbench/inlineConversation"; + +// 一组对话气泡(作者右 / AI 左)。抽屉与内联面板共用同一渲染,外观完全一致(DRY)。 +// 对已落库的 ai 产出气泡挂「再接受」(复用编辑器既有 DRAFT-only HITL 回调,不绕验收事务); +// clarify 气泡与进行中(未落库/流式)气泡不挂。 +export const ACCEPT_LABEL: Record = { + replace_chapter: "接受这版(替换整章)", + append_draft: "插入正文(章末)", + refill_segment: "回填选段", +}; + +interface ConversationThreadProps { + messages: readonly AiMessageView[]; + // 本线程可执行的接受动作(据 thread.kind 推导);null 时全部气泡不挂接受。 + action: AcceptAction | null; + onAccept: (action: AcceptAction, m: AiMessageView) => void; +} + +export function ConversationThread({ + messages, + action, + onAccept, +}: ConversationThreadProps) { + return ( +
+ {messages.map((m) => ( + + ))} +
+ ); +} + +interface BubbleProps { + message: AiMessageView; + action: AcceptAction | null; + onAccept: (action: AcceptAction, m: AiMessageView) => void; +} + +// 单条气泡:作者右(cinnabar-wash)/ AI 左(border+bg);clarify ai 气泡额外渲染只读方向选项。 +// 进行中气泡(meta._pending)不挂再接受;流式且空正文时渲染「生成中」指示(meta._streaming)。 +function Bubble({ message, action, onAccept }: BubbleProps) { + const side = bubbleSide(message.role); + const options = message.kind === "clarify" ? readOptions(message.meta) : []; + const pending = isPendingView(message); + const showThinking = isStreamingView(message) && message.content.length === 0; + // 只有已落库 ai 产出气泡(左侧、非 clarify)挂再接受;进行中气泡(仍在生成)不挂。 + const showAccept = + action !== null && + side === "left" && + message.kind !== "clarify" && + !pending; + return ( +
+
+ {showThinking ? ( + + ) : ( +

+ {message.content} +

+ )} + {options.length > 0 ? ( +
    + {options.map((opt, i) => ( +
  • + {opt} +
  • + ))} +
+ ) : null} + {showAccept && action ? ( + + ) : null} +
+
+ ); +} + +// clarify ai 行 meta.options → 只读选项 label 列表(外部数据,逐项显式取值,不信任形状)。 +function readOptions(meta: AiMessageView["meta"]): string[] { + const raw = (meta ?? {})["options"]; + if (!Array.isArray(raw)) return []; + return raw + .map((o) => + o && typeof o === "object" && "label" in o + ? String((o as { label: unknown }).label ?? "") + : "", + ) + .filter((label) => label.length > 0); +} diff --git a/apps/web/lib/workbench/inlineConversation.test.ts b/apps/web/lib/workbench/inlineConversation.test.ts new file mode 100644 index 0000000..2348975 --- /dev/null +++ b/apps/web/lib/workbench/inlineConversation.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; + +import type { AiMessageView } from "@/lib/api/types"; +import { + PENDING_CREATED_AT, + isPendingView, + isStreamingView, + mergeInlineThreads, + readSegment, + toPendingView, +} from "./inlineConversation"; + +// AiMessageView 工厂:只填测试关心的字段,其余给稳定默认(对齐 aiConversation.test.ts)。 +function view(partial: Partial): AiMessageView { + return { + id: partial.id ?? crypto.randomUUID(), + project_id: "p1", + chapter_no: partial.chapter_no ?? null, + thread_id: partial.thread_id ?? "t1", + seq: partial.seq ?? 0, + kind: partial.kind ?? "refine", + tool_key: partial.tool_key ?? null, + role: partial.role ?? "ai", + content: partial.content ?? "", + meta: partial.meta ?? {}, + created_at: partial.created_at ?? "2026-07-09T00:00:00Z", + }; +} + +describe("mergeInlineThreads", () => { + it("只保留本章 + 指定 kinds 的已落库消息", () => { + const persisted = [ + view({ thread_id: "a", chapter_no: 1, kind: "refine", seq: 0 }), + view({ thread_id: "b", chapter_no: 2, kind: "refine", seq: 0 }), // 他章 + view({ thread_id: "c", chapter_no: 1, kind: "continue", seq: 0 }), // 他 kind + ]; + const threads = mergeInlineThreads(persisted, 1, ["refine", "clarify"], []); + expect(threads.map((t) => t.threadId)).toEqual(["a"]); + }); + + it("并入进行中气泡(按 id 去重、已落库优先),排在本章会话末尾", () => { + const persisted = [ + view({ id: "p1", thread_id: "s", chapter_no: 1, kind: "refine", seq: 0, role: "author", content: "原文" }), + view({ id: "p2", thread_id: "s", chapter_no: 1, kind: "refine", seq: 1, role: "ai", content: "第一版" }), + ]; + const pending = [ + toPendingView({ localId: "pend-a", role: "author", content: "再改" }, { threadId: "s", chapterNo: 1, kind: "refine", seq: 0 }), + toPendingView({ localId: "pend-b", role: "ai", content: "", streaming: true }, { threadId: "s", chapterNo: 1, kind: "refine", seq: 1 }), + ]; + const threads = mergeInlineThreads(persisted, 1, ["refine"], pending); + expect(threads).toHaveLength(1); + expect(threads[0]!.messages.map((m) => m.id)).toEqual(["p1", "p2", "pend-a", "pend-b"]); + }); + + it("进行中气泡 id 与已落库重复时不重复(已落库优先)", () => { + const persisted = [view({ id: "dup", thread_id: "s", chapter_no: 1, kind: "refine", seq: 0 })]; + const pending = [ + toPendingView({ localId: "dup", role: "ai", content: "x" }, { threadId: "s", chapterNo: 1, kind: "refine", seq: 9 }), + ]; + const threads = mergeInlineThreads(persisted, 1, ["refine"], pending); + expect(threads[0]!.messages).toHaveLength(1); + expect(threads[0]!.messages[0]!.id).toBe("dup"); + }); + + it("无已落库时进行中气泡自成一线程", () => { + const pending = [ + toPendingView({ localId: "x", role: "author", content: "hi" }, { threadId: "new", chapterNo: 1, kind: "rewrite", seq: 0 }), + ]; + const threads = mergeInlineThreads([], 1, ["rewrite"], pending); + expect(threads).toHaveLength(1); + expect(threads[0]!.messages[0]!.id).toBe("x"); + }); + + it("空输入 → 空数组", () => { + expect(mergeInlineThreads([], 1, ["refine"], [])).toEqual([]); + }); +}); + +describe("toPendingView", () => { + it("合成未落库气泡:meta._pending,映射 role/content/kind/seq,created_at 恒为未来常量", () => { + const v = toPendingView( + { localId: "L1", role: "ai", content: "改写中", streaming: true }, + { threadId: "t9", chapterNo: 3, kind: "rewrite", seq: 2 }, + ); + expect(v.id).toBe("L1"); + expect(v.role).toBe("ai"); + expect(v.content).toBe("改写中"); + expect(v.thread_id).toBe("t9"); + expect(v.chapter_no).toBe(3); + expect(v.kind).toBe("rewrite"); + expect(v.seq).toBe(2); + expect(v.created_at).toBe(PENDING_CREATED_AT); + expect(isPendingView(v)).toBe(true); + expect(isStreamingView(v)).toBe(true); + }); + + it("非流式种子不带 _streaming", () => { + const v = toPendingView( + { localId: "L2", role: "author", content: "x" }, + { threadId: "t", chapterNo: 1, kind: "refine", seq: 0 }, + ); + expect(isStreamingView(v)).toBe(false); + expect(isPendingView(v)).toBe(true); + }); +}); + +describe("isPendingView / isStreamingView", () => { + it("已落库消息(无标记)均为 false", () => { + const v = view({}); + expect(isPendingView(v)).toBe(false); + expect(isStreamingView(v)).toBe(false); + }); +}); + +describe("readSegment", () => { + it("取 meta.segment 字符串;缺失/非串回退空串", () => { + expect(readSegment({ segment: "原选段" })).toBe("原选段"); + expect(readSegment({})).toBe(""); + expect(readSegment({ segment: 42 })).toBe(""); + expect(readSegment(undefined)).toBe(""); + }); +}); diff --git a/apps/web/lib/workbench/inlineConversation.ts b/apps/web/lib/workbench/inlineConversation.ts new file mode 100644 index 0000000..5d0db9f --- /dev/null +++ b/apps/web/lib/workbench/inlineConversation.ts @@ -0,0 +1,86 @@ +// 内联对话(气泡流)数据装配:把已落库留痕(本章 + 指定 kinds)与「进行中」(尚未落库)的 +// 实时气泡按 id 去重合并,复用 aiConversation 的 groupByThread 归组 + (created_at, seq) 定序。 +// 无 React/IO,确定性纯函数,供 RefinePanel / ChapterRewritePanel 与单测共用。 + +import type { AiMessageView } from "@/lib/api/types"; +import { groupByThread, type AiThread } from "./aiConversation"; + +// 进行中气泡的 created_at 恒取一个远未来常量:确保按 (created_at, seq) 定序时它们稳居 +// 本章会话末尾,且不随每次渲染的「现在」漂移而无谓重排(同 localId → 无闪烁)。 +export const PENDING_CREATED_AT = "9999-12-31T23:59:59.000Z"; + +// meta 上的私有 UI 标记键:仅内联进行中气泡设置;抽屉的已落库消息从不设置 → 其渲染完全不变。 +const PENDING_FLAG = "_pending"; +const STREAMING_FLAG = "_streaming"; + +// 一条「进行中」气泡的视图种子——面板据实时状态(缓冲的意见/反问、流式产出)合成。 +export interface PendingSeed { + // 稳定本地 id:同一轮内不变,避免流式逐 token 重渲染时气泡被当作新节点而闪烁。 + localId: string; + role: "author" | "ai"; + content: string; + // AI 气泡是否仍在流式/思考(据此渲染「生成中」指示,且不挂「再接受」)。 + streaming?: boolean; +} + +export interface PendingContext { + threadId: string; + chapterNo: number; + kind: string; + seq: number; +} + +// 合成一条未落库气泡视图(复用共享 Bubble 渲染,外观与已落库一致)。 +export function toPendingView( + seed: PendingSeed, + ctx: PendingContext, +): AiMessageView { + const meta: Record = { [PENDING_FLAG]: true }; + if (seed.streaming) meta[STREAMING_FLAG] = true; + return { + id: seed.localId, + project_id: "", + chapter_no: ctx.chapterNo, + thread_id: ctx.threadId, + seq: ctx.seq, + kind: ctx.kind, + tool_key: null, + role: seed.role, + content: seed.content, + meta, + created_at: PENDING_CREATED_AT, + }; +} + +// 是否为进行中(未落库)气泡——据此隐藏「再接受」并按进行中样式渲染。 +export function isPendingView(m: AiMessageView): boolean { + return (m.meta ?? {})[PENDING_FLAG] === true; +} + +// 是否为仍在流式/思考的气泡——空正文时渲染「生成中」指示。 +export function isStreamingView(m: AiMessageView): boolean { + return (m.meta ?? {})[STREAMING_FLAG] === true; +} + +// meta.segment(refine 每版都存原选段)——供从气泡「回填选段」按内容重锚。 +export function readSegment(meta: AiMessageView["meta"]): string { + const v = (meta ?? {})["segment"]; + return typeof v === "string" ? v : ""; +} + +// 把已落库消息收窄到本章 + 指定 kinds,与进行中气泡按 id 去重合并(已落库优先),归组成有序线程。 +// fail-soft:任一入参为空都安全返回;进行中气泡若与已落库同 id(正位对账后)不重复渲染。 +export function mergeInlineThreads( + persisted: readonly AiMessageView[], + currentChapterNo: number, + kinds: readonly string[], + pending: readonly AiMessageView[], +): AiThread[] { + const kindSet = new Set(kinds); + const scoped = persisted.filter( + (m) => m.chapter_no === currentChapterNo && kindSet.has(m.kind), + ); + const seen = new Set(scoped.map((m) => m.id)); + const merged = [...scoped, ...pending.filter((m) => !seen.has(m.id))]; + return groupByThread(merged); +}