"use client"; import { Button } from "@/components/ui/Button"; import { GenerationSkeleton } from "@/components/ui/GenerationSkeleton"; import { cn } from "@/lib/ui/variants"; import type { AiMessageView } from "@/lib/api/types"; import { bubbleSide, type AcceptAction } from "@/lib/workbench/aiConversation"; import { isPendingView, isStreamingView, } from "@/lib/workbench/inlineConversation"; // 一组对话气泡(作者右 / AI 左)。抽屉与内联面板共用同一渲染,外观完全一致(DRY)。 // 对已落库的 ai 产出气泡挂「再接受」(复用编辑器既有 DRAFT-only HITL 回调,不绕验收事务); // clarify 气泡与进行中(未落库/流式)气泡不挂。 export const ACCEPT_LABEL: Record = { replace_chapter: "采用(替换整章)", append_draft: "采用(加到章末)", refill_segment: "采用(替换这段)", }; interface ConversationThreadProps { messages: readonly AiMessageView[]; // 本线程可执行的接受动作(据 thread.kind 推导);null 时全部气泡不挂接受。 action: AcceptAction | null; onAccept: (action: AcceptAction, m: AiMessageView) => void; } export function ConversationThread({ messages, action, onAccept, }: ConversationThreadProps) { return (
{messages.map((m) => ( ))}
); } interface BubbleProps { message: AiMessageView; action: AcceptAction | null; onAccept: (action: AcceptAction, m: AiMessageView) => void; } // 单条气泡:作者右(cinnabar-wash)/ AI 左(border+bg);clarify ai 气泡额外渲染只读方向选项。 // 进行中气泡(meta._pending)不挂再接受;流式且空正文时渲染「生成中」指示(meta._streaming)。 function Bubble({ message, action, onAccept }: BubbleProps) { const side = bubbleSide(message.role); const options = message.kind === "clarify" ? readOptions(message.meta) : []; const pending = isPendingView(message); const showThinking = isStreamingView(message) && message.content.length === 0; // 只有已落库 ai 产出气泡(左侧、非 clarify)挂再接受;进行中气泡(仍在生成)不挂。 const showAccept = action !== null && side === "left" && message.kind !== "clarify" && !pending; return (
{showThinking ? ( // 空正文的进行中 ai 气泡(如同步 refine 的整段等待):呼吸微光骨架,比单三点更明显。 ) : (

{message.content}

)} {options.length > 0 ? (
    {options.map((opt, i) => (
  • {opt}
  • ))}
) : null} {showAccept && action ? ( ) : null}
); } // clarify ai 行 meta.options → 只读选项 label 列表(外部数据,逐项显式取值,不信任形状)。 function readOptions(meta: AiMessageView["meta"]): string[] { const raw = (meta ?? {})["options"]; if (!Array.isArray(raw)) return []; return raw .map((o) => o && typeof o === "object" && "label" in o ? String((o as { label: unknown }).label ?? "") : "", ) .filter((label) => label.length > 0); }