Files
writer-work-flow/apps/web/lib/wizard/wizard.ts
Yaojia Wang 02d19019f6 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。
2026-07-06 16:26:08 +02:00

174 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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