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:
Yaojia Wang
2026-06-22 20:37:55 +02:00
parent 1f1afa37b6
commit f43ccd293f
46 changed files with 4848 additions and 12 deletions

View File

@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import { buildIngestRequest, ingestTable } from "./ingest";
describe("ingestTable", () => {
it("maps ingestable kinds to their target table", () => {
expect(ingestTable("GoldenFingerResult")).toBe("world_entities");
expect(ingestTable("GlossaryResult")).toBe("world_entities");
expect(ingestTable("DetailedOutlineResult")).toBe("outline");
});
it("returns null for non-ingestable kinds", () => {
expect(ingestTable("IdeaListResult")).toBeNull();
expect(ingestTable("unknown")).toBeNull();
});
});
describe("buildIngestRequest", () => {
it("maps golden finger rows into world_entities with merged rules", () => {
const body = buildIngestRequest({
outputKind: "GoldenFingerResult",
rows: [
{ name: "吞噬系统", mechanism: "吞噬获取", growth: "等级提升", limits: "需进食" },
],
acknowledgeConflicts: true,
});
expect(body).toEqual({
world_entities: [
{
type: "力量体系",
name: "吞噬系统",
rules: ["吞噬获取", "等级提升", "需进食"],
},
],
acknowledge_conflicts: true,
});
});
it("drops blank rule parts for golden finger", () => {
const body = buildIngestRequest({
outputKind: "GoldenFingerResult",
rows: [{ name: "x", mechanism: "m", growth: "", limits: "" }],
});
expect(body?.world_entities?.[0]?.rules).toEqual(["m"]);
});
it("maps glossary rows preserving type and rules array", () => {
const body = buildIngestRequest({
outputKind: "GlossaryResult",
rows: [{ name: "灵气", type: "概念", rules: ["不可逆"] }],
});
expect(body?.world_entities?.[0]).toEqual({
type: "概念",
name: "灵气",
rules: ["不可逆"],
});
});
it("defaults glossary type to 概念 when missing", () => {
const body = buildIngestRequest({
outputKind: "GlossaryResult",
rows: [{ name: "灵气" }],
});
expect(body?.world_entities?.[0]?.type).toBe("概念");
});
it("maps detailed outline rows to scenes with chapter_no", () => {
const body = buildIngestRequest({
outputKind: "DetailedOutlineResult",
rows: [
{ idx: 2, beat: "对峙", purpose: "立威", conflict: "正反", hook: "悬念" },
{ beat: "收束" },
],
chapterNo: 7,
});
expect(body?.chapter_no).toBe(7);
expect(body?.scenes).toEqual([
{ idx: 2, beat: "对峙", purpose: "立威", conflict: "正反", hook: "悬念" },
{ idx: 1, beat: "收束", purpose: "", conflict: "", hook: "" },
]);
expect(body?.acknowledge_conflicts).toBe(false);
});
it("returns null for non-ingestable output kind", () => {
expect(
buildIngestRequest({ outputKind: "IdeaListResult", rows: [] }),
).toBeNull();
});
});

View File

