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)。
为内联对话视图铺路;抽屉行为完全不变。
This commit is contained in:
Yaojia Wang
2026-07-10 16:31:53 +02:00
parent f6e88d7438
commit 5af45f5034
4 changed files with 336 additions and 84 deletions

View File

@@ -11,11 +11,10 @@ import { StatusNote } from "@/components/ui/StatusNote";
import { handleTabTrap } from "@/lib/a11y/focusTrap"; import { handleTabTrap } from "@/lib/a11y/focusTrap";
import { useRestoreFocus } from "@/lib/a11y/useRestoreFocus"; import { useRestoreFocus } from "@/lib/a11y/useRestoreFocus";
import { useBodyScrollLock } from "@/lib/ui/useBodyScrollLock"; 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 type { AiMessageView } from "@/lib/api/types";
import { import {
acceptActionFor, acceptActionFor,
bubbleSide,
groupByThread, groupByThread,
kindBadge, kindBadge,
kindLabel, kindLabel,
@@ -23,7 +22,9 @@ import {
type AcceptAction, type AcceptAction,
type AiThread, type AiThread,
} from "@/lib/workbench/aiConversation"; } from "@/lib/workbench/aiConversation";
import { readSegment } from "@/lib/workbench/inlineConversation";
import type { UseAiConversation } from "@/lib/workbench/useAiConversation"; import type { UseAiConversation } from "@/lib/workbench/useAiConversation";
import { ConversationThread } from "./ConversationThread";
// AI 对话总开关:一键关闭即回滚(触发按钮据此隐藏),仿 CONTEXT_DRAWER_ENABLED。 // AI 对话总开关:一键关闭即回滚(触发按钮据此隐藏),仿 CONTEXT_DRAWER_ENABLED。
export const AI_CONVERSATION_ENABLED = true; export const AI_CONVERSATION_ENABLED = true;
@@ -266,92 +267,15 @@ function ThreadBlock({ thread, onAccept }: ThreadBlockProps) {
{formatBubbleTime(headAt)} {formatBubbleTime(headAt)}
</span> </span>
</div> </div>
<div className="space-y-2"> <ConversationThread
{thread.messages.map((m) => ( messages={thread.messages}
<Bubble key={m.id} message={m} action={action} onAccept={onAccept} /> action={action}
))} onAccept={onAccept}
</div> />
</div> </div>
); );
} }
interface BubbleProps {
message: AiMessageView;
action: AcceptAction | null;
onAccept: (action: AcceptAction, m: AiMessageView) => void;
}
// 单条气泡作者右cinnabar-wash/ AI 左border+bgclarify 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 (
<div className={cn("flex", side === "right" ? "justify-end" : "justify-start")}>
<div
className={cn(
"max-w-[85%] rounded border px-3 py-2",
side === "right"
? "border-cinnabar/30 bg-[var(--color-cinnabar-wash)]"
: "border-line bg-bg",
)}
>
<p className="max-h-56 overflow-y-auto whitespace-pre-wrap text-sm text-ink">
{message.content}
</p>
{options.length > 0 ? (
<ul className="mt-2 flex flex-wrap gap-1.5">
{options.map((opt, i) => (
<li
key={i}
className="rounded border border-line bg-panel px-2 py-0.5 text-2xs text-ink-soft"
>
{opt}
</li>
))}
</ul>
) : null}
{showAccept && action ? (
<Button
onClick={() => onAccept(action, message)}
variant="secondary"
size="sm"
className="mt-2"
>
{ACCEPT_LABEL[action]}
</Button>
) : null}
</div>
</div>
);
}
const ACCEPT_LABEL: Record<AcceptAction, string> = {
replace_chapter: "接受这版(替换整章)",
append_draft: "插入正文(章末)",
refill_segment: "回填选段",
};
// meta.segmentrefine 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」风格一致 // 气泡时间戳:本地 HH:MM显示用与自动保存「保存于 HH:MM」风格一致
function formatBubbleTime(iso: string): string { function formatBubbleTime(iso: string): string {
const d = new Date(iso); const d = new Date(iso);

View File

@@ -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<AcceptAction, string> = {
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 (
<div className="space-y-2">
{messages.map((m) => (
<Bubble key={m.id} message={m} action={action} onAccept={onAccept} />
))}
</div>
);
}
interface BubbleProps {
message: AiMessageView;
action: AcceptAction | null;
onAccept: (action: AcceptAction, m: AiMessageView) => void;
}
// 单条气泡作者右cinnabar-wash/ AI 左border+bgclarify 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 (
<div
className={cn("flex", side === "right" ? "justify-end" : "justify-start")}
>
<div
className={cn(
"max-w-[85%] rounded border px-3 py-2",
side === "right"
? "border-cinnabar/30 bg-[var(--color-cinnabar-wash)]"
: "border-line bg-bg",
pending && "opacity-90",
)}
>
{showThinking ? (
<ThinkingIndicator label="生成中" className="text-sm text-cinnabar" />
) : (
<p className="max-h-56 overflow-y-auto whitespace-pre-wrap text-sm text-ink">
{message.content}
</p>
)}
{options.length > 0 ? (
<ul className="mt-2 flex flex-wrap gap-1.5">
{options.map((opt, i) => (
<li
key={i}
className="rounded border border-line bg-panel px-2 py-0.5 text-2xs text-ink-soft"
>
{opt}
</li>
))}
</ul>
) : null}
{showAccept && action ? (
<Button
onClick={() => onAccept(action, message)}
variant="secondary"
size="sm"
className="mt-2"
>
{ACCEPT_LABEL[action]}
</Button>
) : null}
</div>
</div>
);
}
// 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);
}

View File

@@ -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>): 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/seqcreated_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("");
});
});

View File

@@ -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<string, unknown> = { [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.segmentrefine 每版都存原选段)——供从气泡「回填选段」按内容重锚。
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);
}