5 Commits

20 changed files with 92 additions and 1121 deletions

View File

@@ -1,343 +0,0 @@
# 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 runopening 能展示初始场景、旁白、对话和选择。
暂不做或仅预留:
- 真实图片/音乐生成队列。
- 多文档解析导入的完整链路。
- 复杂分镜/节点图编辑器。
- 外部 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。

View File

@@ -438,14 +438,6 @@
- 验证:发布链路使用当前 `deploy/systemd``deploy/nginx``scripts/deploy``jenkins/Jenkinsfile.production-*` - 验证:发布链路使用当前 `deploy/systemd``deploy/nginx``scripts/deploy``jenkins/Jenkinsfile.production-*`
- 关联:`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md` - 关联:`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`
## Release Web 产物通过内网 rsync 拉取
- 现象:`Genarrative-Web-Deploy` 发布到 `release` 目标时release agent 本地没有 `/var/cache/genarrative-build/web-artifacts/<job>/<build>/<version>/web.tar.gz`,但 Jenkins controller 又只归档轻量元数据,导致发布阶段找不到 Web 大包。
- 原因Web 大包为了避免从 Linux 构建机拉回 Jenkins controller默认留在构建机稳定缓存目录development 目标与构建机同机可直接读取release 目标是独立机器,需要内网同步。
- 处理release 服务器的 Jenkins 运行用户配置 SSH Host `genarrative-build-internal` 指向构建机内网地址,`Genarrative-Web-Deploy``DEPLOY_TARGET=release` 且本地缺少大包时默认执行 `rsync` 拉取同一路径内容;真实内网 IP、用户和私钥路径只放在服务器本机 SSH config不写入 Jenkinsfile。
- 验证:在 release 服务器上先手工跑通 `rsync -av --progress "genarrative-build-internal:${SRC}/" "${DST}/"`,再运行 Web Deploy流水线会继续执行 `web.tar.gz.sha256` 校验。
- 关联:`jenkins/Jenkinsfile.production-web-deploy``docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`
## Jenkins 生产流水线拉 Git 先本机再域名备用 ## Jenkins 生产流水线拉 Git 先本机再域名备用
- 现象:生产发布、数据库导入导出或服务器配置流水线在目标 Linux agent 上执行 `GitSCM checkout` 时,`http://127.0.0.1:3000/GenarrativeAI/Genarrative.git` 不可达,导致脚本还没拉下来就失败;若 fallback 到公网 Git 时没有限制 refspec、浅克隆和 tags还可能在约 10 分钟后出现 `git-remote-https died of signal 15``early EOF``invalid index-pack output` - 现象:生产发布、数据库导入导出或服务器配置流水线在目标 Linux agent 上执行 `GitSCM checkout` 时,`http://127.0.0.1:3000/GenarrativeAI/Genarrative.git` 不可达,导致脚本还没拉下来就失败;若 fallback 到公网 Git 时没有限制 refspec、浅克隆和 tags还可能在约 10 分钟后出现 `git-remote-https died of signal 15``early EOF``invalid index-pack output`

View File

