feat(frontend): 五类交互接入 AI 对话留痕 + Workbench 抽屉入口(AC-3)
- RefinePanel/ChapterRewritePanel:结束后 append [author(意见/答复), ai(产出)];
clarify buffer-until-concluded——澄清 Q&A(原意见→反问→答复)缓冲,随最终产出批
一起落库同 thread;纯放弃不记。refine ai 行 meta 存 version_no+原选段(每版重锚)。
- ContinuePanel:ai-only(续写无作者输入),每候选一条 ai 气泡 meta{candidate_index}。
- GeneratorRunner:成功后 append [author(字段摘要, meta{tool_key,input_fields}),
ai(渲染预览, meta{tool_key,output_kind})];空产物不记。lib/toolbox/aiSummary.ts 纯助手+单测。
- InlineToolbox→GeneratorRunner 透传 append+本章号;ToolboxPage 用 silent 直投项目级(chapter_no=null)。
- Workbench:挂 useAiConversation 单实例 + AiConversationDrawer + 底栏「AI 对话」入口(flag 灰度),
三个 DRAFT-only 再接受回调(替换整章/插入正文/回填选段),成功关抽屉+toast、漂移不盲替换。
This commit is contained in:
51
apps/web/lib/toolbox/aiSummary.test.ts
Normal file
51
apps/web/lib/toolbox/aiSummary.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ToolInputFieldView } from "@/lib/api/types";
|
||||
import type { PreviewModel } from "./toolbox";
|
||||
import { fieldSummary, renderPreviewText } from "./aiSummary";
|
||||
|
||||
const fields: ToolInputFieldView[] = [
|
||||
{ name: "brief", label: "脑洞", type: "textarea", required: true, help: null },
|
||||
{ name: "count", label: "数量", type: "number", required: false, help: null },
|
||||
];
|
||||
|
||||
describe("fieldSummary", () => {
|
||||
it("拼出「label:value」摘要,跳过空值", () => {
|
||||
const summary = fieldSummary(fields, { brief: "废柴逆袭", count: "" });
|
||||
expect(summary).toBe("脑洞:废柴逆袭");
|
||||
});
|
||||
|
||||
it("多字段用 · 连接", () => {
|
||||
const summary = fieldSummary(fields, { brief: "扮猪吃虎", count: "3" });
|
||||
expect(summary).toBe("脑洞:扮猪吃虎 · 数量:3");
|
||||
});
|
||||
|
||||
it("字段为空 / 无值 → 空串", () => {
|
||||
expect(fieldSummary([], {})).toBe("");
|
||||
expect(fieldSummary(fields, {})).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderPreviewText", () => {
|
||||
it("把预览项渲染成人读文本(heading + 字段 + 正文)", () => {
|
||||
const preview: PreviewModel = {
|
||||
kind: "IdeaListResult",
|
||||
items: [
|
||||
{
|
||||
heading: "废柴逆袭",
|
||||
fields: [{ label: "钩子", value: "扮猪吃虎" }],
|
||||
body: null,
|
||||
},
|
||||
{ heading: null, fields: [], body: "一段开篇正文。" },
|
||||
],
|
||||
};
|
||||
const text = renderPreviewText(preview);
|
||||
expect(text).toContain("废柴逆袭");
|
||||
expect(text).toContain("钩子:扮猪吃虎");
|
||||
expect(text).toContain("一段开篇正文。");
|
||||
});
|
||||
|
||||
it("空预览 → 空串", () => {
|
||||
expect(renderPreviewText({ kind: "x", items: [] })).toBe("");
|
||||
});
|
||||
});
|
||||
33
apps/web/lib/toolbox/aiSummary.ts
Normal file
33
apps/web/lib/toolbox/aiSummary.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// 工具箱生成留痕的两个纯格式化助手(AI 对话记录用):
|
||||
// - fieldSummary:把作者填写的输入字段拼成人读摘要(author 气泡 content;长文进 content 不进 meta)。
|
||||
// - renderPreviewText:把结构化预览渲染成人读文本(ai 气泡 content;不把原始产物塞进 meta)。
|
||||
// 确定性纯函数,无 IO;供 GeneratorRunner 结束后 append 一批往复。
|
||||
|
||||
import type { ToolInputFieldView } from "@/lib/api/types";
|
||||
import type { FieldValues, PreviewModel } from "./toolbox";
|
||||
|
||||
// 输入字段 → 「label:value」摘要,跳过空值,多字段用 · 连接。
|
||||
export function fieldSummary(
|
||||
fields: readonly ToolInputFieldView[],
|
||||
values: FieldValues,
|
||||
): string {
|
||||
return fields
|
||||
.map((f) => ({ label: f.label, value: (values[f.name] ?? "").trim() }))
|
||||
.filter((entry) => entry.value.length > 0)
|
||||
.map((entry) => `${entry.label}:${entry.value}`)
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
// 预览模型 → 人读文本:每项 = heading + 「label:value」次要字段 + 正文段,项间空行分隔。
|
||||
export function renderPreviewText(preview: PreviewModel): string {
|
||||
return preview.items
|
||||
.map((item) => {
|
||||
const lines: string[] = [];
|
||||
if (item.heading) lines.push(item.heading);
|
||||
for (const f of item.fields) lines.push(`${f.label}:${f.value}`);
|
||||
if (item.body) lines.push(item.body);
|
||||
return lines.join("\n");
|
||||
})
|
||||
.filter((block) => block.length > 0)
|
||||
.join("\n\n");
|
||||
}
|
||||
@@ -55,7 +55,7 @@ describe("useGenerator", () => {
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useGenerator());
|
||||
let returned: { outputKind: string; rawPreview: unknown } | null = null;
|
||||
let returned: unknown;
|
||||
await act(async () => {
|
||||
returned = await result.current.generate("p1", "idea", { brief: "脑洞" });
|
||||
});
|
||||
@@ -67,8 +67,9 @@ describe("useGenerator", () => {
|
||||
expect(result.current.preview?.items[0]?.heading).toBe("废柴逆袭");
|
||||
expect(toast).not.toHaveBeenCalled();
|
||||
// 追加断言:generate 返回本次产物(供 GeneratorRunner 结束后留痕)。
|
||||
expect(returned?.outputKind).toBe("IdeaListResult");
|
||||
expect(returned?.rawPreview).toEqual(preview);
|
||||
const artifact = returned as { outputKind: string; rawPreview: unknown };
|
||||
expect(artifact.outputKind).toBe("IdeaListResult");
|
||||
expect(artifact.rawPreview).toEqual(preview);
|
||||
});
|
||||
|
||||
it("生成后端返回 error:genStatus=error 且弹错误 toast", async () => {
|
||||
|
||||
Reference in New Issue
Block a user