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:
122
apps/web/lib/workbench/inlineConversation.test.ts
Normal file
122
apps/web/lib/workbench/inlineConversation.test.ts
Normal 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/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("");
|
||||
});
|
||||
});
|
||||
86
apps/web/lib/workbench/inlineConversation.ts
Normal file
86
apps/web/lib/workbench/inlineConversation.ts
Normal 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.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);
|
||||
}
|
||||
Reference in New Issue
Block a user