修复拼图文字直创过早完成

修正拼图文字直创 compile 回包未出图时继续保持生成中

补充文字直创无正式图的回归测试

更新玩法链路文档和 Hermes 踩坑记录
This commit is contained in:
2026-06-07 15:55:15 +08:00
parent 3965f34b02
commit c810e255a5
4 changed files with 158 additions and 12 deletions

View File

@@ -2067,6 +2067,14 @@
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "puzzle draft generation auto starts trial and runtime back opens draft result"`
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 拼图文字直创的 compile 回包不等于生成完成
- 现象:只输入文字点击生成拼图时,页面刚进入生成页就弹出“生成任务已完成,可以继续查看草稿。”,随后又提示“请先选择一张正式拼图图片。”,结果页关卡里也没有图。
- 原因:统一创作表单路径把 `compile_puzzle_draft` 的同步回包无条件当成 ready但后端在 AI 重绘路径会先返回 `stage=image_refining``progressPercent=88` 的会话,只表示首关草稿已编译且后台首图 / UI 资产任务已启动,还没有正式封面或候选图。
- 处理:前端必须继续用 `isPuzzleCompileActionReady(...)` 判断回包 session没有 `draft.coverImageSrc`、首关 `coverImageSrc` 或候选图时保持生成中,不弹完成、不把作品架 pending 标 ready、不自动试玩。生成页轮询合并 session 进度时,未进入编译态或进度无变化就返回原 state避免轮询制造重复 render。
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "puzzle text-only form stays generating|puzzle draft generation auto starts trial|running puzzle draft opens generation progress"`
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## CreativeImageInputPanel 主图点击默认预览
- 现象:复用 `CreativeImageInputPanel` 的结果页 / 编辑页已有主图时用户点击图片却触发上传无法直接查看大图不同玩法若各自手写上传按钮会让主图、历史图、AI 重绘和参考图行为再次分叉。

View File

