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* 码生成名。
This commit is contained in:
Yaojia Wang
2026-07-09 17:11:09 +02:00
parent dce882ca91
commit ddf7fa061a
6 changed files with 1017 additions and 0 deletions

View File

@@ -0,0 +1,360 @@
"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" });
}