@@ -0,0 +1,113 @@
// 工具箱入库纯逻辑:把结构化预览产物按 output_kind 形变为 ToolIngestRequest 的入库项。
// 复用既有入库双 gatecontinuity 预检 409 + partition_writes 白名单),故仅负责 body 组装。
// 对齐契约world_entities金手指/词条)贴 WorldEntityCardViewoutline细纲贴 OutlineSceneIngestView。
import type {
OutlineSceneIngestView,
ToolIngestRequest,
WorldEntityCardView,
} from "@/lib/api/types";
function asString(v: unknown): string {
if (typeof v === "string") return v;
if (typeof v === "number" || typeof v === "boolean") return String(v);
return "";
}
function asStringArray(v: unknown): string[] {
if (!Array.isArray(v)) return [];
return v.filter((x): x is string => typeof x === "string");
}
function asInt(v: unknown, fallback: number): number {
if (typeof v === "number" && Number.isFinite(v)) return Math.round(v);
if (typeof v === "string") {
const n = Number(v.trim());
if (Number.isFinite(n)) return Math.round(n);
}
return fallback;
}
// 金手指 → world_entitiestype 固定「力量体系」mechanism/growth/limits 合并入 rules硬规则供续审比对
function goldenFingerToEntity(
row: Record<string, unknown>,
): WorldEntityCardView {
const rules = [
asString(row["mechanism"]),
asString(row["growth"]),
asString(row["limits"]),
].filter((r) => r.length > 0);
return { type: "力量体系", name: asString(row["name"]), rules };
}
// 词条 → world_entities保留 type/namerules 取 rules已是硬规则清单
function glossaryToEntity(row: Record<string, unknown>): WorldEntityCardView {
return {
type: asString(row["type"]) || "概念",
name: asString(row["name"]),
rules: asStringArray(row["rules"]),
};
}
// 细纲场景 → outline scenes贴 OutlineSceneIngestViewidx/beat/purpose/conflict/hook
function rowToScene(
row: Record<string, unknown>,
fallbackIdx: number,
): OutlineSceneIngestView {
return {
idx: asInt(row["idx"], fallbackIdx),
beat: asString(row["beat"]),
purpose: asString(row["purpose"]),
conflict: asString(row["conflict"]),
hook: asString(row["hook"]),
};
}
export interface IngestBuildInput {
outputKind: string;
// 作者勾选要入库的预览行(结构化产物原始记录)。
rows: readonly Record<string, unknown>[];
// 细纲入库的目标章号outputKind=DetailedOutlineResult 时必填)。
chapterNo?: number | null;
acknowledgeConflicts?: boolean;
}
// 入库目标表(供 UI 文案 / 判定)。未知 → null不可入库
export function ingestTable(outputKind: string): string | null {
switch (outputKind) {
case "GoldenFingerResult":
case "GlossaryResult":
return "world_entities";
case "DetailedOutlineResult":
return "outline";
default:
return null;
}
}
// 组装通用入库请求;不可入库的 output_kind → null调用方据此隐藏入库 UI
export function buildIngestRequest(
input: IngestBuildInput,
): ToolIngestRequest | null {
const acknowledge_conflicts = input.acknowledgeConflicts ?? false;
switch (input.outputKind) {
case "GoldenFingerResult":
return {
world_entities: input.rows.map(goldenFingerToEntity),
acknowledge_conflicts,
};
case "GlossaryResult":
return {
world_entities: input.rows.map(glossaryToEntity),
acknowledge_conflicts,
};
case "DetailedOutlineResult":
return {
chapter_no: input.chapterNo ?? null,
scenes: input.rows.map((row, i) => rowToScene(row, i)),
acknowledge_conflicts,
};
default:
return null;
}
}

View 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);
});
});

View File