@@ -111,7 +111,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
- 图像输入复用 `CreativeImageInputPanel`
- 结果页每关画面编辑复用 `CreativeImageInputPanel`;入口页和关卡画面只共享受控 UI 模块不共享数据源、状态、action 或存储位置:入口页继续写 `formDraft` 与草稿编译 payload关卡画面写 `levels[].pictureReference/pictureDescription` 并触发 `generate_puzzle_images`。结果页删除独立“素材配置”Tab不再提供单独 UI 背景生成入口。通用图片面板的展示图和 AI 重绘参考图能力必须分开控制:结果页正式关卡图只作为预览图,不因存在正式图自动暴露 AI 重绘开关;只有本地上传、历史选择或已保存 `pictureReference` 可作为重绘参考图时,才显示 AI 重绘开关并把状态带入 `generate_puzzle_images`。用户在本次编辑中上传或选择历史图后,该图优先占据主图卡片,可删除、切换 AI 重绘,也可关闭 AI 重绘直用;仅有正式图预览时,画面描述框仍可上传多张参考图。关卡详情弹窗应使用加宽面板,关卡名称、画面图和画面描述合并在同一个纵向列表中,名称输入和画面编辑模块外层不再包独立 `platform-subpanel`;画面图卡仍必须保留稳定最小高度,避免弹窗内 `flex-1` 布局坍缩后只剩标题、描述输入和操作按钮。
- 支持画面描述生图、多参考图生图、上传或历史生成主图后 AI 重绘、上传或历史生成主图后不重绘;主链要求浏览器先经 `/api/assets/direct-upload-tickets` 直传 OSS 并确认 `asset_object`,创作 action 只提交 `referenceImageAssetObjectId(s)`,由后端校验 owner / bucket / kind / MIME / size 后签发 OSS 只读 URL 并下载为 VectorEngine `/v1/images/edits` 的 multipart `image` part。本地上传 Data URL 与历史 `/generated-*` 图片路径仅保留为旧草稿、旧入口或未迁移客户端的兼容输入;关闭 AI 重绘时,后端统一解析为首关或当前关卡正式图后再持久化,不调用第一段拼图首图生成。
- 草稿生成会先持久化 `generationStatus=generating` 的作品摘要生成完成并回写关卡拼图画面、关卡画面参考图、UI spritesheet 和关卡背景图后再变为 `ready`;当前不自动生成背景音乐。生成页步骤推进必须跟随后端 session `progressPercent` 的真实里程碑:`88` 表示草稿编译完成并进入出图步骤,`94` 表示生成图已保存并进入 UI / 背景步骤,`96` 表示正式图与 UI 背景已确认并进入写入步骤,最终 action 成功或发布才进入完成态;每个步骤内部可以按实际等待时间使用假进度平滑推进。`88/94/96` 只负责切换当前步骤,不作为总进度地板;总进度按已完成步骤权重加当前步骤内假进度推导,非完成态最多停在 `98%`任一同步 action 回包到达时立即以真实完成/失败结果冻结进度
- 草稿生成会先持久化 `generationStatus=generating` 的作品摘要生成完成并回写关卡拼图画面、关卡画面参考图、UI spritesheet 和关卡背景图后再变为 `ready`;当前不自动生成背景音乐。生成页步骤推进必须跟随后端 session `progressPercent` 的真实里程碑:`88` 表示草稿编译完成并进入出图步骤,`94` 表示生成图已保存并进入 UI / 背景步骤,`96` 表示正式图与 UI 背景已确认并进入写入步骤,最终 action 成功或发布才进入完成态;每个步骤内部可以按实际等待时间使用假进度平滑推进。`88/94/96` 只负责切换当前步骤,不作为总进度地板;总进度按已完成步骤权重加当前步骤内假进度推导,非完成态最多停在 `98%`文字直创的 `compile_puzzle_draft` 同步回包只表示已编译首关草稿并启动后台首图 / UI 资产生成;只要回包 session 仍缺正式 `draft.coverImageSrc`、首关 `coverImageSrc` 和候选图,前端必须继续保持生成中,不弹完成通知、不把草稿卡标记为 ready也不得自动进入结果页或试玩
- 作品架拼图草稿的“生成中”遮罩只表示初始草稿还没有可查看结果;只要作品摘要、首关封面或任一关卡候选图已经可用,后续 UI 背景重生成和追加关卡生图都必须作为结果页局部生成态处理,不能阻止打开草稿结果页。生成失败后,同一浏览器会话内的失败 notice 必须覆盖后端可能仍短暂返回的 `generationStatus=generating` 摘要,作品架保留对应草稿卡但不再显示“生成中”,点击后回到失败 / 重试状态。
- 拼图草稿编译是长耗时 action前端 action 请求默认等待 `1_800_000ms`30 分钟)且不自动重试。每次图片生成调用的预期用时按 90 秒计算,但 `生成拼图首图` 单独按 4 分钟展示;完整 AI 重绘路径为 `编译首关草稿` 8 秒、`生成关卡名称` 10 秒、`生成拼图首图` 4 分钟、`生成关卡画面` 90 秒、`生成UI与背景` 90 秒、`写入正式草稿` 10 秒,合计约 448 秒。上传图且关闭 AI 重绘时必须跳过 `生成拼图首图`,直接进入 `生成关卡画面``生成UI与背景`,合计约 208 秒。生成页恢复时必须使用后端 session `updatedAt` 或作品摘要 `updatedAt` 作为原始 `startedAtMs`;失败/完成态用 `finishedAtMs` 冻结耗时。生成完成后若自动进入草稿试玩,进入 `/runtime/puzzle` 前必须先把 `/creation/puzzle/result` 和当前 `sessionId/profileId/workId` 写成浏览器历史前一站;运行态返回按钮和系统返回都应回到结果页,不得退回生成进度页或暴露重新生成入口。未收到对应后端里程碑前,后续步骤保持待处理;即使当前步骤预计时长耗尽,也只能让当前步骤内部进度停在 `98%` 内,不能自动完成当前步骤或跳到后续步骤。生成页每个步骤只展示标题和进度,不展示步骤详细描述。
- 前端创作、结果页、生成页和错误提示不展示 GPT / Gemini 等具体模型名称如需在内部保留模型路由UI 只使用“标准模式”“创意模式”等产品化名称。

View File

