Files
writer-work-flow/apps/web/lib/workbench/inlineConversation.ts
Yaojia Wang 5af45f5034 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)。
为内联对话视图铺路;抽屉行为完全不变。
2026-07-10 16:31:53 +02:00

87 lines
3.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 内联对话(气泡流)数据装配:把已落库留痕(本章 + 指定 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);
}