@@ -324,7 +324,7 @@ Jenkins controller 与 Linux agent 看到的 Git 服务地址不同,必须拆
构建流水线运行在当前 Linux agent 的脱敏 label expression `linux && genarrative-build`。发布、导入导出和服务器配置流水线通过 `DEPLOY_TARGET` 映射到 Linux-only 脱敏部署表达式;其中 `development` 映射到当前 Linux 开发/构建/开发部署 agent 的 `linux && genarrative-build``release` 映射到独立 Linux 生产部署 agent 的 `linux && genarrative-release-deploy`,不能复用当前开发/构建/开发部署 agent。真实机器名、IP 和带 IP 的 Jenkins label 只允许留在 Jenkins 节点连接配置中,不能暴露为 Job 参数默认值、调度标签或文档推荐值。 构建流水线运行在当前 Linux agent 的脱敏 label expression `linux && genarrative-build`。发布、导入导出和服务器配置流水线通过 `DEPLOY_TARGET` 映射到 Linux-only 脱敏部署表达式;其中 `development` 映射到当前 Linux 开发/构建/开发部署 agent 的 `linux && genarrative-build``release` 映射到独立 Linux 生产部署 agent 的 `linux && genarrative-release-deploy`,不能复用当前开发/构建/开发部署 agent。真实机器名、IP 和带 IP 的 Jenkins label 只允许留在 Jenkins 节点连接配置中,不能暴露为 Job 参数默认值、调度标签或文档推荐值。
发布流水线通过 Jenkins `copyArtifacts(...)` 从对应构建 Job 获取归档产物,因此 Jenkins 需要安装并启用 Copy Artifact 插件。Web 大包例外:`Genarrative-Web-Build` 只把轻量元数据归档到 Jenkins controller`web.tar.gz` 保留在 Linux 构建机稳定目录 `/var/cache/genarrative-build/web-artifacts/``Genarrative-Web-Deploy` 在部署目标机器上按构建 Job、构建号和版本号读取该目录。development 目标天然共享当前 Linux 开发/构建/开发部署机release 目标若不是同一台机器,发布流水线默认在本地缓存缺少 `web.tar.gz` 时通过 `rsync` 从 SSH Host `genarrative-build-internal` 拉取同一路径内容。该 Host 必须配置在 release 服务器上 Jenkins 运行用户的 SSH config 中,真实内网 IP、用户和私钥路径只保存在服务器本机如需改名或指定 config可通过 `WEB_ARTIFACT_SYNC_HOST` / `WEB_ARTIFACT_SYNC_SSH_CONFIG` 参数覆盖。也可以提前通过共享存储或其它内网同步方式提供该目录。数据库导入流水线的手动上传模式使用 `stashedFile` 文件参数,因此 Jenkins 还需要安装并启用 File Parameter 插件。所有生产 Pipeline 日志必须带时间戳以便审计Jenkins 需要安装 Timestamper 插件,并在全局配置中启用 `Enabled for all Pipeline builds`。邮件通知流水线使用 Jenkins Pipeline `mail` stepJenkins 需要安装/启用 Mailer 能力,并在系统配置中配置 SMTP。生产发布不能退回到读取构建 workspace 本地目录的旧模式。 发布流水线通过 Jenkins `copyArtifacts(...)` 从对应构建 Job 获取归档产物,因此 Jenkins 需要安装并启用 Copy Artifact 插件。Web 大包例外:`Genarrative-Web-Build` 只把轻量元数据归档到 Jenkins controller`web.tar.gz` 保留在 Linux 构建机稳定目录 `/var/cache/genarrative-build/web-artifacts/``Genarrative-Web-Deploy` 在部署目标机器上按构建 Job、构建号和版本号读取该目录。development 目标天然共享当前 Linux 开发/构建/开发部署机release 目标若不是同一台机器,必须先把该目录通过共享存储、rsync 或其它内网同步方式提供给 release 部署 agent。数据库导入流水线的手动上传模式使用 `stashedFile` 文件参数,因此 Jenkins 还需要安装并启用 File Parameter 插件。所有生产 Pipeline 日志必须带时间戳以便审计Jenkins 需要安装 Timestamper 插件,并在全局配置中启用 `Enabled for all Pipeline builds`。邮件通知流水线使用 Jenkins Pipeline `mail` stepJenkins 需要安装/启用 Mailer 能力,并在系统配置中配置 SMTP。生产发布不能退回到读取构建 workspace 本地目录的旧模式。
邮件通知的持久收件人不写入 Git由 Jenkins `Secret text` 凭据 `genarrative-notification-emails` 保存,凭据内容为逗号分隔邮箱。所有生产流水线仍提供 `NOTIFICATION_EMAILS` 参数作为本次运行的追加收件人;通知 Job 会把持久收件人凭据与本次 `NOTIFICATION_EMAILS` 合并去重后发送,参数留空时只发送给持久收件人。流水线结束时在 `post { always { ... } }` 中异步触发 `Genarrative-Notify-Email`,把来源 Job、构建号、构建 URL、结果、源码分支、源码 commit、发布版本、部署目标和数据库名传给通知 Job。通知 Job 失败不能反向改变业务流水线结果,只在来源流水线日志中记录触发失败。 邮件通知的持久收件人不写入 Git由 Jenkins `Secret text` 凭据 `genarrative-notification-emails` 保存,凭据内容为逗号分隔邮箱。所有生产流水线仍提供 `NOTIFICATION_EMAILS` 参数作为本次运行的追加收件人;通知 Job 会把持久收件人凭据与本次 `NOTIFICATION_EMAILS` 合并去重后发送,参数留空时只发送给持久收件人。流水线结束时在 `post { always { ... } }` 中异步触发 `Genarrative-Notify-Email`,把来源 Job、构建号、构建 URL、结果、源码分支、源码 commit、发布版本、部署目标和数据库名传给通知 Job。通知 Job 失败不能反向改变业务流水线结果,只在来源流水线日志中记录触发失败。
@@ -483,7 +483,6 @@ WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module
- 先按 `SOURCE_BRANCH` / `COMMIT_HASH` 解析并 checkout 部署脚本源码,默认使用 `origin/master` 最新 commit上游构建触发时使用上游传入的实际构建 commit。 - 先按 `SOURCE_BRANCH` / `COMMIT_HASH` 解析并 checkout 部署脚本源码,默认使用 `origin/master` 最新 commit上游构建触发时使用上游传入的实际构建 commit。
- 通过 Jenkins 归档获取 `web.tar.gz.sha256``release-manifest.json``web-artifact-pointer.txt`,再从 `/var/cache/genarrative-build/web-artifacts/<job>/<build>/<version>/` 读取 `web.tar.gz`;先校验 checksum再解压到 `/opt/genarrative/releases/<version>/web` - 通过 Jenkins 归档获取 `web.tar.gz.sha256``release-manifest.json``web-artifact-pointer.txt`,再从 `/var/cache/genarrative-build/web-artifacts/<job>/<build>/<version>/` 读取 `web.tar.gz`;先校验 checksum再解压到 `/opt/genarrative/releases/<version>/web`
-`DEPLOY_TARGET=release` 且 release 服务器本地缓存缺少 `web.tar.gz` 时,默认先执行 `rsync -av --progress <WEB_ARTIFACT_SYNC_HOST>:/var/cache/genarrative-build/web-artifacts/<job>/<build>/<version>/ /var/cache/genarrative-build/web-artifacts/<job>/<build>/<version>/`,再继续校验 checksum默认 Host 为 `genarrative-build-internal`,由 release 服务器本机 SSH config 解析。
- 更新 `/opt/genarrative/current``/srv/genarrative/web` 指向。 - 更新 `/opt/genarrative/current``/srv/genarrative/web` 指向。
- 执行 Nginx 配置测试和静态页面 smoke test。 - 执行 Nginx 配置测试和静态页面 smoke test。
- 不进入维护模式。 - 不进入维护模式。

