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