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:
197
apps/api/tests/test_project_plan_endpoint.py
Normal file
197
apps/api/tests/test_project_plan_endpoint.py
Normal file
@@ -0,0 +1,197 @@
|
||||
"""AI 立项方案生成端点测试(灵感⑤;内存替身,无 DB/无网络)。
|
||||
|
||||
覆盖 `POST /skills/project-plan/generate`(不带 project 前缀):
|
||||
- 无既有 project 也能生成(向导阶段 project 未建)→ 200 结构化预览 + 落 ledger。
|
||||
- 种子门控:缺 genre / logline → 422,不触达网关、不 commit。
|
||||
- 无凭据 → 503。
|
||||
- 向导草稿序列化进网关 context(种子 + 已填字段透传)。
|
||||
LLM 一律 mock,绝不联网。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
from fakes_projects import FakeSession
|
||||
from ww_agents import ProjectPlanResult
|
||||
from ww_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage
|
||||
from ww_shared import AppError, ErrorCode
|
||||
|
||||
|
||||
class _CaptureGateway:
|
||||
"""记录最近一次 req.input(验证向导草稿进了 context),返固定 ProjectPlanResult。"""
|
||||
|
||||
def __init__(self, parsed: ProjectPlanResult) -> None:
|
||||
self._parsed = parsed
|
||||
self.last_input: str | None = None
|
||||
self.calls = 0
|
||||
|
||||
async def run(self, req: LlmRequest) -> LlmResponse:
|
||||
self.calls += 1
|
||||
self.last_input = req.input if isinstance(req.input, str) else str(req.input)
|
||||
return LlmResponse(
|
||||
text=self._parsed.model_dump_json(),
|
||||
parsed=self._parsed,
|
||||
usage=Usage(
|
||||
provider="fake",
|
||||
model="fake",
|
||||
input_tokens=1,
|
||||
output_tokens=1,
|
||||
cost_minor=0,
|
||||
currency="USD",
|
||||
),
|
||||
served_by=ServedBy(provider="fake", model="fake"),
|
||||
)
|
||||
|
||||
|
||||
def _plan() -> ProjectPlanResult:
|
||||
return ProjectPlanResult(
|
||||
title_candidates=["逐光而行", "剑试九霄"],
|
||||
setting="上古仙门世界",
|
||||
narrative_structure="黄金三章立钩 → 升级",
|
||||
story_core="废柴逆袭封神的代价",
|
||||
ending_design="归隐收束(正剧)",
|
||||
tone="热血",
|
||||
)
|
||||
|
||||
|
||||
def _make_app(
|
||||
*,
|
||||
gateway: Any,
|
||||
session: FakeSession | None = None,
|
||||
no_creds: bool = False,
|
||||
) -> tuple[Any, FakeSession]:
|
||||
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
|
||||
from ww_api.main import create_app
|
||||
from ww_api.services.project_deps import get_project_plan_gateway
|
||||
from ww_db import get_session
|
||||
|
||||
session = session or FakeSession()
|
||||
|
||||
async def _ok() -> Any:
|
||||
return gateway
|
||||
|
||||
async def _no_creds() -> Any:
|
||||
raise AppError(ErrorCode.LLM_UNAVAILABLE, "未配置凭据", {"provider": "deepseek"})
|
||||
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_project_plan_gateway] = _no_creds if no_creds else _ok
|
||||
app.dependency_overrides[get_session] = lambda: session
|
||||
return app, session
|
||||
|
||||
|
||||
def _client(app: Any) -> httpx.AsyncClient:
|
||||
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
|
||||
return httpx.AsyncClient(transport=transport, base_url="http://test")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_without_existing_project() -> None:
|
||||
# 向导阶段 project 未建:无 project 前缀端点直接生成,返结构化方案 + 落 ledger。
|
||||
gateway = _CaptureGateway(_plan())
|
||||
app, session = _make_app(gateway=gateway)
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(
|
||||
"/skills/project-plan/generate",
|
||||
json={"genre": "仙侠", "logline": "废柴少年觉醒禁忌血脉,逆势封神。"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["title_candidates"] == ["逐光而行", "剑试九霄"]
|
||||
assert body["story_core"] == "废柴逆袭封神的代价"
|
||||
assert body["ending_design"].endswith("(正剧)")
|
||||
assert body["tone"] == "热血"
|
||||
assert session.commits == 1 # 预览不写业务表,但落 ledger
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_serializes_wizard_draft_into_context() -> None:
|
||||
# 种子 + 已填字段序列化进网关 context(有则贴合)。
|
||||
gateway = _CaptureGateway(_plan())
|
||||
app, _ = _make_app(gateway=gateway)
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(
|
||||
"/skills/project-plan/generate",
|
||||
json={
|
||||
"genre": "都市",
|
||||
"logline": "外卖骑手觉醒读心术。",
|
||||
"title": "听风者",
|
||||
"tone": "轻松",
|
||||
"selling_points": ["逆袭", "系统流"],
|
||||
"brief": "偏悬疑向",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert gateway.last_input is not None
|
||||
assert "题材:都市" in gateway.last_input
|
||||
assert "一句话故事:外卖骑手觉醒读心术。" in gateway.last_input
|
||||
assert "暂定书名:听风者" in gateway.last_input
|
||||
assert "核心卖点:逆袭、系统流" in gateway.last_input
|
||||
assert "偏悬疑向" in gateway.last_input # brief 进 context
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_requires_seed_missing_logline_422() -> None:
|
||||
# 种子门控:缺 logline → 422,不触达网关、不 commit。
|
||||
gateway = _CaptureGateway(_plan())
|
||||
app, session = _make_app(gateway=gateway)
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post("/skills/project-plan/generate", json={"genre": "仙侠"})
|
||||
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["error"]["code"] == ErrorCode.VALIDATION
|
||||
assert gateway.calls == 0
|
||||
assert session.commits == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_requires_seed_missing_genre_422() -> None:
|
||||
gateway = _CaptureGateway(_plan())
|
||||
app, session = _make_app(gateway=gateway)
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post("/skills/project-plan/generate", json={"logline": "一句话故事。"})
|
||||
|
||||
assert resp.status_code == 422
|
||||
assert gateway.calls == 0
|
||||
assert session.commits == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_requires_seed_blank_strings_422() -> None:
|
||||
# 纯空白种子也拒(strip 后为空)。
|
||||
gateway = _CaptureGateway(_plan())
|
||||
app, session = _make_app(gateway=gateway)
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(
|
||||
"/skills/project-plan/generate", json={"genre": " ", "logline": " "}
|
||||
)
|
||||
|
||||
assert resp.status_code == 422
|
||||
assert gateway.calls == 0
|
||||
assert session.commits == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_no_credentials_503() -> None:
|
||||
app, session = _make_app(gateway=object(), no_creds=True)
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(
|
||||
"/skills/project-plan/generate",
|
||||
json={"genre": "仙侠", "logline": "一句话故事。"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 503
|
||||
assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE
|
||||
assert session.commits == 0
|
||||
@@ -26,8 +26,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_agents import (
|
||||
CharacterCard,
|
||||
CharacterRelation,
|
||||
ProjectPlanResult,
|
||||
character_gen_spec,
|
||||
continuity_spec,
|
||||
project_plan_spec,
|
||||
worldbuilder_spec,
|
||||
)
|
||||
from ww_core.domain import (
|
||||
@@ -37,8 +39,10 @@ from ww_core.domain import (
|
||||
)
|
||||
from ww_core.domain.repositories import MemoryRepos
|
||||
from ww_core.orchestrator import (
|
||||
build_brief_context,
|
||||
precheck_generated_cards,
|
||||
run_character_gen,
|
||||
run_generator,
|
||||
run_worldbuilder,
|
||||
)
|
||||
from ww_db import get_session
|
||||
@@ -64,6 +68,7 @@ from ww_api.schemas.generation import (
|
||||
WorldGenerateRequest,
|
||||
WorldGenPreviewResponse,
|
||||
)
|
||||
from ww_api.schemas.projects import ProjectPlanGenerateRequest, ProjectPlanView
|
||||
from ww_api.schemas.rules import RuleView
|
||||
from ww_api.services.credentials import STUB_OWNER_ID
|
||||
from ww_api.services.project_deps import (
|
||||
@@ -71,6 +76,7 @@ from ww_api.services.project_deps import (
|
||||
get_character_write_repo,
|
||||
get_memory_repos,
|
||||
get_precheck_gateway,
|
||||
get_project_plan_gateway,
|
||||
get_project_repo,
|
||||
get_rule_write_repo,
|
||||
get_skill_registry,
|
||||
@@ -90,6 +96,7 @@ SkillRegistryDep = Annotated[SkillRegistry, Depends(get_skill_registry)]
|
||||
WorldGatewayDep = Annotated[Gateway, Depends(get_worldbuilder_gateway)]
|
||||
CharacterGatewayDep = Annotated[Gateway, Depends(get_character_gen_gateway)]
|
||||
PrecheckGatewayDep = Annotated[Gateway, Depends(get_precheck_gateway)]
|
||||
ProjectPlanGatewayDep = Annotated[Gateway, Depends(get_project_plan_gateway)]
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
|
||||
@@ -105,6 +112,46 @@ def _project_context(title: str, genre: str | None, premise: str | None, theme:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _plan_seed_context(body: ProjectPlanGenerateRequest) -> str:
|
||||
"""把立项向导草稿序列化为作品种子(喂 project-plan;仿 `_project_context`)。
|
||||
|
||||
确定性、无时间戳/UUID;只拼非空字段(有则贴合、无则不臆造)。
|
||||
genre + logline 由端点种子门控保证非空。
|
||||
"""
|
||||
lines: list[str] = []
|
||||
label_fields: list[tuple[str, str | None]] = [
|
||||
("暂定书名", body.title),
|
||||
("题材", body.genre),
|
||||
("一句话故事", body.logline),
|
||||
("立意", body.premise),
|
||||
("主题", body.theme),
|
||||
("故事结构", body.structure),
|
||||
("基调", body.tone),
|
||||
("结局取向", body.ending_type),
|
||||
("叙事视角", body.narrative_pov),
|
||||
]
|
||||
for label, value in label_fields:
|
||||
text = (value or "").strip()
|
||||
if text:
|
||||
lines.append(f"{label}:{text}")
|
||||
points = [p.strip() for p in body.selling_points if p.strip()]
|
||||
if points:
|
||||
lines.append(f"核心卖点:{'、'.join(points)}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _plan_to_view(plan: ProjectPlanResult) -> ProjectPlanView:
|
||||
"""ww_agents.ProjectPlanResult → API ProjectPlanView(字段同名直传)。"""
|
||||
return ProjectPlanView(
|
||||
title_candidates=list(plan.title_candidates),
|
||||
setting=plan.setting,
|
||||
narrative_structure=plan.narrative_structure,
|
||||
story_core=plan.story_core,
|
||||
ending_design=plan.ending_design,
|
||||
tone=plan.tone,
|
||||
)
|
||||
|
||||
|
||||
def _render_characters_context(cards: list[CharacterCard]) -> str:
|
||||
"""已有角色简表(喂 character-gen 防雷同 + precheck 真相源)。确定性、保序。"""
|
||||
if not cards:
|
||||
@@ -430,6 +477,53 @@ async def list_skills(registry: SkillRegistryDep) -> SkillListResponse:
|
||||
return SkillListResponse(skills=skills)
|
||||
|
||||
|
||||
# ---- AI 立项方案生成(灵感⑤;不带 project 前缀,立项前 project 未建)----
|
||||
|
||||
|
||||
@skills_router.post("/project-plan/generate")
|
||||
async def generate_project_plan(
|
||||
body: ProjectPlanGenerateRequest,
|
||||
request: Request,
|
||||
gateway: ProjectPlanGatewayDep,
|
||||
session: SessionDep,
|
||||
) -> ProjectPlanView:
|
||||
"""生成结构化立项方案预览(向导阶段,project 未建,不入库)。
|
||||
|
||||
**不带 project 前缀**——绕开 toolbox 的 `project_repo.get→404`(立项前无 project)。
|
||||
**种子门控**:缺 genre 或 logline → 422(空向导不出方案,防 slop)。无凭据 → 503。
|
||||
"""
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
genre = (body.genre or "").strip()
|
||||
logline = (body.logline or "").strip()
|
||||
if not genre or not logline:
|
||||
raise AppError(
|
||||
ErrorCode.VALIDATION,
|
||||
"生成立项方案至少需要「题材」与「一句话故事(logline)」两个种子",
|
||||
{"has_genre": bool(genre), "has_logline": bool(logline)},
|
||||
)
|
||||
|
||||
project_context = _plan_seed_context(body)
|
||||
context = build_brief_context(brief=body.brief or "", project_context=project_context)
|
||||
parsed = await run_generator(
|
||||
project_plan_spec,
|
||||
context=context,
|
||||
gateway=gateway,
|
||||
user_id=STUB_OWNER_ID,
|
||||
project_id=None, # 向导阶段 project 未建;usage_ledger.project_id 可空
|
||||
)
|
||||
# 提交边界:预览不写业务表,但网关 ledger 只 flush → 末尾 commit 落 usage(否则丢失)。
|
||||
await session.commit()
|
||||
if not isinstance(parsed, ProjectPlanResult):
|
||||
# run_generator 已按 output_schema 校验;防御性收窄(不应触达)。
|
||||
raise AppError(ErrorCode.INTERNAL, "立项方案生成返回类型异常")
|
||||
log.info(
|
||||
"project_plan_generate_done",
|
||||
request_id=request_id,
|
||||
title_count=len(parsed.title_candidates),
|
||||
)
|
||||
return _plan_to_view(parsed)
|
||||
|
||||
|
||||
# ---- schema <-> ww_agents 卡转换 ----
|
||||
|
||||
|
||||
|
||||
@@ -52,6 +52,64 @@ class ProjectListResponse(BaseModel):
|
||||
projects: list[ProjectResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ---- AI 立项方案生成(灵感⑤;向导阶段 project 未建,纯预览不写库)----
|
||||
|
||||
# 请求字段长度上界(CR-H9:防超长向导草稿撑爆上下文/成本)。短字段=标签级,长字段=段落级。
|
||||
_PLAN_SHORT_MAX = 200
|
||||
_PLAN_LONG_MAX = 4000
|
||||
_PLAN_LIST_MAX = 20
|
||||
|
||||
|
||||
class ProjectPlanGenerateRequest(BaseModel):
|
||||
"""POST /skills/project-plan/generate:向导草稿种子(不带 project 前缀,立项前 project 未建)。
|
||||
|
||||
**种子门控**:`genre` + `logline` 为必给种子(端点校验非空,否则 422)——空向导不出方案,
|
||||
防 slop。其余字段有则贴合、无则不臆造(序列化进 project_context,仿 `_project_context`)。
|
||||
全字段带 `max_length` 上界(CR-H9),`brief` = 作者一句话方向(可空)。
|
||||
"""
|
||||
|
||||
genre: str | None = Field(default=None, max_length=_PLAN_SHORT_MAX, description="题材(种子)")
|
||||
logline: str | None = Field(
|
||||
default=None, max_length=_PLAN_LONG_MAX, description="一句话故事(种子)"
|
||||
)
|
||||
title: str | None = Field(
|
||||
default=None, max_length=_PLAN_SHORT_MAX, description="暂定书名(可空)"
|
||||
)
|
||||
premise: str | None = Field(default=None, max_length=_PLAN_LONG_MAX, description="立意(可空)")
|
||||
theme: str | None = Field(default=None, max_length=_PLAN_SHORT_MAX, description="主题(可空)")
|
||||
structure: str | None = Field(
|
||||
default=None, max_length=_PLAN_SHORT_MAX, description="故事结构(可空)"
|
||||
)
|
||||
tone: str | None = Field(default=None, max_length=_PLAN_SHORT_MAX, description="基调(可空)")
|
||||
ending_type: str | None = Field(
|
||||
default=None, max_length=_PLAN_SHORT_MAX, description="结局取向(可空)"
|
||||
)
|
||||
narrative_pov: str | None = Field(
|
||||
default=None, max_length=_PLAN_SHORT_MAX, description="叙事视角(可空)"
|
||||
)
|
||||
selling_points: list[str] = Field(
|
||||
default_factory=list, max_length=_PLAN_LIST_MAX, description="核心卖点(可空)"
|
||||
)
|
||||
# `str | None`(非 `str=""`):codegen 渲染为可选字段,前端种子请求可省略(brief 本就可空)。
|
||||
brief: str | None = Field(
|
||||
default=None, max_length=_PLAN_LONG_MAX, description="作者一句话方向/需求(可空)"
|
||||
)
|
||||
|
||||
|
||||
class ProjectPlanView(BaseModel):
|
||||
"""立项方案生成结果(贴 ww_agents.ProjectPlanResult;结构化预览,不入库)。
|
||||
|
||||
全字段有默认值(守解析韧性)——前端按字段回填立项向导,只填非空交集、不覆盖作者已编辑值。
|
||||
"""
|
||||
|
||||
title_candidates: list[str] = Field(default_factory=list, description="书名候选清单")
|
||||
setting: str = Field(default="", description="时空背景")
|
||||
narrative_structure: str = Field(default="", description="叙事结构")
|
||||
story_core: str = Field(default="", description="故事核心")
|
||||
ending_design: str = Field(default="", description="结局设计")
|
||||
tone: str = Field(default="", description="基调")
|
||||
|
||||
|
||||
class DraftStreamRequest(BaseModel):
|
||||
"""POST /projects/:id/chapters/:no/draft:本章生成的可选输入(T4-b)。
|
||||
|
||||
|
||||
@@ -268,6 +268,14 @@ async def get_precheck_gateway(
|
||||
return await build_gateway_for_tier(session, store, "analyst")
|
||||
|
||||
|
||||
async def get_project_plan_gateway(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> Gateway:
|
||||
"""AI 立项方案生成(analyst 档位)的可注入网关缝。测试注 mock(产 ProjectPlanResult)。"""
|
||||
store = SqlCredentialStore(session)
|
||||
return await build_gateway_for_tier(session, store, "analyst")
|
||||
|
||||
|
||||
def get_session_factory() -> SessionFactory:
|
||||
"""验收后到期扫描的**独立 session 工厂**缝。
|
||||
|
||||
|
||||
@@ -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="故事结构"
|
||||
|
||||
164
apps/web/lib/api/schema.d.ts
vendored
164
apps/web/lib/api/schema.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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→空编辑器)。
|
||||
|
||||
95
apps/web/lib/wizard/useProjectPlan.test.ts
Normal file
95
apps/web/lib/wizard/useProjectPlan.test.ts
Normal 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("后端返回 error:status=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();
|
||||
});
|
||||
});
|
||||
59
apps/web/lib/wizard/useProjectPlan.ts
Normal file
59
apps/web/lib/wizard/useProjectPlan.ts
Normal 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 };
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user