diff --git a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE3_IMPLEMENTATION_PLAN_2026-04-14.md b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE3_IMPLEMENTATION_PLAN_2026-04-14.md index a4f1ddac..927240ba 100644 --- a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE3_IMPLEMENTATION_PLAN_2026-04-14.md +++ b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE3_IMPLEMENTATION_PLAN_2026-04-14.md @@ -874,6 +874,7 @@ isCardDetailLoading: boolean; 1. 前端步骤名优先复用服务端 `phaseLabel` 的真实语义,不再单独发明一套四段式文案。 2. 如果服务端处于批处理阶段,顶部 `phaseLabel` / `phaseDetail` 继续直接显示当前批次信息。 3. 自动补主形象与幕背景图也属于草稿生成链路的一部分,不能在进度 UI 中被误折叠成“已完成”后的隐藏耗时。 +4. 进度页“已耗时”必须按服务端 operation 的创建时间 `startedAt` 与当前时间计算;刷新页面、恢复轮询或前端重挂载时不能重新从本地点击时间开始计时。只有旧 operation 缺少 `startedAt` 时,才允许使用本地记录的开始时间作为兜底。 ## 12.1 生成底稿时序 diff --git a/docs/prd/AI_NATIVE_PUZZLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md b/docs/prd/AI_NATIVE_PUZZLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md index 54c436a8..7b239e39 100644 --- a/docs/prd/AI_NATIVE_PUZZLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md +++ b/docs/prd/AI_NATIVE_PUZZLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md @@ -135,6 +135,18 @@ 结果页不是一个只读总结页,而是拼图作品最小可编辑工作台。 +### 5.1.1 已发布作品二次编辑 + +创作者在“我的创作”中点击自己已发布的拼图作品时,不进入只读详情页,而是回到该作品绑定的拼图结果页继续编辑。独立的“体验”按钮仍然直接进入第 1 关,不与编辑入口混用。 + +落地规则: + +1. 已发布拼图作品必须优先通过 `sourceSessionId` 恢复原 Agent session。 +2. 恢复后的结果页沿用原草稿、候选图、正式图、标题、摘要和标签;创作者可以继续改标题、摘要、标签,并重新生成或切换图片。 +3. 再次点击发布时不得创建新作品,必须覆盖同一个 `profileId / workId`。 +4. 覆盖发布只更新作品内容、更新时间、发布时间与广场投影;不得清零 `playCount`,不得改变作品归属。 +5. 如果历史作品缺少 `sourceSessionId`,前端只能退回作品详情,不伪造编辑 session。 + ## 5.2 运行时结论 拼图运行时应该是: diff --git a/packages/shared/src/contracts/rpgAgentActions.ts b/packages/shared/src/contracts/rpgAgentActions.ts index 417314a7..f6d4ceb9 100644 --- a/packages/shared/src/contracts/rpgAgentActions.ts +++ b/packages/shared/src/contracts/rpgAgentActions.ts @@ -66,6 +66,8 @@ export interface RpgAgentOperationRecord { phaseDetail: string; progress: number; error?: string | null; + /** 操作创建时间,草稿生成进度页用它计算总耗时。 */ + startedAt?: string | null; updatedAt?: string | null; } diff --git a/server-rs/crates/api-server/src/big_fish.rs b/server-rs/crates/api-server/src/big_fish.rs index dbc9acee..fb874774 100644 --- a/server-rs/crates/api-server/src/big_fish.rs +++ b/server-rs/crates/api-server/src/big_fish.rs @@ -434,13 +434,7 @@ pub async fn execute_big_fish_action( let now = current_utc_micros(); let session = match payload.action.trim() { "big_fish_compile_draft" => { - compile_big_fish_draft_with_all_assets( - &state, - session_id, - owner_user_id, - now, - ) - .await + compile_big_fish_draft_with_all_assets(&state, session_id, owner_user_id, now).await } "big_fish_generate_level_main_image" => { let asset_url = generate_big_fish_formal_asset( diff --git a/server-rs/crates/api-server/src/custom_world.rs b/server-rs/crates/api-server/src/custom_world.rs index ca34ed4d..09d161a1 100644 --- a/server-rs/crates/api-server/src/custom_world.rs +++ b/server-rs/crates/api-server/src/custom_world.rs @@ -2538,6 +2538,7 @@ fn map_custom_world_agent_operation_response( phase_detail: operation.phase_detail, progress: operation.progress, error: operation.error_message, + started_at: Some(timestamp_micros_to_rfc3339(operation.started_at_micros)), updated_at: Some(timestamp_micros_to_rfc3339(operation.updated_at_micros)), } } diff --git a/server-rs/crates/api-server/src/custom_world_ai.rs b/server-rs/crates/api-server/src/custom_world_ai.rs index 4f3c47c8..5dda2b94 100644 --- a/server-rs/crates/api-server/src/custom_world_ai.rs +++ b/server-rs/crates/api-server/src/custom_world_ai.rs @@ -2548,26 +2548,24 @@ mod tests { name: Some("礁石神殿".to_string()), description: Some("古老礁石上的半沉神殿。".to_string()), }; - let manual_prompt = build_custom_world_scene_image_prompt( - SceneImagePromptParams { - profile: SceneImagePromptProfile { - name: profile_input.name.as_deref().unwrap_or_default(), - subtitle: profile_input.subtitle.as_deref().unwrap_or_default(), - tone: profile_input.tone.as_deref().unwrap_or_default(), - player_goal: profile_input.player_goal.as_deref().unwrap_or_default(), - summary: profile_input.summary.as_deref().unwrap_or_default(), - setting_text: profile_input.setting_text.as_deref().unwrap_or_default(), - }, - landmark: SceneImagePromptLandmark { - name: landmark.name.as_deref().unwrap_or_default(), - description: landmark.description.as_deref().unwrap_or_default(), - }, - user_prompt, - has_reference_image: false, - fallback_landmark_name: Some("礁石神殿"), - fallback_world_name: "雾海群岛", + let manual_prompt = build_custom_world_scene_image_prompt(SceneImagePromptParams { + profile: SceneImagePromptProfile { + name: profile_input.name.as_deref().unwrap_or_default(), + subtitle: profile_input.subtitle.as_deref().unwrap_or_default(), + tone: profile_input.tone.as_deref().unwrap_or_default(), + player_goal: profile_input.player_goal.as_deref().unwrap_or_default(), + summary: profile_input.summary.as_deref().unwrap_or_default(), + setting_text: profile_input.setting_text.as_deref().unwrap_or_default(), }, - ); + landmark: SceneImagePromptLandmark { + name: landmark.name.as_deref().unwrap_or_default(), + description: landmark.description.as_deref().unwrap_or_default(), + }, + user_prompt, + has_reference_image: false, + fallback_landmark_name: Some("礁石神殿"), + fallback_world_name: "雾海群岛", + }); let normalized = normalize_scene_image_request(CustomWorldSceneImageRequest { profile_id: Some("profile_001".to_string()), diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index c1ba06b7..8e9c120b 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -47,13 +47,14 @@ use spacetime_client::{ PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord, - PuzzleCreatorIntentRecord, PuzzleGeneratedImageCandidateRecord, - PuzzleGeneratedImagesSaveRecordInput, PuzzlePublishRecordInput, PuzzleResultDraftRecord, - PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, - PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleMergedGroupRecord, PuzzlePieceStateRecord, - PuzzleRunDragRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, - PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, - PuzzleWorkUpsertRecordInput, SpacetimeClientError, + PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord, + PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, + PuzzleMergedGroupRecord, PuzzlePieceStateRecord, PuzzlePublishRecordInput, + PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, + PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunRecord, + PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord, + PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, PuzzleWorkUpsertRecordInput, + SpacetimeClientError, }; use std::convert::Infallible; use tokio::time::sleep; @@ -1639,7 +1640,10 @@ async fn generate_puzzle_image_candidates( let mut items = Vec::with_capacity(generated.images.len()); for (index, image) in generated.images.into_iter().enumerate() { - let candidate_id = format!("{session_id}-candidate-{}", candidate_start_index + index + 1); + let candidate_id = format!( + "{session_id}-candidate-{}", + candidate_start_index + index + 1 + ); let asset = persist_puzzle_generated_asset( state, owner_user_id, @@ -1690,10 +1694,12 @@ async fn build_local_next_puzzle_run( })) })?; if current_level.status != "cleared" { - return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": PUZZLE_RUNTIME_PROVIDER, - "message": "current level is not cleared", - }))); + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_RUNTIME_PROVIDER, + "message": "current level is not cleared", + })), + ); } if let Some(gallery_item) = resolve_gallery_next_puzzle_work(state, &run).await? { @@ -1702,10 +1708,12 @@ async fn build_local_next_puzzle_run( let source_session_id = payload.source_session_id.unwrap_or_default(); if source_session_id.trim().is_empty() { - return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": PUZZLE_RUNTIME_PROVIDER, - "message": "sourceSessionId is required when gallery has no next puzzle work", - }))); + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_RUNTIME_PROVIDER, + "message": "sourceSessionId is required when gallery has no next puzzle work", + })), + ); } let session = state .spacetime_client() @@ -1767,14 +1775,23 @@ async fn build_local_next_puzzle_run( let candidate = updated_session .draft .as_ref() - .and_then(|draft| draft.candidates.iter().find(|candidate| !candidate.image_src.is_empty())) + .and_then(|draft| { + draft + .candidates + .iter() + .find(|candidate| !candidate.image_src.is_empty()) + }) .ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": PUZZLE_RUNTIME_PROVIDER, "message": "现场生成后没有可用候选图", })) })?; - Ok(build_next_run_from_candidate(run, &updated_session, candidate)) + Ok(build_next_run_from_candidate( + run, + &updated_session, + candidate, + )) } async fn resolve_gallery_next_puzzle_work( @@ -1788,7 +1805,10 @@ async fn resolve_gallery_next_puzzle_work( .map_err(map_puzzle_client_error)?; Ok(items.into_iter().find(|item| { item.publication_status == "published" - && item.cover_image_src.as_ref().is_some_and(|value| !value.is_empty()) + && item + .cover_image_src + .as_ref() + .is_some_and(|value| !value.is_empty()) && !run.played_profile_ids.contains(&item.profile_id) })) } @@ -1836,7 +1856,9 @@ fn build_next_run_from_candidate( .map(|draft| format!("{} · 候选 {}", draft.level_name, level_index)) .unwrap_or_else(|| format!("候选拼图 {level_index}")), "当前草稿".to_string(), - draft.map(|draft| draft.theme_tags.clone()).unwrap_or_default(), + draft + .map(|draft| draft.theme_tags.clone()) + .unwrap_or_default(), Some(candidate.image_src.clone()), ) } @@ -1893,13 +1915,14 @@ fn build_local_puzzle_board(grid_size: u32) -> PuzzleBoardRecord { } let pieces = (0..total) .map(|index| { - let current = positions - .get(index as usize) - .cloned() - .unwrap_or(PuzzleCellPositionRecord { - row: index / grid_size, - col: index % grid_size, - }); + let current = + positions + .get(index as usize) + .cloned() + .unwrap_or(PuzzleCellPositionRecord { + row: index / grid_size, + col: index % grid_size, + }); PuzzlePieceStateRecord { piece_id: format!("piece-{index}"), correct_row: index / grid_size, diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index b34c514d..c4539408 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -452,6 +452,7 @@ pub struct CustomWorldAgentOperationResponse { pub phase_detail: String, pub progress: u32, pub error: Option, + pub started_at: Option, pub updated_at: Option, } diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 33b00d92..2a801718 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -1837,6 +1837,7 @@ pub(crate) fn map_custom_world_agent_operation_snapshot( phase_detail: snapshot.phase_detail, progress: snapshot.progress, error_message: snapshot.error_message, + started_at_micros: snapshot.created_at_micros, updated_at_micros: snapshot.updated_at_micros, } } @@ -3721,6 +3722,7 @@ pub struct CustomWorldAgentOperationRecord { pub phase_detail: String, pub progress: u32, pub error_message: Option, + pub started_at_micros: i64, pub updated_at_micros: i64, } diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index 944d8a0a..36819917 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -1384,7 +1384,9 @@ fn upsert_puzzle_work_profile(ctx: &TxContext, profile: PuzzleWorkProfile) -> Re cover_image_src: profile.cover_image_src, cover_asset_id: profile.cover_asset_id, publication_status: profile.publication_status, - play_count: profile.play_count, + // 二次编辑发布同一个 profile 时,作品内容可以覆盖,但历史游玩数属于 + // 广场消费数据,不能因为重新发布被清零。 + play_count: existing.play_count.max(profile.play_count), anchor_pack_json: serialize_json(&profile.anchor_pack), publish_ready: profile.publish_ready, created_at: existing.created_at, diff --git a/src/components/rpg-entry/rpgEntryShared.ts b/src/components/rpg-entry/rpgEntryShared.ts index 9f9f11f4..9b807cce 100644 --- a/src/components/rpg-entry/rpgEntryShared.ts +++ b/src/components/rpg-entry/rpgEntryShared.ts @@ -50,6 +50,7 @@ export function createFailedRpgEntryAgentOperation(params: { phaseDetail: params.error, progress: 0, error: params.error, + startedAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; } diff --git a/src/routing/RouteImageReadyGate.test.ts b/src/routing/RouteImageReadyGate.test.ts index fd1bf04f..73d33bc6 100644 --- a/src/routing/RouteImageReadyGate.test.ts +++ b/src/routing/RouteImageReadyGate.test.ts @@ -1,12 +1,19 @@ // @vitest-environment jsdom -import { describe, expect, it } from 'vitest'; +import { act, render, screen } from '@testing-library/react'; +import { createElement } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { collectRouteImageUrls, extractCssImageUrls, normalizePreloadImageUrl, } from './routeImageReadyGateUtils'; +import { RouteImageReadyGate } from './RouteImageReadyGate'; + +afterEach(() => { + vi.useRealTimers(); +}); describe('RouteImageReadyGate image url helpers', () => { it('extracts urls from layered CSS image values', () => { @@ -41,4 +48,40 @@ describe('RouteImageReadyGate image url helpers', () => { new URL('/ui/frame.png', document.baseURI).href, ]); }); + + it('reveals route content after a short cap when images stay pending', () => { + vi.useFakeTimers(); + + render( + createElement( + RouteImageReadyGate, + { + eyebrow: '正在载入游戏', + text: '正在载入冒险...', + }, + createElement( + 'section', + { + 'data-testid': 'route-content', + }, + createElement('img', { + src: '/generated-characters/slow-cover.png', + alt: 'slow cover', + }), + ), + ), + ); + + const content = screen.getByTestId('route-content'); + const visibilityGate = content.parentElement; + expect(visibilityGate?.getAttribute('aria-hidden')).toBe('true'); + expect(visibilityGate?.style.visibility).toBe('hidden'); + + act(() => { + vi.advanceTimersByTime(1600); + }); + + expect(visibilityGate?.getAttribute('aria-hidden')).toBe('false'); + expect(visibilityGate?.style.visibility).toBe('visible'); + }); }); diff --git a/src/routing/RouteImageReadyGate.tsx b/src/routing/RouteImageReadyGate.tsx index 2613c2c8..29634871 100644 --- a/src/routing/RouteImageReadyGate.tsx +++ b/src/routing/RouteImageReadyGate.tsx @@ -15,6 +15,7 @@ type RouteImageReadyGateProps = { const IMAGE_GATE_QUIET_MS = 140; const IMAGE_GATE_MIN_VISIBLE_WAIT_MS = 260; +const IMAGE_GATE_MAX_BLOCK_MS = 1400; const IMAGE_PRELOAD_TIMEOUT_MS = 12000; const settledImageUrls = new Set(); @@ -66,7 +67,7 @@ function preloadImageUrl(url: string) { /** * 路由首屏图片门闩:业务页面先真实挂载但不可见, - * 等当前 DOM 中已发现的图片全部 settled 后再一次性显示。 + * 只等待短暂稳定窗口,不再把所有图片加载完成作为首屏硬阻塞。 */ export function RouteImageReadyGate({ children, @@ -78,6 +79,7 @@ export function RouteImageReadyGate({ const scanTimerRef = useRef(null); const revealTimerRef = useRef(null); const scanVersionRef = useRef(0); + const revealedRef = useRef(false); const [ready, setReady] = useState(false); useEffect(() => { @@ -88,6 +90,7 @@ export function RouteImageReadyGate({ let disposed = false; startTimeRef.current = window.performance.now(); + revealedRef.current = false; setReady(false); const clearScanTimer = () => { @@ -110,27 +113,26 @@ export function RouteImageReadyGate({ }; const scheduleReveal = (version: number) => { + if (revealedRef.current) { + return; + } + clearRevealTimer(); const elapsed = window.performance.now() - startTimeRef.current; - const delay = Math.max( + const preferredDelay = Math.max( IMAGE_GATE_QUIET_MS, IMAGE_GATE_MIN_VISIBLE_WAIT_MS - elapsed, ); + const maxRemainingDelay = Math.max(0, IMAGE_GATE_MAX_BLOCK_MS - elapsed); + const delay = Math.min(preferredDelay, maxRemainingDelay); revealTimerRef.current = window.setTimeout(() => { if (disposed || version !== scanVersionRef.current) { return; } - const pendingUrls = collectRouteImageUrls(root).filter( - (url) => !settledImageUrls.has(url), - ); - if (pendingUrls.length > 0) { - scheduleScan(); - return; - } - + revealedRef.current = true; setReady(true); }, delay); }; @@ -146,23 +148,18 @@ export function RouteImageReadyGate({ (url) => !settledImageUrls.has(url), ); - if (pendingUrls.length === 0) { - scheduleReveal(version); - return; + if (pendingUrls.length > 0) { + // 首屏慢加载的核心约束:图片可预热,但不能无限期阻塞页面主体可见。 + pendingUrls.forEach((url) => { + void preloadImageUrl(url); + }); } - // 已进入页面但新 DOM 批量挂载图片时,先回到等待态,避免图片逐张闪入。 - setReady(false); - void Promise.allSettled(pendingUrls.map(preloadImageUrl)).then(() => { - if (disposed || version !== scanVersionRef.current) { - return; - } - scheduleScan(); - }); + scheduleReveal(version); } const observer = new MutationObserver((mutations) => { - if (disposed) { + if (disposed || revealedRef.current) { return; }