diff --git a/apps/web/components/workbench/AiConversationDrawer.tsx b/apps/web/components/workbench/AiConversationDrawer.tsx new file mode 100644 index 0000000..3573554 --- /dev/null +++ b/apps/web/components/workbench/AiConversationDrawer.tsx @@ -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; +} + +// 每章「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(null); + const closeRef = useRef(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 ( +
+
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" + > +
+ + + +
+ +
+ +
+
+
+ ); +} + +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 ( +
+ +
+ ); + } + if (status === "error") { + return ( + +

AI 对话暂不可用。

+ +
+ ); + } + if (chapterThreads.length === 0 && projectThreads.length === 0) { + return ( + + ); + } + return ( +
+ + +
+ ); +} + +interface ThreadSectionProps { + heading: string; + threads: AiThread[]; + onAccept: (action: AcceptAction, m: AiMessageView) => void; + emptyText: string; +} + +function ThreadSection({ + heading, + threads, + onAccept, + emptyText, +}: ThreadSectionProps) { + return ( +
+

+ {heading} +

+ {threads.length === 0 ? ( +

{emptyText}

+ ) : ( +
    + {threads.map((thread) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} + +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 ( +
+
+ {kindLabel(thread.kind)} + {thread.toolKey ? ( + + {thread.toolKey} + + ) : null} + + {formatBubbleTime(headAt)} + +
+
+ {thread.messages.map((m) => ( + + ))} +
+
+ ); +} + +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 ( +
+
+

+ {message.content} +

+ {options.length > 0 ? ( +
    + {options.map((opt, i) => ( +
  • + {opt} +
  • + ))} +
+ ) : null} + {showAccept && action ? ( + + ) : null} +
+
+ ); +} + +const ACCEPT_LABEL: Record = { + 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" }); +} diff --git a/apps/web/lib/api/types.ts b/apps/web/lib/api/types.ts index ed2806f..0751aad 100644 --- a/apps/web/lib/api/types.ts +++ b/apps/web/lib/api/types.ts @@ -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"]; diff --git a/apps/web/lib/workbench/aiConversation.test.ts b/apps/web/lib/workbench/aiConversation.test.ts new file mode 100644 index 0000000..37b6e1f --- /dev/null +++ b/apps/web/lib/workbench/aiConversation.test.ts @@ -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 { + 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(); + }); +}); diff --git a/apps/web/lib/workbench/aiConversation.ts b/apps/web/lib/workbench/aiConversation.ts new file mode 100644 index 0000000..6c0403a --- /dev/null +++ b/apps/web/lib/workbench/aiConversation.ts @@ -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 = { + refine: "润色", + rewrite: "整章重写", + clarify: "反问澄清", + continue: "续写", + generator: "工具箱", +}; + +export const KIND_BADGE: Record = { + 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(); + 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; + } +} diff --git a/apps/web/lib/workbench/useAiConversation.test.ts b/apps/web/lib/workbench/useAiConversation.test.ts new file mode 100644 index 0000000..f6ae069 --- /dev/null +++ b/apps/web/lib/workbench/useAiConversation.test.ts @@ -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 = {}) { + 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([]); + }); +}); diff --git a/apps/web/lib/workbench/useAiConversation.ts b/apps/web/lib/workbench/useAiConversation.ts new file mode 100644 index 0000000..0e7924d --- /dev/null +++ b/apps/web/lib/workbench/useAiConversation.ts @@ -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; +} + +// 一次已结束交换的一批 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; +} + +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("idle"); + const [messages, setMessages] = useState([]); + // 请求哪个 (project:chapter) 的记录;按 key 触发(而非布尔)——项目切换时残留请求不会误发到新项目。 + const [loadKey, setLoadKey] = useState(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( + 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 }; +}