- 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* 码生成名。
188 lines
6.7 KiB
TypeScript
188 lines
6.7 KiB
TypeScript
"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 };
|
||
}
|