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:
@@ -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="故事结构"
|
||||
|
||||
Reference in New Issue
Block a user