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);
+}