"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 }; }