feat(toolbox): T6 创作工具箱通用生成器框架 — 8 新生成器 + 声明驱动落地页 + P2 收尾
通用执行路径驱动全部生成器("加生成器=加一份声明"):
- @llm: ww_agents +7 输出 schema + 7 spec(book-title/blurb/name/golden-finger/
glossary/opening/fine-outline,只声明 tier)+ build_outline_chapter_context
- @backend: ww_skills GeneratorTool 描述符 + TOOLBOX(11) + get_tool;3 通用端点
GET /skills/toolbox · POST .../skills/{tool_key}/generate(预览不写库,仅记账) ·
POST .../ingest(复用 continuity 409 + partition_writes 白名单);纯 context 派发
- @frontend: 工具箱落地页 RSC + 声明驱动 GeneratorRunner + lib/toolbox 纯函数
+ LeftNav「工具箱」+ ⌘K nav-toolbox/action-gen-*;legacy 3 跳现页
- @qa: tests/test_t6_toolbox_e2e.py 5 用例真 pg + mock 网关零 token,无端点 bug
- P2 收尾: 限流→decisions.md 记延后(单用户原型);noopener/Committable 早已修
守不变量 #2(只声明 tier)/#3(预览不写库,入库经验收 gate)/#9(缓存前缀)。无 DB 迁移。
门禁绿: 后端 ruff/format/mypy 195/alembic 无漂移/pytest 583;前端 lint/tsc/vitest 279/build。
spec 回写 PRODUCT_SPEC §7 + ARCHITECTURE §7.2 端点表。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
169
apps/web/lib/toolbox/toolbox.test.ts
Normal file
169
apps/web/lib/toolbox/toolbox.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ToolDescriptorView, ToolInputFieldView } from "@/lib/api/types";
|
||||
import {
|
||||
buildGenerateRequest,
|
||||
initialFieldValues,
|
||||
isLegacyTool,
|
||||
mapPreview,
|
||||
missingRequiredFields,
|
||||
previewRows,
|
||||
resolveLegacyRoute,
|
||||
} from "./toolbox";
|
||||
|
||||
const field = (over: Partial<ToolInputFieldView> = {}): ToolInputFieldView => ({
|
||||
name: "brief",
|
||||
label: "需求",
|
||||
type: "textarea",
|
||||
required: true,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("initialFieldValues", () => {
|
||||
it("seeds defaults and empty strings", () => {
|
||||
const values = initialFieldValues([
|
||||
field({ name: "brief", default: undefined }),
|
||||
field({ name: "count", type: "number", default: "3", required: false }),
|
||||
]);
|
||||
expect(values).toEqual({ brief: "", count: "3" });
|
||||
});
|
||||
|
||||
it("handles undefined fields", () => {
|
||||
expect(initialFieldValues(undefined)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("missingRequiredFields", () => {
|
||||
it("reports labels of blank required fields only", () => {
|
||||
const fields = [
|
||||
field({ name: "brief", label: "需求", required: true }),
|
||||
field({ name: "count", label: "数量", required: false }),
|
||||
];
|
||||
expect(missingRequiredFields(fields, { brief: " ", count: "" })).toEqual([
|
||||
"需求",
|
||||
]);
|
||||
});
|
||||
|
||||
it("passes when all required present", () => {
|
||||
expect(
|
||||
missingRequiredFields([field()], { brief: "一个脑洞" }),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildGenerateRequest", () => {
|
||||
it("trims brief and maps known number fields", () => {
|
||||
expect(
|
||||
buildGenerateRequest({ brief: " 修真 ", count: "5", chapter_no: "3" }),
|
||||
).toEqual({ brief: "修真", count: 5, chapter_no: 3 });
|
||||
});
|
||||
|
||||
it("omits blank/invalid numbers and empty kind", () => {
|
||||
expect(
|
||||
buildGenerateRequest({ brief: "x", count: "", chapter_no: "abc", kind: " " }),
|
||||
).toEqual({ brief: "x" });
|
||||
});
|
||||
|
||||
it("includes kind when present", () => {
|
||||
expect(buildGenerateRequest({ brief: "x", kind: "门派" })).toEqual({
|
||||
brief: "x",
|
||||
kind: "门派",
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults brief to empty string", () => {
|
||||
expect(buildGenerateRequest({})).toEqual({ brief: "" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("previewRows", () => {
|
||||
it("picks the first array field as records", () => {
|
||||
const rows = previewRows({ ideas: [{ premise: "a" }, { premise: "b" }] });
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]).toEqual({ premise: "a" });
|
||||
});
|
||||
|
||||
it("returns empty for missing/non-array", () => {
|
||||
expect(previewRows(undefined)).toEqual([]);
|
||||
expect(previewRows({ note: "x" })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapPreview", () => {
|
||||
it("maps IdeaListResult to heading + fields", () => {
|
||||
const model = mapPreview("IdeaListResult", {
|
||||
ideas: [{ premise: "废柴逆袭", hook: "系统觉醒", genre_fit: "玄幻" }],
|
||||
});
|
||||
expect(model.kind).toBe("IdeaListResult");
|
||||
expect(model.items[0]?.heading).toBe("废柴逆袭");
|
||||
expect(model.items[0]?.fields).toEqual([
|
||||
{ label: "爽点", value: "系统觉醒" },
|
||||
{ label: "题材契合", value: "玄幻" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps BlurbResult text into body and angle into heading", () => {
|
||||
const model = mapPreview("BlurbResult", {
|
||||
variants: [{ angle: "悬念向", text: "一段简介" }],
|
||||
});
|
||||
expect(model.items[0]?.heading).toBe("悬念向");
|
||||
expect(model.items[0]?.body).toBe("一段简介");
|
||||
});
|
||||
|
||||
it("maps GlossaryResult rules into a joined field", () => {
|
||||
const model = mapPreview("GlossaryResult", {
|
||||
terms: [{ name: "灵气", type: "概念", rules: ["不可逆", "有上限"] }],
|
||||
});
|
||||
const ruleField = model.items[0]?.fields.find((f) => f.label === "硬规则");
|
||||
expect(ruleField?.value).toBe("不可逆;有上限");
|
||||
});
|
||||
|
||||
it("maps OpeningResult as pure body", () => {
|
||||
const model = mapPreview("OpeningResult", {
|
||||
variants: [{ text: "开篇第一段" }],
|
||||
});
|
||||
expect(model.items[0]?.heading).toBeNull();
|
||||
expect(model.items[0]?.body).toBe("开篇第一段");
|
||||
});
|
||||
|
||||
it("falls back gracefully for unknown output_kind", () => {
|
||||
const model = mapPreview("MysteryResult", {
|
||||
things: [{ a: "first", b: "second" }],
|
||||
});
|
||||
expect(model.items[0]?.heading).toBe("first");
|
||||
expect(model.items[0]?.fields).toEqual([{ label: "b", value: "second" }]);
|
||||
});
|
||||
|
||||
it("yields no items for empty preview", () => {
|
||||
expect(mapPreview("IdeaListResult", undefined).items).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveLegacyRoute", () => {
|
||||
it("substitutes the {id} placeholder", () => {
|
||||
expect(resolveLegacyRoute("/projects/{id}/codex?gen=world", "p9")).toBe(
|
||||
"/projects/p9/codex?gen=world",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLegacyTool", () => {
|
||||
const tool = (over: Partial<ToolDescriptorView>): ToolDescriptorView => ({
|
||||
key: "k",
|
||||
title: "t",
|
||||
subtitle: "s",
|
||||
is_legacy: false,
|
||||
ingestable: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
it("is true only with legacy flag and a route", () => {
|
||||
expect(
|
||||
isLegacyTool(tool({ is_legacy: true, legacy_route: "/x/{id}" })),
|
||||
).toBe(true);
|
||||
expect(isLegacyTool(tool({ is_legacy: true }))).toBe(false);
|
||||
expect(
|
||||
isLegacyTool(tool({ is_legacy: false, legacy_route: "/x/{id}" })),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user