向导阶段(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。
174 lines
6.6 KiB
TypeScript
174 lines
6.6 KiB
TypeScript
import type {
|
||
ProjectCreateRequest,
|
||
ProjectPlanGenerateRequest,
|
||
ProjectPlanView,
|
||
} from "@/lib/api/types";
|
||
|
||
// 立项向导 5 步(UX §6.2)。表单状态用字符串/数组,提交时归一为 ProjectCreateRequest。
|
||
export interface WizardForm {
|
||
title: string;
|
||
genre: string;
|
||
logline: string;
|
||
sellingPoints: string[];
|
||
structure: string;
|
||
premise: string;
|
||
protagonist: string;
|
||
theme: string;
|
||
// 立项书级常量(灵感④):基调 / 结局取向 / 叙事视角。
|
||
tone: string;
|
||
endingType: string;
|
||
narrativePov: string;
|
||
}
|
||
|
||
export const WIZARD_STEPS = 5;
|
||
|
||
// 各步标题(与 WIZARD_STEPS 一一对应)。第 5 步为「确认/概览」:回显已填字段供复核后提交。
|
||
export const STEP_TITLES = [
|
||
"书名 + 题材",
|
||
"一句话故事 + 卖点 + 结构",
|
||
"立意 / 总纲",
|
||
"主角 / 金手指",
|
||
"确认与提交",
|
||
] as const;
|
||
|
||
export const emptyWizardForm: WizardForm = {
|
||
title: "",
|
||
genre: "",
|
||
logline: "",
|
||
sellingPoints: [],
|
||
structure: "",
|
||
premise: "",
|
||
protagonist: "",
|
||
theme: "",
|
||
tone: "",
|
||
endingType: "",
|
||
narrativePov: "",
|
||
};
|
||
|
||
export const GENRES = ["玄幻", "仙侠", "都市", "科幻", "历史", "悬疑"];
|
||
export const STRUCTURES = ["三幕", "故事圈", "雪花"];
|
||
export const SELLING_POINT_PRESETS = ["逆袭", "打脸", "系统流", "双男主", "群像"];
|
||
// 立项书级常量预设(灵感④ / D 枚举)——free text 单选,命不中预设可留空。
|
||
export const TONES = ["热血", "轻松", "沉重", "治愈", "暗黑", "爽文"];
|
||
export const ENDING_TYPES = ["HE", "BE", "开放", "正剧"];
|
||
export const NARRATIVE_POVS = ["第一人称", "第三人称限知", "第三人称全知", "多视角"];
|
||
|
||
// 每一步可否前进:仅第 1 步(书名)必填,其余可空着继续(草稿式立项)。
|
||
export function canAdvance(step: number, form: WizardForm): boolean {
|
||
if (step === 1) return form.title.trim().length > 0;
|
||
return true;
|
||
}
|
||
|
||
// 整个向导可否提交:书名必填。
|
||
export function canSubmit(form: WizardForm): boolean {
|
||
return form.title.trim().length > 0;
|
||
}
|
||
|
||
export function clampStep(step: number): number {
|
||
if (step < 1) return 1;
|
||
if (step > WIZARD_STEPS) return WIZARD_STEPS;
|
||
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 => {
|
||
const v = s.trim();
|
||
return v.length > 0 ? v : null;
|
||
};
|
||
// 立意(premise)与主角/金手指(protagonist)是两个独立输入,但 M1 projects 表只有
|
||
// premise 一个字段 → 合并进 premise,二者各占一段,互不覆盖(QA H1:此前共用同一字段)。
|
||
const premise =
|
||
[trim(form.premise), form.protagonist.trim() ? `主角/金手指:${form.protagonist.trim()}` : null]
|
||
.filter((s): s is string => s !== null)
|
||
.join("\n\n") || null;
|
||
return {
|
||
title: form.title.trim(),
|
||
genre: trim(form.genre),
|
||
logline: trim(form.logline),
|
||
premise,
|
||
theme: trim(form.theme),
|
||
selling_points: form.sellingPoints,
|
||
structure: trim(form.structure),
|
||
tone: trim(form.tone),
|
||
ending_type: trim(form.endingType),
|
||
narrative_pov: trim(form.narrativePov),
|
||
};
|
||
}
|