@@ -2620,26 +2620,33 @@ function mergePuzzleSessionProgressIntoGenerationState(
const isCompiledGenerationSession = Boolean(
session.draft && !session.draft.formDraft,
);
if (!isCompiledGenerationSession) {
return state;
}
const nextPhaseId = isCompiledGenerationSession
? resolvePuzzlePhaseFromSessionProgress(state, session)
: state.metadata?.puzzleActivePhaseId;
const nextPhaseId = resolvePuzzlePhaseFromSessionProgress(state, session);
const shouldResetActiveStepStart =
isCompiledGenerationSession &&
nextPhaseId != null &&
nextPhaseId !== state.metadata?.puzzleActivePhaseId;
const nextActiveStepStartedAtMs = shouldResetActiveStepStart
? resolveMiniGameDraftGenerationStartedAtMs(session.updatedAt)
: state.metadata?.puzzleActiveStepStartedAtMs;
if (
state.metadata?.puzzleActivePhaseId === nextPhaseId &&
state.metadata?.puzzleActiveStepStartedAtMs === nextActiveStepStartedAtMs &&
state.metadata?.puzzleProgressPercent === session.progressPercent
) {
return state;
}
return {
...state,
metadata: {
...state.metadata,
puzzleActivePhaseId: nextPhaseId,
puzzleActiveStepStartedAtMs: shouldResetActiveStepStart
? resolveMiniGameDraftGenerationStartedAtMs(session.updatedAt)
: state.metadata?.puzzleActiveStepStartedAtMs,
puzzleProgressPercent: isCompiledGenerationSession
? session.progressPercent
: state.metadata?.puzzleProgressPercent,
puzzleActiveStepStartedAtMs: nextActiveStepStartedAtMs,
puzzleProgressPercent: session.progressPercent,
},
};
}
@@ -7977,7 +7984,49 @@ export function PlatformEntryFlowShellImpl({
actionPayload,
);
setPuzzleOperation(response.operation);
const openResult = isViewingPuzzleGeneration(nextSession.sessionId);
const openResult =
isViewingPuzzleGeneration(nextSession.sessionId) ||
isViewingPuzzleGeneration(response.session.sessionId);
if (!isPuzzleCompileActionReady(response.session)) {
// 中文注释:文字直创的同步 action 回包只代表后台生图任务已启动;
// 未拿到正式图前继续保持生成中,避免误弹完成或启动空草稿试玩。
const nextGenerationState = mergePuzzleSessionProgressIntoGenerationState(
generationState,
response.session,
);
activePuzzleGenerationSessionIdRef.current = response.session.sessionId;
setPuzzleBackgroundCompileTasks((current) => {
const next = { ...current };
if (nextSession.sessionId !== response.session.sessionId) {
delete next[nextSession.sessionId];
}
next[response.session.sessionId] = {
session: response.session,
payload,
generationState: nextGenerationState,
error: null,
};
return next;
});
puzzleFlow.setSession(response.session);
if (openResult) {
setPuzzleGenerationState(nextGenerationState);
}
markDraftGenerating('puzzle', [
response.session.sessionId,
buildPuzzleResultWorkId(response.session.sessionId),
response.session.publishedProfileId,
buildPuzzleResultProfileId(response.session.sessionId),
]);
markPendingDraftGenerating(
'puzzle',
response.session.sessionId,
buildPendingPuzzleDraftMetadata(payload),
);
void refreshPuzzleShelf();
return;
}
const readyGenerationState =
resolveFinishedMiniGameDraftGenerationState(
generationState,

View File

@@ -5217,6 +5217,95 @@ test('running puzzle draft opens generation progress from draft tab', async () =
});
});
test('puzzle text-only form stays generating when compile starts background image without cover', async () => {
const user = userEvent.setup();
const initialSession = buildMockPuzzleAgentSession({
sessionId: 'puzzle-session-text-only',
stage: 'collecting_anchors',
progressPercent: 0,
draft: null,
});
const generatingDraft = buildReadyPuzzleDraft({
workTitle: '文字直创拼图',
workDescription: '只输入文字后后台继续生成图片。',
candidates: [],
selectedCandidateId: null,
coverImageSrc: null,
coverAssetId: null,
generationStatus: 'generating',
levels: [
{
...buildReadyPuzzleDraft().levels![0]!,
candidates: [],
selectedCandidateId: null,
coverImageSrc: null,
coverAssetId: null,
generationStatus: 'generating',
},
],
});
const generatingSession = buildMockPuzzleAgentSession({
sessionId: 'puzzle-session-text-only',
stage: 'image_refining',
progressPercent: 88,
draft: generatingDraft,
lastAssistantReply: '已编译首关草稿,并启动首关画面和 UI 资产后台生成。',
resultPreview: {
draft: generatingDraft,
blockers: [
{
id: 'missing-cover-image-puzzle-level-1',
code: 'MISSING_COVER_IMAGE',
message: '正式拼图图片尚未确定',
},
],
qualityFindings: [],
publishReady: false,
},
});
vi.mocked(createPuzzleAgentSession).mockResolvedValueOnce({
session: initialSession,
});
vi.mocked(executePuzzleAgentAction).mockResolvedValueOnce({
operation: {
operationId: 'compile-puzzle-text-only',
type: 'compile_puzzle_draft',
status: 'completed',
phaseLabel: '首关拼图草稿',
phaseDetail: '已编译首关草稿,并启动首关画面和 UI 资产后台生成。',
progress: 0.88,
},
session: generatingSession,
});
vi.mocked(getPuzzleAgentSession).mockResolvedValue({
session: generatingSession,
});
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(await findCreationTypeButton('拼图'));
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
expect(
await screen.findByRole('progressbar', {
name: '拼图图片生成进度',
}),
).toBeTruthy();
await waitFor(() => {
expect(executePuzzleAgentAction).toHaveBeenCalledWith(
'puzzle-session-text-only',
expect.objectContaining({ action: 'compile_puzzle_draft' }),
);
});
expect(screen.queryByRole('dialog', { name: '生成完成' })).toBeNull();
expect(screen.queryByText('请先选择一张正式拼图图片。')).toBeNull();
expect(screen.queryByText('拼图结果页')).toBeNull();
expect(updatePuzzleWork).not.toHaveBeenCalled();
expect(startLocalPuzzleRun).not.toHaveBeenCalled();
});
test('puzzle form checks mud points before creating a draft', async () => {
const user = userEvent.setup();
vi.mocked(getProfileDashboard).mockResolvedValue({