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,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>): 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();
});
});