feat: restore agent sessions from creation drafts
This commit is contained in:
@@ -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. 已验证
|
||||
|
||||
|
||||
100
docs/technical/PUZZLE_DRAFT_SESSION_RESTORE_2026-04-23.md
Normal file
100
docs/technical/PUZZLE_DRAFT_SESSION_RESTORE_2026-04-23.md
Normal file
@@ -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. 发布后不会额外生成第二条重复作品记录。
|
||||
@@ -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 工作区后,历史消息不丢失。
|
||||
21
packages/shared/src/contracts/bigFishWorkSummary.ts
Normal file
21
packages/shared/src/contracts/bigFishWorkSummary.ts
Normal file
@@ -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[];
|
||||
}
|
||||
@@ -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<UnifiedCreationWorkItem[]>(
|
||||
() => [
|
||||
...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);
|
||||
|
||||
@@ -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 ? '草稿' : '已发布'}
|
||||
</span>
|
||||
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
|
||||
{isPuzzle ? '拼图' : 'RPG'}
|
||||
{isPuzzle ? '拼图' : isBigFish ? '大鱼' : 'RPG'}
|
||||
</span>
|
||||
{!isPuzzle && item.item.stageLabel ? (
|
||||
{item.kind === 'rpg' && item.item.stageLabel ? (
|
||||
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
|
||||
{item.item.stageLabel}
|
||||
</span>
|
||||
@@ -133,6 +145,23 @@ export function CustomWorldWorkCard({
|
||||
游玩 {item.item.playCount}
|
||||
</span>
|
||||
</>
|
||||
) : isBigFish ? (
|
||||
<>
|
||||
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
|
||||
关卡 {item.item.levelCount}
|
||||
</span>
|
||||
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
|
||||
主图 {item.item.levelMainImageReadyCount}
|
||||
</span>
|
||||
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
|
||||
动作 {item.item.levelMotionReadyCount}
|
||||
</span>
|
||||
{item.item.backgroundReady ? (
|
||||
<span className="platform-pill platform-pill--success px-3 py-1 text-[10px]">
|
||||
背景已就绪
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
|
||||
|
||||
@@ -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<CustomWorldLibraryEntry<CustomWorldProfile> | null>(null);
|
||||
const [bigFishSession, setBigFishSession] =
|
||||
useState<BigFishSessionSnapshotResponse | null>(null);
|
||||
const [bigFishWorks, setBigFishWorks] = useState<BigFishWorkSummary[]>([]);
|
||||
const [bigFishRun, setBigFishRun] =
|
||||
useState<BigFishRuntimeSnapshotResponse | null>(null);
|
||||
const [bigFishError, setBigFishError] = useState<string | null>(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 = (
|
||||
<CustomWorldCreationHub
|
||||
items={creationHubItems}
|
||||
loading={platformBootstrap.isLoadingPlatform || isPuzzleLoadingLibrary}
|
||||
loading={
|
||||
platformBootstrap.isLoadingPlatform ||
|
||||
isBigFishLoadingLibrary ||
|
||||
isPuzzleLoadingLibrary
|
||||
}
|
||||
error={
|
||||
platformBootstrap.isLoadingPlatform || isPuzzleLoadingLibrary
|
||||
platformBootstrap.isLoadingPlatform ||
|
||||
isBigFishLoadingLibrary ||
|
||||
isPuzzleLoadingLibrary
|
||||
? null
|
||||
: (platformBootstrap.platformError ??
|
||||
sessionController.agentWorkspaceRestoreError ??
|
||||
bigFishError ??
|
||||
puzzleError)
|
||||
}
|
||||
onRetry={() => {
|
||||
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) => {
|
||||
|
||||
@@ -234,6 +234,7 @@ export function PuzzleResultView({
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="返回"
|
||||
onClick={onBack}
|
||||
disabled={isBusy}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-white/10 text-white/84 disabled:opacity-45"
|
||||
|
||||
@@ -32,8 +32,15 @@ import {
|
||||
unpublishRpgEntryWorldProfile,
|
||||
upsertRpgProfileBrowseHistory as upsertProfileBrowseHistory,
|
||||
} from '../../services/rpg-entry';
|
||||
import { createBigFishCreationSession } from '../../services/big-fish-creation';
|
||||
import { createPuzzleAgentSession } from '../../services/puzzle-agent';
|
||||
import {
|
||||
createBigFishCreationSession,
|
||||
getBigFishCreationSession,
|
||||
} from '../../services/big-fish-creation';
|
||||
import { listBigFishWorks } from '../../services/big-fish-works';
|
||||
import {
|
||||
createPuzzleAgentSession,
|
||||
getPuzzleAgentSession,
|
||||
} from '../../services/puzzle-agent';
|
||||
import { listPuzzleWorks } from '../../services/puzzle-works';
|
||||
import type { GameState } from '../../types';
|
||||
import {
|
||||
@@ -114,9 +121,14 @@ vi.mock('../../services/puzzle-works', () => ({
|
||||
vi.mock('../../services/big-fish-creation', () => ({
|
||||
createBigFishCreationSession: vi.fn(),
|
||||
executeBigFishCreationAction: vi.fn(),
|
||||
getBigFishCreationSession: vi.fn(),
|
||||
streamBigFishCreationMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/big-fish-works', () => ({
|
||||
listBigFishWorks: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/puzzle-agent', () => ({
|
||||
createPuzzleAgentSession: vi.fn(),
|
||||
executePuzzleAgentAction: vi.fn(),
|
||||
@@ -124,6 +136,39 @@ vi.mock('../../services/puzzle-agent', () => ({
|
||||
streamPuzzleAgentMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../big-fish-creation/BigFishAgentWorkspace', () => ({
|
||||
BigFishAgentWorkspace: ({
|
||||
session,
|
||||
}: {
|
||||
session: { sessionId: string; messages: Array<{ text: string }> } | null;
|
||||
}) => (
|
||||
<div className="big-fish-agent-workspace-mock">
|
||||
大鱼吃小鱼共创:{session?.sessionId ?? 'missing-session'}
|
||||
{session?.messages.map((message) => (
|
||||
<div key={`${session.sessionId}-${message.text}`}>{message.text}</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../big-fish-result/BigFishResultView', () => ({
|
||||
BigFishResultView: ({
|
||||
session,
|
||||
onBack,
|
||||
}: {
|
||||
session: { draft?: { title: string } | null };
|
||||
onBack: () => void;
|
||||
}) => (
|
||||
<div className="big-fish-result-view-mock">
|
||||
<div>大鱼吃小鱼结果页</div>
|
||||
<div>{session.draft?.title ?? '缺少草稿标题'}</div>
|
||||
<button type="button" onClick={onBack}>
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
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(<TestWrapper withAuth />);
|
||||
|
||||
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(<TestWrapper withAuth />);
|
||||
|
||||
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: '潮雾列岛',
|
||||
|
||||
29
src/services/big-fish-works/bigFishWorksClient.ts
Normal file
29
src/services/big-fish-works/bigFishWorksClient.ts
Normal file
@@ -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<BigFishWorksResponse>(
|
||||
BIG_FISH_WORKS_API_BASE,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取大鱼吃小鱼作品列表失败',
|
||||
{
|
||||
retry: BIG_FISH_WORKS_READ_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export const bigFishWorksClient = {
|
||||
list: listBigFishWorks,
|
||||
};
|
||||
1
src/services/big-fish-works/index.ts
Normal file
1
src/services/big-fish-works/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { bigFishWorksClient, listBigFishWorks } from './bigFishWorksClient';
|
||||
Reference in New Issue
Block a user