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