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:
Yaojia Wang
2026-07-09 17:11:09 +02:00
parent dce882ca91
commit ddf7fa061a
6 changed files with 1017 additions and 0 deletions

View 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 态 + toastreload 可重试", 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、不 toastappend 只 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([]);
});
});