View File

@@ -290,8 +290,6 @@ POST /api/auth/wechat/miniprogram-login
4.`auth_binding_status=pending_bind_phone`,页面必须进入绑定手机号界面 4.`auth_binding_status=pending_bind_phone`,页面必须进入绑定手机号界面
5. 绑定成功后,应切回正常已登录状态 5. 绑定成功后,应切回正常已登录状态
小程序原生手机号授权链路中,请求体应携带 `wechatPhoneCode`。后端调用微信 `getuserphonenumber` 后,需要按微信原始响应字段 `phoneNumber` / `purePhoneNumber` / `countryCode` 解析手机号;如果误按 Rust 字段名 `phone_number` / `pure_phone_number` / `country_code` 解析,会出现已传 `wechatPhoneCode` 但返回“微信手机号授权失败:缺少手机号”的假失败。
## 10. 后端验收点 ## 10. 后端验收点
当前后端至少应满足以下检查: 当前后端至少应满足以下检查:

View File

@@ -132,7 +132,7 @@ Content-Type: application/json
} }
``` ```
9. `api-server` 通过微信 `stable_token` 获取小程序 `access_token`,再调用 `getuserphonenumber` 换取平台验证后的手机号,并复用现有微信待绑定账号合并逻辑。微信返回的手机号字段使用 `phoneNumber` / `purePhoneNumber` / `countryCode`,后端解析时必须兼容这些原始 camelCase 字段;否则会在已收到 `wechatPhoneCode` 的情况下误报“微信手机号授权失败:缺少手机号”。成功后重新签发 `active` 系统 token。 9. `api-server` 通过微信 `stable_token` 获取小程序 `access_token`,再调用 `getuserphonenumber` 换取平台验证后的手机号,并复用现有微信待绑定账号合并逻辑。成功后重新签发 `active` 系统 token。
10. H5 复用 `consumeAuthCallbackResult()` 消费 `auth_token` 并进入现有登录态恢复流程。 10. H5 复用 `consumeAuthCallbackResult()` 消费 `auth_token` 并进入现有登录态恢复流程。
补充H5 里的旧短信验证码绑定页继续保留为非小程序环境兜底;小程序原生手机号授权只替代“手动输入手机号 + 短信验证码”这一步,不代表后台静默读取本机号码。 补充H5 里的旧短信验证码绑定页继续保留为非小程序环境兜底;小程序原生手机号授权只替代“手动输入手机号 + 短信验证码”这一步,不代表后台静默读取本机号码。

