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:
360
apps/web/components/workbench/AiConversationDrawer.tsx
Normal file
360
apps/web/components/workbench/AiConversationDrawer.tsx
Normal 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 级 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>
|
||||
<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+bg);clarify 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.segment(refine 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" });
|
||||
}
|
||||
@@ -125,3 +125,11 @@ export type OutlineSceneIngestView =
|
||||
// F1:拆书入库产物(themes/archetypes/structure/hooks → 项目 rules 条目)。
|
||||
export type TeardownIngestView =
|
||||
components["schemas"]["TeardownIngestView"];
|
||||
|
||||
// AC-3 AI 对话(聊天记录):作者↔AI 五类往复留痕的 append/list 契约(AC-2 C3 扩,snake_case)。
|
||||
export type AiMessageView = components["schemas"]["AiMessageView"];
|
||||
export type AiMessageInput = components["schemas"]["AiMessageInput"];
|
||||
export type AiMessageAppendRequest =
|
||||
components["schemas"]["AiMessageAppendRequest"];
|
||||
export type AiMessageListResponse =
|
||||
components["schemas"]["AiMessageListResponse"];
|
||||
|
||||
135
apps/web/lib/workbench/aiConversation.test.ts
Normal file
135
apps/web/lib/workbench/aiConversation.test.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { AiMessageView } from "@/lib/api/types";
|
||||
import {
|
||||
acceptActionFor,
|
||||
bubbleSide,
|
||||
groupByThread,
|
||||
KIND_LABEL,
|
||||
kindLabel,
|
||||
partitionByScope,
|
||||
} from "./aiConversation";
|
||||
|
||||
// AiMessageView 工厂:只填测试关心的字段,其余给稳定默认。
|
||||
function view(partial: Partial<AiMessageView>): AiMessageView {
|
||||
return {
|
||||
id: partial.id ?? crypto.randomUUID(),
|
||||
project_id: "p1",
|
||||
chapter_no: partial.chapter_no ?? null,
|
||||
thread_id: partial.thread_id ?? "t1",
|
||||
seq: partial.seq ?? 0,
|
||||
kind: partial.kind ?? "refine",
|
||||
tool_key: partial.tool_key ?? null,
|
||||
role: partial.role ?? "ai",
|
||||
content: partial.content ?? "",
|
||||
meta: partial.meta ?? {},
|
||||
created_at: partial.created_at ?? "2026-07-09T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("kindLabel / KIND_LABEL", () => {
|
||||
it("五类 kind 各有中文标签", () => {
|
||||
expect(KIND_LABEL.refine).toBe("润色");
|
||||
expect(KIND_LABEL.rewrite).toBe("整章重写");
|
||||
expect(KIND_LABEL.clarify).toBe("反问澄清");
|
||||
expect(KIND_LABEL.continue).toBe("续写");
|
||||
expect(KIND_LABEL.generator).toBe("工具箱");
|
||||
});
|
||||
|
||||
it("未知 kind 回退为原始串(不崩)", () => {
|
||||
expect(kindLabel("mystery")).toBe("mystery");
|
||||
expect(kindLabel("refine")).toBe("润色");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bubbleSide", () => {
|
||||
it("author 靠右、ai 靠左", () => {
|
||||
expect(bubbleSide("author")).toBe("right");
|
||||
expect(bubbleSide("ai")).toBe("left");
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupByThread", () => {
|
||||
it("按 thread_id 归组,返回每组 kind/toolKey/chapterNo", () => {
|
||||
const msgs = [
|
||||
view({ thread_id: "a", kind: "refine", chapter_no: 1, seq: 0 }),
|
||||
view({ thread_id: "b", kind: "continue", tool_key: "continue", chapter_no: 1, seq: 0 }),
|
||||
view({ thread_id: "a", kind: "refine", chapter_no: 1, seq: 1 }),
|
||||
];
|
||||
const threads = groupByThread(msgs);
|
||||
const a = threads.find((t) => t.threadId === "a");
|
||||
expect(a?.messages).toHaveLength(2);
|
||||
expect(a?.kind).toBe("refine");
|
||||
const b = threads.find((t) => t.threadId === "b");
|
||||
expect(b?.toolKey).toBe("continue");
|
||||
expect(b?.chapterNo).toBe(1);
|
||||
});
|
||||
|
||||
it("同一批次 created_at 相同时,按 seq 升序排组内(不靠 created_at 单独定序)", () => {
|
||||
// 故意乱序传入;同 created_at,靠 seq 稳定定序。
|
||||
const msgs = [
|
||||
view({ thread_id: "a", seq: 2, content: "c2", created_at: "2026-07-09T00:00:00Z" }),
|
||||
view({ thread_id: "a", seq: 0, content: "c0", created_at: "2026-07-09T00:00:00Z" }),
|
||||
view({ thread_id: "a", seq: 1, content: "c1", created_at: "2026-07-09T00:00:00Z" }),
|
||||
];
|
||||
const thread = groupByThread(msgs)[0]!;
|
||||
expect(thread.messages.map((m) => m.content)).toEqual(["c0", "c1", "c2"]);
|
||||
});
|
||||
|
||||
it("跨批次靠 created_at 定序,组内新批 append 在后", () => {
|
||||
const msgs = [
|
||||
view({ thread_id: "a", seq: 0, content: "old", created_at: "2026-07-09T00:00:00Z" }),
|
||||
view({ thread_id: "a", seq: 0, content: "new", created_at: "2026-07-09T00:05:00Z" }),
|
||||
];
|
||||
const thread = groupByThread(msgs)[0]!;
|
||||
expect(thread.messages.map((m) => m.content)).toEqual(["old", "new"]);
|
||||
});
|
||||
|
||||
it("线程按各自首条 (created_at, seq) 升序(老线程在前)", () => {
|
||||
const msgs = [
|
||||
view({ thread_id: "late", seq: 0, created_at: "2026-07-09T01:00:00Z" }),
|
||||
view({ thread_id: "early", seq: 0, created_at: "2026-07-09T00:00:00Z" }),
|
||||
];
|
||||
const threads = groupByThread(msgs);
|
||||
expect(threads.map((t) => t.threadId)).toEqual(["early", "late"]);
|
||||
});
|
||||
|
||||
it("空输入 → 空数组", () => {
|
||||
expect(groupByThread([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("partitionByScope", () => {
|
||||
it("本章线程与项目级(chapter_no=null)线程分区", () => {
|
||||
const msgs = [
|
||||
view({ thread_id: "chap", chapter_no: 3, seq: 0 }),
|
||||
view({ thread_id: "proj", chapter_no: null, kind: "generator", seq: 0 }),
|
||||
];
|
||||
const threads = groupByThread(msgs);
|
||||
const { chapterThreads, projectThreads } = partitionByScope(threads, 3);
|
||||
expect(chapterThreads.map((t) => t.threadId)).toEqual(["chap"]);
|
||||
expect(projectThreads.map((t) => t.threadId)).toEqual(["proj"]);
|
||||
});
|
||||
|
||||
it("非本章号的章线程不落入本章分区(联合查询兜底)", () => {
|
||||
const threads = groupByThread([
|
||||
view({ thread_id: "other", chapter_no: 9, seq: 0 }),
|
||||
]);
|
||||
const { chapterThreads, projectThreads } = partitionByScope(threads, 3);
|
||||
expect(chapterThreads).toHaveLength(0);
|
||||
expect(projectThreads).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("acceptActionFor", () => {
|
||||
it("按 kind 映射接受动作;clarify/未知无动作", () => {
|
||||
const mk = (kind: string) =>
|
||||
groupByThread([view({ thread_id: kind, kind, seq: 0 })])[0]!;
|
||||
expect(acceptActionFor(mk("rewrite"))).toBe("replace_chapter");
|
||||
expect(acceptActionFor(mk("continue"))).toBe("append_draft");
|
||||
expect(acceptActionFor(mk("generator"))).toBe("append_draft");
|
||||
expect(acceptActionFor(mk("refine"))).toBe("refill_segment");
|
||||
expect(acceptActionFor(mk("clarify"))).toBeNull();
|
||||
expect(acceptActionFor(mk("mystery"))).toBeNull();
|
||||
});
|
||||
});
|
||||
139
apps/web/lib/workbench/aiConversation.ts
Normal file
139
apps/web/lib/workbench/aiConversation.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
// AI 对话(聊天记录)纯视图模型:把 append-only 的 AiMessageView 扁平流归组成可渲染的
|
||||
// 线程/分区结构 + kind 标签/徽标/气泡方位映射。无 React/IO,确定性纯函数,供抽屉与单测共用。
|
||||
// 排序纪律(守 AC 契约):组内与线程都按 (created_at ASC, seq ASC)——绝不单靠 created_at
|
||||
// (同一 append 批次共享 created_at,靠 repo 赋的 seq 消歧,这正是后端加 seq 的原因)。
|
||||
|
||||
import type { AiMessageView } from "@/lib/api/types";
|
||||
import type { BadgeVariant } from "@/lib/ui/variants";
|
||||
|
||||
// v1 记录的五类交互(与后端 AiKind Literal 对齐)。
|
||||
export type AiKind = "refine" | "rewrite" | "clarify" | "continue" | "generator";
|
||||
|
||||
// 抽屉里对某条 ai 产出可执行的接受动作(复用编辑器既有 DRAFT-only HITL 回调,不绕开验收事务)。
|
||||
export type AcceptAction = "replace_chapter" | "append_draft" | "refill_segment";
|
||||
|
||||
export const KIND_LABEL: Record<AiKind, string> = {
|
||||
refine: "润色",
|
||||
rewrite: "整章重写",
|
||||
clarify: "反问澄清",
|
||||
continue: "续写",
|
||||
generator: "工具箱",
|
||||
};
|
||||
|
||||
export const KIND_BADGE: Record<AiKind, BadgeVariant> = {
|
||||
refine: "accent",
|
||||
rewrite: "warning",
|
||||
clarify: "info",
|
||||
continue: "success",
|
||||
generator: "neutral",
|
||||
};
|
||||
|
||||
// kind → 中文标签;未知 kind(后端可零迁移新增)回退原始串,永不崩。
|
||||
export function kindLabel(kind: string): string {
|
||||
return KIND_LABEL[kind as AiKind] ?? kind;
|
||||
}
|
||||
|
||||
// kind → 徽标变体;未知 kind 回退中性徽标。
|
||||
export function kindBadge(kind: string): BadgeVariant {
|
||||
return KIND_BADGE[kind as AiKind] ?? "neutral";
|
||||
}
|
||||
|
||||
// 气泡方位:作者靠右、AI 靠左(对话视图约定)。
|
||||
export function bubbleSide(role: string): "left" | "right" {
|
||||
return role === "author" ? "right" : "left";
|
||||
}
|
||||
|
||||
// 一次往复交换(同 thread_id 归组),消息按 (created_at, seq) 升序。
|
||||
export interface AiThread {
|
||||
threadId: string;
|
||||
kind: string;
|
||||
toolKey: string | null;
|
||||
chapterNo: number | null;
|
||||
messages: AiMessageView[];
|
||||
}
|
||||
|
||||
// created_at → 毫秒时间戳(兼容 Z / +00:00 两种序列化;不可解析回退 0)。
|
||||
function timeOf(iso: string): number {
|
||||
const t = Date.parse(iso);
|
||||
return Number.isNaN(t) ? 0 : t;
|
||||
}
|
||||
|
||||
// 稳定定序比较器:先 created_at,再 seq(跨批 created_at 定序、批内 seq 消歧)。
|
||||
function byTimeThenSeq(a: AiMessageView, b: AiMessageView): number {
|
||||
const dt = timeOf(a.created_at) - timeOf(b.created_at);
|
||||
return dt !== 0 ? dt : a.seq - b.seq;
|
||||
}
|
||||
|
||||
// 扁平消息流 → 线程数组。组内按 (created_at, seq) 升序;线程按各自首条 (created_at, seq) 升序
|
||||
// (老线程在前,新往复追加在后——契合聊天视图 + aria additions 在末尾播报新增)。
|
||||
export function groupByThread(messages: readonly AiMessageView[]): AiThread[] {
|
||||
const groups = new Map<string, AiMessageView[]>();
|
||||
for (const msg of messages) {
|
||||
const bucket = groups.get(msg.thread_id);
|
||||
if (bucket) {
|
||||
bucket.push(msg);
|
||||
} else {
|
||||
groups.set(msg.thread_id, [msg]);
|
||||
}
|
||||
}
|
||||
|
||||
const threads: AiThread[] = [];
|
||||
for (const [threadId, rows] of groups) {
|
||||
const sorted = [...rows].sort(byTimeThenSeq);
|
||||
const head = sorted[0];
|
||||
if (!head) continue; // 分组桶恒非空;仅为类型收窄。
|
||||
threads.push({
|
||||
threadId,
|
||||
kind: head.kind,
|
||||
toolKey: head.tool_key,
|
||||
chapterNo: head.chapter_no,
|
||||
messages: sorted,
|
||||
});
|
||||
}
|
||||
|
||||
// 线程按首条消息定序(复用同一比较器,取每组已排序的 messages[0])。
|
||||
return threads.sort((a, b) => {
|
||||
const ha = a.messages[0];
|
||||
const hb = b.messages[0];
|
||||
if (!ha || !hb) return 0;
|
||||
return byTimeThenSeq(ha, hb);
|
||||
});
|
||||
}
|
||||
|
||||
// 线程按作用域分区:本章线程(chapterNo === 当前章)与项目级线程(chapterNo == null,工具箱)。
|
||||
export interface ScopePartition {
|
||||
chapterThreads: AiThread[];
|
||||
projectThreads: AiThread[];
|
||||
}
|
||||
|
||||
export function partitionByScope(
|
||||
threads: readonly AiThread[],
|
||||
currentChapterNo: number,
|
||||
): ScopePartition {
|
||||
const chapterThreads: AiThread[] = [];
|
||||
const projectThreads: AiThread[] = [];
|
||||
for (const thread of threads) {
|
||||
if (thread.chapterNo === currentChapterNo) {
|
||||
chapterThreads.push(thread);
|
||||
} else if (thread.chapterNo === null) {
|
||||
projectThreads.push(thread);
|
||||
}
|
||||
// 其它章号(联合查询理论上不返回):既不归本章也不归项目级,静默丢弃。
|
||||
}
|
||||
return { chapterThreads, projectThreads };
|
||||
}
|
||||
|
||||
// 线程 kind → 抽屉里对 ai 气泡可执行的接受动作;clarify / 未知 kind 无接受动作。
|
||||
export function acceptActionFor(thread: AiThread): AcceptAction | null {
|
||||
switch (thread.kind) {
|
||||
case "rewrite":
|
||||
return "replace_chapter";
|
||||
case "continue":
|
||||
case "generator":
|
||||
return "append_draft";
|
||||
case "refine":
|
||||
return "refill_segment";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
188
apps/web/lib/workbench/useAiConversation.test.ts
Normal file
188
apps/web/lib/workbench/useAiConversation.test.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useAiConversation } from "./useAiConversation";
|
||||
|
||||
// 后端客户端与 Toast 是 hook 的外部副作用边界,单测一律 mock。
|
||||
const get = vi.fn();
|
||||
const post = vi.fn();
|
||||
const toast = vi.fn();
|
||||
vi.mock("@/lib/api/client", () => ({
|
||||
api: {
|
||||
GET: (...a: unknown[]) => get(...a),
|
||||
POST: (...a: unknown[]) => post(...a),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
|
||||
|
||||
const LIST_PATH = "/projects/{project_id}/ai-messages";
|
||||
|
||||
function serverRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: overrides.id ?? crypto.randomUUID(),
|
||||
project_id: "p1",
|
||||
chapter_no: 1,
|
||||
thread_id: "t1",
|
||||
seq: 0,
|
||||
kind: "refine",
|
||||
tool_key: null,
|
||||
role: "ai",
|
||||
content: "server",
|
||||
meta: {},
|
||||
created_at: "2026-07-09T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useAiConversation", () => {
|
||||
beforeEach(() => {
|
||||
get.mockReset();
|
||||
post.mockReset();
|
||||
toast.mockReset();
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it("初始 idle,未触发前不发 GET", () => {
|
||||
const { result } = renderHook(() => useAiConversation("p1", 1));
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
expect(result.current.status).toBe("idle");
|
||||
expect(result.current.messages).toEqual([]);
|
||||
});
|
||||
|
||||
it("ensureLoaded 懒加载一次,缓存不重复拉取", async () => {
|
||||
get.mockResolvedValue({ data: { messages: [serverRow()] }, error: null });
|
||||
const { result } = renderHook(() => useAiConversation("p1", 1));
|
||||
act(() => result.current.ensureLoaded());
|
||||
await waitFor(() => expect(result.current.status).toBe("ready"));
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
act(() => result.current.ensureLoaded());
|
||||
expect(get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("GET 传 chapter_no 过滤(本章 ∪ 项目级)", async () => {
|
||||
get.mockResolvedValue({ data: { messages: [] }, error: null });
|
||||
const { result } = renderHook(() => useAiConversation("p1", 5));
|
||||
act(() => result.current.ensureLoaded());
|
||||
await waitFor(() => expect(result.current.status).toBe("ready"));
|
||||
expect(get).toHaveBeenCalledWith(
|
||||
LIST_PATH,
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
path: { project_id: "p1" },
|
||||
query: { chapter_no: 5 },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("加载失败 → error 态 + toast,reload 可重试", async () => {
|
||||
get.mockResolvedValueOnce({ data: null, error: { detail: "boom" } });
|
||||
const { result } = renderHook(() => useAiConversation("p1", 1));
|
||||
act(() => result.current.ensureLoaded());
|
||||
await waitFor(() => expect(result.current.status).toBe("error"));
|
||||
expect(toast).toHaveBeenCalledWith(expect.any(String), "error");
|
||||
|
||||
get.mockResolvedValueOnce({ data: { messages: [serverRow()] }, error: null });
|
||||
act(() => result.current.reload());
|
||||
await waitFor(() => expect(result.current.status).toBe("ready"));
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("appendMessages:乐观插入 → 成功后按服务器行正位对账", async () => {
|
||||
get.mockResolvedValue({ data: { messages: [] }, error: null });
|
||||
const persisted = serverRow({ id: "srv-1", content: "refined", role: "ai" });
|
||||
post.mockResolvedValue({ data: { messages: [persisted] }, error: null });
|
||||
const { result } = renderHook(() => useAiConversation("p1", 1));
|
||||
act(() => result.current.ensureLoaded());
|
||||
await waitFor(() => expect(result.current.status).toBe("ready"));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.appendMessages({
|
||||
threadId: "t1",
|
||||
kind: "refine",
|
||||
chapterNo: 1,
|
||||
messages: [{ role: "ai", content: "refined" }],
|
||||
});
|
||||
});
|
||||
|
||||
expect(post).toHaveBeenCalledTimes(1);
|
||||
// 临时行被服务器行替换(对账后只剩真实 id)。
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0]?.id).toBe("srv-1");
|
||||
});
|
||||
|
||||
it("appendMessages 失败 → 回滚乐观行 + toast,绝不 throw", async () => {
|
||||
get.mockResolvedValue({ data: { messages: [] }, error: null });
|
||||
post.mockResolvedValue({ data: null, error: { detail: "nope" } });
|
||||
const { result } = renderHook(() => useAiConversation("p1", 1));
|
||||
act(() => result.current.ensureLoaded());
|
||||
await waitFor(() => expect(result.current.status).toBe("ready"));
|
||||
|
||||
await act(async () => {
|
||||
await expect(
|
||||
result.current.appendMessages({
|
||||
threadId: "t1",
|
||||
kind: "refine",
|
||||
chapterNo: 1,
|
||||
messages: [{ role: "ai", content: "x" }],
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
expect(result.current.messages).toHaveLength(0);
|
||||
expect(toast).toHaveBeenCalledWith(expect.any(String), "error");
|
||||
});
|
||||
|
||||
it("silent 模式:不 GET、不 toast,append 只 fire-and-forget POST", async () => {
|
||||
post.mockResolvedValue({ data: { messages: [serverRow()] }, error: null });
|
||||
const { result } = renderHook(() =>
|
||||
useAiConversation("p1", 0, { silent: true }),
|
||||
);
|
||||
act(() => result.current.ensureLoaded());
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
expect(result.current.status).toBe("idle");
|
||||
|
||||
await act(async () => {
|
||||
await result.current.appendMessages({
|
||||
threadId: "t9",
|
||||
kind: "generator",
|
||||
toolKey: "book-title",
|
||||
chapterNo: null,
|
||||
messages: [{ role: "author", content: "brief" }],
|
||||
});
|
||||
});
|
||||
expect(post).toHaveBeenCalledTimes(1);
|
||||
expect(toast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("silent 模式 append 失败也不 toast、不 throw", async () => {
|
||||
post.mockResolvedValue({ data: null, error: { detail: "x" } });
|
||||
const { result } = renderHook(() =>
|
||||
useAiConversation("p1", 0, { silent: true }),
|
||||
);
|
||||
await act(async () => {
|
||||
await result.current.appendMessages({
|
||||
threadId: "t9",
|
||||
kind: "generator",
|
||||
chapterNo: null,
|
||||
messages: [{ role: "author", content: "brief" }],
|
||||
});
|
||||
});
|
||||
expect(toast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("切换项目 → 清缓存并复位(重新可加载)", async () => {
|
||||
get.mockResolvedValue({ data: { messages: [serverRow()] }, error: null });
|
||||
const { result, rerender } = renderHook(
|
||||
({ pid }: { pid: string }) => useAiConversation(pid, 1),
|
||||
{ initialProps: { pid: "p1" } },
|
||||
);
|
||||
act(() => result.current.ensureLoaded());
|
||||
await waitFor(() => expect(result.current.status).toBe("ready"));
|
||||
|
||||
rerender({ pid: "p2" });
|
||||
expect(result.current.status).toBe("idle");
|
||||
expect(result.current.messages).toEqual([]);
|
||||
});
|
||||
});
|
||||
187
apps/web/lib/workbench/useAiConversation.ts
Normal file
187
apps/web/lib/workbench/useAiConversation.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import { useToast } from "@/components/Toast";
|
||||
import type { AiMessageView } from "@/lib/api/types";
|
||||
import type { AiKind } from "./aiConversation";
|
||||
|
||||
// AI 对话数据层(Workbench 级单实例):懒加载本章 ∪ 项目级留痕 + 批量 append(乐观 + 正位对账)。
|
||||
// 记录写入是 fail-soft 副作用——append 失败绝不 throw 进调用方、不阻塞生成/HITL(守不变量 #3 意图),
|
||||
// 至多弹一次 toast。仿 useContextDrawerData:单飞哨兵 + reload nonce + 项目/章切换复位。
|
||||
|
||||
export type AiConversationStatus = "idle" | "loading" | "ready" | "error";
|
||||
|
||||
// 一条待落库 bubble(camelCase 入参,映射到 snake_case AiMessageInput)。
|
||||
export interface AiTurnMessage {
|
||||
role: "author" | "ai";
|
||||
content: string;
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// 一次已结束交换的一批 bubble(同 threadId 归组;批级字段提顶层,与后端 AppendRequest 对齐)。
|
||||
export interface AiTurnInput {
|
||||
threadId: string;
|
||||
kind: AiKind;
|
||||
toolKey?: string | null;
|
||||
chapterNo: number | null;
|
||||
messages: AiTurnMessage[];
|
||||
}
|
||||
|
||||
export interface UseAiConversationOptions {
|
||||
// ToolboxPage 无抽屉:静默直投——不 GET、不 toast、append fire-and-forget。
|
||||
silent?: boolean;
|
||||
}
|
||||
|
||||
export interface UseAiConversation {
|
||||
status: AiConversationStatus;
|
||||
messages: AiMessageView[];
|
||||
// 首次抽屉打开 / 首次 append 触发懒加载;已加载或在途则空操作。silent 下为空操作。
|
||||
ensureLoaded: () => void;
|
||||
// 出错后重试拉取。
|
||||
reload: () => void;
|
||||
// 追加一批往复;乐观插入 + 成功正位对账 / 失败回滚。绝不 throw。
|
||||
appendMessages: (turn: AiTurnInput) => Promise<void>;
|
||||
}
|
||||
|
||||
const LIST_PATH = "/projects/{project_id}/ai-messages" as const;
|
||||
|
||||
// 按 id 并集去重(base 在前,extra 仅补 base 未含的 id)——防加载/追加并发时行重复或被覆盖。
|
||||
function mergeById(
|
||||
base: readonly AiMessageView[],
|
||||
extra: readonly AiMessageView[],
|
||||
): AiMessageView[] {
|
||||
const ids = new Set(base.map((m) => m.id));
|
||||
return [...base, ...extra.filter((m) => !ids.has(m.id))];
|
||||
}
|
||||
|
||||
export function useAiConversation(
|
||||
projectId: string,
|
||||
chapterNo: number,
|
||||
options: UseAiConversationOptions = {},
|
||||
): UseAiConversation {
|
||||
const silent = options.silent ?? false;
|
||||
const currentKey = `${projectId}:${chapterNo}`;
|
||||
const [status, setStatus] = useState<AiConversationStatus>("idle");
|
||||
const [messages, setMessages] = useState<AiMessageView[]>([]);
|
||||
// 请求哪个 (project:chapter) 的记录;按 key 触发(而非布尔)——项目切换时残留请求不会误发到新项目。
|
||||
const [loadKey, setLoadKey] = useState<string | null>(null);
|
||||
const [reloadNonce, setReloadNonce] = useState(0);
|
||||
// 单飞哨兵:已发起过 GET 就不再重复(成功缓存 / 在途去重);出错时复位以允许重试。
|
||||
const reqRef = useRef(false);
|
||||
const toast = useToast();
|
||||
|
||||
// 项目/章切换 → 清缓存 + 复位(先于加载 effect 声明,保证同次提交先复位)。
|
||||
useEffect(() => {
|
||||
reqRef.current = false;
|
||||
setMessages([]);
|
||||
setStatus("idle");
|
||||
setLoadKey(null);
|
||||
}, [projectId, chapterNo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (silent || loadKey !== currentKey || reqRef.current) return;
|
||||
reqRef.current = true;
|
||||
let cancelled = false;
|
||||
setStatus("loading");
|
||||
void (async () => {
|
||||
try {
|
||||
const { data, error } = await api.GET(LIST_PATH, {
|
||||
params: { path: { project_id: projectId }, query: { chapter_no: chapterNo } },
|
||||
});
|
||||
if (cancelled) return;
|
||||
if (error || !data) {
|
||||
reqRef.current = false;
|
||||
setStatus("error");
|
||||
toast("AI 对话加载失败,请重试。", "error");
|
||||
return;
|
||||
}
|
||||
// 合并而非覆盖:不吞掉加载在途时已成功 append 的行。
|
||||
setMessages((prev) => mergeById(data.messages, prev));
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
if (cancelled) return;
|
||||
reqRef.current = false;
|
||||
setStatus("error");
|
||||
toast("AI 对话加载异常,请检查网络。", "error");
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId, chapterNo, currentKey, loadKey, reloadNonce, silent, toast]);
|
||||
|
||||
const ensureLoaded = useCallback((): void => {
|
||||
if (silent) return;
|
||||
setLoadKey(currentKey);
|
||||
}, [silent, currentKey]);
|
||||
|
||||
const reload = useCallback((): void => {
|
||||
reqRef.current = false;
|
||||
setStatus("idle");
|
||||
setLoadKey(currentKey);
|
||||
setReloadNonce((n) => n + 1);
|
||||
}, [currentKey]);
|
||||
|
||||
const appendMessages = useCallback<UseAiConversation["appendMessages"]>(
|
||||
async (turn) => {
|
||||
ensureLoaded();
|
||||
const now = new Date().toISOString();
|
||||
// 乐观临时行(temp id + 本地 seq);引用留存以便成功对账 / 失败回滚。
|
||||
const optimistic: AiMessageView[] = turn.messages.map((m, i) => ({
|
||||
id: `temp-${crypto.randomUUID()}`,
|
||||
project_id: projectId,
|
||||
chapter_no: turn.chapterNo,
|
||||
thread_id: turn.threadId,
|
||||
seq: i,
|
||||
kind: turn.kind,
|
||||
tool_key: turn.toolKey ?? null,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
meta: m.meta ?? {},
|
||||
created_at: now,
|
||||
}));
|
||||
if (!silent) {
|
||||
setMessages((prev) => [...prev, ...optimistic]);
|
||||
}
|
||||
const dropOptimistic = (prev: AiMessageView[]): AiMessageView[] =>
|
||||
prev.filter((m) => !optimistic.includes(m));
|
||||
try {
|
||||
const { data, error } = await api.POST(LIST_PATH, {
|
||||
params: { path: { project_id: projectId } },
|
||||
body: {
|
||||
thread_id: turn.threadId,
|
||||
chapter_no: turn.chapterNo,
|
||||
kind: turn.kind,
|
||||
tool_key: turn.toolKey ?? null,
|
||||
messages: turn.messages.map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
meta: m.meta ?? {},
|
||||
})),
|
||||
},
|
||||
});
|
||||
if (error || !data) {
|
||||
if (!silent) {
|
||||
setMessages(dropOptimistic);
|
||||
toast("对话记录保存失败(不影响本次生成)。", "error");
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 正位对账:移除临时行,按服务器返回行去重合并(response 不回显 temp id,靠位置替换)。
|
||||
if (!silent) {
|
||||
setMessages((prev) => mergeById(dropOptimistic(prev), data.messages));
|
||||
}
|
||||
} catch {
|
||||
if (!silent) {
|
||||
setMessages(dropOptimistic);
|
||||
toast("对话记录保存异常(不影响本次生成)。", "error");
|
||||
}
|
||||
}
|
||||
},
|
||||
[ensureLoaded, projectId, silent, toast],
|
||||
);
|
||||
|
||||
return { status, messages, ensureLoaded, reload, appendMessages };
|
||||
}
|
||||
Reference in New Issue
Block a user