把 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)。 为内联对话视图铺路;抽屉行为完全不变。
285 lines
8.8 KiB
TypeScript
285 lines
8.8 KiB
TypeScript
"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<HTMLElement | null>;
|
||
}
|
||
|
||
// 每章「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<HTMLDivElement>(null);
|
||
const closeRef = useRef<HTMLButtonElement>(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 (
|
||
<div className={overlayScrim} onClick={onClose}>
|
||
<div
|
||
ref={panelRef}
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label="AI 对话"
|
||
onClick={(e) => 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"
|
||
>
|
||
<header className="flex items-center justify-between border-b border-line px-4 py-3">
|
||
<span className="flex items-center gap-2 font-serif text-base text-ink">
|
||
<MessagesSquare
|
||
className="h-4 w-4 text-cinnabar"
|
||
aria-hidden="true"
|
||
/>
|
||
AI 对话
|
||
</span>
|
||
<Button
|
||
ref={closeRef}
|
||
onClick={onClose}
|
||
aria-label="关闭 AI 对话"
|
||
variant="ghost"
|
||
size="icon"
|
||
>
|
||
<X className="h-5 w-5" aria-hidden="true" />
|
||
</Button>
|
||
</header>
|
||
|
||
<div
|
||
role="log"
|
||
aria-live="polite"
|
||
aria-relevant="additions"
|
||
aria-label={summary}
|
||
className="flex-1 overflow-auto overscroll-contain px-4 py-4"
|
||
>
|
||
<DrawerBody
|
||
status={conversation.status}
|
||
chapterThreads={chapterThreads}
|
||
projectThreads={projectThreads}
|
||
onRetry={conversation.reload}
|
||
onAccept={accept}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="py-6 text-center">
|
||
<ThinkingIndicator label="加载中" className="text-sm text-ink-soft" />
|
||
</div>
|
||
);
|
||
}
|
||
if (status === "error") {
|
||
return (
|
||
<StatusNote variant="danger" title="加载失败">
|
||
<p>AI 对话暂不可用。</p>
|
||
<Button
|
||
onClick={onRetry}
|
||
variant="secondary"
|
||
size="sm"
|
||
className="mt-2"
|
||
>
|
||
重试
|
||
</Button>
|
||
</StatusNote>
|
||
);
|
||
}
|
||
if (chapterThreads.length === 0 && projectThreads.length === 0) {
|
||
return (
|
||
<EmptyState
|
||
icon={MessagesSquare}
|
||
title="还没有 AI 对话"
|
||
description="润色、整章重写、续写、工具箱生成的往复会自动留痕,重开也不丢。"
|
||
/>
|
||
);
|
||
}
|
||
return (
|
||
<div className="space-y-5">
|
||
<ThreadSection
|
||
heading="本章对话"
|
||
threads={chapterThreads}
|
||
onAccept={onAccept}
|
||
emptyText="本章还没有对话。"
|
||
/>
|
||
<ThreadSection
|
||
heading="项目级生成"
|
||
threads={projectThreads}
|
||
onAccept={onAccept}
|
||
emptyText="还没有项目级(工具箱)生成。"
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface ThreadSectionProps {
|
||
heading: string;
|
||
threads: AiThread[];
|
||
onAccept: (action: AcceptAction, m: AiMessageView) => void;
|
||
emptyText: string;
|
||
}
|
||
|
||
function ThreadSection({
|
||
heading,
|
||
threads,
|
||
onAccept,
|
||
emptyText,
|
||
}: ThreadSectionProps) {
|
||
return (
|
||
<section>
|
||
<h3 className="mb-2 text-2xs font-medium uppercase tracking-wide text-ink-soft">
|
||
{heading}
|
||
</h3>
|
||
{threads.length === 0 ? (
|
||
<p className="text-xs text-ink-soft">{emptyText}</p>
|
||
) : (
|
||
<ul className="space-y-3">
|
||
{threads.map((thread) => (
|
||
<li key={thread.threadId}>
|
||
<ThreadBlock thread={thread} onAccept={onAccept} />
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="rounded border border-line bg-bg/50 p-3">
|
||
<div className="mb-2 flex items-center gap-2">
|
||
<Badge variant={kindBadge(thread.kind)}>{kindLabel(thread.kind)}</Badge>
|
||
{thread.toolKey ? (
|
||
<span className="font-mono text-2xs text-ink-soft">
|
||
{thread.toolKey}
|
||
</span>
|
||
) : null}
|
||
<span className="ml-auto font-mono text-2xs text-ink-soft">
|
||
{formatBubbleTime(headAt)}
|
||
</span>
|
||
</div>
|
||
<ConversationThread
|
||
messages={thread.messages}
|
||
action={action}
|
||
onAccept={onAccept}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 气泡时间戳:本地 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" });
|
||
}
|