Files
writer-work-flow/apps/web/components/workbench/AiConversationDrawer.tsx
Yaojia Wang 5af45f5034 refactor(frontend): 抽出可复用 ConversationThread + inlineConversation 纯助手
把 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)。
为内联对话视图铺路;抽屉行为完全不变。
2026-07-10 16:31:53 +02:00

285 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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 级 hookappend 早已可能触发过——已加载则空操作)。
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" });
}