View File

@@ -25,9 +25,6 @@ pipeline {
string(name: 'RELEASE_ROOT', defaultValue: '/opt/genarrative/releases', description: '生产 release 根目录') string(name: 'RELEASE_ROOT', defaultValue: '/opt/genarrative/releases', description: '生产 release 根目录')
string(name: 'CURRENT_LINK', defaultValue: '/opt/genarrative/current', description: '当前版本软链接') string(name: 'CURRENT_LINK', defaultValue: '/opt/genarrative/current', description: '当前版本软链接')
string(name: 'WEB_LINK', defaultValue: '/srv/genarrative/web', description: 'Nginx 静态站点软链接') string(name: 'WEB_LINK', defaultValue: '/srv/genarrative/web', description: 'Nginx 静态站点软链接')
booleanParam(name: 'SYNC_WEB_ARTIFACT_FROM_BUILD_HOST', defaultValue: true, description: 'release 目标本地缺少 Web 大包时,是否通过 rsync 从构建机内网拉取')
string(name: 'WEB_ARTIFACT_SYNC_HOST', defaultValue: 'genarrative-build-internal', description: 'rsync 源 SSH Host通常来自 release 服务器上 Jenkins 运行用户的 ~/.ssh/config')
string(name: 'WEB_ARTIFACT_SYNC_SSH_CONFIG', defaultValue: '', description: '可选rsync 使用的 ssh config 绝对路径;留空使用当前用户默认 ~/.ssh/config')
} }
stages { stages {
@@ -112,36 +109,9 @@ pipeline {
set -euo pipefail set -euo pipefail
artifact_dir="${WEB_ARTIFACT_ROOT}/${BUILD_JOB_NAME}/${BUILD_NUMBER_TO_DEPLOY}/${BUILD_VERSION}" artifact_dir="${WEB_ARTIFACT_ROOT}/${BUILD_JOB_NAME}/${BUILD_NUMBER_TO_DEPLOY}/${BUILD_VERSION}"
if [[ ! -f "${artifact_dir}/web.tar.gz" ]]; then
sync_enabled="${SYNC_WEB_ARTIFACT_FROM_BUILD_HOST:-true}"
sync_host="${WEB_ARTIFACT_SYNC_HOST:-genarrative-build-internal}"
sync_ssh_config="${WEB_ARTIFACT_SYNC_SSH_CONFIG:-}"
if [[ "${DEPLOY_TARGET:-development}" == "release" && "${sync_enabled}" == "true" ]]; then
if [[ -z "${sync_host}" ]]; then
echo "[web-deploy] release 目标需要同步 Web 大包,但 WEB_ARTIFACT_SYNC_HOST 为空。" >&2
exit 1
fi
echo "[web-deploy] release 目标本地缓存缺少 Web 大包,尝试从 ${sync_host} 同步: ${artifact_dir}"
if ! command -v rsync >/dev/null 2>&1; then
echo "[web-deploy] 当前 release agent 缺少 rsync请先安装 rsync 或预先挂载 Web 产物目录。" >&2
exit 1
fi
mkdir -p "${artifact_dir}"
rsync_args=(-av --progress)
if [[ -n "${sync_ssh_config}" ]]; then
rsync_args+=(-e "ssh -F ${sync_ssh_config}")
fi
rsync "${rsync_args[@]}" "${sync_host}:${artifact_dir}/" "${artifact_dir}/"
fi
fi
if [[ ! -f "${artifact_dir}/web.tar.gz" ]]; then if [[ ! -f "${artifact_dir}/web.tar.gz" ]]; then
echo "[web-deploy] 未找到构建机本地 Web 大包: ${artifact_dir}/web.tar.gz" >&2 echo "[web-deploy] 未找到构建机本地 Web 大包: ${artifact_dir}/web.tar.gz" >&2
echo "[web-deploy] development 目标要求 Web 构建与发布共享同一 Linux 构建/开发部署机release 目标会默认通过 rsync 从 WEB_ARTIFACT_SYNC_HOST 拉取,也可预先同步或挂载 ${WEB_ARTIFACT_ROOT}。" >&2 echo "[web-deploy] development 目标要求 Web 构建与发布共享同一 Linux 构建/开发部署机release 目标需要预先同步或挂载 ${WEB_ARTIFACT_ROOT}。" >&2
exit 1 exit 1
fi fi

View File

@@ -224,23 +224,11 @@ 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", "initialChoices": "2 到 4 个",
"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
@@ -628,29 +616,6 @@ 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();

View File

@@ -358,13 +358,10 @@ struct WechatPhoneNumberResponse {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct WechatPhoneNumberInfo { struct WechatPhoneNumberInfo {
#[serde(default)] #[serde(default)]
#[serde(alias = "phoneNumber")]
phone_number: Option<String>, phone_number: Option<String>,
#[serde(default)] #[serde(default)]
#[serde(alias = "purePhoneNumber")]
pure_phone_number: Option<String>, pure_phone_number: Option<String>,
#[serde(default)] #[serde(default)]
#[serde(alias = "countryCode")]
country_code: Option<String>, country_code: Option<String>,
} }
@@ -2112,30 +2109,6 @@ mod tests {
); );
} }
#[test]
fn wechat_phone_number_response_accepts_wechat_camel_case_fields() {
let payload = serde_json::from_str::<WechatPhoneNumberResponse>(
r#"{
"errcode": 0,
"errmsg": "ok",
"phone_info": {
"phoneNumber": "+8613800138000",
"purePhoneNumber": "13800138000",
"countryCode": "86"
}
}"#,
)
.expect("wechat phone number response should parse");
let phone_info = payload.phone_info.expect("phone info should exist");
assert_eq!(phone_info.phone_number.as_deref(), Some("+8613800138000"));
assert_eq!(
phone_info.pure_phone_number.as_deref(),
Some("13800138000")
);
assert_eq!(phone_info.country_code.as_deref(), Some("86"));
}
#[test] #[test]
fn mock_wechat_provider_builds_callback_authorization_url() { fn mock_wechat_provider_builds_callback_authorization_url() {
let provider = WechatProvider::new(WechatAuthConfig::new( let provider = WechatProvider::new(WechatAuthConfig::new(

View File

@@ -1409,12 +1409,6 @@ 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;
@@ -3229,10 +3223,6 @@ 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,
@@ -3252,15 +3242,6 @@ 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 {

View File

@@ -1,100 +0,0 @@
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: '可编辑并可试玩的视觉小说草稿',
},
]);
});
});

