"use client"; import { useEffect, useRef, type RefObject } from "react"; import { MessagesSquare, X } from "lucide-react"; import { ThinkingIndicator } from "@/components/ThinkingIndicator"; import { Badge } from "@/components/ui/Badge"; import { Button } from "@/components/ui/Button"; import { EmptyState } from "@/components/ui/EmptyState"; import { StatusNote } from "@/components/ui/StatusNote"; import { handleTabTrap } from "@/lib/a11y/focusTrap"; import { useRestoreFocus } from "@/lib/a11y/useRestoreFocus"; import { useBodyScrollLock } from "@/lib/ui/useBodyScrollLock"; import { overlayScrim } from "@/lib/ui/variants"; import type { AiMessageView } from "@/lib/api/types"; import { acceptActionFor, groupByThread, kindBadge, kindLabel, partitionByScope, type AcceptAction, type AiThread, } from "@/lib/workbench/aiConversation"; import { readSegment } from "@/lib/workbench/inlineConversation"; import type { UseAiConversation } from "@/lib/workbench/useAiConversation"; import { ConversationThread } from "./ConversationThread"; // AI 对话总开关:一键关闭即回滚(触发按钮据此隐藏),仿 CONTEXT_DRAWER_ENABLED。 export const AI_CONVERSATION_ENABLED = true; interface AiConversationDrawerProps { open: boolean; onClose: () => void; conversation: UseAiConversation; currentChapterNo: number; // 从历史再接受(复用编辑器既有 DRAFT-only HITL 回调);返回 true=成功则关抽屉。 onReplaceChapter: (content: string) => boolean; onAppendDraft: (content: string) => boolean; onRefillSegment: (segment: string, refined: string) => boolean; triggerRef?: RefObject; } // 每章「AI 对话」抽屉(对话气泡视图):本章对话 + 项目级生成两段,作者右 / AI 左,kind 徽标 + 线程归组。 // 结构克隆 ContextDrawer:右侧 slide-over + overlayScrim + role=dialog + Esc + focus trap + scroll lock + 还原焦点。 // append-only 只读展示;对 ai 产出可「从历史再接受」——只回填编辑器草稿,不绕开验收事务(守不变量 #3)。 export function AiConversationDrawer({ open, onClose, conversation, currentChapterNo, onReplaceChapter, onAppendDraft, onRefillSegment, triggerRef, }: AiConversationDrawerProps) { const panelRef = useRef(null); const closeRef = useRef(null); useBodyScrollLock(open); useRestoreFocus(open, triggerRef); // 打开即懒加载(Workbench 级 hook,append 早已可能触发过——已加载则空操作)。 useEffect(() => { if (open) conversation.ensureLoaded(); }, [open, conversation]); useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent): void => { if (e.key === "Escape") { e.preventDefault(); onClose(); } }; window.addEventListener("keydown", onKey); closeRef.current?.focus(); return () => window.removeEventListener("keydown", onKey); }, [open, onClose]); if (!open) return null; const threads = groupByThread(conversation.messages); const { chapterThreads, projectThreads } = partitionByScope( threads, currentChapterNo, ); const summary = `本章 ${chapterThreads.length} 段对话,项目级 ${projectThreads.length} 项生成`; // 再接受成功则关抽屉揭示改动后的编辑器;失败(如原选段漂移)保留抽屉与漂移提示。 const accept = (action: AcceptAction, m: AiMessageView): void => { let ok = false; if (action === "replace_chapter") ok = onReplaceChapter(m.content); else if (action === "append_draft") ok = onAppendDraft(m.content); else if (action === "refill_segment") ok = onRefillSegment(readSegment(m.meta), m.content); if (ok) onClose(); }; return (
e.stopPropagation()} onKeyDown={(e) => { if (panelRef.current) handleTabTrap(panelRef.current, e); }} className="absolute right-0 top-0 flex h-full w-96 max-w-[90vw] flex-col border-l border-line bg-panel shadow-paper outline-none motion-safe:transition-transform" >
); } interface DrawerBodyProps { status: UseAiConversation["status"]; chapterThreads: AiThread[]; projectThreads: AiThread[]; onRetry: () => void; onAccept: (action: AcceptAction, m: AiMessageView) => void; } function DrawerBody({ status, chapterThreads, projectThreads, onRetry, onAccept, }: DrawerBodyProps) { if (status === "loading" || status === "idle") { return (
); } if (status === "error") { return (

AI 对话暂不可用。

); } if (chapterThreads.length === 0 && projectThreads.length === 0) { return ( ); } return (
); } interface ThreadSectionProps { heading: string; threads: AiThread[]; onAccept: (action: AcceptAction, m: AiMessageView) => void; emptyText: string; } function ThreadSection({ heading, threads, onAccept, emptyText, }: ThreadSectionProps) { return (

{heading}

{threads.length === 0 ? (

{emptyText}

) : (
    {threads.map((thread) => (
  • ))}
)}
); } interface ThreadBlockProps { thread: AiThread; onAccept: (action: AcceptAction, m: AiMessageView) => void; } function ThreadBlock({ thread, onAccept }: ThreadBlockProps) { const action = acceptActionFor(thread); const headAt = thread.messages[0]?.created_at ?? ""; return (
{kindLabel(thread.kind)} {thread.toolKey ? ( {thread.toolKey} ) : null} {formatBubbleTime(headAt)}
); } // 气泡时间戳:本地 HH:MM(显示用,与自动保存「保存于 HH:MM」风格一致)。 function formatBubbleTime(iso: string): string { const d = new Date(iso); if (Number.isNaN(d.getTime())) return ""; return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); }