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:
34
apps/web/app/projects/[id]/toolbox/page.tsx
Normal file
34
apps/web/app/projects/[id]/toolbox/page.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { ToolboxPage } from "@/components/toolbox/ToolboxPage";
|
||||
import { fetchProject, fetchToolbox } from "@/lib/api/server";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ open?: string }>;
|
||||
}
|
||||
|
||||
// 创作工具箱页。Server Component 取项目 + 工具描述符(GET /skills/toolbox,错误降级为空)。
|
||||
// ?open=<tool_key> 由命令面板 action-gen-<key> 跳转,进页直开对应生成器(仅新工具)。
|
||||
export default async function ToolboxRoutePage({
|
||||
params,
|
||||
searchParams,
|
||||
}: PageProps) {
|
||||
const { id } = await params;
|
||||
const { open } = await searchParams;
|
||||
try {
|
||||
const [project, toolbox] = await Promise.all([
|
||||
fetchProject(id),
|
||||
fetchToolbox(),
|
||||
]);
|
||||
return (
|
||||
<ToolboxPage
|
||||
project={project}
|
||||
tools={toolbox.tools ?? []}
|
||||
initialOpenKey={open}
|
||||
/>
|
||||
);
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,10 @@ import {
|
||||
moveHighlight,
|
||||
projectCommands,
|
||||
type Command,
|
||||
type ToolCommandInfo,
|
||||
} from "@/lib/command/palette";
|
||||
import { handleTabTrap } from "@/lib/a11y/focusTrap";
|
||||
import { api } from "@/lib/api/client";
|
||||
|
||||
// 从 pathname 抽取当前 projectId(/projects/<id>/...)。
|
||||
function projectIdFromPath(pathname: string): string | null {
|
||||
@@ -31,14 +33,17 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
|
||||
const [highlight, setHighlight] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
// 工具箱生成器(命令面板发现性):首次打开惰性拉取一次,失败静默降级为空。
|
||||
const [tools, setTools] = useState<ToolCommandInfo[]>([]);
|
||||
const toolsLoadedRef = useRef(false);
|
||||
|
||||
const projectId = projectIdFromPath(pathname);
|
||||
const commands = useMemo<Command[]>(
|
||||
() =>
|
||||
projectId
|
||||
? [...projectCommands(projectId), ...globalCommands()]
|
||||
? [...projectCommands(projectId, tools), ...globalCommands()]
|
||||
: globalCommands(),
|
||||
[projectId],
|
||||
[projectId, tools],
|
||||
);
|
||||
const results = useMemo(
|
||||
() => filterCommands(commands, query),
|
||||
@@ -76,6 +81,29 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
|
||||
useEffect(() => {
|
||||
if (open) inputRef.current?.focus();
|
||||
}, [open]);
|
||||
|
||||
// 首次打开(项目内)惰性拉工具箱生成器列表,供生成 action-gen-<key> 命令。
|
||||
useEffect(() => {
|
||||
if (!open || !projectId || toolsLoadedRef.current) return;
|
||||
toolsLoadedRef.current = true;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const { data } = await api.GET("/skills/toolbox");
|
||||
if (cancelled || !data?.tools) return;
|
||||
setTools(
|
||||
data.tools
|
||||
.filter((t) => !t.is_legacy)
|
||||
.map((t) => ({ key: t.key, title: t.title, genre: t.genre })),
|
||||
);
|
||||
} catch {
|
||||
// 静默降级:命令面板照常工作,仅缺生成器动作。
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, projectId]);
|
||||
useEffect(() => {
|
||||
setHighlight(0);
|
||||
}, [query]);
|
||||
|
||||
271
apps/web/components/toolbox/GeneratorRunner.tsx
Normal file
271
apps/web/components/toolbox/GeneratorRunner.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import { useToast } from "@/components/Toast";
|
||||
import { ConflictAdjudication } from "@/components/generation/ConflictAdjudication";
|
||||
import type { ToolDescriptorView, ToolInputFieldView } from "@/lib/api/types";
|
||||
import {
|
||||
buildGenerateRequest,
|
||||
initialFieldValues,
|
||||
missingRequiredFields,
|
||||
previewRows,
|
||||
type FieldValues,
|
||||
type PreviewItem,
|
||||
} from "@/lib/toolbox/toolbox";
|
||||
import { ingestTable } from "@/lib/toolbox/ingest";
|
||||
import { useGenerator } from "@/lib/toolbox/useGenerator";
|
||||
|
||||
interface GeneratorRunnerProps {
|
||||
projectId: string;
|
||||
tool: ToolDescriptorView;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// 声明驱动的通用生成器(核心):按 descriptor.input_fields 渲染表单 → 调通用 generate →
|
||||
// 按 output_kind 渲染结构化预览 → 可入库者勾选 + 入库(复用 ConflictAdjudication 过 409)。
|
||||
// 不硬编码任何工具的表单/预览/入库形状——全部由声明 + lib/toolbox 纯逻辑驱动。
|
||||
export function GeneratorRunner({
|
||||
projectId,
|
||||
tool,
|
||||
onClose,
|
||||
}: GeneratorRunnerProps) {
|
||||
const gen = useGenerator();
|
||||
const toast = useToast();
|
||||
const [values, setValues] = useState<FieldValues>(() =>
|
||||
initialFieldValues(tool.input_fields),
|
||||
);
|
||||
// 入库勾选:预览行下标集合(与 gen.rawPreview 行对齐)。
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
|
||||
const setField = useCallback((name: string, value: string): void => {
|
||||
setValues((prev) => ({ ...prev, [name]: value }));
|
||||
}, []);
|
||||
|
||||
const onGenerate = useCallback(async (): Promise<void> => {
|
||||
const missing = missingRequiredFields(tool.input_fields, values);
|
||||
if (missing.length > 0) {
|
||||
toast(`请填写:${missing.join("、")}`, "error");
|
||||
return;
|
||||
}
|
||||
setSelected(new Set());
|
||||
await gen.generate(projectId, tool.key, buildGenerateRequest(values));
|
||||
}, [gen, projectId, tool, values, toast]);
|
||||
|
||||
const rows = useMemo(
|
||||
() => previewRows(gen.rawPreview),
|
||||
[gen.rawPreview],
|
||||
);
|
||||
|
||||
const table = gen.outputKind ? ingestTable(gen.outputKind) : null;
|
||||
const canIngest = tool.ingestable && table !== null && rows.length > 0;
|
||||
|
||||
const toggle = useCallback((idx: number): void => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(idx)) {
|
||||
next.delete(idx);
|
||||
} else {
|
||||
next.add(idx);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const selectedRows = useMemo(
|
||||
() => rows.filter((_, i) => selected.has(i)),
|
||||
[rows, selected],
|
||||
);
|
||||
|
||||
const runIngest = useCallback(
|
||||
async (acknowledge: boolean): Promise<void> => {
|
||||
if (!gen.outputKind) return;
|
||||
const chapterNo = Number(values["chapter_no"]?.trim() || "") || null;
|
||||
await gen.ingest(projectId, tool.key, {
|
||||
outputKind: gen.outputKind,
|
||||
rows: selectedRows,
|
||||
chapterNo,
|
||||
acknowledgeConflicts: acknowledge,
|
||||
});
|
||||
},
|
||||
[gen, projectId, tool.key, selectedRows, values],
|
||||
);
|
||||
|
||||
const generating = gen.genStatus === "generating";
|
||||
const ingesting = gen.ingestStatus === "ingesting";
|
||||
|
||||
return (
|
||||
<section
|
||||
className="flex flex-col gap-4 rounded border border-line bg-panel p-5"
|
||||
aria-label={tool.title}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="font-serif text-lg text-ink">{tool.title}</h2>
|
||||
<p className="mt-1 text-sm text-ink-soft">{tool.subtitle}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded border border-line px-3 py-1 text-sm text-ink-soft hover:text-ink"
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="flex flex-col gap-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void onGenerate();
|
||||
}}
|
||||
>
|
||||
{(tool.input_fields ?? []).map((field) => (
|
||||
<FormField
|
||||
key={field.name}
|
||||
field={field}
|
||||
value={values[field.name] ?? ""}
|
||||
onChange={(v) => setField(field.name, v)}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={generating}
|
||||
className="self-start rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50"
|
||||
>
|
||||
{generating ? "生成中…" : "生成"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{gen.genStatus === "preview" && gen.preview ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<h3 className="font-serif text-sm text-ink">
|
||||
预览({gen.preview.items.length})
|
||||
{canIngest ? "(勾选后可入库)" : null}
|
||||
</h3>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{gen.preview.items.map((item, i) => (
|
||||
<PreviewRow
|
||||
key={i}
|
||||
item={item}
|
||||
index={i}
|
||||
selectable={canIngest}
|
||||
checked={selected.has(i)}
|
||||
onToggle={toggle}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{canIngest && gen.ingestStatus !== "conflict" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void runIngest(false)}
|
||||
disabled={ingesting || selected.size === 0}
|
||||
className="self-start rounded border border-cinnabar px-4 py-2 text-sm text-cinnabar disabled:opacity-50"
|
||||
>
|
||||
{ingesting ? "入库中…" : `入库选中(${selected.size})至 ${table}`}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{gen.ingestStatus === "conflict" && gen.conflicts ? (
|
||||
<ConflictAdjudication
|
||||
conflicts={gen.conflicts}
|
||||
busy={ingesting}
|
||||
onAcknowledge={() => void runIngest(true)}
|
||||
onCancel={() => gen.reset()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{gen.ingestStatus === "done" ? (
|
||||
<p className="text-sm text-ink-soft">
|
||||
已入库 {gen.created.length} 项。
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface FormFieldProps {
|
||||
field: ToolInputFieldView;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
// 声明式控件映射:type → text / textarea / number(select 暂同 text,后端未给 options)。
|
||||
function FormField({ field, value, onChange }: FormFieldProps) {
|
||||
const labelText = field.required ? `${field.label} *` : field.label;
|
||||
const common =
|
||||
"mt-1 w-full rounded border border-line bg-bg px-3 py-2 text-sm text-ink";
|
||||
return (
|
||||
<label className="block text-sm text-ink-soft">
|
||||
{labelText}
|
||||
{field.type === "textarea" ? (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
rows={3}
|
||||
className={`${common} resize-y`}
|
||||
aria-label={field.label}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type={field.type === "number" ? "number" : "text"}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={common}
|
||||
aria-label={field.label}
|
||||
/>
|
||||
)}
|
||||
{field.help ? (
|
||||
<span className="mt-1 block text-xs text-ink-soft/80">{field.help}</span>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
interface PreviewRowProps {
|
||||
item: PreviewItem;
|
||||
index: number;
|
||||
selectable: boolean;
|
||||
checked: boolean;
|
||||
onToggle: (index: number) => void;
|
||||
}
|
||||
|
||||
// 统一预览行渲染(heading + 次要字段 + 正文段),由 mapPreview 归一,跨工具复用。
|
||||
function PreviewRow({
|
||||
item,
|
||||
index,
|
||||
selectable,
|
||||
checked,
|
||||
onToggle,
|
||||
}: PreviewRowProps) {
|
||||
return (
|
||||
<li className="flex gap-2 rounded border border-line bg-bg p-3 text-sm">
|
||||
{selectable ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => onToggle(index)}
|
||||
className="mt-1"
|
||||
aria-label={`选择第 ${index + 1} 项入库`}
|
||||
/>
|
||||
) : null}
|
||||
<div className="min-w-0 flex-1">
|
||||
{item.heading ? (
|
||||
<p className="mb-1 font-serif text-base text-ink">{item.heading}</p>
|
||||
) : null}
|
||||
{item.fields.map((f, j) => (
|
||||
<p key={j} className="text-ink-soft">
|
||||
<span className="text-ink-soft/80">{f.label}:</span>
|
||||
{f.value}
|
||||
</p>
|
||||
))}
|
||||
{item.body ? (
|
||||
<p className="whitespace-pre-wrap text-ink">{item.body}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
41
apps/web/components/toolbox/ToolCard.tsx
Normal file
41
apps/web/components/toolbox/ToolCard.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolDescriptorView } from "@/lib/api/types";
|
||||
|
||||
interface ToolCardProps {
|
||||
tool: ToolDescriptorView;
|
||||
// 点击:legacy 工具导航到现有页面;新工具开 GeneratorRunner。
|
||||
onOpen: (tool: ToolDescriptorView) => void;
|
||||
}
|
||||
|
||||
// 工具箱卡片(对齐 ProjectCard 纸感视觉):标题 + 副标题 + 题材徽标 + NEW/可入库标。
|
||||
// 纯展示;点击交由 ToolboxPage 分派(legacy 跳转 / 新工具开 runner)。
|
||||
export function ToolCard({ tool, onOpen }: ToolCardProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(tool)}
|
||||
className="flex h-full min-h-[140px] flex-col rounded border border-line bg-panel p-5 text-left shadow-paper transition-colors hover:border-cinnabar focus-visible:border-cinnabar focus-visible:outline-none"
|
||||
>
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<h2 className="font-serif text-xl text-ink">{tool.title}</h2>
|
||||
{tool.genre ? (
|
||||
<span className="rounded bg-bg px-2 py-0.5 text-xs text-ink-soft">
|
||||
{tool.genre}
|
||||
</span>
|
||||
) : null}
|
||||
{tool.is_legacy ? null : (
|
||||
<span className="rounded bg-[var(--color-cinnabar-wash)] px-2 py-0.5 text-xs text-cinnabar">
|
||||
NEW
|
||||
</span>
|
||||
)}
|
||||
{tool.ingestable ? (
|
||||
<span className="rounded bg-bg px-2 py-0.5 text-xs text-ink-soft">
|
||||
可入库
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="line-clamp-2 text-sm text-ink-soft">{tool.subtitle}</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
92
apps/web/components/toolbox/ToolboxPage.tsx
Normal file
92
apps/web/components/toolbox/ToolboxPage.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import type { ProjectResponse, ToolDescriptorView } from "@/lib/api/types";
|
||||
import { isLegacyTool, resolveLegacyRoute } from "@/lib/toolbox/toolbox";
|
||||
import { ToolCard } from "./ToolCard";
|
||||
import { GeneratorRunner } from "./GeneratorRunner";
|
||||
|
||||
interface ToolboxPageProps {
|
||||
project: ProjectResponse;
|
||||
tools: ToolDescriptorView[];
|
||||
// 命令面板 action-gen-<key> 深链:进页直接打开该工具的 runner(仅新工具)。
|
||||
initialOpenKey?: string;
|
||||
}
|
||||
|
||||
// 创作工具箱(通用生成器框架):声明驱动的卡片栅格。
|
||||
// legacy 工具(世界观/人设/大纲)跳现有页面保其更丰富的入库流;新工具开 GeneratorRunner。
|
||||
export function ToolboxPage({
|
||||
project,
|
||||
tools,
|
||||
initialOpenKey,
|
||||
}: ToolboxPageProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const findOpenable = useCallback(
|
||||
(key: string | undefined): ToolDescriptorView | null => {
|
||||
if (!key) return null;
|
||||
const tool = tools.find((t) => t.key === key);
|
||||
return tool && !isLegacyTool(tool) ? tool : null;
|
||||
},
|
||||
[tools],
|
||||
);
|
||||
|
||||
const [active, setActive] = useState<ToolDescriptorView | null>(() =>
|
||||
findOpenable(initialOpenKey),
|
||||
);
|
||||
|
||||
const onOpen = useCallback(
|
||||
(tool: ToolDescriptorView): void => {
|
||||
if (isLegacyTool(tool) && tool.legacy_route) {
|
||||
router.push(resolveLegacyRoute(tool.legacy_route, project.id));
|
||||
return;
|
||||
}
|
||||
setActive(tool);
|
||||
},
|
||||
[router, project.id],
|
||||
);
|
||||
|
||||
const sorted = useMemo(
|
||||
// 新工具靠前(更想被发现),legacy 收后;同组保持后端顺序。
|
||||
() => [...tools].sort((a, b) => Number(a.is_legacy) - Number(b.is_legacy)),
|
||||
[tools],
|
||||
);
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
title={`〈${project.title}〉`}
|
||||
subtitle="工具箱"
|
||||
projectId={project.id}
|
||||
activeNav="toolbox"
|
||||
>
|
||||
<div className="mx-auto flex max-w-5xl flex-col gap-6 p-6">
|
||||
<header>
|
||||
<h1 className="font-serif text-lg text-ink">创作工具箱</h1>
|
||||
<p className="mt-1 text-xs text-ink-soft">
|
||||
声明驱动的生成器集合:脑洞 / 书名 / 简介 / 名字 / 金手指 / 词条 / 黄金开篇 / 细纲…
|
||||
选一个开始,结构化产物可一键入库(经一致性预检)。
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{active ? (
|
||||
<GeneratorRunner
|
||||
projectId={project.id}
|
||||
tool={active}
|
||||
onClose={() => setActive(null)}
|
||||
/>
|
||||
) : sorted.length === 0 ? (
|
||||
<p className="text-sm text-ink-soft">(暂无可用生成器)</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{sorted.map((tool) => (
|
||||
<ToolCard key={tool.key} tool={tool} onOpen={onOpen} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
393
apps/web/lib/api/schema.d.ts
vendored
393
apps/web/lib/api/schema.d.ts
vendored
@@ -487,6 +487,68 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/skills/toolbox": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* List Toolbox
|
||||
* @description 创作工具箱全量描述符(legacy + 新工具;前端据此渲染卡片栅格 + 表单 + 路由)。
|
||||
*/
|
||||
get: operations["list_toolbox_skills_toolbox_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/projects/{project_id}/skills/{tool_key}/generate": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Generate With Tool
|
||||
* @description 通用生成(结构化预览,不入库)。未知/ legacy 工具→404;项目不存在→404;无凭据→503。
|
||||
*/
|
||||
post: operations["generate_with_tool_projects__project_id__skills__tool_key__generate_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/projects/{project_id}/skills/{tool_key}/ingest": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Ingest With Tool
|
||||
* @description 通用入库(仅可入库工具):过 continuity 预检 + 白名单 → 写 ingest.table。
|
||||
*
|
||||
* 未知/legacy 工具→404;不可入库工具→422;有冲突且未确认→409;越权写表丢弃+审计。
|
||||
*/
|
||||
post: operations["ingest_with_tool_projects__project_id__skills__tool_key__ingest_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/settings/providers": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1216,6 +1278,31 @@ export interface components {
|
||||
/** Chapters */
|
||||
chapters?: components["schemas"]["OutlineChapterView"][];
|
||||
};
|
||||
/**
|
||||
* OutlineSceneIngestView
|
||||
* @description 细纲入库的单章场景行(贴 ww_agents.Scene;落 outline 表)。
|
||||
*/
|
||||
OutlineSceneIngestView: {
|
||||
/** Idx */
|
||||
idx: number;
|
||||
/** Beat */
|
||||
beat: string;
|
||||
/**
|
||||
* Purpose
|
||||
* @default
|
||||
*/
|
||||
purpose: string;
|
||||
/**
|
||||
* Conflict
|
||||
* @default
|
||||
*/
|
||||
conflict: string;
|
||||
/**
|
||||
* Hook
|
||||
* @default
|
||||
*/
|
||||
hook: string;
|
||||
};
|
||||
/**
|
||||
* ProjectCreateRequest
|
||||
* @description POST /projects:立项向导字段(owner_id 由后端补 stub,不入参)。
|
||||
@@ -1568,6 +1655,175 @@ export interface components {
|
||||
/** Fallback */
|
||||
fallback?: string[];
|
||||
};
|
||||
/**
|
||||
* ToolDescriptorView
|
||||
* @description 单个生成器卡片描述符视图(前端据此渲染卡片栅格 + 输入表单)。
|
||||
*/
|
||||
ToolDescriptorView: {
|
||||
/**
|
||||
* Key
|
||||
* @description 路由用稳定标识(brainstorm / book-title ...)
|
||||
*/
|
||||
key: string;
|
||||
/** Title */
|
||||
title: string;
|
||||
/** Subtitle */
|
||||
subtitle: string;
|
||||
/** Genre */
|
||||
genre?: string | null;
|
||||
/**
|
||||
* Is Legacy
|
||||
* @description legacy 工具(spec=None)→ 前端走 legacy_route 跳现有页面
|
||||
*/
|
||||
is_legacy: boolean;
|
||||
/**
|
||||
* Ingestable
|
||||
* @description 是否可入库(声明了 ingest 目标)
|
||||
*/
|
||||
ingestable: boolean;
|
||||
/** Input Fields */
|
||||
input_fields?: components["schemas"]["ToolInputFieldView"][];
|
||||
/**
|
||||
* Legacy Route
|
||||
* @description legacy 工具指向的现有页面路径(含 {id} 占位)
|
||||
*/
|
||||
legacy_route?: string | null;
|
||||
};
|
||||
/**
|
||||
* ToolGeneratePreviewResponse
|
||||
* @description 通用生成预览:结构化产物(不持久化;仅记 ledger)。
|
||||
*
|
||||
* `output_kind` = 该工具产物的 schema 名(如 IdeaListResult),供前端按类型选预览渲染器;
|
||||
* `preview` = 解析后的结构化产物(按各工具 output_schema 的形,键名见 ww_agents.schemas)。
|
||||
*/
|
||||
ToolGeneratePreviewResponse: {
|
||||
/** Tool Key */
|
||||
tool_key: string;
|
||||
/**
|
||||
* Output Kind
|
||||
* @description 产物 schema 名(前端按此选预览渲染器)
|
||||
*/
|
||||
output_kind: string;
|
||||
/**
|
||||
* Preview
|
||||
* @description 结构化产物(各工具 output_schema 的 model_dump;不入库)
|
||||
*/
|
||||
preview?: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
/**
|
||||
* ToolGenerateRequest
|
||||
* @description 通用生成请求(覆盖全部工具的可选入参;按工具 input_fields 取用,snake_case)。
|
||||
*/
|
||||
ToolGenerateRequest: {
|
||||
/**
|
||||
* Brief
|
||||
* @description 一句话需求/方向(可空,空则按题材发散)
|
||||
* @default
|
||||
*/
|
||||
brief: string;
|
||||
/**
|
||||
* Chapter No
|
||||
* @description 按章展开类工具的章号(开篇/细纲)
|
||||
*/
|
||||
chapter_no?: number | null;
|
||||
/**
|
||||
* Count
|
||||
* @description 生成数量(部分工具可用)
|
||||
*/
|
||||
count?: number | null;
|
||||
/**
|
||||
* Kind
|
||||
* @description 命名对象类别等(部分工具可用)
|
||||
*/
|
||||
kind?: string | null;
|
||||
};
|
||||
/**
|
||||
* ToolIngestRequest
|
||||
* @description 通用入库请求:待持久化的产物 + 冲突确认。
|
||||
*
|
||||
* 仅声明了 `ingest` 的工具可用。按入库表取用对应字段:
|
||||
* - world_entities(金手指/词条)→ `world_entities`(贴 WorldEntityCardView:type/name/rules);
|
||||
* - outline(细纲)→ `chapter_no` + `scenes`(贴 OutlineSceneIngestView)。
|
||||
* `acknowledge_conflicts`:作者已查看并接受 continuity 预检冲突时置 true 放行(仿 accept gate)。
|
||||
*/
|
||||
ToolIngestRequest: {
|
||||
/**
|
||||
* World Entities
|
||||
* @description world_entities 入库项(金手指/词条)
|
||||
*/
|
||||
world_entities?: components["schemas"]["WorldEntityCardView"][];
|
||||
/**
|
||||
* Chapter No
|
||||
* @description outline 入库的目标章号(细纲)
|
||||
*/
|
||||
chapter_no?: number | null;
|
||||
/**
|
||||
* Scenes
|
||||
* @description outline 入库的场景行(细纲)
|
||||
*/
|
||||
scenes?: components["schemas"]["OutlineSceneIngestView"][];
|
||||
/**
|
||||
* Acknowledge Conflicts
|
||||
* @description 作者已知悉并接受 continuity 冲突 → 放行入库
|
||||
* @default false
|
||||
*/
|
||||
acknowledge_conflicts: boolean;
|
||||
};
|
||||
/**
|
||||
* ToolIngestResponse
|
||||
* @description 通用入库结果(201):写入项标识 + 被白名单丢弃的越权表(审计)。
|
||||
*/
|
||||
ToolIngestResponse: {
|
||||
/**
|
||||
* Table
|
||||
* @description 入库目标表
|
||||
*/
|
||||
table: string;
|
||||
/**
|
||||
* Created
|
||||
* @description 写入项的标识(实体名 / 场景索引),按入参顺序
|
||||
*/
|
||||
created?: string[];
|
||||
/**
|
||||
* Rejected Tables
|
||||
* @description 被权限白名单丢弃的越权写表名(审计;正常为空)
|
||||
*/
|
||||
rejected_tables?: string[];
|
||||
};
|
||||
/**
|
||||
* ToolInputFieldView
|
||||
* @description 单个声明式表单字段视图(贴 ww_skills.InputField)。
|
||||
*/
|
||||
ToolInputFieldView: {
|
||||
/** Name */
|
||||
name: string;
|
||||
/** Label */
|
||||
label: string;
|
||||
/**
|
||||
* Type
|
||||
* @description 控件类型:text / textarea / number / select 等
|
||||
*/
|
||||
type: string;
|
||||
/**
|
||||
* Required
|
||||
* @default true
|
||||
*/
|
||||
required: boolean;
|
||||
/** Default */
|
||||
default?: string | null;
|
||||
/** Help */
|
||||
help?: string | null;
|
||||
};
|
||||
/**
|
||||
* ToolboxListResponse
|
||||
* @description GET /skills/toolbox:创作工具箱全量描述符(legacy + 新工具)。
|
||||
*/
|
||||
ToolboxListResponse: {
|
||||
/** Tools */
|
||||
tools?: components["schemas"]["ToolDescriptorView"][];
|
||||
};
|
||||
/** ValidationError */
|
||||
ValidationError: {
|
||||
/** Location */
|
||||
@@ -2681,6 +2937,143 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
list_toolbox_skills_toolbox_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ToolboxListResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
generate_with_tool_projects__project_id__skills__tool_key__generate_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
project_id: string;
|
||||
tool_key: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ToolGenerateRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ToolGeneratePreviewResponse"];
|
||||
};
|
||||
};
|
||||
/** @description 工具或项目不存在 / legacy 工具无通用执行 */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 工具不支持该操作(如不可入库) */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description LLM 不可用 */
|
||||
503: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
ingest_with_tool_projects__project_id__skills__tool_key__ingest_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
project_id: string;
|
||||
tool_key: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ToolIngestRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ToolIngestResponse"];
|
||||
};
|
||||
};
|
||||
/** @description 工具或项目不存在 / legacy 工具无通用执行 */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 存在未裁决 continuity 冲突 */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 工具不支持该操作(如不可入库) */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description LLM 不可用 */
|
||||
503: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
list_providers_settings_providers_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
RuleListResponse,
|
||||
SkillListResponse,
|
||||
StyleFingerprintResponse,
|
||||
ToolboxListResponse,
|
||||
WorldEntityListResponse,
|
||||
} from "./types";
|
||||
|
||||
@@ -107,6 +108,16 @@ export async function fetchSkills(): Promise<SkillListResponse> {
|
||||
return getJson<SkillListResponse>("/skills");
|
||||
}
|
||||
|
||||
// 创作工具箱描述符(GET /skills/toolbox,全局只读)。
|
||||
// 任何错误(含后端未上线)降级为空列表,工具箱页照常渲染(不阻塞进页)。
|
||||
export async function fetchToolbox(): Promise<ToolboxListResponse> {
|
||||
try {
|
||||
return await getJson<ToolboxListResponse>("/skills/toolbox");
|
||||
} catch {
|
||||
return { tools: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// 设定库 Codex:跨会话全量已入库角色(GET .../characters;无行→空列表,非 404)。
|
||||
export async function fetchCharacters(
|
||||
projectId: string,
|
||||
|
||||
@@ -91,3 +91,19 @@ export type RuleView = components["schemas"]["RuleView"];
|
||||
export type RuleListResponse = components["schemas"]["RuleListResponse"];
|
||||
export type SkillView = components["schemas"]["SkillView"];
|
||||
export type SkillListResponse = components["schemas"]["SkillListResponse"];
|
||||
|
||||
// 创作工具箱:声明驱动的通用生成器(GET /skills/toolbox + 通用 generate/ingest)。
|
||||
export type ToolboxListResponse =
|
||||
components["schemas"]["ToolboxListResponse"];
|
||||
export type ToolDescriptorView =
|
||||
components["schemas"]["ToolDescriptorView"];
|
||||
export type ToolInputFieldView =
|
||||
components["schemas"]["ToolInputFieldView"];
|
||||
export type ToolGenerateRequest =
|
||||
components["schemas"]["ToolGenerateRequest"];
|
||||
export type ToolGeneratePreviewResponse =
|
||||
components["schemas"]["ToolGeneratePreviewResponse"];
|
||||
export type ToolIngestRequest = components["schemas"]["ToolIngestRequest"];
|
||||
export type ToolIngestResponse = components["schemas"]["ToolIngestResponse"];
|
||||
export type OutlineSceneIngestView =
|
||||
components["schemas"]["OutlineSceneIngestView"];
|
||||
|
||||
@@ -17,6 +17,32 @@ describe("projectCommands", () => {
|
||||
expect(genChar?.kind).toBe("action");
|
||||
expect(genChar?.href).toBe("/projects/p1/codex?gen=character");
|
||||
});
|
||||
|
||||
it("always offers a 工具箱 nav command", () => {
|
||||
const toolbox = projectCommands("p1").find((c) => c.id === "nav-toolbox");
|
||||
expect(toolbox?.kind).toBe("navigate");
|
||||
expect(toolbox?.href).toBe("/projects/p1/toolbox");
|
||||
});
|
||||
|
||||
it("generates one action-gen-<key> per toolbox tool deep-linking with ?open", () => {
|
||||
const cmds = projectCommands("p1", [
|
||||
{ key: "brainstorm", title: "脑洞生成器", genre: "玄幻" },
|
||||
{ key: "book-title", title: "书名生成器" },
|
||||
]);
|
||||
const brainstorm = cmds.find((c) => c.id === "action-gen-brainstorm");
|
||||
expect(brainstorm?.kind).toBe("action");
|
||||
expect(brainstorm?.href).toBe("/projects/p1/toolbox?open=brainstorm");
|
||||
expect(brainstorm?.title).toBe("脑洞生成器");
|
||||
expect(brainstorm?.keywords).toContain("玄幻");
|
||||
expect(cmds.find((c) => c.id === "action-gen-book-title")?.href).toBe(
|
||||
"/projects/p1/toolbox?open=book-title",
|
||||
);
|
||||
});
|
||||
|
||||
it("emits no tool commands when none provided", () => {
|
||||
const ids = projectCommands("p1").map((c) => c.id);
|
||||
expect(ids.some((id) => id === "action-gen-brainstorm")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("globalCommands", () => {
|
||||
|
||||
@@ -15,9 +15,30 @@ export interface Command {
|
||||
group: string;
|
||||
}
|
||||
|
||||
// 工具箱生成器的最小描述(命令面板按此生成 action-gen-<key>,无需整份 descriptor)。
|
||||
export interface ToolCommandInfo {
|
||||
key: string;
|
||||
title: string;
|
||||
genre?: string | null;
|
||||
}
|
||||
|
||||
// 项目内可用命令(projectId 已知)。导航项给 href;动作项(生成角色等)给 id。
|
||||
export function projectCommands(projectId: string): Command[] {
|
||||
// tools:工具箱生成器列表(可空)→ 每个生成一条 action-gen-<key> 深链命令(命令面板发现性)。
|
||||
export function projectCommands(
|
||||
projectId: string,
|
||||
tools: readonly ToolCommandInfo[] = [],
|
||||
): Command[] {
|
||||
const p = `/projects/${projectId}`;
|
||||
const toolCommands: Command[] = tools.map((tool) => ({
|
||||
id: `action-gen-${tool.key}`,
|
||||
title: tool.title,
|
||||
keywords: ["生成器", "工具箱", "generator", tool.key, tool.genre ?? ""].filter(
|
||||
(k) => k.length > 0,
|
||||
),
|
||||
kind: "action",
|
||||
href: `${p}/toolbox?open=${tool.key}`,
|
||||
group: "动作",
|
||||
}));
|
||||
return [
|
||||
{
|
||||
id: "nav-write",
|
||||
@@ -67,6 +88,14 @@ export function projectCommands(projectId: string): Command[] {
|
||||
href: `${p}/codex`,
|
||||
group: "导航",
|
||||
},
|
||||
{
|
||||
id: "nav-toolbox",
|
||||
title: "工具箱",
|
||||
keywords: ["toolbox", "工具箱", "生成器", "脑洞", "书名", "金手指"],
|
||||
kind: "navigate",
|
||||
href: `${p}/toolbox`,
|
||||
group: "导航",
|
||||
},
|
||||
{
|
||||
id: "nav-rules",
|
||||
title: "规则",
|
||||
@@ -99,6 +128,7 @@ export function projectCommands(projectId: string): Command[] {
|
||||
href: `${p}/codex?gen=world`,
|
||||
group: "动作",
|
||||
},
|
||||
...toolCommands,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -10,10 +10,10 @@ import {
|
||||
const PID = "p1";
|
||||
|
||||
describe("projectNavItems", () => {
|
||||
it("builds the eight project entries scoped to the project id", () => {
|
||||
it("builds the nine project entries scoped to the project id", () => {
|
||||
const items = projectNavItems(PID);
|
||||
|
||||
expect(items).toHaveLength(8);
|
||||
expect(items).toHaveLength(9);
|
||||
expect(items.map((i) => i.key)).toEqual([
|
||||
"write",
|
||||
"outline",
|
||||
@@ -21,6 +21,7 @@ describe("projectNavItems", () => {
|
||||
"review",
|
||||
"style",
|
||||
"codex",
|
||||
"toolbox",
|
||||
"rules",
|
||||
"skills",
|
||||
]);
|
||||
|
||||
@@ -9,7 +9,8 @@ export type ActiveNav =
|
||||
| "style"
|
||||
| "codex"
|
||||
| "rules"
|
||||
| "skills";
|
||||
| "skills"
|
||||
| "toolbox";
|
||||
|
||||
export interface NavItem {
|
||||
href: string;
|
||||
@@ -36,6 +37,7 @@ export function projectNavItems(projectId: string): NavItem[] {
|
||||
{ href: `/projects/${projectId}/review`, label: "审稿", key: "review" },
|
||||
{ href: `/projects/${projectId}/style`, label: "文风", key: "style" },
|
||||
{ href: `/projects/${projectId}/codex`, label: "设定库", key: "codex" },
|
||||
{ href: `/projects/${projectId}/toolbox`, label: "工具箱", key: "toolbox" },
|
||||
{ href: `/projects/${projectId}/rules`, label: "规则", key: "rules" },
|
||||
{ href: `/projects/${projectId}/skills`, label: "技能库", key: "skills" },
|
||||
];
|
||||
|
||||
89
apps/web/lib/toolbox/ingest.test.ts
Normal file
89
apps/web/lib/toolbox/ingest.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
113
apps/web/lib/toolbox/ingest.ts
Normal file
113
apps/web/lib/toolbox/ingest.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
// 工具箱入库纯逻辑:把结构化预览产物按 output_kind 形变为 ToolIngestRequest 的入库项。
|
||||
// 复用既有入库双 gate(continuity 预检 409 + partition_writes 白名单),故仅负责 body 组装。
|
||||
// 对齐契约:world_entities(金手指/词条)贴 WorldEntityCardView;outline(细纲)贴 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_entities:type 固定「力量体系」,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/name,rules 取 rules(已是硬规则清单)。
|
||||
function glossaryToEntity(row: Record<string, unknown>): WorldEntityCardView {
|
||||
return {
|
||||
type: asString(row["type"]) || "概念",
|
||||
name: asString(row["name"]),
|
||||
rules: asStringArray(row["rules"]),
|
||||
};
|
||||
}
|
||||
|
||||
// 细纲场景 → outline scenes:贴 OutlineSceneIngestView(idx/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;
|
||||
}
|
||||
}
|
||||
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);
|
||||
});
|
||||
});
|
||||
259
apps/web/lib/toolbox/toolbox.ts
Normal file
259
apps/web/lib/toolbox/toolbox.ts
Normal 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 {
|
||||
// 用于「入库」时贴对应 schema(world_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;
|
||||
}
|
||||
162
apps/web/lib/toolbox/useGenerator.ts
Normal file
162
apps/web/lib/toolbox/useGenerator.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user