View File

@@ -41,11 +41,6 @@ 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());
} }
@@ -77,55 +72,27 @@ export function buildVisualNovelEntryGenerationProgress(
weight: number; weight: number;
durationMs: number; durationMs: number;
}, },
{
id: string;
label: string;
detail: string;
weight: number;
durationMs: number;
},
{
id: string;
label: string;
detail: string;
weight: number;
durationMs: number;
},
] = [ ] = [
{ {
id: 'visual-novel-intent', id: 'visual-novel-session',
label: '理解一句话创意', label: '创建创作会话',
detail: '提取核心题材、视觉画风、玩家身份和互动叙事目标。', 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, weight: 24,
durationMs: 10_000, durationMs: 5_000,
},
{
id: 'visual-novel-draft',
label: '生成故事底稿',
detail: '整理世界观、角色、场景和剧情阶段。',
weight: 56,
durationMs: 22_000,
}, },
{ {
id: 'visual-novel-ready', id: 'visual-novel-ready',
label: '准备草稿页', label: '准备草稿页',
detail: '校验可编辑字段并进入结果页,后续可保存作品和试玩。', detail: '校验可编辑字段并进入草稿页。',
weight: 10, weight: 20,
durationMs: 3_000, durationMs: 4_000,
}, },
]; ];
let elapsedBeforeStep = 0; let elapsedBeforeStep = 0;
@@ -163,13 +130,9 @@ 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 === 'generating' ? activeStep.id : phase, phaseId: phase,
phaseLabel: phaseLabel:
phase === 'ready' phase === 'ready'
? '生成完成' ? '生成完成'
@@ -178,7 +141,7 @@ export function buildVisualNovelEntryGenerationProgress(
: activeStep.label, : activeStep.label,
phaseDetail: phaseDetail:
phase === 'ready' phase === 'ready'
? '视觉小说草稿已准备完成,可进入结果页编辑、保存并试玩。' ? '视觉小说草稿已准备完成。'
: phase === 'failed' : phase === 'failed'
? '草稿生成失败,请返回入口页调整后重试。' ? '草稿生成失败,请返回入口页调整后重试。'
: activeStep.detail, : activeStep.detail,
@@ -188,7 +151,7 @@ export function buildVisualNovelEntryGenerationProgress(
totalWeight: 100, totalWeight: 100,
elapsedMs, elapsedMs,
estimatedRemainingMs: estimatedRemainingMs:
phase === 'ready' ? 0 : Math.max(0, estimatedTotalMs - elapsedMs), phase === 'ready' ? 0 : Math.max(0, 31_000 - elapsedMs),
activeStepIndex: normalizedActiveStepIndex, activeStepIndex: normalizedActiveStepIndex,
steps: timeline.map((step, index) => { steps: timeline.map((step, index) => {
const isCompleted = const isCompleted =

View File

@@ -11,8 +11,6 @@ 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(),
@@ -136,58 +134,3 @@ 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',
);
});

View File

@@ -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,12 +27,9 @@ 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,
@@ -41,6 +38,7 @@ 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';
@@ -104,23 +102,10 @@ 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;
@@ -419,7 +404,6 @@ 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(() => {
@@ -461,42 +445,6 @@ 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 = '';
@@ -564,7 +512,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 || isGeneratingImage} disabled={disabled || isUploading}
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"
> >
@@ -575,28 +523,11 @@ 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 || isGeneratingImage} disabled={disabled || isUploading}
onChange={(event) => { onChange={(event) => {
void handleUpload(event); void handleUpload(event);
}} }}
@@ -678,7 +609,6 @@ function VisualNovelAssetField({
entityId, entityId,
historyKind, historyKind,
icon: Icon, icon: Icon,
imageGeneratorConfig,
label, label,
onSelect, onSelect,
previewTone, previewTone,
@@ -691,7 +621,6 @@ 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';
@@ -781,7 +710,6 @@ function VisualNovelAssetField({
profileId, profileId,
entityId, entityId,
previewTone, previewTone,
imageGeneratorConfig,
}} }}
disabled={disabled} disabled={disabled}
onClose={() => setIsPickerOpen(false)} onClose={() => setIsPickerOpen(false)}
@@ -1123,7 +1051,6 @@ 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 })
} }
@@ -1394,12 +1321,10 @@ 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 (
@@ -1471,11 +1396,6 @@ 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,
@@ -1512,13 +1432,11 @@ 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 (
@@ -1592,11 +1510,6 @@ 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 })
} }
@@ -1977,7 +1890,6 @@ function VisualNovelEditorDialog({
<VisualNovelCharacterEditor <VisualNovelCharacterEditor
item={target.item} item={target.item}
disabled={disabled} disabled={disabled}
draft={draft}
onChange={updateCharacter} onChange={updateCharacter}
/> />
) : null} ) : null}
@@ -1986,7 +1898,6 @@ 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}

