diff --git a/.hermes/plans/2026-05-13_112225-visual-novel-one-line-genarrative-plan.md b/.hermes/plans/2026-05-13_112225-visual-novel-one-line-genarrative-plan.md new file mode 100644 index 00000000..3a5d2fe4 --- /dev/null +++ b/.hermes/plans/2026-05-13_112225-visual-novel-one-line-genarrative-plan.md @@ -0,0 +1,343 @@ +# Genarrative 视觉小说“一句话生成”最小闭环落地计划 + +生成时间:2026-05-13 11:22 +工作区:`C:/proj/Genarrative/.worktrees/hermes-visual-novel` +参考文档:`C:/Users/DSK/Documents/Interactive-fiction/一句话生成视觉小说整体流程总结.md` + +## 1. 目标 + +把 Interactive-fiction 总结文档中的“一句话生成视觉小说”流程,映射并落地到 Genarrative 现有视觉小说能力中,优先做成一个可端到端验证的最小闭环: + +1. 用户在视觉小说入口输入一句话并选择画风。 +2. 前端进入生成过程页,展示分阶段进度。 +3. 后端创建视觉小说创作会话,并基于 seedText 生成 `VisualNovelResultDraft`。 +4. 生成完成后进入草稿结果页,可看到世界观、角色、场景、剧情阶段、开场选择。 +5. 草稿可编译/保存为作品 profile,并进入视觉小说运行态测试/正式游玩。 + +本计划只覆盖 Genarrative 内部最小闭环,不引入 Interactive-fiction 原项目的独立 TXT 播放记录、分享播放包、外部活动运营、独立账号/交易/资产系统。 + +## 2. 当前上下文与已发现实现 + +### 2.1 Interactive-fiction 总结文档提炼 + +参考文档将整体流程分为: + +- 输入侧:一句话创意、主题/风格、可选文档或素材。 +- 生成侧:理解意图、扩展世界观、角色、场景、剧情阶段、开场与选择。 +- 编辑侧:草稿页可查看和调整生成结果。 +- 运行侧:从草稿进入视觉小说游玩,支持剧情推进、玩家选择、历史与状态。 +- 资产侧:角色立绘、背景、音乐/音效可作为后续增强,最小闭环可先使用文字描述与空资产占位。 + +### 2.2 Genarrative 已有实现基础 + +已确认项目中视觉小说相关能力并非从零开始: + +- 前端入口表单: + - `src/components/visual-novel-creation/VisualNovelAgentWorkspace.tsx` + - 已有“一句话创作” textarea、6 个视觉画风选项、提交按钮“生成视觉小说草稿”。 +- 前端入口 payload/progress: + - `src/components/visual-novel-creation/visualNovelEntryGeneration.ts` + - 已有 `VisualNovelEntryFormPayload`、锚点展示、一句话/画风生成进度步骤。 +- 前端平台主流程: + - `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx` + - 已接入 `createVisualNovelDraftFromForm`,会创建 session、stream message、进入 `visual-novel-generating`,完成后进入 `visual-novel-result`。 +- 前端 API client: + - `src/services/visual-novel-creation/visualNovelCreationClient.ts` + - 已封装 session/message/action/compile 接口。 +- 共享契约: + - `packages/shared/src/contracts/visualNovel.ts` + - 已定义 `VisualNovelResultDraft`、world/characters/scenes/storyPhases/opening/runtimeConfig/work/run/history 等结构。 +- 后端 API: + - `server-rs/crates/api-server/src/visual_novel.rs` + - 已有创建 session、发消息、流式消息、执行 action、compile、work、runtime run 等接口。 +- 后端 prompt: + - `server-rs/crates/api-server/src/prompt/visual_novel.rs` + - 已有 `VISUAL_NOVEL_CREATION_SYSTEM_PROMPT`、结构化输出契约、runtime GM prompt、repair prompt。 +- SpacetimeDB 模块: + - `server-rs/crates/spacetime-module/src/visual_novel.rs` + - 已有 session/message/work/run/history/event 表与 procedure。 +- 文档参考: + - `docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md` + - `docs/technical/VISUAL_NOVEL_IMPLEMENTATION_HANDOFF_2026-05-07.md` + - `docs/technical/VISUAL_NOVEL_PROMPT_AND_LLM_TOOLS_VN03_2026-05-05.md` + +### 2.3 关键实现判断 + +当前项目已经实现了视觉小说的主要骨架,本次不应大规模重写。更合理的落地方式是补齐“一句话生成”闭环中最容易断裂的点: + +- 入口输入与画风信息是否被稳定传给后端 prompt。 +- 后端生成 draft 后是否自动保存/关联可编辑 work profile。 +- 生成过程页是否能清晰展示 Interactive-fiction 文档中提到的阶段。 +- 结果页是否有足够的字段展示与继续游玩入口。 +- 运行态是否能基于 opening/choices 正常启动,而不依赖尚未生成的图片/音乐资产。 + +## 3. 拟采用方案 + +### 3.1 最小闭环范围 + +本次优先实现: + +1. “一句话 + 视觉画风”作为 `sourceMode: 'idea'` 的 seedText。 +2. 后端生成完整 `VisualNovelResultDraft`,包括: + - world + - 3-6 个角色 + - 3-8 个场景 + - 3-6 个剧情阶段 + - opening narration/firstDialogue/2-4 个 choices + - runtimeConfig +3. 若 LLM 输出失败,使用 repair 或确定性 fallback,保证可回到草稿页并显示错误/警告。 +4. 结果页支持保存/编译为 work profile。 +5. work profile 支持启动 runtime run,opening 能展示初始场景、旁白、对话和选择。 + +暂不做或仅预留: + +- 真实图片/音乐生成队列。 +- 多文档解析导入的完整链路。 +- 复杂分镜/节点图编辑器。 +- 外部 Interactive-fiction 项目的播放器、TXT 记录包、分享活动、独立账号系统。 + +### 3.2 与 Genarrative 架构的映射 + +| Interactive-fiction 概念 | Genarrative 落点 | +| --- | --- | +| 一句话创意 | `VisualNovelEntryFormPayload.ideaText` / `seedText` | +| 画风/主题 | `seedText` 中的“视觉画风/画风要求”,后续可结构化为 metadata | +| 世界观设定 | `VisualNovelResultDraft.world` | +| 角色设定 | `VisualNovelResultDraft.characters` | +| 场景设定 | `VisualNovelResultDraft.scenes` | +| 剧情阶段/章节 | `VisualNovelResultDraft.storyPhases` | +| 开场文本与选项 | `VisualNovelResultDraft.opening` | +| 运行时剧情推进 | `VisualNovelRuntimeStep[]` + run snapshot/history | +| 发布/作品库 | `VisualNovelWorkProfileRecord` / works API | + +## 4. 分步计划 + +### Step 1:补齐入口 payload 与生成过程语义 + +涉及文件: + +- `src/components/visual-novel-creation/VisualNovelAgentWorkspace.tsx` +- `src/components/visual-novel-creation/visualNovelEntryGeneration.ts` +- `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx` + +任务: + +1. 保持现有 6 个画风选项,但确认每个 option 的 prompt 会进入 `seedText`。 +2. 将生成过程阶段从当前 3 步细化为更贴合参考文档的 4-5 步,例如: + - 理解一句话创意 + - 扩展世界观与玩家身份 + - 设计角色/场景/剧情阶段 + - 生成开场与选择 + - 准备可编辑草稿 +3. 生成过程页的 anchor 保留“一句话”和“视觉画风”,必要时增加“生成目标:视觉小说草稿”。 +4. 确认 `createVisualNovelDraftFromForm` 对失败状态会保留返回入口/重试能力。 + +验收点:提交一句话后能进入 `visual-novel-generating`,看到阶段进度;完成后进入 `visual-novel-result`。 + +### Step 2:增强后端 creation prompt 与 fallback 约束 + +涉及文件: + +- `server-rs/crates/api-server/src/prompt/visual_novel.rs` +- `server-rs/crates/api-server/src/visual_novel.rs` +- 如已有 domain crate:`server-rs/crates/module-visual-novel/**` 或相关 normalize/validate 文件 + +任务: + +1. 在 creation prompt 中显式吸收 Interactive-fiction 的“一句话生成”目标: + - 从 seedText 提取核心创意、视觉风格、故事类型。 + - 生成可直接运行的 opening 和 choices。 + - 图片/音乐资产先置 null,但必须有可生成图像的描述。 +2. 强化输出约束: + - `opening.sceneId` 必须指向存在且 availability 为 `opening` 的 scene。 + - `opening.initialChoices` 必须 2-4 个。 + - `storyPhases[0]` 必须包含 opening scene 和主要角色。 + - `publishReady` 的判定与 validationIssues 一致。 +3. 检查 `submit_visual_novel_message_turn` / `resolve_action_draft` / compile 相关代码: + - 如果 LLM 失败,是否已有 fallback;没有则补确定性 fallback draft。 + - 如果 draft 不完整,是否会 normalize/repair 并写入 session。 +4. 保留现有“不要输出旧 TXT 播放记录、分享播放包、外部商业字段”的约束,避免把参考项目的外部概念误并入 Genarrative。 + +验收点:后端给定 seedText 时,返回 session.draft 不为空且满足共享契约。 + +### Step 3:确认草稿结果页、保存/编译与作品库链路 + +涉及文件: + +- `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx` +- `src/components/visual-novel-creation/**` +- `src/services/visual-novel-works*` 或相关 visual novel works client +- `server-rs/crates/api-server/src/visual_novel.rs` +- `packages/shared/src/contracts/visualNovel.ts` + +任务: + +1. 查找并确认 `visual-novel-result` 页面组件: + - 是否显示 workTitle/workDescription/world/characters/scenes/storyPhases/opening。 + - 是否有保存/发布/开始试玩按钮。 +2. 确认 `compileVisualNovelWorkProfile` 或 `executeVisualNovelAction({kind:'compile_work_profile'})` 会生成/更新 work profile。 +3. 确认作品架上使用 `profileId` 而不是 sessionId 作为稳定作品 ID。 +4. 如果结果页缺少“一句话来源/画风”的可视化提示,可在结果页或 summary 中补轻量展示,避免用户以为画风丢失。 + +验收点:生成完成后能保存为作品;作品出现在“我的作品/创作架”;再次打开能读取同一 draft。 + +### Step 4:确认运行态 opening 闭环 + +涉及文件: + +- `src/components/visual-novel-runtime/**` +- `src/services/visual-novel-runtime*` +- `server-rs/crates/api-server/src/visual_novel.rs` +- `server-rs/crates/api-server/src/prompt/visual_novel.rs` +- `packages/shared/src/contracts/visualNovel.ts` + +任务: + +1. 启动 visual novel work run 时,优先使用 `draft.opening` 生成第一轮 runtime snapshot/history。 +2. 如果没有图片/音乐,前端 runtime shell 必须可用文字 fallback,不应白屏或阻断游玩。 +3. 玩家选择 `choice` 后,后端 runtime GM prompt 生成下一轮 `VisualNovelRuntimeStep[]`。 +4. 确认正式游玩入口调用 `work_play_start`,并满足已有埋点约定: + - `scope_kind=work` + - `scope_id=稳定作品 ID` + - metadata 包含 `playType/workId/sourceRoute/userId` 等。 + +验收点:从生成出的作品进入运行态,能看到 opening 并点击至少一个选择推进一轮。 + +### Step 5:补测试与文档 + +涉及文件: + +- 前端测试:按仓库现有测试布局查找 `*.test.ts` / `*.test.tsx` +- Rust 测试:`server-rs/crates/api-server/src/**` 或 domain crate tests +- 文档:可追加到 `docs/technical/` 或 `.hermes/shared-memory/decision-log.md`(如团队约定需要) + +建议测试: + +1. TypeScript 单元测试: + - `buildVisualNovelEntryGenerationProgress` 阶段输出。 + - `buildVisualNovelEntryGenerationAnchorEntries` 能展示一句话和画风。 +2. Rust 单元测试: + - creation prompt 包含 seedText、sourceMode、输出契约。 + - draft normalize/fallback 能生成合法 opening/choices。 + - runtime opening 或 first-step 构造不依赖图片/音乐。 +3. 集成/手工测试文档: + - 访问平台视觉小说入口。 + - 输入一句话。 + - 选择画风。 + - 点击生成。 + - 查看结果页。 + - 保存作品。 + - 启动试玩并点击选择。 + +## 5. 可能改动文件清单 + +高概率改动: + +- `src/components/visual-novel-creation/VisualNovelAgentWorkspace.tsx` +- `src/components/visual-novel-creation/visualNovelEntryGeneration.ts` +- `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx` +- `server-rs/crates/api-server/src/prompt/visual_novel.rs` +- `server-rs/crates/api-server/src/visual_novel.rs` +- `packages/shared/src/contracts/visualNovel.ts` + +中概率改动: + +- `src/components/visual-novel-runtime/**` +- `src/services/visual-novel-creation/**` +- `src/services/visual-novel-runtime/**` +- `src/services/visual-novel-works/**` +- `server-rs/crates/spacetime-module/src/visual_novel.rs` +- `server-rs/crates/spacetime-client/**` 生成/绑定文件,若 SpacetimeDB contract 需要更新 + +低概率/仅文档: + +- `docs/technical/VISUAL_NOVEL_IMPLEMENTATION_HANDOFF_2026-05-07.md` +- `docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md` +- `.hermes/shared-memory/decision-log.md` + +## 6. 验证计划 + +### 6.1 静态检查 + +在 worktree 根目录执行: + +```bash +npm run typecheck +``` + +如仓库无统一 typecheck,则按 package scripts 选择最接近的前端类型检查命令。 + +### 6.2 前端定向测试 + +优先运行与 visual novel / platform entry 相关测试,如存在: + +```bash +npm test -- visual-novel +npm test -- platform-entry +``` + +若仓库使用 vitest: + +```bash +npm run test -- visual-novel +``` + +### 6.3 Rust 定向测试 + +在 `server-rs` 下运行 visual novel 相关测试: + +```bash +cargo test -p api-server visual_novel +cargo test -p shared-contracts visual_novel +``` + +如改动 SpacetimeDB module: + +```bash +cargo test -p spacetime-module visual_novel +``` + +### 6.4 人工验收步骤 + +1. 启动本地 dev 栈。 +2. 访问 Genarrative 主站。 +3. 进入创作/视觉小说入口。 +4. 输入:`一个雨夜,失忆的高中生在旧图书馆发现一本会回应她心声的日记。` +5. 选择任一画风,例如“映画动画”。 +6. 点击“生成视觉小说草稿”。 +7. 预期:进入生成过程页,能看到分阶段进度。 +8. 预期:完成后进入草稿结果页,包含标题、简介、世界观、角色、场景、剧情阶段和 opening choices。 +9. 点击保存/编译作品。 +10. 从作品入口进入试玩。 +11. 预期:opening 文本出现,至少 2 个选择可点击;点击后剧情继续推进一轮。 + +## 7. 风险、权衡与开放问题 + +### 7.1 风险 + +- 现有视觉小说代码已较完整,贸然新增一套 parallel pipeline 会制造重复逻辑;应复用当前 `VisualNovelResultDraft` 与 creation agent flow。 +- LLM 输出不稳定可能导致草稿结构不完整;需要 normalize/repair/fallback 确保最小闭环。 +- 视觉/音乐资产生成未接入时,UI 必须接受 null asset,否则运行态可能白屏。 +- `PlatformEntryFlowShellImpl.tsx` 文件很大,改动需局部、谨慎,避免影响其他玩法入口。 +- 若改动 SpacetimeDB 表结构,可能牵涉 publish、client binding、清库/迁移;最小闭环阶段应尽量避免 schema 变更。 + +### 7.2 权衡 + +- 先让文字版视觉小说完整跑通,再补角色立绘/背景图生成。 +- 先用 `seedText` 承载画风,再考虑把 `visualStyleId/Label/Prompt` 结构化进 draft metadata。 +- 先用现有 result/work/runtime 页面闭环,不引入新编辑器。 + +### 7.3 开放问题 + +1. 用户是否要求把 Interactive-fiction 原项目中的具体 UI 样式/页面布局迁移到 Genarrative?当前计划只迁移流程语义,不迁移独立 UI。 +2. 画风是否需要成为作品可编辑字段?当前以 seedText/prompt 影响生成内容,后续可在 draft 中增加 metadata。 +3. 文档导入模式是否本期要做?当前计划聚焦一句话模式,document 模式只保留契约能力。 +4. 是否需要真实图片/音乐生成?当前计划作为后续增强,不纳入最小闭环。 + +## 8. 建议实施顺序 + +1. 先做只改 prompt/progress/少量前端展示的轻量闭环修补。 +2. 运行前后端定向测试,确认现有能力是否已足够。 +3. 如果后端没有 fallback 或 normalize,再补 Rust 层确定性兜底。 +4. 手工跑通“一句话 -> 生成 -> 结果页 -> 保存 -> 试玩”。 +5. 最后再考虑是否需要资产生成、文档导入、结构化画风 metadata。 diff --git a/server-rs/crates/api-server/src/prompt/visual_novel.rs b/server-rs/crates/api-server/src/prompt/visual_novel.rs index 24289717..8dcc8ba9 100644 --- a/server-rs/crates/api-server/src/prompt/visual_novel.rs +++ b/server-rs/crates/api-server/src/prompt/visual_novel.rs @@ -224,11 +224,23 @@ pub(crate) fn build_visual_novel_creation_user_prompt( "currentDraft": params.current_draft, "recentMessages": params.recent_messages, "nowIso": params.now_iso, + "oneLineGenerationFlow": [ + "提取一句话核心创意、故事类型、玩家身份和视觉画风", + "扩展世界观、故事前提、文学风格和默认叙事语气", + "设计 3 到 6 个角色,并为每个角色写出可生成立绘的 appearance", + "设计 3 到 8 个场景,并为 opening 场景写出可生成背景图的 description", + "组织 3 到 6 个剧情阶段,第一阶段必须能从 opening 进入", + "生成 opening.narration、可选 firstDialogue 和 2 到 4 个 initialChoices", + "图片、音乐可先为 null,但文字草稿必须可进入结果页编辑、保存并试玩" + ], "draftRequirements": { "mainCharacters": "3 到 6 个,至少 1 个非玩家主要角色", "scenes": "3 到 8 个,至少 1 个 opening 场景", "storyPhases": "3 到 6 个,第一阶段可从 opening 进入", - "initialChoices": "2 到 4 个", + "initialChoices": "2 到 4 个 initialChoices", + "openingScene": "opening.sceneId 必须指向存在且 availability 为 opening 的 scene", + "firstPhase": "storyPhases[0] 必须包含 opening scene 和主要角色", + "assetFallback": "图片、音乐可先为 null,但 appearance 和 scene description 必须足够后续生成资产", "runtimeConfigDefaults": "沿用契约默认值,attributePanelMode 默认为 off" }, "outputContract": VISUAL_NOVEL_CREATION_OUTPUT_CONTRACT @@ -616,6 +628,29 @@ mod tests { assert!(repair_prompt.contains("scene_change")); } + #[test] + fn creation_prompt_guides_one_line_flow_into_playable_draft() { + let asset_ids = source_asset_ids(); + let prompt = build_visual_novel_creation_user_prompt(VisualNovelCreationPromptParams { + source_mode: "idea", + seed_text: Some( + "雨夜旧图书馆里,失忆高中生发现一本会回应心声的日记。\n视觉画风:映画动画\n画风要求:电影感动画视觉小说画风。", + ), + source_asset_ids: asset_ids.as_slice(), + document_summary: None, + current_draft: None, + recent_messages: &[], + now_iso: "2026-05-13T12:00:00Z", + }); + + assert!(prompt.contains("oneLineGenerationFlow")); + assert!(prompt.contains("提取一句话核心创意")); + assert!(prompt.contains("视觉画风")); + assert!(prompt.contains("opening.sceneId")); + assert!(prompt.contains("2 到 4 个 initialChoices")); + assert!(prompt.contains("图片、音乐可先为 null")); + } + #[test] fn llm_requests_use_responses_template_model() { let asset_ids = source_asset_ids(); diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index e0fcfcd5..2d27ca06 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -1409,6 +1409,12 @@ fn build_public_work_like_id(source_type: &str, profile_id: &str, user_id: &str) mod tests { use super::*; + #[test] + fn duplicate_tracking_event_ids_are_treated_as_idempotent_replays() { + assert!(should_skip_existing_tracking_event_id(true)); + assert!(!should_skip_existing_tracking_event_id(false)); + } + #[test] fn recent_public_work_play_counts_group_requested_profiles_in_window() { let now_micros = PUBLIC_WORK_PLAY_DAY_MICROS * 10; @@ -3223,6 +3229,10 @@ fn record_daily_login_tracking_event(ctx: &ReducerContext, user_id: &str) -> Res ) } +fn should_skip_existing_tracking_event_id(event_exists: bool) -> bool { + event_exists +} + fn record_tracking_event( ctx: &ReducerContext, input: RuntimeTrackingEventInput, @@ -3242,6 +3252,15 @@ fn record_tracking_event( .map_err(|error| error.to_string())?; let occurred_at = Timestamp::from_micros_since_unix_epoch(validated_input.occurred_at_micros); let day_key = runtime_profile_beijing_day_key(validated_input.occurred_at_micros); + if should_skip_existing_tracking_event_id( + ctx.db + .tracking_event() + .event_id() + .find(&validated_input.event_id) + .is_some(), + ) { + return Ok(()); + } // 中文注释:埋点事实与日期维表使用同一北京时间业务日桶,先幂等补齐维表,避免后续周/月/季/年聚合缺少 bucket 映射。 ensure_analytics_date_dimension_row(ctx, day_key)?; ctx.db.tracking_event().insert(TrackingEvent { diff --git a/src/components/visual-novel-creation/visualNovelEntryGeneration.test.ts b/src/components/visual-novel-creation/visualNovelEntryGeneration.test.ts new file mode 100644 index 00000000..63f64d5f --- /dev/null +++ b/src/components/visual-novel-creation/visualNovelEntryGeneration.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from 'vitest'; + +import { + buildVisualNovelEntryGenerationAnchorEntries, + buildVisualNovelEntryGenerationProgress, + type VisualNovelEntryFormPayload, +} from './visualNovelEntryGeneration'; + +function createVisualNovelPayload( + overrides: Partial = {}, +): VisualNovelEntryFormPayload { + return { + sourceMode: 'idea', + seedText: + '雨夜旧图书馆里,失忆高中生发现一本会回应心声的日记。\n视觉画风:映画动画\n画风要求:电影感动画视觉小说画风。', + sourceAssetIds: [], + ideaText: '雨夜旧图书馆里,失忆高中生发现一本会回应心声的日记。', + visualStyleId: 'cinematic-anime', + visualStyleLabel: '映画动画', + visualStylePrompt: '电影感动画视觉小说画风。', + ...overrides, + }; +} + +describe('visualNovelEntryGeneration', () => { + test('one-line visual novel generation exposes reference-flow stages', () => { + const progress = buildVisualNovelEntryGenerationProgress( + 1_000, + 'generating', + 1_500, + ); + + expect(progress.steps.map((step) => step.id)).toEqual([ + 'visual-novel-intent', + 'visual-novel-world', + 'visual-novel-cast-scenes', + 'visual-novel-opening', + 'visual-novel-ready', + ]); + expect(progress.phaseLabel).toBe('理解一句话创意'); + expect(progress.steps[0]?.detail).toBe( + '提取核心题材、视觉画风、玩家身份和互动叙事目标。', + ); + expect(progress.estimatedRemainingMs).toBe(44_500); + expect(progress.overallProgress).toBeGreaterThan(0); + }); + + test('one-line visual novel generation advances to opening choices before ready', () => { + const progress = buildVisualNovelEntryGenerationProgress( + 1_000, + 'generating', + 35_000, + ); + + expect(progress.phaseId).toBe('visual-novel-opening'); + expect(progress.phaseLabel).toBe('生成开场与选择'); + expect(progress.steps[2]?.status).toBe('completed'); + expect(progress.steps[3]?.status).toBe('active'); + expect(progress.overallProgress).toBeLessThan(99); + }); + + test('one-line visual novel generation ready copy points to editable draft', () => { + const progress = buildVisualNovelEntryGenerationProgress( + 1_000, + 'ready', + 46_000, + ); + + expect(progress.phaseId).toBe('ready'); + expect(progress.phaseLabel).toBe('生成完成'); + expect(progress.phaseDetail).toBe( + '视觉小说草稿已准备完成,可进入结果页编辑、保存并试玩。', + ); + expect(progress.overallProgress).toBe(100); + }); + + test('one-line visual novel generation anchors include source, style and target', () => { + const entries = buildVisualNovelEntryGenerationAnchorEntries( + createVisualNovelPayload(), + ); + + expect(entries).toEqual([ + { + id: 'visual-novel-idea', + label: '一句话', + value: '雨夜旧图书馆里,失忆高中生发现一本会回应心声的日记。', + }, + { + id: 'visual-novel-style', + label: '视觉画风', + value: '映画动画', + }, + { + id: 'visual-novel-target', + label: '生成目标', + value: '可编辑并可试玩的视觉小说草稿', + }, + ]); + }); +}); diff --git a/src/components/visual-novel-creation/visualNovelEntryGeneration.ts b/src/components/visual-novel-creation/visualNovelEntryGeneration.ts index ad6cb4b3..8bbb8b25 100644 --- a/src/components/visual-novel-creation/visualNovelEntryGeneration.ts +++ b/src/components/visual-novel-creation/visualNovelEntryGeneration.ts @@ -41,6 +41,11 @@ export function buildVisualNovelEntryGenerationAnchorEntries( label: '视觉画风', value: payload.visualStyleLabel, }, + { + id: 'visual-novel-target', + label: '生成目标', + value: '可编辑并可试玩的视觉小说草稿', + }, ].filter((entry) => entry.value.trim()); } @@ -72,27 +77,55 @@ export function buildVisualNovelEntryGenerationProgress( weight: number; durationMs: number; }, - ] = [ { - id: 'visual-novel-session', - label: '创建创作会话', - detail: '写入一句话与视觉画风,准备生成视觉小说底稿。', - weight: 24, - durationMs: 5_000, + id: string; + label: string; + detail: string; + weight: number; + durationMs: number; }, { - id: 'visual-novel-draft', - label: '生成故事底稿', - detail: '整理世界观、角色、场景和剧情阶段。', - weight: 56, - durationMs: 22_000, + id: string; + label: string; + detail: string; + weight: number; + durationMs: number; + }, + ] = [ + { + id: 'visual-novel-intent', + label: '理解一句话创意', + detail: '提取核心题材、视觉画风、玩家身份和互动叙事目标。', + weight: 16, + durationMs: 6_000, + }, + { + id: 'visual-novel-world', + label: '扩展世界观', + detail: '生成世界背景、故事前提、文学风格和玩家角色。', + weight: 22, + durationMs: 10_000, + }, + { + id: 'visual-novel-cast-scenes', + label: '设计角色与场景', + detail: '补齐主要角色、可生成立绘的外观描述和 opening 场景。', + weight: 28, + durationMs: 16_000, + }, + { + id: 'visual-novel-opening', + label: '生成开场与选择', + detail: '写入开场旁白、首句对白、剧情阶段和 2 到 4 个初始选择。', + weight: 24, + durationMs: 10_000, }, { id: 'visual-novel-ready', label: '准备草稿页', - detail: '校验可编辑字段并进入草稿页。', - weight: 20, - durationMs: 4_000, + detail: '校验可编辑字段并进入结果页,后续可保存作品和试玩。', + weight: 10, + durationMs: 3_000, }, ]; let elapsedBeforeStep = 0; @@ -130,9 +163,13 @@ export function buildVisualNovelEntryGenerationProgress( : phase === 'failed' ? Math.max(1, completedWeight) : Math.min(98, completedWeight + activeStep.weight * activeRatio); + const estimatedTotalMs = timeline.reduce( + (sum, step) => sum + step.durationMs, + 0, + ); return { - phaseId: phase, + phaseId: phase === 'generating' ? activeStep.id : phase, phaseLabel: phase === 'ready' ? '生成完成' @@ -141,7 +178,7 @@ export function buildVisualNovelEntryGenerationProgress( : activeStep.label, phaseDetail: phase === 'ready' - ? '视觉小说草稿已准备完成。' + ? '视觉小说草稿已准备完成,可进入结果页编辑、保存并试玩。' : phase === 'failed' ? '草稿生成失败,请返回入口页调整后重试。' : activeStep.detail, @@ -151,7 +188,7 @@ export function buildVisualNovelEntryGenerationProgress( totalWeight: 100, elapsedMs, estimatedRemainingMs: - phase === 'ready' ? 0 : Math.max(0, 31_000 - elapsedMs), + phase === 'ready' ? 0 : Math.max(0, estimatedTotalMs - elapsedMs), activeStepIndex: normalizedActiveStepIndex, steps: timeline.map((step, index) => { const isCompleted = diff --git a/src/components/visual-novel-result/VisualNovelResultView.test.tsx b/src/components/visual-novel-result/VisualNovelResultView.test.tsx index 949d70f6..dfea00eb 100644 --- a/src/components/visual-novel-result/VisualNovelResultView.test.tsx +++ b/src/components/visual-novel-result/VisualNovelResultView.test.tsx @@ -11,6 +11,8 @@ import { VisualNovelResultView } from './VisualNovelResultView'; vi.mock('../../services/visual-novel-creation', () => ({ createVisualNovelBackgroundMusicTask: vi.fn(), createVisualNovelSoundEffectTask: vi.fn(), + generateVisualNovelImageAsset: vi.fn(), + buildVisualNovelImageGenerationPrompt: vi.fn(() => '默认图片提示词'), listVisualNovelHistoryAssets: vi.fn().mockResolvedValue([]), publishVisualNovelBackgroundMusicAsset: vi.fn(), publishVisualNovelSoundEffectAsset: vi.fn(), @@ -134,3 +136,58 @@ test('visual novel result uploads scene and character assets into platform refer onSaveDraft.mock.calls[0]?.[0].scenes[0]?.backgroundImageSrc, ).toContain('/generated-custom-world-scenes/'); }); + +test('visual novel result generates scene background from asset picker', async () => { + const user = userEvent.setup(); + const onSaveDraft = vi.fn(); + const visualNovelCreation = await import('../../services/visual-novel-creation'); + const generateImageMock = vi.mocked( + visualNovelCreation.generateVisualNovelImageAsset, + ); + + generateImageMock.mockResolvedValue({ + imageSrc: '/generated-custom-world-scenes/vn-profile/scene-ai.webp', + assetId: 'asset-scene-ai', + model: 'test-image-model', + size: '1280*720', + taskId: 'task-scene-ai', + prompt: '默认图片提示词', + }); + + render( + {}} + onSaveDraft={onSaveDraft} + />, + ); + + await user.click(screen.getByRole('button', { name: '场景' })); + await user.click(screen.getByRole('button', { name: /风雪站台/u })); + + const editorDialog = screen.getByRole('dialog', { name: '风雪站台' }); + await user.click( + within(editorDialog).getAllByRole('button', { name: '背景图' })[0]!, + ); + await user.click( + within(screen.getByRole('dialog', { name: '背景图' })).getByRole('button', { + name: 'AI生成', + }), + ); + + await user.click(within(editorDialog).getByRole('button', { name: '关闭' })); + await user.click(screen.getAllByRole('button', { name: '保存草稿' })[1]!); + + expect(generateImageMock).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'scene_background', + scene: expect.objectContaining({ + sceneId: mockVisualNovelDraft.scenes[0]?.sceneId, + }), + prompt: '默认图片提示词', + }), + ); + expect(onSaveDraft.mock.calls[0]?.[0].scenes[0]?.backgroundImageSrc).toBe( + '/generated-custom-world-scenes/vn-profile/scene-ai.webp', + ); +}); diff --git a/src/components/visual-novel-result/VisualNovelResultView.tsx b/src/components/visual-novel-result/VisualNovelResultView.tsx index 893188d2..90ad043e 100644 --- a/src/components/visual-novel-result/VisualNovelResultView.tsx +++ b/src/components/visual-novel-result/VisualNovelResultView.tsx @@ -4,16 +4,16 @@ import { ImagePlus, Images, Loader2, + type LucideIcon, Music, - Save, PenLine, Play, + Save, Settings, Sparkles, Upload, Waves, X, - type LucideIcon, } from 'lucide-react'; import { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; @@ -27,9 +27,12 @@ import type { VisualNovelStoryPhaseDraft, VisualNovelValidationIssue, } from '../../../packages/shared/src/contracts/visualNovel'; +import { resolveAssetReadUrl } from '../../services/assetReadUrlService'; import { + buildVisualNovelImageGenerationPrompt, createVisualNovelBackgroundMusicTask, createVisualNovelSoundEffectTask, + generateVisualNovelImageAsset, listVisualNovelHistoryAssets, publishVisualNovelBackgroundMusicAsset, publishVisualNovelSoundEffectAsset, @@ -38,7 +41,6 @@ import { type VisualNovelHistoryAssetKind, type VisualNovelUploadAssetKind, } from '../../services/visual-novel-creation'; -import { resolveAssetReadUrl } from '../../services/assetReadUrlService'; import { useAuthUi } from '../auth/AuthUiContext'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; import { mockVisualNovelDraft } from '../visual-novel-runtime/visualNovelMockData'; @@ -102,10 +104,23 @@ type VisualNovelAssetPickerConfig = { profileId?: string | null; entityId?: string | null; previewTone: 'image' | 'audio'; + imageGeneratorConfig?: VisualNovelImageGeneratorConfig; }; type VisualNovelAudioGeneratorKind = 'background_music' | 'sound_effect'; +type VisualNovelImageGeneratorKind = + | 'cover' + | 'scene_background' + | 'character_standee'; + +type VisualNovelImageGeneratorConfig = { + kind: VisualNovelImageGeneratorKind; + draft: VisualNovelResultDraft; + scene?: VisualNovelSceneDraft | null; + character?: VisualNovelCharacterDraft | null; +}; + type VisualNovelAudioGeneratorConfig = { kind: VisualNovelAudioGeneratorKind; scene: VisualNovelSceneDraft; @@ -404,6 +419,7 @@ function VisualNovelAssetPickerDialog({ Boolean(config.historyKind), ); const [isUploading, setIsUploading] = useState(false); + const [isGeneratingImage, setIsGeneratingImage] = useState(false); const [error, setError] = useState(null); useEffect(() => { @@ -445,6 +461,42 @@ function VisualNovelAssetPickerDialog({ }; }, [config.historyKind]); + const handleGenerateImage = async () => { + if (!config.imageGeneratorConfig || config.previewTone !== 'image') { + return; + } + + setIsGeneratingImage(true); + setError(null); + try { + const result = await generateVisualNovelImageAsset({ + ...config.imageGeneratorConfig, + prompt: buildVisualNovelImageGenerationPrompt(config.imageGeneratorConfig), + }); + onSelect({ + assetObjectId: result.assetId || result.taskId, + assetKind: + config.uploadKind === 'character_standee' + ? 'character_visual' + : config.uploadKind === 'cover' + ? 'visual_novel_cover_image' + : 'scene_image', + objectKey: '', + imageSrc: result.imageSrc, + profileId: config.profileId ?? null, + entityId: config.entityId ?? null, + }); + } catch (generationError) { + setError( + generationError instanceof Error + ? generationError.message + : 'AI 图片生成失败。', + ); + } finally { + setIsGeneratingImage(false); + } + }; + const handleUpload = async (event: ChangeEvent) => { const file = event.target.files?.[0]; event.currentTarget.value = ''; @@ -512,7 +564,7 @@ function VisualNovelAssetPickerDialog({
+ {config.imageGeneratorConfig && config.previewTone === 'image' ? ( + + ) : null} { void handleUpload(event); }} @@ -609,6 +678,7 @@ function VisualNovelAssetField({ entityId, historyKind, icon: Icon, + imageGeneratorConfig, label, onSelect, previewTone, @@ -621,6 +691,7 @@ function VisualNovelAssetField({ entityId?: string | null; historyKind?: VisualNovelHistoryAssetKind; icon: LucideIcon; + imageGeneratorConfig?: VisualNovelImageGeneratorConfig; label: string; onSelect: (asset: VisualNovelAssetReference) => void; previewTone: 'image' | 'audio'; @@ -710,6 +781,7 @@ function VisualNovelAssetField({ profileId, entityId, previewTone, + imageGeneratorConfig, }} disabled={disabled} onClose={() => setIsPickerOpen(false)} @@ -1051,6 +1123,7 @@ function VisualNovelProfileTab({ accept="image/png,image/jpeg,image/webp" profileId={draft.profileId} previewTone="image" + imageGeneratorConfig={{ kind: 'cover', draft }} onSelect={(asset) => onChange({ ...draft, coverImageSrc: asset.imageSrc }) } @@ -1321,10 +1394,12 @@ function VisualNovelRuntimeConfigTab({ function VisualNovelCharacterEditor({ item, disabled, + draft, onChange, }: { item: VisualNovelCharacterDraft; disabled: boolean; + draft: VisualNovelResultDraft; onChange: (item: VisualNovelCharacterDraft) => void; }) { return ( @@ -1396,6 +1471,11 @@ function VisualNovelCharacterEditor({ profileId={null} entityId={item.characterId} previewTone="image" + imageGeneratorConfig={{ + kind: 'character_standee', + draft, + character: item, + }} onSelect={(asset) => onChange({ ...item, @@ -1432,11 +1512,13 @@ function VisualNovelSceneEditor({ item, disabled, profileId, + draft, onChange, }: { item: VisualNovelSceneDraft; disabled: boolean; profileId?: string | null; + draft: VisualNovelResultDraft; onChange: (item: VisualNovelSceneDraft) => void; }) { return ( @@ -1510,6 +1592,11 @@ function VisualNovelSceneEditor({ profileId={profileId ?? null} entityId={item.sceneId} previewTone="image" + imageGeneratorConfig={{ + kind: 'scene_background', + draft, + scene: item, + }} onSelect={(asset) => onChange({ ...item, backgroundImageSrc: asset.imageSrc }) } @@ -1890,6 +1977,7 @@ function VisualNovelEditorDialog({ ) : null} @@ -1898,6 +1986,7 @@ function VisualNovelEditorDialog({ item={target.item} disabled={disabled} profileId={draft.profileId} + draft={draft} onChange={updateScene} /> ) : null} diff --git a/src/services/creation-agent/creationAgentClientFactory.ts b/src/services/creation-agent/creationAgentClientFactory.ts index 688cffec..208108da 100644 --- a/src/services/creation-agent/creationAgentClientFactory.ts +++ b/src/services/creation-agent/creationAgentClientFactory.ts @@ -68,6 +68,28 @@ async function openCreationAgentSsePost( return response; } +type CreationAgentNormalizedStreamEvent = + | { + kind: 'reply_delta'; + text: string; + } + | { + kind: 'session'; + session: unknown; + } + | { + kind: 'error'; + message: string; + } + | null; + +type CreationAgentStreamOptions = TextStreamOptions & { + normalizeEvent?: ( + eventName: string, + parsed: Record, + ) => CreationAgentNormalizedStreamEvent; +}; + /** * 三类作品创作 Agent 都遵循同一组 HTTP/SSE 端点形状。 * 这里统一请求骨架,玩法 client 只保留路径、类型与中文错误文案差异。 @@ -128,7 +150,7 @@ export function createCreationAgentClient< const streamMessage = async ( sessionId: string, payload: TSendMessagePayload, - options: TextStreamOptions = {}, + options: CreationAgentStreamOptions = {}, ): Promise => { const response = await openCreationAgentSsePost( `${apiBase}/${encodeURIComponent(sessionId)}/messages/stream`, diff --git a/src/services/creation-agent/creationAgentSse.test.ts b/src/services/creation-agent/creationAgentSse.test.ts index 70314f3b..daaca683 100644 --- a/src/services/creation-agent/creationAgentSse.test.ts +++ b/src/services/creation-agent/creationAgentSse.test.ts @@ -1,6 +1,9 @@ -import { expect, test } from 'vitest'; +import { expect, test, vi } from 'vitest'; -import { readCreationAgentSessionFromSse } from './creationAgentSse'; +import { + normalizeVisualNovelAgentStreamEvent, + readCreationAgentSessionFromSse, +} from './creationAgentSse'; function createChunkedStreamResponse(chunks: Uint8Array[]) { const stream = new ReadableStream({ @@ -76,3 +79,51 @@ test('readCreationAgentSessionFromSse keeps streamed updates before error event' expect(updates).toEqual(['先把方洞万能的反差定住。']); }); + +test('readCreationAgentSessionFromSse can normalize typed visual novel stream events', async () => { + const encoder = new TextEncoder(); + const session = { + sessionId: 'vn-session-1', + ownerUserId: 'user-1', + progressPercent: 100, + stage: 'draft_ready', + }; + const onUpdate = vi.fn(); + + const response = createChunkedStreamResponse([ + encoder.encode( + 'data: {"type":"start","sessionId":"vn-session-1"}\n\n' + + 'data: {"type":"phase","phase":"synthesis"}\n\n' + + 'data: {"type":"text_delta","text":"视觉小说底稿已生成。"}\n\n' + + `data: ${JSON.stringify({ type: 'complete', session })}\n\n` + + 'data: {"type":"done"}\n\n', + ), + ]); + + await expect( + readCreationAgentSessionFromSse(response, { + fallbackMessage: '发送失败', + incompleteMessage: '结果不完整', + normalizeEvent: normalizeVisualNovelAgentStreamEvent, + onUpdate, + }), + ).resolves.toEqual(session); + expect(onUpdate).toHaveBeenCalledWith('视觉小说底稿已生成。'); +}); + +test('readCreationAgentSessionFromSse surfaces typed visual novel error events', async () => { + const encoder = new TextEncoder(); + const response = createChunkedStreamResponse([ + encoder.encode( + 'data: {"type":"error","message":"视觉小说流式创作失败","retryable":true}\n\n', + ), + ]); + + await expect( + readCreationAgentSessionFromSse(response, { + fallbackMessage: '发送失败', + incompleteMessage: '结果不完整', + normalizeEvent: normalizeVisualNovelAgentStreamEvent, + }), + ).rejects.toThrow('视觉小说流式创作失败'); +}); diff --git a/src/services/creation-agent/creationAgentSse.ts b/src/services/creation-agent/creationAgentSse.ts index 219f9219..f8ed2f8a 100644 --- a/src/services/creation-agent/creationAgentSse.ts +++ b/src/services/creation-agent/creationAgentSse.ts @@ -1,9 +1,27 @@ +import type { VisualNovelAgentStreamEvent } from '../../../packages/shared/src/contracts/visualNovel'; import type { TextStreamOptions } from '../aiTypes'; type CreationAgentSseOptions = TextStreamOptions & { fallbackMessage: string; incompleteMessage: string; resolveSession?: (rawSession: unknown) => TSession | null; + normalizeEvent?: ( + eventName: string, + parsed: Record, + ) => + | { + kind: 'reply_delta'; + text: string; + } + | { + kind: 'session'; + session: unknown; + } + | { + kind: 'error'; + message: string; + } + | null; }; function findSseEventBoundary(buffer: string) { @@ -65,6 +83,66 @@ function parseJsonObject(data: string) { } } +type NormalizedCreationAgentSseEvent = NonNullable< + CreationAgentSseOptions['normalizeEvent'] +> extends (eventName: string, parsed: Record) => infer TResult + ? TResult + : never; + +function normalizeDefaultCreationAgentEvent( + eventName: string, + parsed: Record, +): NormalizedCreationAgentSseEvent { + if (eventName === 'reply_delta') { + const text = parsed.text; + return typeof text === 'string' ? { kind: 'reply_delta', text } : null; + } + + if (eventName === 'session' && parsed.session) { + return { kind: 'session', session: parsed.session }; + } + + if (eventName === 'error') { + const message = + typeof parsed.message === 'string' && parsed.message.trim() + ? parsed.message.trim() + : ''; + return { kind: 'error', message }; + } + + return null; +} + +export function normalizeVisualNovelAgentStreamEvent( + eventName: string, + parsed: Record, +): NormalizedCreationAgentSseEvent { + const typedEventName = + eventName === 'message' && typeof parsed.type === 'string' + ? parsed.type + : eventName; + const event = { + ...parsed, + type: typedEventName, + } as VisualNovelAgentStreamEvent; + + switch (event.type) { + case 'text_delta': + return typeof event.text === 'string' + ? { kind: 'reply_delta', text: event.text } + : null; + case 'complete': + return event.session ? { kind: 'session', session: event.session } : null; + case 'error': + return { + kind: 'error', + message: event.message.trim(), + }; + default: + return normalizeDefaultCreationAgentEvent(eventName, parsed); + } +} + export async function readCreationAgentSessionFromSse( response: Response, options: CreationAgentSseOptions, @@ -81,15 +159,10 @@ export async function readCreationAgentSessionFromSse( ((rawSession: unknown) => (rawSession as TSession | null) ?? null); let buffer = ''; let finalSession: TSession | null = null; + const normalizeEvent = + options.normalizeEvent ?? normalizeDefaultCreationAgentEvent; - for (;;) { - const { done, value } = await reader.read(); - if (done) { - break; - } - - buffer += decoder.decode(value, { stream: true }); - + const consumeBuffer = () => { for (;;) { const boundary = findSseEventBoundary(buffer); if (!boundary) { @@ -105,70 +178,40 @@ export async function readCreationAgentSessionFromSse( } const parsed = parseJsonObject(data); + if (!parsed) { + continue; + } + const normalized = normalizeEvent(eventName, parsed); - if (eventName === 'reply_delta' && parsed) { - const text = parsed.text; - if (typeof text === 'string') { - options.onUpdate?.(text); - } + if (normalized?.kind === 'reply_delta') { + options.onUpdate?.(normalized.text); continue; } - if (eventName === 'session' && parsed?.session) { - finalSession = resolveSession(parsed.session); + if (normalized?.kind === 'session') { + finalSession = resolveSession(normalized.session); continue; } - if (eventName === 'error' && parsed) { - const message = - typeof parsed.message === 'string' && parsed.message.trim() - ? parsed.message.trim() - : options.fallbackMessage; - throw new Error(message); + if (normalized?.kind === 'error') { + throw new Error(normalized.message || options.fallbackMessage); } } + }; + + for (;;) { + const { done, value } = await reader.read(); + if (done) { + break; + } + + buffer += decoder.decode(value, { stream: true }); + consumeBuffer(); } // 流结束后再 flush 一次解码器,避免 UTF-8 多字节字符残留在内部缓冲里。 buffer += decoder.decode(); - - for (;;) { - const boundary = findSseEventBoundary(buffer); - if (!boundary) { - break; - } - - const eventBlock = buffer.slice(0, boundary.index); - buffer = buffer.slice(boundary.index + boundary.length); - const { eventName, data } = parseSseEventBlock(eventBlock); - - if (!data) { - continue; - } - - const parsed = parseJsonObject(data); - - if (eventName === 'reply_delta' && parsed) { - const text = parsed.text; - if (typeof text === 'string') { - options.onUpdate?.(text); - } - continue; - } - - if (eventName === 'session' && parsed?.session) { - finalSession = resolveSession(parsed.session); - continue; - } - - if (eventName === 'error' && parsed) { - const message = - typeof parsed.message === 'string' && parsed.message.trim() - ? parsed.message.trim() - : options.fallbackMessage; - throw new Error(message); - } - } + consumeBuffer(); if (!finalSession) { throw new Error(options.incompleteMessage); diff --git a/src/services/rpg-creation/rpgCreationAgentClient.ts b/src/services/rpg-creation/rpgCreationAgentClient.ts index 593465a7..0a909009 100644 --- a/src/services/rpg-creation/rpgCreationAgentClient.ts +++ b/src/services/rpg-creation/rpgCreationAgentClient.ts @@ -72,7 +72,7 @@ export async function streamRpgCreationMessage( sessionId: string, payload: SendRpgAgentMessageRequest, options: TextStreamOptions = {}, -) { +): Promise { const response = await openRpgCreationSsePost( `/api/runtime${RPG_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/messages/stream`, payload, diff --git a/src/services/visual-novel-creation/index.ts b/src/services/visual-novel-creation/index.ts index a8ab7acc..fd1bca9b 100644 --- a/src/services/visual-novel-creation/index.ts +++ b/src/services/visual-novel-creation/index.ts @@ -1,3 +1,4 @@ -export * from './visualNovelCreationClient'; export * from './visualNovelAssetClient'; export * from './visualNovelAudioGenerationClient'; +export * from './visualNovelCreationClient'; +export * from './visualNovelImageGenerationClient'; diff --git a/src/services/visual-novel-creation/visualNovelCreationClient.ts b/src/services/visual-novel-creation/visualNovelCreationClient.ts index c755531b..d72ee86c 100644 --- a/src/services/visual-novel-creation/visualNovelCreationClient.ts +++ b/src/services/visual-novel-creation/visualNovelCreationClient.ts @@ -9,7 +9,10 @@ import type { } from '../../../packages/shared/src/contracts/visualNovel'; import type { TextStreamOptions } from '../aiTypes'; import { type ApiRetryOptions, requestJson } from '../apiClient'; -import { createCreationAgentClient } from '../creation-agent'; +import { + createCreationAgentClient, + normalizeVisualNovelAgentStreamEvent, +} from '../creation-agent'; const VISUAL_NOVEL_AGENT_API_BASE = '/api/creation/visual-novel/sessions'; const VISUAL_NOVEL_CREATION_WRITE_RETRY: ApiRetryOptions = { @@ -61,7 +64,10 @@ export function streamVisualNovelMessage( payload: SendVisualNovelMessageRequest, options: TextStreamOptions = {}, ) { - return visualNovelAgentHttpClient.streamMessage(sessionId, payload, options); + return visualNovelAgentHttpClient.streamMessage(sessionId, payload, { + ...options, + normalizeEvent: normalizeVisualNovelAgentStreamEvent, + }); } export function executeVisualNovelAction( diff --git a/src/services/visual-novel-creation/visualNovelImageGenerationClient.ts b/src/services/visual-novel-creation/visualNovelImageGenerationClient.ts new file mode 100644 index 00000000..79323ed4 --- /dev/null +++ b/src/services/visual-novel-creation/visualNovelImageGenerationClient.ts @@ -0,0 +1,158 @@ +import type { + VisualNovelCharacterDraft, + VisualNovelResultDraft, + VisualNovelSceneDraft, +} from '../../../packages/shared/src/contracts/visualNovel'; +import type { + CustomWorldSceneImageRequest, + CustomWorldSceneImageResult, +} from '../aiTypes'; +import { generateRpgWorldSceneImage } from '../rpg-creation/rpgCreationAssetClient'; + +export type VisualNovelImageGenerationKind = + | 'cover' + | 'scene_background' + | 'character_standee'; + +export type VisualNovelImageGenerationRequest = { + kind: VisualNovelImageGenerationKind; + draft: VisualNovelResultDraft; + scene?: VisualNovelSceneDraft | null; + character?: VisualNovelCharacterDraft | null; + prompt?: string; + referenceImageSrc?: string; +}; + +function buildVisualNovelProfile( + draft: VisualNovelResultDraft, +): CustomWorldSceneImageRequest['profile'] { + return { + id: draft.profileId?.trim() || 'visual-novel-draft', + name: draft.workTitle.trim() || draft.world.title.trim() || '视觉小说作品', + subtitle: draft.world.title.trim() || draft.workTitle.trim() || '视觉小说', + summary: draft.workDescription.trim() || draft.world.summary.trim(), + tone: + draft.world.defaultTone.trim() || draft.world.literaryStyle.trim() || '视觉小说', + playerGoal: draft.world.playerRole.trim() || '推进剧情并完成关键选择', + settingText: [ + draft.world.premise, + draft.world.background, + draft.world.literaryStyle, + ] + .map((part) => part.trim()) + .filter(Boolean) + .join('\n'), + }; +} + +function buildVisualNovelLandmark( + payload: VisualNovelImageGenerationRequest, +): CustomWorldSceneImageRequest['landmark'] { + if (payload.kind === 'scene_background' && payload.scene) { + return { + id: payload.scene.sceneId, + name: payload.scene.name.trim() || '视觉小说场景', + description: payload.scene.description.trim() || payload.draft.world.summary, + }; + } + + if (payload.kind === 'character_standee' && payload.character) { + return { + id: payload.character.characterId, + name: `${payload.character.name.trim() || '视觉小说角色'}立绘`, + description: [ + payload.character.appearance, + payload.character.personality, + payload.character.role, + payload.character.relationshipToPlayer, + ] + .map((part) => part?.trim() ?? '') + .filter(Boolean) + .join(';'), + }; + } + + return { + id: payload.draft.profileId?.trim() || 'visual-novel-cover', + name: `${payload.draft.workTitle.trim() || '视觉小说'}封面`, + description: + payload.draft.workDescription.trim() || + payload.draft.world.summary.trim() || + payload.draft.world.premise.trim(), + }; +} + +function buildDefaultVisualNovelImagePrompt( + payload: VisualNovelImageGenerationRequest, +) { + const draft = payload.draft; + if (payload.kind === 'scene_background' && payload.scene) { + return [ + `视觉小说场景背景:${payload.scene.name}`, + payload.scene.description, + draft.world.defaultTone, + '16:9 横版背景图,无文字,无 UI,无人物特写', + ] + .map((part) => part.trim()) + .filter(Boolean) + .join(','); + } + + if (payload.kind === 'character_standee' && payload.character) { + return [ + `视觉小说角色立绘:${payload.character.name}`, + payload.character.appearance, + payload.character.personality, + payload.character.tone, + '透明感二次元全身或半身立绘,干净背景,无文字,无 UI', + ] + .map((part) => part.trim()) + .filter(Boolean) + .join(','); + } + + return [ + `视觉小说作品封面:${draft.workTitle}`, + draft.workDescription, + draft.world.summary, + draft.world.defaultTone, + '精致视觉小说封面构图,无文字,无 UI,适合 4:3/16:9 裁切', + ] + .map((part) => part.trim()) + .filter(Boolean) + .join(','); +} + +function resolveVisualNovelImageSize(kind: VisualNovelImageGenerationKind) { + if (kind === 'character_standee') { + return '768*1024'; + } + return '1280*720'; +} + +export async function generateVisualNovelImageAsset( + payload: VisualNovelImageGenerationRequest, +): Promise { + const userPrompt = + payload.prompt?.trim() || buildDefaultVisualNovelImagePrompt(payload); + + if (!userPrompt.trim()) { + throw new Error('请先补充图片生成提示词。'); + } + + return generateRpgWorldSceneImage({ + profile: buildVisualNovelProfile(payload.draft), + landmark: buildVisualNovelLandmark(payload), + userPrompt, + size: resolveVisualNovelImageSize(payload.kind), + ...(payload.referenceImageSrc?.trim() + ? { referenceImageSrc: payload.referenceImageSrc.trim() } + : {}), + }); +} + +export function buildVisualNovelImageGenerationPrompt( + payload: VisualNovelImageGenerationRequest, +) { + return buildDefaultVisualNovelImagePrompt(payload); +}