Compare commits
3 Commits
release
...
hermes/vis
| Author | SHA1 | Date | |
|---|---|---|---|
| c1131e6f55 | |||
| 2a75a19ece | |||
| c3fbf7a30b |
@@ -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。
|
||||||
@@ -224,11 +224,23 @@ pub(crate) fn build_visual_novel_creation_user_prompt(
|
|||||||
"currentDraft": params.current_draft,
|
"currentDraft": params.current_draft,
|
||||||
"recentMessages": params.recent_messages,
|
"recentMessages": params.recent_messages,
|
||||||
"nowIso": params.now_iso,
|
"nowIso": params.now_iso,
|
||||||
|
"oneLineGenerationFlow": [
|
||||||
|
"提取一句话核心创意、故事类型、玩家身份和视觉画风",
|
||||||
|
"扩展世界观、故事前提、文学风格和默认叙事语气",
|
||||||
|
"设计 3 到 6 个角色,并为每个角色写出可生成立绘的 appearance",
|
||||||
|
"设计 3 到 8 个场景,并为 opening 场景写出可生成背景图的 description",
|
||||||
|
"组织 3 到 6 个剧情阶段,第一阶段必须能从 opening 进入",
|
||||||
|
"生成 opening.narration、可选 firstDialogue 和 2 到 4 个 initialChoices",
|
||||||
|
"图片、音乐可先为 null,但文字草稿必须可进入结果页编辑、保存并试玩"
|
||||||
|
],
|
||||||
"draftRequirements": {
|
"draftRequirements": {
|
||||||
"mainCharacters": "3 到 6 个,至少 1 个非玩家主要角色",
|
"mainCharacters": "3 到 6 个,至少 1 个非玩家主要角色",
|
||||||
"scenes": "3 到 8 个,至少 1 个 opening 场景",
|
"scenes": "3 到 8 个,至少 1 个 opening 场景",
|
||||||
"storyPhases": "3 到 6 个,第一阶段可从 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"
|
"runtimeConfigDefaults": "沿用契约默认值,attributePanelMode 默认为 off"
|
||||||
},
|
},
|
||||||
"outputContract": VISUAL_NOVEL_CREATION_OUTPUT_CONTRACT
|
"outputContract": VISUAL_NOVEL_CREATION_OUTPUT_CONTRACT
|
||||||
@@ -616,6 +628,29 @@ mod tests {
|
|||||||
assert!(repair_prompt.contains("scene_change"));
|
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]
|
#[test]
|
||||||
fn llm_requests_use_responses_template_model() {
|
fn llm_requests_use_responses_template_model() {
|
||||||
let asset_ids = source_asset_ids();
|
let asset_ids = source_asset_ids();
|
||||||
|
|||||||
@@ -1409,6 +1409,12 @@ fn build_public_work_like_id(source_type: &str, profile_id: &str, user_id: &str)
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn recent_public_work_play_counts_group_requested_profiles_in_window() {
|
fn recent_public_work_play_counts_group_requested_profiles_in_window() {
|
||||||
let now_micros = PUBLIC_WORK_PLAY_DAY_MICROS * 10;
|
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(
|
fn record_tracking_event(
|
||||||
ctx: &ReducerContext,
|
ctx: &ReducerContext,
|
||||||
input: RuntimeTrackingEventInput,
|
input: RuntimeTrackingEventInput,
|
||||||
@@ -3242,6 +3252,15 @@ fn record_tracking_event(
|
|||||||
.map_err(|error| error.to_string())?;
|
.map_err(|error| error.to_string())?;
|
||||||
let occurred_at = Timestamp::from_micros_since_unix_epoch(validated_input.occurred_at_micros);
|
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);
|
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 映射。
|
// 中文注释:埋点事实与日期维表使用同一北京时间业务日桶,先幂等补齐维表,避免后续周/月/季/年聚合缺少 bucket 映射。
|
||||||
ensure_analytics_date_dimension_row(ctx, day_key)?;
|
ensure_analytics_date_dimension_row(ctx, day_key)?;
|
||||||
ctx.db.tracking_event().insert(TrackingEvent {
|
ctx.db.tracking_event().insert(TrackingEvent {
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildVisualNovelEntryGenerationAnchorEntries,
|
||||||
|
buildVisualNovelEntryGenerationProgress,
|
||||||
|
type VisualNovelEntryFormPayload,
|
||||||
|
} from './visualNovelEntryGeneration';
|
||||||
|
|
||||||
|
function createVisualNovelPayload(
|
||||||
|
overrides: Partial<VisualNovelEntryFormPayload> = {},
|
||||||
|
): 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: '可编辑并可试玩的视觉小说草稿',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -41,6 +41,11 @@ export function buildVisualNovelEntryGenerationAnchorEntries(
|
|||||||
label: '视觉画风',
|
label: '视觉画风',
|
||||||
value: payload.visualStyleLabel,
|
value: payload.visualStyleLabel,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'visual-novel-target',
|
||||||
|
label: '生成目标',
|
||||||
|
value: '可编辑并可试玩的视觉小说草稿',
|
||||||
|
},
|
||||||
].filter((entry) => entry.value.trim());
|
].filter((entry) => entry.value.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,27 +77,55 @@ export function buildVisualNovelEntryGenerationProgress(
|
|||||||
weight: number;
|
weight: number;
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
},
|
},
|
||||||
] = [
|
|
||||||
{
|
{
|
||||||
id: 'visual-novel-session',
|
id: string;
|
||||||
label: '创建创作会话',
|
label: string;
|
||||||
detail: '写入一句话与视觉画风,准备生成视觉小说底稿。',
|
detail: string;
|
||||||
weight: 24,
|
weight: number;
|
||||||
durationMs: 5_000,
|
durationMs: number;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'visual-novel-draft',
|
id: string;
|
||||||
label: '生成故事底稿',
|
label: string;
|
||||||
detail: '整理世界观、角色、场景和剧情阶段。',
|
detail: string;
|
||||||
weight: 56,
|
weight: number;
|
||||||
durationMs: 22_000,
|
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',
|
id: 'visual-novel-ready',
|
||||||
label: '准备草稿页',
|
label: '准备草稿页',
|
||||||
detail: '校验可编辑字段并进入草稿页。',
|
detail: '校验可编辑字段并进入结果页,后续可保存作品和试玩。',
|
||||||
weight: 20,
|
weight: 10,
|
||||||
durationMs: 4_000,
|
durationMs: 3_000,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
let elapsedBeforeStep = 0;
|
let elapsedBeforeStep = 0;
|
||||||
@@ -130,9 +163,13 @@ export function buildVisualNovelEntryGenerationProgress(
|
|||||||
: phase === 'failed'
|
: phase === 'failed'
|
||||||
? Math.max(1, completedWeight)
|
? Math.max(1, completedWeight)
|
||||||
: Math.min(98, completedWeight + activeStep.weight * activeRatio);
|
: Math.min(98, completedWeight + activeStep.weight * activeRatio);
|
||||||
|
const estimatedTotalMs = timeline.reduce(
|
||||||
|
(sum, step) => sum + step.durationMs,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
phaseId: phase,
|
phaseId: phase === 'generating' ? activeStep.id : phase,
|
||||||
phaseLabel:
|
phaseLabel:
|
||||||
phase === 'ready'
|
phase === 'ready'
|
||||||
? '生成完成'
|
? '生成完成'
|
||||||
@@ -141,7 +178,7 @@ export function buildVisualNovelEntryGenerationProgress(
|
|||||||
: activeStep.label,
|
: activeStep.label,
|
||||||
phaseDetail:
|
phaseDetail:
|
||||||
phase === 'ready'
|
phase === 'ready'
|
||||||
? '视觉小说草稿已准备完成。'
|
? '视觉小说草稿已准备完成,可进入结果页编辑、保存并试玩。'
|
||||||
: phase === 'failed'
|
: phase === 'failed'
|
||||||
? '草稿生成失败,请返回入口页调整后重试。'
|
? '草稿生成失败,请返回入口页调整后重试。'
|
||||||
: activeStep.detail,
|
: activeStep.detail,
|
||||||
@@ -151,7 +188,7 @@ export function buildVisualNovelEntryGenerationProgress(
|
|||||||
totalWeight: 100,
|
totalWeight: 100,
|
||||||
elapsedMs,
|
elapsedMs,
|
||||||
estimatedRemainingMs:
|
estimatedRemainingMs:
|
||||||
phase === 'ready' ? 0 : Math.max(0, 31_000 - elapsedMs),
|
phase === 'ready' ? 0 : Math.max(0, estimatedTotalMs - elapsedMs),
|
||||||
activeStepIndex: normalizedActiveStepIndex,
|
activeStepIndex: normalizedActiveStepIndex,
|
||||||
steps: timeline.map((step, index) => {
|
steps: timeline.map((step, index) => {
|
||||||
const isCompleted =
|
const isCompleted =
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { VisualNovelResultView } from './VisualNovelResultView';
|
|||||||
vi.mock('../../services/visual-novel-creation', () => ({
|
vi.mock('../../services/visual-novel-creation', () => ({
|
||||||
createVisualNovelBackgroundMusicTask: vi.fn(),
|
createVisualNovelBackgroundMusicTask: vi.fn(),
|
||||||
createVisualNovelSoundEffectTask: vi.fn(),
|
createVisualNovelSoundEffectTask: vi.fn(),
|
||||||
|
generateVisualNovelImageAsset: vi.fn(),
|
||||||
|
buildVisualNovelImageGenerationPrompt: vi.fn(() => '默认图片提示词'),
|
||||||
listVisualNovelHistoryAssets: vi.fn().mockResolvedValue([]),
|
listVisualNovelHistoryAssets: vi.fn().mockResolvedValue([]),
|
||||||
publishVisualNovelBackgroundMusicAsset: vi.fn(),
|
publishVisualNovelBackgroundMusicAsset: vi.fn(),
|
||||||
publishVisualNovelSoundEffectAsset: 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,
|
onSaveDraft.mock.calls[0]?.[0].scenes[0]?.backgroundImageSrc,
|
||||||
).toContain('/generated-custom-world-scenes/');
|
).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(
|
||||||
|
<VisualNovelResultView
|
||||||
|
draft={mockVisualNovelDraft}
|
||||||
|
onBack={() => {}}
|
||||||
|
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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,16 +4,16 @@ import {
|
|||||||
ImagePlus,
|
ImagePlus,
|
||||||
Images,
|
Images,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
type LucideIcon,
|
||||||
Music,
|
Music,
|
||||||
Save,
|
|
||||||
PenLine,
|
PenLine,
|
||||||
Play,
|
Play,
|
||||||
|
Save,
|
||||||
Settings,
|
Settings,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Upload,
|
Upload,
|
||||||
Waves,
|
Waves,
|
||||||
X,
|
X,
|
||||||
type LucideIcon,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
|
import { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
@@ -27,9 +27,12 @@ import type {
|
|||||||
VisualNovelStoryPhaseDraft,
|
VisualNovelStoryPhaseDraft,
|
||||||
VisualNovelValidationIssue,
|
VisualNovelValidationIssue,
|
||||||
} from '../../../packages/shared/src/contracts/visualNovel';
|
} from '../../../packages/shared/src/contracts/visualNovel';
|
||||||
|
import { resolveAssetReadUrl } from '../../services/assetReadUrlService';
|
||||||
import {
|
import {
|
||||||
|
buildVisualNovelImageGenerationPrompt,
|
||||||
createVisualNovelBackgroundMusicTask,
|
createVisualNovelBackgroundMusicTask,
|
||||||
createVisualNovelSoundEffectTask,
|
createVisualNovelSoundEffectTask,
|
||||||
|
generateVisualNovelImageAsset,
|
||||||
listVisualNovelHistoryAssets,
|
listVisualNovelHistoryAssets,
|
||||||
publishVisualNovelBackgroundMusicAsset,
|
publishVisualNovelBackgroundMusicAsset,
|
||||||
publishVisualNovelSoundEffectAsset,
|
publishVisualNovelSoundEffectAsset,
|
||||||
@@ -38,7 +41,6 @@ import {
|
|||||||
type VisualNovelHistoryAssetKind,
|
type VisualNovelHistoryAssetKind,
|
||||||
type VisualNovelUploadAssetKind,
|
type VisualNovelUploadAssetKind,
|
||||||
} from '../../services/visual-novel-creation';
|
} from '../../services/visual-novel-creation';
|
||||||
import { resolveAssetReadUrl } from '../../services/assetReadUrlService';
|
|
||||||
import { useAuthUi } from '../auth/AuthUiContext';
|
import { useAuthUi } from '../auth/AuthUiContext';
|
||||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||||
import { mockVisualNovelDraft } from '../visual-novel-runtime/visualNovelMockData';
|
import { mockVisualNovelDraft } from '../visual-novel-runtime/visualNovelMockData';
|
||||||
@@ -102,10 +104,23 @@ type VisualNovelAssetPickerConfig = {
|
|||||||
profileId?: string | null;
|
profileId?: string | null;
|
||||||
entityId?: string | null;
|
entityId?: string | null;
|
||||||
previewTone: 'image' | 'audio';
|
previewTone: 'image' | 'audio';
|
||||||
|
imageGeneratorConfig?: VisualNovelImageGeneratorConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
type VisualNovelAudioGeneratorKind = 'background_music' | 'sound_effect';
|
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 = {
|
type VisualNovelAudioGeneratorConfig = {
|
||||||
kind: VisualNovelAudioGeneratorKind;
|
kind: VisualNovelAudioGeneratorKind;
|
||||||
scene: VisualNovelSceneDraft;
|
scene: VisualNovelSceneDraft;
|
||||||
@@ -404,6 +419,7 @@ function VisualNovelAssetPickerDialog({
|
|||||||
Boolean(config.historyKind),
|
Boolean(config.historyKind),
|
||||||
);
|
);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [isGeneratingImage, setIsGeneratingImage] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -445,6 +461,42 @@ function VisualNovelAssetPickerDialog({
|
|||||||
};
|
};
|
||||||
}, [config.historyKind]);
|
}, [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<HTMLInputElement>) => {
|
const handleUpload = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
event.currentTarget.value = '';
|
event.currentTarget.value = '';
|
||||||
@@ -512,7 +564,7 @@ function VisualNovelAssetPickerDialog({
|
|||||||
<div className="mb-4 flex flex-wrap gap-2">
|
<div className="mb-4 flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={disabled || isUploading}
|
disabled={disabled || isUploading || isGeneratingImage}
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
className="platform-button platform-button--secondary min-h-10 px-4 py-2 text-sm"
|
className="platform-button platform-button--secondary min-h-10 px-4 py-2 text-sm"
|
||||||
>
|
>
|
||||||
@@ -523,11 +575,28 @@ function VisualNovelAssetPickerDialog({
|
|||||||
)}
|
)}
|
||||||
上传
|
上传
|
||||||
</button>
|
</button>
|
||||||
|
{config.imageGeneratorConfig && config.previewTone === 'image' ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled || isUploading || isGeneratingImage}
|
||||||
|
onClick={() => {
|
||||||
|
void handleGenerateImage();
|
||||||
|
}}
|
||||||
|
className="platform-button platform-button--primary min-h-10 px-4 py-2 text-sm"
|
||||||
|
>
|
||||||
|
{isGeneratingImage ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Sparkles className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
AI生成
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept={config.accept}
|
accept={config.accept}
|
||||||
disabled={disabled || isUploading}
|
disabled={disabled || isUploading || isGeneratingImage}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
void handleUpload(event);
|
void handleUpload(event);
|
||||||
}}
|
}}
|
||||||
@@ -609,6 +678,7 @@ function VisualNovelAssetField({
|
|||||||
entityId,
|
entityId,
|
||||||
historyKind,
|
historyKind,
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
|
imageGeneratorConfig,
|
||||||
label,
|
label,
|
||||||
onSelect,
|
onSelect,
|
||||||
previewTone,
|
previewTone,
|
||||||
@@ -621,6 +691,7 @@ function VisualNovelAssetField({
|
|||||||
entityId?: string | null;
|
entityId?: string | null;
|
||||||
historyKind?: VisualNovelHistoryAssetKind;
|
historyKind?: VisualNovelHistoryAssetKind;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
|
imageGeneratorConfig?: VisualNovelImageGeneratorConfig;
|
||||||
label: string;
|
label: string;
|
||||||
onSelect: (asset: VisualNovelAssetReference) => void;
|
onSelect: (asset: VisualNovelAssetReference) => void;
|
||||||
previewTone: 'image' | 'audio';
|
previewTone: 'image' | 'audio';
|
||||||
@@ -710,6 +781,7 @@ function VisualNovelAssetField({
|
|||||||
profileId,
|
profileId,
|
||||||
entityId,
|
entityId,
|
||||||
previewTone,
|
previewTone,
|
||||||
|
imageGeneratorConfig,
|
||||||
}}
|
}}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClose={() => setIsPickerOpen(false)}
|
onClose={() => setIsPickerOpen(false)}
|
||||||
@@ -1051,6 +1123,7 @@ function VisualNovelProfileTab({
|
|||||||
accept="image/png,image/jpeg,image/webp"
|
accept="image/png,image/jpeg,image/webp"
|
||||||
profileId={draft.profileId}
|
profileId={draft.profileId}
|
||||||
previewTone="image"
|
previewTone="image"
|
||||||
|
imageGeneratorConfig={{ kind: 'cover', draft }}
|
||||||
onSelect={(asset) =>
|
onSelect={(asset) =>
|
||||||
onChange({ ...draft, coverImageSrc: asset.imageSrc })
|
onChange({ ...draft, coverImageSrc: asset.imageSrc })
|
||||||
}
|
}
|
||||||
@@ -1321,10 +1394,12 @@ function VisualNovelRuntimeConfigTab({
|
|||||||
function VisualNovelCharacterEditor({
|
function VisualNovelCharacterEditor({
|
||||||
item,
|
item,
|
||||||
disabled,
|
disabled,
|
||||||
|
draft,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
item: VisualNovelCharacterDraft;
|
item: VisualNovelCharacterDraft;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
|
draft: VisualNovelResultDraft;
|
||||||
onChange: (item: VisualNovelCharacterDraft) => void;
|
onChange: (item: VisualNovelCharacterDraft) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -1396,6 +1471,11 @@ function VisualNovelCharacterEditor({
|
|||||||
profileId={null}
|
profileId={null}
|
||||||
entityId={item.characterId}
|
entityId={item.characterId}
|
||||||
previewTone="image"
|
previewTone="image"
|
||||||
|
imageGeneratorConfig={{
|
||||||
|
kind: 'character_standee',
|
||||||
|
draft,
|
||||||
|
character: item,
|
||||||
|
}}
|
||||||
onSelect={(asset) =>
|
onSelect={(asset) =>
|
||||||
onChange({
|
onChange({
|
||||||
...item,
|
...item,
|
||||||
@@ -1432,11 +1512,13 @@ function VisualNovelSceneEditor({
|
|||||||
item,
|
item,
|
||||||
disabled,
|
disabled,
|
||||||
profileId,
|
profileId,
|
||||||
|
draft,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
item: VisualNovelSceneDraft;
|
item: VisualNovelSceneDraft;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
profileId?: string | null;
|
profileId?: string | null;
|
||||||
|
draft: VisualNovelResultDraft;
|
||||||
onChange: (item: VisualNovelSceneDraft) => void;
|
onChange: (item: VisualNovelSceneDraft) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -1510,6 +1592,11 @@ function VisualNovelSceneEditor({
|
|||||||
profileId={profileId ?? null}
|
profileId={profileId ?? null}
|
||||||
entityId={item.sceneId}
|
entityId={item.sceneId}
|
||||||
previewTone="image"
|
previewTone="image"
|
||||||
|
imageGeneratorConfig={{
|
||||||
|
kind: 'scene_background',
|
||||||
|
draft,
|
||||||
|
scene: item,
|
||||||
|
}}
|
||||||
onSelect={(asset) =>
|
onSelect={(asset) =>
|
||||||
onChange({ ...item, backgroundImageSrc: asset.imageSrc })
|
onChange({ ...item, backgroundImageSrc: asset.imageSrc })
|
||||||
}
|
}
|
||||||
@@ -1890,6 +1977,7 @@ function VisualNovelEditorDialog({
|
|||||||
<VisualNovelCharacterEditor
|
<VisualNovelCharacterEditor
|
||||||
item={target.item}
|
item={target.item}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
draft={draft}
|
||||||
onChange={updateCharacter}
|
onChange={updateCharacter}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1898,6 +1986,7 @@ function VisualNovelEditorDialog({
|
|||||||
item={target.item}
|
item={target.item}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
profileId={draft.profileId}
|
profileId={draft.profileId}
|
||||||
|
draft={draft}
|
||||||
onChange={updateScene}
|
onChange={updateScene}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -68,6 +68,28 @@ async function openCreationAgentSsePost(
|
|||||||
return response;
|
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<string, unknown>,
|
||||||
|
) => CreationAgentNormalizedStreamEvent;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 三类作品创作 Agent 都遵循同一组 HTTP/SSE 端点形状。
|
* 三类作品创作 Agent 都遵循同一组 HTTP/SSE 端点形状。
|
||||||
* 这里统一请求骨架,玩法 client 只保留路径、类型与中文错误文案差异。
|
* 这里统一请求骨架,玩法 client 只保留路径、类型与中文错误文案差异。
|
||||||
@@ -128,7 +150,7 @@ export function createCreationAgentClient<
|
|||||||
const streamMessage = async (
|
const streamMessage = async (
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
payload: TSendMessagePayload,
|
payload: TSendMessagePayload,
|
||||||
options: TextStreamOptions = {},
|
options: CreationAgentStreamOptions = {},
|
||||||
): Promise<TSession> => {
|
): Promise<TSession> => {
|
||||||
const response = await openCreationAgentSsePost(
|
const response = await openCreationAgentSsePost(
|
||||||
`${apiBase}/${encodeURIComponent(sessionId)}/messages/stream`,
|
`${apiBase}/${encodeURIComponent(sessionId)}/messages/stream`,
|
||||||
|
|||||||
@@ -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[]) {
|
function createChunkedStreamResponse(chunks: Uint8Array[]) {
|
||||||
const stream = new ReadableStream<Uint8Array>({
|
const stream = new ReadableStream<Uint8Array>({
|
||||||
@@ -76,3 +79,51 @@ test('readCreationAgentSessionFromSse keeps streamed updates before error event'
|
|||||||
|
|
||||||
expect(updates).toEqual(['先把方洞万能的反差定住。']);
|
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('视觉小说流式创作失败');
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,9 +1,27 @@
|
|||||||
|
import type { VisualNovelAgentStreamEvent } from '../../../packages/shared/src/contracts/visualNovel';
|
||||||
import type { TextStreamOptions } from '../aiTypes';
|
import type { TextStreamOptions } from '../aiTypes';
|
||||||
|
|
||||||
type CreationAgentSseOptions<TSession> = TextStreamOptions & {
|
type CreationAgentSseOptions<TSession> = TextStreamOptions & {
|
||||||
fallbackMessage: string;
|
fallbackMessage: string;
|
||||||
incompleteMessage: string;
|
incompleteMessage: string;
|
||||||
resolveSession?: (rawSession: unknown) => TSession | null;
|
resolveSession?: (rawSession: unknown) => TSession | null;
|
||||||
|
normalizeEvent?: (
|
||||||
|
eventName: string,
|
||||||
|
parsed: Record<string, unknown>,
|
||||||
|
) =>
|
||||||
|
| {
|
||||||
|
kind: 'reply_delta';
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'session';
|
||||||
|
session: unknown;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'error';
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
| null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function findSseEventBoundary(buffer: string) {
|
function findSseEventBoundary(buffer: string) {
|
||||||
@@ -65,6 +83,66 @@ function parseJsonObject(data: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type NormalizedCreationAgentSseEvent = NonNullable<
|
||||||
|
CreationAgentSseOptions<unknown>['normalizeEvent']
|
||||||
|
> extends (eventName: string, parsed: Record<string, unknown>) => infer TResult
|
||||||
|
? TResult
|
||||||
|
: never;
|
||||||
|
|
||||||
|
function normalizeDefaultCreationAgentEvent(
|
||||||
|
eventName: string,
|
||||||
|
parsed: Record<string, unknown>,
|
||||||
|
): 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<string, unknown>,
|
||||||
|
): 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<TSession>(
|
export async function readCreationAgentSessionFromSse<TSession>(
|
||||||
response: Response,
|
response: Response,
|
||||||
options: CreationAgentSseOptions<TSession>,
|
options: CreationAgentSseOptions<TSession>,
|
||||||
@@ -81,15 +159,10 @@ export async function readCreationAgentSessionFromSse<TSession>(
|
|||||||
((rawSession: unknown) => (rawSession as TSession | null) ?? null);
|
((rawSession: unknown) => (rawSession as TSession | null) ?? null);
|
||||||
let buffer = '';
|
let buffer = '';
|
||||||
let finalSession: TSession | null = null;
|
let finalSession: TSession | null = null;
|
||||||
|
const normalizeEvent =
|
||||||
|
options.normalizeEvent ?? normalizeDefaultCreationAgentEvent;
|
||||||
|
|
||||||
for (;;) {
|
const consumeBuffer = () => {
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
|
||||||
|
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const boundary = findSseEventBoundary(buffer);
|
const boundary = findSseEventBoundary(buffer);
|
||||||
if (!boundary) {
|
if (!boundary) {
|
||||||
@@ -105,70 +178,40 @@ export async function readCreationAgentSessionFromSse<TSession>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const parsed = parseJsonObject(data);
|
const parsed = parseJsonObject(data);
|
||||||
|
if (!parsed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const normalized = normalizeEvent(eventName, parsed);
|
||||||
|
|
||||||
if (eventName === 'reply_delta' && parsed) {
|
if (normalized?.kind === 'reply_delta') {
|
||||||
const text = parsed.text;
|
options.onUpdate?.(normalized.text);
|
||||||
if (typeof text === 'string') {
|
|
||||||
options.onUpdate?.(text);
|
|
||||||
}
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (eventName === 'session' && parsed?.session) {
|
if (normalized?.kind === 'session') {
|
||||||
finalSession = resolveSession(parsed.session);
|
finalSession = resolveSession(normalized.session);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (eventName === 'error' && parsed) {
|
if (normalized?.kind === 'error') {
|
||||||
const message =
|
throw new Error(normalized.message || options.fallbackMessage);
|
||||||
typeof parsed.message === 'string' && parsed.message.trim()
|
|
||||||
? parsed.message.trim()
|
|
||||||
: options.fallbackMessage;
|
|
||||||
throw new Error(message);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
consumeBuffer();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 流结束后再 flush 一次解码器,避免 UTF-8 多字节字符残留在内部缓冲里。
|
// 流结束后再 flush 一次解码器,避免 UTF-8 多字节字符残留在内部缓冲里。
|
||||||
buffer += decoder.decode();
|
buffer += decoder.decode();
|
||||||
|
consumeBuffer();
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!finalSession) {
|
if (!finalSession) {
|
||||||
throw new Error(options.incompleteMessage);
|
throw new Error(options.incompleteMessage);
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export async function streamRpgCreationMessage(
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
payload: SendRpgAgentMessageRequest,
|
payload: SendRpgAgentMessageRequest,
|
||||||
options: TextStreamOptions = {},
|
options: TextStreamOptions = {},
|
||||||
) {
|
): Promise<RpgAgentSessionSnapshot> {
|
||||||
const response = await openRpgCreationSsePost(
|
const response = await openRpgCreationSsePost(
|
||||||
`/api/runtime${RPG_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/messages/stream`,
|
`/api/runtime${RPG_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/messages/stream`,
|
||||||
payload,
|
payload,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from './visualNovelCreationClient';
|
|
||||||
export * from './visualNovelAssetClient';
|
export * from './visualNovelAssetClient';
|
||||||
export * from './visualNovelAudioGenerationClient';
|
export * from './visualNovelAudioGenerationClient';
|
||||||
|
export * from './visualNovelCreationClient';
|
||||||
|
export * from './visualNovelImageGenerationClient';
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ import type {
|
|||||||
} from '../../../packages/shared/src/contracts/visualNovel';
|
} from '../../../packages/shared/src/contracts/visualNovel';
|
||||||
import type { TextStreamOptions } from '../aiTypes';
|
import type { TextStreamOptions } from '../aiTypes';
|
||||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
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_AGENT_API_BASE = '/api/creation/visual-novel/sessions';
|
||||||
const VISUAL_NOVEL_CREATION_WRITE_RETRY: ApiRetryOptions = {
|
const VISUAL_NOVEL_CREATION_WRITE_RETRY: ApiRetryOptions = {
|
||||||
@@ -61,7 +64,10 @@ export function streamVisualNovelMessage(
|
|||||||
payload: SendVisualNovelMessageRequest,
|
payload: SendVisualNovelMessageRequest,
|
||||||
options: TextStreamOptions = {},
|
options: TextStreamOptions = {},
|
||||||
) {
|
) {
|
||||||
return visualNovelAgentHttpClient.streamMessage(sessionId, payload, options);
|
return visualNovelAgentHttpClient.streamMessage(sessionId, payload, {
|
||||||
|
...options,
|
||||||
|
normalizeEvent: normalizeVisualNovelAgentStreamEvent,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function executeVisualNovelAction(
|
export function executeVisualNovelAction(
|
||||||
|
|||||||
@@ -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<CustomWorldSceneImageResult> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user