@@ -0,0 +1,259 @@
// 创作工具箱纯逻辑:声明式表单值 → 请求体组装、字段校验、output_kind → 预览渲染模型、legacy 路由解析。
// 对齐通用 toolbox 契约GET /skills/toolbox + generate/ingest。纯逻辑便于 node 环境单测。
// 关键边界UI 不硬编码每个工具的表单/预览,全部由 descriptor + 此处映射驱动("加生成器=加声明")。
import type {
ToolDescriptorView,
ToolGenerateRequest,
ToolInputFieldView,
} from "@/lib/api/types";
// 声明式表单字段值(控件原始值;提交前经此处归一为 ToolGenerateRequest
export type FieldValues = Record<string, string>;
// —— 表单初值 ——
// 从 descriptor 的 input_fields 取默认值组装初始表单状态(缺省值用空串,保证受控输入)。
export function initialFieldValues(
fields: readonly ToolInputFieldView[] | undefined,
): FieldValues {
const values: FieldValues = {};
for (const field of fields ?? []) {
values[field.name] = field.default ?? "";
}
return values;
}
// —— 校验 ——
// 必填字段缺失 → 返回字段名列表(空数组=通过。number 类型仅校验非空(值合法性交后端)。
export function missingRequiredFields(
fields: readonly ToolInputFieldView[] | undefined,
values: FieldValues,
): string[] {
const missing: string[] = [];
for (const field of fields ?? []) {
if (!field.required) continue;
if ((values[field.name] ?? "").trim().length === 0) {
missing.push(field.label);
}
}
return missing;
}
// —— 请求体组装 ——
// 已知入参字段brief/chapter_no/count/kind按 ToolGenerateRequest snake_case 取用;
// 其余字段名忽略descriptor 的 input_fields 命名须落在该联合内。number 安全解析,非法→省略。
const KNOWN_NUMBER_FIELDS = ["chapter_no", "count"] as const;
function parseOptionalNumber(raw: string | undefined): number | null {
if (raw === undefined) return null;
const trimmed = raw.trim();
if (trimmed.length === 0) return null;
const n = Number(trimmed);
return Number.isFinite(n) ? n : null;
}
export function buildGenerateRequest(values: FieldValues): ToolGenerateRequest {
const body: ToolGenerateRequest = { brief: (values["brief"] ?? "").trim() };
for (const key of KNOWN_NUMBER_FIELDS) {
const n = parseOptionalNumber(values[key]);
if (n !== null) body[key] = n;
}
const kind = values["kind"]?.trim();
if (kind) body.kind = kind;
return body;
}
// —— output_kind → 预览渲染模型 ——
// 把后端结构化产物preview: 任意 schema 的 model_dump归一为「卡片列表」渲染模型
// 组件据此渲染(小而稳的 switch + 兜底),无需为每个工具写专属组件。
export interface PreviewField {
label: string;
value: string;
}
export interface PreviewItem {
// 标题行(如脑洞 premise / 书名 title / 实体 name可空纯文本变体
heading: string | null;
// 次要字段angle/rationale/mechanism…逐行展示。
fields: PreviewField[];
// 大段正文(开篇/简介变体的 text独占一段。
body: string | null;
}
export interface PreviewModel {
// 用于「入库」时贴对应 schemaworld_entities / outline scenes
kind: string;
items: PreviewItem[];
}
function asString(v: unknown): string {
if (typeof v === "string") return v;
if (typeof v === "number" || typeof v === "boolean") return String(v);
return "";
}
function asStringArray(v: unknown): string[] {
if (!Array.isArray(v)) return [];
return v.filter((x): x is string => typeof x === "string");
}
function asRecordArray(v: unknown): Record<string, unknown>[] {
if (!Array.isArray(v)) return [];
return v.filter(
(x): x is Record<string, unknown> => typeof x === "object" && x !== null,
);
}
// 取 preview 对象里第一个数组型字段各工具的列表键名不同ideas/titles/variants/names/...)。
function firstArrayEntry(
preview: Record<string, unknown> | undefined,
): unknown[] {
if (!preview) return [];
for (const value of Object.values(preview)) {
if (Array.isArray(value)) return value;
}
return [];
}
// 把一条记录的若干「次要字段」按 (key,label) 映射收集为 PreviewField缺省字段跳过
function collectFields(
row: Record<string, unknown>,
specs: readonly { key: string; label: string }[],
): PreviewField[] {
const fields: PreviewField[] = [];
for (const spec of specs) {
const value = asString(row[spec.key]);
if (value) fields.push({ label: spec.label, value });
}
return fields;
}
function rulesToField(row: Record<string, unknown>): PreviewField[] {
const rules = asStringArray(row["rules"]);
return rules.length > 0
? [{ label: "硬规则", value: rules.join("") }]
: [];
}
// 取 preview 里的原始记录数组(供入库形变;与 mapPreview 同源,保证选中下标对齐)。
export function previewRows(
preview: Record<string, unknown> | undefined,
): Record<string, unknown>[] {
return asRecordArray(firstArrayEntry(preview));
}
// output_kind → 行渲染策略。未知 kind 走兜底heading=首个字符串字段,其余键值列出)。
export function mapPreview(
outputKind: string,
preview: Record<string, unknown> | undefined,
): PreviewModel {
const rows = asRecordArray(firstArrayEntry(preview));
const items = rows.map((row) => previewItem(outputKind, row));
return { kind: outputKind, items };
}
function previewItem(
outputKind: string,
row: Record<string, unknown>,
): PreviewItem {
switch (outputKind) {
case "IdeaListResult":
return {
heading: asString(row["premise"]) || null,
fields: collectFields(row, [
{ key: "hook", label: "爽点" },
{ key: "genre_fit", label: "题材契合" },
]),
body: null,
};
case "TitleListResult":
return {
heading: asString(row["title"]) || null,
fields: collectFields(row, [{ key: "rationale", label: "立意" }]),
body: null,
};
case "BlurbResult":
return {
heading: asString(row["angle"]) || null,
fields: [],
body: asString(row["text"]) || null,
};
case "NameListResult":
return {
heading: asString(row["name"]) || null,
fields: collectFields(row, [
{ key: "kind", label: "类别" },
{ key: "note", label: "说明" },
]),
body: null,
};
case "GoldenFingerResult":
return {
heading: asString(row["name"]) || null,
fields: collectFields(row, [
{ key: "mechanism", label: "机制" },
{ key: "growth", label: "成长" },
{ key: "limits", label: "限制" },
]),
body: null,
};
case "GlossaryResult":
return {
heading: asString(row["name"]) || null,
fields: [
...collectFields(row, [
{ key: "type", label: "类型" },
{ key: "definition", label: "释义" },
]),
...rulesToField(row),
],
body: null,
};
case "OpeningResult":
return { heading: null, fields: [], body: asString(row["text"]) || null };
case "DetailedOutlineResult":
return {
heading: asString(row["beat"]) || null,
fields: collectFields(row, [
{ key: "idx", label: "序" },
{ key: "purpose", label: "作用" },
{ key: "conflict", label: "冲突" },
{ key: "hook", label: "钩子" },
]),
body: null,
};
default:
return fallbackItem(row);
}
}
// 兜底:首个字符串字段当标题,其余键值平铺(未知 output_kind 也能展示,不报错)。
function fallbackItem(row: Record<string, unknown>): PreviewItem {
const entries = Object.entries(row);
let heading: string | null = null;
const fields: PreviewField[] = [];
for (const [key, raw] of entries) {
const value = Array.isArray(raw) ? asStringArray(raw).join("") : asString(raw);
if (!value) continue;
if (heading === null) {
heading = value;
} else {
fields.push({ label: key, value });
}
}
return { heading, fields, body: null };
}
// —— legacy 路由解析 ——
// legacy 工具的 legacy_route 含 {id} 占位(如 /projects/{id}/codex?gen=world→ 替换为真实 projectId。
export function resolveLegacyRoute(
route: string,
projectId: string,
): string {
return route.replace(/\{id\}/g, projectId);
}
// 判定工具点击应导航legacy还是开 runner新工具
export function isLegacyTool(tool: Readonly<ToolDescriptorView>): boolean {
return tool.is_legacy && !!tool.legacy_route;
}

