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

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