From 53a9cdd791e5d66f8aeece06e49cd0c978d353c5 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 23 Apr 2026 21:23:46 +0800 Subject: [PATCH] feat: restore agent sessions from creation drafts --- .../CREATION_HUB_CARD_ACTIONS_2026-04-22.md | 7 +- ...PUZZLE_DRAFT_SESSION_RESTORE_2026-04-23.md | 100 +++++ ...EATION_DRAFT_SESSION_RESTORE_2026-04-23.md | 141 ++++++ .../src/contracts/bigFishWorkSummary.ts | 21 + .../CustomWorldCreationHub.tsx | 31 +- .../custom-world-home/CustomWorldWorkCard.tsx | 41 +- .../PlatformEntryFlowShellImpl.tsx | 178 +++++++- .../puzzle-result/PuzzleResultView.tsx | 1 + ...gEntryFlowShell.agent.interaction.test.tsx | 417 +++++++++++++++++- .../big-fish-works/bigFishWorksClient.ts | 29 ++ src/services/big-fish-works/index.ts | 1 + 11 files changed, 949 insertions(+), 18 deletions(-) create mode 100644 docs/technical/PUZZLE_DRAFT_SESSION_RESTORE_2026-04-23.md create mode 100644 docs/technical/UNIFIED_CREATION_DRAFT_SESSION_RESTORE_2026-04-23.md create mode 100644 packages/shared/src/contracts/bigFishWorkSummary.ts create mode 100644 src/services/big-fish-works/bigFishWorksClient.ts create mode 100644 src/services/big-fish-works/index.ts diff --git a/docs/technical/CREATION_HUB_CARD_ACTIONS_2026-04-22.md b/docs/technical/CREATION_HUB_CARD_ACTIONS_2026-04-22.md index 415ab740..aed4349f 100644 --- a/docs/technical/CREATION_HUB_CARD_ACTIONS_2026-04-22.md +++ b/docs/technical/CREATION_HUB_CARD_ACTIONS_2026-04-22.md @@ -15,7 +15,9 @@ | --- | --- | --- | --- | --- | | RPG Agent 草稿 | `draft` | `继续创作` / `继续完善` | 不展示,草稿需要先走发布链 | 不展示,本轮不新增 Agent session 物理删除 | | RPG 已发布作品 | `published` 且 `canEnterWorld=true` | `查看详情` | 展示 `体验`,直接调用现有进入世界链 | 展示 `删除`,走 owner-only 软删除 | -| 拼图草稿 | `draft` | `查看详情` | 不展示 | 不展示,本轮不新增拼图删除契约 | +| Big Fish 草稿 | `draft` | `继续创作` | 不展示,草稿需要先回到聊天或结果页继续完善 | 不展示,本轮不新增 Big Fish 草稿删除 | +| Big Fish 已发布作品 | `published` | `查看详情` | 展示 `体验`,直接调用现有 Big Fish 运行态 | 不展示,本轮不新增 Big Fish 删除契约 | +| 拼图草稿 | `draft` | `继续创作` | 不展示 | 不展示,本轮不新增拼图删除契约 | | 拼图已发布作品 | `published` | `查看详情` | 展示 `体验`,直接调用 `startPuzzleRun` | 不展示,本轮不新增拼图删除契约 | ## 3. 后端边界 @@ -40,6 +42,7 @@ RPG 删除必须继续遵守后端治理里的软删除规则: 2. 不新增拼图作品删除。 3. 不新增独立删除面板。 4. 不新建创作页或运行时页面,只复用现有 `CustomWorldCreationHub`、RPG 进入世界链和拼图运行时链。 +5. Big Fish 草稿恢复链补齐时,只补创作中心 works 投影和恢复入口,不新建独立 Big Fish 作品系统。 ## 6. 已落地结果 @@ -47,6 +50,8 @@ RPG 删除必须继续遵守后端治理里的软删除规则: 2. RPG 与拼图已发布作品卡新增独立 `体验` 入口,直接复用各自现有运行时进入链路。 3. RPG 已发布作品卡新增 `删除` 入口,调用 `/api/runtime/custom-world-library/{profile_id}` 的 `DELETE` 路由,按 owner-only 软删除规则刷新作品列表与公开广场。 4. 创作中心详情页原有删除链路继续保留,和卡片删除共用同一后端删除契约。 +5. 后续拼图草稿恢复链补齐后,拼图 `draft` 卡主按钮语义收口为 `继续创作`,通过 `sourceSessionId` 恢复 Agent session,而不是进入详情页。 +6. 后续 Big Fish 草稿恢复链补齐后,Big Fish `draft` 卡主按钮同样收口为 `继续创作`,通过 `sourceSessionId` 恢复 Agent session,而不是重新创建会话。 ## 7. 已验证 diff --git a/docs/technical/PUZZLE_DRAFT_SESSION_RESTORE_2026-04-23.md b/docs/technical/PUZZLE_DRAFT_SESSION_RESTORE_2026-04-23.md new file mode 100644 index 00000000..ea772025 --- /dev/null +++ b/docs/technical/PUZZLE_DRAFT_SESSION_RESTORE_2026-04-23.md @@ -0,0 +1,100 @@ +# 拼图草稿恢复 Agent 会话设计 + +日期:`2026-04-23` + +## 1. 背景 + +当前拼图链已经具备: + +1. `puzzle_agent_session / puzzle_agent_message` 作为聊天真相。 +2. `get_puzzle_agent_session(sessionId)` 可按会话恢复完整消息。 +3. `puzzle_work_profile` 作为创作中心与广场的作品列表投影。 + +但现状仍有两个断点: + +1. `compile_puzzle_agent_draft` 只把结果页草稿写回 session,没有同步生成 `draft` 态 `puzzle_work_profile`。 +2. 创作中心点击拼图草稿卡时,只会走“查看详情”,没有利用 `sourceSessionId` 恢复聊天会话。 + +这导致“进入创作草稿恢复聊天记录”在拼图链上并不完整。 + +## 2. 目标 + +本轮只实现以下闭环,不扩展到用户级历史列表: + +1. 拼图 Agent 编译出结果页草稿后,创作中心必须出现对应草稿卡。 +2. 草稿卡必须带 `sourceSessionId`,作为恢复聊天记录的唯一索引。 +3. 点击拼图草稿卡时: + - 若 session 仍存在且已带 `draft`,优先进入 `puzzle-result`。 + - 若 session 存在但尚无 `draft`,进入 `puzzle-agent-workspace`。 +4. 恢复后继续复用同一个 `puzzleSession`,返回聊天工作区时能看到完整历史消息。 + +## 3. 真相源与投影边界 + +### 3.1 真相源 + +拼图聊天历史、锚点、阶段、结果页草稿仍以 `puzzle_agent_session` 为准。 + +### 3.2 投影 + +`puzzle_work_profile` 只承担: + +1. 创作中心作品卡展示。 +2. 结果页 / 详情页入口锚点。 +3. 通过 `source_session_id` 反查 Agent session。 + +它不是新的聊天真相,不承担额外会话列表职责。 + +## 4. 后端落地 + +### 4.1 编译草稿时同步 upsert draft 作品 + +`compile_puzzle_agent_draft` 除了更新 session 外,还要: + +1. 依据当前 `draft` 创建或更新一条 `PuzzlePublicationStatus::Draft` 的 `puzzle_work_profile`。 +2. `source_session_id` 固定写当前 session id。 +3. `work_id / profile_id` 使用稳定的 session 派生规则,避免同一 session 每次编译都生成新卡。 + +### 4.2 图片相关操作持续同步 draft 作品 + +`save_puzzle_generated_images`、`select_puzzle_cover_image` 会改变结果页草稿真相,因此也要同步更新对应 draft 作品记录,保证创作中心卡片封面、摘要、标签与当前草稿一致。 + +### 4.3 发布时升级同一条作品记录 + +`publish_puzzle_work` 不再新生成随机 `work_id / profile_id`,而是复用 session 派生的稳定 ID,把同一条 draft 作品升级为 `published`: + +1. 避免创作中心出现“草稿卡 + 已发布卡”两条重复记录。 +2. 保持 `source_session_id` 连续可追溯。 + +## 5. 前端落地 + +### 5.1 创作中心卡片语义 + +拼图草稿卡主按钮从 `查看详情` 改为 `继续创作`。 + +### 5.2 打开拼图草稿 + +平台壳层新增拼图草稿恢复入口: + +1. 读 `PuzzleWorkSummary.sourceSessionId`。 +2. 用现有 `getPuzzleAgentSession(sourceSessionId)` 拉回 session。 +3. 若 `session.draft` 存在: + - 写入 `puzzleSession` + - 切到 `puzzle-result` +4. 若 `session.draft` 不存在: + - 写入 `puzzleSession` + - 切到 `puzzle-agent-workspace` + +### 5.3 失败回退 + +如果 `sourceSessionId` 缺失或对应 session 已失效: + +1. 刷新拼图作品列表。 +2. 停留在创作中心。 +3. 通过现有错误 banner 提示,不新增独立说明 UI。 + +## 6. 验收 + +1. 编译拼图结果页草稿后,创作中心出现 `draft` 态拼图卡。 +2. 草稿卡点击后会恢复对应 `puzzleSession.messages`。 +3. 已有 `draft` 的 session 恢复后直达结果页,点击返回能看到原聊天记录。 +4. 发布后不会额外生成第二条重复作品记录。 diff --git a/docs/technical/UNIFIED_CREATION_DRAFT_SESSION_RESTORE_2026-04-23.md b/docs/technical/UNIFIED_CREATION_DRAFT_SESSION_RESTORE_2026-04-23.md new file mode 100644 index 00000000..15fc4290 --- /dev/null +++ b/docs/technical/UNIFIED_CREATION_DRAFT_SESSION_RESTORE_2026-04-23.md @@ -0,0 +1,141 @@ +# 创作中心全草稿恢复 Agent 会话设计 + +日期:`2026-04-23` + +## 1. 背景 + +当前创作中心已经承载多种“先聊天收束,再进入结果页”的创作链: + +1. RPG / Custom World +2. Big Fish +3. Puzzle + +但三条链当前恢复能力并不一致: + +1. RPG 草稿已经能通过 `sessionId` 恢复 Agent 会话。 +2. Puzzle 草稿已补到通过 `sourceSessionId` 恢复 Agent 会话。 +3. Big Fish 仍停留在“有会话但没有创作中心草稿投影”的状态,用户退出登录后缺少重新进入草稿的入口。 + +这会导致“进入创作草稿继续聊”的体验只在部分品类成立,不满足创作中心统一入口的要求。 + +## 2. 目标 + +本轮统一收口到以下规则: + +1. 只要是创作中心中的 `draft` 草稿,都必须能恢复对应 Agent 聊天历史。 +2. 恢复能力只要求在“重新进入创作中心草稿”时成立,不扩展到用户级独立聊天历史列表。 +3. 所有草稿卡片都遵循同一入口语义: + - 尚无结果页草稿时,进入 Agent 工作区继续聊。 + - 已有结果页草稿时,直接进入结果页。 + - 从结果页返回后,仍能看到原聊天记录。 + +## 3. 统一边界 + +### 3.1 真相源 + +聊天历史、当前阶段、锚点、草稿真相始终在各自的 Agent session 表中: + +1. RPG / Custom World:`custom_world_agent_session` +2. Big Fish:`big_fish_creation_session + big_fish_agent_message` +3. Puzzle:`puzzle_agent_session + puzzle_agent_message` + +### 3.2 创作中心作品卡 + +创作中心作品卡只承担: + +1. 展示草稿摘要。 +2. 保存恢复用的稳定会话标识。 +3. 作为重新进入创作链的入口。 + +它不是新的聊天真相,也不独立承载消息历史。 + +## 4. 统一恢复规则 + +### 4.1 草稿卡必须带会话索引 + +不同品类的草稿卡都必须能反查到会话: + +1. RPG / Custom World:使用现有 `sessionId` +2. Big Fish:新增 `sourceSessionId` +3. Puzzle:使用现有 `sourceSessionId` + +### 4.2 打开草稿时的分流 + +前端打开草稿卡时统一执行: + +1. 先按会话 id 读取对应 session snapshot。 +2. 若 session 已有结果页草稿: + - RPG 进入 `custom-world-result` + - Big Fish 进入 `big-fish-result` + - Puzzle 进入 `puzzle-result` +3. 若 session 尚无结果页草稿: + - RPG 进入 `agent-workspace` + - Big Fish 进入 `big-fish-agent-workspace` + - Puzzle 进入 `puzzle-agent-workspace` + +### 4.3 失败回退 + +如果草稿卡缺少会话索引,或会话已不存在: + +1. 刷新对应作品列表。 +2. 停留在创作中心。 +3. 通过现有错误 banner 提示,不新增规则说明 UI。 + +## 5. 分品类落地要求 + +### 5.1 RPG / Custom World + +RPG 已具备恢复基础,本轮只把它纳入统一口径: + +1. `draft + sessionId` 继续作为恢复前提。 +2. 有结果页草稿时,主按钮保持 `继续完善`。 +3. 没有结果页草稿时,主按钮保持 `继续创作`。 + +### 5.2 Puzzle + +Puzzle 继续沿用本轮已落地的规则: + +1. 编译结果页草稿时同步 upsert `draft` 作品投影。 +2. 作品投影保留 `sourceSessionId`。 +3. 草稿卡点击恢复 `puzzleSession`,优先进入结果页。 + +### 5.3 Big Fish + +Big Fish 需要补齐缺口: + +1. 为 `big_fish_creation_session` 增加 works 读模型输出,不新建第二套聊天存储。 +2. 创作中心读取 Big Fish works 并合并展示。 +3. 草稿卡固定带 `sourceSessionId = sessionId`。 +4. `draft` 卡主按钮使用 `继续创作`。 +5. `published` 卡主按钮使用 `查看详情`,体验入口仍直接进入运行态。 +6. 在独立 Big Fish 详情页补齐前,`查看详情` 先复用结果页承载详情与返回聊天的入口,不额外新建页面。 + +## 6. Big Fish works 最小方案 + +### 6.1 不新增独立 Big Fish profile 表 + +本轮 Big Fish 只补“草稿恢复聊天”闭环,不强行新建完整发布作品仓储。 + +创作中心所需 Big Fish work summary 直接由 `big_fish_creation_session` 派生: + +1. `draft_json` 存在时输出草稿标题、副标题、摘要。 +2. `asset_slots` 提供封面图和资源完成度提示。 +3. `stage == Published` 时视为 `published`。 +4. 其他阶段统一视为 `draft`。 + +### 6.2 稳定 workId + +Big Fish works 使用会话派生稳定 id: + +1. `workId = big-fish-work-{sessionId}` +2. `sourceSessionId = sessionId` + +这样同一份草稿不会在创作中心重复出现。 + +## 7. 验收 + +1. RPG、Big Fish、Puzzle 三类草稿都能在创作中心重新打开。 +2. 退出登录后重新登录,进入同一份草稿仍能恢复对应聊天记录。 +3. Big Fish 草稿首次编译出结果页后,会在创作中心出现草稿卡。 +4. Big Fish / Puzzle 已有结果页草稿时,点击草稿卡直达结果页。 +5. 从结果页返回各自 Agent 工作区后,历史消息不丢失。 diff --git a/packages/shared/src/contracts/bigFishWorkSummary.ts b/packages/shared/src/contracts/bigFishWorkSummary.ts new file mode 100644 index 00000000..d8cd49eb --- /dev/null +++ b/packages/shared/src/contracts/bigFishWorkSummary.ts @@ -0,0 +1,21 @@ +export type BigFishWorkStatus = 'draft' | 'published'; + +export interface BigFishWorkSummary { + workId: string; + sourceSessionId: string; + title: string; + subtitle: string; + summary: string; + coverImageSrc: string | null; + status: BigFishWorkStatus; + updatedAt: string; + publishReady: boolean; + levelCount: number; + levelMainImageReadyCount: number; + levelMotionReadyCount: number; + backgroundReady: boolean; +} + +export interface BigFishWorksResponse { + items: BigFishWorkSummary[]; +} diff --git a/src/components/custom-world-home/CustomWorldCreationHub.tsx b/src/components/custom-world-home/CustomWorldCreationHub.tsx index e6240860..eb25a211 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.tsx @@ -1,6 +1,7 @@ import { useMemo, useState } from 'react'; import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent'; +import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import { CustomWorldCreationStartCard } from './CustomWorldCreationStartCard'; import { @@ -26,8 +27,11 @@ type CustomWorldCreationHubProps = { onDeletePublished?: ((item: CustomWorldWorkSummary) => void) | null; deletingWorkId?: string | null; onExperienceRpg?: ((item: CustomWorldWorkSummary) => void) | null; + bigFishItems?: BigFishWorkSummary[]; + onOpenBigFishDetail?: (item: BigFishWorkSummary) => void; + onExperienceBigFish?: ((item: BigFishWorkSummary) => void) | null; puzzleItems?: PuzzleWorkSummary[]; - onOpenPuzzleDetail?: (profileId: string) => void; + onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void; onExperiencePuzzle?: ((profileId: string) => void) | null; }; @@ -54,6 +58,9 @@ export function CustomWorldCreationHub({ onDeletePublished = null, deletingWorkId = null, onExperienceRpg = null, + bigFishItems = [], + onOpenBigFishDetail, + onExperienceBigFish = null, puzzleItems = [], onOpenPuzzleDetail, onExperiencePuzzle = null, @@ -63,18 +70,23 @@ export function CustomWorldCreationHub({ const unifiedItems = useMemo( () => [ ...items.map((item) => ({ kind: 'rpg', item }) as const), + ...bigFishItems.map((item) => ({ kind: 'big-fish', item }) as const), ...puzzleItems.map((item) => ({ kind: 'puzzle', item }) as const), ], - [items, puzzleItems], + [bigFishItems, items, puzzleItems], ); const draftCount = unifiedItems.filter((entry) => entry.kind === 'puzzle' ? entry.item.publicationStatus === 'draft' + : entry.kind === 'big-fish' + ? entry.item.status === 'draft' : entry.item.status === 'draft', ).length; const publishedCount = unifiedItems.filter((entry) => entry.kind === 'puzzle' ? entry.item.publicationStatus === 'published' + : entry.kind === 'big-fish' + ? entry.item.status === 'published' : entry.item.status === 'published', ).length; const filteredItems = useMemo( @@ -84,6 +96,8 @@ export function CustomWorldCreationHub({ ? true : entry.kind === 'puzzle' ? entry.item.publicationStatus === activeFilter + : entry.kind === 'big-fish' + ? entry.item.status === activeFilter : entry.item.status === activeFilter, ), [activeFilter, unifiedItems], @@ -144,7 +158,12 @@ export function CustomWorldCreationHub({ item={item} onOpen={() => { if (item.kind === 'puzzle') { - onOpenPuzzleDetail?.(item.item.profileId); + onOpenPuzzleDetail?.(item.item); + return; + } + + if (item.kind === 'big-fish') { + onOpenBigFishDetail?.(item.item); return; } @@ -167,6 +186,12 @@ export function CustomWorldCreationHub({ onExperiencePuzzle?.(item.item.profileId); } : null + : item.kind === 'big-fish' + ? item.item.status === 'published' + ? () => { + onExperienceBigFish?.(item.item); + } + : null : item.item.status === 'published' && item.item.canEnterWorld ? () => { onExperienceRpg?.(item.item); diff --git a/src/components/custom-world-home/CustomWorldWorkCard.tsx b/src/components/custom-world-home/CustomWorldWorkCard.tsx index 8168152a..d4e6b6c7 100644 --- a/src/components/custom-world-home/CustomWorldWorkCard.tsx +++ b/src/components/custom-world-home/CustomWorldWorkCard.tsx @@ -1,4 +1,5 @@ import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent'; +import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork'; @@ -21,6 +22,10 @@ export type UnifiedCreationWorkItem = kind: 'rpg'; item: CustomWorldWorkSummary; } + | { + kind: 'big-fish'; + item: BigFishWorkSummary; + } | { kind: 'puzzle'; item: PuzzleWorkSummary; @@ -42,19 +47,26 @@ export function CustomWorldWorkCard({ deleteBusy = false, }: CustomWorldWorkCardProps) { const isPuzzle = item.kind === 'puzzle'; + const isBigFish = item.kind === 'big-fish'; const isDraft = item.kind === 'puzzle' ? item.item.publicationStatus === 'draft' + : item.kind === 'big-fish' + ? item.item.status === 'draft' : item.item.status === 'draft'; - const openActionLabel = isPuzzle - ? '查看详情' + const openActionLabel = isPuzzle || isBigFish + ? isDraft + ? '继续创作' + : '查看详情' : isDraft ? item.item.playableNpcCount > 0 || item.item.landmarkCount > 0 ? '继续完善' : '继续创作' : '查看详情'; - const title = isPuzzle ? item.item.levelName : item.item.title; - const subtitle = isPuzzle ? item.item.authorDisplayName : item.item.subtitle; + const title = + item.kind === 'puzzle' ? item.item.levelName : item.item.title; + const subtitle = + item.kind === 'puzzle' ? item.item.authorDisplayName : item.item.subtitle; const summary = item.item.summary; const updatedAt = item.item.updatedAt; const coverImageSrc = item.item.coverImageSrc ?? null; @@ -87,9 +99,9 @@ export function CustomWorldWorkCard({ {isDraft ? '草稿' : '已发布'} - {isPuzzle ? '拼图' : 'RPG'} + {isPuzzle ? '拼图' : isBigFish ? '大鱼' : 'RPG'} - {!isPuzzle && item.item.stageLabel ? ( + {item.kind === 'rpg' && item.item.stageLabel ? ( {item.item.stageLabel} @@ -133,6 +145,23 @@ export function CustomWorldWorkCard({ 游玩 {item.item.playCount} + ) : isBigFish ? ( + <> + + 关卡 {item.item.levelCount} + + + 主图 {item.item.levelMainImageReadyCount} + + + 动作 {item.item.levelMotionReadyCount} + + {item.item.backgroundReady ? ( + + 背景已就绪 + + ) : null} + ) : ( <> diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 1e72b48e..45ba9227 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -16,6 +16,7 @@ import type { SendBigFishMessageRequest, SubmitBigFishInputRequest, } from '../../../packages/shared/src/contracts/bigFish'; +import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; import type { PuzzleAgentActionRequest, PuzzleAgentOperationRecord, @@ -31,8 +32,10 @@ import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets' import { createBigFishCreationSession, executeBigFishCreationAction, + getBigFishCreationSession, streamBigFishCreationMessage, } from '../../services/big-fish-creation'; +import { listBigFishWorks } from '../../services/big-fish-works'; import { startBigFishRuntimeRun, submitBigFishRuntimeInput, @@ -149,10 +152,12 @@ export function PlatformEntryFlowShellImpl({ useState | null>(null); const [bigFishSession, setBigFishSession] = useState(null); + const [bigFishWorks, setBigFishWorks] = useState([]); const [bigFishRun, setBigFishRun] = useState(null); const [bigFishError, setBigFishError] = useState(null); const [isBigFishBusy, setIsBigFishBusy] = useState(false); + const [isBigFishLoadingLibrary, setIsBigFishLoadingLibrary] = useState(false); const [streamingBigFishReplyText, setStreamingBigFishReplyText] = useState(''); const [isStreamingBigFishReply, setIsStreamingBigFishReply] = useState(false); @@ -404,6 +409,22 @@ export function PlatformEntryFlowShellImpl({ [], ); + const refreshBigFishShelf = useCallback(async () => { + setIsBigFishLoadingLibrary(true); + + try { + const worksResponse = await listBigFishWorks(); + setBigFishWorks(worksResponse.items); + setBigFishError(null); + } catch (error) { + setBigFishError( + resolveBigFishErrorMessage(error, '读取大鱼吃小鱼作品列表失败。'), + ); + } finally { + setIsBigFishLoadingLibrary(false); + } + }, [resolveBigFishErrorMessage]); + const refreshPuzzleShelf = useCallback(async () => { setIsPuzzleLoadingLibrary(true); @@ -1007,6 +1028,112 @@ export function PlatformEntryFlowShellImpl({ [enterCreateTab, resolvePuzzleErrorMessage, setSelectionStage], ); + const openPuzzleDraft = useCallback( + async (item: PuzzleWorkSummary) => { + const sessionId = item.sourceSessionId?.trim(); + if (!sessionId) { + setPuzzleError('这份拼图草稿缺少会话信息,请重新开始创作。'); + return; + } + + setIsPuzzleBusy(true); + setPuzzleError(null); + setPuzzleOperation(null); + setPuzzleRun(null); + setSelectedPuzzleDetail(null); + setStreamingPuzzleReplyText(''); + setIsStreamingPuzzleReply(false); + + try { + const { session } = await getPuzzleAgentSession(sessionId); + setPuzzleSession(session); + enterCreateTab(); + setSelectionStage(session.draft ? 'puzzle-result' : 'puzzle-agent-workspace'); + } catch (error) { + await refreshPuzzleShelf().catch(() => undefined); + setPuzzleError(resolvePuzzleErrorMessage(error, '读取拼图创作草稿失败。')); + enterCreateTab(); + setSelectionStage('platform'); + } finally { + setIsPuzzleBusy(false); + } + }, + [ + enterCreateTab, + refreshPuzzleShelf, + resolvePuzzleErrorMessage, + setSelectionStage, + ], + ); + + const openBigFishDraft = useCallback( + async (item: BigFishWorkSummary) => { + const sessionId = item.sourceSessionId?.trim(); + if (!sessionId) { + setBigFishError('这份大鱼吃小鱼草稿缺少会话信息,请重新开始创作。'); + return; + } + + setIsBigFishBusy(true); + setBigFishError(null); + setBigFishRun(null); + setStreamingBigFishReplyText(''); + setIsStreamingBigFishReply(false); + + try { + const { session } = await getBigFishCreationSession(sessionId); + setBigFishSession(session); + enterCreateTab(); + setSelectionStage( + session.draft ? 'big-fish-result' : 'big-fish-agent-workspace', + ); + } catch (error) { + await refreshBigFishShelf().catch(() => undefined); + setBigFishError( + resolveBigFishErrorMessage(error, '读取大鱼吃小鱼创作草稿失败。'), + ); + enterCreateTab(); + setSelectionStage('platform'); + } finally { + setIsBigFishBusy(false); + } + }, + [ + enterCreateTab, + refreshBigFishShelf, + resolveBigFishErrorMessage, + setSelectionStage, + ], + ); + + const startBigFishRunFromWork = useCallback( + async (item: BigFishWorkSummary) => { + const sessionId = item.sourceSessionId?.trim(); + if (!sessionId) { + setBigFishError('当前作品缺少会话信息,暂时无法进入玩法。'); + return; + } + + setIsBigFishBusy(true); + setBigFishError(null); + + try { + const { session } = await getBigFishCreationSession(sessionId); + const { run } = await startBigFishRuntimeRun(sessionId); + setBigFishSession(session); + setBigFishRun(run); + setSelectionStage('big-fish-runtime'); + } catch (error) { + setBigFishError( + resolveBigFishErrorMessage(error, '启动大鱼吃小鱼玩法失败。'), + ); + } finally { + setIsBigFishBusy(false); + } + }, + [resolveBigFishErrorMessage, setSelectionStage], + ); + useEffect(() => { if ( (platformBootstrap.platformTab === 'create' || @@ -1022,24 +1149,50 @@ export function PlatformEntryFlowShellImpl({ selectionStage, ]); + useEffect(() => { + if ( + (platformBootstrap.platformTab === 'create' || + selectionStage === 'platform') && + platformBootstrap.canReadProtectedData + ) { + void refreshBigFishShelf(); + } + }, [ + platformBootstrap.canReadProtectedData, + platformBootstrap.platformTab, + refreshBigFishShelf, + selectionStage, + ]); + const creationHubContent = ( { platformBootstrap.setPlatformError(null); + setBigFishError(null); + setPuzzleError(null); void platformBootstrap.refreshCustomWorldWorks().catch((error) => { platformBootstrap.setPlatformError( resolveRpgCreationErrorMessage(error, '读取创作作品列表失败。'), ); }); + void refreshBigFishShelf(); + void refreshPuzzleShelf(); }} createError={ sessionController.creationTypeError ?? bigFishError ?? puzzleError @@ -1073,10 +1226,25 @@ export function PlatformEntryFlowShellImpl({ onExperienceRpg={(item) => { handleExperienceRpgWork(item); }} - puzzleItems={puzzleWorks} - onOpenPuzzleDetail={(profileId) => { + bigFishItems={bigFishWorks} + onOpenBigFishDetail={(item) => { runProtectedAction(() => { - void openPuzzleDetail(profileId); + void openBigFishDraft(item); + }); + }} + onExperienceBigFish={(item) => { + runProtectedAction(() => { + void startBigFishRunFromWork(item); + }); + }} + puzzleItems={puzzleWorks} + onOpenPuzzleDetail={(item) => { + runProtectedAction(() => { + if (item.publicationStatus === 'draft') { + void openPuzzleDraft(item); + return; + } + void openPuzzleDetail(item.profileId); }); }} onExperiencePuzzle={(profileId) => { diff --git a/src/components/puzzle-result/PuzzleResultView.tsx b/src/components/puzzle-result/PuzzleResultView.tsx index 269d1341..85bbf4b3 100644 --- a/src/components/puzzle-result/PuzzleResultView.tsx +++ b/src/components/puzzle-result/PuzzleResultView.tsx @@ -234,6 +234,7 @@ export function PuzzleResultView({
+
+ ), +})); + vi.mock('../custom-world-agent/CustomWorldAgentWorkspace', () => ({ CustomWorldAgentWorkspace: ({ session, @@ -593,6 +638,110 @@ beforeEach(() => { updatedAt: '2026-04-22T12:00:00.000Z', }, }); + vi.mocked(getBigFishCreationSession).mockResolvedValue({ + session: { + sessionId: 'big-fish-session-1', + currentTurn: 2, + progressPercent: 90, + stage: 'draft_ready', + anchorPack: { + gameplayPromise: { + key: 'gameplay_promise', + label: '核心玩法', + value: '机械微生物吞并进化', + status: 'confirmed', + }, + ecologyVisualTheme: { + key: 'ecology_visual_theme', + label: '生态视觉', + value: '深海机械浮游生态', + status: 'confirmed', + }, + growthLadder: { + key: 'growth_ladder', + label: '成长阶梯', + value: '从微光孢子到深海巨鲲', + status: 'confirmed', + }, + riskTempo: { + key: 'risk_tempo', + label: '风险节奏', + value: '快节奏吞并,后段压迫感增强', + status: 'confirmed', + }, + }, + draft: { + title: '机械深海 大鱼吃小鱼', + subtitle: '机械微生物吞并进化 · 偏爽快节奏', + coreFun: '吞并更小机械生命并持续合体成长', + ecologyTheme: '深海机械浮游生态', + levels: [ + { + level: 1, + name: '微光孢子', + oneLineFantasy: '像发光尘埃一样在深海漂浮。', + silhouetteDirection: '圆润微型机械球', + sizeRatio: 1, + visualPromptSeed: 'deep sea glowing mechanical spore', + motionPromptSeed: 'soft floating mechanical spore', + mergeSourceLevel: null, + preyWindow: [1], + threatWindow: [2], + isFinalLevel: false, + }, + ], + background: { + theme: '机械深海', + colorMood: '冷青色与暗金反光', + foregroundHints: '漂浮齿轮碎片', + midgroundComposition: '珊瑚状机械群落', + backgroundDepth: '深海远景光柱', + safePlayAreaHint: '中心区域留空', + spawnEdgeHint: '边缘暗流刷怪', + backgroundPromptSeed: 'mechanical deep sea arena', + }, + runtimeParams: { + levelCount: 8, + mergeCountPerUpgrade: 3, + spawnTargetCount: 28, + leaderMoveSpeed: 1.2, + followerCatchUpSpeed: 1, + offscreenCullSeconds: 8, + preySpawnDeltaLevels: [-2, -1], + threatSpawnDeltaLevels: [1, 2], + winLevel: 8, + }, + }, + assetSlots: [], + assetCoverage: { + levelMainImageReadyCount: 0, + levelMotionReadyCount: 0, + backgroundReady: false, + requiredLevelCount: 8, + publishReady: false, + blockers: ['仍有主图、动作和背景未生成'], + }, + messages: [ + { + id: 'big-fish-message-1', + role: 'assistant', + kind: 'chat', + text: '先说说你想要什么样的大鱼生态。', + createdAt: '2026-04-22T12:00:00.000Z', + }, + { + id: 'big-fish-message-2', + role: 'user', + kind: 'chat', + text: '我想做机械深海里微生物互相吞并进化。', + createdAt: '2026-04-22T12:01:00.000Z', + }, + ], + lastAssistantReply: '大鱼结果页草稿已经生成,可以补正式资产。', + publishReady: false, + updatedAt: '2026-04-22T12:10:00.000Z', + }, + }); vi.mocked(createPuzzleAgentSession).mockResolvedValue({ session: { sessionId: 'puzzle-session-1', @@ -640,8 +789,170 @@ beforeEach(() => { updatedAt: '2026-04-22T12:00:00.000Z', }, }); + vi.mocked(getPuzzleAgentSession).mockResolvedValue({ + session: { + sessionId: 'puzzle-session-1', + currentTurn: 3, + progressPercent: 88, + stage: 'draft_ready', + anchorPack: { + themePromise: { + key: 'theme_promise', + label: '主题承诺', + value: '雨夜遗迹探索', + status: 'confirmed', + }, + visualSubject: { + key: 'visual_subject', + label: '视觉主体', + value: '发光猫咪站在遗迹台阶上', + status: 'confirmed', + }, + visualMood: { + key: 'visual_mood', + label: '视觉气质', + value: '潮湿、梦幻、轻悬疑', + status: 'confirmed', + }, + compositionHooks: { + key: 'composition_hooks', + label: '构图钩子', + value: '台阶透视、倒影、门洞', + status: 'confirmed', + }, + tagsAndForbidden: { + key: 'tags_and_forbidden', + label: '标签与禁区', + value: '雨夜、猫咪、遗迹;禁止文字水印', + status: 'confirmed', + }, + }, + draft: { + levelName: '雨夜猫塔', + summary: '一张聚焦发光猫咪与遗迹台阶的雨夜拼图。', + themeTags: ['雨夜', '猫咪', '遗迹'], + forbiddenDirectives: ['文字水印'], + creatorIntent: null, + anchorPack: { + themePromise: { + key: 'theme_promise', + label: '主题承诺', + value: '雨夜遗迹探索', + status: 'confirmed', + }, + visualSubject: { + key: 'visual_subject', + label: '视觉主体', + value: '发光猫咪站在遗迹台阶上', + status: 'confirmed', + }, + visualMood: { + key: 'visual_mood', + label: '视觉气质', + value: '潮湿、梦幻、轻悬疑', + status: 'confirmed', + }, + compositionHooks: { + key: 'composition_hooks', + label: '构图钩子', + value: '台阶透视、倒影、门洞', + status: 'confirmed', + }, + tagsAndForbidden: { + key: 'tags_and_forbidden', + label: '标签与禁区', + value: '雨夜、猫咪、遗迹;禁止文字水印', + status: 'confirmed', + }, + }, + candidates: [], + selectedCandidateId: null, + coverImageSrc: null, + coverAssetId: null, + generationStatus: 'idle', + }, + messages: [ + { + id: 'puzzle-message-1', + role: 'assistant', + kind: 'chat', + text: '先说一个你最想做成拼图的画面。', + createdAt: '2026-04-22T12:00:00.000Z', + }, + { + id: 'puzzle-message-2', + role: 'user', + kind: 'chat', + text: '雨夜里有一只会发光的猫站在遗迹台阶上。', + createdAt: '2026-04-22T12:01:00.000Z', + }, + ], + lastAssistantReply: '拼图结果页草稿已经生成,可以开始出图并确认标签。', + publishedProfileId: null, + suggestedActions: [], + resultPreview: { + draft: { + levelName: '雨夜猫塔', + summary: '一张聚焦发光猫咪与遗迹台阶的雨夜拼图。', + themeTags: ['雨夜', '猫咪', '遗迹'], + forbiddenDirectives: ['文字水印'], + creatorIntent: null, + anchorPack: { + themePromise: { + key: 'theme_promise', + label: '主题承诺', + value: '雨夜遗迹探索', + status: 'confirmed', + }, + visualSubject: { + key: 'visual_subject', + label: '视觉主体', + value: '发光猫咪站在遗迹台阶上', + status: 'confirmed', + }, + visualMood: { + key: 'visual_mood', + label: '视觉气质', + value: '潮湿、梦幻、轻悬疑', + status: 'confirmed', + }, + compositionHooks: { + key: 'composition_hooks', + label: '构图钩子', + value: '台阶透视、倒影、门洞', + status: 'confirmed', + }, + tagsAndForbidden: { + key: 'tags_and_forbidden', + label: '标签与禁区', + value: '雨夜、猫咪、遗迹;禁止文字水印', + status: 'confirmed', + }, + }, + candidates: [], + selectedCandidateId: null, + coverImageSrc: null, + coverAssetId: null, + generationStatus: 'idle', + }, + blockers: [ + { + id: 'missing-cover-image', + code: 'MISSING_COVER_IMAGE', + message: '正式拼图图片尚未确定', + }, + ], + qualityFindings: [], + publishReady: false, + }, + updatedAt: '2026-04-22T12:10:00.000Z', + }, + }); vi.mocked(getRpgCreationSession).mockResolvedValue(mockSession); vi.mocked(listRpgCreationWorks).mockResolvedValue([]); + vi.mocked(listBigFishWorks).mockResolvedValue({ + items: [], + }); vi.mocked(listPuzzleWorks).mockResolvedValue({ items: [], }); @@ -1054,6 +1365,106 @@ test('puzzle creation timeout exits busy state and shows a readable error', asyn expect(screen.queryByText(/正在准备拼图共创工作区/u)).toBeNull(); }); +test('puzzle draft card restores the bound agent session and opens the result view', async () => { + const user = userEvent.setup(); + + vi.mocked(listPuzzleWorks).mockResolvedValue({ + items: [ + { + workId: 'puzzle-work-session-1', + profileId: 'puzzle-profile-session-1', + ownerUserId: 'user-1', + sourceSessionId: 'puzzle-session-1', + authorDisplayName: '测试玩家', + levelName: '雨夜猫塔', + summary: '一张聚焦发光猫咪与遗迹台阶的雨夜拼图。', + themeTags: ['雨夜', '猫咪', '遗迹'], + coverImageSrc: null, + coverAssetId: null, + publicationStatus: 'draft', + updatedAt: '2026-04-22T12:10:00.000Z', + publishedAt: null, + playCount: 0, + publishReady: false, + }, + ], + }); + + render(); + + await openCreationHub(user); + + expect(await screen.findByRole('button', { name: /继续创作/u })).toBeTruthy(); + await user.click(await screen.findByRole('button', { name: /继续创作/u })); + + await waitFor(() => { + expect(getPuzzleAgentSession).toHaveBeenCalledWith('puzzle-session-1'); + }); + + expect(await screen.findByText('拼图结果页')).toBeTruthy(); + + await user.click(screen.getByRole('button', { name: '返回' })); + + expect(await screen.findByText('拼图玩法共创')).toBeTruthy(); + expect( + screen.getByText('雨夜里有一只会发光的猫站在遗迹台阶上。'), + ).toBeTruthy(); +}); + +test('big fish draft card restores the bound agent session and opens the result view', async () => { + const user = userEvent.setup(); + + vi.mocked(listBigFishWorks).mockResolvedValue({ + items: [ + { + workId: 'big-fish-work-big-fish-session-1', + sourceSessionId: 'big-fish-session-1', + title: '机械深海 大鱼吃小鱼', + subtitle: '机械微生物吞并进化 · 偏爽快节奏', + summary: '机械微生物吞并进化', + coverImageSrc: null, + status: 'draft', + updatedAt: '2026-04-22T12:10:00.000Z', + publishReady: false, + levelCount: 8, + levelMainImageReadyCount: 0, + levelMotionReadyCount: 0, + backgroundReady: false, + }, + ], + }); + + render(); + + await openCreationHub(user); + + const title = await screen.findByText('机械深海 大鱼吃小鱼'); + const card = title.closest('.platform-surface'); + if (!(card instanceof HTMLElement)) { + throw new Error('Missing big fish draft card'); + } + + await user.click(within(card).getByRole('button', { name: /继续创作/u })); + + await waitFor(() => { + expect(getBigFishCreationSession).toHaveBeenCalledWith( + 'big-fish-session-1', + ); + }); + + expect(await screen.findByText('大鱼吃小鱼结果页')).toBeTruthy(); + expect(screen.getByText('机械深海 大鱼吃小鱼')).toBeTruthy(); + + await user.click(screen.getByRole('button', { name: '返回' })); + + expect( + await screen.findByText('大鱼吃小鱼共创:big-fish-session-1'), + ).toBeTruthy(); + expect( + screen.getByText('我想做机械深海里微生物互相吞并进化。'), + ).toBeTruthy(); +}); + test('starting draft generation leaves the agent workspace and shows the generation progress view', async () => { const user = userEvent.setup(); @@ -2198,7 +2609,7 @@ test('creation hub published work delete button removes the work directly from c }; const publishedLibraryEntry = { ownerUserId: 'user-1', - profileId: 'world-card-delete-1', + profileId: 'world-card-delete-1',, profile: { id: 'world-card-delete-1', name: '潮雾列岛', diff --git a/src/services/big-fish-works/bigFishWorksClient.ts b/src/services/big-fish-works/bigFishWorksClient.ts new file mode 100644 index 00000000..a98b1e7d --- /dev/null +++ b/src/services/big-fish-works/bigFishWorksClient.ts @@ -0,0 +1,29 @@ +import type { BigFishWorksResponse } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; +import { type ApiRetryOptions, requestJson } from '../apiClient'; + +const BIG_FISH_WORKS_API_BASE = '/api/runtime/big-fish/works'; +const BIG_FISH_WORKS_READ_RETRY: ApiRetryOptions = { + maxRetries: 1, + baseDelayMs: 120, + maxDelayMs: 360, +}; + +/** + * 读取当前用户的大鱼吃小鱼创作作品列表。 + */ +export async function listBigFishWorks() { + return requestJson( + BIG_FISH_WORKS_API_BASE, + { + method: 'GET', + }, + '读取大鱼吃小鱼作品列表失败', + { + retry: BIG_FISH_WORKS_READ_RETRY, + }, + ); +} + +export const bigFishWorksClient = { + list: listBigFishWorks, +}; diff --git a/src/services/big-fish-works/index.ts b/src/services/big-fish-works/index.ts new file mode 100644 index 00000000..f25b1e64 --- /dev/null +++ b/src/services/big-fish-works/index.ts @@ -0,0 +1 @@ +export { bigFishWorksClient, listBigFishWorks } from './bigFishWorksClient';