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:
Yaojia Wang
2026-07-06 16:26:08 +02:00
parent 821ace5989
commit 02d19019f6
22 changed files with 1187 additions and 9 deletions

View File

@@ -1,9 +1,10 @@
"use client";
import { ArrowLeft, ArrowRight, Check, CheckCircle2 } from "lucide-react";
import { ArrowLeft, ArrowRight, Check, CheckCircle2, Sparkles } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { useToast } from "@/components/Toast";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
@@ -14,6 +15,8 @@ import { TextArea } from "@/components/ui/TextArea";
import { TextInput } from "@/components/ui/TextInput";
import { api } from "@/lib/api/client";
import { buttonClass } from "@/lib/ui/variants";
import type { ProjectPlanView } from "@/lib/api/types";
import { useProjectPlan } from "@/lib/wizard/useProjectPlan";
import {
ENDING_TYPES,
GENRES,
@@ -23,11 +26,15 @@ import {
STRUCTURES,
TONES,
WIZARD_STEPS,
applyPlanPatch,
canAdvance,
canGeneratePlan,
canSubmit,
clampStep,
emptyWizardForm,
mapPlanResultToWizardForm,
toCreateRequest,
toPlanSeedRequest,
type WizardForm,
} from "@/lib/wizard/wizard";
@@ -61,6 +68,21 @@ export function ProjectWizard() {
: [...prev.sellingPoints, point],
}));
// AI 立项方案按字段回填:只填当前为空的字段(保护作者已编辑内容,不 bulk 覆盖)。
const applyPlan = (plan: ProjectPlanView): void => {
const patch = mapPlanResultToWizardForm(plan);
const filled = (Object.keys(patch) as Array<keyof WizardForm>).filter(
(k) => typeof form[k] === "string" && (form[k] as string).trim() === "",
);
setForm((prev) => applyPlanPatch(prev, patch));
toast(
filled.length > 0
? `已回填 ${filled.length} 个空字段(已编辑内容不覆盖)`
: "向导相关字段均已填写,未覆盖任何内容",
filled.length > 0 ? "success" : "info",
);
};
const goBack = (): void => setStep((s) => clampStep(s - 1));
const goNext = (): void => setStep((s) => clampStep(s + 1));
@@ -110,6 +132,7 @@ export function ProjectWizard() {
form={form}
update={update}
toggleSellingPoint={toggleSellingPoint}
onApplyPlan={applyPlan}
/>
)}
{step === 3 && <StepPremise form={form} update={update} />}
@@ -199,11 +222,91 @@ function StepBasics({ form, update }: StepProps) {
);
}
// AI 立项方案助手(灵感⑤):种子(题材+logline就绪后一键生成结构化方案 → 预览 → 按字段回填。
function PlanAssistant({
form,
onApplyPlan,
}: {
form: WizardForm;
onApplyPlan: (plan: ProjectPlanView) => void;
}) {
const { status, plan, generate } = useProjectPlan();
const ready = canGeneratePlan(form);
const generating = status === "generating";
return (
<div className="my-4 rounded border border-line bg-bg p-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="flex items-center gap-1.5 text-sm text-ink">
<Sparkles className="h-4 w-4 text-cinnabar" aria-hidden="true" />
AI
</p>
<p className="mt-0.5 text-xs text-ink-soft">
{ready
? "根据题材 + 一句话故事推演书级蓝图,按字段回填(不覆盖已填内容)。"
: "填好「题材」(上一步)与「一句话故事」后即可生成。"}
</p>
</div>
<Button
onClick={() => void generate(toPlanSeedRequest(form))}
disabled={!ready || generating}
variant="secondary"
>
{generating ? (
<ThinkingIndicator label="生成中" />
) : (
<>
<Sparkles className="h-4 w-4" aria-hidden="true" />
</>
)}
</Button>
</div>
{status === "done" && plan && (
<div className="mt-3 border-t border-line pt-3">
<dl className="space-y-1.5 text-sm">
{plan.title_candidates && plan.title_candidates.length > 0 && (
<PlanRow label="书名候选" value={plan.title_candidates.join("、")} />
)}
<PlanRow label="时空背景" value={plan.setting} />
<PlanRow label="叙事结构" value={plan.narrative_structure} />
<PlanRow label="故事核心" value={plan.story_core} />
<PlanRow label="结局设计" value={plan.ending_design} />
<PlanRow label="基调" value={plan.tone} />
</dl>
<div className="mt-3 flex justify-end">
<Button onClick={() => onApplyPlan(plan)} variant="primary">
<Check className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
</div>
)}
</div>
);
}
function PlanRow({ label, value }: { label: string; value: string }) {
if (!value.trim()) return null;
return (
<div className="flex gap-3">
<dt className="w-16 shrink-0 text-ink-soft">{label}</dt>
<dd className="min-w-0 flex-1 whitespace-pre-wrap text-ink">{value}</dd>
</div>
);
}
function StepStory({
form,
update,
toggleSellingPoint,
}: StepProps & { toggleSellingPoint: (p: string) => void }) {
onApplyPlan,
}: StepProps & {
toggleSellingPoint: (p: string) => void;
onApplyPlan: (plan: ProjectPlanView) => void;
}) {
return (
<div>
<Field label="一句话故事logline">
@@ -214,6 +317,7 @@ function StepStory({
placeholder="废柴少年觉醒禁忌血脉,在仙门倾轧中逆势封神。"
/>
</Field>
<PlanAssistant form={form} onApplyPlan={onApplyPlan} />
<Field label="故事结构">
<SegmentedControl
ariaLabel="故事结构"

View File

@@ -560,6 +560,29 @@ export interface paths {
patch?: never;
trace?: never;
};
"/skills/project-plan/generate": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Generate Project Plan
* @description 生成结构化立项方案预览向导阶段project 未建,不入库)。
*
* **不带 project 前缀**——绕开 toolbox 的 `project_repo.get→404`(立项前无 project
* **种子门控**:缺 genre 或 logline → 422空向导不出方案防 slop。无凭据 → 503。
*/
post: operations["generate_project_plan_skills_project_plan_generate_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/skills/toolbox": {
parameters: {
query?: never;
@@ -1533,6 +1556,114 @@ export interface components {
/** Projects */
projects?: components["schemas"]["ProjectResponse"][];
};
/**
* ProjectPlanGenerateRequest
* @description POST /skills/project-plan/generate向导草稿种子不带 project 前缀,立项前 project 未建)。
*
* **种子门控**`genre` + `logline` 为必给种子(端点校验非空,否则 422——空向导不出方案
* 防 slop。其余字段有则贴合、无则不臆造序列化进 project_context仿 `_project_context`)。
* 全字段带 `max_length` 上界CR-H9`brief` = 作者一句话方向(可空)。
*/
ProjectPlanGenerateRequest: {
/**
* Genre
* @description 题材(种子)
*/
genre?: string | null;
/**
* Logline
* @description 一句话故事(种子)
*/
logline?: string | null;
/**
* Title
* @description 暂定书名(可空)
*/
title?: string | null;
/**
* Premise
* @description 立意(可空)
*/
premise?: string | null;
/**
* Theme
* @description 主题(可空)
*/
theme?: string | null;
/**
* Structure
* @description 故事结构(可空)
*/
structure?: string | null;
/**
* Tone
* @description 基调(可空)
*/
tone?: string | null;
/**
* Ending Type
* @description 结局取向(可空)
*/
ending_type?: string | null;
/**
* Narrative Pov
* @description 叙事视角(可空)
*/
narrative_pov?: string | null;
/**
* Selling Points
* @description 核心卖点(可空)
*/
selling_points?: string[];
/**
* Brief
* @description 作者一句话方向/需求(可空)
*/
brief?: string | null;
};
/**
* ProjectPlanView
* @description 立项方案生成结果(贴 ww_agents.ProjectPlanResult结构化预览不入库
*
* 全字段有默认值(守解析韧性)——前端按字段回填立项向导,只填非空交集、不覆盖作者已编辑值。
*/
ProjectPlanView: {
/**
* Title Candidates
* @description 书名候选清单
*/
title_candidates?: string[];
/**
* Setting
* @description 时空背景
* @default
*/
setting: string;
/**
* Narrative Structure
* @description 叙事结构
* @default
*/
narrative_structure: string;
/**
* Story Core
* @description 故事核心
* @default
*/
story_core: string;
/**
* Ending Design
* @description 结局设计
* @default
*/
ending_design: string;
/**
* Tone
* @description 基调
* @default
*/
tone: string;
};
/**
* ProjectResponse
* @description 项目视图(创建/列表/详情共用)。
@@ -3360,6 +3491,39 @@ export interface operations {
};
};
};
generate_project_plan_skills_project_plan_generate_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ProjectPlanGenerateRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProjectPlanView"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
list_toolbox_skills_toolbox_get: {
parameters: {
query?: never;

View File

@@ -4,6 +4,10 @@ import type { components } from "./schema";
export type ProjectResponse = components["schemas"]["ProjectResponse"];
export type ProjectCreateRequest = components["schemas"]["ProjectCreateRequest"];
export type ProjectListResponse = components["schemas"]["ProjectListResponse"];
// AI 立项方案生成(灵感⑤):向导阶段 project 未建,种子门控 + 结构化预览回填向导。
export type ProjectPlanGenerateRequest =
components["schemas"]["ProjectPlanGenerateRequest"];
export type ProjectPlanView = components["schemas"]["ProjectPlanView"];
export type DraftSaveRequest = components["schemas"]["DraftSaveRequest"];
export type DraftResponse = components["schemas"]["DraftResponse"];
// GET .../draft 读端点含正文供工作台重访时重载编辑器404→空编辑器

View 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("后端返回 errorstatus=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();
});
});

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

View File

@@ -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 () => {

View File

@@ -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 => {