feat(web): AI 立项方案生成+预填(种子门控 + 按字段接受)
向导阶段(project 未建)一键推演结构化书级蓝图预填立项向导。 - 新 SPEC project_plan_spec(analyst 档,纯预览 reads/writes=())+ schema ProjectPlanResult(书名候选 + 时空背景/叙事结构/故事核心/结局设计/基调, 全字段默认值守解析韧性)+ 注册 SCHEMA_CATALOG;计数三处 21→22 + regen golden。 - 端点 POST /skills/project-plan/generate(不带 project 前缀,绕开 toolbox 404): 请求体序列化向导草稿为 project_context,复用 build_brief_context + run_generator; 种子门控(缺 genre/logline→422);请求 schema 加 max_length 上界。 - run_generator/_build_request 的 project_id 放宽 uuid.UUID|None(向导无 project)。 - 前端 wizard.ts 加 mapPlanResultToWizardForm(错配字段折进 premise 不静默丢)+ applyPlanPatch(仅回填空字段、保护已编辑)+ useProjectPlan hook + PlanAssistant。
This commit is contained in:
95
apps/web/lib/wizard/useProjectPlan.test.ts
Normal file
95
apps/web/lib/wizard/useProjectPlan.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ProjectPlanGenerateRequest } from "@/lib/api/types";
|
||||
|
||||
import { useProjectPlan } from "./useProjectPlan";
|
||||
|
||||
// 后端客户端与 Toast 是 hook 的外部副作用边界,单测一律 mock。
|
||||
const post = vi.fn();
|
||||
const toast = vi.fn();
|
||||
vi.mock("@/lib/api/client", () => ({ api: { POST: (...a: unknown[]) => post(...a) } }));
|
||||
vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
|
||||
|
||||
const seed: ProjectPlanGenerateRequest = {
|
||||
genre: "仙侠",
|
||||
logline: "废柴逆袭封神。",
|
||||
};
|
||||
|
||||
describe("useProjectPlan", () => {
|
||||
beforeEach(() => {
|
||||
post.mockReset();
|
||||
toast.mockReset();
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it("初始为 idle、无方案", () => {
|
||||
const { result } = renderHook(() => useProjectPlan());
|
||||
expect(result.current.status).toBe("idle");
|
||||
expect(result.current.plan).toBeNull();
|
||||
});
|
||||
|
||||
it("生成成功:status 走到 done 并填充方案", async () => {
|
||||
const plan = {
|
||||
title_candidates: ["逐光而行"],
|
||||
setting: "仙门世界",
|
||||
narrative_structure: "立钩",
|
||||
story_core: "逆袭",
|
||||
ending_design: "归隐",
|
||||
tone: "热血",
|
||||
};
|
||||
post.mockResolvedValue({ data: plan, error: null });
|
||||
|
||||
const { result } = renderHook(() => useProjectPlan());
|
||||
await act(async () => {
|
||||
await result.current.generate(seed);
|
||||
});
|
||||
|
||||
expect(result.current.status).toBe("done");
|
||||
expect(result.current.plan).toEqual(plan);
|
||||
expect(toast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("后端返回 error:status=error 且弹错误 toast", async () => {
|
||||
post.mockResolvedValue({ data: null, error: { detail: "配额不足" } });
|
||||
const { result } = renderHook(() => useProjectPlan());
|
||||
await act(async () => {
|
||||
await result.current.generate(seed);
|
||||
});
|
||||
expect(result.current.status).toBe("error");
|
||||
expect(result.current.plan).toBeNull();
|
||||
expect(toast).toHaveBeenCalledWith(expect.any(String), "error");
|
||||
});
|
||||
|
||||
it("请求抛异常:status=error 且弹网络异常 toast", async () => {
|
||||
post.mockRejectedValue(new Error("network down"));
|
||||
const { result } = renderHook(() => useProjectPlan());
|
||||
await act(async () => {
|
||||
await result.current.generate(seed);
|
||||
});
|
||||
expect(result.current.status).toBe("error");
|
||||
expect(toast).toHaveBeenCalledWith("生成请求异常,请检查网络。", "error");
|
||||
});
|
||||
|
||||
it("reset 清回 idle 与空方案", async () => {
|
||||
post.mockResolvedValue({
|
||||
data: {
|
||||
title_candidates: ["书"],
|
||||
setting: "",
|
||||
narrative_structure: "",
|
||||
story_core: "",
|
||||
ending_design: "",
|
||||
tone: "",
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
const { result } = renderHook(() => useProjectPlan());
|
||||
await act(async () => {
|
||||
await result.current.generate(seed);
|
||||
});
|
||||
act(() => result.current.reset());
|
||||
expect(result.current.status).toBe("idle");
|
||||
expect(result.current.plan).toBeNull();
|
||||
});
|
||||
});
|
||||
59
apps/web/lib/wizard/useProjectPlan.ts
Normal file
59
apps/web/lib/wizard/useProjectPlan.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { useToast } from "@/components/Toast";
|
||||
import { api } from "@/lib/api/client";
|
||||
import { generationErrorMessage } from "@/lib/generation/cards";
|
||||
import type {
|
||||
ProjectPlanGenerateRequest,
|
||||
ProjectPlanView,
|
||||
} from "@/lib/api/types";
|
||||
|
||||
export type ProjectPlanStatus = "idle" | "generating" | "done" | "error";
|
||||
|
||||
export interface UseProjectPlan {
|
||||
status: ProjectPlanStatus;
|
||||
plan: ProjectPlanView | null;
|
||||
generate: (seed: ProjectPlanGenerateRequest) => Promise<void>;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
// AI 立项方案生成器:POST /skills/project-plan/generate → 结构化方案预览(不入库)。
|
||||
// 向导阶段调用(project 未建);种子门控在后端亦校验(缺 genre/logline → 422)。
|
||||
export function useProjectPlan(): UseProjectPlan {
|
||||
const [status, setStatus] = useState<ProjectPlanStatus>("idle");
|
||||
const [plan, setPlan] = useState<ProjectPlanView | null>(null);
|
||||
const toast = useToast();
|
||||
|
||||
const generate = useCallback<UseProjectPlan["generate"]>(
|
||||
async (seed) => {
|
||||
setStatus("generating");
|
||||
setPlan(null);
|
||||
try {
|
||||
const { data, error } = await api.POST(
|
||||
"/skills/project-plan/generate",
|
||||
{ body: seed },
|
||||
);
|
||||
if (error || !data) {
|
||||
setStatus("error");
|
||||
toast(generationErrorMessage(error), "error");
|
||||
return;
|
||||
}
|
||||
setPlan(data);
|
||||
setStatus("done");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
toast("生成请求异常,请检查网络。", "error");
|
||||
}
|
||||
},
|
||||
[toast],
|
||||
);
|
||||
|
||||
const reset = useCallback((): void => {
|
||||
setStatus("idle");
|
||||
setPlan(null);
|
||||
}, []);
|
||||
|
||||
return { status, plan, generate, reset };
|
||||
}
|
||||
@@ -1,16 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ProjectPlanView } from "@/lib/api/types";
|
||||
|
||||
import {
|
||||
applyPlanPatch,
|
||||
canAdvance,
|
||||
canGeneratePlan,
|
||||
canSubmit,
|
||||
clampStep,
|
||||
emptyWizardForm,
|
||||
mapPlanResultToWizardForm,
|
||||
STEP_TITLES,
|
||||
toCreateRequest,
|
||||
toPlanSeedRequest,
|
||||
WIZARD_STEPS,
|
||||
type WizardForm,
|
||||
} from "./wizard";
|
||||
|
||||
const fullPlan: ProjectPlanView = {
|
||||
title_candidates: ["逐光而行", "剑试九霄"],
|
||||
setting: "上古仙门倾轧的洞天世界",
|
||||
narrative_structure: "黄金三章立钩 → 拜师 → 宗门大比爆发",
|
||||
story_core: "废柴少年觉醒禁忌血脉,逆势封神的代价",
|
||||
ending_design: "登顶后归隐,自我救赎收束",
|
||||
tone: "热血",
|
||||
};
|
||||
|
||||
describe("wizard step metadata", () => {
|
||||
it("provides one title per step", () => {
|
||||
expect(STEP_TITLES).toHaveLength(WIZARD_STEPS);
|
||||
@@ -122,6 +137,123 @@ describe("toCreateRequest", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ⑤ AI 立项方案 → 向导映射(含 ④ 字段 tone;错配字段折进 premise,不静默丢内容)。
|
||||
describe("mapPlanResultToWizardForm", () => {
|
||||
it("maps title candidate, structure and tone to their wizard homes", () => {
|
||||
const patch = mapPlanResultToWizardForm(fullPlan);
|
||||
// 书名候选 → title(取首个)。
|
||||
expect(patch.title).toBe("逐光而行");
|
||||
// 叙事结构 → structure。
|
||||
expect(patch.structure).toBe("黄金三章立钩 → 拜师 → 宗门大比爆发");
|
||||
// ④ 字段:基调 → tone。
|
||||
expect(patch.tone).toBe("热血");
|
||||
});
|
||||
|
||||
it("folds setting and ending design into premise without dropping content", () => {
|
||||
const patch = mapPlanResultToWizardForm(fullPlan);
|
||||
// 故事核心 + 时空背景 + 结局设计 逐段拼进 premise(无独立向导字段,不静默丢)。
|
||||
expect(patch.premise).toContain("废柴少年觉醒禁忌血脉,逆势封神的代价");
|
||||
expect(patch.premise).toContain("时空背景:上古仙门倾轧的洞天世界");
|
||||
expect(patch.premise).toContain("结局设计:登顶后归隐,自我救赎收束");
|
||||
});
|
||||
|
||||
it("does not fabricate endingType or narrativePov (④ presets left for author)", () => {
|
||||
// 结局取向/叙事视角是预设枚举,无法从自由文本可靠反推 → 不进 patch(不臆造)。
|
||||
const patch = mapPlanResultToWizardForm(fullPlan);
|
||||
expect(patch.endingType).toBeUndefined();
|
||||
expect(patch.narrativePov).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits empty fields from the patch (parse resilience)", () => {
|
||||
const empty: ProjectPlanView = {
|
||||
title_candidates: [],
|
||||
setting: "",
|
||||
narrative_structure: "",
|
||||
story_core: "",
|
||||
ending_design: "",
|
||||
tone: "",
|
||||
};
|
||||
expect(mapPlanResultToWizardForm(empty)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
// applyPlanPatch:按字段并入、仅填空字段(保护作者已编辑内容,非 bulk 覆盖)。
|
||||
describe("applyPlanPatch", () => {
|
||||
it("fills only empty fields, preserving author-edited values", () => {
|
||||
const form: WizardForm = {
|
||||
...emptyWizardForm,
|
||||
title: "作者已取的书名",
|
||||
genre: "仙侠",
|
||||
};
|
||||
const patch = { title: "AI书名", structure: "三幕", tone: "热血" };
|
||||
const next = applyPlanPatch(form, patch);
|
||||
// 已编辑的 title 受保护、不被覆盖;空的 structure/tone 才回填。
|
||||
expect(next.title).toBe("作者已取的书名");
|
||||
expect(next.structure).toBe("三幕");
|
||||
expect(next.tone).toBe("热血");
|
||||
// 未在 patch 中的字段原样保留。
|
||||
expect(next.genre).toBe("仙侠");
|
||||
});
|
||||
|
||||
it("treats whitespace-only fields as empty (fillable)", () => {
|
||||
const form: WizardForm = { ...emptyWizardForm, premise: " " };
|
||||
const next = applyPlanPatch(form, { premise: "AI 立意" });
|
||||
expect(next.premise).toBe("AI 立意");
|
||||
});
|
||||
|
||||
it("returns a new object without mutating the input form", () => {
|
||||
const form: WizardForm = { ...emptyWizardForm };
|
||||
const next = applyPlanPatch(form, { tone: "热血" });
|
||||
expect(next).not.toBe(form);
|
||||
expect(form.tone).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
// canGeneratePlan:种子门控(题材 + logline 都非空)。
|
||||
describe("canGeneratePlan", () => {
|
||||
it("requires both genre and logline", () => {
|
||||
expect(canGeneratePlan(emptyWizardForm)).toBe(false);
|
||||
expect(
|
||||
canGeneratePlan({ ...emptyWizardForm, genre: "仙侠" }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canGeneratePlan({ ...emptyWizardForm, logline: "一句话" }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canGeneratePlan({ ...emptyWizardForm, genre: "仙侠", logline: "一句话" }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects whitespace-only seeds", () => {
|
||||
expect(
|
||||
canGeneratePlan({ ...emptyWizardForm, genre: " ", logline: " " }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// toPlanSeedRequest:向导草稿 → 生成请求(snake_case,空值省略)。
|
||||
describe("toPlanSeedRequest", () => {
|
||||
it("serializes seed and filled fields, omitting empties", () => {
|
||||
const form: WizardForm = {
|
||||
...emptyWizardForm,
|
||||
genre: "都市",
|
||||
logline: "外卖骑手觉醒读心术。",
|
||||
title: "听风者",
|
||||
sellingPoints: ["逆袭"],
|
||||
endingType: "HE",
|
||||
};
|
||||
const req = toPlanSeedRequest(form);
|
||||
expect(req.genre).toBe("都市");
|
||||
expect(req.logline).toBe("外卖骑手觉醒读心术。");
|
||||
expect(req.title).toBe("听风者");
|
||||
expect(req.ending_type).toBe("HE");
|
||||
expect(req.selling_points).toEqual(["逆袭"]);
|
||||
// 空字段省略(undefined),不发空串。
|
||||
expect(req.premise).toBeUndefined();
|
||||
expect(req.tone).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// wizard→create 流程:归一后的请求体经 mock 客户端提交,返回新建项目 id。
|
||||
describe("wizard create flow (mocked client)", () => {
|
||||
it("submits the normalized body and resolves a project id", async () => {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { ProjectCreateRequest } from "@/lib/api/types";
|
||||
import type {
|
||||
ProjectCreateRequest,
|
||||
ProjectPlanGenerateRequest,
|
||||
ProjectPlanView,
|
||||
} from "@/lib/api/types";
|
||||
|
||||
// 立项向导 5 步(UX §6.2)。表单状态用字符串/数组,提交时归一为 ProjectCreateRequest。
|
||||
export interface WizardForm {
|
||||
@@ -66,6 +70,82 @@ export function clampStep(step: number): number {
|
||||
return step;
|
||||
}
|
||||
|
||||
// AI 立项方案 → 向导表单补丁(灵感⑤)。按字段回填:
|
||||
// - 有对应向导输入的字段直接映射(书名候选→title、叙事结构→structure、基调→tone);
|
||||
// - 无独立向导输入的字段(时空背景/结局设计)折进立意/总纲(premise,带标签),**不静默丢内容**;
|
||||
// - 结局取向/叙事视角这类预设枚举无法从自由文本可靠反推 → 不臆造,留给作者手选(不进 patch)。
|
||||
// 返回补丁而非整表覆盖,由 applyPlanPatch 按字段并入(保护已编辑字段)。
|
||||
export function mapPlanResultToWizardForm(
|
||||
plan: ProjectPlanView,
|
||||
): Partial<WizardForm> {
|
||||
const patch: Partial<WizardForm> = {};
|
||||
const firstTitle = plan.title_candidates?.[0]?.trim();
|
||||
if (firstTitle) patch.title = firstTitle;
|
||||
const structure = plan.narrative_structure.trim();
|
||||
if (structure) patch.structure = structure;
|
||||
const tone = plan.tone.trim();
|
||||
if (tone) patch.tone = tone;
|
||||
// 立意/总纲 = 故事核心 + 无独立向导字段的时空背景/结局设计(带标签,逐段拼接不丢内容)。
|
||||
const premiseParts: string[] = [];
|
||||
const core = plan.story_core.trim();
|
||||
if (core) premiseParts.push(core);
|
||||
const setting = plan.setting.trim();
|
||||
if (setting) premiseParts.push(`时空背景:${setting}`);
|
||||
const ending = plan.ending_design.trim();
|
||||
if (ending) premiseParts.push(`结局设计:${ending}`);
|
||||
if (premiseParts.length > 0) patch.premise = premiseParts.join("\n\n");
|
||||
return patch;
|
||||
}
|
||||
|
||||
// WizardForm 中值为字符串的字段键(排除 sellingPoints: string[])——方案 patch 只回填这些。
|
||||
type WizardStringKey = {
|
||||
[K in keyof WizardForm]: WizardForm[K] extends string ? K : never;
|
||||
}[keyof WizardForm];
|
||||
|
||||
// 把方案补丁按字段并入向导表单:**仅回填当前为空的字段**(保护作者已编辑内容,
|
||||
// 非 bulk 覆盖)。只处理字符串字段(方案 patch 只产字符串值);空 = trim 后为空串。
|
||||
export function applyPlanPatch(
|
||||
form: WizardForm,
|
||||
patch: Partial<WizardForm>,
|
||||
): WizardForm {
|
||||
const next: WizardForm = { ...form };
|
||||
for (const key of Object.keys(patch) as WizardStringKey[]) {
|
||||
const value = patch[key];
|
||||
// 守卫短路:非字符串值(如误传数组键)直接跳过,绝不触达 .trim()。
|
||||
if (typeof value === "string" && form[key].trim() === "") {
|
||||
next[key] = value;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
// AI 立项方案的种子门控:题材 + 一句话故事(logline)都非空才允许生成(后端亦校验,防 slop)。
|
||||
export function canGeneratePlan(form: WizardForm): boolean {
|
||||
return form.genre.trim().length > 0 && form.logline.trim().length > 0;
|
||||
}
|
||||
|
||||
// 向导草稿 → 立项方案生成请求(snake_case;空值省略)。genre+logline 为种子,其余有则贴合。
|
||||
export function toPlanSeedRequest(
|
||||
form: WizardForm,
|
||||
): ProjectPlanGenerateRequest {
|
||||
const trimOrUndef = (s: string): string | undefined => {
|
||||
const v = s.trim();
|
||||
return v.length > 0 ? v : undefined;
|
||||
};
|
||||
return {
|
||||
genre: trimOrUndef(form.genre),
|
||||
logline: trimOrUndef(form.logline),
|
||||
title: trimOrUndef(form.title),
|
||||
premise: trimOrUndef(form.premise),
|
||||
theme: trimOrUndef(form.theme),
|
||||
structure: trimOrUndef(form.structure),
|
||||
tone: trimOrUndef(form.tone),
|
||||
ending_type: trimOrUndef(form.endingType),
|
||||
narrative_pov: trimOrUndef(form.narrativePov),
|
||||
selling_points: form.sellingPoints,
|
||||
};
|
||||
}
|
||||
|
||||
// 归一为后端请求体(snake_case,空值落 null/省略)。
|
||||
export function toCreateRequest(form: WizardForm): ProjectCreateRequest {
|
||||
const trim = (s: string): string | null => {
|
||||
|
||||
Reference in New Issue
Block a user