Files
writer-work-flow/apps/web/components/workbench/AiConversationDrawer.tsx
Yaojia Wang ddf7fa061a feat(frontend): AI 对话核心——视图模型 + 数据/追加 hook + 抽屉(AC-3)
- lib/workbench/aiConversation.ts:纯视图模型(kind 标签/徽标、bubbleSide、
  按 thread 归组 (created_at,seq) 定序、按作用域分区、接受动作映射)+ 单测。
- lib/workbench/useAiConversation.ts:Workbench 级懒加载 + 批量 append(乐观插入 +
  正位对账 / 失败回滚),fail-soft 绝不 throw 进生成/HITL;silent 直投供工具箱页。
  renderHook 测试覆盖懒加载去重、对账、回滚、silent、项目切换复位。
- components/workbench/AiConversationDrawer.tsx:本章对话 + 项目级生成两段的气泡抽屉,
  克隆 ContextDrawer 的 a11y(role=dialog/log、Esc、focus trap、还原焦点);
  ai 气泡挂 DRAFT-only 再接受按钮(替换整章/插入正文/回填选段),不绕开验收事务。
- lib/api/types.ts:re-export AiMessage* 码生成名。
2026-07-09 17:11:09 +02:00

361 lines
11 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 { cn, overlayScrim } from "@/lib/ui/variants";
import type { AiMessageView } from "@/lib/api/types";
import {
acceptActionFor,
bubbleSide,
groupByThread,
kindBadge,
kindLabel,
partitionByScope,
type AcceptAction,
type AiThread,
} from "@/lib/workbench/aiConversation";
import type { UseAiConversation } from "@/lib/workbench/useAiConversation";
// 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>
<div className="space-y-2">
{thread.messages.map((m) => (
<Bubble key={m.id} message={m} action={action} onAccept={onAccept} />
))}
</div>
</div>
);
}
interface BubbleProps {
message: AiMessageView;
action: AcceptAction | null;
onAccept: (action: AcceptAction, m: AiMessageView) => void;
}
// 单条气泡作者右cinnabar-wash/ AI 左border+bgclarify ai 气泡额外渲染只读方向选项。
function Bubble({ message, action, onAccept }: BubbleProps) {
const side = bubbleSide(message.role);
const options = message.kind === "clarify" ? readOptions(message.meta) : [];
// 只有 ai 产出气泡(左侧)挂再接受按钮;作者气泡与 clarify 不挂。
const showAccept = action !== null && side === "left" && message.kind !== "clarify";
return (
<div className={cn("flex", side === "right" ? "justify-end" : "justify-start")}>
<div
className={cn(
"max-w-[85%] rounded border px-3 py-2",
side === "right"
? "border-cinnabar/30 bg-[var(--color-cinnabar-wash)]"
: "border-line bg-bg",
)}
>
<p className="max-h-56 overflow-y-auto whitespace-pre-wrap text-sm text-ink">
{message.content}
</p>
{options.length > 0 ? (
<ul className="mt-2 flex flex-wrap gap-1.5">
{options.map((opt, i) => (
<li
key={i}
className="rounded border border-line bg-panel px-2 py-0.5 text-2xs text-ink-soft"
>
{opt}
</li>
))}
</ul>
) : null}
{showAccept && action ? (
<Button
onClick={() => onAccept(action, message)}
variant="secondary"
size="sm"
className="mt-2"
>
{ACCEPT_LABEL[action]}
</Button>
) : null}
</div>
</div>
);
}
const ACCEPT_LABEL: Record<AcceptAction, string> = {
replace_chapter: "接受这版(替换整章)",
append_draft: "插入正文(章末)",
refill_segment: "回填选段",
};
// meta.segmentrefine ai 行每版都存原选段)——供「回填选段」按内容重锚。
function readSegment(meta: AiMessageView["meta"]): string {
const v = (meta ?? {})["segment"];
return typeof v === "string" ? v : "";
}
// 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);
}
// 气泡时间戳:本地 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" });
}