diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 00000000..9c602fec --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +db.test.ts \ No newline at end of file diff --git a/.idea/editor.xml b/.idea/editor.xml new file mode 100644 index 00000000..963c96fe --- /dev/null +++ b/.idea/editor.xml @@ -0,0 +1,344 @@ + + + + + \ No newline at end of file diff --git a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE6_IMPLEMENTATION_PLAN_2026-04-14.md b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE6_IMPLEMENTATION_PLAN_2026-04-14.md new file mode 100644 index 00000000..dd64840a --- /dev/null +++ b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE6_IMPLEMENTATION_PLAN_2026-04-14.md @@ -0,0 +1,700 @@ +# AI 原生 Agent-First 自定义世界创作工具第六阶段技术落地方案 + +更新时间:`2026-04-14` + +## 0. 文档目的 + +这份文档用于把以下几份文档进一步收束成第六阶段实现方案: + +- [AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md](./AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md) +- [AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE4_IMPLEMENTATION_PLAN_2026-04-14.md](./AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE4_IMPLEMENTATION_PLAN_2026-04-14.md) +- [AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE5_IMPLEMENTATION_PLAN_2026-04-14.md](./AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE5_IMPLEMENTATION_PLAN_2026-04-14.md) + +如果说第五阶段的目标是: + +**把草稿世界里的角色第一次接上正式的主图与核心动作资产工坊** + +那么第六阶段的目标就是: + +**把草稿世界里的营地和关键场景第一次接上正式的背景图工坊。** + +一句话定义: + +**第六阶段把“场景只是文字卡”升级成“场景开始有可预览、可应用的背景图资产”。** + +--- + +## 1. 阶段衔接关系 + +## 1.1 第五阶段已经完成什么 + +第六阶段默认建立在第五阶段已经完成的能力之上: + +1. 角色卡已经可以接入资产工坊 +2. 角色主图与动作可以写回 `draftProfile` +3. `assetCoverage.roleAssets` 已开始发挥作用 +4. 工作区已具备“卡片详情 -> 资产工坊 -> session 同步”的基本模式 + +## 1.2 第六阶段不再重做什么 + +以下内容第六阶段不重做: + +1. 不重做 foundation draft 生成 +2. 不重做草稿设定编辑 +3. 不重做 AI 新增角色 / 场景 +4. 不重做角色主图与动作资产工坊接入 + +第六阶段只继续补: + +1. 营地背景图资产工坊 +2. 场景背景图资产工坊 +3. 场景图状态写回 session 与 draftProfile +4. 场景卡资产状态展示 + +--- + +## 2. 第六阶段在八阶段中的位置 + +八阶段拆分如下: + +1. 阶段 1:创作页面入口、Agent 会话主链与工作区骨架 +2. 阶段 2:最小锚点收集与澄清流程 +3. 阶段 3:世界底稿生成与草稿卡编译 +4. 阶段 4:草稿设定编辑与 AI 新增角色/场景生成 +5. 阶段 5:角色主图与动作资产工坊接入 +6. 阶段 6:场景背景图工坊接入 +7. 阶段 7:长尾内容扩展与自动补齐 +8. 阶段 8:发布、世界库接入与继续创作恢复 + +本文件只覆盖: + +**阶段 6:场景背景图工坊接入** + +--- + +## 3. 第六阶段目标 + +第六阶段只做 7 件必须一起成立的事: + +1. 用户可以从 `landmark` 卡和 `camp` 卡打开场景图工坊 +2. 用户可以为场景生成背景图候选 +3. 用户可以预览生成结果 +4. 用户可以选择结果并保存为正式场景图 +5. 保存成功后,场景对象会写回 `imageSrc / generatedSceneAssetId / generatedScenePrompt / generatedSceneModel` +6. `assetCoverage.sceneAssets` 状态会更新 +7. 工作区和创作页面能感知场景资产状态变化 + +一句话目标: + +**让第六阶段结束时,至少部分关键场景已经不只是“设定存在”,而是“有可用背景图”。** + +--- + +## 4. 第六阶段完成定义 + +第六阶段完成后,必须同时满足以下结果: + +1. 用户从某张 `landmark` 或 `camp` 卡进入详情后,可以点击“场景背景图”打开场景图工坊。 +2. 用户可以输入 prompt 并生成场景背景图结果。 +3. 用户可以预览生成结果,并在保存前决定是否继续重试。 +4. 用户保存成功后,对应场景对象会得到: + - `imageSrc` + - `generatedSceneAssetId` + - `generatedScenePrompt` + - `generatedSceneModel` +5. `assetCoverage.sceneAssets` 中对应场景状态会更新。 +6. 工作区中的地点卡 / 营地卡会显示背景图状态变化。 +7. 第六阶段仍然不要求所有场景都立刻完成背景图,也不要求进入发布。 + +--- + +## 5. 范围控制 + +## 5.1 第六阶段纳入范围 + +纳入范围的模块: + +- `packages/shared/src/contracts/customWorldAgent.ts` +- `src/components/custom-world-agent/CustomWorldAgentWorkspace.tsx` +- `src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx` +- `src/components/custom-world-agent/CustomWorldAgentQuickActions.tsx` +- `src/services/ai.ts` +- `src/services/aiService.ts` +- `server-node/src/services/customWorldAgentSessionStore.ts` +- `server-node/src/services/customWorldAgentOrchestrator.ts` +- `server-node/src/services/customWorldAgentDraftCompiler.ts` +- `server-node/src/services/customWorldWorkSummaryService.ts` +- `server-node/src/services/sceneImageService.ts` +- `server-node/src/routes/runtimeRoutes.ts` + +新增前端模块: + +- `src/components/custom-world-agent/CustomWorldSceneAssetStudioModal.tsx` + +新增服务端模块: + +- `server-node/src/services/customWorldAgentSceneAssetStateService.ts` + +## 5.2 第六阶段明确不做 + +以下内容不放进第六阶段: + +1. 不做角色资产工坊改造 +2. 不做长尾场景批量自动出图 +3. 不做场景图批量预生成 +4. 不做发布时强制所有场景图齐全 +5. 不做场景图精修工作流 +6. 不做地图连接与场景图自动同步生成 + +原因: + +**第六阶段只解决“选中的营地或场景如何进入背景图工坊并成功把结果写回草稿世界”。** + +--- + +## 6. 第六阶段最小闭环 + +建议把第六阶段的最小闭环定义为: + +```text +第五阶段已有 landmark/camp 卡 +-> 用户打开某张地点卡详情 +-> 点击“场景背景图” +-> 打开场景图工坊 +-> 输入 prompt 并开始生成 +-> 预览结果 +-> 保存 +-> sync_scene_assets +-> landmark/camp card / assetCoverage / 创作页面摘要同步更新 +``` + +这个闭环里,先只强接两条高价值链路: + +1. `landmark/camp card -> scene asset studio` +2. `scene asset studio save -> session sync` + +--- + +## 7. 第六阶段产品行为定义 + +## 7.1 哪些对象可以进入场景图工坊 + +第六阶段允许以下对象进入场景图工坊: + +1. `camp` +2. `landmark` + +不处理: + +1. 其他卡片类型 + +## 7.2 入口位置 + +### 地点卡详情入口 + +在 `CustomWorldAgentDraftDetailPanel` 中,当当前卡类型为: + +```ts +kind === 'landmark' || kind === 'camp' +``` + +显示按钮: + +- `场景背景图` + +### 快捷动作入口 + +当当前 focus card 为 `landmark` 或 `camp` 时,`CustomWorldAgentQuickActions` 可显示: + +- `生成场景背景图` + +说明: + +快捷动作与详情按钮最终都打开同一个 modal。 + +## 7.3 第六阶段支持的场景图流程 + +### 阶段 A:场景图生成 + +允许: + +1. 输入场景内容描述 +2. 上传一张可选参考图 +3. 基于当前场景语义生成背景图 + +### 阶段 B:场景图预览 + +用户必须可以: + +1. 看到新生成图片 +2. 对比当前图片 +3. 决定是否保存 + +### 阶段 C:场景图保存 + +保存后把结果写回: + +1. `imageSrc` +2. `generatedSceneAssetId` +3. `generatedScenePrompt` +4. `generatedSceneModel` + +## 7.4 第六阶段不强制的事情 + +第六阶段明确不强制: + +1. 每个场景都必须立刻生成背景图 +2. 每个背景图都必须一次满意 +3. 没有背景图的场景不能继续文本创作 + +说明: + +这一步是“场景开始接资产”,不是“所有场景必须立刻完工”。** + +## 7.5 积分消耗提示规则 + +和第五阶段保持一致: + +**不做预算限制,但高成本生成前必须明确提示积分消耗。** + +因此第六阶段必须遵守: + +### 生成前 + +必须提示: + +1. 本次会消耗多少积分 +2. 这是候选生成,不是最终发布 + +### 保存前 + +保存本身不应再次重复收积分,除非现有场景图服务明确要求。 + +--- + +## 8. 场景资产状态定义 + +## 8.1 `assetCoverage.sceneAssets` + +第六阶段必须开始真正使用它。 + +建议状态: + +```ts +type CustomWorldSceneAssetStatus = 'missing' | 'ready'; +``` + +### 含义 + +#### `missing` + +场景还没有正式背景图 + +#### `ready` + +场景已经有: + +1. `imageSrc` +2. `generatedSceneAssetId` + +## 8.2 场景对象写回字段 + +保存成功后,必须写回: + +```ts +imageSrc +generatedSceneAssetId +generatedScenePrompt +generatedSceneModel +``` + +### 明确要求 + +第六阶段不允许只更新 `assetCoverage`,不更新场景对象本身。 + +--- + +## 9. 数据结构落地方案 + +## 9.1 启用 `CustomWorldAgentActionRequest` + +第六阶段正式启用: + +```ts +| { + action: 'sync_scene_assets'; + sceneId: string; + sceneKind: 'camp' | 'landmark'; + imageSrc: string; + generatedSceneAssetId: string; + generatedScenePrompt?: string | null; + generatedSceneModel?: string | null; + } +``` + +### 第六阶段说明 + +不新增新的 `generate_scene_assets` 前置准备 action。 + +原因: + +当前场景图生成链已经由 `generateCustomWorldSceneImage(...)` 直接承接, +因此第六阶段只需要在保存成功后做 session 同步。 + +## 9.2 扩展 `CustomWorldSceneAssetSummary` + +第六阶段开始必须真正填: + +1. `imageSrc` +2. `generatedSceneAssetId` +3. `status` +4. `nextPointCost` + +## 9.3 新增场景资产同步结果结构 + +建议新增: + +```ts +type SyncSceneAssetsResult = { + sceneId: string; + sceneKind: 'camp' | 'landmark'; + updatedScene: Record; + updatedAssetSummary: CustomWorldSceneAssetSummary; +}; +``` + +--- + +## 10. 服务端实现方案 + +## 10.1 新增 `customWorldAgentSceneAssetStateService.ts` + +### 文件 + +`server-node/src/services/customWorldAgentSceneAssetStateService.ts` + +### 职责 + +根据营地或场景对象真实字段,更新: + +1. `assetCoverage.sceneAssets` +2. `draftCards` 中地点卡 / 营地卡的副摘要 +3. 创作页面作品卡统计 + +### 导出函数建议 + +```ts +rebuildSceneAssetCoverage(draftProfile) +mergeSceneAssetIntoDraftProfile(draftProfile, payload) +``` + +## 10.2 修改 `customWorldAgentOrchestrator.ts` + +第六阶段必须启用: + +1. `sync_scene_assets` + +### `sync_scene_assets` 流程 + +```text +收到 sync_scene_assets +-> 校验 sceneId +-> 校验 sceneKind +-> 写回 draftProfile.camp 或 draftProfile.landmarks +-> 重建 assetCoverage.sceneAssets +-> 重新编译地点卡 / 营地卡摘要 +-> 写入 assistant action_result +-> 写入 checkpoint +-> operation completed +``` + +### camp 写回规则 + +当: + +```ts +sceneKind === 'camp' +``` + +写回: + +```ts +draftProfile.camp +``` + +### landmark 写回规则 + +当: + +```ts +sceneKind === 'landmark' +``` + +写回: + +```ts +draftProfile.landmarks.find(...) +``` + +## 10.3 修改 `customWorldAgentDraftCompiler.ts` + +第六阶段它必须让: + +1. `landmark` 卡摘要带出场景图状态 +2. `camp` 卡摘要带出场景图状态 + +### 推荐展示方式 + +在 `subtitle` 或 `summary` 中追加: + +1. `背景图已就绪` +2. `待生成背景图` + +但不要把卡片变成技术表。 + +## 10.4 修改 `customWorldWorkSummaryService.ts` + +第六阶段创作页面草稿卡应支持展示: + +1. 已有多少场景具备背景图 + +第一版如果不想上具体数字,也至少要能体现: + +- `场景资产进行中` + +--- + +## 11. 前端实现方案 + +## 11.1 新增 `CustomWorldSceneAssetStudioModal.tsx` + +### 来源 + +从当前 `CustomWorldEntityEditorModal.tsx` 中的 `SceneImageGenerationModal` 思路抽出, +但改成可被 Agent 工作区调用的版本。 + +### props + +```ts +{ + profile: Pick; + scene: { + id: string; + kind: 'camp' | 'landmark'; + name: string; + description: string; + dangerLevel?: string; + imageSrc?: string; + }; + onPublishSuccess: (payload) => void; + onClose: () => void; +} +``` + +### 功能 + +1. 输入场景 prompt +2. 上传可选参考图 +3. 调用 `generateCustomWorldSceneImage(...)` +4. 展示预览 +5. 保存并回调 `onPublishSuccess` + +## 11.2 修改 `CustomWorldAgentDraftDetailPanel.tsx` + +当卡片类型为 `landmark` 或 `camp` 时,新增: + +1. `场景背景图` 按钮 +2. 资产状态 badge + +### 状态显示建议 + +1. `待生成背景图` +2. `背景图已就绪` + +## 11.3 修改 `CustomWorldAgentQuickActions.tsx` + +当当前 focus card 为 `landmark` 或 `camp` 时,可显示: + +- `生成场景背景图` + +点击后: + +1. 直接打开 `CustomWorldSceneAssetStudioModal` + +## 11.4 修改 `CustomWorldAgentWorkspace.tsx` + +新增状态: + +```ts +activeSceneAssetTarget?: { + sceneId: string; + sceneKind: 'camp' | 'landmark'; +} | null; +showSceneAssetStudio: boolean; +``` + +### 打开逻辑 + +1. 来自 detail panel +2. 来自 quick actions + +### 关闭逻辑 + +关闭不代表写回成功。 + +必须等: + +1. 场景图生成完成并保存 +2. `sync_scene_assets` 成功 + +之后才刷新场景资产状态。 + +## 11.5 修改 `CustomWorldCreationHub.tsx` + +第六阶段它必须支持草稿作品卡的“场景资产进度感”。 + +第一版至少做到: + +1. 草稿卡可展示: + - 场景资产进行中 + - 或若数量可得,则展示场景背景图完成数 + +--- + +## 12. 交互时序 + +## 12.1 打开场景图工坊 + +```text +用户点击地点卡 +-> 打开 detail panel +-> 点击“场景背景图” +-> 前端打开 CustomWorldSceneAssetStudioModal +``` + +## 12.2 保存场景图 + +```text +用户在工坊中生成场景图 +-> 预览结果 +-> 点击保存 +-> 工坊得到 imageSrc + generatedSceneAssetId + prompt + model +-> 前端调用 sync_scene_assets +-> 服务端写回 draftProfile.camp 或 landmark +-> 服务端重建 assetCoverage.sceneAssets +-> 服务端重编译地点卡摘要 +-> 前端刷新 snapshot +-> 工坊关闭 +``` + +--- + +## 13. 与第五阶段的兼容要求 + +## 13.1 兼容有图场景 + +如果某场景已经有: + +1. `imageSrc` +2. `generatedSceneAssetId` + +仍允许继续打开工坊重新生成,再保存覆盖。 + +## 13.2 兼容无图场景 + +如果某场景完全无: + +1. `imageSrc` +2. `generatedSceneAssetId` + +也允许打开工坊从零生成。 + +## 13.3 兼容新增场景 + +第四阶段新增的 `landmark` 一旦存在于 `draftProfile.landmarks` 中,立即允许进入场景图工坊。 + +--- + +## 14. 落地文件清单 + +## 14.1 frontend + +必须新增: + +1. `src/components/custom-world-agent/CustomWorldSceneAssetStudioModal.tsx` + +必须修改: + +1. `src/components/custom-world-agent/CustomWorldAgentWorkspace.tsx` +2. `src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx` +3. `src/components/custom-world-agent/CustomWorldAgentQuickActions.tsx` +4. `src/components/custom-world-home/CustomWorldCreationHub.tsx` +5. `src/services/ai.ts` +6. `src/services/aiService.ts` + +## 14.2 backend + +必须新增: + +1. `server-node/src/services/customWorldAgentSceneAssetStateService.ts` + +必须修改: + +1. `server-node/src/services/customWorldAgentOrchestrator.ts` +2. `server-node/src/services/customWorldAgentDraftCompiler.ts` +3. `server-node/src/services/customWorldAgentSessionStore.ts` +4. `server-node/src/services/customWorldWorkSummaryService.ts` +5. `server-node/src/services/sceneImageService.ts` +6. `server-node/src/routes/runtimeRoutes.ts` + +--- + +## 15. 测试要求 + +## 15.1 服务端测试 + +至少要补: + +1. `sync_scene_assets` 能正确写回 `camp` +2. `sync_scene_assets` 能正确写回 `landmark` +3. 写回后 `assetCoverage.sceneAssets` 状态更新 +4. 写回后地点卡 / 营地卡摘要更新 +5. 写回后 checkpoint 存在 + +## 15.2 前端测试 + +至少要补: + +1. landmark/camp 卡详情可显示背景图入口 +2. quick actions 可打开场景图工坊 +3. 工坊保存成功后会触发 `sync_scene_assets` +4. snapshot 刷新后场景卡显示新状态 + +## 15.3 手工回归 + +至少走这 4 条: + +1. 为一个无图场景生成背景图 +2. 为营地生成背景图 +3. 返回 workspace 确认场景卡状态变化 +4. 返回创作页面确认草稿卡摘要变化 + +--- + +## 16. 第六阶段验收标准 + +做到以下几点,才算第六阶段真正完成: + +1. 场景卡和营地卡已经可以接入并打开背景图工坊。 +2. 保存成功后,场景对象会写回 `imageSrc / generatedSceneAssetId / generatedScenePrompt / generatedSceneModel`。 +3. 场景资产状态会同步反映到 session snapshot 和场景卡摘要。 +4. 场景图接入不会阻塞继续文本创作。 +5. 第六阶段仍然不越界去做长尾自动补齐和发布逻辑。 + +--- + +## 17. 一句话结论 + +第六阶段最重要的不是“让所有场景都立刻有图”,而是: + +**先把草稿世界里的营地和关键场景,真正接到一条可预览、可保存、可写回的背景图工坊链路上。** diff --git a/src/components/CustomWorldGenerationView.tsx b/src/components/CustomWorldGenerationView.tsx index c37f8881..c0d8318b 100644 --- a/src/components/CustomWorldGenerationView.tsx +++ b/src/components/CustomWorldGenerationView.tsx @@ -13,7 +13,17 @@ interface CustomWorldGenerationViewProps { onBack: () => void; onEditSetting: () => void; onRetry: () => void; - onInterrupt: () => void; + onInterrupt?: () => void; + backLabel?: string; + settingActionLabel?: string; + retryLabel?: string; + interruptLabel?: string; + settingTitle?: string; + settingDescription?: string; + progressTitle?: string; + activeBadgeLabel?: string; + pausedBadgeLabel?: string; + idleBadgeLabel?: string; } function formatDuration(ms: number) { @@ -46,6 +56,16 @@ export function CustomWorldGenerationView({ onEditSetting, onRetry, onInterrupt, + backLabel = '返回', + settingActionLabel = '修改设定', + retryLabel = '重新开始生成', + interruptLabel = '中断世界生成', + settingTitle = '玩家设定', + settingDescription = '这段文本会直接驱动本轮世界框架、角色与场景生成。', + progressTitle = '生成进度', + activeBadgeLabel = '世界建设中', + pausedBadgeLabel = '生成已暂停', + idleBadgeLabel = '等待操作', }: CustomWorldGenerationViewProps) { const progressValue = getProgressPercentage(progress); const steps = progress?.steps ?? []; @@ -68,10 +88,14 @@ export function CustomWorldGenerationView({ disabled={isGenerating} className={`rounded-full border border-white/10 bg-black/18 px-3 py-1.5 text-[11px] text-zinc-300 transition-colors hover:text-white ${isGenerating ? 'cursor-not-allowed opacity-45' : ''}`} > - 返回 + {backLabel}
- {isGenerating ? '世界建设中' : error ? '生成已暂停' : '等待操作'} + {isGenerating + ? activeBadgeLabel + : error + ? pausedBadgeLabel + : idleBadgeLabel}
@@ -86,10 +110,10 @@ export function CustomWorldGenerationView({
- 玩家设定 + {settingTitle}
- 这段文本会直接驱动本轮世界框架、角色与场景生成。 + {settingDescription}
@@ -116,7 +140,7 @@ export function CustomWorldGenerationView({
- 生成进度 + {progressTitle}
{progress?.phaseLabel ?? '正在启动世界生成'} @@ -211,7 +235,7 @@ export function CustomWorldGenerationView({ onClick={onEditSetting} className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-300 transition-colors hover:text-white" > - 返回修改 + {settingActionLabel} - ) : ( + ) : onInterrupt ? ( - )} + ) : null}
diff --git a/src/components/GameShell.tsx b/src/components/GameShell.tsx index 81080c5b..4592f9a7 100644 --- a/src/components/GameShell.tsx +++ b/src/components/GameShell.tsx @@ -273,9 +273,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP gameState.currentScene === 'Selection' && Boolean(gameState.worldType) && !gameState.playerCharacter; - const hideSelectionHero = - gameState.currentScene === 'Selection' && - selectionStage !== 'platform'; + const collapseTopStage = gameState.currentScene === 'Selection'; const shouldHideStoryOptions = sceneTransitionPhase !== 'idle'; const dialogueIndicator = useMemo(() => { @@ -375,15 +373,8 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP backgroundRepeat: 'repeat', }} > -
- {gameState.currentScene === 'Selection' && !hideSelectionHero ? ( -
-
-
叙世
-
视觉叙事 RPG
-
-
- ) : ( +
+ {collapseTopStage ? null : ( - {gameState.currentScene === 'Selection' && !hideSelectionHero ? ( -
-
-
叙世
-
视觉叙事 RPG
-
-
- ) : ( + {hideSelectionHero ? null : ( void; + onSelectRpg: () => void; +}; + +type CreationGameTypeCard = { + id: 'rpg' | 'airp' | 'visual-novel'; + title: string; + subtitle: string; + badge: string; + locked: boolean; +}; + +const CREATION_GAME_TYPES: CreationGameTypeCard[] = [ + { + id: 'rpg', + title: '角色扮演 RPG', + subtitle: 'Agent 共创', + badge: '可创建', + locked: false, + }, + { + id: 'airp', + title: 'AIRP', + subtitle: '敬请期待', + badge: '锁定', + locked: true, + }, + { + id: 'visual-novel', + title: '视觉小说', + subtitle: '敬请期待', + badge: '锁定', + locked: true, + }, +]; + +function CreationTypeCard(props: { + item: CreationGameTypeCard; + busy: boolean; + onSelect: () => void; +}) { + const { item, busy, onSelect } = props; + const disabled = item.locked || busy; + + return ( + + ); +} + +export function PlatformCreationTypeModal({ + isOpen, + isBusy, + error, + onClose, + onSelectRpg, +}: PlatformCreationTypeModalProps) { + if (!isOpen) { + return null; + } + + return ( +
+
+
+
+
+
+ 选择创作类型 +
+
+ 先选玩法类型,再进入对应创作工作台。 +
+
+ +
+ +
+
+ {CREATION_GAME_TYPES.map((item) => ( + { + if (item.id === 'rpg') { + onSelectRpg(); + } + }} + /> + ))} +
+ + {error ? ( +
+ {error} +
+ ) : null} +
+
+
+
+ ); +} diff --git a/src/components/game-shell/PlatformHomeView.tsx b/src/components/game-shell/PlatformHomeView.tsx index bfa6ec87..a5dd9e67 100644 --- a/src/components/game-shell/PlatformHomeView.tsx +++ b/src/components/game-shell/PlatformHomeView.tsx @@ -1,14 +1,27 @@ +import { type ComponentType, useMemo } from 'react'; +import { + BookOpen, + Camera, + ChevronRight, + Clock3, + Coins, + Copy, + Crown, + MessageCircle, + Pencil, + Settings, + Ticket, + UserPlus, +} from 'lucide-react'; + import type { CustomWorldGalleryCard, CustomWorldLibraryEntry, } from '../../../packages/shared/src/contracts/runtime'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; +import type { AuthUser } from '../../services/authService'; import type { CustomWorldProfile } from '../../types'; -import { - CHROME_ICONS, - getNineSliceStyle, - UI_CHROME, -} from '../../uiAssets'; +import { getNineSliceStyle, UI_CHROME } from '../../uiAssets'; import { useAuthUi } from '../auth/AuthUiContext'; import { PixelIcon } from '../PixelIcon'; import { @@ -20,43 +33,20 @@ import { resolvePlatformWorldLeadPortrait, } from './platformWorldPresentation'; -function SectionHeader({ - title, - detail, - actionLabel, - onAction, -}: { - title: string; - detail: string; - actionLabel?: string; - onAction?: (() => void) | null; -}) { +export type PlatformHomeTab = 'home' | 'create' | 'profile'; + +function SectionHeader({ title, detail }: { title: string; detail: string }) { return ( -
-
-
- {detail} -
-
{title}
+
+
+ {detail}
- {actionLabel && onAction ? ( - - ) : null} +
{title}
); } -function EmptyShelf({ - text, -}: { - text: string; -}) { +function EmptyShelf({ text }: { text: string }) { return (
void; +}) { + return ( + + ); +} + +function formatSnapshotTime(value: string | null | undefined) { + if (!value) { + return '刚刚保存'; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + + return date.toLocaleString('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); +} + +function describeLoginMethod(loginMethod: AuthUser['loginMethod']) { + switch (loginMethod) { + case 'phone': + return '手机号'; + case 'wechat': + return '微信'; + default: + return '账号密码'; + } +} + +function describeBindingStatus(bindingStatus: AuthUser['bindingStatus']) { + return bindingStatus === 'pending_bind_phone' ? '待绑定手机号' : '正常'; +} + +function formatPlayTime(playTimeMs: number) { + const totalSeconds = Math.max(0, Math.floor(playTimeMs / 1000)); + const days = Math.floor(totalSeconds / 86400); + const hours = Math.floor((totalSeconds % 86400) / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + + if (days > 0) { + return `${days}天 ${hours}小时`; + } + if (hours > 0) { + return `${hours}小时 ${minutes}分`; + } + return `${minutes}分`; +} + +function buildPublicUserCode(user: AuthUser | null | undefined) { + const raw = + user?.id.replace(/[^a-zA-Z0-9]/gu, '').toUpperCase() || + user?.username.replace(/[^a-zA-Z0-9]/gu, '').toUpperCase() || + '00000000'; + + return `SY-${raw.slice(-8).padStart(8, '0')}`; +} + +function getUserAvatarLabel(user: AuthUser | null | undefined) { + return (user?.displayName || user?.username || '叙').slice(0, 1).toUpperCase(); +} + +function copyText(value: string) { + if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) { + return; + } + + void navigator.clipboard.writeText(value); +} + +function ProfileStatCard({ + label, + value, + icon, +}: { + label: string; + value: string; + icon: ComponentType<{ className?: string }>; +}) { + const Icon = icon; + + return ( +
+
+ + {label} +
+
{value}
+
+ ); +} + +function ProfileShortcutButton({ + label, + icon, + onClick, +}: { + label: string; + icon: ComponentType<{ className?: string }>; + onClick?: (() => void) | null; +}) { + const Icon = icon; + + return ( + + ); +} + export function PlatformHomeView({ + activeTab, + onTabChange, hasSavedGame, savedSnapshot, featuredEntries, @@ -159,11 +315,13 @@ export function PlatformHomeView({ isLoadingPlatform, platformError, onContinueGame, - onRefresh, onOpenCreateWorld, + onOpenCreateTypePicker, onOpenGalleryDetail, onOpenLibraryDetail, }: { + activeTab: PlatformHomeTab; + onTabChange: (tab: PlatformHomeTab) => void; hasSavedGame: boolean; savedSnapshot: HydratedSavedGameSnapshot | null; featuredEntries: CustomWorldGalleryCard[]; @@ -172,12 +330,18 @@ export function PlatformHomeView({ isLoadingPlatform: boolean; platformError: string | null; onContinueGame: () => void; - onRefresh: () => void; onOpenCreateWorld: () => void; + onOpenCreateTypePicker: () => void; onOpenGalleryDetail: (entry: CustomWorldGalleryCard) => void; - onOpenLibraryDetail: (entry: CustomWorldLibraryEntry) => void; + onOpenLibraryDetail: ( + entry: CustomWorldLibraryEntry, + ) => void; }) { const authUi = useAuthUi(); + const featuredShelf = useMemo( + () => featuredEntries.slice(0, 6), + [featuredEntries], + ); const snapshotWorldName = savedSnapshot?.gameState.customWorldProfile?.name ?? savedSnapshot?.gameState.currentScenePreset?.name ?? @@ -186,169 +350,401 @@ export function PlatformHomeView({ savedSnapshot?.gameState.playerCharacter?.title ?? savedSnapshot?.gameState.playerCharacter?.name ?? '旅人'; - const featuredShelf = featuredEntries.slice(0, 6); + const snapshotDigest = + savedSnapshot?.gameState.storyEngineMemory?.continueGameDigest ?? + savedSnapshot?.currentStory?.text ?? + savedSnapshot?.gameState.customWorldProfile?.summary ?? + '上一次冒险已经保存,可以从这里继续推进故事。'; + const publicUserCode = buildPublicUserCode(authUi?.user); + const avatarLabel = getUserAvatarLabel(authUi?.user); + const remainingNarrativeCoins = savedSnapshot?.gameState.playerCurrency ?? 0; + const totalPlayTime = formatPlayTime( + savedSnapshot?.gameState.runtimeStats.playTimeMs ?? 0, + ); + const playedWorkCount = hasSavedGame ? 1 : 0; + const tabIcons = { + home: "/Icons/Admurin's Pixel Items/Admurin's Pixel Items/Miscellaneous/Singles/192_RustyTrinket_House.png", + create: '/Icons/01_Scroll.png', + profile: '/UI/Icon_Eq_Head.png', + } as const; + const recentPlayItems = savedSnapshot + ? [ + { + id: 'latest-save', + title: snapshotWorldName, + subtitle: snapshotCharacterName, + summary: snapshotDigest, + updatedAt: savedSnapshot.savedAt, + }, + ] + : []; - return ( -
-
-
-
- -
-
-
- GENARRATIVE PLATFORM + let content = ( +
+ - {authUi?.user ? ( - + + {platformError ? ( +
+ {platformError} +
+ ) : null} + +
+ + {isLoadingPlatform ? ( + + ) : featuredShelf.length > 0 ? ( +
+ {featuredShelf.map((entry: CustomWorldGalleryCard) => ( + onOpenGalleryDetail(entry)} + /> + ))} +
+ ) : ( + + )} +
+ +
+ + {isLoadingPlatform ? ( + + ) : latestEntries.length > 0 ? ( +
+ {latestEntries.map((entry: CustomWorldGalleryCard) => ( + onOpenGalleryDetail(entry)} + /> + ))} +
+ ) : ( + + )} +
+
+ ); + + if (activeTab === 'create') { + content = ( +
+ + +
+ + {isLoadingPlatform ? ( + + ) : myEntries.length > 0 ? ( +
+ {myEntries.map( + (entry: CustomWorldLibraryEntry) => ( + onOpenLibraryDetail(entry)} + /> + ), + )} +
+ ) : ( + + )} +
+
+ ); + } + + if (activeTab === 'profile') { + content = ( +
+ {authUi?.user ? ( + <> +
+
+
+ + +
+
+
+ {authUi.user.displayName} +
+ +
+
+ 叙世号 {publicUserCode} + +
+
+ + {describeLoginMethod(authUi.user.loginMethod)} + + + {describeBindingStatus(authUi.user.bindingStatus)} + +
+
+
+ + +
+
+ +
- {authUi.user.displayName} - - ) : null} +
+ + + +
+
+ +
+ + {recentPlayItems.length > 0 ? ( +
+ {recentPlayItems.map((item) => ( + + ))} +
+ ) : ( + + )} +
+ +
+ +
+ + + +
+
+ +
+ +
+ + ) : ( + + )} +
+ ); + } + + return ( +
+
+
叙世
+
+ GENARRATIVE
-
- + {content} +
- {platformError ? ( -
- {platformError} -
- ) : null} - -
- - {isLoadingPlatform ? ( - - ) : featuredShelf.length > 0 ? ( -
- {featuredShelf.map((entry) => ( - onOpenGalleryDetail(entry)} - /> - ))} -
- ) : ( - - )} -
- -
- - {isLoadingPlatform ? ( - - ) : latestEntries.length > 0 ? ( -
- {latestEntries.map((entry) => ( - onOpenGalleryDetail(entry)} - /> - ))} -
- ) : ( - - )} -
- -
- -
- - - {myEntries.map((entry) => ( - onOpenLibraryDetail(entry)} - /> - ))} -
- {!isLoadingPlatform && myEntries.length === 0 ? ( -
- -
- ) : null} -
+
+
+ onTabChange('home')} + /> + onTabChange('create')} + /> + onTabChange('profile')} + />
diff --git a/src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx b/src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx new file mode 100644 index 00000000..04afb375 --- /dev/null +++ b/src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx @@ -0,0 +1,153 @@ +/* @vitest-environment jsdom */ + +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; +import { beforeEach, expect, test, vi } from 'vitest'; + +import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent'; +import { + createCustomWorldAgentSession, + getCustomWorldAgentSession, +} from '../../services/aiService'; +import { + listCustomWorldGallery, + listCustomWorldLibrary, +} from '../../services/storageService'; +import type { GameState } from '../../types'; +import { + PreGameSelectionFlow, + type SelectionStage, +} from './PreGameSelectionFlow'; + +vi.mock('../../services/aiService', () => ({ + createCustomWorldAgentSession: vi.fn(), + executeCustomWorldAgentAction: vi.fn(), + generateCustomWorldProfile: vi.fn(), + getCustomWorldAgentOperation: vi.fn(), + getCustomWorldAgentSession: vi.fn(), + sendCustomWorldAgentMessage: vi.fn(), +})); + +vi.mock('../../services/storageService', () => ({ + getCustomWorldGalleryDetail: vi.fn(), + listCustomWorldGallery: vi.fn(), + listCustomWorldLibrary: vi.fn(), + publishCustomWorldProfile: vi.fn(), + unpublishCustomWorldProfile: vi.fn(), + upsertCustomWorldProfile: vi.fn(), +})); + +vi.mock('../custom-world-agent/CustomWorldAgentWorkspace', () => ({ + CustomWorldAgentWorkspace: ({ + session, + }: { + session: CustomWorldAgentSessionSnapshot | null; + }) => ( +
+ Agent工作区:{session?.sessionId ?? 'missing-session'} +
+ ), +})); + +const mockSession: CustomWorldAgentSessionSnapshot = { + sessionId: 'custom-world-agent-session-1', + stage: 'clarifying', + focusCardId: null, + creatorIntent: {}, + creatorIntentReadiness: { + isReady: false, + completedKeys: ['world_hook'], + missingKeys: [ + 'player_premise', + 'theme_and_tone', + 'core_conflict', + 'relationship_seed', + 'iconic_element', + ], + }, + anchorPack: {}, + lockState: {}, + draftProfile: null, + messages: [ + { + id: 'message-1', + role: 'assistant', + kind: 'summary', + text: '先告诉我你想做一个怎样的 RPG 世界。', + createdAt: '2026-04-14T12:00:00.000Z', + relatedOperationId: null, + }, + ], + draftCards: [], + pendingClarifications: [], + suggestedActions: [], + recommendedReplies: [], + qualityFindings: [], + assetCoverage: { + roleAssets: [], + sceneAssets: [], + allRoleAssetsReady: false, + allSceneAssetsReady: false, + }, + updatedAt: '2026-04-14T12:00:00.000Z', +}; + +function TestWrapper() { + const [selectionStage, setSelectionStage] = + useState('platform'); + + return ( + {}} + handleStartNewGame={() => {}} + handleCustomWorldSelect={() => {}} + /> + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + window.history.replaceState(null, '', '/'); + window.sessionStorage.clear(); + vi.mocked(listCustomWorldLibrary).mockResolvedValue([]); + vi.mocked(listCustomWorldGallery).mockResolvedValue([]); + vi.mocked(createCustomWorldAgentSession).mockResolvedValue({ + session: mockSession, + }); + vi.mocked(getCustomWorldAgentSession).mockResolvedValue(mockSession); +}); + +test('create tab opens game type modal, keeps AIRP and visual novel locked, and enters agent workspace for RPG', async () => { + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByRole('button', { name: '创作' })); + await user.click(screen.getByRole('button', { name: /开启新的创作/u })); + + expect(screen.getByText('选择创作类型')).toBeTruthy(); + + const airpButton = screen.getByRole('button', { name: /AIRP/u }); + const visualNovelButton = screen.getByRole('button', { + name: /视觉小说/u, + }); + + expect((airpButton as HTMLButtonElement).disabled).toBe(true); + expect((visualNovelButton as HTMLButtonElement).disabled).toBe(true); + + await user.click(screen.getByRole('button', { name: /角色扮演 RPG/u })); + + await waitFor(() => { + expect(createCustomWorldAgentSession).toHaveBeenCalledTimes(1); + }); + + expect( + await screen.findByText('Agent工作区:custom-world-agent-session-1'), + ).toBeTruthy(); +}); diff --git a/src/components/game-shell/PreGameSelectionFlow.tsx b/src/components/game-shell/PreGameSelectionFlow.tsx index f7141b13..2c1676a5 100644 --- a/src/components/game-shell/PreGameSelectionFlow.tsx +++ b/src/components/game-shell/PreGameSelectionFlow.tsx @@ -10,6 +10,12 @@ import { } from 'react'; import type { JsonObject } from '../../../packages/shared/src/contracts/common'; +import type { + CustomWorldAgentActionRequest, + CustomWorldAgentOperationRecord, + CustomWorldAgentSessionSnapshot, + SendCustomWorldAgentMessageRequest, +} from '../../../packages/shared/src/contracts/customWorldAgent'; import type { CustomWorldGalleryCard, CustomWorldGenerationProgress, @@ -17,7 +23,24 @@ import type { } from '../../../packages/shared/src/contracts/runtime'; import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; -import { generateCustomWorldProfile } from '../../services/aiService'; +import { + createCustomWorldAgentSession, + executeCustomWorldAgentAction, + generateCustomWorldProfile, + getCustomWorldAgentOperation, + getCustomWorldAgentSession, + sendCustomWorldAgentMessage, +} from '../../services/aiService'; +import { + readCustomWorldAgentUiState, + writeCustomWorldAgentUiState, +} from '../../services/customWorldAgentUiState'; +import { + buildAgentDraftFoundationGenerationProgress, + buildAgentDraftFoundationSettingText, + isDraftFoundationOperation, + isDraftFoundationOperationRunning, +} from '../../services/customWorldAgentGenerationProgress'; import { buildCustomWorldCreatorIntentDisplayText, buildCustomWorldCreatorIntentGenerationText, @@ -37,7 +60,8 @@ import { type CustomWorldProfile, type GameState, } from '../../types'; -import { PlatformHomeView } from './PlatformHomeView'; +import { PlatformCreationTypeModal } from './PlatformCreationTypeModal'; +import { type PlatformHomeTab,PlatformHomeView } from './PlatformHomeView'; import { PlatformWorldDetailView } from './PlatformWorldDetailView'; const CustomWorldGenerationView = lazy(async () => { @@ -61,12 +85,27 @@ const CustomWorldCreatorModal = lazy(async () => { }; }); +const CustomWorldAgentWorkspace = lazy(async () => { + const module = await import( + '../custom-world-agent/CustomWorldAgentWorkspace' + ); + return { + default: module.CustomWorldAgentWorkspace, + }; +}); + export type SelectionStage = | 'platform' | 'detail' + | 'agent-workspace' | 'custom-world-generating' | 'custom-world-result'; +type CustomWorldGenerationViewSource = + | 'classic' + | 'agent-draft-foundation' + | null; + type PreGameSelectionFlowProps = { selectionStage: SelectionStage; setSelectionStage: (stage: SelectionStage) => void; @@ -151,6 +190,22 @@ function resolveErrorMessage(error: unknown, fallback: string) { return error instanceof Error ? error.message : fallback; } +function createFailedAgentOperation(params: { + type: CustomWorldAgentOperationRecord['type']; + phaseLabel: string; + error: string; +}): CustomWorldAgentOperationRecord { + return { + operationId: `local-failed-${Date.now()}`, + type: params.type, + status: 'failed', + phaseLabel: params.phaseLabel, + phaseDetail: params.error, + progress: 100, + error: params.error, + }; +} + function LazyPanelFallback({ label }: { label: string }) { return (
@@ -170,6 +225,8 @@ export function PreGameSelectionFlow({ handleStartNewGame, handleCustomWorldSelect, }: PreGameSelectionFlowProps) { + const initialAgentUiStateRef = useRef(readCustomWorldAgentUiState()); + const hasAppliedInitialAgentWorkspaceRef = useRef(false); const [generatedCustomWorldProfile, setGeneratedCustomWorldProfile] = useState(null); const [savedCustomWorldEntries, setSavedCustomWorldEntries] = useState< @@ -178,8 +235,25 @@ export function PreGameSelectionFlow({ const [publishedGalleryEntries, setPublishedGalleryEntries] = useState< CustomWorldGalleryCard[] >([]); + const [platformTab, setPlatformTab] = useState('home'); const [selectedDetailEntry, setSelectedDetailEntry] = useState | null>(null); + const [showCreationTypeModal, setShowCreationTypeModal] = useState(false); + const [creationTypeError, setCreationTypeError] = useState( + null, + ); + const [isCreatingAgentSession, setIsCreatingAgentSession] = useState(false); + const [activeAgentSessionId, setActiveAgentSessionId] = useState< + string | null + >(() => initialAgentUiStateRef.current.activeSessionId ?? null); + const [activeAgentOperationId, setActiveAgentOperationId] = useState< + string | null + >(() => initialAgentUiStateRef.current.activeOperationId ?? null); + const [agentSession, setAgentSession] = + useState(null); + const [agentOperation, setAgentOperation] = + useState(null); + const [isLoadingAgentSession, setIsLoadingAgentSession] = useState(false); const [showCustomWorldModal, setShowCustomWorldModal] = useState(false); const [customWorldCreatorIntent, setCustomWorldCreatorIntent] = useState(() => @@ -196,6 +270,10 @@ export function PreGameSelectionFlow({ const [isMutatingDetail, setIsMutatingDetail] = useState(false); const [customWorldProgress, setCustomWorldProgress] = useState(null); + const [customWorldGenerationViewSource, setCustomWorldGenerationViewSource] = + useState(null); + const [agentDraftGenerationStartedAt, setAgentDraftGenerationStartedAt] = + useState(null); const customWorldAbortControllerRef = useRef(null); const previewCustomWorldCharacters = useMemo( @@ -211,6 +289,24 @@ export function PreGameSelectionFlow({ [publishedGalleryEntries], ); + const persistAgentUiState = useCallback( + (nextSessionId: string | null, nextOperationId: string | null) => { + setActiveAgentSessionId(nextSessionId); + setActiveAgentOperationId(nextOperationId); + writeCustomWorldAgentUiState({ + activeSessionId: nextSessionId, + activeOperationId: nextOperationId, + }); + }, + [], + ); + + const syncAgentSessionSnapshot = useCallback(async (sessionId: string) => { + const nextSession = await getCustomWorldAgentSession(sessionId); + setAgentSession(nextSession); + return nextSession; + }, []); + const refreshPlatformData = useCallback(async () => { setIsLoadingPlatform(true); setPlatformError(null); @@ -239,6 +335,18 @@ export function PreGameSelectionFlow({ } }, [selectedDetailEntry]); + useEffect(() => { + if (hasAppliedInitialAgentWorkspaceRef.current) { + return; + } + + hasAppliedInitialAgentWorkspaceRef.current = true; + if (initialAgentUiStateRef.current.activeSessionId) { + setPlatformTab('create'); + setSelectionStage('agent-workspace'); + } + }, [setSelectionStage]); + useEffect(() => { let isActive = true; @@ -293,6 +401,117 @@ export function PreGameSelectionFlow({ [], ); + useEffect(() => { + if (!activeAgentSessionId) { + setAgentSession(null); + setIsLoadingAgentSession(false); + return; + } + + let cancelled = false; + setIsLoadingAgentSession(true); + + void syncAgentSessionSnapshot(activeAgentSessionId) + .then(() => { + if (!cancelled) { + setCreationTypeError(null); + } + }) + .catch((error) => { + if (cancelled) { + return; + } + + setCreationTypeError( + resolveErrorMessage(error, '读取 Agent 共创工作区失败。'), + ); + setAgentSession(null); + setAgentOperation(null); + persistAgentUiState(null, null); + setPlatformTab('create'); + setSelectionStage('platform'); + }) + .finally(() => { + if (!cancelled) { + setIsLoadingAgentSession(false); + } + }); + + return () => { + cancelled = true; + }; + }, [ + activeAgentSessionId, + persistAgentUiState, + setSelectionStage, + syncAgentSessionSnapshot, + ]); + + useEffect(() => { + if (!activeAgentSessionId || !activeAgentOperationId) { + return; + } + + let cancelled = false; + + const pollOperation = async () => { + try { + const nextOperation = await getCustomWorldAgentOperation( + activeAgentSessionId, + activeAgentOperationId, + ); + + if (cancelled) { + return; + } + + setAgentOperation(nextOperation); + + if ( + nextOperation.status === 'completed' || + nextOperation.status === 'failed' + ) { + persistAgentUiState(activeAgentSessionId, null); + await syncAgentSessionSnapshot(activeAgentSessionId).catch( + () => null, + ); + } + } catch (error) { + if (cancelled) { + return; + } + + const errorMessage = resolveErrorMessage( + error, + '读取共创操作状态失败。', + ); + setAgentOperation( + createFailedAgentOperation({ + type: 'process_message', + phaseLabel: '读取操作状态失败', + error: errorMessage, + }), + ); + persistAgentUiState(activeAgentSessionId, null); + } + }; + + void pollOperation(); + const intervalId = window.setInterval(() => { + void pollOperation(); + }, 1200); + + return () => { + cancelled = true; + window.clearInterval(intervalId); + }; + }, [ + activeAgentOperationId, + activeAgentSessionId, + persistAgentUiState, + syncAgentSessionSnapshot, + ]); + const customWorldSettingPreview = useMemo(() => { if (customWorldCreatorIntent.sourceMode === 'freeform') { return customWorldCreatorIntent.rawSettingText.trim(); @@ -308,10 +527,40 @@ export function PreGameSelectionFlow({ return customWorldCreatorIntent.rawSettingText.trim(); }, [customWorldCreatorIntent]); + const agentDraftSettingPreview = useMemo( + () => buildAgentDraftFoundationSettingText(agentSession), + [agentSession], + ); + + const agentDraftGenerationProgress = useMemo( + () => + buildAgentDraftFoundationGenerationProgress( + agentOperation, + agentDraftGenerationStartedAt, + ), + [agentDraftGenerationStartedAt, agentOperation], + ); + + const isAgentDraftGenerationView = + customWorldGenerationViewSource === 'agent-draft-foundation'; + const activeGenerationProgress = isAgentDraftGenerationView + ? agentDraftGenerationProgress + : customWorldProgress; + const isActiveGenerationRunning = isAgentDraftGenerationView + ? isDraftFoundationOperationRunning(agentOperation) + : isGeneratingCustomWorld; + const activeGenerationError = + isAgentDraftGenerationView && + isDraftFoundationOperation(agentOperation) && + agentOperation.status === 'failed' + ? agentOperation.error || agentOperation.phaseDetail + : customWorldError; + const leaveCustomWorldResult = () => { setGeneratedCustomWorldProfile(null); setCustomWorldError(null); setCustomWorldProgress(null); + setCustomWorldGenerationViewSource(null); setSelectionStage(selectedDetailEntry ? 'detail' : 'platform'); }; @@ -322,6 +571,101 @@ export function PreGameSelectionFlow({ setCustomWorldError(null); setCustomWorldProgress(null); + setCustomWorldGenerationViewSource(null); + setSelectionStage('platform'); + }; + + const openCreationTypePicker = () => { + if (isCreatingAgentSession) { + return; + } + + setCreationTypeError(null); + setShowCreationTypeModal(true); + }; + + const openRpgAgentWorkspace = async () => { + if (isCreatingAgentSession) { + return; + } + + setIsCreatingAgentSession(true); + setCreationTypeError(null); + + try { + const { session } = await createCustomWorldAgentSession({}); + setAgentSession(session); + setAgentOperation(null); + persistAgentUiState(session.sessionId, null); + setShowCreationTypeModal(false); + setPlatformTab('create'); + setSelectionStage('agent-workspace'); + } catch (error) { + setCreationTypeError(resolveErrorMessage(error, '开启共创工作台失败。')); + } finally { + setIsCreatingAgentSession(false); + } + }; + + const submitAgentMessage = async ( + payload: SendCustomWorldAgentMessageRequest, + ) => { + if (!activeAgentSessionId) { + return; + } + + try { + const { operation } = await sendCustomWorldAgentMessage( + activeAgentSessionId, + payload, + ); + setAgentOperation(operation); + persistAgentUiState(activeAgentSessionId, operation.operationId); + } catch (error) { + const errorMessage = resolveErrorMessage(error, '发送共创消息失败。'); + setAgentOperation( + createFailedAgentOperation({ + type: 'process_message', + phaseLabel: '发送消息失败', + error: errorMessage, + }), + ); + persistAgentUiState(activeAgentSessionId, null); + } + }; + + const executeAgentAction = async (payload: CustomWorldAgentActionRequest) => { + if (!activeAgentSessionId) { + return; + } + + try { + const { operation } = await executeCustomWorldAgentAction( + activeAgentSessionId, + payload, + ); + setAgentOperation(operation); + persistAgentUiState(activeAgentSessionId, operation.operationId); + } catch (error) { + const errorMessage = resolveErrorMessage(error, '执行共创操作失败。'); + setAgentOperation( + createFailedAgentOperation({ + type: + payload.action === 'draft_foundation' + ? 'draft_foundation' + : payload.action, + phaseLabel: '执行操作失败', + error: errorMessage, + }), + ); + persistAgentUiState(activeAgentSessionId, null); + } + }; + + const leaveAgentWorkspace = () => { + setPlatformTab('create'); + setAgentOperation(null); + persistAgentUiState(activeAgentSessionId, null); setSelectionStage('platform'); }; @@ -340,7 +684,9 @@ export function PreGameSelectionFlow({ setDetailError(null); setCustomWorldError(null); setCustomWorldProgress(null); - setCustomWorldCreatorIntent(createEmptyCustomWorldCreatorIntent('freeform')); + setCustomWorldCreatorIntent( + createEmptyCustomWorldCreatorIntent('freeform'), + ); setCustomWorldGenerationMode('fast'); setShowCustomWorldModal(true); }; @@ -400,7 +746,9 @@ export function PreGameSelectionFlow({ } try { - const mutation = await upsertCustomWorldProfile(generatedCustomWorldProfile); + const mutation = await upsertCustomWorldProfile( + generatedCustomWorldProfile, + ); setSavedCustomWorldEntries(mutation.entries); setSelectedDetailEntry(mutation.entry); await refreshPlatformData(); @@ -684,18 +1032,20 @@ export function PreGameSelectionFlow({ className="flex h-full min-h-0 flex-col" > { - void refreshPlatformData(); - }} onOpenCreateWorld={openCustomWorldCreator} + onOpenCreateTypePicker={openCreationTypePicker} onOpenGalleryDetail={(entry) => { void openGalleryDetail(entry); }} @@ -750,6 +1100,50 @@ export function PreGameSelectionFlow({ )} + {selectionStage === 'agent-workspace' && ( + + + } + > + {agentSession ? ( + { + if (!activeAgentSessionId) { + return; + } + void syncAgentSessionSnapshot(activeAgentSessionId); + }} + onSubmitMessage={(payload) => { + void submitAgentMessage(payload); + }} + onExecuteAction={(payload) => { + void executeAgentAction(payload); + }} + /> + ) : ( +
+
+ {isLoadingAgentSession + ? '正在准备 Agent 共创工作区...' + : creationTypeError || '正在恢复创作工作区...'} +
+
+ )} +
+
+ )} + {selectionStage === 'custom-world-generating' && ( - } + fallback={} > + { + if (isCreatingAgentSession) { + return; + } + setShowCreationTypeModal(false); + }} + onSelectRpg={() => { + void openRpgAgentWorkspace(); + }} + /> + {showCustomWorldModal ? ( diff --git a/src/services/customWorldAgentGenerationProgress.test.ts b/src/services/customWorldAgentGenerationProgress.test.ts new file mode 100644 index 00000000..6ed1f591 --- /dev/null +++ b/src/services/customWorldAgentGenerationProgress.test.ts @@ -0,0 +1,131 @@ +import { expect, test } from 'vitest'; + +import type { + CustomWorldAgentOperationRecord, + CustomWorldAgentSessionSnapshot, +} from '../../packages/shared/src/contracts/customWorldAgent'; +import { + buildAgentDraftFoundationGenerationProgress, + buildAgentDraftFoundationSettingText, + isDraftFoundationOperationRunning, +} from './customWorldAgentGenerationProgress'; + +const baseOperation: CustomWorldAgentOperationRecord = { + operationId: 'operation-1', + type: 'draft_foundation', + status: 'running', + phaseLabel: '生成世界底稿', + phaseDetail: '正在根据已确认锚点编译第一版世界结构。', + progress: 38, + error: null, +}; + +const baseSession: CustomWorldAgentSessionSnapshot = { + sessionId: 'session-1', + stage: 'foundation_review', + focusCardId: null, + creatorIntent: { + sourceMode: 'card', + worldHook: '海雾、旧灯塔和失控航路交织的边缘群岛', + themeKeywords: ['海雾', '灯塔', '旧航路'], + toneDirectives: ['压抑', '悬疑'], + playerPremise: '玩家刚回到群岛,准备调查父亲沉船的真相。', + openingSituation: '首夜就有陌生船只在禁航区点灯。', + coreConflicts: ['航运公会与守灯会争夺航路控制权'], + keyFactions: [], + keyCharacters: [], + keyLandmarks: [], + iconicElements: ['会移动的海雾'], + forbiddenDirectives: [], + rawSettingText: '', + }, + creatorIntentReadiness: { + isReady: true, + completedKeys: [], + missingKeys: [], + }, + anchorPack: null, + lockState: null, + draftProfile: null, + messages: [ + { + id: 'message-1', + role: 'user', + kind: 'chat', + text: '我想做一个被海雾吞没的旧航路世界。', + createdAt: '2026-04-14T10:00:00.000Z', + relatedOperationId: null, + }, + ], + draftCards: [], + pendingClarifications: [], + suggestedActions: [], + recommendedReplies: [], + qualityFindings: [], + assetCoverage: { + roleAssets: [], + sceneAssets: [], + allRoleAssetsReady: false, + allSceneAssetsReady: false, + }, + updatedAt: '2026-04-14T10:00:00.000Z', +}; + +test('maps running draft_foundation operation to legacy generation progress', () => { + const progress = buildAgentDraftFoundationGenerationProgress( + baseOperation, + 1_000, + 5_000, + ); + + expect(progress).not.toBeNull(); + expect(progress?.phaseId).toBe('foundation'); + expect(progress?.batchLabel).toBe('生成世界底稿'); + expect(progress?.overallProgress).toBe(38); + expect(progress?.elapsedMs).toBe(4_000); + expect(progress?.estimatedRemainingMs).toBeGreaterThan(0); + expect(progress?.steps.map((step) => step.status)).toEqual([ + 'completed', + 'active', + 'pending', + 'pending', + ]); + expect(isDraftFoundationOperationRunning(baseOperation)).toBe(true); +}); + +test('marks all legacy progress steps complete when draft foundation finishes', () => { + const progress = buildAgentDraftFoundationGenerationProgress( + { + ...baseOperation, + status: 'completed', + phaseLabel: '世界底稿已生成', + phaseDetail: '第一版世界底稿和 6 张草稿卡已经整理完成。', + progress: 100, + }, + 1_000, + 5_000, + ); + + expect(progress?.phaseId).toBe('workspace'); + expect(progress?.estimatedRemainingMs).toBe(0); + expect(progress?.steps.every((step) => step.status === 'completed')).toBe( + true, + ); +}); + +test('builds readable draft setting text from creator intent first', () => { + const settingText = buildAgentDraftFoundationSettingText(baseSession); + + expect(settingText).toContain('世界核心'); + expect(settingText).toContain('玩家开局'); + expect(settingText).toContain('标志元素'); +}); + +test('falls back to latest user message when creator intent is unavailable', () => { + const settingText = buildAgentDraftFoundationSettingText({ + ...baseSession, + creatorIntent: null, + }); + + expect(settingText).toBe('我想做一个被海雾吞没的旧航路世界。'); +}); diff --git a/src/services/customWorldAgentGenerationProgress.ts b/src/services/customWorldAgentGenerationProgress.ts new file mode 100644 index 00000000..f36c4428 --- /dev/null +++ b/src/services/customWorldAgentGenerationProgress.ts @@ -0,0 +1,210 @@ +import type { + CustomWorldAgentOperationRecord, + CustomWorldAgentSessionSnapshot, +} from '../../packages/shared/src/contracts/customWorldAgent'; +import type { + CustomWorldGenerationProgress, + CustomWorldGenerationStep, +} from '../../packages/shared/src/contracts/runtime'; +import { + buildCustomWorldCreatorIntentDisplayText, + buildCustomWorldCreatorIntentGenerationText, + normalizeCustomWorldCreatorIntent, +} from './customWorldCreatorIntent'; + +const AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS = [ + { + id: 'queue', + label: '接收生成请求', + detail: '正在锁定当前已确认的世界锚点与草稿范围。', + }, + { + id: 'foundation', + label: '生成世界底稿', + detail: '正在根据世界核心、关系种子与冲突线编排第一版世界结构。', + }, + { + id: 'cards', + label: '编译草稿卡', + detail: '正在整理世界卡、角色卡与地点卡的摘要和详情。', + }, + { + id: 'workspace', + label: '准备精修工作区', + detail: '正在写回草稿数据,并切回可继续精修的工作区。', + }, +] as const satisfies ReadonlyArray<{ + id: string; + label: string; + detail: string; +}>; + +function clampProgress(progress: number | null | undefined) { + if (typeof progress !== 'number' || Number.isNaN(progress)) { + return 0; + } + + return Math.max(0, Math.min(100, Math.round(progress))); +} + +function resolveAgentDraftFoundationStepIndex( + operation: CustomWorldAgentOperationRecord, +) { + const progress = clampProgress(operation.progress); + const phaseLabel = operation.phaseLabel.trim(); + + if ( + operation.status === 'completed' || + phaseLabel.includes('世界底稿已生成') || + progress >= 90 + ) { + return 3; + } + + if (phaseLabel.includes('编译草稿卡') || progress >= 60) { + return 2; + } + + if (phaseLabel.includes('生成世界底稿') || progress >= 25) { + return 1; + } + + return 0; +} + +function buildAgentDraftFoundationSteps( + operation: CustomWorldAgentOperationRecord, + activeStepIndex: number, +) { + return AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS.map((step, index) => { + const isCompleted = + operation.status === 'completed' || index < activeStepIndex; + const isActive = !isCompleted && index === activeStepIndex; + + return { + id: step.id, + label: step.label, + detail: step.detail, + completed: isCompleted ? 1 : 0, + total: 1, + status: isCompleted + ? 'completed' + : isActive + ? 'active' + : 'pending', + } satisfies CustomWorldGenerationStep; + }); +} + +function resolveEstimatedRemainingMs( + progress: number, + startedAtMs: number | null, + nowMs: number, + status: CustomWorldAgentOperationRecord['status'], +) { + if (status === 'completed') { + return 0; + } + + if (!startedAtMs || progress <= 0 || progress >= 100) { + return null; + } + + const elapsedMs = Math.max(0, nowMs - startedAtMs); + const progressFraction = progress / 100; + + return Math.max( + 0, + Math.round(elapsedMs / progressFraction - elapsedMs), + ); +} + +export function isDraftFoundationOperation( + operation: CustomWorldAgentOperationRecord | null | undefined, +): operation is CustomWorldAgentOperationRecord { + return Boolean(operation && operation.type === 'draft_foundation'); +} + +export function isDraftFoundationOperationRunning( + operation: CustomWorldAgentOperationRecord | null | undefined, +) { + return ( + isDraftFoundationOperation(operation) && + (operation.status === 'queued' || operation.status === 'running') + ); +} + +export function buildAgentDraftFoundationGenerationProgress( + operation: CustomWorldAgentOperationRecord | null | undefined, + startedAtMs: number | null, + nowMs = Date.now(), +): CustomWorldGenerationProgress | null { + if (!isDraftFoundationOperation(operation)) { + return null; + } + + const overallProgress = clampProgress(operation.progress); + const activeStepIndex = resolveAgentDraftFoundationStepIndex(operation); + const elapsedMs = startedAtMs ? Math.max(0, nowMs - startedAtMs) : 0; + const estimatedRemainingMs = resolveEstimatedRemainingMs( + overallProgress, + startedAtMs, + nowMs, + operation.status, + ); + const activeStep = + AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[activeStepIndex] ?? + AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[0]; + + return { + phaseId: activeStep.id, + phaseLabel: operation.phaseLabel || activeStep.label, + phaseDetail: operation.phaseDetail || activeStep.detail, + batchLabel: activeStep.label, + overallProgress, + completedWeight: overallProgress, + totalWeight: 100, + elapsedMs, + estimatedRemainingMs, + activeStepIndex, + steps: buildAgentDraftFoundationSteps(operation, activeStepIndex), + }; +} + +export function buildAgentDraftFoundationSettingText( + session: CustomWorldAgentSessionSnapshot | null | undefined, +) { + if (!session) { + return ''; + } + + const creatorIntent = normalizeCustomWorldCreatorIntent( + session.creatorIntent, + 'freeform', + ); + + if (creatorIntent) { + const displayText = + buildCustomWorldCreatorIntentDisplayText(creatorIntent).trim(); + const generationText = + buildCustomWorldCreatorIntentGenerationText(creatorIntent).trim(); + + if (displayText) { + return displayText; + } + + if (generationText) { + return generationText; + } + + if (creatorIntent.rawSettingText.trim()) { + return creatorIntent.rawSettingText.trim(); + } + } + + const latestUserMessage = [...session.messages] + .reverse() + .find((message) => message.role === 'user' && message.text.trim()); + + return latestUserMessage?.text.trim() ?? '正在整理当前共创设定。'; +}