Files
writer-work-flow/apps/web/lib/workbench/useAiConversation.ts
Yaojia Wang ddf7fa061a 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* 码生成名。
2026-07-09 17:11:09 +02:00

188 lines
6.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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";
// 一条待落库 bubblecamelCase 入参,映射到 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 };
}