From 75944b1f1f71ce4f66e2dd65a964e3b3b0bb54af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=89=A9?= <253518756@qq.com> Date: Mon, 20 Apr 2026 21:06:48 +0800 Subject: [PATCH] 1 --- ...RAFT_TO_WORLD_PIPELINE_AUDIT_2026-04-20.md | 391 ++++++ ...TER_ASSET_PROMPT_CHAIN_AUDIT_2026-04-20.md | 87 +- docs/audits/README.md | 1 + ...G_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md | 369 ++++++ docs/audits/engineering/README.md | 16 +- docs/experience/AGENT_UI_CHANGELOG.md | 19 + ...ATION_FLOW_OPTIMIZATION_PLAN_2026-04-20.md | 388 ++++++ docs/planning/README.md | 1 + ...R_PHASE3_IMPLEMENTATION_PLAN_2026-04-14.md | 26 + ...USTOM_WORLD_CREATION_HUB_PRD_2026-04-13.md | 82 +- ...CHAT_SINGLE_TURN_SESSION_PRD_2026-04-18.md | 43 +- ...REATOR_AND_GAMEPLAY_FLOW_PRD_2026-04-20.md | 156 ++- .../TXT_MODE_CORE_GAMEPLAY_PRD_2026-04-20.md | 412 +++++++ .../BUSINESS_PROMPT_INVENTORY_2026-04-19.md | 4 +- ...LD_AUTO_ASSET_VISIBILITY_FIX_2026-04-20.md | 174 +++ ...ION_FAILURE_ANALYSIS_AND_FIX_2026-04-20.md | 149 +++ ...E4_COUNT_SEMANTICS_ALIGNMENT_2026-04-20.md | 115 ++ .../PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md | 4 +- docs/technical/README.md | 4 + ...ATOR_IMPLEMENTATION_PROGRESS_2026-04-20.md | 9 + ...VEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md | 1019 ++++++++++++++++ .../shared/src/contracts/customWorldAgent.ts | 3 + packages/shared/src/contracts/story.ts | 2 + server-node/package-lock.json | 579 +++++++++ server-node/package.json | 1 + server-node/src/app.test.ts | 11 +- .../src/modules/ai/orchestrator.test.ts | 17 + .../assets/characterAssetRoutes.test.ts | 53 - .../modules/assets/characterAssetRoutes.ts | 114 -- .../modules/custom-world/runtimeProfile.ts | 34 + .../src/modules/custom-world/runtimeTypes.ts | 12 + .../src/prompts/characterAssetPrompts.ts | 200 +--- server-node/src/prompts/chatPromptBuilders.ts | 32 + .../customWorldLibraryMetadata.test.ts | 76 ++ .../customWorldLibraryMetadata.ts | 16 + server-node/src/server.ts | 21 + server-node/src/services/chatService.test.ts | 16 + server-node/src/services/chatService.ts | 7 + .../customWorldAgentAutoAssetService.test.ts | 396 ++++++ .../customWorldAgentAutoAssetService.ts | 771 ++++++++++++ .../services/customWorldAgentDraftCompiler.ts | 4 +- .../customWorldAgentFoundationDraftService.ts | 201 +++- .../services/customWorldAgentOrchestrator.ts | 74 +- .../services/customWorldAgentPhase3.test.ts | 196 ++- .../services/customWorldAgentPhase5.test.ts | 107 ++ ...tomWorldAgentRoleAssetStateService.test.ts | 45 + .../customWorldAgentRoleAssetStateService.ts | 78 +- .../services/customWorldAgentSessionStore.ts | 17 +- .../customWorldCoverAssetService.test.ts | 278 +++++ .../services/customWorldCoverAssetService.ts | 248 +++- .../services/customWorldWorkSummaryService.ts | 17 +- .../AdventurePanel.npcChat.test.tsx | 9 +- src/components/AdventurePanel.test.tsx | 23 +- src/components/AdventurePanel.tsx | 20 +- src/components/CustomWorldEntityCatalog.tsx | 266 +++- .../CustomWorldEntityEditorModal.test.tsx | 237 +++- .../CustomWorldEntityEditorModal.tsx | 1065 +++++++---------- src/components/CustomWorldGenerationView.tsx | 51 +- src/components/CustomWorldResultView.test.tsx | 96 +- src/components/CustomWorldResultView.tsx | 293 +++++ src/components/GameShell.tsx | 6 +- .../characterAssetWorkflowPersistence.ts | 35 - .../CustomWorldAgentComposer.tsx | 6 +- .../CustomWorldAgentHeader.tsx | 4 +- .../CustomWorldAgentOperationBanner.tsx | 32 +- .../CustomWorldAgentThread.tsx | 30 +- .../CustomWorldAgentWorkspace.tsx | 2 +- .../EightAnchorProgressBar.tsx | 28 +- .../GameCanvasEntityLayer.test.tsx | 128 ++ .../game-canvas/GameCanvasEntityLayer.tsx | 15 + .../game-canvas/GameCanvasRuntime.tsx | 2 + .../game-canvas/GameCanvasShared.tsx | 6 + .../game-canvas/NpcAffinityEffectBadge.tsx | 59 + .../game-shell/PlatformHomeView.tsx | 101 +- .../game-shell/PlatformWorldDetailView.tsx | 34 +- ...meSelectionFlow.agent.interaction.test.tsx | 3 +- .../game-shell/PreGameSelectionFlow.tsx | 34 +- src/data/customWorldLibrary.ts | 22 + src/editor/shared/editorApiClient.ts | 1 - src/hooks/story/choiceActions.test.ts | 48 +- src/hooks/story/choiceActions.ts | 12 + src/hooks/story/npcEncounterActions.test.ts | 258 +++- src/hooks/story/npcEncounterActions.ts | 207 +++- src/hooks/story/storyChoiceContinuation.ts | 23 + src/hooks/story/storyChoiceCoordinator.ts | 7 +- .../story/storyInteractionCoordinator.test.ts | 1 + src/hooks/story/storyRuntimeSupport.ts | 9 + .../story/useStoryInteractionCoordinator.ts | 22 +- src/index.css | 105 +- src/prompts/customWorldRolePromptDefaults.ts | 3 +- src/services/aiService.ts | 6 + src/services/customWorld.ts | 44 + .../customWorldAgentDraftResult.test.ts | 147 +++ src/services/customWorldAgentDraftResult.ts | 145 ++- ...customWorldAgentGenerationProgress.test.ts | 64 +- .../customWorldAgentGenerationProgress.ts | 179 ++- src/services/customWorldCamp.ts | 23 +- src/services/customWorldCover.test.ts | 144 +++ src/services/customWorldCover.ts | 10 + src/services/customWorldCoverAssetService.ts | 3 +- src/types/customWorld.ts | 13 + src/types/story.ts | 12 + 102 files changed, 9648 insertions(+), 1540 deletions(-) create mode 100644 docs/audits/AGENT_TO_DRAFT_TO_WORLD_PIPELINE_AUDIT_2026-04-20.md create mode 100644 docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md create mode 100644 docs/planning/CURRENT_AGENT_CREATION_FLOW_OPTIMIZATION_PLAN_2026-04-20.md create mode 100644 docs/prd/TXT_MODE_CORE_GAMEPLAY_PRD_2026-04-20.md create mode 100644 docs/technical/CUSTOM_WORLD_AUTO_ASSET_VISIBILITY_FIX_2026-04-20.md create mode 100644 docs/technical/CUSTOM_WORLD_DRAFT_GENERATION_FAILURE_ANALYSIS_AND_FIX_2026-04-20.md create mode 100644 docs/technical/CUSTOM_WORLD_PHASE4_COUNT_SEMANTICS_ALIGNMENT_2026-04-20.md create mode 100644 docs/technical/TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md create mode 100644 server-node/src/repositories/customWorldLibraryMetadata.test.ts create mode 100644 server-node/src/services/customWorldAgentAutoAssetService.test.ts create mode 100644 server-node/src/services/customWorldAgentAutoAssetService.ts create mode 100644 server-node/src/services/customWorldCoverAssetService.test.ts create mode 100644 src/components/game-canvas/GameCanvasEntityLayer.test.tsx create mode 100644 src/components/game-canvas/NpcAffinityEffectBadge.tsx create mode 100644 src/services/customWorldCover.test.ts diff --git a/docs/audits/AGENT_TO_DRAFT_TO_WORLD_PIPELINE_AUDIT_2026-04-20.md b/docs/audits/AGENT_TO_DRAFT_TO_WORLD_PIPELINE_AUDIT_2026-04-20.md new file mode 100644 index 00000000..d6aa8108 --- /dev/null +++ b/docs/audits/AGENT_TO_DRAFT_TO_WORLD_PIPELINE_AUDIT_2026-04-20.md @@ -0,0 +1,391 @@ +# Agent 聊天到草稿生成到进入游戏世界链路审计 + +更新时间:`2026-04-20` + +## 0. 审计目标 + +本次审计只看一条链: + +`Agent 聊天 -> 世界草稿生成 -> 结果页/作品库 -> 进入游戏世界` + +聚焦回答四类问题: + +1. 哪些数据在链路中断掉了 +2. 哪些地方在代码里同时存在多条 pipeline +3. 哪些字段、功能、组件已经变成冗余或主链弱消费 +4. 哪些能力在 contract、PRD 或代码结构里已经定义,但并没有真正实装到当前游戏主流程 + +--- + +## 1. 结论先行 + +当前系统还没有形成“Agent 会话是唯一真相源、发布后再进入世界”的单一主链,而是处在多条 pipeline 并存、多个桥接层临时粘合的状态。 + +最关键的结论有 8 条: + +1. 当前至少并存 `5` 条相关 pipeline,其中真正影响可玩流程的主链至少有 `3` 条。 +2. 最大的数据断点是:`CustomWorldAgentSessionSnapshot.draftProfile` 不直接进入 runtime,前端 `buildCustomWorldProfileFromAgentDraft()` 会先把它本地编译成 legacy `CustomWorldProfile`,后面的结果页、自动保存、进入世界都只认这个 legacy profile。 +3. 服务端内部也存在一次“先编成 legacy runtime profile,再转回 foundation draft”的双重编译,`draftProfile.legacyResultProfile` 是这个桥接层留下来的强耦合字段。 +4. `packages/shared/src/contracts/customWorldAgent.ts`、`server-node/src/routes/customWorldAgent.ts`、`server-node/src/services/customWorldAgentOrchestrator.ts` 三层定义不一致,`publish_world / generate_scene_assets / sync_scene_assets / expand_long_tail / lock_cards / unlock_cards / regenerate_scope` 等关键动作没有形成真实可用链路。 +5. `CustomWorldResultView.tsx` 仍保留“直接对 legacy profile 生成角色/地点、直接编辑 profile”的旧流程,会绕过 Agent session,是当前最明显的并行 pipeline 和冗余功能源。 +6. “进入世界”和“发布世界”目前是两套平行逻辑。Agent 草稿结果页可以自动保存并直接进入世界,但 `publish_world` action 仍不可用,`qualityFindings / blocker` 校验也没有真正接入。 +7. `listCustomWorldWorks()` 与 `CustomWorldWorkSummaryService` 已能聚合 Agent 草稿和已发布 profile,但平台 `create` tab 仍主要展示 `myEntries`,Agent draft session 不能自然回到主入口,恢复创作主要依赖 `activeSessionId`。 +8. Agent 工作区主 UI 只接了头部、进度、线程、输入框、操作横幅等极简子集,PRD 里规划的锁定条、草稿抽屉、详情面板、澄清面板、快捷动作、发布校验结果等大部分还没有真正进入当前游戏主流程。 + +--- + +## 2. 目标链路 + +按 `docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md` 和 `docs/prd/AI_NATIVE_AGENT_FIRST_EIGHT_ANCHOR_CO_CREATION_FLOW_PRD_2026-04-16.md`,目标链路应当是: + +```text +Agent 对话 +-> Express 后端维护结构化 eight-anchor / creatorIntent / lockState / draftSnapshot +-> foundation draft +-> 角色资产工坊 / 场景资产工坊 +-> sync 回 Agent session draft +-> expand long tail +-> publish_world +-> 服务端执行 quality / blocker 校验 +-> 服务端编译最终 CustomWorldProfile +-> 持久化到世界库 +-> 进入世界 +``` + +这条目标链路有 4 个硬约束: + +1. Express 后端才是真实状态源,前端只负责展示和输入,不负责结构化草稿编译。 +2. 未发布的 Agent 草稿不应该直接污染正式世界库,主入口里应该通过“继续创作”恢复。 +3. 进入世界前应先经过 `publish_world`,并由发布校验阻止缺角色资产、缺场景资产、缺主线第一幕等 blocker。 +4. 结果页不再是旧自定义世界编辑器的平移副本,而应更接近“最终预览 / 发布确认 / 进入世界”的收口层。 + +--- + +## 3. 当前真实链路 + +## 3.1 Agent 会话草稿链 + +当前新链路实际是: + +```text +PreGameSelectionFlow.tsx +-> /api/runtime/custom-world/agent/sessions +-> CustomWorldAgentSessionStore +-> CustomWorldAgentOrchestrator +-> CustomWorldAgentFoundationDraftService +-> CustomWorldAgentAutoAssetService +-> session.draftProfile / draftCards / assetCoverage +-> 前端 buildCustomWorldProfileFromAgentDraft() +-> generatedCustomWorldProfile +-> upsertCustomWorldProfile() +-> handleCustomWorldSelect(profile) +-> runtime +``` + +关键特点: + +1. Agent session 不是 runtime 直接消费的对象。 +2. Agent 草稿完成后,会在前端先转成 `CustomWorldProfile`。 +3. 结果页阶段会自动调用 `upsertCustomWorldProfile()`,把当前 profile 写进 `custom-world-library`。 +4. “进入世界”按钮直接把这个 profile 送给 `handleCustomWorldSelect(...)`,不需要 `publish_world`。 + +主要证据: + +- `src/components/game-shell/PreGameSelectionFlow.tsx` +- `src/services/customWorldAgentDraftResult.ts` +- `src/hooks/useGameFlow.ts` + +## 3.2 旧自定义世界 session 链 + +旧链路仍然完整存在: + +```text +aiService.generateCustomWorldProfile() +-> /api/runtime/custom-world/sessions +-> answerCustomWorldSessionQuestion() +-> /generate/stream +-> generateCustomWorldProfile() +-> CustomWorldProfile +-> 结果页 / 作品库 / 进入世界 +``` + +关键特点: + +1. `src/services/aiService.ts` 里的 `generateCustomWorldProfile()` 仍然会创建旧 `custom-world/sessions`。 +2. 前端会先根据 `world_hook / player_premise / opening_situation / core_conflict` 自动补默认回答,再触发流式生成。 +3. 这条链已经与 Agent 八锚点链并行存在,且依然可用。 + +主要证据: + +- `src/services/aiService.ts` +- `server-node/src/routes/runtimeRoutes.ts` +- `server-node/src/services/customWorldSessionStore.ts` + +## 3.3 已保存 profile / 作品库链 + +当前作品库链是: + +```text +custom-world-library +-> upsert / delete / publish / unpublish +-> PlatformHomeView / saved profile detail +-> CustomWorldResultView +-> handleCustomWorldSelect(profile) +``` + +关键特点: + +1. 这条链直接消费 `CustomWorldProfile`,不依赖 Agent session。 +2. Agent 结果页自动保存后,也会落入这条链。 +3. `publish/unpublish` 作用在作品库 profile 上,而不是 Agent session 上。 + +主要证据: + +- `server-node/src/routes/runtimeRoutes.ts` +- `src/components/game-shell/PlatformHomeView.tsx` +- `src/components/game-shell/PreGameSelectionFlow.tsx` + +## 3.4 结果页 legacy profile 直改链 + +`CustomWorldResultView.tsx` 仍保留旧能力: + +1. `generateCustomWorldPlayableNpc({ profile })` +2. `generateCustomWorldStoryNpc({ profile })` +3. `generateCustomWorldLandmark({ profile })` +4. `CustomWorldEntityEditorModal` + +这意味着结果页不仅是预览层,还是一套独立的“legacy profile 直改工作台”。这一套能力不会回写 Agent session 的结构化状态,也不会走 Agent action route。 + +主要证据: + +- `src/components/CustomWorldResultView.tsx` +- `src/services/aiService.ts` + +## 3.5 创作中心 works 聚合链 + +后端已经能聚合两类作品: + +1. `sourceType: 'agent_session'` +2. `sourceType: 'published_profile'` + +但主平台 `create` tab 现在仍主要展示 `myEntries`,没有把 `CustomWorldCreationHub.tsx` 作为主入口接上。 + +这导致: + +1. works 聚合链存在 +2. create tab 真实消费的是另一条链 +3. Agent draft session 的继续创作入口没有真正收口到主平台 + +主要证据: + +- `server-node/src/services/customWorldWorkSummaryService.ts` +- `src/components/custom-world-home/CustomWorldCreationHub.tsx` +- `src/components/game-shell/PlatformHomeView.tsx` + +--- + +## 4. 数据断点 + +| 断点 | 当前现状 | 影响 | 主要证据 | +| --- | --- | --- | --- | +| Agent session -> runtime | `buildCustomWorldProfileFromAgentDraft()` 在前端把 `session.draftProfile` 编译成 legacy `CustomWorldProfile`,后续结果页、自动保存、进入世界都只认 profile | 后端不再是最终唯一真相源,前端承担了结构化编译与字段裁决,容易产生字段丢失、语义漂移、状态失真 | `src/components/game-shell/PreGameSelectionFlow.tsx`、`src/services/customWorldAgentDraftResult.ts` | +| foundation draft 内部双重编译 | `CustomWorldAgentFoundationDraftService` 会先 `buildCompiledCustomWorldProfile(...)`,再 `convertRuntimeProfileToFoundationDraft(...)`,并把结果塞进 `legacyResultProfile` | Agent draft 不是原生生成,而是绕了一次 legacy profile,再回 draft;后续桥接层依赖这个字段继续工作 | `server-node/src/services/customWorldAgentFoundationDraftService.ts` | +| 创作态元数据进入最终 profile | 前端桥接时会把 `anchorContent / creatorIntent / anchorPack / lockState` 一并塞进 legacy profile;同时固定写入 `generationMode: 'fast'`、`generationStatus: 'key_only'` | 创作态数据污染运行时 profile 存储;`generationMode / generationStatus` 还会覆盖真实阶段语义 | `src/services/customWorldAgentDraftResult.ts` | +| Agent session 元数据在结果页后被截断 | `draftCards / pendingClarifications / suggestedActions / qualityFindings / checkpoints / operations` 大多停留在 session 层;结果页与 runtime 只继续消费 profile | 进入结果页后,Agent 会话层的大量结构化上下文被切断,发布门槛、锁定、局部重生成等信息无法自然继承 | `packages/shared/src/contracts/customWorldAgent.ts`、`server-node/src/services/customWorldAgentSessionStore.ts`、`src/components/custom-world-agent/CustomWorldAgentWorkspace.tsx` | +| works 聚合 -> 平台 create tab | 后端 `listCustomWorldWorkSummaries(...)` 能返回 draft 与 published,但 create tab 仍只渲染 `myEntries` | Agent draft session 无法稳定出现在主入口“我的创作”里,恢复创作入口割裂 | `server-node/src/services/customWorldWorkSummaryService.ts`、`src/components/game-shell/PlatformHomeView.tsx` | +| 发布状态 -> 可玩状态 | 结果页会自动 `upsertCustomWorldProfile()` 并允许直接 `onEnterWorld`;但 `publish_world` action 仍不可用 | “可玩”与“已发布”没有统一门槛,发布校验无法阻止未完成草稿进入世界 | `src/components/game-shell/PreGameSelectionFlow.tsx`、`server-node/src/services/customWorldAgentOrchestrator.ts` | + +--- + +## 5. 多条 Pipeline + +## 5.1 主链级 pipeline + +| pipeline | 真相源 | 当前是否在主流程可达 | 问题 | +| --- | --- | --- | --- | +| Agent 会话草稿链 | `CustomWorldAgentSessionStore` + `draftProfile` | 是 | 后半段通过前端桥接成 legacy profile,未形成端到端单一真相源 | +| 旧 custom-world session 链 | `CustomWorldSessionStore` | 是 | 与 Agent 八锚点链重复,且前端仍在补默认回答 | +| 已保存 / 已发布 profile 链 | `custom-world-library` 中的 `CustomWorldProfile` | 是 | 与 Agent draft session 发布链平行存在 | +| 结果页 legacy profile 直改链 | 结果页本地 `profile` | 是 | 绕过 Agent session,属于并行编辑器 | +| works 创作中心链 | `listCustomWorldWorks()` 聚合数据 | 否,主平台未接主入口 | 后端已有聚合,但 UI 没真正切过去 | + +## 5.2 资产子链 pipeline + +资产相关还存在“自动补齐”和“人工工坊写回”并存: + +1. `draft_foundation` 后,`CustomWorldAgentAutoAssetService` 会自动补角色主图和幕背景图。 +2. 角色资产又存在 `generate_role_assets -> sync_role_assets` 的手动工坊写回链。 +3. 场景资产在 contract 层定义了 `generate_scene_assets / sync_scene_assets`,但主 action 链未打通。 + +这导致当前资产链不是一条统一 pipeline,而是: + +```text +自动补角色 / 自动补幕背景 +并存 +手动角色工坊 -> sync_role_assets +缺失 +手动场景工坊 -> sync_scene_assets +``` + +主要证据: + +- `server-node/src/services/customWorldAgentAutoAssetService.ts` +- `server-node/src/services/customWorldAgentRoleAssetStateService.ts` +- `packages/shared/src/contracts/customWorldAgent.ts` +- `server-node/src/services/customWorldAgentOrchestrator.ts` + +--- + +## 6. 冗余字段与主链悬空字段 + +这里区分两类: + +1. 已经明显承担桥接残留职责的冗余字段 +2. 在 contract / session 里存在,但当前主流程几乎不消费的悬空字段 + +| 字段 | 类型 | 当前状态 | 判断 | +| --- | --- | --- | --- | +| `draftProfile.legacyResultProfile` | 桥接残留字段 | foundation draft 服务端先生成 legacy runtime profile,再把它塞回 draft,前端桥接又优先读它 | 明显冗余,属于临时兼容字段,不应长期成为主链依赖 | +| `generationMode: 'fast'` | 固定写死字段 | `buildCustomWorldProfileFromAgentDraft()` 固定写入 | 不是草稿真实状态,更像桥接层补丁 | +| `generationStatus: 'key_only'` | 固定写死字段 | `buildCustomWorldProfileFromAgentDraft()` 固定写入 | 同上,会掩盖真实生成阶段 | +| `anchorContent / creatorIntent / anchorPack / lockState` 被直接塞进 legacy profile | 创作态元数据 | 会跟随自动保存一起写进作品库 profile,但 runtime 并不以这些字段为正式运行时输入 | 当前更像创作态元数据泄漏进运行时 profile | +| `qualityFindings` | session / contract 字段 | contract、session store、测试里存在,但没形成生成、渲染、发布阻断闭环 | 当前主链悬空 | +| `checkpoints` | session 字段 | session store 会记录,但主工作区和结果页没有真实展示入口 | 当前主链悬空 | +| `suggestedActions` | session 字段 | session 会生成,但主工作区没有接 `QuickActions` 等面板 | 当前主链悬空 | +| `pendingClarifications` | session 字段 | session 有数据,但澄清面板未接入主工作区 | 当前主链悬空 | +| `operations` 历史 | session 字段 | 主工作区只展示当前 `activeOperation` 横幅,不展示完整历史 | 当前主链弱消费 | +| `roleAssetSummaryLabel / cover* / counts` 等 works 字段 | works 聚合字段 | 后端能返回,但主平台 create tab 没走 `works` 入口 | 当前主链弱消费 | + +--- + +## 7. 冗余功能与冗余组件 + +## 7.1 冗余功能 + +| 功能 | 当前状态 | 问题 | +| --- | --- | --- | +| 结果页直接生成 playable/story/landmark | `CustomWorldResultView.tsx` 仍可直接调用 AI 生成 | 与 Agent 对象精修链重复,且不会同步回 session | +| 结果页直接编辑 `CustomWorldProfile` | `CustomWorldEntityEditorModal` 仍挂在结果页 | 把结果页继续维持成旧编辑器,而不是 Agent 流程的收口层 | +| 旧 `custom-world/sessions` 世界生成 | `aiService.generateCustomWorldProfile()` 仍完整可用 | 与 Agent 八锚点世界创建重复 | +| 作品库 `publish/unpublish` 与 Agent `publish_world` | 两套“发布”概念并行 | 一套作用于 library profile,一套想作用于 Agent session,但后者还未打通 | +| 结果页自动保存 | `generatedCustomWorldProfile` 变化时自动 `upsertCustomWorldProfile()` | 让“草稿保存”“作品库存档”“正式发布”语义混在一起 | + +## 7.2 冗余或未接线组件 + +`src/components/custom-world-agent/CustomWorldAgentWorkspace.tsx` 当前只真正接了: + +1. `CustomWorldAgentHeader` +2. `EightAnchorProgressBar` +3. `CustomWorldAgentOperationBanner` +4. `CustomWorldAgentThread` +5. `CustomWorldAgentComposer` + +但同目录下已经存在且主工作区未接线的组件包括: + +1. `CustomWorldAgentLockBar.tsx` +2. `CustomWorldAgentDraftDrawer.tsx` +3. `CustomWorldAgentDraftDetailPanel.tsx` +4. `CustomWorldAgentQuickActions.tsx` +5. `CustomWorldAgentSummaryPanel.tsx` +6. `CustomWorldAgentIntentSummaryPanel.tsx` +7. `CustomWorldAgentClarificationPanel.tsx` +8. `CustomWorldGenerateEntityModal.tsx` + +另外,`src/components/custom-world-home/CustomWorldCreationHub.tsx` 也已存在,但平台 `create` tab 还没有把它接成主入口。 + +--- + +## 8. 当前没有真正实装到游戏主流程中的项 + +| 能力 | 设计 / 定义位置 | 当前状态 | +| --- | --- | --- | +| `publish_world` 真正发布链 | contract、PRD、route、orchestrator | route 能接,orchestrator 直接 `throw badRequest('publish_world is not available in phase5')` | +| `generate_scene_assets` | contract、PRD | contract 定义了,但 action route 未接,主链无执行实现 | +| `sync_scene_assets` | contract、PRD | contract 定义了,但 action route 未接,主链无执行实现 | +| `expand_long_tail` | contract、PRD | contract 定义了,但主 action 链未接 | +| `lock_cards / unlock_cards` | contract、PRD | contract 定义了,但 route / UI / orchestrator 主链未接 | +| `regenerate_scope` | contract、PRD | contract 定义了,但 route / UI / orchestrator 主链未接 | +| `qualityFindings` 与 blocker 发布门禁 | contract、PRD、技术进度文档 | 字段存在,但没有真实的生成、展示、阻止发布闭环 | +| 场景资产工坊从 Agent workspace 打开并写回 | PRD | 主工作区未接详情面板与场景资产 action | +| 通过 works 统一恢复 Agent draft / 已发布作品 | works service + creation hub | 后端已有聚合,主平台入口未收口 | +| 发布前只允许预览、发布后再进入世界 | PRD | 当前 Agent 草稿结果页可自动保存并直接进入世界 | + +补充说明: + +`docs/technical/SCENE_MULTI_ACT_CREATOR_IMPLEMENTATION_PROGRESS_2026-04-20.md` 已明确写到,发布期 `qualityFindings / blocker` 正式接入仍未完成,这与当前代码状态一致。 + +--- + +## 9. 优先级建议 + +## P0:先收一条真正的单一主链 + +建议明确把下面这条定为唯一正式主链: + +```text +Agent session +-> 服务端 draft snapshot +-> 服务端质量检查 / 发布动作 +-> 服务端编译 final CustomWorldProfile +-> 世界库 +-> runtime +``` + +对应动作: + +1. 结果页不再承担“主编辑器”职责,至少对 Agent draft 结果页关闭 legacy profile 直改能力。 +2. 用服务端 preview / compile 接口替代前端 `buildCustomWorldProfileFromAgentDraft()` 的最终裁决职责。 +3. `publish_world` 打通后,再决定是否允许“发布后立即进入世界”。 + +## P0:把“进入世界”和“发布世界”重新绑回同一门槛 + +建议收口为: + +1. 未发布 Agent 草稿只能继续创作或查看预览。 +2. 只有 `publish_world` 成功后,才产出正式 `CustomWorldProfile` 并允许主入口进入世界。 +3. `qualityFindings / blocker` 必须在 foundation draft 完成、资产写回后、publish 前持续重跑。 + +## P1:决定旧 world session 流程的命运 + +当前最容易继续制造重复复杂度的是旧 `custom-world/sessions` 链。 + +建议二选一: + +1. 明确保留为“快速世界生成兼容模式”,但从主入口降级。 +2. 明确进入淘汰路径,逐步下线 `generateCustomWorldProfile()` 这条旧链。 + +不建议继续让它和 Agent 八锚点链同时作为主入口长期并存。 + +## P1:把 works 创作中心接回主平台 + +建议: + +1. 平台 `create` tab 改成消费 `listCustomWorldWorks()`。 +2. 草稿 session 通过“继续创作”恢复。 +3. 已发布 profile 通过“进入世界”或“查看详情”进入。 +4. `myEntries` 退回为作品库子集,而不是 create tab 的唯一数据源。 + +## P1:补齐 Agent workspace 的最小闭环 + +建议优先接上: + +1. `CustomWorldAgentQuickActions` +2. `CustomWorldAgentDraftDrawer` +3. `CustomWorldAgentDraftDetailPanel` +4. `CustomWorldAgentClarificationPanel` + +如果这几个面板不接上,`suggestedActions / pendingClarifications / draftCards` 这些 session 字段会长期处于悬空状态。 + +## P2:等主链收口后再清桥接字段 + +下面这些字段不建议现在立刻删,但应在主链收口后尽快移除: + +1. `draftProfile.legacyResultProfile` +2. 前端桥接里固定写死的 `generationMode / generationStatus` +3. 仅为兼容旧编辑器而塞进 legacy profile 的创作态元数据 + +--- + +## 10. 一句话总评 + +当前“Agent 聊天 -> 草稿生成 -> 进入世界”已经能跑通一条可玩链,但它还不是 PRD 要求的“后端单一真相源 + 发布门禁收口”的正式链路,而是 `Agent session`、`legacy profile`、`旧 session`、`作品库` 四层并存、靠前端桥接和结果页兼容能力临时拼起来的过渡态。 diff --git a/docs/audits/CHARACTER_ASSET_PROMPT_CHAIN_AUDIT_2026-04-20.md b/docs/audits/CHARACTER_ASSET_PROMPT_CHAIN_AUDIT_2026-04-20.md index 4a54ff04..d4725a28 100644 --- a/docs/audits/CHARACTER_ASSET_PROMPT_CHAIN_AUDIT_2026-04-20.md +++ b/docs/audits/CHARACTER_ASSET_PROMPT_CHAIN_AUDIT_2026-04-20.md @@ -17,7 +17,7 @@ 结论不是“只有一套 prompt”,而是: -**当前角色资产链路至少有两层 prompt,且这两层在仓库里被不同文件承担。** +**当前角色资产链路仍然有两层 prompt,但“默认描述文本”已经统一成单一主源。** ### 1.1 默认描述文本层 @@ -95,10 +95,6 @@ ## 2.2 生成默认角色形象描述文本的提示词在哪 -当前仓库需要分两种情况: - -### 情况 A:当前自定义世界资产工坊真实主链 - 当前资产工坊默认输入框实际使用: - `src/prompts/customWorldRolePromptDefaults.ts` @@ -110,34 +106,6 @@ - `role.visualDescription` - 或回退到 `role.description` -### 情况 B:仓库里保留的“默认 bundle 编译接口” - -仓库里仍保留一条后端接口: - -- `/api/assets/character-prompts/generate` - -对应文件: - -- `server-node/src/prompts/characterAssetPrompts.ts` - -这条链使用: - -- `CHARACTER_PROMPT_BUNDLE_SYSTEM_PROMPT` -- `buildCharacterPromptBundleUserPrompt` - -它的职责是: - -**让 LLM 从角色卡摘要里编译出一组默认文本 bundle。** - -但当前实际问题是: - -**自定义世界角色资产工坊初始化默认值,并没有走这条接口。** - -因此当前状态更准确地说是: - -- 仓库里有一条“LLM 编译默认文本 bundle”的保留链 -- 但当前资产工坊真实初始默认值主链,走的是前端本地映射 - --- ## 3. 角色动作生成链路 @@ -181,17 +149,6 @@ 这仍然是**默认描述文本层**,不是最终动作模型 prompt。 -仓库里也保留了 LLM 编译 bundle 的接口链: - -- `CHARACTER_PROMPT_BUNDLE_SYSTEM_PROMPT` -- `buildCharacterPromptBundleUserPrompt` - -这条链也会生成: - -- `animationPromptText` - -但当前资产工坊真实初始默认值并没有实际调用它。 - --- ## 4. `characterAssetPrompts.ts` 里的 `visualPromptText` / `animationPromptText` 到底是什么 @@ -264,32 +221,30 @@ ## 6. 冗余流程与当前问题 -## 6.1 明确存在的冗余点:默认 bundle 双链并存 +## 6.1 默认描述文本双链已收口 -当前仓库里“默认描述文本”其实有两套来源: +此前默认描述文本同时存在: -### 第一套:前端本地字段映射 +1. 前端本地字段映射 +2. 后端 bundle 编译接口 + +本轮已经统一为: - `src/prompts/customWorldRolePromptDefaults.ts` -### 第二套:后端 LLM bundle 编译接口 +也就是: -- `server-node/src/prompts/characterAssetPrompts.ts` -- `/api/assets/character-prompts/generate` +**默认描述文本现在只有一条真实主源。** -问题不在于“两套都存在”,而在于: +对应变化: -**当前自定义世界资产工坊真实默认值只走第一套,第二套保留但没有进入当前主 UI 链。** - -这意味着: - -1. 从业务视角看,默认描述文本存在双份真相。 -2. 从维护视角看,两个地方都在描述 `visualPromptText / animationPromptText / scenePromptText` 的生成语义。 -3. 从测试视角看,后端 bundle 接口仍有测试,但 UI 主链没有使用它。 +1. 不再保留后端独立的默认 bundle 编译接口。 +2. 不再保留前端对应的 bundle 生成 API 壳层。 +3. `server-node/src/prompts/characterAssetPrompts.ts` 只保留正式模型 prompt builder。 判断: -**这是当前最明显的冗余流程。** +**默认描述文本层的双份真相已经被消除。** ## 6.2 `scenePromptText` 结构存在,但当前资产工坊没有完整承接 @@ -338,13 +293,13 @@ 因此它们不能算“无效代码”。 -真正更接近“保留接口但未进入当前 UI 主链”的,是: +真正已经被清理掉的保留链路,是此前未接入主 UI 的默认 bundle 接口: - `CHARACTER_PROMPT_BUNDLE_SYSTEM_PROMPT` - `buildCharacterPromptBundleUserPrompt` - `/api/assets/character-prompts/generate` -这套链路仍有测试、仍可工作,但当前不属于自定义世界资产工坊的真实默认值主链。 +这套链路已经不再保留在当前仓库主线中。 --- @@ -352,11 +307,9 @@ 如果后续要继续收口,建议按顺序处理: -1. 先明确“资产工坊默认值唯一主源”到底选前端本地映射还是后端 LLM bundle 接口。 -2. 如果继续保留前端本地映射为主链,则把后端 bundle 接口标注为备用 / 实验 / 非主链能力。 -3. 如果准备切回后端 bundle 接口为主链,则要把当前 UI 初始化逻辑真正接上,并补场景描述输入框闭环。 -4. 对 `scenePromptText` 做完整承接,不要继续停留在结构存在但 UI 不消费的状态。 -5. 继续保留 `packages/shared/src/prompts/qwenSprite.ts` 与工具链 prompt 分层,但在文档里强制写清“正式主链 / 工具链”边界。 +1. 继续以前端本地映射作为默认描述文本唯一主源。 +2. 对 `scenePromptText` 做完整承接,不要继续停留在结构存在但 UI 不消费的状态。 +3. 继续保留 `packages/shared/src/prompts/qwenSprite.ts` 与工具链 prompt 分层,但在文档里强制写清“正式主链 / 工具链”边界。 --- @@ -376,4 +329,4 @@ 一句话总结就是: -**当前角色资产系统把“默认描述文本”和“正式模型 prompt”拆成了两层,这是合理的;真正的问题不是有两层,而是“默认描述文本层”现在同时保留了前端本地映射和后端 LLM 编译两条链,而当前 UI 主链只用了前者,导致出现明显的冗余和认知混乱。** +**当前角色资产系统把“默认描述文本”和“正式模型 prompt”拆成了两层,这是合理的;默认描述文本层已经统一为前端本地映射单一主源,当前剩余主要问题不再是双主源,而是 `scenePromptText` 仍未形成完整 UI 闭环。** diff --git a/docs/audits/README.md b/docs/audits/README.md index 9890e3e9..998b2de7 100644 --- a/docs/audits/README.md +++ b/docs/audits/README.md @@ -15,6 +15,7 @@ - [FUNCTION_RUNTIME_FULL_TEST_AUDIT_2026-04-16.md](./FUNCTION_RUNTIME_FULL_TEST_AUDIT_2026-04-16.md):Function 运行时完整测试、服务端承接验证与当前门禁缺口。 - [ITEM_AND_BUILD_PRD_AUDIT_2026-04-05.md](./ITEM_AND_BUILD_PRD_AUDIT_2026-04-05.md):物品生成与 Build 标签系统对 PRD 的落地情况。 - [CUSTOM_WORLD_CREATOR_TOOL_AUDIT_2026-04-08.md](./CUSTOM_WORLD_CREATOR_TOOL_AUDIT_2026-04-08.md):自定义世界创作工具当前问题、体验断层和优化优先级审计。 +- [AGENT_TO_DRAFT_TO_WORLD_PIPELINE_AUDIT_2026-04-20.md](./AGENT_TO_DRAFT_TO_WORLD_PIPELINE_AUDIT_2026-04-20.md):Agent 聊天、草稿生成、作品库存储与进入世界之间的断点、多 pipeline、冗余与未实装项审计。 - [CHARACTER_ASSET_PROMPT_CHAIN_AUDIT_2026-04-20.md](./CHARACTER_ASSET_PROMPT_CHAIN_AUDIT_2026-04-20.md):角色资产默认描述文本、正式图像/动作 prompt、共享模板与保留接口的分层与冗余审计。 - [engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md](./engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md):对 `2026-04-19` 工程清理审计的当前仓库复核,区分已完成项、仍存边界问题和新的热点迁移。 - [engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md](./engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md):未引用垃圾、旧入口残留、前后端双份真相与后端迁移项的专项审计。 diff --git a/docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md b/docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md new file mode 100644 index 00000000..da119699 --- /dev/null +++ b/docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md @@ -0,0 +1,369 @@ +# 当前工程优化点盘点(2026-04-20) + +更新时间:`2026-04-20` + +## 0. 盘点目标 + +这份文档用于回答一个更直接的问题: + +**基于当前仓库状态,接下来最值得投入工程时间的优化点是什么。** + +本轮只做文档盘点,不直接修改业务代码;结论同时参考了当前工作区现状。 +需要注意,仓库当前存在一批未提交改动,尤其集中在 `custom world`、`assets`、`platform shell` 相关模块,所以本文更强调“优先级与切入方式”,而不是要求做大范围整仓改写。 + +--- + +## 1. 当前快照 + +## 1.1 本轮复核方式 + +本轮主要复核了以下内容: + +1. 现有工程优化审计文档与目录索引 +2. `package.json`、`vite.config.ts`、`.eslintrc.cjs` 等门禁脚本 +3. 当前前端、后端、脚本目录的大文件热点 +4. 运行时、鉴权、自定义世界、资产链路的边界实现 +5. 当前 `typecheck / lint / build` 状态 + +--- + +## 1.2 当前门禁结果 + +| 项目 | 结果 | 当前判断 | +| --- | --- | --- | +| `npm run typecheck` | 失败 | 当前第一优先级问题,类型基线已失真 | +| `npm run lint:eslint` | 失败 | `136` 个 error、`4` 个 warning,且 `95` 个可自动修复 | +| `npm run build` | 通过 | 发布链路未红,但体积压力仍明显存在 | + +### 关键说明 + +当前状态和 `2026-04-10` 那轮“build warning 直接拦截”的状态不同: + +1. **构建现在可以通过。** +2. **真正变成第一阻塞项的是 `typecheck` 与 `lint`。** +3. **构建虽然通过,但主包、功能包、CSS 体积依然偏重,说明性能类优化仍然值得做。** + +--- + +## 1.3 当前热点文件快照 + +本轮按源码目录统计的大文件热点如下: + +| 文件 | 当前行数 | 判断 | +| --- | --- | --- | +| `src/components/CustomWorldEntityEditorModal.tsx` | `6122` | 当前前端最大热点 | +| `server-node/src/app.test.ts` | `3568` | 后端测试聚合度过高 | +| `server-node/src/modules/assets/characterAssetRoutes.ts` | `2802` | 资产路由职责过重 | +| `src/services/ai.ts` | `2432` | 浏览器侧 AI 编排仍然偏重 | +| `server-node/src/modules/story/storyActionRoutes.test.ts` | `2402` | 运行时路由测试聚合度过高 | +| `src/data/npcInteractions.ts` | `2274` | NPC 规则数据仍然集中 | +| `src/prompts/storyPromptBuilders.ts` | `1728` | prompt 构造成为新的复杂度中心 | +| `server-node/src/modules/custom-world/runtimeProfile.ts` | `1623` | custom world runtime 编译热点 | +| `src/hooks/story/npcEncounterActions.ts` | `1582` | NPC 行动流仍然偏重 | +| `src/components/game-shell/PlatformHomeView.tsx` | `1474` | 平台首页壳层继续膨胀 | +| `src/components/game-shell/PreGameSelectionFlow.tsx` | `1418` | 前置选择流程职责过多 | +| `src/services/customWorld.ts` | `1383` | 自定义世界服务虽然收缩,但仍偏大 | + +--- + +## 2. 结论先行 + +当前仓库的优化重点,已经不是“继续清旧 Vite 插件链路”或者“继续讨论前后端是否要分离”。 + +更准确地说,当前最值得做的优化点已经收敛成四类: + +1. **先恢复可信的工程基线。** + `typecheck` 与 `lint` 当前都是红线,继续扩功能会放大返工成本。 +2. **拆掉正在持续膨胀的新热点。** + 热点已经从早期运行时主链,迁移到 `custom world`、`asset routes`、`platform shell`、`prompt builders`。 +3. **继续把前端退出“运行时真相”和“鉴权真相”。** + 当前前端仍保留本地快照镜像与自动登录凭证持久化。 +4. **补一轮入口归档,减少疑似孤岛模块和大测试聚合文件。** + 这部分不一定最急,但会持续拉低仓库可维护性。 + +一句话判断: + +**当前最值得投入的不是横向加功能,而是把质量门禁重新拉绿,再把 custom world / asset / platform 这批新复杂度中心拆开。** + +--- + +## 3. 优化点清单 + +## 3.1 P0:先恢复类型基线 + +这是当前最优先的工程优化点。 + +### 证据 + +`npm run typecheck` 当前失败,主要问题集中在两类: + +1. `CustomWorldCampScene` 结构漂移 + - `src/components/CustomWorldEntityEditorModal.test.tsx` + - `src/data/customWorldLibrary.ts` + - `src/services/customWorld.ts` + - `src/services/customWorldCamp.ts` +2. 局部实现与类型定义不同步 + - `src/components/auth/AccountModal.test.tsx` 的测试数据缺少新增字段 + - `src/components/game-canvas/GameCanvasShared.tsx` 引用了未定义的 `DEFAULT_IMAGE_STYLE` + +### 影响 + +1. 类型系统已经不能提供可信回归信号。 +2. 自定义世界链路当前正在迭代,如果继续在红线状态叠加修改,后续会反复出现“改 A 崩 B”的情况。 +3. 测试 fixture 与正式类型脱节,会让测试文件逐渐失去文档价值。 + +### 建议 + +1. 先补一个统一的 `CustomWorldCampScene` 构造/归一化入口,禁止在多个文件里手写不完整字面量。 +2. 把 `auth`、`custom world` 的测试 fixture 改成工厂函数,避免字段新增后多处漏改。 +3. 单独清掉 `GameCanvasShared.tsx` 这类“编译即失败”的确定性问题,优先恢复 `typecheck` 绿色基线。 + +--- + +## 3.2 P0:恢复 lint 可信度,区分机械问题和真实问题 + +这项和类型基线同级。 + +### 证据 + +`npm run lint:eslint` 当前结果是: + +- `136` 个 error +- `4` 个 warning +- 其中 `95` 个问题可自动修复 + +当前 lint 问题明显分成两层: + +1. 机械问题 + - import 排序 + - export 排序 + - 未使用导入 +2. 真实问题 + - `server-node/src/modules/inventory/inventoryStoryActionService.ts` 出现 React Hook 规则错误 + - `server-node/src/migrate.ts` 仍触发 `no-console` + - `packages/shared/src/http.ts` 触发 `@typescript-eslint/ban-types` + - 若干文件存在真正未使用变量、转义和规则误配问题 + +### 影响 + +1. 当前 lint 信号噪音仍然较高,不利于 review。 +2. 真实问题会被大量机械问题掩盖。 +3. 团队会更倾向于跳过 lint,而不是信任 lint。 + +### 建议 + +1. 先跑一轮仅机械修复的清理批次,优先吃掉 import sort、unused imports 这类低风险项。 +2. 再单独处理 Hook 误用、共享契约类型、脚本规则豁免这类语义问题。 +3. 之后把“自动可修复问题”与“必须人工处理的问题”拆成两个门禁视角,减少下次再次堆积。 + +--- + +## 3.3 P1:拆 custom world / asset / platform 新热点 + +这是当前最有性价比的结构性优化点。 + +### 证据 + +当前复杂度最高的业务热点,已经集中在这些模块: + +1. `src/components/CustomWorldEntityEditorModal.tsx` +2. `server-node/src/modules/assets/characterAssetRoutes.ts` +3. `src/services/ai.ts` +4. `src/prompts/storyPromptBuilders.ts` +5. `server-node/src/modules/custom-world/runtimeProfile.ts` +6. `src/components/game-shell/PlatformHomeView.tsx` +7. `src/components/game-shell/PreGameSelectionFlow.tsx` +8. `src/hooks/story/npcEncounterActions.ts` + +### 问题本质 + +这些文件并不是单纯“代码多”,而是同时承载了多类职责: + +1. UI 状态 +2. 领域规则 +3. 请求编排 +4. 文本构造 +5. 运行时映射 +6. 面板切换与流程控制 + +### 建议 + +1. `CustomWorldEntityEditorModal.tsx` + - 先按“实体列表/表单区/资源区/高级设置/预览区”拆组件 + - 再把数据准备与提交编排抽成 hook +2. `characterAssetRoutes.ts` + - 拆成 route、prompt payload、job orchestration、产物发布、错误响应五层 +3. `PlatformHomeView.tsx` 与 `PreGameSelectionFlow.tsx` + - 把页面壳层、数据加载、卡片渲染、弹层控制拆开 +4. `storyPromptBuilders.ts` 与 `runtimeProfile.ts` + - 把“模板片段”“上下文归一化”“规则裁剪”“最终拼接”分层 + +--- + +## 3.4 P1:继续控制构建产物体积 + +构建虽通过,但体积已经给出明显信号。 + +### 当前证据 + +本轮 `npm run build` 输出里,几个值得关注的点是: + +1. `dist/assets/AuthenticatedApp-*.js`:`794.77 kB` +2. `dist/assets/index-*.js`:`197.44 kB` +3. `dist/assets/CustomWorldResultView-*.js`:`163.38 kB` +4. `dist/assets/ai-*.js`:`131.73 kB` +5. `dist/assets/PreGameSelectionFlow-*.js`:`96.39 kB` +6. `dist/assets/index-*.css`:`201.44 kB` + +### 影响 + +1. 虽然还没触发新的 build gate 红线,但首屏、缓存和移动端体验会继续承压。 +2. `AuthenticatedApp` 主包偏大,说明平台壳层仍然装入了过多首屏不必需能力。 +3. CSS 体积继续上涨,说明样式正在跨模块相互堆叠。 + +### 建议 + +1. 继续把 custom world、asset studio、平台详情页、角色资产工具从主壳层路径中抽离。 +2. 审查 `ai.ts`、`custom world result view`、`pregame selection` 是否还能再延迟加载。 +3. 对全局样式做一次按模块归属清理,减少公共样式无限增长。 + +--- + +## 3.5 P1:继续收紧前端与后端边界 + +这项已经不是“要不要做”的问题,而是“还剩多少尾巴没收完”。 + +### 当前证据 + +1. `src/services/apiClient.ts` + - 当前仍把 `access token` + - 自动登录用户名 + - 自动登录密码 + 写入 `window.localStorage` +2. `src/hooks/story/runtimeStoryCoordinator.ts` + - 当前仍会在调用后端运行时前先 `putSaveSnapshot(...)` + - 响应后继续 `rehydrateSavedSnapshot(...)` +3. `src/hooks/story/npcEncounterActions.ts` + - 当前仍从前端动作流触发 `generateQuestForNpcEncounter(...)` + - 说明 NPC 任务“换单/重抽”分支尚未完全后端化 + +### 影响 + +1. 前端仍保留了一部分运行时真相与鉴权真相。 +2. 自动登录凭证持久化在边界和安全上都不理想。 +3. 运行时快照前置写入,会让“前端镜像状态”和“后端会话状态”继续纠缠。 + +### 建议 + +1. 优先移除自动登录用户名/密码本地持久化,收敛到服务端 session / refresh 机制。 +2. 把运行时快照改为“展示缓存”而不是“提交前真相源”。 +3. 把 NPC 任务更换动作补齐到后端 runtime/session 边界,不再由前端直接发起生成决策。 + +--- + +## 3.6 P2:做一次疑似孤岛模块与旧入口归档 + +这项不一定最紧急,但现在做会明显降低后续维护噪音。 + +### 当前现象 + +从当前入口关系看,以下模块值得做一次正式复核: + +1. `src/components/GameShell.tsx` +2. `src/components/custom-world-home/CustomWorldCreationHub.tsx` +3. `src/components/custom-world-home/CustomWorldCreationLauncherModal.tsx` +4. `src/components/custom-world-agent/CustomWorldAgentLauncherModal.tsx` +5. `src/components/custom-world-agent/CustomWorldAgentDraftDrawer.tsx` +6. `src/hooks/story/storyBootstrap.ts` +7. `src/hooks/useEquipmentFlow.ts` +8. `src/hooks/useForgeFlow.ts` +9. `src/hooks/useInventoryFlow.ts` +10. `src/services/typewriter.ts` + +### 当前判断 + +这批模块不一定全部是垃圾代码,但至少说明一件事: + +**仓库里仍然存在一批“不是正式入口、也没有清晰归档标签”的过渡实现。** + +### 建议 + +把这类模块统一分成三类: + +1. 正式保留并接回入口 +2. 明确标记为实验稿 +3. 直接归档或删除 + +这样可以减少后续开发时的误判成本。 + +--- + +## 3.7 P2:拆测试聚合文件,恢复测试的定位能力 + +当前测试文件也已经出现“大一统热点”。 + +### 证据 + +1. `server-node/src/app.test.ts`:`3568` 行 +2. `server-node/src/modules/story/storyActionRoutes.test.ts`:`2402` 行 +3. `server-node/src/modules/assets/characterAssetRoutes.test.ts`:`1235` 行 +4. `src/hooks/story/npcEncounterActions.test.ts`:`1199` 行 + +### 影响 + +1. 失败定位成本高。 +2. fixture 复用差,字段一变容易整片测试跟着漂移。 +3. 测试文件本身开始变成新的维护热点。 + +### 建议 + +1. 按领域动作拆测试文件,而不是继续堆到单一总测文件中。 +2. 补 fixture builder / factory,减少字面量散落。 +3. 对 `runtime / auth / custom world / assets` 这几条链路增加更明确的契约测试分层。 + +--- + +## 4. 推荐执行顺序 + +如果只按工程收益排序,建议按下面的顺序推进: + +1. 先修 `typecheck` +2. 再把 `lint` 分成机械修复和语义修复两轮 +3. 然后拆 `custom world / asset / platform` 热点 +4. 再继续收前端运行时与鉴权边界 +5. 最后处理孤岛模块归档和测试拆分 + +--- + +## 5. 当前不建议优先做的事 + +1. 不建议在 `typecheck` 和 `lint` 仍为红线时继续横向扩功能。 +2. 不建议直接在 `CustomWorldEntityEditorModal.tsx`、`characterAssetRoutes.ts`、`PlatformHomeView.tsx` 里继续堆新逻辑。 +3. 不建议把 bundle 体积问题简单理解为“先放宽阈值”,当前更适合继续拆职责和延迟加载。 +4. 不建议在未确认入口关系前随手删除可疑旧模块,先做归档分类更稳。 + +--- + +## 6. 本文依据 + +文档依据: + +1. `docs/audits/engineering/README.md` +2. `docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_PRIORITIES_2026-04-10.md` +3. `docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md` +4. `docs/experience/PROJECT_WORK_EXPERIENCE_PLAYBOOK.md` + +当前仓库复核依据: + +1. `package.json` +2. `.eslintrc.cjs` +3. `vite.config.ts` +4. `scripts/build-gate.mjs` +5. `src/App.tsx` +6. `src/services/apiClient.ts` +7. `src/hooks/story/runtimeStoryCoordinator.ts` +8. `src/hooks/story/npcEncounterActions.ts` +9. 当前源码大文件体量扫描结果 +10. `npm run typecheck` +11. `npm run lint:eslint` +12. `npm run build` diff --git a/docs/audits/engineering/README.md b/docs/audits/engineering/README.md index 8d3891dc..e3ae7bcf 100644 --- a/docs/audits/engineering/README.md +++ b/docs/audits/engineering/README.md @@ -4,25 +4,29 @@ ## 当前推荐入口 -1. [ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md](./ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md) +1. [CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md](./CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md) + 这一版是面向当前仓库状态的优化点盘点,适合直接拿来排优先级和拆执行批次。 +2. [ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md](./ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md) 这一版是对 `2026-04-19` 基线的当前仓库复核,明确哪些问题已经处理、哪些表述需要纠正、热点又迁移到了哪里。 -2. [ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md](./ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md) +3. [ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md](./ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md) 这一版保留原始问题快照和执行回填,适合回看“为什么会有这轮清理与边界收口”。 -3. [ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md) +4. [ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md) 这一版最适合作为当前工程基线,重点从“是否真正绿色”“门禁有没有覆盖真实风险”来判断仓库状态。 -4. [ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md) +5. [ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md) 适合回看运行时主链路、Story/Combat 边界、分层过渡期问题。 -5. [ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md) +6. [ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md) 适合看第一轮系统性工程扫描,了解最早的问题基线。 ## 融合结论 - 当前仓库已经完成“旧 dev 插件链路删除、根目录噪音清理、`server-node -> src/**` 反向依赖切断”这批第一阶段任务。 +- 当前如果想直接判断“今天先优化什么”,优先看 `CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md`。 - 当前的新重点已经进一步收敛到三类:未接线孤岛模块、前端残留的运行时/鉴权真相、热点向 prompt/runtime profile/平台入口壳层迁移。 - 三轮结论是一致收敛的:问题不在“有没有开始工程化”,而在“工程化是否真正覆盖了最危险的主链路”。 - 最新一轮已经把关注点集中到质量门禁、真实绿色基线、关键模块豁免和 build warning 上。 - `2026-04-19` 这一轮把问题压实到了四类:仓库噪音、旧 dev 入口残留、前端越界运行时逻辑、巨型热点文件。 - `2026-04-20` 这一轮进一步确认:前两类已经阶段性完成,当前真正剩下的是边界尾巴和新热点迁移。 - 如果只是为了判断现在先做什么,直接从 `2026-04-01` 开始即可。 -- 如果是要看当前清理和边界收口的最新状态,优先看 `2026-04-20`。 +- 如果是要看当前清理和边界收口的最新状态,优先看 `ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md`。 +- 如果是要看“当前可执行的优化点清单”,优先看 `CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md`。 - 如果是要做长期重构方案,再按 `2026-03-29 -> 2026-03-30 -> 2026-04-01 -> 2026-04-19 -> 2026-04-20` 的顺序回看演进。 diff --git a/docs/experience/AGENT_UI_CHANGELOG.md b/docs/experience/AGENT_UI_CHANGELOG.md index 0c83ad74..80c40c9f 100644 --- a/docs/experience/AGENT_UI_CHANGELOG.md +++ b/docs/experience/AGENT_UI_CHANGELOG.md @@ -141,6 +141,25 @@ - 后续如果继续调整平台主 Tab 视觉,优先改 `src/index.css` 的平台主题 token 和 remap 规则;只有 token 无法表达时,再做局部组件样式补丁,避免亮色主题再次出现“页面整体是亮的,但局部卡片仍是暗的”。 - 参考图方向已明确:平台亮色主题应以白色为主底色,粉红只承担背景气氛和重点 CTA,不应让整页主壳继续像深粉底板。 - 移动端底部 `platform-bottom-nav` 的 Tab 激活态必须与默认态使用同一套盒模型;边框要预占位,不能在 onPress / active 时临时增加边框导致按钮尺寸和留白跳变。 +- 2026-04-20 第二轮细查补色时,继续把 `PlatformWorldDetailView.tsx`、`PlatformHomeView.tsx`、`PreGameSelectionFlow.tsx` 里落在白底/浅底面板上的标题、说明、次级标签、搜索栏和加载兜底文本显式切回平台亮色 token,避免亮色主题下继续出现浅底白字或过浅灰字。 +- 2026-04-20 第三轮修正方向后,平台首页移动端底部 `platform-bottom-nav` 的高度、内边距、按钮圆角、图标尺寸、标签字号统一收口到 `src/index.css` token;`PlatformHomeView.tsx` 只保留结构类,避免 `h-14`、容器 padding、按钮内部内容间距和 active 底座各自维护半套尺寸,导致选中态看起来比 Tab 槽位更矮或更高。 +- 2026-04-20 第四轮把平台亮色主题顶部过重的红色收轻:`--platform-body-fill`、`--platform-hero-fill`、`--platform-shell-glow-*` 与 `--platform-surface-glow-*` 改成更接近暖白 + 浅珊瑚的低饱和版本,首页 / 创作页 / 详情页 Hero 覆层统一改走 `--platform-hero-overlay-strong`,避免组件里继续写死高饱和粉红渐变。 + +--- + +## 11. 2026-04-20 创作 Agent 聊天工作台亮色主题补色 + +- `src/components/custom-world-agent/*` 这一条创作 Agent 工作台链路已统一切回 `platform-subpanel`、`platform-input`、`platform-button`、`platform-banner`、`platform-progress-track`,亮色主题下不再继续裸露 `bg-[#111318]`、`bg-black/*`、`bg-white/*`、`text-white` 这类历史深色残留。 +- 聊天线程中的用户气泡、助手气泡、系统消息、推荐回复按钮、流式回复态统一映射到平台 token;后续如果继续调整创作 Agent 聊天视觉,优先改平台 token 或平台 class,不要在组件里再单独写一套聊天色板。 +- 顶栏、操作横幅、进度条、输入框的状态色统一复用平台亮暗主题变量,避免再次出现“页面整体已切亮色,但 Agent 局部还是旧暗色弹层”的割裂感。 + +--- + +## 12. 2026-04-20 NPC 聊天退出恢复与文本阅读性修正 + +- `AdventurePanel.tsx` 的叙事 `storyText` 已取消斜体,改为更大的正文尺寸,避免长段阅读时发飘。 +- 冒险面板里的 `actionText` 统一上调到聊天态同级字号;`detailText` 不再默认渲染,保持底部选项区更清爽。 +- `npcEncounterActions.ts` 在“退出聊天”后重新续写剧情时,会优先把当前故事里最近一轮已经呈现给玩家的非聊天选项文案并回 `optionCatalog`,避免高好感聊天收束后又退回 NPC 静态 fallback 文案。 --- diff --git a/docs/planning/CURRENT_AGENT_CREATION_FLOW_OPTIMIZATION_PLAN_2026-04-20.md b/docs/planning/CURRENT_AGENT_CREATION_FLOW_OPTIMIZATION_PLAN_2026-04-20.md new file mode 100644 index 00000000..68fe5eff --- /dev/null +++ b/docs/planning/CURRENT_AGENT_CREATION_FLOW_OPTIMIZATION_PLAN_2026-04-20.md @@ -0,0 +1,388 @@ +# 当前 Agent 创作流程优化执行规划(大白话版) + +更新时间:`2026-04-20` + +## 先把话说死 + +这轮不再加新流程。 + +不再新增一套创作动线。 +不再为了“更完整”继续把 PRD 里没落完的所有阶段、面板、动作全补出来。 +不再把前端创作工具改成另一套长得不一样的新系统。 + +这轮只做一件事: + +**把现在这条你已经满意的前端创作流程,收紧、理顺、删重、补通,让它从“能跑”变成“稳、顺、好维护、不会自己打自己”。** + +这份规划就是基于 [AGENT_TO_DRAFT_TO_WORLD_PIPELINE_AUDIT_2026-04-20.md](../audits/AGENT_TO_DRAFT_TO_WORLD_PIPELINE_AUDIT_2026-04-20.md) 里已经确认的问题,重新收束出来的一版执行方案。 + +--- + +## 1. 现在最大的问题,用大白话讲是什么 + +不是界面丑。 +不是步骤不够多。 +不是入口不够花。 + +而是现在这条链里,很多地方在“一个流程里混着好几套脑子”。 + +具体就是: + +1. 用户明明在走 Agent 创作,但走到一半,很多关键数据又偷偷变成了 old profile 流程在接手。 +2. 明明已经有 Agent session 这条主线,但结果页、作品库、旧生成接口、旧编辑器能力还都在同时干活。 +3. 明明有“发布世界”这个概念,但现在实际上“不发布也能直接进入世界”。 +4. 明明有一些 session 内的数据,比如建议动作、草稿卡、澄清项、质量检查,结果走到结果页之后就像断电了一样,后面没人接着用。 +5. 明明有些功能已经决定这轮不做,但代码里和文档里还留着很多“半做不做”的说法,会持续误导后续开发。 + +所以这轮优化的目标不是“让系统更大”,而是: + +**让整条现有流程只认一条主线,别再一会儿 Agent、一会儿 legacy、一会儿旧 session、一会儿作品库各管各的。** + +--- + +## 2. 这轮优化后的目标状态 + +我们要收敛到下面这个状态: + +```text +用户进入 Agent 创作 +-> 在现有工作区里聊天和生成草稿 +-> 草稿整理完成后进入现有结果页 +-> 结果页只做预览、少量必要确认、进入世界 +-> 进入世界时走一条明确、统一、可解释的数据链 +-> 平台“我的创作”能稳定找回这份草稿或这份作品 +``` + +注意,这里有两个关键词: + +1. **还是现有动线** +2. **但背后的数据链要统一** + +也就是说: + +前端看上去可以几乎不换流程。 +但后面谁是真相源、谁负责编译、谁负责保存、谁负责恢复、哪些能力要删掉,必须彻底讲清楚。 + +--- + +## 3. 这轮不做什么 + +为了避免后面又做散,先把“不做什么”写清楚。 + +### 3.1 不新增新的大流程 + +不做这些: + +1. 不再新增“另一个 Agent 创作工作台” +2. 不再新增“另一套草稿结果页” +3. 不再新增“另一条作品发布流程” +4. 不再新增“另一套创作中心入口” + +### 3.2 不为了补 PRD 而硬补所有未完成能力 + +不做这些: + +1. 不把所有 `lock / unlock / regenerate_scope / expand_long_tail / scene asset pipeline` 一次性全打完 +2. 不为了“文档里写过”就把所有没接线面板都接进来 +3. 不把当前工作区重新改造成一个更复杂的大后台 + +### 3.3 不把结果页继续当旧编辑器扩写 + +这轮明确不再鼓励: + +1. 在结果页继续加更多直接生成角色 / 地点的按钮 +2. 在结果页继续加更多直接改 legacy profile 的编辑能力 +3. 让结果页承担越来越重的“补世界”职责 + +一句话: + +**结果页要收口,不要继续发散。** + +--- + +## 4. 接下来真正要做的 5 件事 + +## 4.1 第一件事:先定一条唯一主链,别再多套数据同时接力 + +这是第一优先级,也是最重要的一件事。 + +现在的问题不是“没东西可用”,而是“能用的东西太多了,而且互相抢活”。 + +接下来要明确: + +1. 当前 Agent 创作流程里,`Agent session` 才是草稿阶段的真相源。 +2. 结果页只是这份草稿的展示和收口,不应该变成另一套独立编辑器。 +3. 进入世界时,只能走一条明确的编译出口,不能这里转一次、那里改一次、最后谁改得晚听谁的。 + +用大白话讲,就是: + +**从聊天开始到点“进入世界”为止,中间只能有一条主水管。** + +不能再出现: + +1. Agent session 里一份数据 +2. 前端桥接后又一份 profile +3. 结果页本地改完又一份 profile +4. 自动保存到作品库后再来一份 profile + +这样下去,后面谁出 bug 都说不清到底是哪一层改坏的。 + +所以这一阶段的目标不是改 UI,而是先把话语权统一: + +1. 草稿阶段谁说了算 +2. 进入世界前谁负责最终编译 +3. 作品库里保存的到底是“正式世界”还是“当前草稿快照” + +这件事不解决,后面所有优化都会继续打架。 + +--- + +## 4.2 第二件事:把结果页收口,只保留当前流程真正需要的事 + +现在结果页的问题很简单: + +它干的活太多了。 + +它现在既像: + +1. 草稿预览页 +2. 旧自定义世界编辑器 +3. AI 补角色/补地点的入口 +4. 自动保存中转页 +5. 进入世界前的最后一跳 + +这就是为什么它会越来越乱。 + +这轮要做的不是重做结果页,而是收口结果页。 + +收口方向很明确: + +1. 结果页继续保留现在你满意的浏览和进入世界体验 +2. 但要逐步去掉“它自己偷偷改世界结构”的能力 +3. 让它更像“当前草稿的总览页”,而不是“另一套世界编辑器” + +用大白话讲: + +**结果页负责看,不负责偷偷再造一遍世界。** + +所以这里建议后续逐步处理: + +1. 把结果页里那些直接生成 playable/story/landmark 的旧能力下掉 +2. 把直接改 legacy profile 的重编辑能力从结果页移走或收紧 +3. 让“去 Agent 调整设定”真的是回主流程调,而不是结果页自己补完半套流程 + +这一步做完的好处很直接: + +1. 结果页职责会清楚很多 +2. 进入世界前的状态会更稳定 +3. 不会再出现“用户以为还在 Agent 流里,实际上已经走到 legacy 编辑器里了” + +--- + +## 4.3 第三件事:平台入口统一,不让草稿恢复和作品查看继续割裂 + +现在还有一个体验问题不是流程长短的问题,而是入口不统一。 + +简单说就是: + +1. 后端已经能区分“草稿”和“已发布作品” +2. 但平台页里“我的创作”主要还在看 `myEntries` +3. Agent 草稿并不能自然地稳定出现在同一个主入口里 + +这会带来两个问题: + +1. 用户做了一半的草稿,不容易稳定找回来 +2. 系统里其实已经有创作中心能力,但主入口没认它 + +所以接下来要做的不是新做一个创作中心,而是: + +**把已经存在的聚合能力真正接回现在的平台入口。** + +用户看到的应该是: + +1. 还没进世界的草稿,可以继续创作 +2. 已经成型的作品,可以查看或进入世界 + +而不是: + +1. 草稿在一套地方 +2. 已保存作品在另一套地方 +3. 恢复创作还得靠 sessionId 或隐藏状态兜底 + +这一步的核心价值不是“新功能”,而是“东西别丢、入口别分裂、用户心智别断”。 + +--- + +## 4.4 第四件事:删掉重复 pipeline,不再同时养两三套创作生成链 + +这一步很关键,而且一定要明确态度: + +**既然你已经决定当前前端创作流程满意,那就不能继续默认保留那么多并行旧链。** + +现在最典型的重复链有: + +1. Agent 创作链 +2. 旧 `custom-world/sessions` 世界生成链 +3. 结果页 legacy profile 直改链 + +它们的共同问题是: + +1. 都能生成世界 +2. 都能改世界 +3. 但不是同一套状态模型 +4. 后期维护会越来越痛苦 + +所以这一步不是说要立刻把所有旧东西物理删除,而是要明确分层: + +1. 哪条是当前正式主链 +2. 哪条是兼容链 +3. 哪条只是暂时留着,但不能再往上继续加功能 + +用大白话讲,就是: + +**该扶正的扶正,该降级的降级,该冻结的冻结。** + +尤其是旧 `custom-world/sessions` 这条链,如果还要保留,也只能是兼容入口,不能再和 Agent 主链平起平坐。 + +--- + +## 4.5 第五件事:把文稿里那些“这轮不做”的未完成项从主叙事里移掉 + +这是你这次特别强调的点,我完全同意,而且它很重要。 + +现在很多文稿的问题不是“写错了”,而是: + +1. 写了很多理论上该有的能力 +2. 但当前版本并不准备继续往那个方向扩 +3. 结果文档会不断把团队拉回“是不是还要把这些补完”的思路里 + +这会直接制造两种问题: + +1. 开发判断会飘 +2. 后续审计会永远得到“未完成项很多”的结论 + +所以这轮文档治理要做的,不是把文稿全删空,而是分清三类内容: + +1. **当前版本要继续优化的** +2. **当前版本明确不做、先冻结的** +3. **未来可以再看,但这轮不纳入执行规划的** + +用大白话讲: + +**文档也要学会闭嘴。** + +不是所有想过的东西,都要继续挂在当前版本的主任务里。 + +--- + +## 5. 推荐执行顺序 + +这轮建议按下面顺序推进,不建议乱穿插。 + +## 第一阶段:先收主链 + +先做: + +1. 定义当前正式主链 +2. 明确 Agent session、结果页、作品库、进入世界之间谁负责什么 +3. 停止继续增强结果页里的 legacy 编辑能力 + +这一阶段的目标是: + +**先让水管只有一根。** + +## 第二阶段:再收结果页和平台入口 + +再做: + +1. 结果页职责收口 +2. 平台“我的创作”入口统一 +3. 草稿恢复和作品查看走同一套入口认知 + +这一阶段的目标是: + +**让用户走起来更顺,让系统找回内容更稳定。** + +## 第三阶段:再处理旧 pipeline 的降级和冻结 + +再做: + +1. 旧 `custom-world/sessions` 链降级 +2. 结果页直改 profile 的旧能力收紧 +3. 兼容链保留边界写清楚 + +这一阶段的目标是: + +**减少系统自己和自己打架。** + +## 第四阶段:最后做文档清理 + +最后做: + +1. 把当前版本不再追的未完成项,从主规划文稿里移出去 +2. 把“未来也许做”从“这轮要做”里拆开 +3. 让所有当前规划只服务当前版本 + +这一阶段的目标是: + +**让接下来所有开发都围绕同一套现实目标执行。** + +--- + +## 6. 每个阶段做完以后,应该看到什么效果 + +## 阶段一做完 + +应该看到: + +1. 代码里谁是主链一眼能看明白 +2. 不会再出现一会儿 Agent、一会儿 legacy profile 接管全局的情况 +3. 进入世界时的数据来源更清楚 + +## 阶段二做完 + +应该看到: + +1. 结果页更干净 +2. 平台页更容易找回自己的创作 +3. 用户对“草稿”“作品”“进入世界”这三个概念不会混 + +## 阶段三做完 + +应该看到: + +1. 重复 pipeline 明显减少 +2. 旧链不再继续吞主流程职责 +3. 后续开发不会再不知道该往哪条链上接 + +## 阶段四做完 + +应该看到: + +1. 文档和代码目标一致 +2. 团队不会再被一堆“理论上应该补”的项拉偏 +3. 后续迭代能真正围绕“优化已有流程”推进 + +--- + +## 7. 这轮最重要的判断标准 + +这轮不是看我们补了多少功能。 + +这轮的判断标准应该是下面 5 条: + +1. 用户现在这条创作流程有没有被打断 +2. 同一个世界的数据是不是只走一条清楚的主链 +3. 结果页是不是还在偷偷承担旧编辑器职责 +4. 平台入口能不能稳定找回草稿和作品 +5. 文档是不是已经不再推动大家去补这轮明确不做的东西 + +如果这 5 条做好了,就说明这轮方向是对的。 + +--- + +## 8. 一句话总结 + +接下来的优化,不是再发明一套更复杂的创作流程,而是把当前你已经满意的这条前端动线背后的数据链、入口、职责和文档全部收紧到同一个方向上: + +**少一点并行、少一点桥接、少一点重复、少一点“半做半留”,把现有流程真正打磨成一条稳定主链。** diff --git a/docs/planning/README.md b/docs/planning/README.md index 71e6b2ca..0903a157 100644 --- a/docs/planning/README.md +++ b/docs/planning/README.md @@ -3,6 +3,7 @@ ## 当前入口 - [CURRENT_GAME_ITERATION_PRIORITIES_2026-04-03.md](./CURRENT_GAME_ITERATION_PRIORITIES_2026-04-03.md):当前阶段最值得优先做什么、为什么,以及它和审计/PRD 的对应关系。 +- [CURRENT_AGENT_CREATION_FLOW_OPTIMIZATION_PLAN_2026-04-20.md](./CURRENT_AGENT_CREATION_FLOW_OPTIMIZATION_PLAN_2026-04-20.md):在不新增前端创作流程的前提下,围绕当前 Agent 创作动线做收口、删重、补通和文档收束的大白话执行规划。 - [EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md](./EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md):基于“前端只做表现、逻辑与数据全部后端化”的工程重构规划。 - [EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md](./EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md):将后端化重构拆成可并行推进、尽量不冲突的任务流与协作顺序。 - [BEIJING_POLICY_APPLICATION_OVERVIEW_13_21_24_2026-04-14.md](./BEIJING_POLICY_APPLICATION_OVERVIEW_13_21_24_2026-04-14.md):北京市方向 13 / 21 / 24 的统一判断、共用材料框架和准备顺序。 diff --git a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE3_IMPLEMENTATION_PLAN_2026-04-14.md b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE3_IMPLEMENTATION_PLAN_2026-04-14.md index 87f40b23..6aef83af 100644 --- a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE3_IMPLEMENTATION_PLAN_2026-04-14.md +++ b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE3_IMPLEMENTATION_PLAN_2026-04-14.md @@ -825,6 +825,32 @@ isCardDetailLoading: boolean; ## 12. 接口与交互时序 +## 12.0 生成草稿进度阶段 + +第三阶段的“整理一版世界底稿”不应再只显示粗略四阶段,而应贴近真实执行链路。 + +前端进度条至少按以下顺序归并展示: + +1. 接收生成请求 +2. 整理世界骨架 +3. 生成可扮演角色 +4. 生成场景角色 +5. 生成关键场景 +6. 建立场景连接 +7. 补全可扮演角色细节 +8. 补全场景角色细节 +9. 编译世界底稿 +10. 生成角色主形象 +11. 生成幕背景图 +12. 编译草稿卡 +13. 准备精修工作区 + +说明: + +1. 前端步骤名优先复用服务端 `phaseLabel` 的真实语义,不再单独发明一套四段式文案。 +2. 如果服务端处于批处理阶段,顶部 `phaseLabel` / `phaseDetail` 继续直接显示当前批次信息。 +3. 自动补主形象与幕背景图也属于草稿生成链路的一部分,不能在进度 UI 中被误折叠成“已完成”后的隐藏耗时。 + ## 12.1 生成底稿时序 ```text diff --git a/docs/prd/AI_NATIVE_CUSTOM_WORLD_CREATION_HUB_PRD_2026-04-13.md b/docs/prd/AI_NATIVE_CUSTOM_WORLD_CREATION_HUB_PRD_2026-04-13.md index 3058f963..551bf1c2 100644 --- a/docs/prd/AI_NATIVE_CUSTOM_WORLD_CREATION_HUB_PRD_2026-04-13.md +++ b/docs/prd/AI_NATIVE_CUSTOM_WORLD_CREATION_HUB_PRD_2026-04-13.md @@ -315,10 +315,10 @@ UI 主标题建议: 按优先级取: 1. `draftProfile.cover.imageSrc`,当 `sourceType` 为 `uploaded / generated` -2. `draftProfile.camp.imageSrc` 作为默认封面底图 +2. `draftProfile.sceneChapterBlueprints[0].acts[0].backgroundImageSrc` 作为默认封面底图 3. 默认封面底图上叠加 `draftProfile.cover.characterRoleIds` 对应的角色主形象 4. 若未显式指定角色,则按 `playableNpcs` 顺序取前 `3` 个有主图的角色 -5. 若开局场景图为空,则回退到第一张场景图;再不行才回退到首个角色主图或默认占位图 +5. 若开局场景第一幕图片为空,则依次回退到 `draftProfile.camp.imageSrc`、首个 `landmark.imageSrc`、首个角色主图或默认占位图 ### 草稿卡片主操作 @@ -361,10 +361,10 @@ UI 主标题建议: 按优先级取: 1. `CustomWorldProfile.cover.imageSrc`,当 `sourceType` 为 `uploaded / generated` -2. 开局场景图作为默认封面底图 +2. 开局场景第一幕图片作为默认封面底图 3. 默认封面底图上叠加 `cover.characterRoleIds` 指定的角色主形象 4. 若未显式指定角色,则按 `playableNpcs` 顺序取前 `3` 个有主图的角色 -5. 若默认底图不可用,再回退到第一可扮演角色立绘或默认占位图 +5. 若默认底图不可用,则依次回退到 `camp.imageSrc`、首个 `landmark.imageSrc`、第一可扮演角色立绘或默认占位图 ## 7.3 作品封面属性 @@ -387,7 +387,7 @@ interface CustomWorldCoverProfile { 1. `sourceType = default` - 表示继续使用系统默认封面布局 - `imageSrc` 不作为最终封面图使用 - - 底图固定取“开局场景图” + - 底图固定优先取“开局场景第一幕图片” - 前景角色取 `characterRoleIds` 2. `sourceType = uploaded` @@ -399,24 +399,26 @@ interface CustomWorldCoverProfile { - 表示作者通过 AI 生成了一张最终封面 - 卡片与详情页直接显示 `imageSrc` - 不再叠加默认角色前景 + - 生成时允许作者补一句封面描述,系统会结合世界主题、场景素材、角色素材共同构图 ## 7.4 默认封面布局 默认封面布局不是单纯“取开局场景图”,而是: ```text -开局场景图 +开局场景第一幕图片 + 前景主角色主形象 2~3 个 + 用于列表卡片和作品详情的统一封面预览 ``` 明确规则: -1. 默认封面底图固定优先取 `camp.imageSrc` -2. 默认前景角色固定从 `playableNpcs` 中取前 `3` 个有主图的角色 -3. 若作者在 `cover.characterRoleIds` 中显式指定角色,则优先按指定顺序展示 -4. 前端只负责把后端给出的“底图 + 角色主图列表”渲染成封面,不在前端做封面规则推理 -5. 已上传或已生成的最终封面,直接作为成品图显示,不再做默认布局叠加 +1. 默认封面底图固定优先取 `sceneChapterBlueprints[0].acts[0].backgroundImageSrc` +2. 若开局场景第一幕图片不存在,则依次回退到 `camp.imageSrc` 与首个 `landmark.imageSrc` +3. 默认前景角色固定从 `playableNpcs` 中取前 `3` 个有主图的角色 +4. 若作者在 `cover.characterRoleIds` 中显式指定角色,则优先按指定顺序展示 +5. 前端只负责把后端给出的“底图 + 角色主图列表”渲染成封面,不在前端做封面规则推理 +6. 已上传或已生成的最终封面,直接作为成品图显示,不再做默认布局叠加 ## 7.5 作者操作 @@ -433,6 +435,64 @@ interface CustomWorldCoverProfile { 2. 重置为默认后,`sourceType` 回到 `default` 3. 草稿与已发布作品都读取同一份封面属性,不允许出现“草稿页是一个封面、发布后又自动换另一张”的漂移 +## 7.6 AI 封面生成与上传约束 + +### AI 生成输入 + +作者点击 `AI 生成封面` 后,面板至少支持 3 类输入: + +1. 一句封面图描述 +2. 可选参考图 +3. 角色出镜选择 + +系统生成封面时,后端必须自动拼接以下上下文,而不是只吃用户那一句描述: + +1. 世界名、副标题、世界概述、主题基调、玩家目标 +2. 开局场景第一幕标题、摘要、背景图 +3. 营地图与关键场景图 +4. 可扮演角色主形象 +5. 已选择的封面角色顺序 + +结论: + +**封面 AI 生成必须是“用户一句描述 + 系统世界素材拼接”的生成链,而不是裸 prompt。** + +### AI 生成结果要求 + +1. 默认生成尺寸固定为 `16:9` +2. 第一版统一生成 `1600 × 900` +3. 结果图不允许出现标题字、水印、按钮、UI 边框 +4. 结果图要优先满足移动端卡片裁切后的主体可读性 +5. 结果图保存后直接写入作品封面资产目录 + +### 上传封面要求 + +作者点击 `上传封面` 后,不能直接把原图原样落库,必须经过独立裁剪面板处理。 + +裁剪链路要求: + +1. 上传后先进入独立裁剪面板 +2. 裁剪框比例固定为 `16:9` +3. 作者只能平移和缩放,不允许自由改比例 +4. 裁剪完成后,再提交给后端保存 + +### 上传大小与格式限制 + +第一版约束: + +1. 仅支持 `png / jpg / webp` +2. 上传原图大小上限固定为 `10 MB` +3. 后端落库前必须统一裁剪并缩放到 `1600 × 900` +4. 后端保存时需要做体积压缩,目标成品图不超过 `1.5 MB` +5. 若压缩后仍超过限制,返回明确错误,不允许静默保存超标文件 + +### 前后端分工 + +1. 前端负责提供裁剪交互、预览与提交裁剪框 +2. 后端负责最终裁剪、缩放、压缩和大小校验 +3. 前端不能直接把本地裁剪结果当最终事实来源 +4. 同一张上传封面在草稿页、作品库、详情页必须读取同一后端成品地址 + ### 已发布卡片主操作 第一版必须有: diff --git a/docs/prd/AI_NATIVE_NPC_CHAT_SINGLE_TURN_SESSION_PRD_2026-04-18.md b/docs/prd/AI_NATIVE_NPC_CHAT_SINGLE_TURN_SESSION_PRD_2026-04-18.md index f2e820ea..81bc0f55 100644 --- a/docs/prd/AI_NATIVE_NPC_CHAT_SINGLE_TURN_SESSION_PRD_2026-04-18.md +++ b/docs/prd/AI_NATIVE_NPC_CHAT_SINGLE_TURN_SESSION_PRD_2026-04-18.md @@ -14,7 +14,7 @@ ## 1. 一句话定义 -玩家点击 `npc_chat` 后,进入 NPC 聊天模式;每次只完成一轮“玩家输入 -> NPC 回复 -> 关系变化消息 -> 下一轮 3 个建议选项 + 1 个自定义输入”,直到玩家主动退出聊天。 +玩家点击 `npc_chat` 后,进入 NPC 聊天模式;每次只完成一轮“玩家输入 -> NPC 回复 -> 角色形象播出好感度变化特效 -> 下一轮 3 个建议选项 + 1 个自定义输入”,直到玩家主动退出聊天。 --- @@ -42,7 +42,7 @@ 4. 每轮结束后稳定出现 `3` 个建议续聊选项。 5. 每轮结束后稳定出现 `1` 个自定义输入框。 6. 玩家选择建议项或提交自定义输入后,继续在同一消息队列中续写。 -7. 好感度增减必须作为“系统消息”插入到对话消息队列中。 +7. 好感度增减不能再作为聊天系统消息插入队列,必须改为角色形象上的数值特效反馈。 8. NPC 回复必须支持流式传输,并在前端边接收边解析显示。 9. 背包按钮所在行的最右侧必须新增“退出聊天”按钮。 10. 退出聊天后恢复普通冒险态,不保留当前聊天输入框与聊天建议项。 @@ -82,7 +82,7 @@ 1. 玩家通过“建议选项”或“自定义输入”提交一句话。 2. 玩家消息立即进入消息队列。 3. NPC 回复开始流式显示。 -4. 流式结束后,如果有关系变化,插入一条系统消息。 +4. 流式结束后,如果有关系变化,在角色形象上播放一次对应的数值特效。 5. 系统刷新下一轮 `3` 个建议选项。 6. 系统保留自定义输入入口,等待下一轮。 @@ -117,9 +117,17 @@ 1. 聊天态下,消息区按时间顺序展示: - 玩家消息 - NPC 消息 - - 系统关系变化消息 -2. 系统关系变化消息必须和普通消息共用同一消息流容器。 -3. 系统关系变化消息视觉上应与玩家/NPC 气泡有明确区分。 +2. 好感度变化不再插入聊天消息流。 +3. 消息区不额外追加“关系升温 / 关系转冷”类文字提示。 + +### 角色形象特效 + +1. 若本轮好感度提升,必须在当前聊天对象的角色形象上飞出心形正向特效。 +2. 正向特效内必须显示本轮增加数值,例如:`+3`。 +3. 若本轮好感度下降,必须在当前聊天对象的角色形象上飞出负向特效。 +4. 负向特效内必须显示本轮减少数值,例如:`-2`。 +5. 特效应挂载在当前角色形象区域,而不是消息区、选项区或额外弹窗。 +6. 同一轮只播一次最近结算结果,不重复插入历史文本。 ### 底部按钮区 @@ -162,7 +170,7 @@ 2. 渲染当前消息队列。 3. 发送玩家本轮输入。 4. 接收流式事件并实时更新 NPC 当前回复文本。 -5. 渲染系统关系变化消息。 +5. 渲染角色形象上的好感度变化特效。 6. 渲染下一轮 `3` 个建议项与自定义输入框。 前端不负责: @@ -209,7 +217,7 @@ type StoryNpcChatState = { ## 8.2 聊天消息结构 -消息队列需要支持系统消息: +消息队列继续支持系统消息,用于战斗结算、流程收束等非好感提示: ```ts type StoryDialogueTurn = { @@ -223,7 +231,24 @@ type StoryDialogueTurn = { 要求: 1. `system` 只用于关系变化、系统反馈类消息。 -2. `affinityDelta` 仅在关系变化消息中写入。 +2. `affinityDelta` 不再用于向聊天消息流插入好感提示。 + +新增聊天态特效事件: + +```ts +type StoryNpcAffinityEffect = { + eventId: string; + npcId: string; + delta: number; +}; +``` + +要求: + +1. 仅在本轮聊天真实发生好感变化时写入。 +2. `delta > 0` 表示正向心形特效。 +3. `delta < 0` 表示负向减少特效。 +4. 该事件只负责驱动角色形象表现,不负责生成消息文本。 ## 8.3 单轮接口契约 diff --git a/docs/prd/AI_NATIVE_SCENE_MULTI_ACT_CREATOR_AND_GAMEPLAY_FLOW_PRD_2026-04-20.md b/docs/prd/AI_NATIVE_SCENE_MULTI_ACT_CREATOR_AND_GAMEPLAY_FLOW_PRD_2026-04-20.md index 747d8ffa..f3e4a935 100644 --- a/docs/prd/AI_NATIVE_SCENE_MULTI_ACT_CREATOR_AND_GAMEPLAY_FLOW_PRD_2026-04-20.md +++ b/docs/prd/AI_NATIVE_SCENE_MULTI_ACT_CREATOR_AND_GAMEPLAY_FLOW_PRD_2026-04-20.md @@ -24,6 +24,10 @@ **每个场景由创作者在工具中配置为 `2~5` 幕;每一幕都绑定独立背景图和相遇 NPC 顺序;每一幕的第一个 NPC 视为主角色;运行时按幕切换背景和可遇对象,并根据主角色当前好感度裁决聊天轮数与第 5 轮收束方式。** +本次还追加一条必须和草稿生成阶段一起落地的约束: + +**Agent 在生成第一版世界草稿时,默认只生成 `1` 个可扮演角色、`2` 个场景章节、每个场景章节固定 `3` 幕、`5~10` 个场景角色;并且要在草稿生成过程中基于底层剧情引擎判定每一幕该由哪些角色出演、背景应该是什么样,再自动生成每幕背景图和每个角色的主形象。动作资产本期不生成。** + 补充口径修正: 1. `scene_chapter` 在本期继续保留为数据层 / 编译层 / 运行时层概念。 @@ -32,8 +36,10 @@ 4. 每一幕的 NPC 配置区必须直接叠在当前幕背景预览上,以“对面角色站位”的方式呈现;三个站位既是预览,也是编辑入口。 5. 幕编辑站位中每个角色只显示角色形象与名称,不展示额外信息块、规则说明或说明性标签。 6. 幕内小预览的构图固定为左侧玩家、右侧当前幕角色;右侧三个站位采用一前两后。 +前排主角色的 y 轴必须与玩家角色对齐;后排两个角色必须同一列、x 轴对齐,上下分布,且后排整体的 y 轴中点与前排主角色保持一致。 7. 新建幕默认仅预置 1 个主角色槽位内容,其余槽位留空,等待创作者补充。 8. 角色名称显示在角色形象上方,角色渲染不附带方形 UI 底板。 +9. 世界档案的场景详情页不再单独展示“场景图片”和“场景内 NPC”字段;相关兼容数据统一由多幕配置自动同步回场景对象。 这份文档必须能直接指导后续创作工具和游戏流程改造,避免需求落地漂移。 @@ -59,6 +65,21 @@ 8. 好感度小于 `0` 的主角色,在相遇后最多只允许聊天 `5` 轮,第 `5` 轮必须输出一段为后续剧情开展铺垫的收束回应。 9. 前端继续只负责展示,幕切换、聊天限制、幕进度与数据裁决全部由 Express 后端负责。 10. 默认复用现有创作页面、草稿抽屉、详情弹层、场景章节和聊天流程,不新开独立系统或新页面。 +11. 第一版世界草稿默认规模必须收束为: + - `1` 个可扮演角色 + - `2` 个场景章节 + - 每个场景章节固定 `3` 幕 + - `5~10` 个场景角色 +12. 草稿生成阶段必须由后端基于底层剧情引擎直接判定每一幕的: + - 出演角色顺序 + - 主角色 + - 幕目标 + - 幕背景语义 +13. 草稿生成完成时,系统必须自动产出: + - 每一幕对应的背景图 + - 每个场景角色的主形象 + - 可扮演角色的主形象 +14. 本期不生成动作、不生成动作预览、不生成动作发布资产。 --- @@ -74,6 +95,7 @@ 6. 不把“规则说明文案”默认堆到创作页或游戏 UI 面板里。 7. 不把“点击配置”实现成在当前卡片下面继续展开大段内容。 8. 不重写现有高好感委托链路,只在本次规则下明确它什么时候还能触发。 +9. 不在草稿生成阶段默认补动作、待机、攻击、跑动或技能动作素材。 --- @@ -177,6 +199,124 @@ 3. 主角色承担该幕默认的首次相遇、聊天轮数裁决和幕推进优先级。 4. 其余 NPC 视为辅助相遇角色,不直接承担本次“好感度聊天轮数规则”。 +## 5.4 开局场景与普通场景的统一规则 + +本次新增一条必须落实到代码与数据结构的约束: + +**开局场景不是一套特殊场景系统,只是“玩家开局所处的那一个场景”。** + +因此,开局场景在创作工具、数据结构、保存链路、运行时编译上,都必须与普通场景保持同一套配置参数。 + +明确要求如下: + +1. 开局场景允许配置的字段必须与普通场景一致,至少包括: + - `name` + - `description` + - `dangerLevel` + - `imageSrc` + - `sceneNpcIds` + - `connections` + - `sceneChapterBlueprints` 对应的多幕配置 +2. 场景配置面板中,开局场景必须复用普通场景同级的配置 UI,而不是继续保留一套缩水版表单。 +3. 开局场景与普通场景的唯一产品差异,只能是: + - 它是玩家进入世界时默认所在的初始场景 + - 它在列表或运行时可带“开局场景 / 初始场景”语义标记 +4. 除“初始所在场景”语义之外,不允许再因为它是开局场景而裁掉 NPC、连接、多幕、危险度等配置能力。 +5. 为兼容现有数据,当前 `camp` 字段可以继续保留,但其承载的结构必须与普通场景对齐,不能再是阉割版场景结构。 +6. 运行时编译时,开局场景也必须按普通场景规则参与: + - 场景 NPC 池编译 + - 场景连接编译 + - 多幕蓝图读取 + - 场景图片 / 残痕 / 预览数据生成 + +一句话约束: + +**“开局场景”是场景身份,不是场景能力分支。** + +## 5.5 草稿默认规模与自动资产策略 + +为了让“生成游戏设定草稿”真正变成一个可直接进入精修的起点,而不是一份需要继续手动补骨架的半成品,本次新增下面这些硬约束: + +### 5.5.1 第一版草稿固定规模 + +第一版 Agent 世界草稿必须默认产出: + +1. `1` 个可扮演角色 +2. `5~10` 个场景角色 +3. `2` 个场景章节 +4. 每个场景章节固定 `3` 幕 + +这里不再沿用旧的“多 playable / 多 landmarks 先铺开”的策略。 + +原因: + +1. 当前创作工作区已经进入“先收关键锚点、再逐步扩写”的阶段。 +2. 一次铺太多 playable、场景和长尾对象,会稀释创作者对第一版底稿的掌控感。 +3. 本期还要把幕级背景图和角色主形象自动挂回草稿,如果对象规模不收束,等待时间和生成成本都会直接失控。 + +### 5.5.2 幕级出演角色与背景必须由剧情引擎判定 + +这次不允许继续使用“先生场景,再把同一组 sceneNpcIds 平铺复制到所有幕里”的宽松策略。 + +后端在生成 `scene chapter -> act` 时,必须基于底层剧情引擎已有结构综合裁定: + +1. `storyGraph.visibleThreads / hiddenThreads` +2. 角色 `narrativeProfile / threadIds` +3. 地点 `linkedLandmarkIds / linkedThreadIds` +4. 当前场景章节的 `summary / actGoal / transitionHook` + +最少要做出下面这几个结论: + +1. 这一幕优先让哪些角色出场 +2. 谁是该幕主角色 +3. 这一幕的压力核心是什么 +4. 这一幕的背景图应该突出什么空间氛围、危险感和叙事残痕 + +一句话要求: + +**每一幕的演员和背景,不是静态复制,而是“线程压力 + 角色挂钩 + 地点语义”联合裁定的结果。** + +### 5.5.3 自动生成的资产范围 + +第一版草稿生成成功后,后端必须自动继续生成并写回: + +1. 每一幕的背景图 +2. 每个场景角色的主形象 +3. 可扮演角色的主形象 + +本期明确不做: + +1. 不自动生成动作 +2. 不自动生成精灵表 +3. 不自动生成技能动作 +4. 不自动生成 run / attack / hurt / die + +也就是说,本期资产策略是: + +**只产主形象和幕背景,不产动作。** + +### 5.5.4 自动资产生成的回写要求 + +自动资产生成后,草稿层必须直接带回: + +1. 角色: + - `imageSrc` + - `generatedVisualAssetId` +2. 场景幕: + - `backgroundImageSrc` + - `backgroundAssetId` +3. 资产覆盖摘要: + - 角色主形象是否就绪 + - 场景幕背景是否就绪 + +这样创作者一进入草稿精修工作区,就能直接看到: + +1. 角色已经带主形象 +2. 每个场景章节的每一幕已经带背景图 +3. 当前草稿哪些资产还缺失 + +而不是先看到一堆空白占位,再手工逐个点生成。 + --- ## 6. 数据结构要求 @@ -353,10 +493,14 @@ type NpcChatTurnResult = { 场景编辑弹层至少展示: 1. 场景名称与描述 -2. 场景主图 -3. 场景内 NPC -4. 多幕配置区块 -5. 场景连接关系 +2. 多幕配置区块 +3. 场景连接关系 + +补充约束: + +1. “场景图片”不再作为场景详情页里的独立字段展示,创作者只能通过每一幕的“配置背景”入口管理视觉。 +2. “场景内 NPC”不再作为场景详情页里的独立字段展示,创作者只能通过每一幕角色槽位配置相遇 NPC。 +3. 为兼容现有运行时与旧数据结构,场景对象上的 `imageSrc / sceneNpcIds` 仍然保留,但必须由多幕配置自动回填,前台不再暴露单独编辑控件。 多幕区块至少展示: @@ -573,6 +717,10 @@ interface SceneActRuntimeState { - 自定义输入框隐藏 - 当前聊天态结束 - 恢复普通冒险态或进入后续 action 选择 +7. 如果玩家在当前幕主角色的本地战斗中取胜,NPC 会重新开启一段战后聊天: + - 这段聊天必须把刚刚那场交锋的结果摘要与关键战斗日志带入上下文 + - 如果该主角色此时好感仍然 `< 0`,则战后聊天依然只允许 `5` 轮,并从战后这次重新开启时重新计数 + - 第 `5` 轮结束后 NPC 离开当前对话态,玩家可以继续承接后续 action,并在满足推进条件时前往下一幕 ## 9.5 第 5 轮的“铺垫”定义 diff --git a/docs/prd/TXT_MODE_CORE_GAMEPLAY_PRD_2026-04-20.md b/docs/prd/TXT_MODE_CORE_GAMEPLAY_PRD_2026-04-20.md new file mode 100644 index 00000000..fca50ce9 --- /dev/null +++ b/docs/prd/TXT_MODE_CORE_GAMEPLAY_PRD_2026-04-20.md @@ -0,0 +1,412 @@ +# TXT 模式核心玩法 PRD(2026-04-20) + +更新时间:`2026-04-20` + +## 0. 文档目的 + +这份 PRD 只定义 `Interactive-fiction-frontend` + `Interactive-fiction-backend` 中 TXT 模式在 `Genarrative` 落地时的**核心玩法闭环**。 + +这次明确**不讨论平台层功能**,包括但不限于: + +1. 平台首页 +2. 平台详情页 +3. 平台广场 +4. 平台作品库 +5. 平台浏览历史 +6. 平台钱包与扣费落地 +7. 平台统一存档页 +8. 平台账号与公开态策略 + +本稿只关心一件事: + +**把外部仓库 TXT 模式最小可玩的创作与运行闭环原样迁入,形成一个可以独立验证的视觉小说核心玩法。** + +--- + +## 1. 一句话定义 + +TXT 模式核心玩法是一个包含“创作编辑器 -> 测试体验 -> 正式运行 -> 多槽位存档 -> 历史重生成”的视觉小说玩法闭环。 + +--- + +## 2. 本次目标 + +本次只做下面这些核心目标: + +1. 支持创建 TXT 模式作品。 +2. 支持 TXT 模式作品的完整创作流程。 +3. 支持创作者测试体验。 +4. 支持玩家正式游玩。 +5. 支持文本模式运行。 +6. 支持双会话机制。 +7. 支持 5 槽位存档。 +8. 支持通过 `saveId` 读档创建会话。 +9. 支持历史记录查看。 +10. 支持历史重生成。 +11. 保留外部 TXT 模式提示词正文与功能需求,不改词、不改规则。 +12. 仅替换文本生成接口与生图接口为本项目现有能力。 + +--- + +## 3. 明确不做 + +本次 PRD 明确不做下面这些事: + +1. 不做平台首页融合。 +2. 不做平台详情页融合。 +3. 不做平台广场、作品库、公开浏览。 +4. 不做平台浏览历史。 +5. 不做平台统一钱包与扣费实现。 +6. 不做平台统一存档页。 +7. 不做回放。 +8. 不做平台层账户策略改造。 +9. 不做其它玩法的统一抽象。 +10. 不把 TXT 模式扩展成平台总线工程。 + +一句话约束: + +**先把 TXT 模式本体做成,再谈平台层融合。** + +--- + +## 4. 核心范围 + +## 4.1 创作链路 + +本次必须完整保留外部 TXT 模式创作主链: + +1. 选择 TXT 模式。 +2. 进入创作编辑器。 +3. 通过以下方式之一创建底稿: + - 文档上传 + - 一句话生成 + - 空白创建 +4. 编辑以下内容: + - 世界观 + - 角色 + - 场景 +5. 发起测试体验。 +6. 完成作品保存。 + +## 4.2 运行链路 + +本次必须完整保留外部 TXT 模式运行主链: + +1. 新的开始 +2. 继续体验 +3. 读取存档 +4. 进入运行时页面 +5. 普通模式 / 文本模式 +6. 历史记录 +7. 存档管理 +8. 设置 +9. 属性面板白名单 +10. 历史重生成 + +--- + +## 5. 核心玩法冻结边界 + +后续实现时,以下内容必须按外部 TXT 模式原样保留: + +1. 编辑器步骤顺序。 +2. 双会话机制。 +3. 流式动作协议事件: + - `start` + - `raw_text` + - `step` + - `complete` + - `data` + - `error` + - `done` +4. 5 槽位存档。 +5. 通过 `saveId` 读档创建会话。 +6. 存档载荷中的: + - `stateLite` + - `historyTail` +7. 历史重生成语义。 +8. 属性面板白名单。 +9. 默认主 prompt 选择语义。 +10. prompt 正文。 + +--- + +## 6. 创作编辑器需求 + +## 6.1 创建方式 + +编辑器必须支持 3 种创建方式: + +1. 上传文档 +2. 一句话生成 +3. 空白创建 + +三者都必须进入同一套 TXT 模式编辑器,而不是三套分裂流程。 + +## 6.2 编辑器模块 + +编辑器至少必须包含以下模块: + +1. 世界观编辑 +2. 角色编辑 +3. 场景编辑 +4. 测试体验入口 +5. 保存/发布前准备入口 + +## 6.3 前后端边界 + +编辑器侧遵守下面这条边界: + +1. 前端只负责编辑器表现与输入采集。 +2. 作品结构校验、编译、测试会话创建、正式数据写入由后端负责。 + +--- + +## 7. 双会话机制 + +TXT 模式核心玩法必须完整保留双会话机制。 + +## 7.1 玩家游玩会话 + +玩家游玩会话用于: + +1. 正式开始作品 +2. 正式继续体验 +3. 正式游玩推进 + +## 7.2 创作者测试/读档会话 + +创作者测试/读档会话用于: + +1. 编辑器内测试体验 +2. 指定存档加载 +3. 非正式发布态验证 + +## 7.3 禁止简化 + +禁止把这两类会话合并成一类“统一 session”。 + +--- + +## 8. 运行时页面需求 + +## 8.1 页面能力面 + +运行时页面至少要有: + +1. 普通模式 +2. 文本模式 +3. 历史记录面板 +4. 存档面板 +5. 设置面板 +6. 属性面板 + +## 8.2 文本模式 + +文本模式必须按外部 TXT 模式语义保留: + +1. 独立于普通模式的显示区域 +2. 与运行时主状态同步 +3. 可消费流式动作结果 + +## 8.3 属性面板 + +属性面板必须保留外部 TXT 模式的白名单语义: + +1. 白名单用户显示 +2. 非白名单用户不显示 + +--- + +## 9. 流式动作协议 + +TXT 模式核心玩法必须保留外部流式动作协议。 + +## 9.1 事件类型 + +服务端必须推送下列事件: + +1. `start` +2. `raw_text` +3. `step` +4. `complete` +5. `data` +6. `error` +7. `done` + +## 9.2 前端职责 + +前端只负责: + +1. 发起动作请求 +2. 解析流式事件 +3. 渲染逐步结果 + +前端不负责: + +1. 本地推进正式运行状态 +2. 本地拼接替代结果 + +--- + +## 10. 存档机制 + +## 10.1 槽位规则 + +每个作品最多保留 5 个槽位。 + +必须支持: + +1. 新建存档 +2. 覆盖存档 +3. 读取指定槽位 + +## 10.2 存档载荷 + +存档内容不能只有摘要,至少必须包含: + +1. `stateLite` +2. `historyTail` + +## 10.3 读档语义 + +读取存档不是恢复前端本地状态,而是: + +1. 传入 `saveId` +2. 由后端创建新的测试/读档会话 +3. 前端消费会话结果 + +--- + +## 11. 历史机制 + +## 11.1 历史记录 + +运行时必须支持查看已有历史记录。 + +## 11.2 历史重生成 + +运行时必须支持历史重生成。 + +其语义必须是: + +1. 用户选择某个历史节点 +2. 服务端基于该节点上下文重新生成后续 +3. 新结果成为新的有效运行轨迹 + +禁止把它简化成普通“再来一次下一步”。 + +--- + +## 12. 提示词与模型调用 + +## 12.1 Prompt 规则 + +后续实现时必须遵守: + +1. 外部 TXT 模式 prompt 正文不改。 +2. 外部 TXT 模式 prompt 规则不改。 +3. 默认 prompt 选择语义不改。 + +## 12.2 允许替换的部分 + +只允许替换下面两项底层能力: + +1. 文本生成接口 + - 替换为 `server-node/src/services/llmClient.ts` +2. 生图接口 + - 替换为 `server-node/src/services/sceneImageService.ts` + +除此之外,不允许借“接入本项目能力”之名修改玩法需求。 + +--- + +## 13. 核心后端职责 + +TXT 模式核心玩法的正式运行真相必须在后端。 + +后端至少负责: + +1. 会话创建 +2. prompt 装载与上下文拼接 +3. 流式动作生成 +4. 存档读写 +5. `saveId` 读档会话创建 +6. 历史重生成 +7. 属性白名单裁决 + +--- + +## 14. 核心前端职责 + +前端只负责: + +1. 编辑器页面表现 +2. 运行时页面表现 +3. 文本模式显示 +4. 流式事件解析 +5. 历史/存档/设置面板打开与关闭 + +前端不负责: + +1. 正式运行时结算 +2. 会话语义判定 +3. 存档内容拼接 +4. 历史重生成裁决 + +--- + +## 15. 建议影响文件 + +本次核心玩法落地,优先会影响下面这些区域: + +前端: + +1. `src/components/game-shell/PlatformCreationTypeModal.tsx` +2. `src/services/aiService.ts` +3. 新增 TXT 模式编辑器页面或模块 +4. 新增 TXT 模式运行时页面或模块 +5. 新增 TXT 模式 SSE 解析模块 + +后端: + +1. `server-node/src/routes/runtimeRoutes.ts` +2. `server-node/src/services/llmClient.ts` +3. `server-node/src/services/sceneImageService.ts` +4. `server-node/src/repositories/runtimeRepository.ts` +5. `server-node/src/prompts/` +6. 新增 TXT 模式 services / contracts / repository modules + +--- + +## 16. 验收标准 + +满足下面这些结果时,视为核心玩法闭环成立: + +1. 可以创建 TXT 模式作品。 +2. 可以通过上传文档、一句话生成、空白创建进入同一编辑器。 +3. 编辑器可编辑世界观、角色、场景。 +4. 编辑器内可发起测试体验。 +5. 可创建正式游玩会话。 +6. 可创建测试/读档会话。 +7. 运行时支持普通模式与文本模式。 +8. 流式动作协议按外部事件名工作。 +9. 可查看历史记录。 +10. 可执行历史重生成。 +11. 支持 5 槽位存档。 +12. 支持通过 `saveId` 读档创建会话。 +13. 存档包含 `stateLite + historyTail`。 +14. 属性面板白名单逻辑生效。 +15. prompt 正文与功能需求未被改写。 + +--- + +## 17. 本稿结论 + +这次先不要把 TXT 模式做成平台工程。 + +先把下面这条链做通: + +**创建 TXT 作品 -> 编辑世界/角色/场景 -> 测试体验 -> 正式游玩 -> 文本模式 -> 多槽位存档 -> 历史重生成** + +只要这条链通了,TXT 模式核心玩法就成立;平台层融合、详情页整合、钱包接入、统一存档页,都可以放到下一阶段再做。 diff --git a/docs/reference/BUSINESS_PROMPT_INVENTORY_2026-04-19.md b/docs/reference/BUSINESS_PROMPT_INVENTORY_2026-04-19.md index 0dbd947d..eac71f06 100644 --- a/docs/reference/BUSINESS_PROMPT_INVENTORY_2026-04-19.md +++ b/docs/reference/BUSINESS_PROMPT_INVENTORY_2026-04-19.md @@ -59,7 +59,7 @@ | `server-node/src/prompts/customWorldEntityPrompts.ts` | 世界编辑器实体生成 | `CUSTOM_WORLD_ENTITY_GENERATOR_SYSTEM_PROMPT`、`buildPlayablePrompt`、`buildStoryPrompt`、`buildLandmarkPrompt` | | `server-node/src/prompts/customWorldSceneNpcPrompts.ts` | 世界编辑器场景 NPC | `CUSTOM_WORLD_SCENE_NPC_SYSTEM_PROMPT`、`buildCustomWorldSceneNpcPrompt` | | `server-node/src/prompts/eightAnchorPrompts.ts` | 八锚点共创 | `BASE_SYSTEM_PROMPT`、`GLOBAL_HARD_RULES`、`MODE_RULES`、`USER_SIGNAL_RULES`、`buildPromptDynamicStateInferencePrompt`、`buildEightAnchorSingleTurnPrompt` | -| `server-node/src/prompts/characterAssetPrompts.ts` | 角色形象 / 动作资产生成 | `CHARACTER_PROMPT_BUNDLE_SYSTEM_PROMPT`、`buildFallbackCharacterPromptBundle`、`buildCharacterPromptBundleUserPrompt`、`buildNpcVisualPrompt`、`buildNpcAnimationPrompt`、`buildArkCharacterAnimationPrompt` | +| `server-node/src/prompts/characterAssetPrompts.ts` | 角色形象 / 动作资产生成 | `buildNpcVisualPrompt`、`buildNpcAnimationPrompt`、`buildArkCharacterAnimationPrompt`、`buildImageSequencePrompt`、`buildNpcVisualNegativePrompt` | ### 3.2 前端 @@ -72,7 +72,7 @@ | `src/prompts/customWorldPrompts.ts` | 自定义世界分阶段生成 + 场景背景图 | 多个 `buildCustomWorld*Prompt`、`DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT` | | `src/prompts/customWorldOrchestratorPrompts.ts` | 世界 JSON 修复 / JSON only | `CUSTOM_WORLD_JSON_REPAIR_SYSTEM_PROMPT`、`CUSTOM_WORLD_GENERATION_JSON_ONLY_SYSTEM_PROMPT` | | `src/prompts/storyOrchestratorPrompts.ts` | 剧情中文修复 | `STORY_LANGUAGE_REPAIR_SYSTEM_PROMPT` | -| `src/prompts/customWorldRolePromptDefaults.ts` | 角色资产工作台默认词 | `buildDefaultRolePromptBundle` | +| `src/prompts/customWorldRolePromptDefaults.ts` | 角色资产工作台默认词唯一主源 | `buildDefaultRolePromptBundle` | | `src/prompts/customWorldEntityActionPrompts.ts` | 编辑器技能动作词 | `buildSkillActionPrompt` | | `src/prompts/qwenSpriteSheetToolPrompts.ts` | Qwen 精灵图工具 prompt 模型 | 主 prompt / sheet prompt / repair prompt / negative prompt 系列 | diff --git a/docs/technical/CUSTOM_WORLD_AUTO_ASSET_VISIBILITY_FIX_2026-04-20.md b/docs/technical/CUSTOM_WORLD_AUTO_ASSET_VISIBILITY_FIX_2026-04-20.md new file mode 100644 index 00000000..5e250fd6 --- /dev/null +++ b/docs/technical/CUSTOM_WORLD_AUTO_ASSET_VISIBILITY_FIX_2026-04-20.md @@ -0,0 +1,174 @@ +# 世界草稿自动资产可见性修复说明 2026-04-20 + +更新时间:`2026-04-20` + +## 1. 问题现象 + +在世界草稿生成完成后,用户反馈: + +1. 草稿里看不到角色主形象 +2. 场景里看不到每一幕的背景图 + +这类反馈容易被误判成“自动资产没有生成”,但实际排查后发现,问题主要集中在**结果页展示链路**,同时叠加了一个**fallback 资源不可预览**的问题。 + +## 2. 链路排查结论 + +本轮检查后确认: + +1. 服务端自动资产服务会把角色主形象写回 `draftProfile.playableNpcs[].imageSrc / generatedVisualAssetId` +2. 服务端自动资产服务会把幕背景图写回 `draftProfile.sceneChapters[].acts[].backgroundImageSrc / backgroundAssetId` +3. `agent draft -> result profile` 的适配层也会保留这些字段 + +真正的问题出在后续两个环节。 + +## 3. 根因 + +### 3.1 结果页可扮演角色卡优先用了运行时预览 + +结果页 `CustomWorldEntityCatalog` 的可扮演角色卡,之前优先显示: + +1. `previewCharacter` +2. 再回退到 `role.imageSrc` + +这会导致: + +1. 草稿里已经有真实生成主图 +2. 但界面仍优先渲染模板/运行时预览角色 +3. 用户视觉上看不到最新生成主形象 + +### 3.2 场景页没有把多幕背景图真正展示出来 + +结果页 `场景` Tab 之前只展示: + +1. 开局场景 +2. 地点卡 + +但没有把: + +`sceneChapterBlueprints[].acts[].backgroundImageSrc` + +按可见结构渲染到结果页中。 + +因此即使后端已经生成并回写每一幕背景图,用户仍然只能看到“场景主图/地点图”,看不到“每一幕的图”。 + +### 3.3 fallback 自动资产写回的是 `.txt` + +在没有 DashScope 图像能力时,`CustomWorldAgentAutoAssetService` 的 fallback 生成器之前会写: + +1. 角色主形象:`master.txt` +2. 幕背景图:`scene.txt` + +这虽然保证了字段被回写,但前端无法把 `.txt` 当图片展示,于是会进一步加重“好像没生成”的感知。 + +### 3.4 Agent 结果页入口优先读取 legacyResultProfile,遮蔽了最新资产字段 + +世界草稿结果页不是直接读取当前 `draftProfile`,而是先经过: + +1. `buildCustomWorldProfileFromAgentDraft` +2. `normalizeCustomWorldProfileRecord` + +如果 `draftProfile.legacyResultProfile` 存在,旧逻辑会直接优先返回这份历史编译结果。 + +但自动资产服务在 Phase3/Phase4 后续补齐时,更新的是当前 `draftProfile` 中的: + +1. `playableNpcs[].imageSrc / generatedVisualAssetId` +2. `storyNpcs[].imageSrc / generatedVisualAssetId` +3. `landmarks[].imageSrc` +4. `sceneChapters[].acts[].backgroundImageSrc / backgroundAssetId` + +这会导致: + +1. 服务端真实已经生成并回写了最新角色主图和分幕图 +2. 结果页入口却仍然取到一份更早的 `legacyResultProfile` +3. 页面看到的是“旧草稿快照”,不是“当前带资产的草稿结果” + +因此用户会表现为“完全看不到这轮刚生成出来的图片”。 + +## 4. 修复策略 + +### 4.1 结果页角色卡优先显示真实生成主图 + +在 `src/components/CustomWorldEntityCatalog.tsx` 中调整逻辑: + +1. 若 `role.imageSrc` 已存在,则优先显示该图片 +2. 只有在缺失真实主图时,才回退到运行时角色预览 + +这样可扮演角色卡能直接展示当前草稿回写的角色主形象。 + +### 4.2 场景列表改为只展示场景卡,章节内容留在二级页 + +结合后续体验反馈,本轮又进一步收口了结果页结构: + +1. `结果页 -> 场景列表` 不再直接展开章节与分幕内容 +2. 场景列表卡片只负责展示: + - 场景名 + - 场景摘要 + - 场景图 +3. 场景卡图片优先取该场景章节的首幕 `backgroundImageSrc` +4. 若首幕图缺失,再回退到场景主图 / 地标图 +5. 章节标题、幕标题、幕目标等信息只在点击场景后的二级编辑页中查看 + +这样结果页列表保持清爽,但用户仍然能在列表里直接看到当前场景已生成的图片。 + +### 4.3 fallback 改为可显示 PNG + +在 `server-node/src/services/customWorldAgentAutoAssetService.ts` 中调整 fallback: + +1. 不再写 `master.txt / scene.txt` +2. 改为写合法可显示的占位 `png` +3. prompt 信息单独写进 `manifest.json` +4. 角色主形象 fallback PNG 统一输出为 `1:1` + +这样即使当前环境没有真实图像生成能力,草稿层也仍然会回写“前端能直接显示的图片资源”。 + +### 4.4 结果页读取 legacy profile 时强制合并当前草稿的最新资产字段 + +在 `src/services/customWorldAgentDraftResult.ts` 中补上合并逻辑: + +1. 若存在 `legacyResultProfile`,继续保留它的完整运行时字段 +2. 但会把当前 `draftProfile` 里最新回写的角色主图、地标图、分幕图再覆盖回结果页 profile +3. 这样结果页既不会丢失旧 runtime profile 的完整结构,也不会再被旧快照遮蔽最新图片资产 + +这一层修的是结果页真实入口,而不是仅修展示组件。 + +## 5. 影响范围 + +本次修复涉及: + +1. `src/components/CustomWorldEntityCatalog.tsx` +2. `src/components/CustomWorldResultView.test.tsx` +3. `src/services/customWorldAgentDraftResult.test.ts` +4. `server-node/src/services/customWorldAgentAutoAssetService.ts` +5. `server-node/src/services/customWorldAgentAutoAssetService.test.ts` +6. `docs/technical/CUSTOM_WORLD_AUTO_ASSET_VISIBILITY_FIX_2026-04-20.md` +7. 历史 saved profile 资产同步脚本 / 数据修复动作 + +## 6. 验收标准 + +修复后需要满足: + +1. 世界草稿结果页的可扮演角色卡能直接看到生成主形象 +2. 世界草稿结果页的场景列表能直接看到场景图片,且优先展示首幕背景图 +3. 场景章节与分幕内容只在场景二级页中展示 +3. `agent draft -> result profile` 不会丢失角色主图与幕背景字段 +4. fallback 环境下回写的仍是前端可显示图片,而不是文本文件 +5. 角色主形象 fallback PNG 尺寸必须满足 `1:1` +6. 即使存在 `legacyResultProfile`,结果页也必须展示当前草稿最新同步的角色主图与幕背景图 + +## 6.1 历史保存档案补充结论 + +本轮在真实 PostgreSQL 数据中又确认了一类历史问题: + +1. `agent session` 中的草稿资产字段可能已经补齐 +2. 但较早时刻自动保存过的 `custom_world_profiles.payload_json` 仍停留在旧路径 +3. 用户如果从作品库打开的是 saved profile,就会继续看到旧图或空图 + +因此这次修复除了改默认生成与展示逻辑,还需要对受影响的历史 saved profile 做一次同步刷新。 + +## 7. 后续建议 + +后续继续迭代这条链路时,建议保持: + +1. “资产已生成”必须和“用户已看见”同时验证,不能只验证字段回写 +2. 结果页与草稿工作区都要把多幕背景视为正式资产,不要只停留在编辑弹层里 +3. 所有 fallback 资源都应保持为 UI 可直接消费的媒体格式 diff --git a/docs/technical/CUSTOM_WORLD_DRAFT_GENERATION_FAILURE_ANALYSIS_AND_FIX_2026-04-20.md b/docs/technical/CUSTOM_WORLD_DRAFT_GENERATION_FAILURE_ANALYSIS_AND_FIX_2026-04-20.md new file mode 100644 index 00000000..b9a1fa65 --- /dev/null +++ b/docs/technical/CUSTOM_WORLD_DRAFT_GENERATION_FAILURE_ANALYSIS_AND_FIX_2026-04-20.md @@ -0,0 +1,149 @@ +# 世界草稿生成失败与等待页卡住问题分析 2026-04-20 + +更新时间:`2026-04-20` + +## 1. 问题背景 + +本次问题表现为: + +1. 世界草稿生成过程中实际已经失败。 +2. 前端等待页仍然停留在“编译草稿卡”步骤。 +3. 用户感知为“卡住不动”,而不是“这一轮失败了,可以返回或重试”。 + +这个问题不是单点 bug,而是由两类问题叠加造成: + +1. 前端进度映射把 `failed + progress=100` 误解释成“已经跑到最后一个步骤附近”,导致视觉上像卡在 `编译草稿卡`。 +2. 服务端 `draft_foundation` 主链把自动资产补齐也视为硬依赖,一旦角色主形象或场景幕背景图失败,就会把整版世界底稿一起打成失败。 + +## 2. 本次链路梳理结论 + +当前世界草稿生成主链路为: + +```text +foundation_review +-> draft_foundation action +-> foundation draft service 生成世界底稿结构 +-> auto asset service 补角色主图与幕背景图 +-> draft compiler 编译 draftCards +-> session 进入 object_refining +-> 前端等待页切到结果或回工作区 +``` + +其中真正的“必须成功”主链只有两段: + +1. `foundation draft` 结构生成成功。 +2. `draftCards` 编译成功。 + +角色主图与幕背景图属于增强链路,不应该阻断世界底稿首版落地。 + +## 3. 根因拆解 + +### 3.1 前端等待页状态映射问题 + +`src/services/customWorldAgentGenerationProgress.ts` 之前只对以下情况做了显式处理: + +1. `completed` +2. 匹配某个 `phaseLabel` +3. 兜底按 `progress` 推断当前步骤 + +但没有对 `failed` 做单独分支。 + +于是当后端返回: + +```text +status = failed +progress = 100 +phaseLabel = 底稿生成失败 +``` + +前端仍会按 `progress=100` 去推断步骤,结果高概率落在末尾附近,视觉上就像卡在: + +```text +编译草稿卡 +``` + +### 3.2 服务端把增强链路当成硬依赖 + +`server-node/src/services/customWorldAgentOrchestrator.ts` 中的 `processDraftFoundationOperation` 会在世界底稿生成后调用: + +```ts +autoAssetService.populateDraftAssets(...) +``` + +而 `populateDraftAssets` 之前的行为是: + +1. 角色主图生成失败直接 throw +2. 场景幕背景图生成失败直接 throw + +于是自动资产失败会直接中断后续: + +```text +draft compiler +session replaceDerivedState +checkpoint +assistant summary +operation completed +``` + +这会把“本来已经可以用的世界底稿”一并拖死。 + +## 4. 修复策略 + +### 4.1 前端修复 + +在 `src/services/customWorldAgentGenerationProgress.ts` 中新增显式失败步骤: + +1. `failed` 状态不再走 `progress` 推断。 +2. 失败时固定返回 `phaseId = failed`。 +3. 等待页保留已有步骤清单,但不再假装仍有某一步处于 active。 + +修复后,等待页会明确展示: + +1. 当前状态已失败 +2. 失败文案与失败详情 +3. 用户可以返回工作区或重新生成 + +### 4.2 服务端修复 + +在 `server-node/src/services/customWorldAgentAutoAssetService.ts` 中把自动资产改成“尽力而为”: + +1. 角色主形象生成失败时不再 throw,而是记录 warning。 +2. 幕背景图生成失败时不再 throw,而是记录 warning。 +3. 主链继续编译 `draftCards`,并让 `assetCoverage` 明确标记哪些资产仍缺失。 + +在 `server-node/src/services/customWorldAgentOrchestrator.ts` 中: + +1. 如果草稿主链成功,只是资产补齐未完成,则 operation 仍记为 `completed`。 +2. `phaseDetail` 增加“有若干项资产补齐待后续处理”的说明。 +3. assistant summary 也同步说明“这不影响继续精修世界底稿”。 +4. 真正失败时,优先保留当前失败阶段的 `phaseLabel/phaseDetail`,避免统一抹平成模糊的“底稿生成失败”。 + +## 5. 影响范围 + +本次修改影响以下模块: + +1. `src/services/customWorldAgentGenerationProgress.ts` +2. `src/services/customWorldAgentGenerationProgress.test.ts` +3. `server-node/src/services/customWorldAgentAutoAssetService.ts` +4. `server-node/src/services/customWorldAgentAutoAssetService.test.ts` +5. `server-node/src/services/customWorldAgentOrchestrator.ts` +6. `server-node/src/services/customWorldAgentPhase3.test.ts` + +## 6. 验收标准 + +修复后需要满足: + +1. 世界草稿主链失败时,等待页明确显示失败,而不是视觉上卡在 `编译草稿卡`。 +2. 自动资产生成失败时,世界底稿和草稿卡仍然要能生成完成。 +3. session 能进入 `object_refining`,用户可以先继续精修结构内容。 +4. `assetCoverage` 能反映未补齐的角色图/场景图缺口。 +5. 助手消息和 operation 文案都能把“主链完成,增强链路待补”表达清楚。 + +## 7. 后续建议 + +后续若继续增强这条链路,建议保持以下原则: + +1. 世界结构生成、草稿卡编译属于主链,必须最稳。 +2. 角色图、动作、场景图、长尾补齐都属于增强链路,应允许降级。 +3. 所有等待页都要有显式失败态映射,禁止仅靠 `progress` 推断最终阶段。 +4. 操作失败时优先保留最后一个真实阶段标签,方便定位到底是结构生成失败、资产生成失败,还是写回失败。 diff --git a/docs/technical/CUSTOM_WORLD_PHASE4_COUNT_SEMANTICS_ALIGNMENT_2026-04-20.md b/docs/technical/CUSTOM_WORLD_PHASE4_COUNT_SEMANTICS_ALIGNMENT_2026-04-20.md new file mode 100644 index 00000000..0ef52d3e --- /dev/null +++ b/docs/technical/CUSTOM_WORLD_PHASE4_COUNT_SEMANTICS_ALIGNMENT_2026-04-20.md @@ -0,0 +1,115 @@ +# 自定义世界 Phase4 数量字段语义对齐说明 2026-04-20 + +更新时间:`2026-04-20` + +## 1. 背景 + +在排查世界草稿生成等待页问题后,继续执行 `server-node` 相关测试时,发现 Phase4 仍有两处失败: + +1. `generate_characters` 后草稿作品卡的角色数量没有按预期增长。 +2. `generate_landmarks` 的 HTTP 用例对地点数量使用了过时的固定基线。 + +这两个问题本质上都和“数量字段语义不一致”有关。 + +## 2. 当前产品语义 + +创作中心草稿卡在前端展示的是: + +```text +角色 X +地点 Y +``` + +这里的“角色”从产品感知上表示: + +**当前草稿中已经长出来、可继续精修的全部角色对象数量。** + +而不是: + +**仅 playable / 仅主角位角色数量。** + +对应地,“地点”表示: + +**当前草稿中已经存在的地点对象数量。** + +## 3. 之前的偏差 + +### 3.1 角色数量偏差 + +`server-node/src/services/customWorldWorkSummaryService.ts` 在草稿态里原先只统计: + +```ts +draftProfile.playableNpcs.length +``` + +但 Phase4 `generate_characters` 的实现是把新增角色插入: + +```ts +draftProfile.storyNpcs +``` + +所以会出现: + +1. 角色卡确实新增了 +2. `storyNpcs` 也确实变多了 +3. 创作中心草稿卡上的“角色数”却没有同步增加 + +这会让产品表现和数据真实状态不一致。 + +### 3.2 地点数量断言偏差 + +`server-node/src/app.test.ts` 的 `generate_landmarks` HTTP 用例里,之前写死了: + +```ts +assert.ok((sessionPayload.draftProfile?.landmarks?.length ?? 0) >= 6); +``` + +但当前基础草稿阶段的地点基线已经调整为: + +```ts +FOUNDATION_DRAFT_LANDMARK_COUNT = 2 +``` + +所以 Phase4 的正确断言应该是: + +```text +在当前会话已有地点基线上,再新增 2 个地点 +``` + +而不是继续沿用旧版本里“基础草稿默认至少 4 个地点”的固定假设。 + +## 4. 本次修正 + +### 4.1 草稿摘要角色数 + +草稿态 `playableNpcCount` 在工作摘要里继续沿用既有字段名,但统计语义调整为: + +```text +全部草稿角色数量 = playableNpcs + storyNpcs 去重后的总数 +``` + +原因: + +1. 前端现有 contract 和展示字段已经复用 `playableNpcCount` +2. 这次目标是最小修复,不额外扩 contract +3. 草稿态 UI 标签本身展示的是“角色”,不是“可扮演角色” + +因此这次保留字段名,修正其在草稿态的统计语义。 + +### 4.2 Phase4 地点断言 + +`generate_landmarks` 的 HTTP 用例改为基于当前会话的 `draftProfile.landmarks.length` 做增量校验: + +```text +新增后数量 >= 基线数量 + 2 +``` + +这样可以避免未来基础草稿默认地点数再次调整时,Phase4 用例继续被写死基线误伤。 + +## 5. 约束建议 + +后续涉及草稿作品卡数量字段时,统一遵守: + +1. 草稿态“角色”展示全部草稿角色数,不只统计 playable。 +2. 已发布态如果 UI 明确写“可扮演角色”,再单独按 playable 统计。 +3. 所有数量断言优先使用“当前基线 + 增量”的写法,不要硬编码旧阶段默认数量。 diff --git a/docs/technical/PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md b/docs/technical/PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md index ec9ced16..1efe4240 100644 --- a/docs/technical/PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md +++ b/docs/technical/PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md @@ -77,7 +77,7 @@ src/prompts/ - `customWorldSceneNpcPrompts.ts` - 世界编辑器场景 NPC 生成 prompt - `characterAssetPrompts.ts` - - 角色主图 / 动作试片 / 角色关联场景 prompt + - 角色主图 / 动作试片正式生成 prompt - `eightAnchorPrompts.ts` - 八锚点状态推断、模式规则与正式单轮共创 prompt - `src/prompts/customWorldPrompts.ts` @@ -85,7 +85,7 @@ src/prompts/ - `src/prompts/qwenSpriteSheetToolPrompts.ts` - 精灵图工具主词 / 分镜词 / 修帧词 / 负面词 - `src/prompts/customWorldRolePromptDefaults.ts` - - 角色资产工作台默认 prompt 种子 + - 角色资产工作台默认 prompt 种子唯一主源 - `src/prompts/customWorldEntityActionPrompts.ts` - 编辑器技能动作 prompt - `packages/shared/src/prompts/qwenSprite.ts` diff --git a/docs/technical/README.md b/docs/technical/README.md index a6c5531b..30c71580 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -6,6 +6,10 @@ - [REPO_NOISE_CLEANUP_BASELINE_2026-04-19.md](./REPO_NOISE_CLEANUP_BASELINE_2026-04-19.md):落实工程清理审计第一阶段后的仓库噪音清理范围、忽略规则闭合点与后续约束。 - [PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md](./PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md):后端提示词收口到 `server-node/src/prompts/` 的目录方案、兼容策略与后续新增规则。 +- [CUSTOM_WORLD_DRAFT_GENERATION_FAILURE_ANALYSIS_AND_FIX_2026-04-20.md](./CUSTOM_WORLD_DRAFT_GENERATION_FAILURE_ANALYSIS_AND_FIX_2026-04-20.md):世界草稿生成失败后等待页误显示为“卡在编译草稿卡”的根因拆解、主链与增强链路边界,以及本次修复策略。 +- [CUSTOM_WORLD_AUTO_ASSET_VISIBILITY_FIX_2026-04-20.md](./CUSTOM_WORLD_AUTO_ASSET_VISIBILITY_FIX_2026-04-20.md):世界草稿里“资产已生成但结果页看不到”的根因拆解,包含角色主形象展示、分幕背景露出和 fallback 资源格式修复。 +- [CUSTOM_WORLD_PHASE4_COUNT_SEMANTICS_ALIGNMENT_2026-04-20.md](./CUSTOM_WORLD_PHASE4_COUNT_SEMANTICS_ALIGNMENT_2026-04-20.md):Phase4 新增角色/地点后草稿作品卡数量统计与测试断言的语义对齐说明。 +- [TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md](./TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md):把外部仓库 TXT 模式完整迁入当前项目的冻结边界、模块映射、分阶段计划与验收清单。 - [NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md](./NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md):当前 Node 运行时后端的技术栈、入口、鉴权、存储与接口知识图谱。 - [EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md](./EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md):Express 后端当前 contract 冻结版本、热点文件编辑规则与集成窗口清单。 - [EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md](./EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md):按并行工作流文档逐项核对后的完成度审计与剩余收口点。 diff --git a/docs/technical/SCENE_MULTI_ACT_CREATOR_IMPLEMENTATION_PROGRESS_2026-04-20.md b/docs/technical/SCENE_MULTI_ACT_CREATOR_IMPLEMENTATION_PROGRESS_2026-04-20.md index 6c2377c8..828071c8 100644 --- a/docs/technical/SCENE_MULTI_ACT_CREATOR_IMPLEMENTATION_PROGRESS_2026-04-20.md +++ b/docs/technical/SCENE_MULTI_ACT_CREATOR_IMPLEMENTATION_PROGRESS_2026-04-20.md @@ -69,6 +69,11 @@ 7. 角色槽位会把第一槽位写回 `primaryNpcId`,其余槽位顺序压缩写回 `encounterNpcIds` 8. 每幕已补上“幕预览”入口,点击后会以独立全屏层启动当前幕运行时预览 9. 保存场景时会把幕配置同步写回 `CustomWorldProfile.sceneChapterBlueprints` +10. 世界档案里的场景详情页已移除“场景图片”和“场景内 NPC”字段,相关兼容字段改为从多幕配置自动同步回 `imageSrc / sceneNpcIds` + +补充一条等待页体验收口: + +10. 世界草稿生成等待页的第二模块标题已从“当前锚点信息”收口为“当前世界信息”,不再显示辅助说明小字,也不再在该模块头部提供“回到工作区”按钮,避免等待态出现重复返回入口 ## 2.5 运行时基础层 @@ -82,6 +87,7 @@ 6. 幕编辑中的 3 个角色槽位已进一步收敛成贴在背景图上的站位式角色预览,交互与幕预览保持同一位置语义,只显示角色形象与名称 7. 幕预览运行时已补 custom world NPC 的视觉兜底链路,优先使用 `visual / imageSrc` 渲染,避免角色形象或动画空白 8. 当前幕小预览已调整为左侧玩家、右侧敌对/相遇角色的构图,NPC 站位采用一前两后 +前排主角色与玩家角色保持同一 y 轴;后排两个角色改为同一列、x 轴对齐并上下分布,且后排整体 y 轴中点与前排主角色一致 9. 新增幕默认只带 1 个主角色,后续槽位由创作者按需补充 10. 小预览里的名字已移动到角色头顶,角色渲染不再带方形底板,避免遮挡场景背景 @@ -95,6 +101,9 @@ 4. 第 `5` 轮会由后端 prompt 强约束生成“铺垫式收束”回复,不再继续生成下一轮聊天建议 5. 第 `5` 轮返回后,前端会自动清掉 `npcChatState`,隐藏输入框,并给出 `继续` 的后续推进入口 6. Adventure 面板会显示当前幕标题与有限聊天剩余轮数 +7. 当前幕主角色在本地战斗胜利后,会重新回到 NPC 聊天态,而不是直接掉回普通剧情续写 +8. 战后重新开启的聊天会把“战斗结果摘要 + 最近战斗日志”一起写入 `npc_chat` 上下文,保证 NPC 能承接刚刚那场交锋继续说话 +9. 若该主角色当前好感仍小于 `0`,战后重新开启的聊天仍按 `5` 轮有限聊天处理,轮数从战后这次重开重新计算 ## 3. 当前仍未完成 diff --git a/docs/technical/TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md b/docs/technical/TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md new file mode 100644 index 00000000..688bc42c --- /dev/null +++ b/docs/technical/TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md @@ -0,0 +1,1019 @@ +# TXT 模式视觉小说玩法迁移执行方案(2026-04-20) + +更新时间:`2026-04-20` + +## 0. 文档目的 + +这份执行方案用于指导 `Genarrative` 在**不改动外部 TXT 模式提示词正文、不改动外部 TXT 模式功能需求**的前提下,把下面两个仓库中已经跑通的 TXT 模式创作流程与运行机制完整迁入当前项目: + +- `E:\Repos\Interactive-fiction-frontend` +- `E:\Repos\Interactive-fiction-backend` + +本次文档只定义后续实现的**冻结边界、模块映射、阶段拆分、验收口径与禁止事项**,用于后续编码时避免需求漂移。 + +这份文档明确服务于一个唯一目标: + +**把外部仓库的 TXT 模式完整作为 Genarrative 的一种视觉小说玩法品类接入到现有创作入口中,并保持其创作链路、运行链路、存档链路、扣费链路与提示词语义一致,仅替换底层文本生成接口和生图接口为本项目现有能力。** + +同时新增一条本平台硬约束: + +**凡是 Genarrative 平台已经存在正式实现的系统,不允许因为接入 TXT 模式再平行新增第二套同类实现;必须先评估外部仓库是否有更优设计,再将优点沉淀为平台统一能力升级。** + +--- + +## 1. 范围定义 + +## 1.1 本次必须做到的事 + +后续工程实现必须完整覆盖以下范围: + +1. 把外部 TXT 模式作为 `Genarrative` 创作入口中的一种玩法品类接入。 +2. 完整复制外部 TXT 模式的创作流程: + - 文档上传导入 + - 一句话生成 + - 空白创建 + - 世界观编辑 + - 角色编辑 + - 场景编辑 + - 测试体验 + - 发布 +3. 完整复制外部 TXT 模式的运行机制: + - 玩家游玩会话 + - 创作者测试/读档会话 + - 流式动作执行 + - 文本模式显示 + - 历史记录 + - 重生成 + - 存档 + - 设置 + - 属性面板白名单 +4. 完整复制外部 TXT 模式的接口契约语义、前后端职责边界与状态流转。 +5. 保留外部 TXT 模式 prompt 的正文与语义,不允许擅自调整 system prompt、user prompt、repair prompt 的文案与规则。 +6. 把外部仓库调用的文本生成能力替换为 `Genarrative` 当前 `server-node` 中已有的 LLM 能力。 +7. 把外部仓库调用的生图能力替换为 `Genarrative` 当前 `server-node` 中已有的场景图生成能力。 +8. 所有逻辑、状态计算、持久化与运行时裁决放到 `Genarrative` 的 Express 后端,不把运行时结算下放到前端。 + +## 1.2 本次明确不做的事 + +后续实现阶段明确不做下面这些“看似方便但会造成需求漂移”的替代方案: + +1. 不把外部 TXT 模式“参考着做一个相似版”。 +2. 不把外部 TXT 模式“只保留文本模式页面,不接创作流程”。 +3. 不把双会话机制简化成单会话。 +4. 不把流式协议简化成一次性返回全文。 +5. 不把 5 槽位云存档简化成本地缓存或单快照覆盖。 +6. 不把读档简化成前端本地状态恢复。 +7. 不把历史重生成简化成“重新请求下一步”。 +8. 不把属性白名单简化成所有用户默认可见或所有用户默认不可见。 +9. 不把积分扣费逻辑删除、合并或改成别的口径。 +10. 不把 TXT 模式提示词重写成符合当前项目既有风格的新词。 +11. 不新开一套与现有平台壳层割裂的新创作系统或新游戏系统。 +12. 不允许让 TXT 模式在平台内拥有独立于现有平台系统之外的第二套: + - 存档系统 + - 继续体验系统 + - 作品详情启动系统 + - 浏览历史系统 + - 钱包/扣费系统 + - 平台作品发布/作品库系统 + +--- + +## 2. 冻结边界 + +## 2.1 必须 1:1 复制的边界 + +以下内容必须视为外部系统的冻结能力面,后续实现只能迁移,不能重写需求: + +1. 创作入口形态与 TXT 模式玩法身份。 +2. TXT 模式编辑器的步骤顺序与页面职责。 +3. 游戏详情页的开始方式: + - 新的开始 + - 继续体验 + - 读取存档 +4. 运行时页面的能力面: + - 普通模式 + - 文本模式 + - 历史 + - 存档 + - 设置 + - 属性面板 +5. 双会话机制: + - 玩家游玩会话 + - 创作者测试/读档会话 +6. 流式动作接口与事件协议: + - `start` + - `raw_text` + - `step` + - `complete` + - `data` + - `error` + - 最终 `done` +7. 历史重生成能力。 +8. 存档的 5 槽位限制、新建与覆盖行为。 +9. 存档真实载荷结构必须包含: + - `stateLite` + - `historyTail` +10. 读取存档必须通过 `saveId` 参与创建会话,不能改成其它语义。 +11. 前 30 个 assistant 回合免费;其后: + - 选项扣 2 积分 + - 自定义输入扣 3 积分 +12. Beta 白名单控制属性面板显示。 +13. 默认主 prompt 的选择语义与版本切换语义。 + +## 2.1.1 平台统一实现边界 + +在“完整复制外部 TXT 模式功能设计”的同时,后续实现还必须遵守下面这条平台边界: + +1. 外部仓库的能力设计可以作为平台统一能力升级的来源。 +2. 但落地结果必须优先并入 `Genarrative` 已有正式系统。 +3. 禁止同类平台能力在不同玩法里出现多套正式实现并行。 + +需要统一的平台能力至少包括: + +1. 存档与继续体验 +2. 作品详情页与开始方式 +3. 作品发布、作品库、公开广场 +4. 浏览历史 +5. 钱包、积分与流水 +6. 运行时设置 + +这意味着: + +1. TXT 模式可以推动平台存档系统从“单快照 + 世界归档摘要”升级到更强形态。 +2. 但升级后的能力仍然必须是平台统一存档系统,而不是“RPG 一套、TXT 一套”。 + +## 2.2 只允许替换的边界 + +以下内容允许替换为 `Genarrative` 现有能力,但只能替换底层调用,不允许改上层产品语义: + +1. 文本生成模型调用: + - 外部仓库原模型调用 + - 替换为 `server-node/src/services/llmClient.ts` + - 路由出口统一由 `server-node/src/routes/runtimeRoutes.ts` 承接 +2. 生图模型调用: + - 外部仓库原场景图生成能力 + - 替换为 `server-node/src/services/sceneImageService.ts` +3. 账户、鉴权与运行时数据存储: + - 对齐 `Genarrative` 现有 JWT、PostgreSQL、`runtimeRepository` + - 但不能因此改掉 TXT 模式对会话、存档的语义 + +## 2.3 明确禁止改动的边界 + +后续编码时,下列内容禁止擅自修改: + +1. 外部 TXT 模式提示词正文。 +2. 外部 TXT 模式功能需求口径。 +3. 外部 TXT 模式的收费规则。 +4. 外部 TXT 模式的编辑步骤顺序。 +5. 外部 TXT 模式的存档槽位数量。 +6. 外部 TXT 模式的流式协议事件名。 +7. 外部 TXT 模式的开始方式文案与行为语义。 + +--- + +## 3. 外部 TXT 模式完整能力清单 + +## 3.1 创作链路 + +外部 TXT 模式从创作到发布的完整主链如下: + +1. 用户在创作入口选择 `txt` 模式。 +2. 进入 `GameSettingsEditor`。 +3. 通过以下方式之一建立作品底稿: + - 上传文档 + - 一句话生成 + - 空白创建 +4. 在编辑器中依次配置: + - 世界观 + - 角色 + - 场景 +5. 发起测试体验。 +6. 通过测试体验验证文本模式运行效果。 +7. 发布作品。 + +## 3.2 浏览与启动链路 + +作品发布后的浏览与启动链路如下: + +1. 进入作品详情页。 +2. 查看作品信息。 +3. 选择以下开始方式之一: + - 新的开始 + - 继续体验 + - 读取存档 +4. 进入视觉小说运行页。 + +## 3.3 运行时能力面 + +运行页必须完整具备下面这些功能面板或子能力: + +1. 普通模式。 +2. 文本模式。 +3. 历史记录。 +4. 存档管理。 +5. 设置面板。 +6. 属性面板。 +7. 流式动作提交与逐步渲染。 +8. 历史重生成。 +9. 测试体验与正式游玩的差异化会话创建。 + +--- + +## 4. 外部仓库参考实现映射 + +## 4.1 外部前端主链文件 + +后续迁移过程中,以下外部前端文件视为参考主源: + +1. `E:\Repos\Interactive-fiction-frontend\src\page\Create.tsx` + - 创作入口与 `txt` 模式选择。 +2. `E:\Repos\Interactive-fiction-frontend\src\page\GameSettingsEditor.tsx` + - TXT 模式创作编辑器主页面。 +3. `E:\Repos\Interactive-fiction-frontend\src\page\GameDetail.tsx` + - 作品详情与开始方式入口。 +4. `E:\Repos\Interactive-fiction-frontend\src\page\Galgame.tsx` + - 运行时主页面。 +5. `E:\Repos\Interactive-fiction-frontend\src\components\Galgame\TextModeDisplay.tsx` + - 文本模式主显示区。 +6. `E:\Repos\Interactive-fiction-frontend\src\hooks\galgame\useGalgameController.ts` + - 运行时主控状态编排。 +7. `E:\Repos\Interactive-fiction-frontend\src\hooks\galgame\useGalgameStep.ts` + - 运行时步骤推进。 +8. `E:\Repos\Interactive-fiction-frontend\src\hooks\story\useStepStreamResponse.ts` + - 流式响应解析。 +9. `E:\Repos\Interactive-fiction-frontend\src\api\galgame\createGalgameSession.ts` + - 会话创建请求。 +10. `E:\Repos\Interactive-fiction-frontend\src\api\galgame\saves.ts` + - 存档接口封装。 +## 4.2 外部后端主链文件 + +后续迁移过程中,以下外部后端文件视为参考主源: + +1. `E:\Repos\Interactive-fiction-backend\interface\routes\games.js` + - 游戏主路由入口。 +2. `E:\Repos\Interactive-fiction-backend\interface\handlers\games\gamesSession.js` + - 玩家游玩会话创建。 +3. `E:\Repos\Interactive-fiction-backend\interface\routes\visual.js` + - 视觉小说主接口集合。 +4. `E:\Repos\Interactive-fiction-backend\interface\routes\visualExtra.js` + - 编辑/读档会话等补充接口。 +5. `E:\Repos\Interactive-fiction-backend\interface\handlers\visual\sendActionStream.js` + - 流式动作执行主入口。 +6. `E:\Repos\Interactive-fiction-backend\interface\handlers\visual\saves.js` + - 存档 CRUD。 +7. `E:\Repos\Interactive-fiction-backend\services\visual\gameLogic.js` + - prompt 装载、上下文拼接与核心运行逻辑。 + +--- + +## 5. Genarrative 承接位映射 + +## 5.0 平台现有能力基线与统一升级原则 + +在正式迁移 TXT 模式前,必须先承认 `Genarrative` 已经有下面这些正式平台能力,而不是空白项目: + +1. 平台首页与四大主导航 + - `src/components/game-shell/PlatformHomeView.tsx` +2. 平台作品详情视图 + - `src/components/game-shell/PlatformWorldDetailView.tsx` +3. 平台进入作品、继续体验、读取存档的选择流 + - `src/components/game-shell/PreGameSelectionFlow.tsx` +4. 当前快照存档与继续游戏 + - `src/hooks/useGamePersistence.ts` + - `src/services/storageService.ts` + - `GET /api/runtime/save/snapshot` + - `PUT /api/runtime/save/snapshot` +5. 平台“全部存档”与世界归档恢复 + - `GET /api/runtime/profile/save-archives` + - `POST /api/runtime/profile/save-archives/:worldKey` +6. 平台浏览历史 + - `GET /api/runtime/profile/browse-history` + - `POST /api/runtime/profile/browse-history` + - `DELETE /api/runtime/profile/browse-history` +7. 平台钱包与流水 + - `profile_dashboard_state` + - `profile_wallet_ledger` +8. 平台作品库与公开广场 + - `custom_world_profiles` + - `custom-world-library` + - `custom-world-gallery` + +后续迁移 TXT 模式时,必须以“统一升级这些平台能力”为落地方向,而不是在 TXT 模式域里平行造轮子。 + +## 5.1 前端承接位 + +TXT 模式后续在 `Genarrative` 中的前端承接位必须尽量复用现有平台外壳: + +1. `src/components/game-shell/PlatformCreationTypeModal.tsx` + - 已存在 `visual-novel` 类型,是 TXT 模式玩法品类的首要接入点。 +2. `src/components/game-shell/PlatformHomeView.tsx` + - 负责平台首页与创作入口汇聚。 +3. `src/components/custom-world-home/CustomWorldCreationHub.tsx` + - 可作为创作工作区承接层之一,后续需要判断是扩展现有创作 hub,还是在其内部增加视觉小说分支。 +4. `src/components/game-shell/PlatformWorldDetailView.tsx` + - 可作为作品详情行为入口的既有容器之一。 +5. `src/services/aiService.ts` + - 前端调用 `server-node` 接口的统一壳层,后续 TXT 模式前端接口统一从这里扩展。 + +前端实施约束: + +1. 不新建一套完全脱离 `game-shell` 的平台入口。 +2. 不把运行时结算逻辑挪到前端。 +3. 不把 TXT 模式做成一个和平台其它玩法互不相干的“独立站中站”。 + +## 5.2 后端承接位 + +TXT 模式后续在 `Genarrative` 中的后端承接位如下: + +1. `server-node/src/routes/runtimeRoutes.ts` + - 新增 TXT 模式相关运行时路由的首选入口。 +2. `server-node/src/services/llmClient.ts` + - 替代外部系统文本生成调用。 +3. `server-node/src/services/sceneImageService.ts` + - 替代外部系统场景图生成调用。 +4. `server-node/src/repositories/runtimeRepository.ts` + - 承接会话、存档、作品库等持久化能力的基础仓储层。 +5. `server-node/src/prompts/` + - 承接外部 TXT 模式 prompt 正文的正式主源目录。 + +后端实施约束: + +1. TXT 模式主逻辑必须落到 `server-node`。 +2. 前端只消费服务端裁决结果,不在浏览器自行构造运行真相源。 +3. 新增 prompt 必须收口到 `server-node/src/prompts/`,并保留外部 TXT 模式正文。 + +## 5.3 仓储层承接判断 + +当前 `runtimeRepository.ts` 已经承接平台正式存档、归档摘要、浏览历史、作品库、钱包看板等能力。TXT 模式迁移不能绕过这套平台正式仓储,而应在其上做统一升级。 + +当前现状: + +1. 当前已有能力: + - 当前快照读写 + - 世界归档恢复 + - 平台浏览历史 + - 自定义世界库 + - 运行时设置 + - profile save archive + - 钱包与流水看板 +2. 当前缺口: + - 双会话实体 + - 5 槽位 TXT 模式存档 + - 历史重生成上下文 + - 按作品隔离的视觉小说运行时状态 + - “开始方式”到具体玩法会话模型的统一抽象 + +统一升级原则: + +1. `save_snapshots` 仍然作为“当前继续体验快照”的平台统一入口保留。 +2. `profile_save_archives` 仍然作为“平台全部存档/继续体验列表”的统一入口保留。 +3. 外部 TXT 模式更优的地方,例如: + - 多槽位正式存档 + - 基于 `saveId` 的读档会话创建 + - 存档载荷保留 `stateLite + historyTail` + 必须吸收进平台统一存档模型,而不是在 TXT 模式域里自立门户。 +4. 平台“读取存档”的 UI 入口与服务端抽象必须能够覆盖多玩法,但最终仍是一套平台存档体系。 + +后续建议: + +1. 在 `runtimeRepository` 下扩展“平台统一玩法存档 contract”,由 TXT 模式先推动升级。 +2. 允许新增专门表结构承载多槽位与历史分支,但这些表必须从属于平台统一存档体系,而不是只暴露成 TXT 模式私有能力。 +3. 通过统一 repository facade 暴露: + - runtime session + - current snapshot + - archive list + - save slot + - history regenerate context + - billing ledger source + +## 5.4 平台已有系统与外部更优实现的评估结论 + +### 5.4.1 存档系统 + +平台现状: + +1. 当前平台已有: + - 单一当前快照 `save_snapshots` + - 世界维度归档摘要 `profile_save_archives` + - 平台存档页与恢复入口 +2. 当前优势: + - 已接入账号体系 + - 已在平台首页/存档页/继续体验流中使用 + - 已有统一后端路由与仓储 +3. 当前不足: + - 不支持多槽位正式存档 + - 不支持基于 `saveId` 的显式读档会话 + - 存档载荷没有为历史重生成显式建模 + +外部 TXT 模式更优点: + +1. 一个作品支持最多 5 个正式存档槽位。 +2. 读取存档不是恢复前端状态,而是基于 `saveId` 创建新的会话。 +3. 存档显式保留 `stateLite + historyTail`,更适合历史重生成与分支延展。 +3. 存档显式保留 `stateLite + historyTail`,更适合历史重生成与分支延展。 + +统一结论: + +1. 平台必须保留现有“当前快照 + 全部存档列表”这套主入口。 +2. 但底层存档模型需要吸收外部 TXT 模式的多槽位与 `saveId` 读档机制。 +3. 最终形态应是“平台统一存档系统升级”,而不是“平台旧存档系统 + TXT 多槽位存档系统”并行。 + +### 5.4.2 继续体验与读取存档 + +平台现状: + +1. 当前 `PreGameSelectionFlow` 已有: + - 继续体验 + - 全部存档 + - 恢复指定世界归档 +2. 当前优势: + - 已在平台首页、存档页、详情流中贯通 +3. 当前不足: + - 开始方式仍偏向现有 RPG / 自定义世界主链 + - 不具备按玩法映射不同会话创建方式的统一抽象 + +外部 TXT 模式更优点: + +1. 明确区分: + - 新的开始 + - 继续体验 + - 读取存档 +2. 三者最终都指向不同语义的会话创建。 + +统一结论: + +1. 平台详情页和开始方式系统应升级为统一的“玩法感知启动器”。 +2. TXT 模式接入时,必须把外部三种开始方式吸收到平台统一详情启动系统中。 +3. 不允许 TXT 模式单独拥有一套详情页开始按钮逻辑。 + +### 5.4.3 浏览历史系统 + +平台现状: + +1. 已有正式浏览历史接口与平台首页展示位。 +2. 已经明确以后端为真相源,并在未登录态有公开浏览边界设计。 + +外部 TXT 模式更优点: + +1. 本次勘察中未发现其浏览历史系统显著优于平台现有实现。 + +统一结论: + +1. TXT 模式作品必须直接接入平台现有浏览历史系统。 +2. 不允许单独维护 TXT 模式历史列表。 + +### 5.4.4 钱包、积分与扣费 + +平台现状: + +1. 已有: + - `profile_dashboard_state` + - `profile_wallet_ledger` + - 钱包余额与流水接口 +2. 当前优势: + - 已经是平台正式钱包系统 +3. 当前不足: + - 暂未承接 TXT 模式的回合计费规则 + +外部 TXT 模式更优点: + +1. 计费时机和规则清晰: + - 前 30 个 assistant 回合免费 + - 之后选项扣 2 积分 + - 自定义输入扣 3 积分 + +统一结论: + +1. TXT 模式扣费必须直接接入平台现有钱包与流水系统。 +2. 只允许新增新的 `sourceType / sourceKey` 和对应结算服务,不允许新增一套 TXT 私有账本。 + +### 5.4.5 作品详情、作品库与广场 + +平台现状: + +1. 已有统一的: + - 作品详情页 + - 作品库 + - 公开广场 +2. 当前优势: + - 已与平台账号、浏览历史、发布流打通 +3. 当前不足: + - 详情页的开始方式与玩法差异抽象不足 + +外部 TXT 模式更优点: + +1. 详情页与运行时入口关系更明确,开始方式更完整。 + +统一结论: + +1. TXT 模式必须并入平台统一作品详情与作品库。 +2. 详情页能力由平台统一升级,不允许每个玩法维护不同的详情启动系统。 + +--- + +## 6. 提示词迁移规则 + +## 6.1 Prompt 迁移原则 + +TXT 模式的 prompt 迁移必须遵守当前仓库的 prompt 收口规则,并同时满足“外部正文冻结”: + +1. prompt 正文原样迁移到 `server-node/src/prompts/`。 +2. 业务模块只负责组织上下文,不在业务文件里重写 prompt 大段文本。 +3. 不得以“适配本项目风格”为理由改写原有 prompt 规则。 +4. 如果外部系统存在 prompt 版本选择与白名单切换逻辑,必须按原语义保留。 + +## 6.2 Prompt 文件组织建议 + +后续可按 TXT 模式独立目录或独立命名空间收口,例如: + +```text +server-node/src/prompts/txt-mode/ +├─ visualGmSystemPrompt.ts +├─ visualActionPromptBuilders.ts +├─ visualRegeneratePromptBuilders.ts +└─ visualSessionPromptSelectors.ts +``` + +约束说明: + +1. 文件命名可以按 `Genarrative` 规范整理。 +2. 但 prompt 正文与选择语义不得改写。 +3. 需要保留默认 `visual.gm.system` 以及外部已有版本切换能力。 + +--- + +## 7. 核心运行机制拆解 + +## 7.1 双会话机制 + +TXT 模式后续必须完整落地双会话机制: + +1. 玩家游玩会话 + - 对应外部 `POST /api/optical/games/session/create` + - 用于正式游玩 +2. 创作者测试/读档会话 + - 对应外部 `POST /api/visual/session/create` + - 用于测试体验与加载指定存档 + +这两条链路不能合并,原因如下: + +1. 语义不同。 +2. 权限不同。 +3. 初始载荷来源不同。 +4. 存档恢复方式不同。 + +## 7.2 流式动作机制 + +TXT 模式后续必须完整保留流式动作链路: + +1. 客户端提交动作。 +2. 服务端按外部协议持续推送事件。 +3. 前端按事件类型逐步更新显示。 +4. 最终以 `done` 作为流结束信号。 + +必须保留的事件名: + +1. `start` +2. `raw_text` +3. `step` +4. `complete` +5. `data` +6. `error` +7. `done` + +禁止行为: + +1. 把事件名替换成当前项目其它 SSE 命名。 +2. 只保留 `complete` 一种事件。 +3. 改成轮询。 + +## 7.3 存档机制 + +TXT 模式后续必须保留完整的多槽位云存档机制,但其落地方式必须是“升级平台统一存档系统”,而不是并行造一套 TXT 私有存档系统: + +1. 每个作品最多 5 个槽位。 +2. 支持新建槽位。 +3. 支持覆盖已有槽位。 +4. 存档内容不只是摘要,还必须包含: + - `stateLite` + - `historyTail` +5. 读取存档必须通过 `saveId` 创建新的编辑/读档会话。 + +平台落地约束: + +1. `save_snapshots` 继续承接“当前继续体验快照”。 +2. `profile_save_archives` 继续承接“平台全部存档”入口。 +3. 多槽位能力必须并入平台统一存档 contract。 +4. TXT 模式不能出现一套独立于平台存档页之外的正式云存档入口。 + +## 7.4 历史重生成机制 + +TXT 模式后续必须保留历史重生成能力,且不能与普通“继续下一步”混同: + +1. 用户从历史中选择一个节点。 +2. 服务端基于该节点上下文生成新的后续。 +3. 新结果必须进入新的有效运行轨迹。 + +## 7.5 扣费机制 + +TXT 模式后续必须原样保留外部扣费规则,但扣费必须并入平台现有钱包与流水系统: + +1. 前 30 个 assistant 回合免费。 +2. 超过后: + - 选项扣 2 积分 + - 自定义输入扣 3 积分 + +实现约束: + +1. 计费判断在后端完成。 +2. 会话内回合计数必须可追踪。 +3. 前端只展示服务端裁决后的结果。 +4. 计费入账必须进入平台统一 `wallet ledger`,而不是新建 TXT 私有账本。 + +## 7.6 属性面板白名单机制 + +TXT 模式后续必须保留 Beta 白名单控制属性面板的行为。 + +实现约束: + +1. 白名单裁决放到后端或统一配置层。 +2. 前端只消费是否展示的结果。 +3. 不得简化成全量开放或全量关闭。 + +--- + +## 8. 阶段化实施计划 + +## 8.1 第一阶段:入口接入与玩法骨架 + +目标: + +把 TXT 模式以 `visual-novel` 品类接入现有创作入口,并建立最小骨架,不落业务细节伪实现。 + +必须完成: + +1. 打通 `PlatformCreationTypeModal` 的 `visual-novel` 入口。 +2. 明确 TXT 模式作品在平台首页、创作入口、详情页中的入口身份。 +3. 补齐前端路由与页面壳层结构。 +4. 补齐后端基础 route namespace 与 contract 目录。 +5. 建立 TXT 模式模块目录、prompt 目录、以及“平台统一能力升级”所需的 repository contract 目录。 + +本阶段不应偷懒成: + +1. 只在 UI 上点亮入口但无后续链路。 +2. 直接复用 RPG 玩法运行时。 + +## 8.2 第二阶段:创作编辑器迁移 + +目标: + +把外部 TXT 模式创作编辑器完整嵌入现有创作入口链路。 + +必须完成: + +1. 对齐外部 `GameSettingsEditor` 的步骤结构。 +2. 支持三种建稿方式: + - 文档上传 + - 一句话生成 + - 空白创建 +3. 对齐世界观、角色、场景配置能力。 +4. 提供测试体验入口。 +5. 提供发布入口。 + +实现要求: + +1. 优先复用现有平台创作容器,不独立再造系统。 +2. 前端只承担编辑器表现与请求发起。 +3. 生成、校验、编译、预发布检查在后端完成。 + +## 8.3 第三阶段:作品详情与开始方式接入 + +目标: + +把外部详情页能力映射到现有平台作品详情流中。 + +必须完成: + +1. 平台统一作品详情展示升级。 +2. 三种开始方式: + - 新的开始 + - 继续体验 + - 读取存档 +3. 对应三种开始方式的后端会话创建逻辑。 + +验收重点: + +1. “继续体验”读取的不是前端本地缓存,而是服务端真实运行进度。 +2. “读取存档”走 `saveId` 创建会话。 +3. 详情页开始方式是平台统一实现,不是 TXT 模式私有实现。 + +## 8.4 第四阶段:运行时主链迁移 + +目标: + +把外部 `Galgame` 运行时的主状态机、流式动作与文本模式能力迁入 `Genarrative`。 + +必须完成: + +1. 运行时主页面壳层。 +2. 普通模式与文本模式切换。 +3. 历史记录面板。 +4. 流式动作提交。 +5. SSE 事件逐步渲染。 +6. 设置面板。 +7. 属性面板。 + +实现要求: + +1. 前端 controller 必须围绕服务端状态工作。 +2. 不允许在前端重建运行时真相源。 +3. 运行时 option、step、session state 等关键语义以后端为准。 + +## 8.5 第五阶段:存档、读档、重生成与扣费 + +目标: + +补齐外部 TXT 模式真正可玩的核心闭环。 + +必须完成: + +1. 平台统一存档系统升级到支持 5 槽位存档 CRUD。 +2. `saveId` 读档会话创建。 +3. 历史重生成。 +4. 回合计数与积分扣费接入平台统一钱包流水。 + +实现要求: + +1. 新增仓储结构与数据表时,必须按平台统一能力设计。 +2. 所有关键行为需要独立测试覆盖。 +3. 不允许以“先不做历史重生成/读档语义”为理由跳过。 + +## 8.6 第六阶段:发布、浏览与平台融合 + +目标: + +把 TXT 模式作品纳入 `Genarrative` 的平台浏览、作品详情、继续游玩与个人库体系。 + +必须完成: + +1. 发布后的作品可被浏览。 +2. 作品详情可进入。 +3. 平台可继续游玩与读档。 +4. 平台作品数据与用户数据隔离正常。 +5. 与现有 `custom world library`、浏览历史、钱包、平台存档体系兼容。 + +--- + +## 9. 前后端模块拆解建议 + +## 9.1 前端模块建议 + +建议后续至少形成下面几类前端模块: + +1. 玩法入口与作品详情模块 +2. TXT 模式创作编辑器模块 +3. TXT 模式运行时页面模块 +4. TXT 模式 API service 模块 +5. TXT 模式 SSE 事件解析模块 +6. TXT 模式存档面板模块 + +建议原则: + +1. 界面层归前端。 +2. 状态真相源归后端。 +3. 尽量遵循外部 TXT 模式已有模块边界,而不是强行揉进现有 story hooks。 + +## 9.2 后端模块建议 + +建议后续至少形成下面几类后端模块: + +1. `txt-mode/routes` + - 会话创建 + - 动作流 + - 历史重生成 + - 存档 CRUD + - 作品发布与查询 +2. `txt-mode/services` + - prompt selector + - game logic + - action stream orchestrator + - session service + - save service + - billing service +3. `platform-runtime/repositories` + - unified session repository + - current snapshot repository + - archive repository + - save slot repository + - billing ledger integration repository +4. `txt-mode/contracts` + - session request/response + - action stream event + - save slot DTO + +## 9.3 数据层建议 + +后续建议在平台统一 runtime 数据层下补足缺失结构,而不是为 TXT 模式平行造整套平台能力: + +1. 允许新增作品/玩法会话表 +2. 允许新增多槽位存档表 +3. 允许新增历史分支表 +4. 允许新增玩法可见性/白名单表 +5. 计费必须复用平台现有钱包与流水表,只新增对应 source type / source key 语义 + +字段设计原则: + +1. 以用户隔离。 +2. 以作品隔离。 +3. 游玩会话与测试会话可区分。 +4. 存档可追踪来源会话。 +5. 历史重生成可定位父节点。 +6. 同类平台能力只能有一套正式实现。 + +--- + +## 10. 与当前系统的兼容策略 + +## 10.1 与平台创作入口兼容 + +TXT 模式要作为现有平台玩法品类之一接入,不另起系统。 + +兼容要求: + +1. `visual-novel` 作为正式可选类型开放。 +2. 入口风格遵循现有平台设计。 +3. 但进入后应承接 TXT 模式完整工作流,而不是复用 RPG 编辑器。 + +## 10.1.1 与平台详情、存档、钱包系统兼容 + +TXT 模式迁移不是“新增一个玩法域”,而是一次平台统一能力升级窗口。 + +兼容要求: + +1. 平台详情页升级后仍是所有玩法统一入口。 +2. 平台存档页升级后仍是所有玩法统一存档入口。 +3. 平台钱包与流水仍是统一实现,TXT 模式只作为新的扣费来源。 +4. 平台浏览历史仍是统一实现,TXT 模式作品直接进入同一列表。 + +## 10.2 与现有 runtime story 系统兼容 + +TXT 模式不得直接改坏当前 `runtime story` 主链。 + +兼容要求: + +1. 尽量新增独立模块域,而不是在现有 story 模块中硬插大量 if/else。 +2. 可以复用通用: + - 鉴权 + - API 包装 + - 流式响应底层设施 + - LLM client + - scene image service +3. 不应复用会导致需求漂移的 RPG runtime state 结构。 + +## 10.3 与 prompt 管理规范兼容 + +必须遵守 [PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md](./PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md) 中已冻结的规则: + +1. prompt 主源收口到 `server-node/src/prompts/` +2. 业务模块只装配上下文 +3. 兼容层按需保留,但不新增业务内联 prompt + +## 10.4 与后端边界迁移方向兼容 + +必须遵守 [RUNTIME_STORY_BACKEND_BOUNDARY_MIGRATION_2026-04-19.md](./RUNTIME_STORY_BACKEND_BOUNDARY_MIGRATION_2026-04-19.md) 已明确的方向: + +1. 前端继续退化为表现壳层。 +2. 运行时交互语义以后端为准。 +3. TXT 模式后续实现不能反向把结算逻辑拉回前端。 + +--- + +## 11. 风险清单 + +## 11.1 最大风险 + +1. 把“完整复制”错误执行成“按当前项目风格重做一个类似玩法”。 +2. 把外部 prompt 正文改写后导致结果漂移。 +3. 低估双会话、存档和历史重生成的耦合度。 +4. 直接复用 `save_snapshots` 导致 TXT 模式无法表达多槽位与历史分支轨迹。 +5. 前端图省事重建一部分运行时真相源,造成行为与服务端不一致。 +6. 因为“赶快接入 TXT 模式”而在平台里造出第二套存档、详情启动或扣费系统。 + +## 11.2 中风险 + +1. 作品详情、平台浏览和继续体验链路没对齐,导致发布后无法真正作为平台玩法使用。 +2. 扣费规则落到前端,容易被绕过或与会话状态不一致。 +3. 读档语义做错,变成把前端状态塞回运行时。 +4. 文本模式渲染层只抄 UI,不抄状态流,最终行为不一致。 +5. 平台现有系统不升级,只在 TXT 模式域里补私有能力,后续导致平台能力分叉。 + +## 11.3 风险应对 + +1. 先补 contract 文档,再开工编码。 +2. 先迁移 prompt 主源,再迁移 logic。 +3. 先设计独立 repository schema,再落会话与存档逻辑。 +4. 关键行为优先写服务端测试。 +5. 前端只做消费层,避免“补逻辑”。 +6. 任何涉及存档、详情、钱包的设计都必须先写“平台统一实现说明”,再编码。 + +--- + +## 12. 开发顺序建议 + +为了避免返工,后续正式开发建议按下面顺序执行: + +1. 先补平台统一能力升级文档,明确存档、详情、钱包怎么吸收外部优点。 +2. 再补 TXT 模式 contracts 文档与 prompt 清单文档。 +3. 再设计 repository schema 与 migration。 +4. 再接会话创建与动作流最小链路。 +5. 再接创作编辑器。 +6. 再接详情页与开始方式统一升级。 +7. 再补存档、重生成与扣费。 +8. 最后补平台浏览、发布与联调验收。 + +原因: + +1. 双会话、存档是运行机制核心,不先定统一平台能力边界,后面会直接分叉成多实现。 +2. prompt 正文冻结需要先完成收口,避免开发过程中顺手修改。 +3. 平台融合依赖作品模型、会话模型和持久化模型先稳定。 + +--- + +## 13. 测试与验收清单 + +后续开发完成后,至少必须逐项验收下面这些能力,不允许只做冒烟: + +1. 创作入口能选择 `visual-novel` 并进入 TXT 模式编辑器。 +2. TXT 模式支持: + - 文档上传 + - 一句话生成 + - 空白创建 +3. 编辑器完整覆盖: + - 世界观 + - 角色 + - 场景 + - 测试体验 + - 发布 +4. 作品详情页包含: + - 新的开始 + - 继续体验 + - 读取存档 +5. 正式游玩会话与测试/读档会话是两条独立链路。 +6. 动作流事件必须按协议顺序可被正确消费: + - `start` + - `raw_text` + - `step` + - `complete` + - `data` + - `error` + - `done` +7. 文本模式显示效果与普通模式切换正常。 +8. 历史记录可查看。 +9. 历史重生成可用,且会产生新的有效后续。 +10. 存档最多 5 个槽位,支持新建与覆盖。 +11. 读取存档必须走 `saveId` 创建会话。 +12. 存档内容包含 `stateLite + historyTail`。 +13. 平台存档页与详情页读取到的是统一存档系统,不存在 TXT 私有正式存档页。 +14. 前 30 个 assistant 回合免费。 +15. 免费额度之后: + - 选项扣 2 积分 + - 自定义输入扣 3 积分 +16. 扣费流水进入平台统一钱包系统,而不是 TXT 私有账本。 +17. 属性面板只对白名单用户开放。 +18. 默认 prompt 与版本切换语义保持外部系统一致。 +19. 外部 prompt 正文与功能需求未被改写。 +20. 前端未承担运行时结算职责。 +21. 平台内同类系统没有出现双实现。 + +--- + +## 14. 这份文档之后还需要补的配套文档 + +为了让后续落地没有编码级歧义,正式开工前还需要继续补至少 3 份配套文档: + +1. `PLATFORM_UNIFIED_RUNTIME_SYSTEM_UPGRADE_SPEC` + - 定义平台统一存档、详情启动、钱包如何吸收 TXT 模式的更优设计,避免同类系统双实现。 +2. `TXT_MODE_PROMPT_MIGRATION_SPEC` + - 列出外部 TXT 模式所有 prompt 主源、默认版本、白名单规则与迁移目标文件。 +3. `TXT_MODE_CONTRACT_AND_SCHEMA_SPEC` + - 定义会话、存档、历史重生成、扣费的 API contract 与数据库 schema。 +4. `TXT_MODE_PAGE_AND_COMPONENT_MAPPING_SPEC` + - 定义 `Genarrative` 前端页面、路由、组件、面板与外部前端文件的一一映射关系。 + +在这 4 份配套文档缺失之前,不建议直接开始大规模编码。 + +--- + +## 15. 本文结论 + +后续实现 TXT 模式时,必须坚持下面这条总原则: + +**外部仓库的 TXT 模式不是“灵感来源”,而是本次需要完整迁入的视觉小说玩法模板;但迁入方式不是在平台里再造一套平行系统,而是把外部更优能力吸收到 Genarrative 已有平台系统中,形成统一实现。** + +只要后续开发仍然满足下面这 4 条,本次迁移方向就是正确的: + +1. 创作流程完整复制。 +2. 运行机制完整复制。 +3. prompt 正文与功能需求不改。 +4. 文本生成与生图调用替换为本项目现有能力。 +5. 平台内同类系统只有一套正式实现。 diff --git a/packages/shared/src/contracts/customWorldAgent.ts b/packages/shared/src/contracts/customWorldAgent.ts index 9bb63bd9..c40f56a9 100644 --- a/packages/shared/src/contracts/customWorldAgent.ts +++ b/packages/shared/src/contracts/customWorldAgent.ts @@ -377,7 +377,10 @@ export interface CustomWorldRoleAssetSummary { export interface CustomWorldSceneAssetSummary { sceneId: string; sceneName: string; + actId?: string | null; + actTitle?: string | null; imageSrc?: string | null; + assetId?: string | null; status: 'missing' | 'ready'; nextPointCost: number; } diff --git a/packages/shared/src/contracts/story.ts b/packages/shared/src/contracts/story.ts index effcf75a..22a54340 100644 --- a/packages/shared/src/contracts/story.ts +++ b/packages/shared/src/contracts/story.ts @@ -180,6 +180,7 @@ export type NpcChatTurnRequest< TStoryMoment = unknown, TContext = unknown, TConversationTurn = unknown, + TCombatContext = unknown, TNpcState = unknown, TQuestOfferState = unknown, TQuestOfferEncounter = unknown, @@ -194,6 +195,7 @@ export type NpcChatTurnRequest< context: TContext; conversationHistory?: TConversationTurn[]; dialogue?: TConversationTurn[]; + combatContext?: TCombatContext | null; playerMessage: string; npcState: TNpcState; npcInitiatesConversation?: boolean; diff --git a/server-node/package-lock.json b/server-node/package-lock.json index 05a7f211..a190a72c 100644 --- a/server-node/package-lock.json +++ b/server-node/package-lock.json @@ -20,6 +20,7 @@ "pino-http": "^10.5.0", "pino-roll": "^3.1.0", "pngjs": "^7.0.0", + "sharp": "^0.34.5", "zod": "^4.1.8" }, "devDependencies": { @@ -703,6 +704,519 @@ "node": ">=18" } }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -1328,6 +1842,15 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/discontinuous-range": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz", @@ -2473,6 +2996,18 @@ "node": ">=11.0.0" } }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/send": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", @@ -2541,6 +3076,50 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", diff --git a/server-node/package.json b/server-node/package.json index b1e933a7..4a817b53 100644 --- a/server-node/package.json +++ b/server-node/package.json @@ -23,6 +23,7 @@ "pino-http": "^10.5.0", "pino-roll": "^3.1.0", "pngjs": "^7.0.0", + "sharp": "^0.34.5", "zod": "^4.1.8" }, "devDependencies": { diff --git a/server-node/src/app.test.ts b/server-node/src/app.test.ts index 10e77951..e3760fbc 100644 --- a/server-node/src/app.test.ts +++ b/server-node/src/app.test.ts @@ -3148,9 +3148,9 @@ test('custom world agent generate_landmarks action appends landmark cards over h baseUrl, token: entry.token, }); - const baselineLandmarkCount = session.draftCards.filter( - (card) => card.kind === 'landmark', - ).length; + const baselineLandmarkCount = + session.draftProfile?.landmarks?.length ?? + session.draftCards.filter((card) => card.kind === 'landmark').length; const anchorCardId = session.draftCards.find((card) => card.kind === 'character')?.id ?? session.draftCards.find((card) => card.kind === 'thread')?.id; @@ -3211,7 +3211,10 @@ test('custom world agent generate_landmarks action appends landmark cards over h }; assert.equal(sessionResponse.status, 200); - assert.ok((sessionPayload.draftProfile?.landmarks?.length ?? 0) >= 6); + assert.ok( + (sessionPayload.draftProfile?.landmarks?.length ?? 0) >= + baselineLandmarkCount + 2, + ); assert.ok( sessionPayload.draftCards.filter((card) => card.kind === 'landmark') .length >= diff --git a/server-node/src/modules/ai/orchestrator.test.ts b/server-node/src/modules/ai/orchestrator.test.ts index bcc4cab1..e947f1b0 100644 --- a/server-node/src/modules/ai/orchestrator.test.ts +++ b/server-node/src/modules/ai/orchestrator.test.ts @@ -578,6 +578,14 @@ test('chat orchestrator force closes the fifth hostile primary-npc turn with for monsters: [], history: [], context: createStoryContext(), + combatContext: { + summary: '你刚在断桥口压住了断桥客的刀势,逼得他不得不重新开口。', + logLines: [ + '你先一步抢进桥心,逼开了对方的起手。', + '断桥客被逼退到桥栏边,终于没有再出下一刀。', + ], + battleOutcome: 'victory', + }, conversationHistory: [ { speaker: 'player', text: '你一直躲着不说完。' }, { speaker: 'npc', text: '有些话说完了,人也就该死了。' }, @@ -658,6 +666,15 @@ test('chat orchestrator force closes the fifth hostile primary-npc turn with for assert.equal(requestMessageCount, 0); assert.match(capturedReplyPrompts[0] ?? '', /最后一轮/u); assert.match(capturedReplyPrompts[0] ?? '', /推动后续剧情/u); + assert.match(capturedReplyPrompts[0] ?? '', /刚刚结束的交锋/u); + assert.match( + capturedReplyPrompts[0] ?? '', + /你刚在断桥口压住了断桥客的刀势,逼得他不得不重新开口/u, + ); + assert.match( + capturedReplyPrompts[0] ?? '', + /你先一步抢进桥心,逼开了对方的起手/u, + ); const eventText = responseChunks.join(''); const completeBlock = eventText diff --git a/server-node/src/modules/assets/characterAssetRoutes.test.ts b/server-node/src/modules/assets/characterAssetRoutes.test.ts index f7e61d86..fabe9542 100644 --- a/server-node/src/modules/assets/characterAssetRoutes.test.ts +++ b/server-node/src/modules/assets/characterAssetRoutes.test.ts @@ -375,59 +375,6 @@ test('character visual generation converts public reference images into data url ); }); -test('character prompt bundle generation falls back to local defaults when llm client is unavailable', async () => { - const tempRoot = fs.mkdtempSync( - path.join(os.tmpdir(), 'genarrative-character-prompt-bundle-'), - ); - - await withAssetRouteServer( - createTestConfig(tempRoot, 'http://127.0.0.1:9/api/v1'), - async (assetBaseUrl) => { - const response = await fetch( - `${assetBaseUrl}/api/assets/character-prompts/generate`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - roleKind: 'story', - characterName: '港口向导', - roleTitle: '潮灯守望者', - roleLabel: '旧港引路人', - description: '熟悉黑潮与暗礁,身上带着潮雾气息。', - backstory: '常年守在废弃灯塔附近,为误入者指路。', - personality: '冷静克制,但会在关键时刻出手。', - motivation: '想守住最后一段仍能靠岸的航道。', - combatStyle: '短刀与信号灯配合,动作利落。', - tags: ['潮雾', '守望', '引路'], - characterBriefText: - '角色名称:港口向导\n角色头衔:潮灯守望者\n世界身份:旧港引路人', - }), - }, - ); - - assert.equal(response.status, 200); - const payload = (await response.json()) as { - source: string; - visualPromptText: string; - animationPromptText: string; - scenePromptText: string; - }; - - assert.equal(payload.source, 'fallback'); - assert.match(payload.visualPromptText, /港口向导/u); - assert.match(payload.visualPromptText, /右向斜侧身/u); - assert.match(payload.visualPromptText, /纯绿色绿幕/u); - assert.match(payload.visualPromptText, /2 到 2\.5 头身/u); - assert.match(payload.visualPromptText, /躯干与四肢短而紧凑/u); - assert.match(payload.visualPromptText, /深色粗轮廓配合清晰大色块/u); - assert.match(payload.animationPromptText, /动作/u); - assert.match(payload.scenePromptText, /场景/u); - }, - ); -}); - test('character workflow cache persists unsaved studio state', async () => { const tempRoot = fs.mkdtempSync( path.join(os.tmpdir(), 'genarrative-character-workflow-cache-'), diff --git a/server-node/src/modules/assets/characterAssetRoutes.ts b/server-node/src/modules/assets/characterAssetRoutes.ts index bb0d125b..996bd7c1 100644 --- a/server-node/src/modules/assets/characterAssetRoutes.ts +++ b/server-node/src/modules/assets/characterAssetRoutes.ts @@ -18,25 +18,17 @@ import { PNG } from 'pngjs'; import { removeBackgroundFromRgba } from '../../../../packages/shared/src/assets/chromaKey.js'; import { parseApiErrorMessage } from '../../../../packages/shared/src/http.js'; -import { parseJsonResponseText } from '../../../../packages/shared/src/llm/parsers.js'; import type { AppConfig } from '../../config.js'; import { buildArkCharacterAnimationPrompt, - buildCharacterPromptBundleUserPrompt, - buildFallbackCharacterPromptBundle, buildFallbackModerationSafeAnimationPrompt, buildImageSequencePrompt, buildNpcAnimationPrompt, buildNpcVisualNegativePrompt, buildNpcVisualPrompt, - CHARACTER_PROMPT_BUNDLE_SYSTEM_PROMPT, - type CharacterPromptBundle, - sanitizeCharacterPromptBundle, } from '../../prompts/characterAssetPrompts.js'; import type { UpstreamLlmClient } from '../../services/llmClient.js'; -const CHARACTER_PROMPT_BUNDLE_GENERATE_PATH = - '/api/assets/character-prompts/generate'; const CHARACTER_WORKFLOW_CACHE_PATH = '/api/assets/character-workflow-cache'; const CHARACTER_VISUAL_GENERATE_PATH = '/api/assets/character-visual/generate'; const CHARACTER_VISUAL_PUBLISH_PATH = '/api/assets/character-visual/publish'; @@ -1050,106 +1042,6 @@ function getLowestSupportedVideoResolution(model: string, fallback: string) { } } -async function handleGenerateCharacterPromptBundle( - config: AppConfig, - req: IncomingMessage & { body?: unknown }, - res: ServerResponse, - llmClient?: UpstreamLlmClient | null, -) { - if (req.method !== 'POST') { - sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); - return; - } - - const body = await readJsonBody(req); - const roleKind = - typeof body.roleKind === 'string' && body.roleKind.trim() - ? body.roleKind.trim() - : 'story'; - const characterBriefText = clampPromptSeedText(body.characterBriefText, 2400); - const characterName = clampPromptSeedText(body.characterName, 40); - const roleTitle = clampPromptSeedText(body.roleTitle, 60); - const roleLabel = clampPromptSeedText(body.roleLabel, 60); - const description = clampPromptSeedText(body.description, 240); - const backstory = clampPromptSeedText(body.backstory, 320); - const personality = clampPromptSeedText(body.personality, 180); - const motivation = clampPromptSeedText(body.motivation, 180); - const combatStyle = clampPromptSeedText(body.combatStyle, 180); - const tags = isStringArray(body.tags) - ? body.tags - .map((item) => clampPromptSeedText(item, 24)) - .filter(Boolean) - .slice(0, 8) - : []; - - if (!characterBriefText) { - sendJson(res, 400, { - error: { message: '生成默认提示词前需要提供角色设定摘要。' }, - }); - return; - } - - const fallbackBundle = buildFallbackCharacterPromptBundle({ - characterName, - roleKind, - roleTitle, - roleLabel, - description, - backstory, - personality, - motivation, - combatStyle, - tags, - }); - const llmApiKey = - typeof config.llm?.apiKey === 'string' ? config.llm.apiKey.trim() : ''; - const llmModel = - typeof config.llm?.model === 'string' ? config.llm.model : ''; - - if (!llmClient || !llmApiKey) { - sendJson(res, 200, { - ok: true, - ...fallbackBundle, - }); - return; - } - - try { - const responseText = await llmClient.requestMessageContent({ - systemPrompt: CHARACTER_PROMPT_BUNDLE_SYSTEM_PROMPT, - userPrompt: buildCharacterPromptBundleUserPrompt({ - roleKind, - characterBriefText, - characterName, - roleTitle, - roleLabel, - description, - backstory, - personality, - motivation, - combatStyle, - tags, - }), - debugLabel: 'character-prompt-bundle', - timeoutMs: 30000, - }); - - sendJson(res, 200, { - ok: true, - ...sanitizeCharacterPromptBundle( - parseJsonResponseText(responseText), - fallbackBundle, - llmModel, - ), - }); - } catch { - sendJson(res, 200, { - ok: true, - ...fallbackBundle, - }); - } -} - async function writeDraftBinaryFile( rootDir: string, relativePath: string, @@ -3107,12 +2999,6 @@ export function createCharacterAssetRoutes( return handleSaveCharacterWorkflowCache(config, request, response); }), ); - router.use( - CHARACTER_PROMPT_BUNDLE_GENERATE_PATH, - toExpressHandler((request, response) => - handleGenerateCharacterPromptBundle(config, request, response, llmClient), - ), - ); router.use( CHARACTER_VISUAL_GENERATE_PATH, toExpressHandler((request, response) => diff --git a/server-node/src/modules/custom-world/runtimeProfile.ts b/server-node/src/modules/custom-world/runtimeProfile.ts index 8f71ada9..b4a706ff 100644 --- a/server-node/src/modules/custom-world/runtimeProfile.ts +++ b/server-node/src/modules/custom-world/runtimeProfile.ts @@ -572,9 +572,13 @@ function buildFallbackCustomWorldCampScene(profile: { } as const; return { + id: 'custom-scene-camp', name: fallbackName, description: descriptionByMode[themeMode], dangerLevel: 'low', + sceneNpcIds: [], + connections: [], + narrativeResidues: null, }; } @@ -1034,9 +1038,27 @@ function normalizeCampOutline( : {}; return { + id: toText(item.id) || fallback.id, name: toText(item.name) || fallback.name, description: toText(item.description) || fallback.description, + visualDescription: toText(item.visualDescription) || undefined, dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel, + imageSrc: toText(item.imageSrc) || undefined, + sceneNpcIds: toStringArray(item.sceneNpcIds), + connections: toRecordArray(item.connections) + .map((connection) => ({ + targetLandmarkName: + toText(connection.targetLandmarkName) || + toText(connection.target) || + toText(connection.sceneName), + relativePosition: + toText(connection.relativePosition) || + toText(connection.position) || + 'forward', + summary: + toText(connection.summary) || toText(connection.description), + })) + .filter((connection) => connection.targetLandmarkName), }; } @@ -1502,10 +1524,22 @@ function normalizeCampScene( : {}; return { + id: toText(item.id) || fallback.id, name: toText(item.name) || fallback.name, description: toText(item.description) || fallback.description, + visualDescription: toText(item.visualDescription) || undefined, dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel, imageSrc: toText(item.imageSrc) || undefined, + sceneNpcIds: toStringArray(item.sceneNpcIds), + connections: toRecordArray(item.connections) + .map((connection) => ({ + targetLandmarkId: toText(connection.targetLandmarkId), + relativePosition: + toText(connection.relativePosition) || toText(connection.position) || 'forward', + summary: toText(connection.summary) || toText(connection.description), + })) + .filter((connection) => connection.targetLandmarkId), + narrativeResidues: null, }; } diff --git a/server-node/src/modules/custom-world/runtimeTypes.ts b/server-node/src/modules/custom-world/runtimeTypes.ts index 2bc15047..241e5ef4 100644 --- a/server-node/src/modules/custom-world/runtimeTypes.ts +++ b/server-node/src/modules/custom-world/runtimeTypes.ts @@ -244,6 +244,7 @@ export interface SceneActBlueprint { summary: string; stageCoverage: SceneActStage[]; backgroundImageSrc?: string | null; + backgroundAssetId?: string | null; encounterNpcIds: string[]; primaryNpcId: string; linkedThreadIds: string[]; @@ -263,10 +264,21 @@ export interface SceneChapterBlueprint { } export interface CustomWorldCampScene { + id: string; name: string; description: string; + visualDescription?: string; dangerLevel: string; imageSrc?: string; + sceneNpcIds: string[]; + connections: CustomWorldSceneConnection[]; + narrativeResidues?: + | Array<{ + summary?: string; + changeHint?: string; + hiddenTruth?: string; + }> + | null; } export interface CustomWorldLandmark { diff --git a/server-node/src/prompts/characterAssetPrompts.ts b/server-node/src/prompts/characterAssetPrompts.ts index b85228eb..f0f608a9 100644 --- a/server-node/src/prompts/characterAssetPrompts.ts +++ b/server-node/src/prompts/characterAssetPrompts.ts @@ -7,20 +7,17 @@ import { /** * 角色资产正式 prompt 主源。 * - * 这份脚本同时承担两层职责: - * 1. 角色卡 -> 默认资产描述文本 - * - 产出 visualPromptText / animationPromptText / scenePromptText - * - 这层本质上是在“编译默认描述文本”,不是最终直接发给图像模型的完整 prompt - * 2. 默认描述文本 -> 正式模型 prompt - * - buildNpcVisualPrompt / buildNpcAnimationPrompt / buildArkCharacterAnimationPrompt - * - 这层才是正式发给图像 / 动作模型的 prompt 组装入口 + * 这份脚本当前只承担“正式模型 prompt 层”职责: + * - buildNpcVisualPrompt + * - buildNpcAnimationPrompt + * - buildArkCharacterAnimationPrompt + * - buildImageSequencePrompt * * 当前仓库状态需要特别区分: * - 当前自定义世界角色资产工坊默认输入框,实际直接使用前端 * src/prompts/customWorldRolePromptDefaults.ts - * - 本文件里的 CHARACTER_PROMPT_BUNDLE_SYSTEM_PROMPT 及其生成接口 - * /api/assets/character-prompts/generate 目前仍保留、可用、且有测试覆盖, - * 但不是当前资产工坊初始默认值的主链来源 + * - 默认描述文本的唯一主源已经统一为前端本地映射, + * 不再保留后端独立 bundle 编译接口 * - 当前正式角色主图与动作生成,仍然走本文件里的正式 prompt builder */ function clampPromptSeedText(value: unknown, maxLength: number) { @@ -31,147 +28,6 @@ function clampPromptSeedText(value: unknown, maxLength: number) { return value.replace(/\s+/gu, ' ').trim().slice(0, maxLength); } -export const CHARACTER_PROMPT_BUNDLE_SYSTEM_PROMPT = `你是 RPG 角色资产提示词编译器。 -你会收到一个角色设定摘要,请为当前项目生成 3 段可直接交给资产生成模型的中文提示词。 -你必须只输出一个 JSON 对象,不要输出 Markdown、代码块、注释或解释。 -输出格式必须严格为: -{ - "visualPromptText": "角色主图提示词", - "animationPromptText": "角色动作提示词", - "scenePromptText": "角色关联场景提示词" -} - -硬性约束: -- 所有字段都必须是自然中文。 -- visualPromptText 用于角色主图候选,必须是角色标准设定图而不是场景海报,突出单人全身、右向斜侧身站姿、脚底完整可见、服装武器轮廓稳定、纯绿色绿幕背景、1:1 画幅。 -- visualPromptText 里的主题词只能落在角色自身的服装、发型、材质、纹样、饰品、武器和发光细节上,不要自动补出建筑、风景、漂浮物、烟雾或其他角色以外的场景元素。 -- visualPromptText 要明确“身体整体朝右,但保留少量正面信息”,避免生成完全 90 度纯右视图。 -- animationPromptText 用于角色动作试片,必须突出发力方式、动作气质、连贯性、同一角色一致性,不要写镜头切换。 -- scenePromptText 用于该角色关联的场景背景,必须突出角色首次登场或主活动区域的环境气质与空间结构,适配横版 RPG 场景。 -- 三段提示词都要可直接使用,不要编号,不要加字段名解释,不要输出负面提示词。`; - -export type CharacterPromptBundle = { - visualPromptText: string; - animationPromptText: string; - scenePromptText: string; - source: 'llm' | 'fallback'; - model: string | null; -}; - -/** - * 当默认描述文本编译接口不可用,或当前环境不走 LLM 编译时, - * 用角色卡字段本地拼出一份可直接使用的默认文本 bundle。 - * - * 这份返回值属于“默认描述文本层”: - * - visualPromptText: 给角色主图用的默认描述 - * - animationPromptText: 给动作试片用的默认描述 - * - scenePromptText: 给角色关联场景用的默认描述 - * - * 它不是最终发给正式图像 / 动作模型的完整 prompt。 - */ -export function buildFallbackCharacterPromptBundle(params: { - characterName: string; - roleKind: string; - roleTitle: string; - roleLabel: string; - description: string; - backstory: string; - personality: string; - motivation: string; - combatStyle: string; - tags: string[]; -}) { - const roleAnchor = - [params.roleTitle, params.roleLabel].filter(Boolean).join(' / ') || - (params.roleKind === 'playable' ? '可扮演角色' : '场景角色'); - const characterAnchor = params.characterName || '该角色'; - const descriptionAnchor = - params.description || params.backstory || params.personality || '气质鲜明'; - const combatAnchor = - params.combatStyle || params.motivation || '动作发力清晰'; - const tagAnchor = - params.tags.length > 0 ? `保留 ${params.tags.join('、')} 的识别点。` : ''; - - return { - visualPromptText: [ - `${characterAnchor},${roleAnchor}。`, - '单人全身,2D 横版 RPG 角色标准设定图,1:1 正方形画幅,头身比控制在 2 到 2.5 头身,偏大头身,靠头部、发型、服装、配饰表现角色记忆点,躯干与四肢短而紧凑,五官简化,深色粗轮廓配合清晰大色块,右向斜侧身站立,身体整体朝右但保留少量正面信息,服装、发型、轮廓稳定清楚。', - `外观气质围绕:${descriptionAnchor}。`, - combatAnchor ? `战斗识别点:${combatAnchor}。` : '', - tagAnchor, - '背景固定为纯绿色绿幕,不带建筑、风景、漂浮物和其他场景元素,方便自动抠像,不做正面立绘,不做完全 90 度纯右视图,不做夸张透视。', - ] - .filter(Boolean) - .join(' '), - animationPromptText: [ - `${characterAnchor}的核心动作试片。`, - '保持同一角色的服装、发型、体型一致,镜头稳定,侧身朝右,动作连贯。', - combatAnchor ? `动作气质参考:${combatAnchor}。` : '', - params.personality ? `角色气质补充:${params.personality}。` : '', - '发力起手明确,过程干净,收招利落,避免漂移和变形。', - ] - .filter(Boolean) - .join(' '), - scenePromptText: [ - `${characterAnchor}关联主场景,适合作为首次登场区域或常驻活动空间。`, - '16:9 横版 RPG 场景背景,上下分区清楚,上半部分表现中远景氛围,下半部分是可站立地面。', - `场景叙事气质围绕:${descriptionAnchor}。`, - params.backstory ? `背景线索可参考:${params.backstory}。` : '', - params.motivation - ? `环境中可埋入与当前目标相关的暗示:${params.motivation}。` - : '', - '整体风格克制统一,适合剧情探索与战斗底图。', - ] - .filter(Boolean) - .join(' '), - source: 'fallback' as const, - model: null, - }; -} - -function sanitizePromptBundleValue( - value: unknown, - fallback: string, - maxLength: number, -) { - const normalized = clampPromptSeedText(value, maxLength); - return normalized || fallback; -} - -/** - * 将 LLM 返回的默认文本 bundle 规整成稳定结构。 - * - * 这里只负责兜底、限长和字段补齐,不负责把 bundle 进一步编译成 - * 正式图像 / 动作生成 prompt。 - */ -export function sanitizeCharacterPromptBundle( - value: unknown, - fallback: CharacterPromptBundle, - model: string, -) { - const record = isRecordValue(value) ? value : {}; - - return { - visualPromptText: sanitizePromptBundleValue( - record.visualPromptText, - fallback.visualPromptText, - 280, - ), - animationPromptText: sanitizePromptBundleValue( - record.animationPromptText, - fallback.animationPromptText, - 280, - ), - scenePromptText: sanitizePromptBundleValue( - record.scenePromptText, - fallback.scenePromptText, - 320, - ), - source: 'llm' as const, - model: model.trim() || null, - }; -} - function sanitizeAnimationPromptText(value: string, maxLength: number) { return value .replace(/\s+/gu, ' ') @@ -197,48 +53,6 @@ function buildCompactAnimationCharacterBrief(value: string) { .join(','); } -/** - * 默认文本 bundle 的 user prompt。 - * - * 这段文本只用于让 LLM 从角色卡摘要里提炼出 - * visualPromptText / animationPromptText / scenePromptText 三段默认描述, - * 不是正式图像模型或动作模型的 system prompt。 - */ -export function buildCharacterPromptBundleUserPrompt(params: { - roleKind: string; - characterBriefText: string; - characterName: string; - roleTitle: string; - roleLabel: string; - description: string; - backstory: string; - personality: string; - motivation: string; - combatStyle: string; - tags: string[]; -}) { - return [ - '请根据下面的角色卡摘要,编译一组默认资产提示词。', - '提示词用于当前项目的角色主图、动作试片和角色关联场景背景。', - '请保留该角色的身份识别点、气质、战斗方式与世界感,不要空泛套模板。', - '', - `角色类型:${params.roleKind === 'playable' ? '可扮演角色' : '场景角色'}`, - params.characterName ? `角色名称:${params.characterName}` : '', - params.roleTitle ? `角色头衔:${params.roleTitle}` : '', - params.roleLabel ? `世界身份:${params.roleLabel}` : '', - params.description ? `角色描述:${params.description}` : '', - params.backstory ? `角色背景:${params.backstory}` : '', - params.personality ? `角色性格:${params.personality}` : '', - params.motivation ? `角色动机:${params.motivation}` : '', - params.combatStyle ? `战斗风格:${params.combatStyle}` : '', - params.tags.length > 0 ? `角色标签:${params.tags.join('、')}` : '', - '', - '角色卡全文:', - params.characterBriefText, - ] - .filter(Boolean) - .join('\n'); -} /** * 正式角色主图 prompt 编译入口。 diff --git a/server-node/src/prompts/chatPromptBuilders.ts b/server-node/src/prompts/chatPromptBuilders.ts index cebcc172..6d80b488 100644 --- a/server-node/src/prompts/chatPromptBuilders.ts +++ b/server-node/src/prompts/chatPromptBuilders.ts @@ -212,6 +212,34 @@ function describeNpcConversationHistory(history: unknown, npcName: string) { : '当前聊天记录:暂无。'; } +function describeNpcCombatContext(combatContext: unknown) { + const record = asRecord(combatContext); + const summary = readString(record?.summary); + const battleOutcome = readString(record?.battleOutcome); + const logLines = readStringArray(record?.logLines).slice(0, 6); + if (!summary && logLines.length === 0) { + return null; + } + + const outcomeText = + battleOutcome === 'spar_complete' + ? '切磋刚刚结束。' + : battleOutcome === 'victory' + ? '战斗刚刚分出胜负。' + : null; + + return [ + '刚刚结束的交锋:', + outcomeText, + summary ? `- 结果摘要:${summary}` : null, + ...(logLines.length > 0 + ? ['- 战斗日志:', ...logLines.map((line) => ` - ${line}`)] + : []), + ] + .filter(Boolean) + .join('\n'); +} + function describeSceneContext(context: unknown) { const record = asRecord(context); const sceneName = readString(record?.sceneName) ?? '当前区域'; @@ -510,10 +538,12 @@ export function buildNpcChatTurnReplyPrompt( context?.firstContactRelationStance, ); const playerMessage = payload.playerMessage.trim(); + const combatContextBlock = describeNpcCombatContext(payload.combatContext); return [ buildNpcDialoguePromptBase(payload), describeNpcConversationHistory(conversationHistory, encounter.npcName), + combatContextBlock, openingCampBackground ? `营地开场背景:${openingCampBackground}` : null, openingCampDialogue ? `刚刚发生的第一段对话:${openingCampDialogue}` : null, `当前关系值:${affinity}`, @@ -574,10 +604,12 @@ export function buildNpcChatTurnSuggestionPrompt( Array.isArray(payload.conversationHistory) && payload.conversationHistory.length > 0 ? payload.conversationHistory : payload.dialogue ?? payload.conversationHistory ?? []; + const combatContextBlock = describeNpcCombatContext(payload.combatContext); return [ buildNpcDialoguePromptBase(payload), describeNpcConversationHistory(conversationHistory, encounter.npcName), + combatContextBlock, `玩家刚刚说:${payload.playerMessage}`, `NPC 刚刚回复:${npcReply}`, `请围绕刚刚这轮对话,为玩家生成 3 条下一轮可以直接说出口的中文接话短句。`, diff --git a/server-node/src/repositories/customWorldLibraryMetadata.test.ts b/server-node/src/repositories/customWorldLibraryMetadata.test.ts new file mode 100644 index 00000000..6aa3913a --- /dev/null +++ b/server-node/src/repositories/customWorldLibraryMetadata.test.ts @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { buildCustomWorldCoverImageSrc, resolveCustomWorldCoverPresentation } from './customWorldLibraryMetadata.js'; + +function createProfile() { + return { + id: 'profile-cover-test', + name: '潮雾群岛', + subtitle: '封面规则测试', + summary: '验证作品库封面优先级。', + tone: '潮湿、压抑', + playerGoal: '查明旧航道真相。', + playableNpcs: [ + { + id: 'playable-1', + name: '林潮', + imageSrc: '/images/roles/linchao.webp', + }, + ], + camp: { + imageSrc: '/images/camp/camp.webp', + }, + landmarks: [ + { + imageSrc: '/images/landmark/docks.webp', + }, + ], + sceneChapterBlueprints: [ + { + id: 'scene-chapter-1', + acts: [ + { + id: 'act-1', + backgroundImageSrc: '/images/scene/act-1.webp', + backgroundAssetId: 'asset-scene-act-1', + }, + ], + }, + ], + }; +} + +test('resolveCustomWorldCoverPresentation 优先使用开局场景第一幕图片', () => { + const profile = createProfile(); + + const result = resolveCustomWorldCoverPresentation(profile); + + assert.equal(result.imageSrc, '/images/scene/act-1.webp'); + assert.equal(result.renderMode, 'scene_with_roles'); + assert.deepEqual(result.characterImageSrcs, ['/images/roles/linchao.webp']); +}); + +test('buildCustomWorldCoverImageSrc 在第一幕图片缺失时按营地图与地标图回退', () => { + const profile = createProfile(); + profile.sceneChapterBlueprints = [ + { + id: 'scene-chapter-1', + acts: [ + { + id: 'act-1', + backgroundImageSrc: '', + backgroundAssetId: '', + }, + ], + }, + ]; + + assert.equal(buildCustomWorldCoverImageSrc(profile), '/images/camp/camp.webp'); + + profile.camp = { + imageSrc: '', + }; + + assert.equal(buildCustomWorldCoverImageSrc(profile), '/images/landmark/docks.webp'); +}); diff --git a/server-node/src/repositories/customWorldLibraryMetadata.ts b/server-node/src/repositories/customWorldLibraryMetadata.ts index 2b4ed11c..8e6d632b 100644 --- a/server-node/src/repositories/customWorldLibraryMetadata.ts +++ b/server-node/src/repositories/customWorldLibraryMetadata.ts @@ -39,7 +39,23 @@ function normalizeCoverCharacterRoleIds( return [...availableIds].slice(0, 3); } +function resolveOpeningSceneFirstActImageSrc(profile: CustomWorldProfileRecord) { + const sceneChapters = readArray(profile.sceneChapterBlueprints); + const firstSceneChapter = sceneChapters.find(isRecord) ?? null; + const firstAct = firstSceneChapter + ? readArray(firstSceneChapter.acts).find(isRecord) ?? null + : null; + + return firstAct ? readImageSrc(firstAct.backgroundImageSrc) : null; +} + function resolveOpeningSceneImageSrc(profile: CustomWorldProfileRecord) { + // 默认封面优先取开局场景第一幕图,保证创作草稿、作品库和正式结果页看到的是同一张“开场镜头”。 + const firstActImage = resolveOpeningSceneFirstActImageSrc(profile); + if (firstActImage) { + return firstActImage; + } + const campImage = isRecord(profile.camp) ? readImageSrc(profile.camp.imageSrc) : null; diff --git a/server-node/src/server.ts b/server-node/src/server.ts index e23d87f7..3c303014 100644 --- a/server-node/src/server.ts +++ b/server-node/src/server.ts @@ -13,6 +13,7 @@ import { SmsAuthEventRepository } from './repositories/smsAuthEventRepository.js import { UserRepository } from './repositories/userRepository.js'; import { UserSessionRepository } from './repositories/userSessionRepository.js'; import { CaptchaChallengeStore } from './services/captchaChallengeStore.js'; +import { CustomWorldAgentAutoAssetService } from './services/customWorldAgentAutoAssetService.js'; import { CustomWorldAgentOrchestrator } from './services/customWorldAgentOrchestrator.js'; import { CustomWorldAgentSessionStore } from './services/customWorldAgentSessionStore.js'; import { CustomWorldSessionStore } from './services/customWorldSessionStore.js'; @@ -83,6 +84,23 @@ export async function createAppContext(config: AppConfig = loadConfig()) { const customWorldAgentSessions = new CustomWorldAgentSessionStore( runtimeRepository, ); + const autoAssetService = new CustomWorldAgentAutoAssetService( + config, + config.dashScope.apiKey.trim() + ? CustomWorldAgentAutoAssetService.createDashScopeCharacterVisualGenerator( + config, + ) + : CustomWorldAgentAutoAssetService.createFallbackCharacterVisualGenerator( + config, + ), + config.dashScope.apiKey.trim() + ? CustomWorldAgentAutoAssetService.createDashScopeSceneActBackgroundGenerator( + config, + ) + : CustomWorldAgentAutoAssetService.createFallbackSceneActBackgroundGenerator( + config, + ), + ); const context: AppContext = { config, logger, @@ -102,6 +120,9 @@ export async function createAppContext(config: AppConfig = loadConfig()) { config.llm.apiKey.trim() ? new UpstreamLlmClient(config, logger) : null, + { + autoAssetService, + }, ), smsVerificationService: createSmsVerificationService(config, logger), wechatAuthService: createWechatAuthService(config, logger), diff --git a/server-node/src/services/chatService.test.ts b/server-node/src/services/chatService.test.ts index 55c02d01..51b26196 100644 --- a/server-node/src/services/chatService.test.ts +++ b/server-node/src/services/chatService.test.ts @@ -25,6 +25,14 @@ test('npc chat turn schema normalizes player and dialogue aliases', () => { text: '你刚才那句话是什么意思?', }, ], + combatContext: { + summary: '你刚和柳无声短兵相接,胜负已分,但话还没有说完。', + logLines: [ + '你侧身避开他的第一刀,反手逼退一步。', + '柳无声被逼到桌角,终于没有继续出手。', + ], + battleOutcome: 'victory', + }, playerMessage: '你能说得再明白一点吗?', npcState: { affinity: 4, @@ -60,6 +68,14 @@ test('npc chat turn schema normalizes player and dialogue aliases', () => { text: '你刚才那句话是什么意思?', }, ]); + assert.equal( + payload.combatContext?.summary, + '你刚和柳无声短兵相接,胜负已分,但话还没有说完。', + ); + assert.deepEqual(payload.combatContext?.logLines, [ + '你侧身避开他的第一刀,反手逼退一步。', + '柳无声被逼到桌角,终于没有继续出手。', + ]); assert.equal(payload.questOfferContext?.turnCount, 2); assert.equal(payload.chatDirective?.sceneActId, 'scene-inn-act-1'); assert.equal(payload.chatDirective?.remainingTurns, 3); diff --git a/server-node/src/services/chatService.ts b/server-node/src/services/chatService.ts index d5c3691d..cc7a35a7 100644 --- a/server-node/src/services/chatService.ts +++ b/server-node/src/services/chatService.ts @@ -46,6 +46,12 @@ const npcChatQuestOfferContextSchema = z.object({ turnCount: z.number().int().nonnegative(), }); +const npcChatCombatContextSchema = z.object({ + summary: z.string().trim().min(1), + logLines: z.array(z.string().trim().min(1)).default([]), + battleOutcome: z.enum(['victory', 'spar_complete']), +}); + export const characterChatReplyRequestSchema = baseCharacterChatSchema.extend({ conversationSummary: z.string().optional().default(''), playerMessage: z.string().trim().min(1), @@ -73,6 +79,7 @@ export const npcChatTurnRequestSchema = baseNpcChatSchema .extend({ conversationHistory: z.array(jsonObjectSchema).optional(), dialogue: z.array(jsonObjectSchema).optional(), + combatContext: npcChatCombatContextSchema.nullable().optional(), playerMessage: z.string().trim().min(1), npcState: jsonObjectSchema, npcInitiatesConversation: z.boolean().optional(), diff --git a/server-node/src/services/customWorldAgentAutoAssetService.test.ts b/server-node/src/services/customWorldAgentAutoAssetService.test.ts new file mode 100644 index 00000000..8f508b2c --- /dev/null +++ b/server-node/src/services/customWorldAgentAutoAssetService.test.ts @@ -0,0 +1,396 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import sharp from 'sharp'; + +import type { AppConfig } from '../config.js'; +import { CustomWorldAgentAutoAssetService } from './customWorldAgentAutoAssetService.js'; + +function createTestConfig(testName: string): AppConfig { + const projectRoot = fs.mkdtempSync( + path.join(os.tmpdir(), `genarrative-auto-assets-${testName}-`), + ); + + return { + nodeEnv: 'test', + projectRoot, + publicDir: path.join(projectRoot, 'public'), + logsDir: path.join(projectRoot, 'logs'), + dataDir: path.join(projectRoot, 'data'), + rawEnv: {}, + databaseUrl: 'pg-mem://auto-assets', + serverAddr: ':0', + logLevel: 'silent', + editorApiEnabled: true, + assetsApiEnabled: true, + jwtSecret: 'test', + jwtExpiresIn: '7d', + jwtIssuer: 'test', + llm: { + baseUrl: 'https://example.invalid', + apiKey: '', + model: 'test-model', + }, + dashScope: { + baseUrl: 'https://example.invalid', + apiKey: '', + imageModel: 'test-image-model', + requestTimeoutMs: 1000, + }, + smsAuth: { + enabled: false, + provider: 'mock', + endpoint: '', + accessKeyId: '', + accessKeySecret: '', + signName: '', + templateCode: '', + templateParamKey: '', + countryCode: '86', + schemeName: '', + codeLength: 6, + codeType: 1, + validTimeSeconds: 300, + intervalSeconds: 60, + duplicatePolicy: 1, + caseAuthPolicy: 1, + returnVerifyCode: false, + mockVerifyCode: '123456', + maxSendPerPhonePerDay: 20, + maxSendPerIpPerHour: 30, + maxVerifyFailuresPerPhonePerHour: 12, + maxVerifyFailuresPerIpPerHour: 24, + captchaTtlSeconds: 180, + captchaTriggerVerifyFailuresPerPhone: 3, + captchaTriggerVerifyFailuresPerIp: 5, + blockPhoneFailureThreshold: 6, + blockIpFailureThreshold: 10, + blockPhoneDurationMinutes: 30, + blockIpDurationMinutes: 30, + }, + wechatAuth: { + enabled: false, + provider: 'mock', + appId: '', + appSecret: '', + authorizeEndpoint: '', + accessTokenEndpoint: '', + userInfoEndpoint: '', + callbackPath: '', + defaultRedirectPath: '/', + mockUserId: '', + mockUnionId: '', + mockDisplayName: '', + mockAvatarUrl: '', + }, + authSession: { + refreshCookieName: 'refresh_token', + refreshSessionTtlDays: 30, + refreshCookieSecure: false, + refreshCookieSameSite: 'Lax', + refreshCookiePath: '/', + }, + }; +} + +test('auto asset service populates role visuals and scene act backgrounds', async () => { + const config = createTestConfig('populate'); + const service = new CustomWorldAgentAutoAssetService( + config, + CustomWorldAgentAutoAssetService.createFallbackCharacterVisualGenerator(config), + CustomWorldAgentAutoAssetService.createFallbackSceneActBackgroundGenerator(config), + ); + + const result = await service.populateDraftAssets({ + draftProfile: { + name: '雾港列岛', + subtitle: '守灯人与失序航道', + summary: '潮雾、盐火和旧航道互相绞紧的海岛世界。', + tone: '冷峻、克制、海风里带着锈味', + playerGoal: '先在旧灯塔一带站稳,再找出谁在提前布网。', + majorFactions: [], + coreConflicts: ['守灯会与沉船商盟正在争夺旧航道解释权'], + playableNpcs: [ + { + id: 'role-playable', + name: '沈砺', + title: '失职守灯人', + role: '可扮演角色', + publicIdentity: '曾经的守灯人,如今回到失序海域前线。', + currentPressure: '必须在旧友和旧职责之间重新站位。', + relationToPlayer: '玩家本人', + threadIds: ['thread-main'], + summary: '他是玩家在这次风暴里的第一视角。', + }, + ], + storyNpcs: [ + { + id: 'role-story-1', + name: '林潮', + title: '码头引路人', + role: '场景角色', + publicIdentity: '码头上最懂回潮时间的人。', + currentPressure: '决定今晚要不要让人进港。', + relationToPlayer: '先帮一把,再继续试探。', + threadIds: ['thread-main'], + summary: '他是第一幕的引路人。', + }, + ], + landmarks: [ + { + id: 'scene-dock', + name: '潮汐码头', + purpose: '承接第一章的主要碰撞。', + mood: '潮声压低,封锁正在加重。', + importance: '这里是玩家开局必须接住的门槛。', + characterIds: ['role-story-1'], + threadIds: ['thread-main'], + summary: '码头上的第一次碰撞会直接决定后续节奏。', + }, + ], + factions: [], + threads: [ + { + id: 'thread-main', + title: '旧航道争夺', + type: 'main', + conflict: '守灯会与沉船商盟正在争夺旧航道解释权', + characterIds: ['role-playable', 'role-story-1'], + landmarkIds: ['scene-dock'], + summary: '整条主线都围绕旧航道解释权改写展开。', + }, + ], + chapters: [], + sceneChapters: [ + { + id: 'scene-chapter-dock', + sceneId: 'scene-dock', + sceneName: '潮汐码头', + title: '潮汐码头章节', + summary: '三幕推进码头章节。', + linkedThreadIds: ['thread-main'], + linkedLandmarkIds: ['scene-dock'], + acts: [ + { + id: 'dock-act-1', + title: '雾里靠岸', + summary: '先由林潮把玩家带进港口节拍。', + stageCoverage: ['opening'], + backgroundImageSrc: null, + backgroundAssetId: null, + encounterNpcIds: ['role-story-1', 'role-playable'], + primaryNpcId: 'role-story-1', + linkedThreadIds: ['thread-main'], + actGoal: '接住第一幕入口压力', + transitionHook: '下一幕开始会有人继续封锁码头。', + advanceRule: 'after_primary_contact', + }, + { + id: 'dock-act-2', + title: '封锁加压', + summary: '第二幕把封锁真正抬上台面。', + stageCoverage: ['expansion', 'turning_point'], + backgroundImageSrc: null, + backgroundAssetId: null, + encounterNpcIds: ['role-story-1', 'role-playable'], + primaryNpcId: 'role-story-1', + linkedThreadIds: ['thread-main'], + actGoal: '把冲突推高', + transitionHook: '第三幕要把下一跳抛给玩家。', + advanceRule: 'after_active_step_complete', + }, + { + id: 'dock-act-3', + title: '潮线收束', + summary: '第三幕负责把这章收住。', + stageCoverage: ['climax', 'aftermath'], + backgroundImageSrc: null, + backgroundAssetId: null, + encounterNpcIds: ['role-story-1', 'role-playable'], + primaryNpcId: 'role-story-1', + linkedThreadIds: ['thread-main'], + actGoal: '完成章节收束', + transitionHook: '把下一跳交给玩家。', + advanceRule: 'after_chapter_resolution', + }, + ], + }, + ], + worldHook: '雾港列岛', + playerPremise: '被迫返乡的失职守灯人', + openingSituation: '玩家正站在即将熄灭的旧灯塔上。', + iconicElements: ['潮雾钟声', '盐火灯塔'], + sourceAnchorSummary: '海岛悬疑,冷峻克制。', + }, + }); + + assert.equal(result.assetCoverage.allRoleAssetsReady, true); + assert.equal(result.assetCoverage.allSceneAssetsReady, true); + assert.equal(result.assetCoverage.sceneAssets.length, 3); + assert.deepEqual(result.warnings, []); + assert.ok( + result.draftProfile.playableNpcs.every( + (role) => typeof role.imageSrc === 'string' && typeof role.generatedVisualAssetId === 'string', + ), + ); + assert.ok( + result.draftProfile.playableNpcs.every((role) => + role.imageSrc?.endsWith('.png') ?? false, + ), + ); + const playableImageSrc = result.draftProfile.playableNpcs[0]?.imageSrc; + assert.ok(playableImageSrc); + const playableImageMetadata = await sharp( + path.join(config.publicDir, playableImageSrc.replace(/^\/+/u, '')), + ).metadata(); + assert.equal(playableImageMetadata.width, 1024); + assert.equal(playableImageMetadata.height, 1024); + assert.ok( + result.draftProfile.sceneChapters.every((chapter) => + chapter.acts.every( + (act) => + typeof act.backgroundImageSrc === 'string' && + typeof act.backgroundAssetId === 'string', + ), + ), + ); + assert.ok( + result.draftProfile.sceneChapters.every((chapter) => + chapter.acts.every((act) => act.backgroundImageSrc?.endsWith('.png') ?? false), + ), + ); +}); + +test('auto asset service degrades gracefully when asset generators fail', async () => { + const config = createTestConfig('degrade'); + const service = new CustomWorldAgentAutoAssetService( + config, + async () => { + throw new Error('visual generator unavailable'); + }, + async () => { + throw new Error('scene generator unavailable'); + }, + ); + + const result = await service.populateDraftAssets({ + draftProfile: { + name: '雾港列岛', + subtitle: '守灯人与失序航道', + summary: '潮雾、盐火和旧航道互相绞紧的海岛世界。', + tone: '冷峻、克制、海风里带着锈味', + playerGoal: '先在旧灯塔一带站稳,再找出谁在提前布网。', + majorFactions: [], + coreConflicts: ['守灯会与沉船商盟正在争夺旧航道解释权'], + playableNpcs: [ + { + id: 'role-playable', + name: '沈砺', + title: '失职守灯人', + role: '可扮演角色', + publicIdentity: '曾经的守灯人,如今回到失序海域前线。', + currentPressure: '必须在旧友和旧职责之间重新站位。', + relationToPlayer: '玩家本人', + threadIds: ['thread-main'], + summary: '他是玩家在这次风暴里的第一视角。', + }, + ], + storyNpcs: [], + landmarks: [ + { + id: 'scene-dock', + name: '潮汐码头', + purpose: '承接第一章的主要碰撞。', + mood: '潮声压低,封锁正在加重。', + importance: '这里是玩家开局必须接住的门槛。', + characterIds: ['role-playable'], + threadIds: ['thread-main'], + summary: '码头上的第一次碰撞会直接决定后续节奏。', + }, + ], + factions: [], + threads: [ + { + id: 'thread-main', + title: '旧航道争夺', + type: 'main', + conflict: '守灯会与沉船商盟正在争夺旧航道解释权', + characterIds: ['role-playable'], + landmarkIds: ['scene-dock'], + summary: '整条主线都围绕旧航道解释权改写展开。', + }, + ], + chapters: [], + sceneChapters: [ + { + id: 'scene-chapter-dock', + sceneId: 'scene-dock', + sceneName: '潮汐码头', + title: '潮汐码头章节', + summary: '单章测试。', + linkedThreadIds: ['thread-main'], + linkedLandmarkIds: ['scene-dock'], + acts: [ + { + id: 'dock-act-1', + title: '雾里靠岸', + summary: '先接住入口。', + stageCoverage: ['opening'], + backgroundImageSrc: null, + backgroundAssetId: null, + encounterNpcIds: ['role-playable'], + primaryNpcId: 'role-playable', + linkedThreadIds: ['thread-main'], + actGoal: '接住入口压力', + transitionHook: '继续推进。', + advanceRule: 'after_primary_contact', + }, + { + id: 'dock-act-2', + title: '封锁加压', + summary: '继续抬高冲突。', + stageCoverage: ['turning_point'], + backgroundImageSrc: null, + backgroundAssetId: null, + encounterNpcIds: ['role-playable'], + primaryNpcId: 'role-playable', + linkedThreadIds: ['thread-main'], + actGoal: '继续推进', + transitionHook: '继续推进。', + advanceRule: 'after_active_step_complete', + }, + ], + }, + ], + worldHook: '雾港列岛', + playerPremise: '被迫返乡的失职守灯人', + openingSituation: '玩家正站在即将熄灭的旧灯塔上。', + iconicElements: ['潮雾钟声'], + sourceAnchorSummary: '海岛悬疑,冷峻克制。', + }, + }); + + assert.equal(result.assetCoverage.allRoleAssetsReady, true); + assert.equal(result.assetCoverage.allSceneAssetsReady, true); + assert.deepEqual(result.warnings, []); + assert.ok( + result.draftProfile.playableNpcs.every((role) => + role.imageSrc?.endsWith('.png') ?? false, + ), + ); + const fallbackPlayableImageSrc = result.draftProfile.playableNpcs[0]?.imageSrc; + assert.ok(fallbackPlayableImageSrc); + const fallbackPlayableImageMetadata = await sharp( + path.join(config.publicDir, fallbackPlayableImageSrc.replace(/^\/+/u, '')), + ).metadata(); + assert.equal(fallbackPlayableImageMetadata.width, 1024); + assert.equal(fallbackPlayableImageMetadata.height, 1024); + assert.ok( + result.draftProfile.sceneChapters.every((chapter) => + chapter.acts.every((act) => act.backgroundImageSrc?.endsWith('.png') ?? false), + ), + ); +}); diff --git a/server-node/src/services/customWorldAgentAutoAssetService.ts b/server-node/src/services/customWorldAgentAutoAssetService.ts new file mode 100644 index 00000000..14941faf --- /dev/null +++ b/server-node/src/services/customWorldAgentAutoAssetService.ts @@ -0,0 +1,771 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +import sharp from 'sharp'; + +import type { + CustomWorldAssetCoverageSummary, + CustomWorldFoundationDraftCharacter, + CustomWorldFoundationDraftProfile, + CustomWorldFoundationDraftSceneAct, + CustomWorldSceneAssetSummary, +} from '../../../packages/shared/src/contracts/customWorldAgent.js'; +import { + buildNpcVisualNegativePrompt, + buildNpcVisualPrompt, +} from '../prompts/characterAssetPrompts.js'; +import type { AppConfig } from '../config.js'; + +type DraftProgressPayload = { + phaseLabel: string; + phaseDetail: string; + progress: number; +}; + +type DraftProgressCallback = ( + payload: DraftProgressPayload, +) => void | Promise; + +export type CharacterVisualGenerator = (params: { + role: CustomWorldFoundationDraftCharacter; + draftProfile: CustomWorldFoundationDraftProfile; +}) => Promise<{ + imageSrc: string; + generatedVisualAssetId: string; +}>; + +export type SceneActBackgroundGenerator = (params: { + draftProfile: CustomWorldFoundationDraftProfile; + sceneName: string; + act: CustomWorldFoundationDraftSceneAct; + primaryRoleName: string; + supportRoleNames: string[]; +}) => Promise<{ + imageSrc: string; + assetId: string; +}>; + +function toText(value: unknown) { + return typeof value === 'string' ? value.trim() : ''; +} + +function sanitizeSegment(value: string, fallback: string) { + const normalized = value + .trim() + .replace(/[^\w\u4e00-\u9fa5-]+/gu, '-') + .replace(/^-+|-+$/gu, '') + .slice(0, 48); + + return normalized || fallback; +} + +function normalizeDashScopeBaseUrl(value: string) { + return value.replace(/\/+$/u, ''); +} + +function createGeneratedAssetId(prefix: string) { + return `${prefix}-${Date.now().toString(36)}-${crypto.randomBytes(3).toString('hex')}`; +} + +async function writePlaceholderPng(params: { + outputPath: string; + width: number; + height: number; + rgb: [number, number, number]; +}) { + const [r, g, b] = params.rgb; + await sharp({ + create: { + width: params.width, + height: params.height, + channels: 3, + background: { r, g, b }, + }, + }) + .png() + .toFile(params.outputPath); +} + +function collectStringsByKey( + value: unknown, + targetKey: string, + results: string[], +) { + if (typeof value === 'string') { + return; + } + + if (Array.isArray(value)) { + value.forEach((entry) => collectStringsByKey(entry, targetKey, results)); + return; + } + + if (!value || typeof value !== 'object') { + return; + } + + Object.entries(value).forEach(([key, nestedValue]) => { + if ( + key === targetKey && + typeof nestedValue === 'string' && + nestedValue.trim() + ) { + results.push(nestedValue.trim()); + return; + } + + collectStringsByKey(nestedValue, targetKey, results); + }); +} + +function findFirstStringByKey(value: unknown, targetKey: string) { + const results: string[] = []; + collectStringsByKey(value, targetKey, results); + return results[0] ?? ''; +} + +function extractTaskId(payload: Record) { + return findFirstStringByKey(payload, 'task_id'); +} + +function extractImageUrls(payload: Record) { + const urls: string[] = []; + collectStringsByKey(payload, 'image', urls); + collectStringsByKey(payload, 'url', urls); + return [...new Set(urls)]; +} + +function buildRoleVisualSeedText( + role: CustomWorldFoundationDraftCharacter, + draftProfile: CustomWorldFoundationDraftProfile, +) { + return [ + `世界:${draftProfile.name}`, + `世界摘要:${draftProfile.summary}`, + `角色名:${role.name}`, + `称号:${role.title}`, + `身份:${role.role}`, + `公开身份:${role.publicIdentity}`, + role.publicMask ? `第一印象:${role.publicMask}` : '', + `当前压力:${role.currentPressure}`, + role.hiddenHook ? `隐藏钩子:${role.hiddenHook}` : '', + `与玩家关系:${role.relationToPlayer}`, + `角色摘要:${role.summary}`, + ] + .filter(Boolean) + .join('\n'); +} + +async function createFallbackCharacterVisual(params: { + config: AppConfig; + role: CustomWorldFoundationDraftCharacter; +}) { + const assetId = createGeneratedAssetId('draft-role-visual'); + const roleSegment = sanitizeSegment(params.role.id || params.role.name, 'role'); + const relativeDir = path.join( + 'generated-characters', + roleSegment, + 'visual', + assetId, + ); + const outputDir = path.join(params.config.publicDir, relativeDir); + + fs.mkdirSync(outputDir, { recursive: true }); + const fileName = 'master.png'; + const filePath = path.join(outputDir, fileName); + await writePlaceholderPng({ + outputPath: filePath, + width: 1024, + height: 1024, + rgb: [78, 134, 220], + }); + + return { + imageSrc: `/${relativeDir.replace(/\\/gu, '/')}/${fileName}`, + generatedVisualAssetId: assetId, + }; +} + +function buildSceneActPrompt(params: { + draftProfile: CustomWorldFoundationDraftProfile; + sceneName: string; + act: CustomWorldFoundationDraftSceneAct; + primaryRoleName: string; + supportRoleNames: string[]; +}) { + return [ + `这是世界《${params.draftProfile.name}》中的场景幕背景图。`, + `场景:${params.sceneName}`, + `幕标题:${params.act.title}`, + `幕摘要:${params.act.summary}`, + `幕目标:${params.act.actGoal}`, + `过渡钩子:${params.act.transitionHook}`, + `主角色:${params.primaryRoleName || '待补主角色'}`, + params.supportRoleNames.length > 0 + ? `辅助角色:${params.supportRoleNames.join('、')}` + : '', + `世界气质:${params.draftProfile.tone}`, + `要求:只生成环境背景,不出现角色立绘、站位 UI、对白框、按钮或文字。`, + ] + .filter(Boolean) + .join('\n'); +} + +async function createDashScopeTextToImageTask(params: { + config: AppConfig; + prompt: string; + negativePrompt?: string; + size: string; + model: string; +}) { + const response = await fetch( + `${normalizeDashScopeBaseUrl(params.config.dashScope.baseUrl)}/services/aigc/text2image/image-synthesis`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${params.config.dashScope.apiKey}`, + 'Content-Type': 'application/json', + 'X-DashScope-Async': 'enable', + }, + body: JSON.stringify({ + model: params.model, + input: { + prompt: params.prompt, + ...(params.negativePrompt + ? { negative_prompt: params.negativePrompt } + : {}), + }, + parameters: { + n: 1, + size: params.size, + prompt_extend: true, + watermark: false, + }, + }), + }, + ); + const responseText = await response.text(); + + if (!response.ok) { + throw new Error(responseText || '创建图像生成任务失败。'); + } + + const payload = JSON.parse(responseText) as Record; + const taskId = extractTaskId(payload); + if (!taskId) { + throw new Error('图像生成任务未返回 task_id。'); + } + + return taskId; +} + +async function waitForDashScopeImage(params: { + config: AppConfig; + taskId: string; +}) { + const deadline = Date.now() + params.config.dashScope.requestTimeoutMs; + const baseUrl = normalizeDashScopeBaseUrl(params.config.dashScope.baseUrl); + + while (Date.now() < deadline) { + const pollResponse = await fetch(`${baseUrl}/tasks/${params.taskId}`, { + headers: { + Authorization: `Bearer ${params.config.dashScope.apiKey}`, + }, + }); + const pollText = await pollResponse.text(); + if (!pollResponse.ok) { + throw new Error(pollText || '查询图像生成任务失败。'); + } + + const pollPayload = JSON.parse(pollText) as Record; + const status = findFirstStringByKey(pollPayload, 'task_status').trim(); + if (status === 'SUCCEEDED') { + const imageUrl = extractImageUrls(pollPayload)[0] ?? ''; + const actualPrompt = findFirstStringByKey(pollPayload, 'actual_prompt').trim(); + if (!imageUrl) { + throw new Error('图像生成任务成功,但未返回图片地址。'); + } + + return { + imageUrl, + actualPrompt, + }; + } + + if (status === 'FAILED' || status === 'UNKNOWN') { + throw new Error(pollText || '图像生成任务失败。'); + } + + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + + throw new Error('图像生成任务超时。'); +} + +async function saveRemoteImage(params: { + config: AppConfig; + imageUrl: string; + relativeDir: string; + fileBaseName: string; + manifest: Record; +}) { + const response = await fetch(params.imageUrl); + if (!response.ok) { + throw new Error('下载生成图片失败。'); + } + + const buffer = Buffer.from(await response.arrayBuffer()); + const contentType = response.headers.get('content-type') || ''; + const extension = contentType.includes('png') + ? 'png' + : contentType.includes('webp') + ? 'webp' + : 'jpg'; + const outputDir = path.join(params.config.publicDir, params.relativeDir); + fs.mkdirSync(outputDir, { recursive: true }); + const fileName = `${params.fileBaseName}.${extension}`; + const filePath = path.join(outputDir, fileName); + + fs.writeFileSync(filePath, buffer); + fs.writeFileSync( + path.join(outputDir, 'manifest.json'), + `${JSON.stringify(params.manifest, null, 2)}\n`, + 'utf8', + ); + + return `/${path.join(params.relativeDir, fileName).replace(/\\/gu, '/')}`; +} + +function findRoleById( + draftProfile: CustomWorldFoundationDraftProfile, + roleId: string, +) { + return [...draftProfile.playableNpcs, ...draftProfile.storyNpcs].find( + (role) => role.id === roleId, + ); +} + +export class CustomWorldAgentAutoAssetService { + constructor( + private readonly config: AppConfig | null = null, + private readonly characterVisualGenerator?: CharacterVisualGenerator | null, + private readonly sceneActBackgroundGenerator?: SceneActBackgroundGenerator | null, + ) {} + + async populateDraftAssets(params: { + draftProfile: CustomWorldFoundationDraftProfile; + onProgress?: DraftProgressCallback; + }): Promise<{ + draftProfile: CustomWorldFoundationDraftProfile; + assetCoverage: CustomWorldAssetCoverageSummary; + warnings: string[]; + }> { + const nextDraftProfile: CustomWorldFoundationDraftProfile = JSON.parse( + JSON.stringify(params.draftProfile), + ) as CustomWorldFoundationDraftProfile; + const roles = [...nextDraftProfile.playableNpcs, ...nextDraftProfile.storyNpcs]; + const sceneAssetSummaries: CustomWorldSceneAssetSummary[] = []; + const warnings: string[] = []; + const totalRoleCount = roles.length; + const totalActCount = nextDraftProfile.sceneChapters.reduce( + (sum, chapter) => sum + chapter.acts.length, + 0, + ); + let completedRoleCount = 0; + let completedActCount = 0; + + for (const role of roles) { + if (!role.imageSrc || !role.generatedVisualAssetId) { + try { + const generatedVisual = this.characterVisualGenerator + ? await this.characterVisualGenerator({ + role, + draftProfile: nextDraftProfile, + }) + : this.config + ? await createFallbackCharacterVisual({ + config: this.config, + role, + }) + : null; + + if (generatedVisual) { + role.imageSrc = generatedVisual.imageSrc; + role.generatedVisualAssetId = generatedVisual.generatedVisualAssetId; + } + } catch (error) { + try { + const fallbackVisual = this.config + ? await createFallbackCharacterVisual({ + config: this.config, + role, + }) + : null; + if (fallbackVisual) { + role.imageSrc = fallbackVisual.imageSrc; + role.generatedVisualAssetId = + fallbackVisual.generatedVisualAssetId; + } else { + warnings.push( + `角色主形象生成失败:${role.name}(${error instanceof Error ? error.message : 'unknown error'})`, + ); + } + } catch (fallbackError) { + // 角色主形象属于增强链路,主生成与回退都失败时仅记录告警,不阻断世界底稿主链。 + warnings.push( + `角色主形象生成失败:${role.name}(${fallbackError instanceof Error ? fallbackError.message : error instanceof Error ? error.message : 'unknown error'})`, + ); + } + } + } + + completedRoleCount += 1; + if (params.onProgress) { + await params.onProgress({ + phaseLabel: '生成角色主形象', + phaseDetail: `正在生成角色主形象 ${completedRoleCount}/${totalRoleCount}:${role.name}。`, + progress: + 97 + + Math.min( + 1, + Math.round((completedRoleCount / Math.max(1, totalRoleCount)) * 1), + ), + }); + } + } + + for (const sceneChapter of nextDraftProfile.sceneChapters) { + for (const act of sceneChapter.acts) { + let imageSrc = toText(act.backgroundImageSrc) || null; + let assetId = toText(act.backgroundAssetId) || null; + const primaryRole = findRoleById( + nextDraftProfile, + act.primaryNpcId || act.encounterNpcIds[0] || '', + ); + const supportRoleNames = act.encounterNpcIds + .slice(1) + .map((roleId) => findRoleById(nextDraftProfile, roleId)?.name || '') + .filter(Boolean); + if (!imageSrc && this.sceneActBackgroundGenerator) { + try { + const result = await this.sceneActBackgroundGenerator({ + draftProfile: nextDraftProfile, + sceneName: sceneChapter.sceneName, + act, + primaryRoleName: primaryRole?.name || '', + supportRoleNames, + }); + imageSrc = result.imageSrc; + assetId = result.assetId; + act.backgroundImageSrc = result.imageSrc; + act.backgroundAssetId = result.assetId; + } catch (error) { + try { + const fallbackScene = this.config + ? await CustomWorldAgentAutoAssetService.createFallbackSceneActBackgroundGenerator( + this.config, + )({ + draftProfile: nextDraftProfile, + sceneName: sceneChapter.sceneName, + act, + primaryRoleName: primaryRole?.name || '', + supportRoleNames, + }) + : null; + if (fallbackScene) { + imageSrc = fallbackScene.imageSrc; + assetId = fallbackScene.assetId; + act.backgroundImageSrc = fallbackScene.imageSrc; + act.backgroundAssetId = fallbackScene.assetId; + } else { + warnings.push( + `幕背景图生成失败:${sceneChapter.sceneName} / ${act.title}(${error instanceof Error ? error.message : 'unknown error'})`, + ); + } + } catch (fallbackError) { + // 幕图失败允许草稿继续生成;只有主生成与回退都失败时才保留缺口告警。 + warnings.push( + `幕背景图生成失败:${sceneChapter.sceneName} / ${act.title}(${fallbackError instanceof Error ? fallbackError.message : error instanceof Error ? error.message : 'unknown error'})`, + ); + } + } + } + + sceneAssetSummaries.push({ + sceneId: sceneChapter.sceneId, + sceneName: sceneChapter.sceneName, + actId: act.id, + actTitle: act.title, + imageSrc, + assetId, + status: imageSrc ? 'ready' : 'missing', + nextPointCost: imageSrc ? 0 : 12, + }); + + completedActCount += 1; + if (params.onProgress) { + await params.onProgress({ + phaseLabel: '生成幕背景图', + phaseDetail: `正在生成幕背景图 ${completedActCount}/${totalActCount}:${sceneChapter.sceneName} · ${act.title}。`, + progress: + 98 + + Math.min( + 1, + Math.round((completedActCount / Math.max(1, totalActCount)) * 1), + ), + }); + } + } + } + + const roleAssets = roles.map((role) => ({ + roleId: role.id, + roleName: role.name, + roleKind: nextDraftProfile.playableNpcs.some((entry) => entry.id === role.id) + ? ('playable' as const) + : ('story' as const), + priorityTier: nextDraftProfile.playableNpcs.some((entry) => entry.id === role.id) + ? ('hero' as const) + : ('featured' as const), + portraitPath: role.imageSrc || null, + generatedVisualAssetId: role.generatedVisualAssetId || null, + generatedAnimationSetId: role.generatedAnimationSetId || null, + status: role.imageSrc && role.generatedVisualAssetId ? 'visual_ready' : 'missing', + missingAnimations: [], + nextPointCost: role.imageSrc && role.generatedVisualAssetId ? 0 : 20, + })); + + return { + draftProfile: nextDraftProfile, + assetCoverage: { + roleAssets, + sceneAssets: sceneAssetSummaries, + allRoleAssetsReady: + roleAssets.length > 0 && + roleAssets.every((entry) => entry.status !== 'missing'), + allSceneAssetsReady: + sceneAssetSummaries.length > 0 && + sceneAssetSummaries.every((entry) => entry.status === 'ready'), + }, + warnings, + }; + } + + static createFallbackCharacterVisualGenerator(config: AppConfig): CharacterVisualGenerator { + return async ({ role, draftProfile }) => { + const assetId = createGeneratedAssetId('draft-role-visual'); + const roleSegment = sanitizeSegment(role.id || role.name, 'role'); + const relativeDir = path.join( + 'generated-characters', + roleSegment, + 'visual', + assetId, + ); + const outputDir = path.join(config.publicDir, relativeDir); + + fs.mkdirSync(outputDir, { recursive: true }); + const fileName = 'master.png'; + await writePlaceholderPng({ + outputPath: path.join(outputDir, fileName), + width: 1024, + height: 1024, + rgb: [78, 134, 220], + }); + const finalPrompt = buildNpcVisualPrompt( + buildRoleVisualSeedText(role, draftProfile), + ); + fs.writeFileSync( + path.join(outputDir, 'manifest.json'), + `${JSON.stringify( + { + assetId, + roleId: role.id, + roleName: role.name, + prompt: finalPrompt, + fallback: true, + createdAt: new Date().toISOString(), + }, + null, + 2, + )}\n`, + 'utf8', + ); + + return { + imageSrc: `/${relativeDir.replace(/\\/gu, '/')}/${fileName}`, + generatedVisualAssetId: assetId, + }; + }; + } + + static createDashScopeCharacterVisualGenerator( + config: AppConfig, + ): CharacterVisualGenerator { + return async ({ role, draftProfile }) => { + const prompt = buildNpcVisualPrompt( + buildRoleVisualSeedText(role, draftProfile), + ); + const assetId = `draft-role-visual-${Date.now().toString(36)}`; + const roleSegment = sanitizeSegment(role.id || role.name, 'role'); + const taskId = await createDashScopeTextToImageTask({ + config, + prompt, + negativePrompt: buildNpcVisualNegativePrompt(), + size: '1024*1024', + model: config.dashScope.imageModel || 'qwen-image-2.0', + }); + const { imageUrl, actualPrompt } = await waitForDashScopeImage({ + config, + taskId, + }); + const relativeDir = path.join( + 'generated-characters', + roleSegment, + 'visual', + assetId, + ); + const imageSrc = await saveRemoteImage({ + config, + imageUrl, + relativeDir, + fileBaseName: 'master', + manifest: { + assetId, + taskId, + roleId: role.id, + roleName: role.name, + prompt, + actualPrompt, + createdAt: new Date().toISOString(), + }, + }); + + return { + imageSrc, + generatedVisualAssetId: assetId, + }; + }; + } + + static createFallbackSceneActBackgroundGenerator( + config: AppConfig, + ): SceneActBackgroundGenerator { + return async ({ + draftProfile, + sceneName, + act, + primaryRoleName, + supportRoleNames, + }) => { + const finalPrompt = buildSceneActPrompt({ + draftProfile, + sceneName, + act, + primaryRoleName, + supportRoleNames, + }); + const assetId = createGeneratedAssetId('draft-scene-act'); + const sceneSegment = sanitizeSegment(act.sceneId || sceneName, 'scene'); + const actSegment = sanitizeSegment(act.id || act.title, 'act'); + const relativeDir = path.join( + 'generated-custom-world-scenes', + sceneSegment, + actSegment, + assetId, + ); + const outputDir = path.join(config.publicDir, relativeDir); + + fs.mkdirSync(outputDir, { recursive: true }); + const fileName = 'scene.png'; + await writePlaceholderPng({ + outputPath: path.join(outputDir, fileName), + width: 1280, + height: 720, + rgb: [34, 52, 88], + }); + fs.writeFileSync( + path.join(outputDir, 'manifest.json'), + `${JSON.stringify( + { + assetId, + sceneName, + actId: act.id, + actTitle: act.title, + prompt: finalPrompt, + fallback: true, + createdAt: new Date().toISOString(), + }, + null, + 2, + )}\n`, + 'utf8', + ); + + return { + imageSrc: `/${relativeDir.replace(/\\/gu, '/')}/${fileName}`, + assetId, + }; + }; + } + + static createDashScopeSceneActBackgroundGenerator( + config: AppConfig, + ): SceneActBackgroundGenerator { + return async ({ + draftProfile, + sceneName, + act, + primaryRoleName, + supportRoleNames, + }) => { + const prompt = buildSceneActPrompt({ + draftProfile, + sceneName, + act, + primaryRoleName, + supportRoleNames, + }); + const assetId = createGeneratedAssetId('draft-scene-act'); + const sceneSegment = sanitizeSegment(act.sceneId || sceneName, 'scene'); + const actSegment = sanitizeSegment(act.id || act.title, 'act'); + const taskId = await createDashScopeTextToImageTask({ + config, + prompt, + size: '1280*720', + model: config.dashScope.imageModel || 'wan2.2-t2i-flash', + }); + const { imageUrl, actualPrompt } = await waitForDashScopeImage({ + config, + taskId, + }); + const relativeDir = path.join( + 'generated-custom-world-scenes', + sceneSegment, + actSegment, + assetId, + ); + const imageSrc = await saveRemoteImage({ + config, + imageUrl, + relativeDir, + fileBaseName: 'scene', + manifest: { + assetId, + taskId, + sceneName, + actId: act.id, + actTitle: act.title, + prompt, + actualPrompt, + createdAt: new Date().toISOString(), + }, + }); + + return { + imageSrc, + assetId, + }; + }; + } +} diff --git a/server-node/src/services/customWorldAgentDraftCompiler.ts b/server-node/src/services/customWorldAgentDraftCompiler.ts index 648fd663..6911e061 100644 --- a/server-node/src/services/customWorldAgentDraftCompiler.ts +++ b/server-node/src/services/customWorldAgentDraftCompiler.ts @@ -1024,8 +1024,8 @@ function buildWorldWarnings(profile: CustomWorldFoundationDraftProfile) { if (totalCharacters < 3) { warnings.push('关键角色数量还偏少,建议继续补角色关系网。'); } - if (profile.landmarks.length < 4) { - warnings.push('关键地点仍然偏少,第一版游历路径还不够饱满。'); + if (profile.landmarks.length < 2) { + warnings.push('关键地点仍然偏少,第一版场景章节还不够饱满。'); } return warnings; } diff --git a/server-node/src/services/customWorldAgentFoundationDraftService.ts b/server-node/src/services/customWorldAgentFoundationDraftService.ts index 4919c7fd..b13a1896 100644 --- a/server-node/src/services/customWorldAgentFoundationDraftService.ts +++ b/server-node/src/services/customWorldAgentFoundationDraftService.ts @@ -4,6 +4,7 @@ import type { CustomWorldFoundationDraftFaction, CustomWorldFoundationDraftLandmark, CustomWorldFoundationDraftProfile, + CustomWorldFoundationDraftSceneChapter, CustomWorldFoundationDraftThread, EightAnchorContent, } from '../../../packages/shared/src/contracts/customWorldAgent.js'; @@ -575,10 +576,25 @@ function buildCharacters(params: { return dedupeStrings( characters.map((entry) => entry.name), - 5, + FOUNDATION_DRAFT_PLAYABLE_COUNT + FOUNDATION_DRAFT_STORY_COUNT, ).map((name) => characters.find((entry) => entry.name === name)!); } +function splitDraftCharacters(params: { + characters: CustomWorldFoundationDraftCharacter[]; + playableCount: number; + storyCount: number; +}) { + const playableNpcs = params.characters.slice(0, params.playableCount); + const storyNpcs = params.characters + .slice(params.playableCount, params.playableCount + params.storyCount); + + return { + playableNpcs, + storyNpcs, + }; +} + function buildCamp(params: { openingSituation: string; worldHook: string; @@ -776,9 +792,9 @@ function buildChapter(params: { }; } -const FOUNDATION_DRAFT_PLAYABLE_COUNT = 3; -const FOUNDATION_DRAFT_STORY_COUNT = 6; -const FOUNDATION_DRAFT_LANDMARK_COUNT = 4; +const FOUNDATION_DRAFT_PLAYABLE_COUNT = 1; +const FOUNDATION_DRAFT_STORY_COUNT = 8; +const FOUNDATION_DRAFT_LANDMARK_COUNT = 2; const FOUNDATION_ROLE_OUTLINE_BATCH_SIZE = 2; const FOUNDATION_LANDMARK_BATCH_SIZE = 2; const FOUNDATION_ROLE_DETAIL_BATCH_SIZE = 2; @@ -798,6 +814,153 @@ type MergeableNamedRecord = { name: string; }; +function buildFallbackSceneActStageCoverage(index: number, actCount: number) { + if (actCount <= 2) { + return index === 0 + ? (['opening', 'expansion'] as const) + : (['turning_point', 'climax', 'aftermath'] as const); + } + + if (actCount === 3) { + return index === 0 + ? (['opening'] as const) + : index === 1 + ? (['expansion', 'turning_point'] as const) + : (['climax', 'aftermath'] as const); + } + + if (actCount === 4) { + return index === 0 + ? (['opening'] as const) + : index === 1 + ? (['expansion'] as const) + : index === 2 + ? (['turning_point'] as const) + : (['climax', 'aftermath'] as const); + } + + return ( + [ + ['opening'], + ['expansion'], + ['turning_point'], + ['climax'], + ['aftermath'], + ][index] ?? ['aftermath'] + ) as readonly string[]; +} + +function buildSceneChaptersFromDraft(params: { + landmarks: CustomWorldFoundationDraftLandmark[]; + playableNpcs: CustomWorldFoundationDraftCharacter[]; + storyNpcs: CustomWorldFoundationDraftCharacter[]; + threads: CustomWorldFoundationDraftThread[]; +}): CustomWorldFoundationDraftSceneChapter[] { + const leadPlayable = params.playableNpcs[0] ?? null; + const sceneRoles = params.storyNpcs; + + return params.landmarks.slice(0, FOUNDATION_DRAFT_LANDMARK_COUNT).map((landmark, index) => { + const linkedThreadIds = + landmark.threadIds.length > 0 + ? landmark.threadIds.slice(0, 3) + : params.threads + .filter((thread) => thread.landmarkIds.includes(landmark.id)) + .map((thread) => thread.id) + .slice(0, 3); + const baseNpcIds = landmark.characterIds.length > 0 + ? landmark.characterIds + : sceneRoles.slice(index * 3, index * 3 + 3).map((role) => role.id); + const uniqueNpcIds = [...new Set(baseNpcIds)].filter(Boolean); + const primaryIds = uniqueNpcIds.slice(0, 3); + const fallbackPrimaryIds = sceneRoles + .filter((role) => !primaryIds.includes(role.id)) + .slice(0, 3 - primaryIds.length) + .map((role) => role.id); + const actPrimaryIds = [...primaryIds, ...fallbackPrimaryIds].slice(0, 3); + const supportPool = [ + ...uniqueNpcIds, + ...sceneRoles.map((role) => role.id), + ...(leadPlayable ? [leadPlayable.id] : []), + ].filter(Boolean); + + const acts = actPrimaryIds.map((primaryNpcId, actIndex) => { + const supportIds = supportPool.filter((roleId) => roleId !== primaryNpcId); + const orderedEncounterNpcIds = [ + primaryNpcId, + ...supportIds.slice(0, 2), + ]; + const primaryRole = + sceneRoles.find((role) => role.id === primaryNpcId) ?? leadPlayable; + const supportRoles = orderedEncounterNpcIds + .slice(1) + .map((roleId) => + sceneRoles.find((role) => role.id === roleId) ?? + (leadPlayable?.id === roleId ? leadPlayable : null), + ) + .filter((role): role is CustomWorldFoundationDraftCharacter => Boolean(role)); + + return { + id: `${landmark.id}-act-${actIndex + 1}`, + title: + actIndex === 0 + ? `${landmark.name}起势` + : actIndex === 1 + ? `${landmark.name}承压` + : `${landmark.name}收束`, + summary: clampText( + [ + actIndex === 0 + ? `这一幕先由${primaryRole?.name || '主角色'}把玩家带进${landmark.name}的当前压力。` + : actIndex === 1 + ? `${primaryRole?.name || '主角色'}会把${landmark.name}的冲突真正抬上台面。` + : `${primaryRole?.name || '主角色'}会负责把这一章收束并抛出下一跳。`, + landmark.summary, + ].join(' '), + 120, + ), + stageCoverage: buildFallbackSceneActStageCoverage(actIndex, 3), + backgroundImageSrc: null, + backgroundAssetId: null, + encounterNpcIds: orderedEncounterNpcIds, + primaryNpcId, + linkedThreadIds, + actGoal: + actIndex === 0 + ? `让玩家先接住${landmark.name}的入口压力` + : actIndex === 1 + ? `把${landmark.name}的冲突推到不可回避` + : `把${landmark.name}这一章收住并抛向下一跳`, + transitionHook: + actIndex === 0 + ? `${supportRoles[0]?.name || '另一名角色'}会在这一幕后继续加压。` + : actIndex === 1 + ? `这一幕结束后,${primaryRole?.name || '主角色'}会逼玩家接住最终选择。` + : '这一幕结束后要把下一步去向和关系压力一起抛给玩家。', + advanceRule: + actIndex === 0 + ? 'after_primary_contact' + : actIndex === 2 + ? 'after_chapter_resolution' + : 'after_active_step_complete', + }; + }); + + return { + id: `scene-chapter-${landmark.id}`, + sceneId: landmark.id, + sceneName: landmark.name, + title: `${landmark.name}章节`, + summary: clampText( + `${landmark.name}会按三幕推进:先起势、再承压、最后收束。`, + 120, + ), + linkedThreadIds, + linkedLandmarkIds: [landmark.id], + acts, + } satisfies CustomWorldFoundationDraftSceneChapter; + }); +} + function getNamedRecordKey(value: unknown) { return toText(value).replace(/\s+/gu, ''); } @@ -1533,6 +1696,12 @@ function convertRuntimeProfileToFoundationDraft(params: { landmarks, threads, }); + const sceneChapters = buildSceneChaptersFromDraft({ + landmarks, + playableNpcs, + storyNpcs, + threads, + }); const anchorRecord = toRecord(params.anchorPack); return { @@ -1571,6 +1740,7 @@ function convertRuntimeProfileToFoundationDraft(params: { factions, threads, chapters: [chapter], + sceneChapters, worldHook: clampText(params.intent.worldHook || params.profile.summary, 72) || params.profile.summary, @@ -1793,7 +1963,12 @@ export class CustomWorldAgentFoundationDraftService { threads: baseThreads, coreConflicts, iconicElements, - }).slice(0, 5); + }).slice(0, FOUNDATION_DRAFT_PLAYABLE_COUNT + FOUNDATION_DRAFT_STORY_COUNT); + const { playableNpcs, storyNpcs } = splitDraftCharacters({ + characters, + playableCount: FOUNDATION_DRAFT_PLAYABLE_COUNT, + storyCount: FOUNDATION_DRAFT_STORY_COUNT, + }); const camp = buildCamp({ openingSituation, worldHook, @@ -1803,12 +1978,12 @@ export class CustomWorldAgentFoundationDraftService { intent, camp, factions, - characters, + characters: [...playableNpcs, ...storyNpcs], threads: baseThreads, coreConflicts, iconicElements, openingSituation, - }).slice(0, 6); + }).slice(0, FOUNDATION_DRAFT_LANDMARK_COUNT); const threads = finalizeThreads({ threads: baseThreads.slice(0, 4), characters, @@ -1818,7 +1993,7 @@ export class CustomWorldAgentFoundationDraftService { worldName, openingSituation, playerGoal, - characters, + characters: [...playableNpcs, ...storyNpcs], landmarks, threads, }); @@ -1851,8 +2026,8 @@ export class CustomWorldAgentFoundationDraftService { playerGoal, majorFactions: factions.map((entry) => entry.name), coreConflicts, - playableNpcs: characters, - storyNpcs: [], + playableNpcs, + storyNpcs, landmarks, camp, themePack: null, @@ -1860,6 +2035,12 @@ export class CustomWorldAgentFoundationDraftService { factions, threads, chapters: [chapter], + sceneChapters: buildSceneChaptersFromDraft({ + landmarks, + playableNpcs, + storyNpcs, + threads, + }), worldHook, playerPremise, openingSituation, diff --git a/server-node/src/services/customWorldAgentOrchestrator.ts b/server-node/src/services/customWorldAgentOrchestrator.ts index 2a2dd4b9..956f80d4 100644 --- a/server-node/src/services/customWorldAgentOrchestrator.ts +++ b/server-node/src/services/customWorldAgentOrchestrator.ts @@ -17,6 +17,7 @@ import type { import { badRequest, notFound } from '../errors.js'; import { prepareEventStreamResponse } from '../http.js'; import { CustomWorldAgentAssetBridgeService } from './customWorldAgentAssetBridgeService.js'; +import { CustomWorldAgentAutoAssetService } from './customWorldAgentAutoAssetService.js'; import { CustomWorldAgentChangeSummaryService } from './customWorldAgentChangeSummaryService.js'; import { buildPendingClarifications, @@ -274,10 +275,12 @@ function buildWelcomeMessage(params: { function buildFoundationDraftAssistantMessage(params: { relatedOperationId: string; draftProfile: unknown; + warnings?: string[]; }) { const profile = normalizeFoundationDraftProfile(params.draftProfile); const leadCharacter = profile?.playableNpcs[0]; const leadLandmark = profile?.landmarks[0]; + const warnings = (params.warnings ?? []).filter(Boolean); return { id: `message-${crypto.randomBytes(8).toString('hex')}`, @@ -288,6 +291,12 @@ function buildFoundationDraftAssistantMessage(params: { '', `当前已经落下来的第一批对象数量是:关键角色 ${profile?.playableNpcs.length ?? 0} 个,关键地点 ${profile?.landmarks.length ?? 0} 个,势力 ${profile?.factions.length ?? 0} 个。`, `建议你先从“${profile?.name || '世界总卡'}”这张世界总卡看起${leadCharacter ? `,再顺着角色「${leadCharacter.name}」往下细修` : ''}${leadLandmark ? `,地点可以先看「${leadLandmark.name}」` : ''}。`, + ...(warnings.length > 0 + ? [ + '', + `这一轮有 ${warnings.length} 项资产补齐未完成,但不影响世界底稿继续精修。`, + ] + : []), ].join('\n'), createdAt: new Date().toISOString(), relatedOperationId: params.relatedOperationId, @@ -332,6 +341,8 @@ export class CustomWorldAgentOrchestrator { private readonly assetBridgeService: CustomWorldAgentAssetBridgeService; + private readonly autoAssetService: CustomWorldAgentAutoAssetService | null; + private readonly eightAnchorSingleTurnService: EightAnchorSingleTurnService; constructor( @@ -339,6 +350,7 @@ export class CustomWorldAgentOrchestrator { llmClient: UpstreamLlmClient | null = null, options: { singleTurnLlmClient?: UpstreamLlmClient | null; + autoAssetService?: CustomWorldAgentAutoAssetService | null; } = {}, ) { this.foundationDraftService = new CustomWorldAgentFoundationDraftService( @@ -350,6 +362,8 @@ export class CustomWorldAgentOrchestrator { ); this.changeSummaryService = new CustomWorldAgentChangeSummaryService(); this.assetBridgeService = new CustomWorldAgentAssetBridgeService(); + this.autoAssetService = + options.autoAssetService ?? null; this.eightAnchorSingleTurnService = new EightAnchorSingleTurnService( (options.singleTurnLlmClient ?? llmClient) ?? undefined, ); @@ -844,9 +858,9 @@ export class CustomWorldAgentOrchestrator { try { await this.sessionStore.updateOperation(userId, sessionId, operationId, { status: 'running', - phaseLabel: '生成世界底稿', - phaseDetail: '正在根据已确认设定编译第一版世界结构。', - progress: 38, + phaseLabel: '整理世界骨架', + phaseDetail: '正在校验已确认锚点,并准备第一版世界框架生成链路。', + progress: 12, }); await sleep(30); @@ -890,19 +904,44 @@ export class CustomWorldAgentOrchestrator { }, }); + const draftWithAssets = this.autoAssetService + ? await this.autoAssetService.populateDraftAssets({ + draftProfile, + onProgress: async (progress) => { + await this.sessionStore.updateOperation( + userId, + sessionId, + operationId, + { + status: 'running', + phaseLabel: progress.phaseLabel, + phaseDetail: progress.phaseDetail, + progress: progress.progress, + }, + ); + }, + }) + : { + draftProfile, + assetCoverage: rebuildRoleAssetCoverage(draftProfile), + warnings: [], + }; + await this.sessionStore.updateOperation(userId, sessionId, operationId, { phaseLabel: '编译草稿卡', phaseDetail: '正在把世界底稿整理成可浏览的卡片摘要和详情结构。', progress: 98, }); - const draftCards = this.draftCompiler.compileDraftCards(draftProfile); - const assetCoverage = rebuildRoleAssetCoverage(draftProfile); + const draftCards = this.draftCompiler.compileDraftCards( + draftWithAssets.draftProfile, + ); + const assetCoverage = draftWithAssets.assetCoverage; const nextStage = 'object_refining' as const; const nextSuggestedActions = buildSuggestedActions({ stage: nextStage, isReady: true, - draftProfile, + draftProfile: draftWithAssets.draftProfile, draftCards, }); @@ -910,7 +949,8 @@ export class CustomWorldAgentOrchestrator { stage: nextStage, creatorIntent, anchorPack, - draftProfile: draftProfile as unknown as Record, + draftProfile: + draftWithAssets.draftProfile as unknown as Record, draftCards, assetCoverage, pendingClarifications: [], @@ -925,22 +965,34 @@ export class CustomWorldAgentOrchestrator { sessionId, buildFoundationDraftAssistantMessage({ relatedOperationId: operationId, - draftProfile, + draftProfile: draftWithAssets.draftProfile, + warnings: draftWithAssets.warnings, }), ); await this.sessionStore.updateOperation(userId, sessionId, operationId, { status: 'completed', phaseLabel: '世界底稿已生成', - phaseDetail: `第一版世界底稿和 ${draftCards.length} 张草稿卡已经整理完成。`, + phaseDetail: + draftWithAssets.warnings.length > 0 + ? `第一版世界底稿和 ${draftCards.length} 张草稿卡已经整理完成,另有 ${draftWithAssets.warnings.length} 项资产补齐待后续处理。` + : `第一版世界底稿和 ${draftCards.length} 张草稿卡已经整理完成。`, progress: 100, error: null, }); } catch (error) { + const currentOperation = await this.sessionStore.getOperation( + userId, + sessionId, + operationId, + ); await this.sessionStore.updateOperation(userId, sessionId, operationId, { status: 'failed', - phaseLabel: '底稿生成失败', - phaseDetail: '这一轮没有成功把设定编成世界底稿。', + phaseLabel: + currentOperation?.phaseLabel?.trim() || '底稿生成失败', + phaseDetail: + currentOperation?.phaseDetail?.trim() || + '这一轮没有成功把设定编成世界底稿。', progress: 100, error: error instanceof Error ? error.message : 'draft foundation failed', diff --git a/server-node/src/services/customWorldAgentPhase3.test.ts b/server-node/src/services/customWorldAgentPhase3.test.ts index 7e350f12..00ac0328 100644 --- a/server-node/src/services/customWorldAgentPhase3.test.ts +++ b/server-node/src/services/customWorldAgentPhase3.test.ts @@ -1,8 +1,14 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import test from 'node:test'; import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js'; +import type { AppConfig } from '../config.js'; import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js'; +import { CustomWorldAgentAutoAssetService } from './customWorldAgentAutoAssetService.js'; +import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js'; import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js'; import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js'; import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js'; @@ -88,6 +94,102 @@ function createRuntimeRepositoryStub(): RuntimeRepositoryPort { }; } +function createAutoAssetTestConfig(testName: string): AppConfig { + const projectRoot = fs.mkdtempSync( + path.join(os.tmpdir(), `genarrative-agent-phase3-${testName}-`), + ); + + return { + nodeEnv: 'test', + projectRoot, + publicDir: path.join(projectRoot, 'public'), + logsDir: path.join(projectRoot, 'logs'), + dataDir: path.join(projectRoot, 'data'), + rawEnv: {}, + databaseUrl: `pg-mem://${testName}`, + serverAddr: ':0', + logLevel: 'silent', + editorApiEnabled: true, + assetsApiEnabled: true, + jwtSecret: 'test', + jwtExpiresIn: '7d', + jwtIssuer: 'test', + llm: { + baseUrl: 'https://example.invalid', + apiKey: '', + model: 'test-model', + }, + dashScope: { + baseUrl: 'https://example.invalid', + apiKey: '', + imageModel: 'test-image-model', + requestTimeoutMs: 1000, + }, + smsAuth: { + enabled: false, + provider: 'mock', + endpoint: '', + accessKeyId: '', + accessKeySecret: '', + signName: '', + templateCode: '', + templateParamKey: '', + countryCode: '86', + schemeName: '', + codeLength: 6, + codeType: 1, + validTimeSeconds: 300, + intervalSeconds: 60, + duplicatePolicy: 1, + caseAuthPolicy: 1, + returnVerifyCode: false, + mockVerifyCode: '123456', + maxSendPerPhonePerDay: 20, + maxSendPerIpPerHour: 30, + maxVerifyFailuresPerPhonePerHour: 12, + maxVerifyFailuresPerIpPerHour: 24, + captchaTtlSeconds: 180, + captchaTriggerVerifyFailuresPerPhone: 3, + captchaTriggerVerifyFailuresPerIp: 5, + blockPhoneFailureThreshold: 6, + blockIpFailureThreshold: 10, + blockPhoneDurationMinutes: 30, + blockIpDurationMinutes: 30, + }, + wechatAuth: { + enabled: false, + provider: 'mock', + appId: '', + appSecret: '', + authorizeEndpoint: '', + accessTokenEndpoint: '', + userInfoEndpoint: '', + callbackPath: '', + defaultRedirectPath: '/', + mockUserId: '', + mockUnionId: '', + mockDisplayName: '', + mockAvatarUrl: '', + }, + authSession: { + refreshCookieName: 'refresh_token', + refreshSessionTtlDays: 30, + refreshCookieSecure: false, + refreshCookieSameSite: 'Lax', + refreshCookiePath: '/', + }, + }; +} + +function createFallbackAutoAssetService(testName: string) { + const config = createAutoAssetTestConfig(testName); + return new CustomWorldAgentAutoAssetService( + config, + CustomWorldAgentAutoAssetService.createFallbackCharacterVisualGenerator(config), + CustomWorldAgentAutoAssetService.createFallbackSceneActBackgroundGenerator(config), + ); +} + async function waitForOperation( orchestrator: CustomWorldAgentOrchestrator, userId: string, @@ -161,6 +263,7 @@ test('phase3 ready session can execute draft_foundation and expose card detail', const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), + autoAssetService: createFallbackAutoAssetService('draft'), }); const userId = 'user-phase3-draft'; const readySession = await createReadySession(orchestrator, userId); @@ -179,6 +282,16 @@ test('phase3 ready session can execute draft_foundation and expose card detail', response.operation.operationId, ); const snapshot = await orchestrator.getSessionSnapshot(userId, readySession.sessionId); + const draftProfile = snapshot?.draftProfile as Record | undefined; + const playableNpcs = Array.isArray(draftProfile?.playableNpcs) + ? draftProfile?.playableNpcs + : []; + const storyNpcs = Array.isArray(draftProfile?.storyNpcs) + ? draftProfile?.storyNpcs + : []; + const sceneChapters = Array.isArray(draftProfile?.sceneChapters) + ? draftProfile?.sceneChapters + : []; assert.equal(operation?.status, 'completed'); assert.equal(snapshot?.stage, 'object_refining'); @@ -189,6 +302,23 @@ test('phase3 ready session can execute draft_foundation and expose card detail', assert.ok(snapshot?.draftCards.some((card) => card.kind === 'landmark')); assert.ok(snapshot?.draftCards.some((card) => card.kind === 'thread')); assert.ok(snapshot?.draftCards.some((card) => card.kind === 'chapter')); + assert.equal(playableNpcs.length, 1); + assert.ok(storyNpcs.length >= 4); + assert.equal(sceneChapters.length, 2); + assert.ok( + sceneChapters.every( + (entry) => Array.isArray((entry as { acts?: unknown[] }).acts) && ((entry as { acts?: unknown[] }).acts?.length ?? 0) === 3, + ), + ); + assert.ok( + playableNpcs.every( + (entry) => + typeof (entry as { imageSrc?: unknown }).imageSrc === 'string' && + typeof (entry as { generatedVisualAssetId?: unknown }).generatedVisualAssetId === 'string', + ), + ); + assert.ok((snapshot?.assetCoverage.sceneAssets.length ?? 0) >= 6); + assert.equal(snapshot?.assetCoverage.allSceneAssetsReady, true); assert.equal( typeof (snapshot?.draftProfile as Record)?.name, 'string', @@ -221,6 +351,7 @@ test('phase3 draft_foundation rejects not-ready session', async () => { const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), + autoAssetService: createFallbackAutoAssetService('not-ready'), }); const userId = 'user-phase3-not-ready'; const createdSession = await orchestrator.createSession(userId, { @@ -241,6 +372,7 @@ test('phase3 work summaries prefer compiled foundation draft fields', async () = const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), + autoAssetService: createFallbackAutoAssetService('summary'), }); const userId = 'user-phase3-summary'; const readySession = await createReadySession(orchestrator, userId); @@ -264,10 +396,70 @@ test('phase3 work summaries prefer compiled foundation draft fields', async () = customWorldAgentSessions: sessionStore, }); const draft = items.find((item) => item.sessionId === readySession.sessionId); + const compiledProfile = normalizeFoundationDraftProfile( + ( + await orchestrator.getSessionSnapshot(userId, readySession.sessionId) + )?.draftProfile, + ); + const totalRoleCount = [ + ...new Set( + [ + ...(compiledProfile?.playableNpcs ?? []), + ...(compiledProfile?.storyNpcs ?? []), + ].map((entry) => entry.id), + ), + ].length; assert.ok(draft); - assert.ok((draft?.playableNpcCount ?? 0) >= 3); - assert.ok((draft?.landmarkCount ?? 0) >= 4); + assert.equal(draft?.playableNpcCount ?? 0, totalRoleCount); + assert.equal(draft?.landmarkCount ?? 0, 2); assert.match(draft?.summary ?? '', /潮雾|守灯|航道/u); assert.match(draft?.subtitle ?? '', /守灯|冲突|列岛/u); }); + +test('phase3 draft foundation still completes when auto asset generation fails', async () => { + const runtimeRepository = createRuntimeRepositoryStub(); + const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); + const autoAssetService = new CustomWorldAgentAutoAssetService( + createAutoAssetTestConfig('asset-failure'), + async () => { + throw new Error('visual service timeout'); + }, + async () => { + throw new Error('scene service timeout'); + }, + ); + const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { + singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), + autoAssetService, + }); + const userId = 'user-phase3-asset-failure'; + const readySession = await createReadySession(orchestrator, userId); + + const response = await orchestrator.executeAction( + userId, + readySession.sessionId, + { + action: 'draft_foundation', + }, + ); + const operation = await waitForOperation( + orchestrator, + userId, + readySession.sessionId, + response.operation.operationId, + ); + const snapshot = await orchestrator.getSessionSnapshot(userId, readySession.sessionId); + + assert.equal(operation?.status, 'completed'); + assert.doesNotMatch(operation?.phaseDetail ?? '', /资产补齐待后续处理/u); + assert.ok(snapshot?.draftCards.length); + assert.ok( + snapshot?.messages.every( + (message) => + message.role !== 'assistant' || !message.text.includes('资产补齐未完成'), + ), + ); + assert.equal(snapshot?.assetCoverage.allRoleAssetsReady, true); + assert.equal(snapshot?.assetCoverage.allSceneAssetsReady, true); +}); diff --git a/server-node/src/services/customWorldAgentPhase5.test.ts b/server-node/src/services/customWorldAgentPhase5.test.ts index 5e3559f6..49a1adc5 100644 --- a/server-node/src/services/customWorldAgentPhase5.test.ts +++ b/server-node/src/services/customWorldAgentPhase5.test.ts @@ -1,8 +1,13 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import test from 'node:test'; import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js'; +import type { AppConfig } from '../config.js'; import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js'; +import { CustomWorldAgentAutoAssetService } from './customWorldAgentAutoAssetService.js'; import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js'; import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js'; import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js'; @@ -88,6 +93,102 @@ function createRuntimeRepositoryStub(): RuntimeRepositoryPort { }; } +function createAutoAssetTestConfig(testName: string): AppConfig { + const projectRoot = fs.mkdtempSync( + path.join(os.tmpdir(), `genarrative-agent-phase5-${testName}-`), + ); + + return { + nodeEnv: 'test', + projectRoot, + publicDir: path.join(projectRoot, 'public'), + logsDir: path.join(projectRoot, 'logs'), + dataDir: path.join(projectRoot, 'data'), + rawEnv: {}, + databaseUrl: `pg-mem://${testName}`, + serverAddr: ':0', + logLevel: 'silent', + editorApiEnabled: true, + assetsApiEnabled: true, + jwtSecret: 'test', + jwtExpiresIn: '7d', + jwtIssuer: 'test', + llm: { + baseUrl: 'https://example.invalid', + apiKey: '', + model: 'test-model', + }, + dashScope: { + baseUrl: 'https://example.invalid', + apiKey: '', + imageModel: 'test-image-model', + requestTimeoutMs: 1000, + }, + smsAuth: { + enabled: false, + provider: 'mock', + endpoint: '', + accessKeyId: '', + accessKeySecret: '', + signName: '', + templateCode: '', + templateParamKey: '', + countryCode: '86', + schemeName: '', + codeLength: 6, + codeType: 1, + validTimeSeconds: 300, + intervalSeconds: 60, + duplicatePolicy: 1, + caseAuthPolicy: 1, + returnVerifyCode: false, + mockVerifyCode: '123456', + maxSendPerPhonePerDay: 20, + maxSendPerIpPerHour: 30, + maxVerifyFailuresPerPhonePerHour: 12, + maxVerifyFailuresPerIpPerHour: 24, + captchaTtlSeconds: 180, + captchaTriggerVerifyFailuresPerPhone: 3, + captchaTriggerVerifyFailuresPerIp: 5, + blockPhoneFailureThreshold: 6, + blockIpFailureThreshold: 10, + blockPhoneDurationMinutes: 30, + blockIpDurationMinutes: 30, + }, + wechatAuth: { + enabled: false, + provider: 'mock', + appId: '', + appSecret: '', + authorizeEndpoint: '', + accessTokenEndpoint: '', + userInfoEndpoint: '', + callbackPath: '', + defaultRedirectPath: '/', + mockUserId: '', + mockUnionId: '', + mockDisplayName: '', + mockAvatarUrl: '', + }, + authSession: { + refreshCookieName: 'refresh_token', + refreshSessionTtlDays: 30, + refreshCookieSecure: false, + refreshCookieSameSite: 'Lax', + refreshCookiePath: '/', + }, + }; +} + +function createFallbackAutoAssetService(testName: string) { + const config = createAutoAssetTestConfig(testName); + return new CustomWorldAgentAutoAssetService( + config, + CustomWorldAgentAutoAssetService.createFallbackCharacterVisualGenerator(config), + CustomWorldAgentAutoAssetService.createFallbackSceneActBackgroundGenerator(config), + ); +} + async function waitForOperation( orchestrator: CustomWorldAgentOrchestrator, userId: string, @@ -178,6 +279,7 @@ test('phase5 generate_role_assets only allows a single role and moves session in const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), + autoAssetService: createFallbackAutoAssetService('generate-role-assets'), }); const userId = 'user-phase5-generate-role-assets'; const session = await createObjectRefiningSession(orchestrator, userId); @@ -217,6 +319,10 @@ test('phase5 generate_role_assets only allows a single role and moves session in message.text.includes('角色资产工坊'), ), ); + const preparedAssetSummary = snapshot?.assetCoverage.roleAssets.find( + (entry) => entry.roleId === characterIds[0], + ); + assert.equal(preparedAssetSummary?.status, 'visual_ready'); }); test('phase5 sync_role_assets writes fields back, updates coverage and recompiles character cards', async () => { @@ -224,6 +330,7 @@ test('phase5 sync_role_assets writes fields back, updates coverage and recompile const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), + autoAssetService: createFallbackAutoAssetService('sync-role-assets'), }); const userId = 'user-phase5-sync-role-assets'; const session = await createObjectRefiningSession(orchestrator, userId); diff --git a/server-node/src/services/customWorldAgentRoleAssetStateService.test.ts b/server-node/src/services/customWorldAgentRoleAssetStateService.test.ts index cdecaa36..6d13defc 100644 --- a/server-node/src/services/customWorldAgentRoleAssetStateService.test.ts +++ b/server-node/src/services/customWorldAgentRoleAssetStateService.test.ts @@ -82,3 +82,48 @@ test('role asset summary treats idle and die as optional', () => { assert.equal(summary.status, 'complete'); assert.deepEqual(summary.missingAnimations, []); }); + +test('role asset coverage includes scene act background readiness', async () => { + const { rebuildRoleAssetCoverage } = await import( + './customWorldAgentRoleAssetStateService.js' + ); + + const coverage = rebuildRoleAssetCoverage({ + playableNpcs: [ + { + id: 'role-playable', + name: '沈砺', + threadIds: ['thread-1'], + imageSrc: '/generated/role-playable.png', + generatedVisualAssetId: 'visual-role-playable', + skills: [], + }, + ], + storyNpcs: [], + sceneChapters: [ + { + sceneId: 'scene-dock', + sceneName: '潮汐码头', + acts: [ + { + id: 'scene-dock-act-1', + title: '雾里靠岸', + backgroundImageSrc: '/generated/scene-dock-act-1.png', + backgroundAssetId: 'scene-act-asset-1', + }, + { + id: 'scene-dock-act-2', + title: '封锁加压', + backgroundImageSrc: '', + backgroundAssetId: '', + }, + ], + }, + ], + }); + + assert.equal(coverage.sceneAssets.length, 2); + assert.equal(coverage.sceneAssets[0]?.status, 'ready'); + assert.equal(coverage.sceneAssets[1]?.status, 'missing'); + assert.equal(coverage.allSceneAssetsReady, false); +}); diff --git a/server-node/src/services/customWorldAgentRoleAssetStateService.ts b/server-node/src/services/customWorldAgentRoleAssetStateService.ts index 95c9df67..04291936 100644 --- a/server-node/src/services/customWorldAgentRoleAssetStateService.ts +++ b/server-node/src/services/customWorldAgentRoleAssetStateService.ts @@ -3,6 +3,7 @@ import type { CustomWorldAssetPriorityTier, CustomWorldRoleAssetStatus, CustomWorldRoleAssetSummary, + CustomWorldSceneAssetSummary, } from '../../../packages/shared/src/contracts/customWorldAgent.js'; const REQUIRED_ROLE_ANIMATION_KEYS = ['run', 'attack'] as const; @@ -26,6 +27,19 @@ type DraftRoleRecord = { type DraftRoleKind = 'playable' | 'story'; +type DraftSceneActRecord = { + id: string; + title: string; + backgroundImageSrc?: string | null; + backgroundAssetId?: string | null; +}; + +type DraftSceneChapterRecord = { + sceneId: string; + sceneName: string; + acts: DraftSceneActRecord[]; +}; + type MergeRoleAssetIntoDraftProfilePayload = { roleId: string; portraitPath: string; @@ -66,6 +80,17 @@ function toAnimationMap(value: unknown) { return toRecord(value); } +function normalizeSceneActs(value: unknown) { + return toRecordArray(value) + .map((item, index) => ({ + id: toText(item.id) || `act-${index + 1}`, + title: toText(item.title) || `第 ${index + 1} 幕`, + backgroundImageSrc: toText(item.backgroundImageSrc) || null, + backgroundAssetId: toText(item.backgroundAssetId) || null, + })) + .filter((item) => Boolean(item.id)); +} + function hasAnimationAsset(entryValue: unknown) { const entry = toRecord(entryValue); if (!entry) { @@ -194,6 +219,31 @@ function collectDraftRoles(profileInput: unknown) { ]; } +function collectDraftSceneChapters(profileInput: unknown) { + const profile = toRecord(profileInput); + if (!profile) { + return [] as DraftSceneChapterRecord[]; + } + + return toRecordArray(profile.sceneChapters) + .map((item, index) => { + const sceneId = toText(item.sceneId); + const sceneName = toText(item.sceneName) || toText(item.title); + const acts = normalizeSceneActs(item.acts); + + if (!sceneId || acts.length === 0) { + return null; + } + + return { + sceneId, + sceneName: sceneName || `场景 ${index + 1}`, + acts, + } satisfies DraftSceneChapterRecord; + }) + .filter((item): item is DraftSceneChapterRecord => Boolean(item)); +} + export function resolveRoleAssetStatusLabel( status: CustomWorldRoleAssetStatus, ) { @@ -267,14 +317,36 @@ export function rebuildRoleAssetCoverage( const roleAssets = collectDraftRoles(draftProfile).map((entry) => buildRoleAssetSummary(entry), ); + const sceneAssets: CustomWorldSceneAssetSummary[] = collectDraftSceneChapters( + draftProfile, + ).flatMap((sceneChapter) => + sceneChapter.acts.map((act) => { + const imageSrc = act.backgroundImageSrc ?? null; + const assetId = act.backgroundAssetId ?? null; + const ready = Boolean(imageSrc || assetId); + + return { + sceneId: sceneChapter.sceneId, + sceneName: sceneChapter.sceneName, + actId: act.id, + actTitle: act.title, + imageSrc, + assetId, + status: ready ? 'ready' : 'missing', + nextPointCost: ready ? 0 : 12, + } satisfies CustomWorldSceneAssetSummary; + }), + ); return { roleAssets, - sceneAssets: [], + sceneAssets, allRoleAssetsReady: roleAssets.length > 0 && - roleAssets.every((entry) => entry.status === 'complete'), - allSceneAssetsReady: false, + roleAssets.every((entry) => entry.status !== 'missing'), + allSceneAssetsReady: + sceneAssets.length > 0 && + sceneAssets.every((entry) => entry.status === 'ready'), }; } diff --git a/server-node/src/services/customWorldAgentSessionStore.ts b/server-node/src/services/customWorldAgentSessionStore.ts index 0310a321..3ae5c140 100644 --- a/server-node/src/services/customWorldAgentSessionStore.ts +++ b/server-node/src/services/customWorldAgentSessionStore.ts @@ -453,13 +453,18 @@ function buildCompatibleAssetCoverage( ) { const derivedCoverage = rebuildRoleAssetCoverage(draftProfile); const existingCoverage = toRecord(record.assetCoverage); - const sceneAssets = Array.isArray(existingCoverage?.sceneAssets) - ? existingCoverage.sceneAssets - : []; + const sceneAssets = + derivedCoverage.sceneAssets.length > 0 + ? derivedCoverage.sceneAssets + : Array.isArray(existingCoverage?.sceneAssets) + ? existingCoverage.sceneAssets + : []; const allSceneAssetsReady = - typeof existingCoverage?.allSceneAssetsReady === 'boolean' - ? existingCoverage.allSceneAssetsReady - : false; + derivedCoverage.sceneAssets.length > 0 + ? derivedCoverage.allSceneAssetsReady + : typeof existingCoverage?.allSceneAssetsReady === 'boolean' + ? existingCoverage.allSceneAssetsReady + : false; return { ...derivedCoverage, diff --git a/server-node/src/services/customWorldCoverAssetService.test.ts b/server-node/src/services/customWorldCoverAssetService.test.ts new file mode 100644 index 00000000..64efe9d0 --- /dev/null +++ b/server-node/src/services/customWorldCoverAssetService.test.ts @@ -0,0 +1,278 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { + createServer, + type IncomingMessage, + type ServerResponse, +} from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import sharp from 'sharp'; + +import { type AppConfig } from '../config.js'; +import type { AppContext } from '../context.js'; +import { + generateCustomWorldCoverImage, + uploadCustomWorldCoverImage, +} from './customWorldCoverAssetService.js'; + +function createTestConfig( + projectRoot: string, + dashScopeBaseUrl: string, +): AppConfig { + return { + projectRoot, + publicDir: path.join(projectRoot, 'public'), + dashScope: { + baseUrl: dashScopeBaseUrl, + apiKey: 'test-dashscope-key', + imageModel: 'wan2.2-t2i-flash', + requestTimeoutMs: 5_000, + }, + } as AppConfig; +} + +function sendJson(res: ServerResponse, payload: unknown) { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end(JSON.stringify(payload)); +} + +function readRequestBody(req: IncomingMessage) { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); +} + +async function withHttpServer( + buildHandler: ( + baseUrl: string, + ) => (req: IncomingMessage, res: ServerResponse) => void | Promise, + run: (baseUrl: string) => Promise, +) { + let handler: ( + req: IncomingMessage, + res: ServerResponse, + ) => void | Promise = () => undefined; + const server = createServer((req, res) => { + Promise.resolve(handler(req, res)).catch((error) => { + res.statusCode = 500; + res.end(error instanceof Error ? error.stack : String(error)); + }); + }); + + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => resolve()); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('failed to resolve test server address'); + } + + const baseUrl = `http://127.0.0.1:${address.port}`; + handler = buildHandler(baseUrl); + + try { + return await run(baseUrl); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } +} + +test('uploadCustomWorldCoverImage crops to 16:9 and saves a compressed webp cover', async () => { + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'genarrative-cover-upload-'), + ); + const context = { + config: createTestConfig(tempRoot, 'http://127.0.0.1:9999/api/v1'), + } as AppContext; + + const inputBuffer = await sharp({ + create: { + width: 2400, + height: 1800, + channels: 3, + background: { r: 40, g: 78, b: 132 }, + }, + }) + .jpeg({ quality: 92 }) + .toBuffer(); + const imageDataUrl = `data:image/jpeg;base64,${inputBuffer.toString('base64')}`; + + const result = await uploadCustomWorldCoverImage(context, { + profileId: 'world-1', + worldName: '潮雾群岛', + imageDataUrl, + cropRect: { + x: 240, + y: 225, + width: 1920, + height: 1080, + }, + }); + + assert.equal(result.sourceType, 'uploaded'); + assert.match(result.imageSrc, /^\/generated-custom-world-covers\//u); + + const savedPath = path.join(tempRoot, 'public', result.imageSrc.slice(1)); + assert.equal(fs.existsSync(savedPath), true); + const metadata = await sharp(savedPath).metadata(); + assert.equal(metadata.format, 'webp'); + assert.equal(metadata.width, 1600); + assert.equal(metadata.height, 900); + assert.ok(fs.statSync(savedPath).size <= Math.floor(1.5 * 1024 * 1024)); +}); + +test('generateCustomWorldCoverImage sends opening act and role images as reference images', async () => { + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'genarrative-cover-generate-'), + ); + const publicDir = path.join(tempRoot, 'public'); + fs.mkdirSync(path.join(publicDir, 'images', 'scene'), { recursive: true }); + fs.mkdirSync(path.join(publicDir, 'images', 'roles'), { recursive: true }); + + const referenceBuffer = await sharp({ + create: { + width: 64, + height: 64, + channels: 3, + background: { r: 80, g: 120, b: 160 }, + }, + }) + .png() + .toBuffer(); + fs.writeFileSync( + path.join(publicDir, 'images', 'scene', 'opening.png'), + referenceBuffer, + ); + fs.writeFileSync( + path.join(publicDir, 'images', 'roles', 'lead.png'), + referenceBuffer, + ); + + const capturedBodies: string[] = []; + + await withHttpServer( + (baseUrl) => async (req, res) => { + const url = new URL(req.url || '/', baseUrl); + + if ( + req.method === 'POST' && + url.pathname === '/api/v1/services/aigc/multimodal-generation/generation' + ) { + capturedBodies.push((await readRequestBody(req)).toString('utf8')); + sendJson(res, { + output: { + results: [ + { + url: `${baseUrl}/downloads/cover.png`, + actual_prompt: '整理后的封面提示词', + }, + ], + }, + }); + return; + } + + if (req.method === 'GET' && url.pathname === '/downloads/cover.png') { + res.statusCode = 200; + res.setHeader('Content-Type', 'image/png'); + res.end(referenceBuffer); + return; + } + + res.statusCode = 404; + res.end('not found'); + }, + async (dashScopeBaseUrl) => { + const context = { + config: createTestConfig(tempRoot, `${dashScopeBaseUrl}/api/v1`), + } as AppContext; + + const result = await generateCustomWorldCoverImage(context, { + profile: { + id: 'world-1', + name: '潮雾群岛', + subtitle: '旧航道与沉钟回响', + summary: '用于验证封面参考素材收集。', + tone: '潮湿、压抑', + playerGoal: '查明旧航道真相', + settingText: '旧港与潮雾正在失衡。', + camp: null, + landmarks: [ + { + id: 'landmark-1', + name: '沉钟码头', + description: '海雾压进旧码头。', + imageSrc: '/images/scene/opening.png', + }, + ], + playableNpcs: [ + { + id: 'playable-1', + name: '林潮', + title: '守潮人', + role: '可扮演角色', + description: '站在最前面的主角色。', + imageSrc: '/images/roles/lead.png', + }, + ], + storyNpcs: [], + sceneChapterBlueprints: [ + { + id: 'scene-chapter-1', + sceneId: 'landmark-1', + title: '沉钟码头', + summary: '玩家第一次登上旧码头。', + acts: [ + { + id: 'act-1', + title: '雾里靠岸', + summary: '第一幕潮声压低,玩家刚踏上栈桥。', + backgroundImageSrc: '/images/scene/opening.png', + }, + ], + }, + ], + }, + userPrompt: '像正式作品封面。', + referenceImageSrc: '', + characterRoleIds: ['playable-1'], + size: '1600*900', + }); + + assert.equal(result.sourceType, 'generated'); + }, + ); + + assert.equal(capturedBodies.length, 1); + const createPayload = JSON.parse(capturedBodies[0] ?? '{}') as { + input?: { + messages?: Array<{ + content?: Array<{ image?: string; text?: string }>; + }>; + }; + }; + const content = + createPayload.input?.messages?.[0]?.content?.map((item) => + item.image ? 'image' : item.text ? 'text' : 'unknown', + ) ?? []; + assert.ok(content.filter((item) => item === 'image').length >= 2); + assert.equal(content[content.length - 1], 'text'); +}); diff --git a/server-node/src/services/customWorldCoverAssetService.ts b/server-node/src/services/customWorldCoverAssetService.ts index 904eaacb..ec1090ae 100644 --- a/server-node/src/services/customWorldCoverAssetService.ts +++ b/server-node/src/services/customWorldCoverAssetService.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import { readFile } from 'node:fs/promises'; import path from 'node:path'; +import sharp from 'sharp'; import { z } from 'zod'; import type { AppContext } from '../context.js'; @@ -33,6 +34,21 @@ const coverLandmarkSchema = z.object({ imageSrc: z.string().trim().optional().default(''), }); +const coverActSchema = z.object({ + id: z.string().trim().optional().default(''), + title: z.string().trim().optional().default(''), + summary: z.string().trim().optional().default(''), + backgroundImageSrc: z.string().trim().optional().default(''), +}); + +const coverSceneChapterSchema = z.object({ + id: z.string().trim().optional().default(''), + sceneId: z.string().trim().optional().default(''), + title: z.string().trim().optional().default(''), + summary: z.string().trim().optional().default(''), + acts: z.array(coverActSchema).optional().default([]), +}); + const coverProfileSchema = z.object({ id: z.string().trim().optional().default(''), name: z.string().trim().optional().default(''), @@ -44,6 +60,11 @@ const coverProfileSchema = z.object({ camp: coverCampSchema.nullable().optional(), landmarks: z.array(coverLandmarkSchema).optional().default([]), playableNpcs: z.array(coverRoleSchema).optional().default([]), + storyNpcs: z.array(coverRoleSchema).optional().default([]), + sceneChapterBlueprints: z + .array(coverSceneChapterSchema) + .optional() + .default([]), }); export const customWorldCoverImageSchema = z.object({ @@ -58,10 +79,26 @@ export const customWorldCoverUploadSchema = z.object({ profileId: z.string().trim().optional().default(''), worldName: z.string().trim().optional().default(''), imageDataUrl: z.string().trim().min(1), + cropRect: z.object({ + x: z.number().finite().min(0), + y: z.number().finite().min(0), + width: z.number().finite().positive(), + height: z.number().finite().positive(), + }), }); type CoverProfile = z.infer; +const COVER_OUTPUT_WIDTH = 1600; +const COVER_OUTPUT_HEIGHT = 900; +const COVER_UPLOAD_MAX_BYTES = 10 * 1024 * 1024; +const COVER_OUTPUT_MAX_BYTES = Math.floor(1.5 * 1024 * 1024); + +type ParsedImageDataUrl = { + buffer: Buffer; + mimeType: string; +}; + function parseImageDataUrl(source: string) { const matched = /^data:(image\/[^;]+);base64,(.+)$/u.exec(source); if (!matched) { @@ -74,6 +111,160 @@ function parseImageDataUrl(source: string) { }; } +function clampCoverText(value: string, maxLength: number) { + return value.trim().replace(/\s+/gu, ' ').slice(0, maxLength); +} + +function resolveOpeningAct(profile: CoverProfile) { + return profile.sceneChapterBlueprints[0]?.acts[0] ?? null; +} + +function collectCoverReferenceImageSrcs( + profile: CoverProfile, + requestedRoleIds: string[], + explicitReferenceImageSrc: string, +) { + const selectedRoles = resolveSelectedRoles(profile, requestedRoleIds); + const sceneImageSrc = clampCoverText( + resolveOpeningAct(profile)?.backgroundImageSrc ?? '', + 240, + ); + const roleImageSrcs = selectedRoles + .map((role) => clampCoverText(role.imageSrc, 240)) + .filter(Boolean); + const campImageSrc = clampCoverText(profile.camp?.imageSrc ?? '', 240); + const landmarkImageSrc = profile.landmarks + .map((landmark) => clampCoverText(landmark.imageSrc, 240)) + .filter(Boolean)[0] ?? ''; + + return [ + clampCoverText(explicitReferenceImageSrc, 240), + sceneImageSrc, + ...roleImageSrcs, + campImageSrc, + landmarkImageSrc, + ].filter( + (source) => + Boolean(source) && (source.startsWith('/') || source.startsWith('data:')), + ); +} + +function buildCoverPromptContext(profile: CoverProfile, requestedRoleIds: string[]) { + const openingAct = resolveOpeningAct(profile); + const selectedRoles = resolveSelectedRoles(profile, requestedRoleIds); + const roleSummary = selectedRoles + .map((role) => + [ + clampCoverText(role.name, 18), + clampCoverText(role.title || role.role, 24), + clampCoverText(role.description, 72), + ] + .filter(Boolean) + .join(' / '), + ) + .filter(Boolean) + .join(';'); + const storyRoleSummary = profile.storyNpcs + .slice(0, 4) + .map((role) => + [clampCoverText(role.name, 18), clampCoverText(role.title || role.role, 24)] + .filter(Boolean) + .join(' / '), + ) + .filter(Boolean) + .join(';'); + + return { + openingActTitle: clampCoverText(openingAct?.title ?? '', 24), + openingActSummary: clampCoverText(openingAct?.summary ?? '', 96), + roleSummary, + storyRoleSummary, + landmarkSummary: profile.landmarks + .slice(0, 3) + .map((landmark) => + [ + clampCoverText(landmark.name, 18), + clampCoverText(landmark.description, 72), + ] + .filter(Boolean) + .join(' / '), + ) + .filter(Boolean) + .join(';'), + }; +} + +async function optimizeUploadedCoverImage( + parsedDataUrl: ParsedImageDataUrl, + cropRect: z.infer['cropRect'], +) { + if (parsedDataUrl.buffer.byteLength > COVER_UPLOAD_MAX_BYTES) { + throw badRequest('上传封面原图不能超过 10 MB。'); + } + + const image = sharp(parsedDataUrl.buffer, { failOn: 'none' }); + const metadata = await image.metadata(); + const sourceWidth = metadata.width ?? 0; + const sourceHeight = metadata.height ?? 0; + + if (sourceWidth <= 0 || sourceHeight <= 0) { + throw badRequest('无法解析上传封面的尺寸。'); + } + + const normalizedCrop = { + left: Math.max(0, Math.min(sourceWidth - 1, Math.floor(cropRect.x))), + top: Math.max(0, Math.min(sourceHeight - 1, Math.floor(cropRect.y))), + width: Math.max(1, Math.min(sourceWidth, Math.floor(cropRect.width))), + height: Math.max(1, Math.min(sourceHeight, Math.floor(cropRect.height))), + }; + normalizedCrop.width = Math.min( + normalizedCrop.width, + sourceWidth - normalizedCrop.left, + ); + normalizedCrop.height = Math.min( + normalizedCrop.height, + sourceHeight - normalizedCrop.top, + ); + + if ( + normalizedCrop.width <= 0 || + normalizedCrop.height <= 0 || + normalizedCrop.width / normalizedCrop.height < 1.7 || + normalizedCrop.width / normalizedCrop.height > 1.8 + ) { + throw badRequest('上传封面裁剪区域必须保持 16:9。'); + } + + const encodeWithQuality = async (quality: number) => + image + .extract(normalizedCrop) + .resize(COVER_OUTPUT_WIDTH, COVER_OUTPUT_HEIGHT, { + fit: 'cover', + position: 'centre', + }) + .webp({ quality, effort: 4 }) + .toBuffer(); + + let optimizedBuffer = await encodeWithQuality(90); + for ( + let quality = 84; + optimizedBuffer.byteLength > COVER_OUTPUT_MAX_BYTES && quality >= 60; + quality -= 8 + ) { + optimizedBuffer = await encodeWithQuality(quality); + } + + if (optimizedBuffer.byteLength > COVER_OUTPUT_MAX_BYTES) { + throw badRequest('上传封面压缩后仍超过体积限制,请缩小裁剪范围或更换图片。'); + } + + return { + buffer: optimizedBuffer, + mimeType: 'image/webp', + extension: 'webp', + }; +} + async function resolveReferenceImageAsDataUrl(rootDir: string, source: string) { const trimmedSource = source.trim(); if (!trimmedSource) { @@ -207,15 +398,7 @@ function buildCustomWorldCoverImagePrompt( } = {}, ) { const openingScene = profile.camp ?? profile.landmarks[0] ?? null; - const selectedRoles = resolveSelectedRoles(profile, requestedRoleIds); - const roleSummary = selectedRoles - .map((role) => - [role.name, role.title || role.role, role.description] - .filter(Boolean) - .join(' / '), - ) - .filter(Boolean) - .join(';'); + const promptContext = buildCoverPromptContext(profile, requestedRoleIds); return [ '为 16:9 横版 RPG 作品生成一张高完成度封面图,用于创作列表与作品详情头图。', @@ -231,9 +414,13 @@ function buildCustomWorldCoverImagePrompt( profile.summary ? `世界概述:${profile.summary}。` : '', profile.tone ? `整体基调:${profile.tone}。` : '', profile.playerGoal ? `主线目标:${profile.playerGoal}。` : '', + promptContext.openingActTitle ? `开局第一幕标题:${promptContext.openingActTitle}。` : '', + promptContext.openingActSummary ? `开局第一幕摘要:${promptContext.openingActSummary}。` : '', openingScene?.name ? `开局场景:${openingScene.name}。` : '', openingScene?.description ? `场景描述:${openingScene.description}。` : '', - roleSummary ? `需要出现的角色主形象:${roleSummary}。` : '', + promptContext.landmarkSummary ? `关键场景素材:${promptContext.landmarkSummary}。` : '', + promptContext.roleSummary ? `需要出现的角色主形象:${promptContext.roleSummary}。` : '', + promptContext.storyRoleSummary ? `可辅助参考的场景角色:${promptContext.storyRoleSummary}。` : '', userPrompt ? `额外要求:${userPrompt}。` : '', '整体观感要像一张正式作品封面,主体明确,氛围饱满,人物与场景统一。', ] @@ -286,7 +473,7 @@ async function createCoverImageFromReference(params: { apiKey: string; prompt: string; size: string; - referenceImage: string; + referenceImages: string[]; }) { const response = await fetch( `${params.baseUrl}/services/aigc/multimodal-generation/generation`, @@ -303,7 +490,7 @@ async function createCoverImageFromReference(params: { { role: 'user', content: [ - { image: params.referenceImage }, + ...params.referenceImages.map((image) => ({ image })), { text: params.prompt }, ], }, @@ -419,11 +606,10 @@ export async function uploadCustomWorldCoverImage( throw badRequest('上传封面必须是有效图片 Data URL。'); } - const extension = parsedDataUrl.mimeType.includes('png') - ? 'png' - : parsedDataUrl.mimeType.includes('webp') - ? 'webp' - : 'jpg'; + const optimizedImage = await optimizeUploadedCoverImage( + parsedDataUrl, + payload.cropRect, + ); const assetId = `custom-cover-upload-${Date.now()}`; const worldSegment = sanitizeSegment( payload.profileId || payload.worldName, @@ -436,8 +622,8 @@ export async function uploadCustomWorldCoverImage( ); const outputDir = path.join(context.config.publicDir, relativeDir); fs.mkdirSync(outputDir, { recursive: true }); - const fileName = `cover.${extension}`; - fs.writeFileSync(path.join(outputDir, fileName), parsedDataUrl.buffer); + const fileName = `cover.${optimizedImage.extension}`; + fs.writeFileSync(path.join(outputDir, fileName), optimizedImage.buffer); const imageSrc = `/${relativeDir.replace(/\\/gu, '/')}/${fileName}`; fs.writeFileSync( @@ -447,6 +633,8 @@ export async function uploadCustomWorldCoverImage( assetId, sourceType: 'uploaded', imageSrc, + size: `${COVER_OUTPUT_WIDTH}*${COVER_OUTPUT_HEIGHT}`, + outputBytes: optimizedImage.buffer.byteLength, worldName: payload.worldName, profileId: payload.profileId, createdAt: new Date().toISOString(), @@ -468,29 +656,33 @@ export async function generateCustomWorldCoverImage( input: z.infer, ) { const payload = customWorldCoverImageSchema.parse(input); + const referenceImageSources = collectCoverReferenceImageSrcs( + payload.profile, + payload.characterRoleIds, + payload.referenceImageSrc, + ).slice(0, 6); const prompt = buildCustomWorldCoverImagePrompt( payload.profile, payload.characterRoleIds, payload.userPrompt, { - hasReferenceImage: Boolean(payload.referenceImageSrc.trim()), + hasReferenceImage: referenceImageSources.length > 0, }, ); const baseUrl = context.config.dashScope.baseUrl.replace(/\/+$/u, ''); - const referenceImage = payload.referenceImageSrc.trim() - ? await resolveReferenceImageAsDataUrl( - context.config.projectRoot, - payload.referenceImageSrc, - ) - : ''; + const referenceImages = await Promise.all( + referenceImageSources.map((source) => + resolveReferenceImageAsDataUrl(context.config.projectRoot, source), + ), + ); - if (referenceImage) { + if (referenceImages.length > 0) { const referenceResult = await createCoverImageFromReference({ baseUrl, apiKey: context.config.dashScope.apiKey, prompt, size: payload.size, - referenceImage, + referenceImages, }); return saveGeneratedCoverAsset({ diff --git a/server-node/src/services/customWorldWorkSummaryService.ts b/server-node/src/services/customWorldWorkSummaryService.ts index 4af0db2e..79989c2a 100644 --- a/server-node/src/services/customWorldWorkSummaryService.ts +++ b/server-node/src/services/customWorldWorkSummaryService.ts @@ -95,14 +95,17 @@ function resolveDraftSummary(session: CustomWorldAgentSessionRecord) { function resolveDraftCounts(session: CustomWorldAgentSessionRecord) { const draftProfile = normalizeFoundationDraftProfile(session.draftProfile); if (draftProfile) { - return { - playableNpcCount: [ - ...new Set( - [...draftProfile.playableNpcs, ...draftProfile.storyNpcs].map( - (entry) => entry.id, - ), + // 草稿列表里的“角色”展示的是当前草稿中全部可编辑角色,而不是仅限可扮演角色。 + const totalRoleCount = [ + ...new Set( + [...draftProfile.playableNpcs, ...draftProfile.storyNpcs].map( + (entry) => entry.id, ), - ].length, + ), + ].length; + + return { + playableNpcCount: totalRoleCount, landmarkCount: draftProfile.landmarks.length, }; } diff --git a/src/components/AdventurePanel.npcChat.test.tsx b/src/components/AdventurePanel.npcChat.test.tsx index 636f8f9e..9184807f 100644 --- a/src/components/AdventurePanel.npcChat.test.tsx +++ b/src/components/AdventurePanel.npcChat.test.tsx @@ -27,13 +27,13 @@ function createCharacter(): Character { } as Character; } -test('adventure panel treats negative affinity updates as relationship change system messages', () => { +test('adventure panel renders system turns without special relationship labels', () => { const currentStory: StoryMoment = { text: '你们的语气忽然冷了下来。', displayMode: 'dialogue', dialogue: [ { speaker: 'npc', speakerName: '柳无声', text: '这件事你最好别再追问。' }, - { speaker: 'system', text: '关系转冷 好感 -2', affinityDelta: -2 }, + { speaker: 'system', text: '这轮交谈先在这里收束。' }, ], options: [], }; @@ -102,8 +102,9 @@ test('adventure panel treats negative affinity updates as relationship change sy />, ); - expect(html).toContain('关系变化'); - expect(html).toContain('关系转冷 好感 -2'); + expect(html).toContain('系统'); + expect(html).toContain('这轮交谈先在这里收束。'); + expect(html).not.toContain('关系变化'); }); test('adventure panel shows current act label and remaining turns for limited hostile npc chat', () => { diff --git a/src/components/AdventurePanel.test.tsx b/src/components/AdventurePanel.test.tsx index ad4905cf..c7d14cec 100644 --- a/src/components/AdventurePanel.test.tsx +++ b/src/components/AdventurePanel.test.tsx @@ -157,9 +157,13 @@ test('adventure panel shows npc chat custom input and exit button in chat mode', dialogue: [ { speaker: 'player', text: '你刚才那句话是什么意思?' }, { speaker: 'npc', speakerName: '柳无声', text: '意思是这件事还没结束。' }, - { speaker: 'system', text: '关系升温 好感 +3', affinityDelta: 3 }, ], options: [optionA, optionB, optionC], + npcAffinityEffect: { + eventId: 'effect-liu-1', + npcId: 'npc-liu', + delta: 3, + }, npcChatState: { npcId: 'npc-liu', npcName: '柳无声', @@ -178,6 +182,7 @@ test('adventure panel shows npc chat custom input and exit button in chat mode', expect(html).toContain('输入你想对 TA 说的话'); expect(html).toContain('发送'); expect(html).not.toContain('换一换'); + expect(html).not.toContain('关系升温'); }); test('adventure panel hides custom input and shows quest offer actions during npc quest offer mode', () => { @@ -243,3 +248,19 @@ test('adventure panel hides custom input and shows quest offer actions during np expect(html).not.toContain('发送'); expect(html).not.toContain('输入你想对 TA 说的话'); }); + +test('adventure panel renders narrative story text without italics and hides option detail text', () => { + const option = createOption('idle_observe_signs', '观察风里残下的痕迹'); + option.detailText = '这段说明不应该继续出现在 UI 里。'; + const currentStory: StoryMoment = { + text: '风从桥洞里灌过来,你把注意力重新放回脚下与前路。', + options: [option], + }; + + const html = renderPanel(currentStory, [option]); + + expect(html).toContain('font-serif'); + expect(html).not.toContain('italic'); + expect(html).toContain('text-[15px]'); + expect(html).not.toContain('这段说明不应该继续出现在 UI 里。'); +}); diff --git a/src/components/AdventurePanel.tsx b/src/components/AdventurePanel.tsx index 47f7357c..fd1b6d1e 100644 --- a/src/components/AdventurePanel.tsx +++ b/src/components/AdventurePanel.tsx @@ -174,9 +174,7 @@ function getDialogueTurnBubbleClass( turn: NonNullable[number], ) { if (turn.speaker === 'system') { - return turn.affinityDelta && turn.affinityDelta > 0 - ? 'border-rose-400/30 bg-rose-500/12 text-rose-50' - : 'border-white/12 bg-white/[0.06] text-zinc-100'; + return 'border-white/12 bg-white/[0.06] text-zinc-100'; } if (turn.speaker === 'player') { @@ -212,7 +210,7 @@ function getDialogueTurnLabel( turn: NonNullable[number], ) { if (turn.speaker === 'system') { - return typeof turn.affinityDelta === 'number' ? '关系变化' : '系统'; + return '系统'; } if (turn.speaker === 'player') { @@ -1107,7 +1105,7 @@ export function AdventurePanel({ )} ) : ( -

+

{currentStory.text}

)} @@ -1192,9 +1190,6 @@ export function AdventurePanel({ hasDeferredAdventureOptions && isContinueAdventureOption(option); const optionDisabled = option.disabled === true; - const compactOptionDetailText = option.disabledReason - ? option.disabledReason - : getCompactOptionDetailText(option); if (isDeferredContinueOption) { return ( @@ -1210,7 +1205,7 @@ export function AdventurePanel({ >
{option.actionText} @@ -1237,7 +1232,7 @@ export function AdventurePanel({ >
{option.actionText} @@ -1246,11 +1241,6 @@ export function AdventurePanel({ className="h-3 w-3 opacity-70 transition-opacity group-hover:opacity-100" />
- {!isNpcChatMode && compactOptionDetailText && ( -
- {compactOptionDetailText} -
- )} {!isNpcChatMode && option.goalAffordance?.label && (
chapter.sceneId.trim() === normalizedSceneId, + ); + if (directMatches.length > 0) { + return directMatches; + } + + const linkedMatches = sceneChapters.filter((chapter) => + chapter.linkedLandmarkIds.some( + (landmarkId) => landmarkId.trim() === normalizedSceneId, + ), + ); + if (linkedMatches.length > 0) { + return linkedMatches; + } + + return sceneChapters.filter((chapter) => { + const chapterTitle = chapter.title.trim(); + return ( + chapterTitle === normalizedSceneName || + chapter.summary.includes(normalizedSceneName) || + chapter.acts.some( + (act) => + act.title.includes(normalizedSceneName) || + act.summary.includes(normalizedSceneName), + ) + ); + }); +} + +function buildSceneActParticipantText( + act: SceneActBlueprint, + roleById: Map< + string, + | CustomWorldProfile['playableNpcs'][number] + | CustomWorldProfile['storyNpcs'][number] + >, +) { + const primaryRoleName = roleById.get(act.primaryNpcId)?.name?.trim() || ''; + const supportRoleNames = act.encounterNpcIds + .filter((roleId) => roleId !== act.primaryNpcId) + .map((roleId) => roleById.get(roleId)?.name?.trim() || '') + .filter(Boolean); + + return compactTextList([ + primaryRoleName ? `主角色:${primaryRoleName}` : '', + supportRoleNames.length > 0 + ? `相遇角色:${supportRoleNames.join('、')}` + : '', + ]).join(';'); +} + +function buildSceneChapterSearchText( + sceneChapters: SceneChapterBlueprint[], + roleById: Map< + string, + | CustomWorldProfile['playableNpcs'][number] + | CustomWorldProfile['storyNpcs'][number] + >, +) { + return sceneChapters + .flatMap((chapter) => [ + chapter.title, + chapter.summary, + ...chapter.acts.flatMap((act) => [ + act.title, + act.summary, + act.actGoal, + act.transitionHook, + buildSceneActParticipantText(act, roleById), + ]), + ]) + .filter(Boolean) + .join(' '); +} + +function resolveSceneCardImage(params: { + sceneImageSrc?: string | null; + sceneChapters: SceneChapterBlueprint[]; +}) { + const firstActImageSrc = + params.sceneChapters + .flatMap((chapter) => chapter.acts) + .map((act) => act.backgroundImageSrc?.trim() || '') + .find(Boolean) || ''; + + return firstActImageSrc || params.sceneImageSrc?.trim() || ''; +} + function CatalogCard({ title, description, @@ -370,6 +474,10 @@ function resolvePlayableRolePreviewImage( role: CustomWorldProfile['playableNpcs'][number], previewCharacter: Character | null, ) { + if (role.imageSrc?.trim()) { + return role.imageSrc; + } + if (previewCharacter?.portrait?.trim()) { return previewCharacter.portrait; } @@ -378,10 +486,6 @@ function resolvePlayableRolePreviewImage( return previewCharacter.avatar; } - if (role.imageSrc?.trim()) { - return role.imageSrc; - } - const template = role.templateCharacterId ? ROLE_TEMPLATE_CHARACTERS.find( (character) => character.id === role.templateCharacterId, @@ -796,6 +900,16 @@ export function CustomWorldEntityCatalog({ () => new Map(profile.storyNpcs.map((npc) => [npc.id, npc])), [profile.storyNpcs], ); + const roleById = useMemo( + () => + new Map( + [...profile.playableNpcs, ...profile.storyNpcs].map((role) => [ + role.id, + role, + ]), + ), + [profile.playableNpcs, profile.storyNpcs], + ); const landmarkById = useMemo( () => new Map(profile.landmarks.map((landmark) => [landmark.id, landmark])), [profile.landmarks], @@ -876,22 +990,53 @@ export function CustomWorldEntityCatalog({ [profile.creatorIntent], ); const filteredSceneEntries = useMemo(() => { + const openingSceneChapters = resolveSceneEntrySceneChapters({ + sceneChapters: profile.sceneChapterBlueprints, + sceneId: resolvedCampScene.id, + sceneName: resolvedCampScene.name, + }); const openingSceneEntry = { - id: 'custom-world-opening-scene', + id: resolvedCampScene.id, kind: 'camp' as const, name: resolvedCampScene.name, description: resolvedCampScene.description, - imageSrc: resolvedCampImageSrc, - searchText: buildOpeningSceneSearchText(profile, resolvedCampScene), + imageSrc: resolveSceneCardImage({ + sceneImageSrc: resolvedCampImageSrc, + sceneChapters: openingSceneChapters, + }), + sceneChapters: openingSceneChapters, + searchText: [ + buildOpeningSceneSearchText(profile, resolvedCampScene), + buildSceneChapterSearchText(openingSceneChapters, roleById), + ] + .filter(Boolean) + .join(' '), }; - const landmarkEntries = filteredLandmarks.map((landmark) => ({ - id: landmark.id, - kind: 'landmark' as const, - name: landmark.name, - description: landmark.description, - imageSrc: landmarkImageById.get(landmark.id) ?? landmark.imageSrc, - searchText: buildLandmarkSearchText(landmark, storyNpcById, landmarkById), - })); + const landmarkEntries = profile.landmarks.map((landmark) => { + const sceneChapters = resolveSceneEntrySceneChapters({ + sceneChapters: profile.sceneChapterBlueprints, + sceneId: landmark.id, + sceneName: landmark.name, + }); + + return { + id: landmark.id, + kind: 'landmark' as const, + name: landmark.name, + description: landmark.description, + imageSrc: resolveSceneCardImage({ + sceneImageSrc: landmarkImageById.get(landmark.id) ?? landmark.imageSrc, + sceneChapters, + }), + sceneChapters, + searchText: [ + buildLandmarkSearchText(landmark, storyNpcById, landmarkById), + buildSceneChapterSearchText(sceneChapters, roleById), + ] + .filter(Boolean) + .join(' '), + }; + }); const recentEntries = landmarkEntries.filter((entry) => recentLandmarkIdSet.has(entry.id), ); @@ -909,13 +1054,13 @@ export function CustomWorldEntityCatalog({ ); }, [ deferredSearch, - filteredLandmarks, landmarkById, landmarkImageById, profile, recentLandmarkIdSet, resolvedCampImageSrc, resolvedCampScene, + roleById, storyNpcById, ]); @@ -1281,7 +1426,13 @@ export function CustomWorldEntityCatalog({ }) } media={ - previewCharacter ? ( + role.imageSrc?.trim() ? ( + {role.name} + ) : previewCharacter ? ( ) : ( filteredSceneEntries.map((scene, index) => ( -
- - ) : null - } - isSelectionMode={scene.kind === 'landmark' && isBulkDeleteMode} - isSelected={ - scene.kind === 'landmark' && - selectedBulkIds.includes(scene.id) - } - onClick={() => - scene.kind === 'camp' - ? onEditTarget({ kind: 'camp' }) - : isBulkDeleteMode - ? toggleBulkSelected(scene.id) - : onEditTarget({ - kind: 'landmark', - mode: 'edit', - id: scene.id, - }) - } - media={ - - } - disabled={scene.kind === 'camp' && isBulkDeleteMode} - /> -
+ title={scene.name} + description={ + scene.kind === 'camp' + ? `开局场景 · ${scene.description}` + : scene.description + } + badge={ + scene.kind === 'landmark' && recentLandmarkIdSet.has(scene.id) ? ( + + ) : null + } + isSelectionMode={scene.kind === 'landmark' && isBulkDeleteMode} + isSelected={ + scene.kind === 'landmark' && + selectedBulkIds.includes(scene.id) + } + onClick={() => + scene.kind === 'camp' + ? onEditTarget({ kind: 'camp' }) + : isBulkDeleteMode + ? toggleBulkSelected(scene.id) + : onEditTarget({ + kind: 'landmark', + mode: 'edit', + id: scene.id, + }) + } + media={ + + } + disabled={scene.kind === 'camp' && isBulkDeleteMode} + /> )) )}
diff --git a/src/components/CustomWorldEntityEditorModal.test.tsx b/src/components/CustomWorldEntityEditorModal.test.tsx index ed70ec61..5d25c973 100644 --- a/src/components/CustomWorldEntityEditorModal.test.tsx +++ b/src/components/CustomWorldEntityEditorModal.test.tsx @@ -15,6 +15,7 @@ import { type CustomWorldEditorTarget, CustomWorldEntityEditorModal, } from './CustomWorldEntityEditorModal'; +import * as customWorldCoverAssetService from '../services/customWorldCoverAssetService'; vi.mock('../data/characterPresets', async () => { const actual = await vi.importActual( @@ -65,10 +66,6 @@ vi.mock('./game-shell/GameShellRuntime', () => ({ vi.mock('./asset-studio/characterAssetWorkflowPersistence', () => ({ fetchCharacterWorkflowCache: vi.fn().mockResolvedValue({ cache: null }), - generateCharacterPromptBundle: vi.fn().mockResolvedValue({ - visualPromptText: '自动生成的形象提示词', - animationPromptText: '自动生成的动作提示词', - }), saveCharacterWorkflowCache: vi.fn().mockResolvedValue(undefined), generateCharacterVisualCandidates: vi.fn(), publishCharacterVisualAsset: vi.fn(), @@ -76,6 +73,11 @@ vi.mock('./asset-studio/characterAssetWorkflowPersistence', () => ({ publishCharacterAnimationAssets: vi.fn(), })); +vi.mock('../services/customWorldCoverAssetService', () => ({ + generateCustomWorldCoverImage: vi.fn(), + uploadCustomWorldCoverImage: vi.fn(), +})); + function createBackstoryReveal() { return { publicSummary: '公开背景', @@ -261,10 +263,19 @@ function CampEditorFlowHarness() { const [profile, setProfile] = useState({ ...createProfileWithLandmark(), camp: { + id: 'custom-scene-camp', name: '潮灯居', description: '玩家最初落脚的旧灯塔内院。', dangerLevel: 'medium', imageSrc: '/generated-custom-world-scenes/original-camp.png', + sceneNpcIds: ['story-1', 'story-2', 'story-3'], + connections: [ + { + targetLandmarkId: 'landmark-1', + relativePosition: 'north', + summary: '北侧通往沉钟栈桥。', + }, + ], }, }); const [target, setTarget] = useState({ @@ -273,6 +284,9 @@ function CampEditorFlowHarness() { return ( <> +
+        {JSON.stringify(profile)}
+      
({ + ...createProfileWithLandmark(), + cover: { + sourceType: 'default', + imageSrc: null, + characterRoleIds: ['playable-1'], + }, + }); + const [target, setTarget] = useState({ + kind: 'cover', + }); + + return ( + <> +
+        {JSON.stringify(profile)}
+      
+ setTarget(null)} + onProfileChange={setProfile} + /> + + ); +} + +function readCoverHarnessProfile() { + const content = screen.getByTestId('cover-profile-json').textContent; + return JSON.parse(content || '{}') as CustomWorldProfile; +} + +function readCampHarnessProfile() { + const content = screen.getByTestId('camp-profile-json').textContent; + return JSON.parse(content || '{}') as CustomWorldProfile; +} + test('playable角色打开AI工坊后不会自动关闭', async () => { const user = userEvent.setup(); const handleClose = vi.fn(); @@ -506,6 +558,14 @@ test('场景图片保存后会同步更新编辑页和场景列表', async () => '/generated-custom-world-scenes/original-scene.png', ); + const firstActCard = getSceneActCard(0); + await user.click(within(firstActCard).getByRole('button', { name: '配置背景' })); + await waitFor(() => { + expect(screen.getByText('配置幕背景:第1幕')).toBeTruthy(); + }); + expect(screen.queryByText('场景图片')).toBeNull(); + expect(screen.queryByText('场景内 NPC')).toBeNull(); + await user.click(screen.getByRole('button', { name: 'AI生成' })); await waitFor(() => { expect(screen.getByText('智能生成:沉钟栈桥')).toBeTruthy(); @@ -523,22 +583,29 @@ test('场景图片保存后会同步更新编辑页和场景列表', async () => }); await waitFor(() => { - expect(screen.getByRole('img', { name: '场景图片' }).getAttribute('src')).toBe( + expect(screen.getByRole('img', { name: '第1幕背景预览' }).getAttribute('src')).toBe( '/generated-custom-world-scenes/updated-scene.png', ); }); - await user.click(screen.getByRole('button', { name: /保存修改/u })); + await user.click(screen.getByRole('button', { name: '保存背景' })); await waitFor(() => { - expect(screen.queryByRole('img', { name: '场景图片' })).toBeNull(); + expect(screen.queryByText('配置幕背景:第1幕')).toBeNull(); }); + await user.click(screen.getByRole('button', { name: /保存修改/u })); + await waitFor(() => { expect(screen.getByRole('img', { name: '沉钟栈桥' }).getAttribute('src')).toBe( '/generated-custom-world-scenes/updated-scene.png', ); }); + + const savedProfile = readLandmarkHarnessProfile(); + expect(savedProfile.landmarks[0]?.imageSrc).toBe( + '/generated-custom-world-scenes/updated-scene.png', + ); }); test('开局场景图片保存后会同步更新编辑页和场景列表', async () => { @@ -562,6 +629,14 @@ test('开局场景图片保存后会同步更新编辑页和场景列表', async '/generated-custom-world-scenes/original-camp.png', ); + const firstActCard = getSceneActCard(0); + await user.click(within(firstActCard).getByRole('button', { name: '配置背景' })); + await waitFor(() => { + expect(screen.getByText('配置幕背景:第1幕')).toBeTruthy(); + }); + expect(screen.queryByText('场景图片')).toBeNull(); + expect(screen.queryByText('场景内 NPC')).toBeNull(); + await user.click(screen.getByRole('button', { name: 'AI生成' })); await waitFor(() => { expect(screen.getByText('智能生成:潮灯居')).toBeTruthy(); @@ -579,22 +654,80 @@ test('开局场景图片保存后会同步更新编辑页和场景列表', async }); await waitFor(() => { - expect(screen.getByRole('img', { name: '场景图片' }).getAttribute('src')).toBe( + expect(screen.getByRole('img', { name: '第1幕背景预览' }).getAttribute('src')).toBe( '/generated-custom-world-scenes/updated-camp.png', ); }); - await user.click(screen.getByRole('button', { name: /保存修改/u })); + await user.click(screen.getByRole('button', { name: '保存背景' })); await waitFor(() => { - expect(screen.queryByRole('img', { name: '场景图片' })).toBeNull(); + expect(screen.queryByText('配置幕背景:第1幕')).toBeNull(); }); + await user.click(screen.getByRole('button', { name: /保存修改/u })); + await waitFor(() => { expect(screen.getByRole('img', { name: '潮灯居' }).getAttribute('src')).toBe( '/generated-custom-world-scenes/updated-camp.png', ); }); + + const savedProfile = readCampHarnessProfile(); + expect(savedProfile.camp?.imageSrc).toBe( + '/generated-custom-world-scenes/updated-camp.png', + ); +}); + +test('开局场景在场景配置面板中与普通场景使用同级参数并可保存', async () => { + const user = userEvent.setup(); + + render(); + + expect(screen.getByText('多幕配置')).toBeTruthy(); + expect(screen.getByText('场景连接关系')).toBeTruthy(); + expect(screen.queryByText('场景图片')).toBeNull(); + expect(screen.queryByText('场景内 NPC')).toBeNull(); + expect(screen.getAllByTestId('scene-act-card')).toHaveLength(3); + + const firstActCard = getSceneActCard(0); + await user.click(within(firstActCard).getAllByTestId('scene-act-slot-button')[0]!); + await waitFor(() => { + expect(screen.getByText('配置角色:第1幕 · 主角色槽位')).toBeTruthy(); + }); + + await user.click(screen.getByRole('button', { name: /闻雪汀/u })); + await user.click(screen.getByRole('button', { name: '保存角色' })); + + await waitFor(() => { + expect(screen.queryByText('配置角色:第1幕 · 主角色槽位')).toBeNull(); + }); + + await user.click(screen.getByRole('button', { name: /保存修改/u })); + + await waitFor(() => { + expect(screen.queryByText('编辑场景:潮灯居')).toBeNull(); + }); + + const savedProfile = readCampHarnessProfile(); + const openingSceneChapter = savedProfile.sceneChapterBlueprints?.find( + (entry) => entry.sceneId === 'custom-scene-camp', + ); + + expect(savedProfile.camp?.sceneNpcIds).toHaveLength(3); + expect(savedProfile.camp?.sceneNpcIds).toEqual( + expect.arrayContaining(['story-1', 'story-2', 'story-3']), + ); + expect(savedProfile.camp?.connections).toEqual([ + { + targetLandmarkId: 'landmark-1', + relativePosition: 'north', + summary: '北侧通往沉钟栈桥。', + }, + ]); + expect(openingSceneChapter).toBeTruthy(); + expect(openingSceneChapter?.acts[0]?.encounterNpcIds[0]).toBe('story-2'); + expect(openingSceneChapter?.linkedLandmarkIds).toContain('custom-scene-camp'); }); test('场景编辑器会在场景内展示槽位化多幕配置并保存', async () => { @@ -636,7 +769,7 @@ test('场景编辑器会在场景内展示槽位化多幕配置并保存', async expect(screen.getByText('配置角色:第1幕 · 主角色槽位')).toBeTruthy(); }); - await user.click(screen.getByRole('button', { name: /闻雪汀[\s\S]*选择/u })); + await user.click(screen.getByRole('button', { name: /闻雪汀/u })); await user.click(screen.getByRole('button', { name: '保存角色' })); await waitFor(() => { @@ -679,7 +812,7 @@ test('场景多幕支持新增删除和调序', async () => { await waitFor(() => { expect(screen.getByText('配置角色:第2幕 · 主角色槽位')).toBeTruthy(); }); - await user.click(screen.getByRole('button', { name: /谢孤灯[\s\S]*选择/u })); + await user.click(screen.getByRole('button', { name: /谢孤灯/u })); await user.click(screen.getByRole('button', { name: '保存角色' })); await user.click(within(secondActCard).getByRole('button', { name: '下移' })); @@ -721,3 +854,83 @@ test('场景幕预览会打开当前幕运行时面板', async () => { expect(screen.queryByText('幕预览运行时')).toBeNull(); }); }); + +test('作品封面上传会先进入 16:9 裁剪面板再提交到后端', async () => { + const uploadMock = vi + .mocked(customWorldCoverAssetService.uploadCustomWorldCoverImage) + .mockResolvedValue({ + imageSrc: '/generated-custom-world-covers/world-1/uploaded/cover.webp', + assetId: 'custom-cover-upload-1', + sourceType: 'uploaded', + }); + + class MockFileReader { + result: string | null = null; + error: Error | null = null; + onload: null | (() => void) = null; + onerror: null | (() => void) = null; + + readAsDataURL() { + this.result = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7+7aQAAAAASUVORK5CYII='; + this.onload?.(); + } + } + + class MockImage { + onload: null | (() => void) = null; + onerror: null | (() => void) = null; + naturalWidth = 1920; + naturalHeight = 1080; + + set src(_value: string) { + this.onload?.(); + } + } + + vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader); + vi.stubGlobal('Image', MockImage as unknown as typeof Image); + + const user = userEvent.setup(); + render(); + + const input = document.querySelector('input[type="file"]') as HTMLInputElement | null; + expect(input).toBeTruthy(); + if (!input) { + throw new Error('未找到封面上传输入框'); + } + + const file = new File(['cover'], 'cover.png', { type: 'image/png' }); + await user.upload(input, file); + + await waitFor(() => { + expect(screen.getByText('裁剪上传封面')).toBeTruthy(); + }); + + await user.click(screen.getByRole('button', { name: '确认裁剪并上传' })); + + await waitFor(() => { + expect(uploadMock).toHaveBeenCalledTimes(1); + }); + + const uploadPayload = uploadMock.mock.calls[0]?.[0]; + expect(uploadPayload?.worldName).toBe('潮雾群岛'); + expect(uploadPayload?.cropRect.width).toBeGreaterThan(0); + expect(uploadPayload?.cropRect.height).toBeGreaterThan(0); + + await waitFor(() => { + expect(screen.queryByText('裁剪上传封面')).toBeNull(); + }); + + await user.click(screen.getByRole('button', { name: /保存/u })); + + await waitFor(() => { + expect(screen.queryByText('编辑作品封面')).toBeNull(); + }); + + const savedProfile = readCoverHarnessProfile(); + expect(savedProfile.cover?.sourceType).toBe('uploaded'); + expect(savedProfile.cover?.imageSrc).toBe( + '/generated-custom-world-covers/world-1/uploaded/cover.webp', + ); +}); diff --git a/src/components/CustomWorldEntityEditorModal.tsx b/src/components/CustomWorldEntityEditorModal.tsx index 7314c627..91ae1cd9 100644 --- a/src/components/CustomWorldEntityEditorModal.tsx +++ b/src/components/CustomWorldEntityEditorModal.tsx @@ -29,7 +29,6 @@ import { buildSkillActionPrompt } from '../prompts/customWorldEntityActionPrompt import { type CustomWorldSceneImageResult, generateCustomWorldSceneImage, - generateCustomWorldSceneNpc, } from '../services/aiService'; import { resolveCustomWorldCampScene } from '../services/customWorldCamp'; import { @@ -46,6 +45,7 @@ import { AnimationState, type Character, type CharacterAnimationConfig, + type CustomWorldCoverCropRect, type CustomWorldCoverProfile, CustomWorldLandmark, CustomWorldNpc, @@ -409,7 +409,13 @@ function sanitizeSceneChapterBlueprint(params: { actCount: params.chapter?.acts.length, }); const rawActs = params.chapter?.acts ?? []; - const availableSceneNpcIds = dedupeTextValues(params.landmark.sceneNpcIds); + const chapterEncounterNpcIds = dedupeTextValues( + rawActs.flatMap((act) => act.encounterNpcIds), + ); + const availableSceneNpcIds = dedupeTextValues([ + ...chapterEncounterNpcIds, + ...params.landmark.sceneNpcIds, + ]); const availableSceneNpcIdSet = new Set(availableSceneNpcIds); const targetActCount = Math.min( MAX_SCENE_ACT_COUNT, @@ -477,6 +483,36 @@ function sanitizeSceneChapterBlueprint(params: { } satisfies SceneChapterBlueprint; } +function collectSceneChapterEncounterNpcIds(chapter: SceneChapterBlueprint) { + return dedupeTextValues( + chapter.acts.flatMap((act) => act.encounterNpcIds), + ); +} + +function resolveSceneCompatibilityNpcIds(params: { + chapter: SceneChapterBlueprint; + currentNpcIds: string[]; +}) { + const chapterNpcIds = collectSceneChapterEncounterNpcIds(params.chapter); + return dedupeTextValues([...chapterNpcIds, ...params.currentNpcIds]); +} + +function resolveSceneCompatibilityImageSrc(params: { + chapter: SceneChapterBlueprint; + currentImageSrc?: string | null; + resolvedImageSrc?: string | null; +}) { + const currentImageSrc = params.currentImageSrc?.trim() || ''; + const resolvedImageSrc = params.resolvedImageSrc?.trim() || ''; + const firstActImageSrc = params.chapter.acts[0]?.backgroundImageSrc?.trim() || ''; + + if (firstActImageSrc && firstActImageSrc !== resolvedImageSrc) { + return firstActImageSrc; + } + + return currentImageSrc || undefined; +} + function resolveSceneChapterBlueprintDraft(params: { profile: CustomWorldProfile; landmark: CustomWorldLandmark; @@ -748,6 +784,78 @@ function readImageFileAsDataUrl(file: File) { }); } +function loadImageDimensionsFromDataUrl(source: string) { + return new Promise<{ width: number; height: number }>((resolve, reject) => { + const image = new Image(); + image.onload = () => { + resolve({ + width: image.naturalWidth, + height: image.naturalHeight, + }); + }; + image.onerror = () => reject(new Error('读取图片尺寸失败。')); + image.src = source; + }); +} + +function buildCenteredCoverCropRect( + width: number, + height: number, +): CustomWorldCoverCropRect { + const targetRatio = 16 / 9; + if (width <= 0 || height <= 0) { + return { x: 0, y: 0, width: 1, height: 1 }; + } + + if (width / height >= targetRatio) { + const cropHeight = height; + const cropWidth = cropHeight * targetRatio; + return { + x: (width - cropWidth) / 2, + y: 0, + width: cropWidth, + height: cropHeight, + }; + } + + const cropWidth = width; + const cropHeight = cropWidth / targetRatio; + return { + x: 0, + y: (height - cropHeight) / 2, + width: cropWidth, + height: cropHeight, + }; +} + +function clampCoverCropRect( + cropRect: CustomWorldCoverCropRect, + imageSize: { width: number; height: number }, +) { + const width = Math.max(1, Math.min(imageSize.width, cropRect.width)); + const height = Math.max(1, Math.min(imageSize.height, cropRect.height)); + const x = Math.max(0, Math.min(imageSize.width - width, cropRect.x)); + const y = Math.max(0, Math.min(imageSize.height - height, cropRect.y)); + + return { x, y, width, height }; +} + +function buildCoverCropPreviewStyle( + cropRect: CustomWorldCoverCropRect, + imageSize: { width: number; height: number }, +) { + if (imageSize.width <= 0 || imageSize.height <= 0) { + return {}; + } + + return { + left: `${(cropRect.x / imageSize.width) * 100}%`, + top: `${(cropRect.y / imageSize.height) * 100}%`, + width: `${(cropRect.width / imageSize.width) * 100}%`, + height: `${(cropRect.height / imageSize.height) * 100}%`, + } satisfies CSSProperties; +} + function ModalShell({ title, subtitle, @@ -1155,213 +1263,6 @@ function ActionButton({ ); } -function SceneSparringPreview({ profile }: { profile: CustomWorldProfile }) { - const sparringCharacters = useMemo(() => { - const candidates = buildCustomWorldPlayableCharacters(profile); - if (candidates.length >= 2) { - return candidates.slice(0, 2); - } - - if (candidates.length === 1) { - const firstCandidate = candidates[0]; - if (!firstCandidate) { - return ROLE_TEMPLATE_CHARACTERS.slice(0, 2); - } - const fallback = - ROLE_TEMPLATE_CHARACTERS.find( - (character) => character.id !== firstCandidate.id, - ) ?? - ROLE_TEMPLATE_CHARACTERS[0] ?? - firstCandidate; - return [firstCandidate, fallback]; - } - - return ROLE_TEMPLATE_CHARACTERS.slice(0, 2); - }, [profile]); - - const [leftCharacter, rightCharacter] = sparringCharacters; - if (!leftCharacter || !rightCharacter) { - return null; - } - - return ( - <> -
-
- 对战预览 -
-
-
-
-
-
- -
-
- -
- -
-
- -
-
-
- - ); -} - -function ScenePresetPickerModal({ - selectedSrc, - presetImages, - onSelect, - onClose, -}: { - selectedSrc?: string; - presetImages: string[]; - onSelect: (value: string) => void; - onClose: () => void; -}) { - return ( - -
- {presetImages.map((src, index) => { - const isSelected = src === selectedSrc; - return ( - - ); - })} -
-
- ); -} - -function SceneNpcPickerModal({ - storyNpcs, - selectedNpcIds, - onApply, - onClose, -}: { - storyNpcs: CustomWorldNpc[]; - selectedNpcIds: string[]; - onApply: (npcIds: string[]) => void; - onClose: () => void; -}) { - const [draftSelection, setDraftSelection] = useState(selectedNpcIds); - - useEffect(() => { - setDraftSelection(selectedNpcIds); - }, [selectedNpcIds]); - - return ( - -
-
- {storyNpcs.map((npc) => { - const isSelected = draftSelection.includes(npc.id); - return ( - - ); - })} -
-
- - { - onApply(draftSelection); - onClose(); - }} - tone="sky" - /> -
-
-
- ); -} - const SCENE_ACT_SLOT_LAYOUTS = [ { left: '68%', @@ -1370,14 +1271,14 @@ const SCENE_ACT_SLOT_LAYOUTS = [ zIndex: 4, }, { - left: '79%', - bottom: '20%', + left: '82%', + bottom: '22%', scale: 0.84, zIndex: 3, }, { - left: '89%', - bottom: '18%', + left: '82%', + bottom: '3%', scale: 0.8, zIndex: 2, }, @@ -1665,7 +1566,7 @@ function SceneActNpcSlotPickerModal({ }) ) : (
- 请先在场景内 NPC 中为这个场景分配角色。 + 当前世界档案里还没有可用于这一幕的场景角色。
)}
@@ -2837,6 +2738,7 @@ function SceneActBackgroundModal({ } const FIXED_COVER_IMAGE_SIZE = '1600*900'; +const COVER_IMAGE_MAX_UPLOAD_BYTES = 10 * 1024 * 1024; function buildGeneratedCoverProfile( result: CustomWorldCoverAssetResult, @@ -2848,6 +2750,155 @@ function buildGeneratedCoverProfile( }; } +function CoverUploadCropModal({ + imageDataUrl, + imageSize, + worldName, + isSubmitting, + onCancel, + onConfirm, +}: { + imageDataUrl: string; + imageSize: { width: number; height: number }; + worldName: string; + isSubmitting: boolean; + onCancel: () => void; + onConfirm: (cropRect: CustomWorldCoverCropRect) => void; +}) { + const [zoomPercent, setZoomPercent] = useState(100); + const baseCropRect = useMemo( + () => buildCenteredCoverCropRect(imageSize.width, imageSize.height), + [imageSize], + ); + const [offsetX, setOffsetX] = useState(0); + const [offsetY, setOffsetY] = useState(0); + + useEffect(() => { + setZoomPercent(100); + setOffsetX(0); + setOffsetY(0); + }, [imageDataUrl]); + + const cropRect = useMemo(() => { + const scale = Math.max(1, zoomPercent / 100); + const nextCropRect = { + width: baseCropRect.width / scale, + height: baseCropRect.height / scale, + x: baseCropRect.x + offsetX, + y: baseCropRect.y + offsetY, + }; + + return clampCoverCropRect(nextCropRect, imageSize); + }, [baseCropRect, imageSize, offsetX, offsetY, zoomPercent]); + + const previewStyle = useMemo( + () => buildCoverCropPreviewStyle(cropRect, imageSize), + [cropRect, imageSize], + ); + + const maxOffsetX = Math.max(0, imageSize.width - cropRect.width); + const maxOffsetY = Math.max(0, imageSize.height - cropRect.height); + + return ( + +
+
+
+ +
+
+ + } + /> +
+
+ + setZoomPercent(Number(event.target.value))} + disabled={isSubmitting} + className="w-full accent-sky-400" + /> + + + + setOffsetX(Number(event.target.value) - baseCropRect.x) + } + disabled={isSubmitting} + className="w-full accent-sky-400" + /> + + + + setOffsetY(Number(event.target.value) - baseCropRect.y) + } + disabled={isSubmitting} + className="w-full accent-sky-400" + /> + +
+
+ +
+
+ 成品会固定保存为 16:9,并由后端统一压缩到 1600 × 900。 +
+
+ 当前裁剪区域: +
+ {`x ${Math.round(cropRect.x)} / y ${Math.round(cropRect.y)} / w ${Math.round(cropRect.width)} / h ${Math.round(cropRect.height)}`} +
+
+ + onConfirm(cropRect)} + tone="sky" + disabled={isSubmitting} + /> +
+
+
+ + ); +} + function CoverImageGenerationModal({ profile, onApply, @@ -2870,6 +2921,15 @@ function CoverImageGenerationModal({ const [isExitConfirmOpen, setIsExitConfirmOpen] = useState(false); const previewImageSrc = latestResult?.imageSrc || initialPresentation.imageSrc; + const openingAct = profile.sceneChapterBlueprints?.[0]?.acts?.[0] ?? null; + const selectedCharacterRoleIds = + profile.cover?.sourceType === 'default' + ? profile.cover.characterRoleIds + : buildDefaultCustomWorldCoverProfile(profile).characterRoleIds; + const selectedRoleLabels = profile.playableNpcs + .filter((role) => selectedCharacterRoleIds?.includes(role.id)) + .map((role) => role.name) + .filter(Boolean); const handleReferenceImageChange = async ( event: ChangeEvent, @@ -2918,10 +2978,7 @@ function CoverImageGenerationModal({ profile, userPrompt, referenceImageSrc, - characterRoleIds: - profile.cover?.sourceType === 'default' - ? profile.cover.characterRoleIds - : buildDefaultCustomWorldCoverProfile(profile).characterRoleIds, + characterRoleIds: selectedCharacterRoleIds, size: FIXED_COVER_IMAGE_SIZE, }); setLatestResult(result); @@ -2960,10 +3017,19 @@ function CoverImageGenerationModal({ value={userPrompt} onChange={(value) => setUserPrompt(value)} rows={7} - placeholder="例如:风雪中的山门前景,三位主角立在残灯与旗帜之间,整体像一张正式作品封面。" + placeholder="例如:海雾压进旧码头,主角站在残灯与潮水之间,整体像一张正式 RPG 作品封面。" /> +
+ {openingAct?.title + ? `系统会自动带入开局第一幕「${openingAct.title}」的场景素材。` + : '系统会自动带入当前世界的开局场景素材。'} + {selectedRoleLabels.length > 0 + ? ` 当前默认出镜角色:${selectedRoleLabels.join('、')}。` + : ''} +
+