View File

@@ -68,28 +68,6 @@ 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 只保留路径、类型与中文错误文案差异。
@@ -150,7 +128,7 @@ export function createCreationAgentClient<
const streamMessage = async ( const streamMessage = async (
sessionId: string, sessionId: string,
payload: TSendMessagePayload, payload: TSendMessagePayload,
options: CreationAgentStreamOptions = {}, options: TextStreamOptions = {},
): Promise<TSession> => { ): Promise<TSession> => {
const response = await openCreationAgentSsePost( const response = await openCreationAgentSsePost(
`${apiBase}/${encodeURIComponent(sessionId)}/messages/stream`, `${apiBase}/${encodeURIComponent(sessionId)}/messages/stream`,

View File

@@ -1,9 +1,6 @@
import { expect, test, vi } from 'vitest'; import { expect, test } from 'vitest';
import { import { readCreationAgentSessionFromSse } from './creationAgentSse';
normalizeVisualNovelAgentStreamEvent,
readCreationAgentSessionFromSse,
} from './creationAgentSse';
function createChunkedStreamResponse(chunks: Uint8Array[]) { function createChunkedStreamResponse(chunks: Uint8Array[]) {
const stream = new ReadableStream<Uint8Array>({ const stream = new ReadableStream<Uint8Array>({
@@ -79,51 +76,3 @@ 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('视觉小说流式创作失败');
});

View File

@@ -1,27 +1,9 @@
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) {
@@ -83,66 +65,6 @@ 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>,
@@ -159,10 +81,15 @@ 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;
const consumeBuffer = () => { for (;;) {
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) {
@@ -178,40 +105,70 @@ export async function readCreationAgentSessionFromSse<TSession>(
} }
const parsed = parseJsonObject(data); const parsed = parseJsonObject(data);
if (!parsed) {
continue;
}
const normalized = normalizeEvent(eventName, parsed);
if (normalized?.kind === 'reply_delta') { if (eventName === 'reply_delta' && parsed) {
options.onUpdate?.(normalized.text); const text = parsed.text;
if (typeof text === 'string') {
options.onUpdate?.(text);
}
continue; continue;
} }
if (normalized?.kind === 'session') { if (eventName === 'session' && parsed?.session) {
finalSession = resolveSession(normalized.session); finalSession = resolveSession(parsed.session);
continue; continue;
} }
if (normalized?.kind === 'error') { if (eventName === 'error' && parsed) {
throw new Error(normalized.message || options.fallbackMessage); const message =
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);

View File

@@ -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,

View File

@@ -1,4 +1,3 @@
export * from './visualNovelCreationClient';
export * from './visualNovelAssetClient'; export * from './visualNovelAssetClient';
export * from './visualNovelAudioGenerationClient'; export * from './visualNovelAudioGenerationClient';
export * from './visualNovelCreationClient';
export * from './visualNovelImageGenerationClient';

View File

@@ -9,10 +9,7 @@ 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 { import { createCreationAgentClient } from '../creation-agent';
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 = {
@@ -64,10 +61,7 @@ export function streamVisualNovelMessage(
payload: SendVisualNovelMessageRequest, payload: SendVisualNovelMessageRequest,
options: TextStreamOptions = {}, options: TextStreamOptions = {},
) { ) {
return visualNovelAgentHttpClient.streamMessage(sessionId, payload, { return visualNovelAgentHttpClient.streamMessage(sessionId, payload, options);
...options,
normalizeEvent: normalizeVisualNovelAgentStreamEvent,
});
} }
export function executeVisualNovelAction( export function executeVisualNovelAction(

View File

@@ -1,158 +0,0 @@
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);
}