// AI 对话(聊天记录)纯视图模型:把 append-only 的 AiMessageView 扁平流归组成可渲染的 // 线程/分区结构 + kind 标签/徽标/气泡方位映射。无 React/IO,确定性纯函数,供抽屉与单测共用。 // 排序纪律(守 AC 契约):组内与线程都按 (created_at ASC, seq ASC)——绝不单靠 created_at // (同一 append 批次共享 created_at,靠 repo 赋的 seq 消歧,这正是后端加 seq 的原因)。 import type { AiMessageView } from "@/lib/api/types"; import type { BadgeVariant } from "@/lib/ui/variants"; // v1 记录的五类交互(与后端 AiKind Literal 对齐)。 export type AiKind = "refine" | "rewrite" | "clarify" | "continue" | "generator"; // 抽屉里对某条 ai 产出可执行的接受动作(复用编辑器既有 DRAFT-only HITL 回调,不绕开验收事务)。 export type AcceptAction = "replace_chapter" | "append_draft" | "refill_segment"; export const KIND_LABEL: Record = { refine: "润色", rewrite: "整章重写", clarify: "反问澄清", continue: "续写", generator: "工具箱", }; export const KIND_BADGE: Record = { refine: "accent", rewrite: "warning", clarify: "info", continue: "success", generator: "neutral", }; // kind → 中文标签;未知 kind(后端可零迁移新增)回退原始串,永不崩。 export function kindLabel(kind: string): string { return KIND_LABEL[kind as AiKind] ?? kind; } // kind → 徽标变体;未知 kind 回退中性徽标。 export function kindBadge(kind: string): BadgeVariant { return KIND_BADGE[kind as AiKind] ?? "neutral"; } // 气泡方位:作者靠右、AI 靠左(对话视图约定)。 export function bubbleSide(role: string): "left" | "right" { return role === "author" ? "right" : "left"; } // 一次往复交换(同 thread_id 归组),消息按 (created_at, seq) 升序。 export interface AiThread { threadId: string; kind: string; toolKey: string | null; chapterNo: number | null; messages: AiMessageView[]; } // created_at → 毫秒时间戳(兼容 Z / +00:00 两种序列化;不可解析回退 0)。 function timeOf(iso: string): number { const t = Date.parse(iso); return Number.isNaN(t) ? 0 : t; } // 稳定定序比较器:先 created_at,再 seq(跨批 created_at 定序、批内 seq 消歧)。 function byTimeThenSeq(a: AiMessageView, b: AiMessageView): number { const dt = timeOf(a.created_at) - timeOf(b.created_at); return dt !== 0 ? dt : a.seq - b.seq; } // 扁平消息流 → 线程数组。组内按 (created_at, seq) 升序;线程按各自首条 (created_at, seq) 升序 // (老线程在前,新往复追加在后——契合聊天视图 + aria additions 在末尾播报新增)。 export function groupByThread(messages: readonly AiMessageView[]): AiThread[] { const groups = new Map(); for (const msg of messages) { const bucket = groups.get(msg.thread_id); if (bucket) { bucket.push(msg); } else { groups.set(msg.thread_id, [msg]); } } const threads: AiThread[] = []; for (const [threadId, rows] of groups) { const sorted = [...rows].sort(byTimeThenSeq); const head = sorted[0]; if (!head) continue; // 分组桶恒非空;仅为类型收窄。 threads.push({ threadId, kind: head.kind, toolKey: head.tool_key, chapterNo: head.chapter_no, messages: sorted, }); } // 线程按首条消息定序(复用同一比较器,取每组已排序的 messages[0])。 return threads.sort((a, b) => { const ha = a.messages[0]; const hb = b.messages[0]; if (!ha || !hb) return 0; return byTimeThenSeq(ha, hb); }); } // 线程按作用域分区:本章线程(chapterNo === 当前章)与项目级线程(chapterNo == null,工具箱)。 export interface ScopePartition { chapterThreads: AiThread[]; projectThreads: AiThread[]; } export function partitionByScope( threads: readonly AiThread[], currentChapterNo: number, ): ScopePartition { const chapterThreads: AiThread[] = []; const projectThreads: AiThread[] = []; for (const thread of threads) { if (thread.chapterNo === currentChapterNo) { chapterThreads.push(thread); } else if (thread.chapterNo === null) { projectThreads.push(thread); } // 其它章号(联合查询理论上不返回):既不归本章也不归项目级,静默丢弃。 } return { chapterThreads, projectThreads }; } // 线程 kind → 抽屉里对 ai 气泡可执行的接受动作;clarify / 未知 kind 无接受动作。 export function acceptActionFor(thread: AiThread): AcceptAction | null { switch (thread.kind) { case "rewrite": return "replace_chapter"; case "continue": case "generator": return "append_draft"; case "refine": return "refill_segment"; default: return null; } }