View File

@@ -0,0 +1,162 @@
"use client";
import { useCallback, useState } from "react";
import { api } from "@/lib/api/client";
import { useToast } from "@/components/Toast";
import {
extractIngestConflicts,
generationErrorMessage,
type IngestConflicts,
} from "@/lib/generation/cards";
import { mapPreview, type PreviewModel } from "./toolbox";
import { buildIngestRequest, type IngestBuildInput } from "./ingest";
import type { ToolGenerateRequest } from "@/lib/api/types";
export type GenStatus = "idle" | "generating" | "preview" | "error";
export type IngestStatus = "idle" | "ingesting" | "conflict" | "done" | "error";
export interface UseGenerator {
genStatus: GenStatus;
preview: PreviewModel | null;
// 原始结构化产物(供入库按选中下标形变;与 preview.items 同源对齐)。
rawPreview: Record<string, unknown> | undefined;
outputKind: string | null;
ingestStatus: IngestStatus;
// 409 冲突详情(待作者裁决);裁决后带 acknowledge 重发。
conflicts: IngestConflicts | null;
created: string[];
generate: (
projectId: string,
toolKey: string,
body: ToolGenerateRequest,
) => Promise<void>;
// 入库选中行acknowledge 用于过 409已查看冲突后重发
ingest: (
projectId: string,
toolKey: string,
input: IngestBuildInput,
) => Promise<boolean>;
reset: () => void;
}
// 通用生成器状态机idle→generating→preview+ 入库 409 裁决流,仿 useCharacterGen。
// 声明驱动generate/ingest 不感知具体工具,按 tool_key + descriptor 形变后调通用端点。
export function useGenerator(): UseGenerator {
const [genStatus, setGenStatus] = useState<GenStatus>("idle");
const [preview, setPreview] = useState<PreviewModel | null>(null);
const [rawPreview, setRawPreview] = useState<
Record<string, unknown> | undefined
>(undefined);
const [outputKind, setOutputKind] = useState<string | null>(null);
const [ingestStatus, setIngestStatus] = useState<IngestStatus>("idle");
const [conflicts, setConflicts] = useState<IngestConflicts | null>(null);
const [created, setCreated] = useState<string[]>([]);
const toast = useToast();
const generate = useCallback<UseGenerator["generate"]>(
async (projectId, toolKey, body) => {
setGenStatus("generating");
setPreview(null);
setRawPreview(undefined);
setOutputKind(null);
setConflicts(null);
setIngestStatus("idle");
setCreated([]);
try {
const { data, error } = await api.POST(
"/projects/{project_id}/skills/{tool_key}/generate",
{
params: { path: { project_id: projectId, tool_key: toolKey } },
body,
},
);
if (error || !data) {
setGenStatus("error");
toast(generationErrorMessage(error), "error");
return;
}
setPreview(mapPreview(data.output_kind, data.preview));
setRawPreview(data.preview);
setOutputKind(data.output_kind);
setGenStatus("preview");
} catch {
setGenStatus("error");
toast("生成请求异常,请检查网络。", "error");
}
},
[toast],
);
const ingest = useCallback<UseGenerator["ingest"]>(
async (projectId, toolKey, input) => {
const body = buildIngestRequest(input);
if (!body) {
toast("该生成器不支持入库。", "error");
return false;
}
if (input.rows.length === 0) {
toast("请至少选择一项入库。", "error");
return false;
}
setIngestStatus("ingesting");
try {
const { data, error } = await api.POST(
"/projects/{project_id}/skills/{tool_key}/ingest",
{
params: { path: { project_id: projectId, tool_key: toolKey } },
body,
},
);
if (error || !data) {
const conflict = extractIngestConflicts(error);
if (conflict) {
// 409 CONFLICT_UNRESOLVED展示冲突让作者裁决再带 acknowledge 重发。
setConflicts(conflict);
setIngestStatus("conflict");
return false;
}
setIngestStatus("error");
toast(generationErrorMessage(error), "error");
return false;
}
setCreated(data.created ?? []);
setConflicts(null);
setIngestStatus("done");
toast(`已入库 ${data.created?.length ?? 0} 项至 ${data.table}`, "success");
if (data.rejected_tables && data.rejected_tables.length > 0) {
toast(`越权写表被丢弃:${data.rejected_tables.join("、")}`, "info");
}
return true;
} catch {
setIngestStatus("error");
toast("入库请求异常,请检查网络。", "error");
return false;
}
},
[toast],
);
const reset = useCallback((): void => {
setGenStatus("idle");
setPreview(null);
setRawPreview(undefined);
setOutputKind(null);
setIngestStatus("idle");
setConflicts(null);
setCreated([]);
}, []);
return {
genStatus,
preview,
rawPreview,
outputKind,
ingestStatus,
conflicts,
created,
generate,
ingest,
reset,
};
}