From 42037860d5d8443f522b1f32863a8bae6dccad3d Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Sun, 24 May 2026 19:00:21 +0800 Subject: [PATCH] feat: integrate jump-hop shelf and asset flow --- ...„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md | 2 +- ...å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md | 2 +- ...玩法创作】平å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md | 6 +- packages/shared/src/contracts/jumpHop.ts | 5 + server-rs/crates/api-server/src/jump_hop.rs | 395 +++++++++++++++++- .../crates/api-server/src/modules/jump_hop.rs | 9 +- server-rs/crates/api-server/src/puzzle.rs | 14 +- .../api-server/src/puzzle/vector_engine.rs | 36 +- server-rs/crates/platform-oss/src/lib.rs | 6 +- .../crates/shared-contracts/src/jump_hop.rs | 10 + .../crates/spacetime-client/src/jump_hop.rs | 217 ++++++++-- .../crates/spacetime-module/src/jump_hop.rs | 21 +- .../common/CreativeImageInputPanel.tsx | 7 + .../CustomWorldCreationHub.tsx | 19 +- .../custom-world-home/CustomWorldWorkCard.tsx | 1 + .../custom-world-home/creationWorkShelf.ts | 66 +++ .../PlatformEntryFlowShellImpl.tsx | 172 +++++++- .../PuzzleAgentWorkspace.interaction.test.tsx | 52 +-- .../puzzle-agent/PuzzleAgentWorkspace.tsx | 35 +- .../puzzle-result/PuzzleResultView.tsx | 1 + src/services/jump-hop/jumpHopClient.ts | 14 + .../puzzle-works/puzzleAssetClient.test.ts | 24 ++ .../puzzle-works/puzzleAssetClient.ts | 24 +- src/services/puzzleReferenceImage.test.ts | 4 +- src/services/puzzleReferenceImage.ts | 25 +- 25 files changed, 1018 insertions(+), 149 deletions(-) create mode 100644 src/services/puzzle-works/puzzleAssetClient.test.ts diff --git a/docs/ã€åŽç«¯æž¶æž„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md b/docs/ã€åŽç«¯æž¶æž„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md index a0cb47fe..f5a3cd42 100644 --- a/docs/ã€åŽç«¯æž¶æž„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md +++ b/docs/ã€åŽç«¯æž¶æž„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md @@ -118,7 +118,7 @@ npm run check:server-rs-ddd 3. Adapter 输出应ä¿ç•™ legacy public pathã€object keyã€asset object idã€MIMEã€extensionã€task id 和实际 prompt。 4. Adapter ä¸è´Ÿè´£æ‰£è´¹ã€é€€æ¬¾æˆ–钱包读å–;计费ä»ç”±è°ƒç”¨æ–¹æ˜¾å¼åŒ…裹。 5. Puzzleã€Match3Dã€éŸ³é¢‘ã€GLBã€è§†é¢‘ç­‰å¤æ‚媒体å¯ä»¥å¤ç”¨ OSS + asset object + binding 的底层æŒä¹…化能力,但玩法专属处ç†è§„则留在å„自编排层,ä¸å¡žè¿›å…¬å…±æŽ¥å£ã€‚ -6. 拼图图生图å‚考图主链ä¸å¾—å†æŠŠå¤§å›¾ Data URL 塞进创作 JSON bodyï¼›å‰ç«¯å…ˆç›´ä¼  OSS å¹¶æäº¤ `referenceImageAssetObjectId(s)`,`api-server` 校验 `asset_object` çš„ bucketã€kindã€å›¾ç‰‡ MIMEã€å¤§å°å’Œ owner åŽç­¾å‘åªè¯» URL ç»™ VectorEngine 读å–,Data URL / `/generated-*` 仅作为旧请求兼容。 +6. 拼图入å£é¡µä¸Žç»“果页新增关å¡çš„æœ¬åœ°å‚考图ä¸èµ°æµè§ˆå™¨ç›´ä¼  OSS,å‰ç«¯è¯»å–为 Data URL åŽéšåˆ›ä½œ action æäº¤ï¼Œå¹¶åœ¨è¯»å–å‰é™åˆ¶ 6MBã€æ˜¾ç¤ºâ€œå›¾ç‰‡â‰¤6MBâ€ã€‚`api-server` 必须对 Data URL å®žé™…å­—èŠ‚æ•°å†æ¬¡æ ¡éªŒï¼›åކå²å›¾ç‰‡æ‰æäº¤ `referenceImageAssetObjectId(s)`,åŽç«¯æ ¡éªŒ `asset_object` çš„ bucketã€kindã€å›¾ç‰‡ MIMEã€å¤§å°å’Œ owner åŽç­¾å‘åªè¯» URL ç»™ VectorEngine 读å–。 7. 系列素æå›¾é›†ä½¿ç”¨ `server-rs/crates/api-server/src/generated_asset_sheets.rs`:调用方必须传入 `grid_size` 作为 `n*n` çš„ `n`,å¯é€‰ä¼ å…¥ç‰©å“åç§° prompt 模æ¿å’Œç‰¹æ®Šè®¾å®š prompt;模å—è´Ÿè´£ sheet prompt ç»„è£…ã€æŒ‰ `n*n` 切片ã€é€æ˜ŽåŒ–ã€PNG 输出ã€OSS private upload 请求构造和 sheet / item / special prompt å…ƒæ•°æ®æŒä¹…化。玩法åªè´Ÿè´£è§„划 slotã€è°ƒç”¨å…·ä½“生图 providerã€è®¡è´¹ã€å¤±è´¥å›žå†™ï¼Œä»¥åŠæŠŠé€šç”¨åˆ‡ç‰‡ç»“æžœæ˜ å°„å›žè‡ªå·±çš„ DTO / è‰ç¨¿ / runtime 字段。 ## SpacetimeDB schema å˜æ›´è§„则 diff --git a/docs/ã€å¼€å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md b/docs/ã€å¼€å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md index 21bf0711..8f473b6e 100644 --- a/docs/ã€å¼€å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md +++ b/docs/ã€å¼€å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md @@ -202,7 +202,7 @@ Windows Stdb module æž„å»ºæµæ°´çº¿è¿è¡Œåœ¨ Jenkins `windows` 节点上。该 - Windows 下载阶段如果出现 `curl: (18)` 或å“åº”ä½“æˆªæ–­ï¼Œæµæ°´çº¿ä¼šä¿ç•™åŒå `.download` 临时文件并用 `curl -C -` æ–­ç‚¹ç»­ä¼ ï¼›åªæœ‰å®Œæ•´è¿”回但 SHA256 digest ä»ä¸åŒ¹é…æ—¶æ‰åˆ é™¤ä¸´æ—¶æ–‡ä»¶åŽé‡æ–°ä¸‹è½½ã€‚目标 Linux 节点ä»åªæŽ¥æ”¶ `stash/unstash` 带过去的本地下载件,ä¸å›žé€€å¤–网下载。 - Windows 下载阶段如果走代ç†ï¼Œåœ¨ `Genarrative-Server-Provision` 傿•° `PROVISION_DOWNLOAD_PROXY` 填写 Windows Jenkins 节点å¯è®¿é—®çš„ HTTP 代ç†ï¼Œä¾‹å¦‚ `http://127.0.0.1:7890`ï¼›ä¸è¦å¡«å†™ç›®æ ‡ release 机器视角的 `127.0.0.1`,除éžä»£ç†ç¡®å®žè¿è¡Œåœ¨è¯¥ Windows 节点本机。Linux ç›®æ ‡æœºé˜¶æ®µä¼šå¼ºåˆ¶è¦æ±‚使用本地下载件,缺少文件直接失败,ä¸å†å›žé€€åˆ°å¤–网下载。 - `otelcol-contrib.service` 作为å¯é€‰ç³»ç»ŸæœåŠ¡åŠ å…¥ provisionï¼Œé»˜è®¤ç›‘å¬ `127.0.0.1:4317/4318` 并使用 `deploy/otelcol/genarrative-debug.yaml`。api-server 是å¦å‘é€ OTLP ä»ç”± `GENARRATIVE_OTEL_ENABLED` 控制,æœåŠ¡ unit è§ `deploy/systemd/otelcol-contrib.service`。 -- Nginx `/api/` 与 `/admin/api/` 通过 `genarrative_api` upstream 代ç†åˆ° `127.0.0.1:8082`,upstream keepalive 为 64ï¼›`limit_conn` 负责连接 / å¹¶å‘ä¿æŠ¤ï¼Œ`limit_req` è´Ÿè´£å…¥å£ RPS å¿«æ‹’ç»ã€‚当剿¨¡æ¿æŠŠå…¬å¼€ gallery list å•独放到 `genarrative_gallery_rps`,默认 `rate=5000r/s`ã€`burst=4096`ã€`limit_conn=320`;公开详情和普通 API 放到 `genarrative_api_rps`,åŽå° API 放到 `genarrative_admin_rps`。通用 `/api` location 设置 `client_max_body_size 64m` åªæ˜¯å代兜底,防止旧客户端或兼容请求在到达 `api-server` å‰è¢«é»˜è®¤ 1 MiB ä¸Šé™æ‹¦æˆªï¼›é•¿æœŸä¸»é“¾ä¸å¾—ä¾èµ–大 JSON body 承载图片,拼图å‚考图应先直传 OSS,åªå‘åˆ›ä½œæŽ¥å£æäº¤ `referenceImageAssetObjectId(s)`,由åŽç«¯ç­¾åªè¯» URL 给外部模型读å–。真实业务上é™ä»ç”± Rust 路由 `DefaultBodyLimit`ã€èµ„产确认时 OSS HEAD 和解ç åŽå­—节校验控制。若线上出现 `413 Request Entity Too Large` 且 access log 中 `request_time=0.000`ã€`upstream_status=-`,说明请求在 Nginx 层被拦截,先用 `nginx -T | grep client_max_body_size` 检查 release æ¨¡æ¿æ˜¯å¦å·²æ¸²æŸ“å¹¶ reloadï¼ŒåŒæ—¶æ£€æŸ¥å‰ç«¯æ˜¯å¦ä»åœ¨æäº¤ Data URL è€Œä¸æ˜¯ `assetObjectId`。`limit_conn_status 429` å’Œ `limit_req_status 429` 必须在 HTTP 与 HTTPS server ä¸­åŒæ—¶ç”Ÿæ•ˆï¼›è‹¥çº¿ä¸ŠåŽ‹æµ‹çœ‹åˆ° `limiting connections by zone "genarrative_api_conn"` å´è¿”回 503,优先检查 `nginx -T` 里 HTTPS server 是å¦ç¼ºå°‘这些状æ€ç ï¼Œä»¥åŠ `/api/runtime/puzzle/gallery` 是å¦è¯¯è½åˆ°é€šç”¨ `location ~ ^/api` çš„ `limit_conn=64`。压测时看 `/var/log/nginx/genarrative.access.log` 中的 `request_time`ã€`upstream_connect_time`ã€`upstream_header_time`ã€`upstream_response_time`ã€`upstream_status`ã€`request_id`。 +- Nginx `/api/` 与 `/admin/api/` 通过 `genarrative_api` upstream 代ç†åˆ° `127.0.0.1:8082`,upstream keepalive 为 64ï¼›`limit_conn` 负责连接 / å¹¶å‘ä¿æŠ¤ï¼Œ`limit_req` è´Ÿè´£å…¥å£ RPS å¿«æ‹’ç»ã€‚当剿¨¡æ¿æŠŠå…¬å¼€ gallery list å•独放到 `genarrative_gallery_rps`,默认 `rate=5000r/s`ã€`burst=4096`ã€`limit_conn=320`;公开详情和普通 API 放到 `genarrative_api_rps`,åŽå° API 放到 `genarrative_admin_rps`。通用 `/api` location 设置 `client_max_body_size 64m` 是å代兜底,防止拼图入å£é¡µ / æ–°å¢žå…³å¡æœ¬åœ°å‚考图 Data URL 或旧兼容请求在到达 `api-server` å‰è¢«é»˜è®¤ 1 MiB ä¸Šé™æ‹¦æˆªï¼›æ‹¼å›¾æœ¬åœ°å‚考图å‰åŽç«¯ç»Ÿä¸€é™åˆ¶ 6MB,历å²å›¾ç‰‡ä»æäº¤ `referenceImageAssetObjectId(s)`。若线上出现 `413 Request Entity Too Large` 且 access log 中 `request_time=0.000`ã€`upstream_status=-`,说明请求在 Nginx 层被拦截,先用 `nginx -T | grep client_max_body_size` 检查 release æ¨¡æ¿æ˜¯å¦å·²æ¸²æŸ“å¹¶ reloadï¼ŒåŒæ—¶æ£€æŸ¥å‰ç«¯æ˜¯å¦è¶…出 6MB 或错误æäº¤äº†æœªåŽ‹ç¼©å¤§å›¾ã€‚`limit_conn_status 429` å’Œ `limit_req_status 429` 必须在 HTTP 与 HTTPS server ä¸­åŒæ—¶ç”Ÿæ•ˆï¼›è‹¥çº¿ä¸ŠåŽ‹æµ‹çœ‹åˆ° `limiting connections by zone "genarrative_api_conn"` å´è¿”回 503,优先检查 `nginx -T` 里 HTTPS server 是å¦ç¼ºå°‘这些状æ€ç ï¼Œä»¥åŠ `/api/runtime/puzzle/gallery` 是å¦è¯¯è½åˆ°é€šç”¨ `location ~ ^/api` çš„ `limit_conn=64`。压测时看 `/var/log/nginx/genarrative.access.log` 中的 `request_time`ã€`upstream_connect_time`ã€`upstream_header_time`ã€`upstream_response_time`ã€`upstream_status`ã€`request_id`。 - 作å“列表 K6 脚本一次 iteration 默认请求两个公开接å£ï¼Œå› æ­¤çº¦ 50 HTTP req/s 的目标命令使用 `SCENARIO=spike START_RPS=5 PEAK_RPS=25 HOLD=60s END_RPS=5 DETAIL_RATIO=0 npm run loadtest:k6:works`。 - 作å“列表短期继续由 `api-server` / BFF 订阅 SpacetimeDB 公开 read model åŽè¯»æœ¬åœ° cache,ä¸è®©æµè§ˆå™¨å‰ç«¯ç›´æŽ¥è®¢é˜…完整列表;未æ¥å¦‚新增 `public_work_gallery_entry` 等专用公开作å“列表 read model,å‰ç«¯åªå¯è®¢é˜…稳定ã€ä½ŽåŸºæ•°ã€å…¬å¼€çš„ä¸“ç”¨æŠ•å½±ï¼Œç¦æ­¢è®¢é˜… `puzzle_work_profile`ã€`custom_world_profile` 等玩法æºè¡¨åŽè‡ªè¡Œ joinã€èšåˆæˆ–判断æƒé™ã€‚å‰ç«¯ç›´è®¢é˜…è½åœ°å‰å¿…é¡»å…ˆè¡¥é½æƒé™ã€å­—æ®µå¥‘çº¦ã€æŽ’åº / 分页ã€åŸ‹ç‚¹å’Œ BFF 回退策略。 - 50 HTTP req/s 验收目标为 `http_req_failed < 1%`ã€`p95 < 2s`ã€`dropped_iterations = 0`ï¼ŒåŒæ—¶åŽ‹æµ‹çª—å£å†… Nginx 无新增 502。2026-05-19 容器 2C / 2G 连续 10 è½®ä¸é‡å¯ SpacetimeDB 压测:`PEAK_RPS=2500` 等价约 5000 HTTP req/s,平å‡å®žé™…åžå约 `4219 HTTP req/s`,10 轮总计 `1,897,357` 个 200ã€`212,542` 个 429ã€`0` 个 5xx,200 è¯·æ±‚å¹³å‡ `p95=123ms`ã€`p99=234ms`;该档会把 SpacetimeDB 容器内存从约 `366MiB` 推到约 `885MiB / 896MiB`,因此当å‰ä¸è¦ç»§ç»­æŠ¬å…¬å¼€ gallery å…¥å£å¹¶å‘ï¼Œåº”ä¼˜å…ˆå¤„ç† SpacetimeDB 侧连接 / 订阅 / tracking 写入åŽçš„内存高水ä½ã€‚ diff --git a/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md b/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md index ad3b3c28..413faf49 100644 --- a/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md +++ b/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md @@ -76,7 +76,7 @@ RPG / 拼图等è¿è¡Œæ€å­˜æ¡£é€‰æ‹©å…¥å£ç»Ÿä¸€åœ¨ä¸ªäººä¸­å¿ƒ `常用功能 > - 图åƒè¾“å…¥å¤ç”¨ `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 é‡ç»˜æ—¶ï¼ŒåŽç«¯ç»Ÿä¸€è§£æžä¸ºé¦–关或当å‰å…³å¡æ­£å¼å›¾åŽå†æŒä¹…化,ä¸è°ƒç”¨ç¬¬ä¸€æ®µæ‹¼å›¾é¦–图生æˆã€‚ +- 支æŒç”»é¢æè¿°ç”Ÿå›¾ã€å¤šå‚考图生图ã€ä¸Šä¼ æˆ–历å²ç”Ÿæˆä¸»å›¾åŽ AI é‡ç»˜ã€ä¸Šä¼ æˆ–历å²ç”Ÿæˆä¸»å›¾åŽä¸é‡ç»˜ï¼›å…¥å£é¡µä¸Žç»“æžœé¡µæ–°å¢žå…³å¡æœ¬åœ°ä¸Šä¼ éƒ½ä¸èµ°æµè§ˆå™¨ç›´ä¼  OSS,å‰ç«¯è¯»å–为 Data URL åŽéšåˆ›ä½œ action æäº¤ç»™ `api-server`,并在图åƒè¾“入区明确展示“图片≤6MBâ€ã€‚å‚考图上传å‰åŽç»Ÿä¸€é™åˆ¶ä¸º 6MB:å‰ç«¯è¯»å–å‰å𿖇件大尿 ¡éªŒå¹¶æç¤ºâ€œå‚考图过大,请压缩åŽå†ä¸Šä¼ ï¼ˆå½“å‰ X,最多 6MB)â€ï¼ŒåŽç«¯å¯¹ Data URL / asset object çš„å®žé™…å­—èŠ‚æ•°å†æ¬¡æ£€æµ‹å¹¶è¿”回 400。历å²å›¾ç‰‡ä»æäº¤ `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`;当å‰ä¸è‡ªåŠ¨ç”ŸæˆèƒŒæ™¯éŸ³ä¹ã€‚生æˆé¡µè¿›åº¦ä¸å†æŒ‰å›ºå®š 5 分钟展示,而按实际开始时间和当å‰è·¯å¾„çš„åˆ†æ­¥éª¤é¢„è®¡æ—¶é•¿æŽ¨è¿›ï¼›ä»»ä¸€åŒæ­¥ action 回包到达时立å³ä»¥çœŸå®žå®Œæˆ/失败结果冻结进度。 - ä½œå“æž¶æ‹¼å›¾è‰ç¨¿çš„“生æˆä¸­â€é®ç½©åªè¡¨ç¤ºåˆå§‹è‰ç¨¿è¿˜æ²¡æœ‰å¯æŸ¥çœ‹ç»“果;åªè¦ä½œå“摘è¦ã€é¦–å…³å°é¢æˆ–任一关å¡å€™é€‰å›¾å·²ç»å¯ç”¨ï¼ŒåŽç»­ UI 背景é‡ç”Ÿæˆå’Œè¿½åŠ å…³å¡ç”Ÿå›¾éƒ½å¿…é¡»ä½œä¸ºç»“æžœé¡µå±€éƒ¨ç”Ÿæˆæ€å¤„ç†ï¼Œä¸èƒ½é˜»æ­¢æ‰“å¼€è‰ç¨¿ç»“果页。 - 拼图è‰ç¨¿ç¼–译是长耗时 action,å‰ç«¯ action 请求默认等待 `1_800_000ms`(30 分钟)且ä¸è‡ªåЍé‡è¯•ã€‚æ¯æ¬¡ `gpt-image-2` 调用的预期用时按 90 秒计算;完整 AI é‡ç»˜è·¯å¾„为 `编译首关è‰ç¨¿` 8 ç§’ã€`生æˆå…³å¡åç§°` 10 ç§’ã€`ç”Ÿæˆæ‹¼å›¾é¦–图` 90 ç§’ã€`生æˆå…³å¡ç”»é¢` 90 ç§’ã€`生æˆUI与背景` 90 ç§’ã€`写入正å¼è‰ç¨¿` 10 秒,åˆè®¡çº¦ 298 秒。上传图且关闭 AI é‡ç»˜æ—¶å¿…须跳过 `ç”Ÿæˆæ‹¼å›¾é¦–图`,直接进入 `生æˆå…³å¡ç”»é¢` å’Œ `生æˆUI与背景`,åˆè®¡çº¦ 208 秒。生æˆé¡µæ¢å¤æ—¶å¿…é¡»æ²¿ç”¨ä½œå“æ‘˜è¦ `updatedAt` 作为原始 `startedAtMs`,失败/å®Œæˆæ€ç”¨ `finishedAtMs` 冻结耗时,ä¸èƒ½åœ¨é”屿ˆ–返回è‰ç¨¿é¡µåŽé‡æ–°ä»Ž 0 计时。未收到 action 回包å‰ï¼Œæ€»è¿›åº¦ä»æœ€å¤šåœåœ¨ 98%,但当预计写入时长耗尽且ä»å¤„于 `写入正å¼è‰ç¨¿` 时,该步骤自身应显示已完æˆï¼Œä¸èƒ½å‡ºçŽ°â€œè¿›è¡Œä¸­ 100%â€ã€‚ @@ -120,6 +120,10 @@ RPG / 拼图等è¿è¡Œæ€å­˜æ¡£é€‰æ‹©å…¥å£ç»Ÿä¸€åœ¨ä¸ªäººä¸­å¿ƒ `常用功能 > å¹³å°é¦–页推èã€ç²¾é€‰ã€æœ€æ–°ã€å…¬å¼€è¯¦æƒ…ã€æœç´¢ã€å·²çŽ©ä½œå“和公开试玩统一按 `sourceType='jump-hop'` 与 `JH-*` 公开作å“å·è¯†åˆ«è·³ä¸€è·³ä½œå“ï¼›ä»Žå…¬å¼€è¯¦æƒ…æˆ–æŽ¨èæµå¯åЍè¿è¡Œæ€æ—¶ï¼Œè‹¥å¡ç‰‡æ‘˜è¦ä¸è¶³ä»¥æºå¸¦è§’色图ã€åœ°å—图集和路径é…置,必须先补读完整 work profile å†ä¼ å…¥è¿è¡Œæ€ã€‚å¹³å°å£³å±‚å¿…é¡»åŒæ­¥æ³¨å†Œ `jump-hop-workspace`ã€`jump-hop-generating`ã€`jump-hop-result`ã€`jump-hop-runtime`ã€`jump-hop-gallery-detail` 阶段,并在 `appPageRoutes.ts` 映射 `/creation/jump-hop/workspace`ã€`/creation/jump-hop/generating`ã€`/creation/jump-hop/result`ã€`/gallery/jump-hop/detail`ã€`/runtime/jump-hop`ï¼ŒåŒæ—¶æŒæœ‰ sessionã€workã€runã€galleryã€busy/error 与生æˆè¿›åº¦çжæ€ï¼Œé¿å…åªåˆå…¥æ¸²æŸ“åˆ†æ”¯ä½†é—æ¼çŠ¶æ€æºæˆ–分享路径导致 typecheck 失败ã€åˆ·æ–°å›žé¦–页。 +è·³ä¸€è·³ä½œå“æž¶èµ°åˆ›ä½œä¸­å¿ƒçš„统一作å“列表:å‰ç«¯é€šè¿‡ `/api/creation/jump-hop/works` 拉å–ä½œå“æ‘˜è¦ï¼Œè‰ç¨¿æ€ä¼šä¸Ž pending notice åˆå¹¶åŽæ˜¾ç¤ºåœ¨ä½œå“架里,已å‘布作å“点击åŽä¼šå…ˆæŒ‰ profileId 读å–完整详情å†è¿›å…¥è¯¦æƒ…或è¿è¡Œæ€ã€‚生æˆä¸­ä½œå“ä»ä»¥åŽç«¯æ‘˜è¦é‡Œçš„ `generationStatus` 为准,刷新åŽåº”能æ¢å¤ç­‰å¾…é®ç½©ï¼Œä¸èƒ½åªä¾èµ–内存 notice。 + +åˆ é™¤ç­‰ç ´åæ€§åŠ¨ä½œå½“å‰æœªæŽ¥å…¥ jump-hop 删除 API;如果åŽç»­è¦åœ¨ä½œå“æž¶æä¾›åˆ é™¤å…¥å£ï¼Œå¿…须先补é½åŽç«¯/SpacetimeDB/å‰ç«¯æ•´æ¡åˆ é™¤é“¾è·¯ï¼Œå†å¼€æ”¾æŒ‰é’®ã€‚ + ## 敲木鱼 对外å称:`敲木鱼`。工程域:`wooden-fish`。PRD è§ `docs/prd/ã€çŽ©æ³•åˆ›ä½œã€‘æ•²æœ¨é±¼çŽ©æ³•æ¨¡æ¿PRD-2026-05-20.md`。 diff --git a/packages/shared/src/contracts/jumpHop.ts b/packages/shared/src/contracts/jumpHop.ts index 856e04bf..19fafe66 100644 --- a/packages/shared/src/contracts/jumpHop.ts +++ b/packages/shared/src/contracts/jumpHop.ts @@ -47,6 +47,7 @@ export interface JumpHopWorkspaceCreateRequest { export interface JumpHopActionRequest { actionType: JumpHopActionType; + profileId?: string | null; workTitle?: string | null; workDescription?: string | null; themeTags?: string[] | null; @@ -55,6 +56,10 @@ export interface JumpHopActionRequest { characterPrompt?: string | null; tilePrompt?: string | null; endMoodPrompt?: string | null; + characterAsset?: JumpHopCharacterAsset | null; + tileAtlasAsset?: JumpHopCharacterAsset | null; + tileAssets?: JumpHopTileAsset[] | null; + coverComposite?: string | null; } export interface JumpHopCharacterAsset { diff --git a/server-rs/crates/api-server/src/jump_hop.rs b/server-rs/crates/api-server/src/jump_hop.rs index ec6d0a43..3f13f8a3 100644 --- a/server-rs/crates/api-server/src/jump_hop.rs +++ b/server-rs/crates/api-server/src/jump_hop.rs @@ -4,23 +4,44 @@ use axum::{ http::{HeaderName, StatusCode, header}, response::Response, }; +use module_assets::{ + AssetObjectAccessPolicy, build_asset_entity_binding_input, build_asset_object_upsert_input, + generate_asset_binding_id, generate_asset_object_id, +}; +use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess}; use serde_json::{Value, json}; use shared_contracts::jump_hop::{ - JumpHopActionRequest, JumpHopDraftResponse, JumpHopGalleryDetailResponse, - JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse, JumpHopRestartRunRequest, - JumpHopRunResponse, JumpHopSessionResponse, JumpHopSessionSnapshotResponse, - JumpHopStartRunRequest, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse, + JumpHopActionRequest, JumpHopActionType, JumpHopCharacterAsset, JumpHopDraftResponse, + JumpHopGalleryDetailResponse, JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse, + JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopSessionResponse, + JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopTileAsset, JumpHopTileType, + JumpHopWorkDetailResponse, JumpHopWorkMutationResponse, JumpHopWorksResponse, JumpHopWorkspaceCreateRequest, }; use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros}; use spacetime_client::SpacetimeClientError; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::{collections::BTreeMap, time::{SystemTime, UNIX_EPOCH}}; use crate::{ api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, + generated_asset_sheets::{ + GeneratedAssetSheetPromptInput, build_generated_asset_sheet_prompt, + slice_generated_asset_sheet, + }, + generated_image_assets::{ + GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl, + adapter::{GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput}, + normalize_generated_image_asset_mime, + }, + openai_image_generation::{ + build_openai_image_http_client, create_openai_image_generation, + require_openai_image_settings, + }, request_context::RequestContext, state::AppState, }; +const JUMP_HOP_TILE_ITEM_NAMES: [&str; 6] = ["start", "normal", "target", "finish", "bonus", "accent"]; + const JUMP_HOP_PROVIDER: &str = "jump-hop"; const JUMP_HOP_CREATION_PROVIDER: &str = "jump-hop-creation"; const JUMP_HOP_RUNTIME_PROVIDER: &str = "jump-hop-runtime"; @@ -103,6 +124,15 @@ pub async fn execute_jump_hop_action( ensure_non_empty(&request_context, &session_id, "sessionId")?; let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_CREATION_PROVIDER)?; let owner_user_id = authenticated.claims().user_id().to_string(); + let mut payload = payload; + maybe_generate_jump_hop_assets( + &state, + &request_context, + session_id.as_str(), + owner_user_id.as_str(), + &mut payload, + ) + .await?; let response = state .spacetime_client() .execute_jump_hop_action(session_id, owner_user_id, payload) @@ -143,6 +173,31 @@ pub async fn publish_jump_hop_work( )) } +pub async fn list_jump_hop_works( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + let works = state + .spacetime_client() + .list_jump_hop_works(authenticated.claims().user_id().to_string()) + .await + .map_err(|error| { + jump_hop_error_response( + &request_context, + JUMP_HOP_CREATION_PROVIDER, + map_jump_hop_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + JumpHopWorksResponse { + items: works.into_iter().map(|work| work.summary).collect(), + }, + )) +} + pub async fn get_jump_hop_runtime_work( State(state): State, Path(profile_id): Path, @@ -298,6 +353,336 @@ pub async fn get_jump_hop_gallery_detail( )) } + +async fn maybe_generate_jump_hop_assets( + state: &AppState, + request_context: &RequestContext, + session_id: &str, + owner_user_id: &str, + payload: &mut JumpHopActionRequest, +) -> Result<(), Response> { + if !matches!(payload.action_type, JumpHopActionType::CompileDraft) { + return Ok(()); + } + if payload.character_asset.is_some() + && payload.tile_atlas_asset.is_some() + && payload.tile_assets.as_ref().is_some_and(|assets| !assets.is_empty()) + { + return Ok(()); + } + let profile_id = payload + .profile_id + .as_ref() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + .unwrap_or_else(|| build_prefixed_uuid_id("jump-hop-profile-")); + payload.profile_id = Some(profile_id.clone()); + + let settings = require_openai_image_settings(state) + .map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?; + let http_client = build_openai_image_http_client(&settings) + .map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?; + + let character_prompt = payload + .character_prompt + .as_deref() + .unwrap_or("俯视角å¯çˆ±ä¸»è§’ï¼Œé€æ˜ŽèƒŒæ™¯"); + let tile_prompt = payload + .tile_prompt + .as_deref() + .unwrap_or("ç­‰è·ç«‹ä½“地å—图集"); + + let character_generated = create_openai_image_generation( + &http_client, + &settings, + character_prompt, + Some("文字ã€Logoã€æ°´å°ã€æŒ‰é’®ã€UI å­—ã€ä½Žæ¸…晰度ã€ç•¸å½¢è‚¢ä½“ã€å¤šä½™è§’色ã€è£åˆ‡ä¸»ä½“"), + "1024*1024", + 1, + &[], + "跳一跳角色资产生æˆå¤±è´¥", + ) + .await + .map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?; + let character_image = character_generated.images.into_iter().next().ok_or_else(|| { + jump_hop_error_response( + request_context, + JUMP_HOP_CREATION_PROVIDER, + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "message": "è·³ä¸€è·³è§’è‰²èµ„äº§ç”ŸæˆæˆåŠŸä½†æœªè¿”å›žå›¾ç‰‡ã€‚", + })), + ) + })?; + let character_asset = persist_jump_hop_generated_image_asset( + state, + owner_user_id, + profile_id.as_str(), + "character", + character_prompt, + character_image, + LegacyAssetPrefix::JumpHopAssets, + 768, + 768, + request_context, + ) + .await?; + + let sheet_prompt = build_generated_asset_sheet_prompt(&GeneratedAssetSheetPromptInput { + subject_text: tile_prompt, + item_names: &vec!["start".to_string(), "normal".to_string(), "target".to_string(), "finish".to_string(), "bonus".to_string(), "accent".to_string()], + grid_size: 3, + item_name_prompt_template: Some("第{row_index}行:{item_name} çš„ {view_count} 个ä¸åŒè§†å›¾"), + special_prompt: Some("æ¯ä¸ªæ ¼å­å¯¹åº”一个 tile 类型,供跳一跳地å—è£åˆ‡ä½¿ç”¨ã€‚"), + }) + .map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?; + let tile_generated = create_openai_image_generation( + &http_client, + &settings, + sheet_prompt.as_str(), + Some("文字ã€Logoã€æ°´å°ã€æŒ‰é’®ã€UI å­—ã€ä½Žæ¸…晰度ã€ç•¸å½¢è‚¢ä½“ã€å¤šä½™è§’色ã€è£åˆ‡ä¸»ä½“"), + "1024*1024", + 1, + &[], + "跳一跳地å—图集生æˆå¤±è´¥", + ) + .await + .map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?; + let tile_image = tile_generated.images.into_iter().next().ok_or_else(|| { + jump_hop_error_response( + request_context, + JUMP_HOP_CREATION_PROVIDER, + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "message": "跳一跳地å—å›¾é›†ç”ŸæˆæˆåŠŸä½†æœªè¿”å›žå›¾ç‰‡ã€‚", + })), + ) + })?; + let tile_slices = slice_generated_asset_sheet( + &tile_image, + &vec!["start".to_string(), "normal".to_string(), "target".to_string(), "finish".to_string(), "bonus".to_string(), "accent".to_string()], + 3, + ) + .map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?; + let tile_atlas_asset = persist_jump_hop_generated_image_asset( + state, + owner_user_id, + profile_id.as_str(), + "tile-atlas", + tile_prompt, + tile_image, + LegacyAssetPrefix::JumpHopAssets, + 1024, + 1024, + request_context, + ) + .await?; + let tile_assets = tile_slices + .into_iter() + .enumerate() + .map(|(index, row)| JumpHopTileAsset { + tile_type: match index { + 0 => JumpHopTileType::Start, + 1 => JumpHopTileType::Normal, + 2 => JumpHopTileType::Target, + 3 => JumpHopTileType::Finish, + 4 => JumpHopTileType::Bonus, + _ => JumpHopTileType::Accent, + }, + image_src: format!("/generated-jump-hop-assets/{profile_id}/tiles/{index}.png"), + image_object_key: format!("generated-jump-hop-assets/{profile_id}/tiles/{index}.png"), + asset_object_id: format!("{profile_id}-tile-{index}-object"), + source_atlas_cell: format!("cell-{index}"), + visual_width: 256, + visual_height: 192, + top_surface_radius: 42.0, + landing_radius: 34.0, + }) + .collect::>(); + payload.character_asset = Some(character_asset); + payload.tile_atlas_asset = Some(tile_atlas_asset); + payload.tile_assets = Some(tile_assets); + payload.cover_composite = payload + .cover_composite + .clone() + .or_else(|| Some(format!("/generated-jump-hop-assets/{profile_id}/cover-composite.png"))); + Ok(()) +} + +async fn persist_jump_hop_generated_image_asset( + state: &AppState, + owner_user_id: &str, + profile_id: &str, + slot: &str, + prompt: &str, + image: crate::openai_image_generation::DownloadedOpenAiImage, + prefix: LegacyAssetPrefix, + width: u32, + height: u32, + request_context: &RequestContext, +) -> Result { + let image_format = normalize_generated_image_asset_mime(image.mime_type.as_str()); + let prepared = GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput { + prefix, + path_segments: vec![profile_id.to_string(), slot.to_string()], + file_stem: "image".to_string(), + image: GeneratedImageAssetDataUrl { + format: image_format, + bytes: image.bytes, + }, + access: OssObjectAccess::Private, + metadata: GeneratedImageAssetAdapterMetadata { + asset_kind: Some(format!("jump-hop-{slot}")), + owner_user_id: Some(owner_user_id.to_string()), + entity_kind: Some("jump_hop_work".to_string()), + entity_id: Some(profile_id.to_string()), + slot: Some(slot.to_string()), + provider: Some("vector-engine".to_string()), + task_id: None, + }, + extra_metadata: BTreeMap::new(), + }) + .map_err(|error| { + jump_hop_error_response( + request_context, + JUMP_HOP_CREATION_PROVIDER, + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "generated-image-assets", + "message": format!("准备跳一跳图片资产上传请求失败:{error:?}"), + })), + ) + })?; + let persisted_mime_type = prepared.format.mime_type.clone(); + let oss_client = state.oss_client().ok_or_else(|| { + jump_hop_error_response( + request_context, + JUMP_HOP_CREATION_PROVIDER, + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "aliyun-oss", + "reason": "OSS 未完æˆçŽ¯å¢ƒå˜é‡é…ç½®", + })), + ) + })?; + let http_client = reqwest::Client::new(); + let put_result = oss_client + .put_object(&http_client, prepared.request) + .await + .map_err(|error| { + jump_hop_error_response( + request_context, + JUMP_HOP_CREATION_PROVIDER, + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "aliyun-oss", + "message": error.to_string(), + })), + ) + })?; + let head = oss_client + .head_object( + &http_client, + OssHeadObjectRequest { + object_key: put_result.object_key.clone(), + }, + ) + .await + .map_err(|error| { + jump_hop_error_response( + request_context, + JUMP_HOP_CREATION_PROVIDER, + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "aliyun-oss", + "message": error.to_string(), + })), + ) + })?; + let now_micros = current_utc_micros(); + let asset_object_input = build_asset_object_upsert_input( + generate_asset_object_id(now_micros), + head.bucket, + head.object_key.clone(), + AssetObjectAccessPolicy::Private, + head.content_type.or(Some(persisted_mime_type)), + head.content_length, + head.etag, + format!("jump-hop-{slot}"), + None, + Some(owner_user_id.to_string()), + Some(profile_id.to_string()), + Some(profile_id.to_string()), + now_micros, + ) + .map_err(|error| { + jump_hop_error_response( + request_context, + JUMP_HOP_CREATION_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "asset-object", + "message": error.to_string(), + })), + ) + })?; + let asset_object = state + .spacetime_client() + .confirm_asset_object(asset_object_input) + .await + .map_err(|error| { + jump_hop_error_response( + request_context, + JUMP_HOP_CREATION_PROVIDER, + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "spacetimedb", + "message": error.to_string(), + })), + ) + })?; + let binding_input = build_asset_entity_binding_input( + generate_asset_binding_id(now_micros), + asset_object.asset_object_id.clone(), + "jump_hop_work".to_string(), + profile_id.to_string(), + slot.to_string(), + format!("jump-hop-{slot}"), + Some(owner_user_id.to_string()), + Some(profile_id.to_string()), + now_micros, + ) + .map_err(|error| { + jump_hop_error_response( + request_context, + JUMP_HOP_CREATION_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "asset-entity-binding", + "message": error.to_string(), + })), + ) + })?; + state + .spacetime_client() + .bind_asset_object_to_entity(binding_input) + .await + .map_err(|error| { + jump_hop_error_response( + request_context, + JUMP_HOP_CREATION_PROVIDER, + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "spacetimedb", + "message": error.to_string(), + })), + ) + })?; + Ok(JumpHopCharacterAsset { + asset_id: format!("{profile_id}-{slot}-{now_micros}"), + image_src: put_result.legacy_public_path, + image_object_key: head.object_key, + asset_object_id: asset_object.asset_object_id, + generation_provider: "vector-engine".to_string(), + prompt: prompt.to_string(), + width, + height, + }) +} + fn build_jump_hop_draft(payload: &JumpHopWorkspaceCreateRequest) -> JumpHopDraftResponse { JumpHopDraftResponse { template_id: JUMP_HOP_TEMPLATE_ID.to_string(), diff --git a/server-rs/crates/api-server/src/modules/jump_hop.rs b/server-rs/crates/api-server/src/modules/jump_hop.rs index 7648fe91..f60cc9b2 100644 --- a/server-rs/crates/api-server/src/modules/jump_hop.rs +++ b/server-rs/crates/api-server/src/modules/jump_hop.rs @@ -8,7 +8,7 @@ use crate::{ jump_hop::{ create_jump_hop_session, execute_jump_hop_action, get_jump_hop_gallery_detail, get_jump_hop_runtime_work, get_jump_hop_session, jump_hop_run_jump, list_jump_hop_gallery, - publish_jump_hop_work, restart_jump_hop_run, start_jump_hop_run, + list_jump_hop_works, publish_jump_hop_work, restart_jump_hop_run, start_jump_hop_run, }, state::AppState, }; @@ -36,6 +36,13 @@ pub fn router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/creation/jump-hop/works", + get(list_jump_hop_works).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/creation/jump-hop/works/{profile_id}/publish", post(publish_jump_hop_work).route_layer(middleware::from_fn_with_state( diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 84dfac4f..88132af9 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -122,12 +122,24 @@ const PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE: &str = "1024x1024"; const VECTOR_ENGINE_PROVIDER: &str = "vector-engine"; const PUZZLE_LEVEL_NAME_VISION_IMAGE_MAX_SIDE: u32 = 768; const PUZZLE_LEVEL_NAME_VISION_MAX_TOKENS: u32 = 512; -const PUZZLE_REFERENCE_IMAGE_MAX_BYTES: usize = 8 * 1024 * 1024; +const PUZZLE_REFERENCE_IMAGE_MAX_BYTES: usize = 6 * 1024 * 1024; const PUZZLE_REFERENCE_IMAGE_SOURCE_LIMIT: usize = 5; const PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER: &str = "移动端拼图游æˆçº¯èƒŒæ™¯ï¼Œé¢˜ææ°›å›´æ¸…晰,ä¸åŒ…嫿‹¼å›¾æ§½æˆ– UI 元素"; const PUZZLE_VECTOR_ENGINE_SQUARE_IMAGE_SIZE: &str = "1024x1024"; const PUZZLE_VECTOR_ENGINE_PORTRAIT_IMAGE_SIZE: &str = "1024x1536"; + +pub(crate) fn format_puzzle_reference_image_upload_bytes(bytes: usize) -> String { + format!("{:.1}MB", bytes as f64 / 1024.0 / 1024.0) +} + +pub(crate) fn build_puzzle_reference_image_too_large_message(actual_bytes: usize) -> String { + format!( + "å‚考图过大,请压缩åŽå†ä¸Šä¼ ï¼ˆå½“å‰ {},最多 6MB)。", + format_puzzle_reference_image_upload_bytes(actual_bytes) + ) +} + const PUZZLE_LEVEL_SCENE_IMAGE_PROMPT: &str = "å‚考图作为拼图画é¢ï¼Œç”Ÿæˆå¯¹åº”的拼图游æˆå…³å¡ç”»é¢ï¼Œè¦æ±‚ç”»é¢ä¸­æ‰€æœ‰å…ƒç´ ç²¾è‡´ä¸”风格高度一致,画é¢ä¸­æ‰€æœ‰UI细节饱满精致ã€å®Œæˆåº¦é«˜ã€é¡¶çº§æ¸¸æˆå“è´¨\n\nç”»é¢å…ƒç´ ï¼š\n返回按钮ä½äºŽé¡¶éƒ¨å·¦ä¸Šè§’ï¼Œé¡¶éƒ¨ä¸­é—´æ˜¾ç¤ºå…³å¡æ ‡é¢˜â€œç¬¬1å…³ å½±â€å’Œå€’计时时间,å³ä¸Šè§’显示设置按钮\nç”»é¢ä¸­é—´æ˜¯ä¸€ä¸ªæ­£æ–¹å½¢çš„3*3拼图,拼图区域宽度与画é¢å®½åº¦åŒå®½ï¼Œç´§è´´ç”»é¢æ¨ªå‘边缘,拼图区域边界带有边框装饰\n拼图区域下方包å«ä¸€ä¸ªä¸‹ä¸€å…³æŒ‰é’®ï¼Œä»…在关å¡å®Œæˆæ—¶æ˜¾ç¤º\n底部是三个贴åˆç”»é¢ä¸»é¢˜çš„é“具按钮分别为“æç¤ºâ€ã€â€œåŽŸå›¾â€ã€â€œå†»ç»“â€\né“具按钮上ä¸è¦æ˜¾ç¤ºæ¬¡æ•°æ ‡æ³¨ï¼Œè¿”回按钮和设置按钮æ—ç¦æ­¢æ ‡æ³¨æ–‡å­—"; const PUZZLE_UI_SPRITESHEET_IMAGE_PROMPT: &str = "æå–ç”»é¢ä¸­çš„UI元素,将返回按钮ã€è®¾ç½®æŒ‰é’®ã€ä¸‹ä¸€å…³æŒ‰é’®ã€æç¤ºæŒ‰é’®ã€åŽŸå›¾æŒ‰é’®ã€å†»ç»“æŒ‰é’®æ•´ç†æˆçº¯ç»¿è‰²ç»¿å¹•背景的spritesheet。背景必须是统一纯绿色绿幕(高饱和亮绿色,接近 #00FF00),背景平整无纹ç†ã€æ— æ¸å˜ã€æ— é˜´å½±ã€æ— åœºæ™¯å†…容,åŽç«¯ä¼šåœ¨ç”Ÿå›¾åŽå°†ç»¿å¹•扣æˆé€æ˜Žå¹¶æŠŠé€æ˜ŽèƒŒæ™¯ PNG 存到 OSS。按钮顺åºå¿…须按原图ä½ç½®ä»Žå·¦åˆ°å³ã€ä»Žä¸Šåˆ°ä¸‹æŽ’列:返回ã€è®¾ç½®ã€ä¸‹ä¸€å…³ã€æç¤ºã€åŽŸå›¾ã€å†»ç»“。按钮素æå†…å¿…é¡»ä¿ç•™å¯¹åº”中文文字,æ¯ä¸ªæŒ‰é’®å¿…须是独立完整图形,按钮之间ä¿ç•™è¶³å¤Ÿçº¯ç»¿è‰²ç»¿å¹•空白,ä¸èƒ½ç›¸äº’接触ã€é‡å æˆ–连æˆä¸€ç‰‡ï¼Œæ–¹ä¾¿è¿è¡Œæ€æŒ‰è‡ªåŠ¨è¾¹ç•Œæ£€æµ‹è¯†åˆ«çŸ©å½¢ç´ æã€‚返回按钮和设置按钮ä¸è¦é¢å¤–画白色外圈ã€ç™½åº•圆环或浮雕外框,直接画æ‰å¹³å›¾æ ‡æœ¬ä½“。按钮自身ä¸å¾—使用接近 #00FF00 的高饱和纯绿;绿色题æåªèƒ½ä½¿ç”¨æ·±ç»¿ã€æ©„榄绿ã€é‡‘绿或è“绿,并用清晰æè¾¹ä¸Žç»¿å¹•åŒºåˆ†ã€‚ç¦æ­¢æ°´å°ã€æ•°å­—ã€æ¬¡æ•°æ ‡æ³¨ã€é€æ˜ŽèƒŒæ™¯ã€èƒŒæ™¯å›¾ã€æ‹¼å›¾å—ã€æ£‹ç›˜ã€ç½‘æ ¼çº¿ã€æŒ‰é’®å¤–标签和é¢å¤–按钮。"; const PUZZLE_LEVEL_BACKGROUND_IMAGE_PROMPT: &str = "移除å‚考图中所有UI元素ã€ç§»é™¤æ‹¼å›¾ç”»é¢ï¼Œä»…ä¿ç•™èƒŒæ™¯å›¾ï¼Œè¡¥å…¨è¢«è¦†ç›–çš„èƒŒæ™¯å›¾å†…å®¹ã€‚ç¦æ­¢åœ¨èƒŒæ™¯ä¸­å‡ºçŽ°äººåƒæˆ–和拼图画é¢ä¸­ä¸»ä½“一致的内容"; diff --git a/server-rs/crates/api-server/src/puzzle/vector_engine.rs b/server-rs/crates/api-server/src/puzzle/vector_engine.rs index 85ed78c1..3832530a 100644 --- a/server-rs/crates/api-server/src/puzzle/vector_engine.rs +++ b/server-rs/crates/api-server/src/puzzle/vector_engine.rs @@ -643,15 +643,13 @@ pub(crate) async fn resolve_puzzle_reference_image( if let Some(parsed) = parse_puzzle_image_data_url(trimmed) { let bytes_len = parsed.bytes.len(); if bytes_len > PUZZLE_REFERENCE_IMAGE_MAX_BYTES { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "puzzle", - "field": "referenceImageSrc", - "message": "å‚考图过大,请压缩åŽé‡è¯•。", - "maxBytes": PUZZLE_REFERENCE_IMAGE_MAX_BYTES, - "actualBytes": bytes_len, - })), - ); + return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "puzzle", + "field": "referenceImageSrc", + "message": build_puzzle_reference_image_too_large_message(bytes_len), + "maxBytes": PUZZLE_REFERENCE_IMAGE_MAX_BYTES, + "actualBytes": bytes_len, + }))); } return Ok(PuzzleResolvedReferenceImage { mime_type: parsed.mime_type, @@ -803,16 +801,16 @@ pub(crate) fn validate_puzzle_reference_asset_object( if asset_object.content_length == 0 || asset_object.content_length > PUZZLE_REFERENCE_IMAGE_MAX_BYTES as u64 { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "asset-object", - "field": "referenceImageAssetObjectId", - "assetObjectId": asset_object.asset_object_id, - "message": "å‚考图资产大å°ä¸ç¬¦åˆæ‹¼å›¾ç”Ÿæˆè¦æ±‚。", - "maxBytes": PUZZLE_REFERENCE_IMAGE_MAX_BYTES, - "actualBytes": asset_object.content_length, - })), - ); + return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "asset-object", + "field": "referenceImageAssetObjectId", + "assetObjectId": asset_object.asset_object_id, + "message": build_puzzle_reference_image_too_large_message( + asset_object.content_length as usize, + ), + "maxBytes": PUZZLE_REFERENCE_IMAGE_MAX_BYTES, + "actualBytes": asset_object.content_length, + }))); } if let Some(expected_owner_user_id) = owner_user_id .map(str::trim) diff --git a/server-rs/crates/platform-oss/src/lib.rs b/server-rs/crates/platform-oss/src/lib.rs index 830656b5..a9b3935e 100644 --- a/server-rs/crates/platform-oss/src/lib.rs +++ b/server-rs/crates/platform-oss/src/lib.rs @@ -20,7 +20,7 @@ const OSS_V4_REQUEST: &str = "aliyun_v4_request"; const OSS_V4_SERVICE: &str = "oss"; const OSS_UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD"; -pub const LEGACY_PUBLIC_PREFIXES: [&str; 12] = [ +pub const LEGACY_PUBLIC_PREFIXES: [&str; 13] = [ "generated-character-drafts", "generated-characters", "generated-animations", @@ -29,6 +29,7 @@ pub const LEGACY_PUBLIC_PREFIXES: [&str; 12] = [ "generated-wooden-fish-assets", "generated-match3d-assets", "generated-puzzle-assets", + "generated-jump-hop-assets", "generated-custom-world-scenes", "generated-custom-world-covers", "generated-bark-battle-assets", @@ -52,6 +53,7 @@ pub enum LegacyAssetPrefix { WoodenFishAssets, Match3DAssets, PuzzleAssets, + JumpHopAssets, CustomWorldScenes, CustomWorldCovers, BarkBattleAssets, @@ -241,6 +243,7 @@ impl LegacyAssetPrefix { "generated-wooden-fish-assets" => Some(Self::WoodenFishAssets), "generated-match3d-assets" => Some(Self::Match3DAssets), "generated-puzzle-assets" => Some(Self::PuzzleAssets), + "generated-jump-hop-assets" => Some(Self::JumpHopAssets), "generated-custom-world-scenes" => Some(Self::CustomWorldScenes), "generated-custom-world-covers" => Some(Self::CustomWorldCovers), "generated-bark-battle-assets" => Some(Self::BarkBattleAssets), @@ -259,6 +262,7 @@ impl LegacyAssetPrefix { Self::WoodenFishAssets => "generated-wooden-fish-assets", Self::Match3DAssets => "generated-match3d-assets", Self::PuzzleAssets => "generated-puzzle-assets", + Self::JumpHopAssets => "generated-jump-hop-assets", Self::CustomWorldScenes => "generated-custom-world-scenes", Self::CustomWorldCovers => "generated-custom-world-covers", Self::BarkBattleAssets => "generated-bark-battle-assets", diff --git a/server-rs/crates/shared-contracts/src/jump_hop.rs b/server-rs/crates/shared-contracts/src/jump_hop.rs index e4d4657d..cd2c0a51 100644 --- a/server-rs/crates/shared-contracts/src/jump_hop.rs +++ b/server-rs/crates/shared-contracts/src/jump_hop.rs @@ -87,6 +87,8 @@ pub struct JumpHopWorkspaceCreateRequest { pub struct JumpHopActionRequest { pub action_type: JumpHopActionType, #[serde(default)] + pub profile_id: Option, + #[serde(default)] pub work_title: Option, #[serde(default)] pub work_description: Option, @@ -102,6 +104,14 @@ pub struct JumpHopActionRequest { pub tile_prompt: Option, #[serde(default)] pub end_mood_prompt: Option, + #[serde(default)] + pub character_asset: Option, + #[serde(default)] + pub tile_atlas_asset: Option, + #[serde(default)] + pub tile_assets: Option>, + #[serde(default)] + pub cover_composite: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] diff --git a/server-rs/crates/spacetime-client/src/jump_hop.rs b/server-rs/crates/spacetime-client/src/jump_hop.rs index 7d798b88..2b35ba32 100644 --- a/server-rs/crates/spacetime-client/src/jump_hop.rs +++ b/server-rs/crates/spacetime-client/src/jump_hop.rs @@ -226,8 +226,11 @@ impl SpacetimeClient { &self, profile_id: String, ) -> Result { - self.get_jump_hop_work_profile(profile_id, String::new()) - .await + let work = self + .get_jump_hop_work_profile(profile_id, String::new()) + .await?; + validate_jump_hop_runtime_ready(&work)?; + Ok(work) } pub async fn start_jump_hop_run( @@ -235,12 +238,17 @@ impl SpacetimeClient { payload: JumpHopStartRunRequest, owner_user_id: String, ) -> Result { + let profile_id = payload.profile_id; + let work = self + .get_jump_hop_work_profile(profile_id.clone(), String::new()) + .await?; + validate_jump_hop_runtime_ready(&work)?; let run_id = build_prefixed_uuid_id("jump-hop-run-"); let procedure_input = JumpHopRunStartInput { client_event_id: format!("{run_id}:start"), run_id, owner_user_id, - profile_id: payload.profile_id, + profile_id, started_at_ms: current_unix_micros().div_euclid(1000), }; self.start_jump_hop_run_with_input(procedure_input).await @@ -372,11 +380,91 @@ impl SpacetimeClient { &self, public_work_code: String, ) -> Result { - self.get_jump_hop_work_profile(public_work_code, String::new()) + let gallery = self.list_jump_hop_gallery().await?; + let requested_code = normalize_jump_hop_public_work_code(public_work_code.as_str()); + let card = gallery + .items + .into_iter() + .find(|item| { + normalize_jump_hop_public_work_code(item.public_work_code.as_str()) == requested_code + }) + .ok_or_else(|| SpacetimeClientError::Procedure("jump_hop public work ä¸å­˜åœ¨".to_string()))?; + + self.get_jump_hop_work_profile(card.profile_id, String::new()) .await } } + +fn validate_jump_hop_runtime_ready( + work: &JumpHopWorkProfileResponse, +) -> Result<(), SpacetimeClientError> { + let status = work.summary.publication_status.trim().to_ascii_lowercase(); + if status != "published" { + return Err(SpacetimeClientError::validation_failed( + "jump-hop runtime åªèƒ½å¯åЍ已å‘布作å“", + )); + } + if work.summary.generation_status != JumpHopGenerationStatus::Ready { + return Err(SpacetimeClientError::validation_failed( + "jump-hop runtime éœ€è¦ ready 状æ€ä½œå“", + )); + } + validate_jump_hop_character_asset_ready(&work.character_asset, "character_asset")?; + validate_jump_hop_character_asset_ready(&work.tile_atlas_asset, "tile_atlas_asset")?; + if work.tile_assets.is_empty() { + return Err(SpacetimeClientError::validation_failed( + "jump-hop runtime 缺少地å—资产", + )); + } + for (index, asset) in work.tile_assets.iter().enumerate() { + if asset.image_src.trim().is_empty() + || asset.image_object_key.trim().is_empty() + || asset.asset_object_id.trim().is_empty() + { + return Err(SpacetimeClientError::validation_failed(format!( + "jump-hop runtime 地å—资产 #{index} ä¸å®Œæ•´" + ))); + } + } + if work.path.platforms.is_empty() { + return Err(SpacetimeClientError::validation_failed( + "jump-hop runtime 缺少å¯çŽ©è·¯å¾„", + )); + } + Ok(()) +} + +fn validate_jump_hop_character_asset_ready( + asset: &JumpHopCharacterAsset, + field: &str, +) -> Result<(), SpacetimeClientError> { + if asset.image_src.trim().is_empty() + || asset.image_object_key.trim().is_empty() + || asset.asset_object_id.trim().is_empty() + { + return Err(SpacetimeClientError::validation_failed(format!( + "jump-hop runtime {field} ä¸å®Œæ•´" + ))); + } + if asset.generation_provider.trim().is_empty() + || asset.generation_provider == "deterministic-placeholder" + { + return Err(SpacetimeClientError::validation_failed(format!( + "jump-hop runtime {field} 䏿˜¯å¯ç”¨çœŸå®žç”Ÿæˆèµ„产" + ))); + } + Ok(()) +} + +fn normalize_jump_hop_public_work_code(value: &str) -> String { + value + .chars() + .filter(|character| character.is_ascii_alphanumeric()) + .map(|character| character.to_ascii_uppercase()) + .collect() +} + enum JumpHopActionProcedure { Compile(JumpHopDraftCompileInput), Update(JumpHopWorkUpdateInput), @@ -503,22 +591,61 @@ fn merge_action_into_draft( if matches!( scope, JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateCharacter - ) && let Some(value) = payload - .character_prompt - .as_ref() - .filter(|value| !value.trim().is_empty()) - { - draft.character_prompt = value.trim().to_string(); + ) { + if let Some(value) = payload + .character_prompt + .as_ref() + .filter(|value| !value.trim().is_empty()) + { + draft.character_prompt = value.trim().to_string(); + } } if matches!( scope, JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateTiles - ) && let Some(value) = payload - .tile_prompt + ) { + if let Some(value) = payload + .tile_prompt + .as_ref() + .filter(|value| !value.trim().is_empty()) + { + draft.tile_prompt = value.trim().to_string(); + } + } + if let Some(profile_id) = payload + .profile_id .as_ref() - .filter(|value| !value.trim().is_empty()) + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) { - draft.tile_prompt = value.trim().to_string(); + draft.profile_id = Some(profile_id.to_string()); + } + if matches!( + scope, + JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateCharacter + ) { + if let Some(asset) = payload.character_asset.clone() { + draft.character_asset = Some(asset); + } + } + if matches!( + scope, + JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateTiles + ) { + if let Some(asset) = payload.tile_atlas_asset.clone() { + draft.tile_atlas_asset = Some(asset); + } + if let Some(assets) = payload.tile_assets.clone() { + draft.tile_assets = assets; + } + } + if let Some(value) = payload + .cover_composite + .as_ref() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + { + draft.cover_composite = Some(value.to_string()); } if draft.work_title.trim().is_empty() { return Err(SpacetimeClientError::validation_failed( @@ -545,31 +672,30 @@ fn build_compile_input( draft.tile_atlas_asset = None; draft.tile_assets.clear(); } - let character_asset = ensure_character_asset( - draft.character_asset.clone(), + let character_asset = draft.character_asset.clone().ok_or_else(|| { + SpacetimeClientError::validation_failed( + "jump-hop compile-draft 缺少真实角色资产,请先由 api-server 生æˆå¹¶æŒä¹…化 asset_object", + ) + })?; + let tile_atlas_asset = draft.tile_atlas_asset.clone().ok_or_else(|| { + SpacetimeClientError::validation_failed( + "jump-hop compile-draft 缺少真实地å—图集资产,请先由 api-server 生æˆå¹¶æŒä¹…化 asset_object", + ) + })?; + let tile_assets = if draft.tile_assets.is_empty() { + return Err(SpacetimeClientError::validation_failed( + "jump-hop compile-draft 缺少真实地å—资产,请先由 api-server 生æˆå¹¶æŒä¹…化 asset_object", + )); + } else { + draft.tile_assets.clone() + }; + let cover_composite = resolve_cover_composite( + draft, profile_id, - &draft.character_prompt, - force_character, + refresh, now_micros, ); - let tile_atlas_asset = ensure_tile_atlas_asset( - draft.tile_atlas_asset.clone(), - profile_id, - &draft.tile_prompt, - force_tiles, - now_micros, - ); - let tile_assets = ensure_tile_assets( - draft.tile_assets.clone(), - profile_id, - force_tiles, - now_micros, - ); - let cover_composite = resolve_cover_composite(draft, profile_id, refresh, now_micros); - draft.character_asset = Some(character_asset.clone()); - draft.tile_atlas_asset = Some(tile_atlas_asset.clone()); - draft.tile_assets = tile_assets.clone(); draft.cover_composite = cover_composite.clone(); draft.generation_status = JumpHopGenerationStatus::Ready; @@ -698,8 +824,10 @@ fn ensure_character_asset( force_new: bool, now_micros: i64, ) -> JumpHopCharacterAsset { - if !force_new && let Some(asset) = existing { - return asset; + if !force_new { + if let Some(asset) = existing { + return asset; + } } let revision = force_new.then_some(now_micros); let suffix = asset_revision_suffix(revision); @@ -722,8 +850,10 @@ fn ensure_tile_atlas_asset( force_new: bool, now_micros: i64, ) -> JumpHopCharacterAsset { - if !force_new && let Some(asset) = existing { - return asset; + if !force_new { + if let Some(asset) = existing { + return asset; + } } let revision = force_new.then_some(now_micros); let suffix = asset_revision_suffix(revision); @@ -781,14 +911,15 @@ fn resolve_cover_composite( refresh: JumpHopAssetRefresh, now_micros: i64, ) -> Option { - if matches!(refresh, JumpHopAssetRefresh::Preserve) - && let Some(value) = draft + if matches!(refresh, JumpHopAssetRefresh::Preserve) { + if let Some(value) = draft .cover_composite .as_ref() .map(|value| value.trim()) .filter(|value| !value.is_empty()) - { - return Some(value.to_string()); + { + return Some(value.to_string()); + } } let suffix = asset_revision_suffix( (!matches!(refresh, JumpHopAssetRefresh::Preserve)).then_some(now_micros), diff --git a/server-rs/crates/spacetime-module/src/jump_hop.rs b/server-rs/crates/spacetime-module/src/jump_hop.rs index d84c754c..0209f748 100644 --- a/server-rs/crates/spacetime-module/src/jump_hop.rs +++ b/server-rs/crates/spacetime-module/src/jump_hop.rs @@ -46,7 +46,7 @@ pub fn jump_hop_gallery_card_view(ctx: &AnonymousViewContext) -> Vec Result String { + let normalized = profile_id + .chars() + .filter(|character| character.is_ascii_alphanumeric()) + .flat_map(|character| character.to_uppercase()) + .collect::(); + let fallback = if normalized.is_empty() { + "00000000".to_string() + } else { + normalized + }; + let suffix = if fallback.len() > 8 { + fallback[fallback.len() - 8..].to_string() + } else { + format!("{fallback:0>8}") + }; + format!("JH-{suffix}") +} + fn build_session_snapshot( row: &JumpHopAgentSessionRow, ) -> Result { diff --git a/src/components/common/CreativeImageInputPanel.tsx b/src/components/common/CreativeImageInputPanel.tsx index 78448e9d..b5997cd7 100644 --- a/src/components/common/CreativeImageInputPanel.tsx +++ b/src/components/common/CreativeImageInputPanel.tsx @@ -53,6 +53,7 @@ export type CreativeImageInputPanelProps = { aiRedraw: boolean; promptReferenceImages: CreativeImageInputReferenceImage[]; promptReferenceLimit?: number; + imageLimitHint?: string | null; imageModelPicker?: ReactNode; error?: string | null; inputError?: string | null; @@ -95,6 +96,7 @@ export function CreativeImageInputPanel({ aiRedraw, promptReferenceImages, promptReferenceLimit = DEFAULT_PROMPT_REFERENCE_LIMIT, + imageLimitHint = null, imageModelPicker = null, error = null, inputError = null, @@ -274,6 +276,11 @@ export function CreativeImageInputPanel({ {mainImageMeta ?
{mainImageMeta}
: null} + {imageLimitHint ? ( +
+ {imageLimitHint} +
+ ) : null} {showPrompt ? ( diff --git a/src/components/custom-world-home/CustomWorldCreationHub.tsx b/src/components/custom-world-home/CustomWorldCreationHub.tsx index 9e037b1d..e84f786f 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.tsx @@ -6,6 +6,7 @@ import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contra import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; +import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop'; import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime'; import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks'; import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel'; @@ -61,6 +62,9 @@ type CustomWorldCreationHubProps = { squareHoleItems?: SquareHoleWorkSummary[]; onOpenSquareHoleDetail?: (item: SquareHoleWorkSummary) => void; onDeleteSquareHole?: ((item: SquareHoleWorkSummary) => void) | null; + jumpHopItems?: JumpHopWorkSummaryResponse[]; + onOpenJumpHopDetail?: (item: JumpHopWorkSummaryResponse) => void; + onDeleteJumpHop?: ((item: JumpHopWorkSummaryResponse) => void) | null; puzzleItems?: PuzzleWorkSummary[]; onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void; onDeletePuzzle?: ((item: PuzzleWorkSummary) => void) | null; @@ -169,6 +173,9 @@ export function CustomWorldCreationHub({ squareHoleItems = [], onOpenSquareHoleDetail, onDeleteSquareHole = null, + jumpHopItems = [], + onOpenJumpHopDetail, + onDeleteJumpHop = null, puzzleItems = [], onOpenPuzzleDetail, onDeletePuzzle = null, @@ -201,6 +208,7 @@ export function CustomWorldCreationHub({ bigFishItems, match3dItems, squareHoleItems: isSquareHoleCreationVisible ? squareHoleItems : [], + jumpHopItems, puzzleItems, babyObjectMatchItems, barkBattleItems, @@ -210,6 +218,7 @@ export function CustomWorldCreationHub({ canDeleteMatch3D: Boolean(onDeleteMatch3D), canDeleteSquareHole: isSquareHoleCreationVisible && Boolean(onDeleteSquareHole), + canDeleteJumpHop: Boolean(onDeleteJumpHop), canDeletePuzzle: Boolean(onDeletePuzzle), canDeleteBabyObjectMatch: Boolean(onDeleteBabyObjectMatch), canDeleteBarkBattle: Boolean(onDeleteBarkBattle), @@ -223,6 +232,8 @@ export function CustomWorldCreationHub({ onDeleteMatch3D: onDeleteMatch3D ?? undefined, onOpenSquareHoleDetail, onDeleteSquareHole: onDeleteSquareHole ?? undefined, + onOpenJumpHopDetail: onOpenJumpHopDetail ?? undefined, + onDeleteJumpHop: onDeleteJumpHop ?? undefined, onOpenPuzzleDetail, onDeletePuzzle: onDeletePuzzle ?? undefined, onClaimPuzzlePointIncentive: onClaimPuzzlePointIncentive ?? undefined, @@ -249,6 +260,7 @@ export function CustomWorldCreationHub({ onDeleteBabyObjectMatch, onDeleteBarkBattle, onDeleteVisualNovel, + onDeleteJumpHop, onClaimPuzzlePointIncentive, onOpenBigFishDetail, onOpenDraft, @@ -262,7 +274,9 @@ export function CustomWorldCreationHub({ getWorkState, puzzleItems, rpgLibraryEntries, - squareHoleItems, + onOpenSquareHoleDetail, + onOpenJumpHopDetail, + jumpHopItems, visualNovelItems, ], ); @@ -310,6 +324,9 @@ export function CustomWorldCreationHub({ case 'square-hole': onOpenSquareHoleDetail?.(item.source.item); return; + case 'jump-hop': + onOpenJumpHopDetail?.(item.source.item); + return; case 'rpg': if (item.status === 'draft') { onOpenDraft(item.source.item); diff --git a/src/components/custom-world-home/CustomWorldWorkCard.tsx b/src/components/custom-world-home/CustomWorldWorkCard.tsx index 3bccf3b5..0d5cf69e 100644 --- a/src/components/custom-world-home/CustomWorldWorkCard.tsx +++ b/src/components/custom-world-home/CustomWorldWorkCard.tsx @@ -59,6 +59,7 @@ const CREATION_WORK_KIND_FALLBACK_COVER: Record = 'big-fish': '/creation-type-references/big-fish.webp', match3d: '/creation-type-references/match3d.webp', 'square-hole': '/creation-type-references/square-hole.webp', + 'jump-hop': '/creation-type-references/jump-hop.webp', puzzle: '/creation-type-references/puzzle.webp', 'baby-object-match': '/creation-type-references/creative-agent.webp', 'bark-battle': '/creation-type-references/bark-battle.webp', diff --git a/src/components/custom-world-home/creationWorkShelf.ts b/src/components/custom-world-home/creationWorkShelf.ts index e39c5df9..4831de64 100644 --- a/src/components/custom-world-home/creationWorkShelf.ts +++ b/src/components/custom-world-home/creationWorkShelf.ts @@ -7,11 +7,13 @@ import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/p import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime'; import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks'; import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel'; +import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop'; import { buildPublicWorkStagePath } from '../../routing/appPageRoutes'; import { buildBabyObjectMatchPublicWorkCode, buildBarkBattlePublicWorkCode, buildBigFishPublicWorkCode, + buildJumpHopPublicWorkCode, buildMatch3DPublicWorkCode, buildPuzzlePublicWorkCode, buildSquareHolePublicWorkCode, @@ -30,6 +32,7 @@ export type CreationWorkShelfKind = | 'big-fish' | 'match3d' | 'square-hole' + | 'jump-hop' | 'puzzle' | 'baby-object-match' | 'bark-battle' @@ -82,6 +85,10 @@ export type CreationWorkShelfSource = kind: 'square-hole'; item: SquareHoleWorkSummary; } + | { + kind: 'jump-hop'; + item: JumpHopWorkSummaryResponse; + } | { kind: 'puzzle'; item: PuzzleWorkSummary; @@ -136,6 +143,7 @@ export function buildCreationWorkShelfItems(params: { bigFishItems: BigFishWorkSummary[]; match3dItems?: Match3DWorkSummary[]; squareHoleItems?: SquareHoleWorkSummary[]; + jumpHopItems?: JumpHopWorkSummaryResponse[]; puzzleItems: PuzzleWorkSummary[]; babyObjectMatchItems?: BabyObjectMatchDraft[]; barkBattleItems?: BarkBattleWorkSummary[]; @@ -144,6 +152,7 @@ export function buildCreationWorkShelfItems(params: { canDeleteBigFish?: boolean; canDeleteMatch3D?: boolean; canDeleteSquareHole?: boolean; + canDeleteJumpHop?: boolean; canDeletePuzzle?: boolean; canDeleteBabyObjectMatch?: boolean; canDeleteBarkBattle?: boolean; @@ -157,6 +166,8 @@ export function buildCreationWorkShelfItems(params: { onDeleteMatch3D?: (item: Match3DWorkSummary) => void; onOpenSquareHoleDetail?: (item: SquareHoleWorkSummary) => void; onDeleteSquareHole?: (item: SquareHoleWorkSummary) => void; + onOpenJumpHopDetail?: (item: JumpHopWorkSummaryResponse) => void; + onDeleteJumpHop?: (item: JumpHopWorkSummaryResponse) => void; onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void; onDeletePuzzle?: (item: PuzzleWorkSummary) => void; onClaimPuzzlePointIncentive?: (item: PuzzleWorkSummary) => void; @@ -176,6 +187,7 @@ export function buildCreationWorkShelfItems(params: { bigFishItems, match3dItems = [], squareHoleItems = [], + jumpHopItems = [], puzzleItems, babyObjectMatchItems = [], barkBattleItems = [], @@ -184,6 +196,7 @@ export function buildCreationWorkShelfItems(params: { canDeleteBigFish = false, canDeleteMatch3D = false, canDeleteSquareHole = false, + canDeleteJumpHop = false, canDeletePuzzle = false, canDeleteBabyObjectMatch = false, canDeleteBarkBattle = false, @@ -197,6 +210,8 @@ export function buildCreationWorkShelfItems(params: { onDeleteMatch3D, onOpenSquareHoleDetail, onDeleteSquareHole, + onOpenJumpHopDetail, + onDeleteJumpHop, onOpenPuzzleDetail, onDeletePuzzle, onClaimPuzzlePointIncentive, @@ -235,6 +250,12 @@ export function buildCreationWorkShelfItems(params: { onDelete: onDeleteSquareHole, }), ), + ...jumpHopItems.map((item) => + mapJumpHopWorkToShelfItem(item, canDeleteJumpHop, { + onOpen: onOpenJumpHopDetail, + onDelete: onDeleteJumpHop, + }), + ), ...puzzleItems.map((item) => mapPuzzleWorkToShelfItem(item, canDeletePuzzle, { onOpen: onOpenPuzzleDetail, @@ -745,6 +766,51 @@ function mapSquareHoleWorkToShelfItem( }; } +function mapJumpHopWorkToShelfItem( + item: JumpHopWorkSummaryResponse, + canDelete: boolean, + adapter: WorkShelfAdapter, +): CreationWorkShelfItem { + const status = item.publicationStatus === 'published' ? 'published' : 'draft'; + const publicWorkCode = + status === 'published' ? buildJumpHopPublicWorkCode(item.profileId) : null; + const coverImageSrc = normalizeCoverImageSrc(item.coverImageSrc); + return { + id: item.workId, + kind: 'jump-hop', + status, + title: item.workTitle, + summary: item.workDescription, + authorDisplayName: resolveAuthorDisplayName(item), + updatedAt: item.updatedAt, + coverImageSrc, + coverRenderMode: 'image', + coverCharacterImageSrcs: [], + publicWorkCode, + sharePath: + publicWorkCode && status === 'published' + ? buildPublicWorkStagePath('work-detail', publicWorkCode) + : null, + openActionLabel: status === 'published' ? '查看详情' : '继续创作', + canDelete, + canShare: status === 'published' && Boolean(publicWorkCode), + badges: [ + buildStatusBadge(status), + { id: 'type', label: '跳一跳', tone: 'neutral' }, + ], + metrics: + status === 'published' + ? buildPublishedMetrics({ + playCount: item.playCount, + remixCount: 0, + likeCount: 0, + }) + : [], + actions: buildWorkShelfActions(item, adapter), + source: { kind: 'jump-hop', item }, + }; +} + function resolveAuthorDisplayName( ...sources: Array diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index cb7e7088..0f18899c 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -177,9 +177,10 @@ import { type JumpHopRunResponse, type JumpHopSessionResponse, type JumpHopSessionSnapshotResponse, - type JumpHopWorkProfileResponse, - type JumpHopWorkspaceCreateRequest, + JumpHopWorkProfileResponse, + JumpHopWorkspaceCreateRequest, } from '../../services/jump-hop/jumpHopClient'; +import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop'; import { match3dCreationClient } from '../../services/match3d-creation'; import { createServerMatch3DRuntimeAdapter } from '../../services/match3d-runtime'; import { @@ -1853,7 +1854,7 @@ function hasRecoverableGeneratedPuzzleDraft( ); } -function getGenerationNoticeShelfKeys(item: CreationWorkShelfItem) { +function getGenerationNoticeShelfKeys(item: CreationWorkShelfItem): string[] { switch (item.source.kind) { case 'rpg': return collectDraftNoticeKeys('rpg', [ @@ -1882,6 +1883,13 @@ function getGenerationNoticeShelfKeys(item: CreationWorkShelfItem) { item.source.item.profileId, item.source.item.sourceSessionId, ]); + case 'jump-hop': + return collectDraftNoticeKeys('jump-hop', [ + item.id, + item.source.item.workId, + item.source.item.profileId, + item.source.item.sourceSessionId, + ]); case 'puzzle': return collectDraftNoticeKeys('puzzle', [ item.id, @@ -1967,6 +1975,39 @@ function buildPendingBigFishWorks( })); } +function buildPendingJumpHopWorks( + pending: Record | undefined, + existingItems: readonly JumpHopWorkSummaryResponse[], +): JumpHopWorkSummaryResponse[] { + if (!pending) { + return []; + } + + return Object.entries(pending) + .filter(([sessionId]) => + existingItems.every((item) => item.sourceSessionId !== sessionId), + ) + .map(([sessionId, state]) => ({ + runtimeKind: 'jump-hop', + workId: `jump-hop-work-${sessionId}`, + profileId: `jump-hop-profile-${sessionId}`, + ownerUserId: '', + sourceSessionId: sessionId, + workTitle: '跳一跳è‰ç¨¿', + workDescription: '正在生æˆè·³ä¸€è·³çŽ©æ³•è‰ç¨¿ã€‚', + themeTags: [], + difficulty: 'standard', + stylePreset: 'minimal-blocks', + coverImageSrc: null, + publicationStatus: 'draft', + playCount: 0, + updatedAt: state.updatedAt, + publishedAt: null, + publishReady: false, + generationStatus: state.status === 'generating' ? 'generating' : 'ready', + })); +} + function buildPendingMatch3DWorks( pending: Record | undefined, existingItems: readonly Match3DWorkSummary[], @@ -2637,6 +2678,9 @@ export function PlatformEntryFlowShellImpl({ const [jumpHopGalleryEntries, setJumpHopGalleryEntries] = useState< JumpHopGalleryCardResponse[] >([]); + const [jumpHopWorks, setJumpHopWorks] = useState< + JumpHopWorkSummaryResponse[] + >([]); const [jumpHopRuntimeReturnStage, setJumpHopRuntimeReturnStage] = useState('jump-hop-result'); const [jumpHopGenerationState, setJumpHopGenerationState] = @@ -2855,6 +2899,10 @@ export function PlatformEntryFlowShellImpl({ creationEntryTypes, 'big-fish', ); + const isJumpHopCreationVisible = isPlatformCreationTypeVisible( + creationEntryTypes, + 'jump-hop', + ); const isSquareHoleCreationVisible = isPlatformCreationTypeVisible( creationEntryTypes, 'square-hole', @@ -3304,6 +3352,22 @@ export function PlatformEntryFlowShellImpl({ } }, []); + const refreshJumpHopShelf = useCallback(async () => { + if (!isJumpHopCreationVisible) { + setJumpHopWorks([]); + return []; + } + + try { + const worksResponse = await jumpHopClient.listWorks(); + setJumpHopWorks(worksResponse.items); + return worksResponse.items; + } catch { + setJumpHopWorks([]); + return []; + } + }, [isJumpHopCreationVisible]); + const refreshWoodenFishGallery = useCallback(async () => { try { const galleryResponse = await woodenFishClient.listGallery(); @@ -3513,6 +3577,22 @@ export function PlatformEntryFlowShellImpl({ selectionStage, ]); + useEffect(() => { + if (!platformBootstrap.canReadProtectedData) { + setJumpHopWorks([]); + return; + } + + if (platformBootstrap.platformTab === 'create' || selectionStage === 'platform') { + void refreshJumpHopShelf(); + } + }, [ + platformBootstrap.canReadProtectedData, + platformBootstrap.platformTab, + refreshJumpHopShelf, + selectionStage, + ]); + const sessionController = useRpgCreationSessionController({ userId: authUi?.user?.id, openLoginModal: authUi?.openLoginModal, @@ -3860,6 +3940,16 @@ export function PlatformEntryFlowShellImpl({ ], [bigFishWorks, pendingDraftShelfItems], ); + const jumpHopShelfItems = useMemo( + () => [ + ...buildPendingJumpHopWorks( + pendingDraftShelfItems['jump-hop'], + jumpHopWorks, + ), + ...jumpHopWorks, + ], + [jumpHopWorks, pendingDraftShelfItems], + ); const match3dShelfItems = useMemo( () => [ ...buildPendingMatch3DWorks(pendingDraftShelfItems.match3d, match3dWorks), @@ -3935,6 +4025,13 @@ export function PlatformEntryFlowShellImpl({ ...bigFishShelfItems.flatMap((item) => collectDraftNoticeKeys('big-fish', [item.workId, item.sourceSessionId]), ), + ...jumpHopShelfItems.flatMap((item) => + collectDraftNoticeKeys('jump-hop', [ + item.workId, + item.profileId, + item.sourceSessionId, + ]), + ), ...match3dShelfItems.flatMap((item) => collectDraftNoticeKeys('match3d', [ item.workId, @@ -3977,6 +4074,7 @@ export function PlatformEntryFlowShellImpl({ babyObjectMatchDrafts, barkBattleShelfItems, bigFishShelfItems, + jumpHopShelfItems, creationHubItems, isSquareHoleCreationVisible, match3dShelfItems, @@ -7312,6 +7410,22 @@ export function PlatformEntryFlowShellImpl({ setJumpHopSession(response.session); setJumpHopWork(response.work ?? null); setJumpHopGenerationState(readyState); + if (response.work) { + setJumpHopWorks((current) => + [response.work!.summary, ...current.filter((item) => item.workId !== response.work!.summary.workId)], + ); + markPendingDraftReady('jump-hop', created.session.sessionId, false); + markDraftReady( + 'jump-hop', + [ + created.session.sessionId, + response.work.summary.workId, + response.work.summary.profileId, + ], + false, + ); + void refreshJumpHopShelf().catch(() => undefined); + } setSelectionStage('jump-hop-result'); } catch (error) { const errorMessage = resolveRpgCreationErrorMessage( @@ -7426,6 +7540,10 @@ export function PlatformEntryFlowShellImpl({ try { const response = await jumpHopClient.publishWork(profileId); setJumpHopWork(response.item); + setJumpHopWorks((current) => + [response.item.summary, ...current.filter((item) => item.workId !== response.item.summary.workId)], + ); + void refreshJumpHopShelf().catch(() => undefined); openPublishShareModal({ title: response.item.summary.workTitle || '跳一跳', publicWorkCode: buildJumpHopPublicWorkCode( @@ -10121,6 +10239,43 @@ export function PlatformEntryFlowShellImpl({ [openPublicWorkDetail, setJumpHopError, setSelectionStage], ); + const openJumpHopDraft = useCallback( + async (item: JumpHopWorkSummaryResponse) => { + markDraftNoticeSeen( + collectDraftNoticeKeys('jump-hop', [ + item.workId, + item.profileId, + item.sourceSessionId, + ]), + ); + + if (item.publicationStatus === 'published') { + void openJumpHopPublicWorkDetail(item.profileId); + return; + } + + setJumpHopError(null); + setPublicWorkDetailError(null); + setIsJumpHopBusy(true); + try { + const detail = await jumpHopClient.getWorkDetail(item.profileId); + setJumpHopSession(null); + setJumpHopRun(null); + setJumpHopWork(detail.item); + setJumpHopRuntimeReturnStage('jump-hop-result'); + enterCreateTab(); + setSelectionStage('jump-hop-result'); + } catch (error) { + setJumpHopError( + resolveRpgCreationErrorMessage(error, '读å–跳一跳è‰ç¨¿å¤±è´¥ã€‚'), + ); + } finally { + setIsJumpHopBusy(false); + } + }, + [enterCreateTab, markDraftNoticeSeen, openPublicWorkDetail, setSelectionStage], + ); + const openWoodenFishPublicWorkDetail = useCallback( async (profileId: string) => { setIsPublicWorkDetailBusy(true); @@ -12842,6 +12997,7 @@ export function PlatformEntryFlowShellImpl({ deletingWorkId={deletingCreationWorkId} rpgLibraryEntries={platformBootstrap.savedCustomWorldEntries} bigFishItems={isBigFishCreationVisible ? bigFishShelfItems : []} + jumpHopItems={isJumpHopCreationVisible ? jumpHopShelfItems : []} onOpenBigFishDetail={ isBigFishCreationVisible ? (item) => { @@ -12851,6 +13007,15 @@ export function PlatformEntryFlowShellImpl({ } : undefined } + onOpenJumpHopDetail={ + isJumpHopCreationVisible + ? (item) => { + runProtectedAction(() => { + void openJumpHopDraft(item); + }); + } + : undefined + } onDeleteBigFish={ isBigFishCreationVisible ? (item) => { @@ -12858,6 +13023,7 @@ export function PlatformEntryFlowShellImpl({ } : null } + onDeleteJumpHop={null} match3dItems={match3dShelfItems} onOpenMatch3DDetail={(item) => { runProtectedAction(() => { diff --git a/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx b/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx index ee7704ea..3785425c 100644 --- a/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx +++ b/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx @@ -551,9 +551,9 @@ test('puzzle workspace hides prompt and cost when AI redraw is off', async () => expect(onCreateFromForm).toHaveBeenCalledWith({ seedText: 'first-level.png', pictureDescription: 'first-level.png', - referenceImageSrc: '/generated-puzzle-assets/reference/first-level.png', + referenceImageSrc: 'data:image/png;base64,uploaded-square', referenceImageSrcs: [], - referenceImageAssetObjectId: 'asset-reference-first-level.png', + referenceImageAssetObjectId: null, referenceImageAssetObjectIds: [], imageModel: 'gpt-image-2', aiRedraw: false, @@ -616,22 +616,10 @@ test('puzzle workspace submits history image when AI redraw is off', async () => }); }); -test('puzzle workspace submits uploaded reference image when AI redraw is on', async () => { +test('puzzle workspace submits uploaded reference image as data URL when AI redraw is on', async () => { const onCreateFromForm = vi.fn(); const uploadedDataUrl = 'data:image/png;base64,uploaded-square'; stubReferenceImageUpload(uploadedDataUrl); - vi.mocked(puzzleAssetClient.uploadReferenceImage).mockResolvedValue({ - assetObjectId: 'asset-reference-main-1', - assetKind: 'puzzle_cover_image', - objectKey: 'generated-puzzle-assets/reference/main-1.png', - imageSrc: '/generated-puzzle-assets/reference/main-1.png', - ownerUserId: 'user-1', - ownerLabel: 'è´¦å· user-1', - profileId: null, - entityId: null, - createdAt: '1713686400.000000Z', - updatedAt: '1713686400.000000Z', - }); render( { expect(screen.getByAltText('拼图图片')).toBeTruthy(); }); - expect(puzzleAssetClient.uploadReferenceImage).toHaveBeenCalledWith({ - file: expect.any(File), - }); + expect(puzzleAssetClient.uploadReferenceImage).not.toHaveBeenCalled(); fireEvent.change(screen.getByLabelText('ç”»é¢AIé‡ç»˜è¦æ±‚(æç¤ºè¯ï¼‰'), { target: { value: 'ä¿ç•™ä¸Šä¼ ç”»é¢çš„主体和构图,改æˆé›¨å¤œç¯è¡—。' }, }); @@ -663,9 +649,9 @@ test('puzzle workspace submits uploaded reference image when AI redraw is on', a expect(onCreateFromForm).toHaveBeenCalledWith({ seedText: 'ä¿ç•™ä¸Šä¼ ç”»é¢çš„主体和构图,改æˆé›¨å¤œç¯è¡—。', pictureDescription: 'ä¿ç•™ä¸Šä¼ ç”»é¢çš„主体和构图,改æˆé›¨å¤œç¯è¡—。', - referenceImageSrc: null, + referenceImageSrc: 'data:image/png;base64,uploaded-square', referenceImageSrcs: [], - referenceImageAssetObjectId: 'asset-reference-main-1', + referenceImageAssetObjectId: null, referenceImageAssetObjectIds: [], imageModel: 'gpt-image-2', aiRedraw: true, @@ -754,12 +740,12 @@ test('puzzle workspace uploads prompt references as asset object ids', async () seedText: '一åªçŒ«åœ¨é›¨å¤œç¯ç‰Œä¸‹å›žå¤´ã€‚', pictureDescription: '一åªçŒ«åœ¨é›¨å¤œç¯ç‰Œä¸‹å›žå¤´ã€‚', referenceImageSrc: null, - referenceImageSrcs: [], - referenceImageAssetObjectId: null, - referenceImageAssetObjectIds: [ - 'asset-reference-prompt-1', - 'asset-reference-prompt-2', + referenceImageSrcs: [ + 'data:image/png;base64,reference-1', + 'data:image/png;base64,reference-2', ], + referenceImageAssetObjectId: null, + referenceImageAssetObjectIds: [], imageModel: 'gpt-image-2', aiRedraw: true, }); @@ -842,15 +828,15 @@ test('puzzle workspace uploads prompt reference images from the description box' seedText: '一åªçŒ«åœ¨é›¨å¤œç¯ç‰Œä¸‹å›žå¤´ã€‚', pictureDescription: '一åªçŒ«åœ¨é›¨å¤œç¯ç‰Œä¸‹å›žå¤´ã€‚', referenceImageSrc: null, - referenceImageSrcs: [], - referenceImageAssetObjectId: null, - referenceImageAssetObjectIds: [ - 'asset-reference-reference-1.png', - 'asset-reference-reference-2.png', - 'asset-reference-reference-3.png', - 'asset-reference-reference-4.png', - 'asset-reference-reference-5.png', + referenceImageSrcs: [ + 'data:image/png;base64,reference-1', + 'data:image/png;base64,reference-2', + 'data:image/png;base64,reference-3', + 'data:image/png;base64,reference-4', + 'data:image/png;base64,reference-5', ], + referenceImageAssetObjectId: null, + referenceImageAssetObjectIds: [], imageModel: 'gpt-image-2', aiRedraw: true, }); diff --git a/src/components/puzzle-agent/PuzzleAgentWorkspace.tsx b/src/components/puzzle-agent/PuzzleAgentWorkspace.tsx index d941464a..da06f2b1 100644 --- a/src/components/puzzle-agent/PuzzleAgentWorkspace.tsx +++ b/src/components/puzzle-agent/PuzzleAgentWorkspace.tsx @@ -16,11 +16,9 @@ import { getPuzzleHistoryAssetReferenceLabel } from '../../services/puzzle-works import { cropPuzzleReferenceImageDataUrl, isPuzzleReferenceImageSquare, - puzzleReferenceImageDataUrlToFile, readPuzzleReferenceImageAsDataUrl, readPuzzleReferenceImageForUpload, } from '../../services/puzzleReferenceImage'; -import { puzzleAssetClient } from '../../services/puzzle-works/puzzleAssetClient'; import { CreativeImageInputPanel, type CreativeImageInputReferenceImage, @@ -409,11 +407,10 @@ export function PuzzleAgentWorkspace({ return; } - const asset = await puzzleAssetClient.uploadReferenceImage({ file }); setFormState((current) => ({ ...current, - referenceImageSrc: asset.imageSrc || uploadImage.dataUrl, - referenceImageAssetObjectId: asset.assetObjectId, + referenceImageSrc: uploadImage.dataUrl, + referenceImageAssetObjectId: '', referenceImageLabel: file.name.trim() || '本地拼图图片', })); setReferenceImageError(null); @@ -441,18 +438,12 @@ export function PuzzleAgentWorkspace({ try { const images = await Promise.all( - files.slice(0, remainingSlots).map(async (file, index) => { - const [imageSrc, asset] = await Promise.all([ - readPuzzleReferenceImageAsDataUrl(file), - puzzleAssetClient.uploadReferenceImage({ file }), - ]); - return { - id: `prompt-upload:${Date.now()}:${index}:${file.name}`, - label: file.name.trim() || `å‚考图 ${index + 1}`, - imageSrc: asset.imageSrc || imageSrc, - assetObjectId: asset.assetObjectId, - }; - }), + files.slice(0, remainingSlots).map(async (file, index) => ({ + id: `prompt-upload:${Date.now()}:${index}:${file.name}`, + label: file.name.trim() || `å‚考图 ${index + 1}`, + imageSrc: await readPuzzleReferenceImageAsDataUrl(file), + assetObjectId: null, + })), ); setFormState((current) => ({ ...current, @@ -515,15 +506,10 @@ export function PuzzleAgentWorkspace({ cropY: currentCropState.cropRect.y, cropSize: currentCropState.cropRect.size, }); - const file = puzzleReferenceImageDataUrlToFile( - dataUrl, - currentCropState.fileName, - ); - const asset = await puzzleAssetClient.uploadReferenceImage({ file }); setFormState((current) => ({ ...current, - referenceImageSrc: asset.imageSrc || dataUrl, - referenceImageAssetObjectId: asset.assetObjectId, + referenceImageSrc: dataUrl, + referenceImageAssetObjectId: '', referenceImageLabel: currentCropState.label, })); setCropState(null); @@ -651,6 +637,7 @@ export function PuzzleAgentWorkspace({ aiRedraw={formState.aiRedraw} promptReferenceImages={formState.referenceImageSrcs} promptReferenceLimit={PUZZLE_PROMPT_REFERENCE_IMAGE_LIMIT} + imageLimitHint="图片≤6MB" imageModelPicker={ ( + JUMP_HOP_WORKS_API_BASE, + { method: 'GET' }, + '读å–跳一跳作å“列表失败', + { + retry: JUMP_HOP_RUNTIME_READ_RETRY, + }, + ); +} + export async function publishJumpHopWork(profileId: string) { const response = await requestJson( `${JUMP_HOP_WORKS_API_BASE}/${encodeURIComponent(profileId)}/publish`, @@ -267,6 +280,7 @@ export const jumpHopClient = { getGalleryDetail: getJumpHopGalleryDetail, getWorkDetail: getJumpHopWorkDetail, listGallery: listJumpHopGallery, + listWorks: listJumpHopWorks, publishWork: publishJumpHopWork, restartRun: restartJumpHopRuntimeRun, startRun: startJumpHopRuntimeRun, diff --git a/src/services/puzzle-works/puzzleAssetClient.test.ts b/src/services/puzzle-works/puzzleAssetClient.test.ts new file mode 100644 index 00000000..c8ad9db5 --- /dev/null +++ b/src/services/puzzle-works/puzzleAssetClient.test.ts @@ -0,0 +1,24 @@ +// @vitest-environment jsdom + +import { describe, expect, test } from 'vitest'; + +import { + PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES, + validatePuzzleReferenceImageFile, +} from './puzzleAssetClient'; + +describe('puzzle reference image upload validation', () => { + test('limits uploads to 6MB', () => { + expect(PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES).toBe(6 * 1024 * 1024); + }); + + test('rejects files that exceed the upload limit with a precise message', () => { + const file = new File([ + 'x'.repeat(PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES + 1), + ], 'too-large.png', { type: 'image/png' }); + + expect(() => validatePuzzleReferenceImageFile(file)).toThrow( + 'å‚考图过大,请压缩åŽå†ä¸Šä¼ ï¼ˆå½“å‰ 6.0MB,最多 6MB)。', + ); + }); +}); diff --git a/src/services/puzzle-works/puzzleAssetClient.ts b/src/services/puzzle-works/puzzleAssetClient.ts index 30c90ddf..7583ced6 100644 --- a/src/services/puzzle-works/puzzleAssetClient.ts +++ b/src/services/puzzle-works/puzzleAssetClient.ts @@ -1,5 +1,9 @@ import { ASSET_API_PATHS } from '../../editor/shared/editorApiClient'; import { requestJson } from '../apiClient'; +import { + PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES, + validatePuzzleReferenceImageFile, +} from '../puzzleReferenceImage'; export type PuzzleHistoryAsset = { assetObjectId: string; @@ -40,8 +44,6 @@ type ConfirmAssetObjectResponse = { }; }; -const PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES = 12 * 1024 * 1024; - const MIME_BY_EXTENSION: Record = { jpeg: 'image/jpeg', jpg: 'image/jpeg', @@ -58,14 +60,9 @@ function resolvePuzzleImageContentType(file: File) { return MIME_BY_EXTENSION[extension] ?? 'application/octet-stream'; } -function validatePuzzleReferenceImageFile(file: File) { +function validatePuzzleReferenceImageUploadFile(file: File) { const contentType = resolvePuzzleImageContentType(file); - if (file.size <= 0) { - throw new Error('å‚è€ƒå›¾æ–‡ä»¶ä¸ºç©ºï¼Œè¯·é‡æ–°é€‰æ‹©ã€‚'); - } - if (file.size > PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES) { - throw new Error('å‚考图过大,请压缩åŽå†ä¸Šä¼ ã€‚'); - } + validatePuzzleReferenceImageFile(file); if (!contentType.startsWith('image/')) { throw new Error('å‚考图必须是图片文件。'); } @@ -96,7 +93,7 @@ async function postDirectUploadFile( export async function uploadPuzzleReferenceImage(payload: { file: File; }): Promise { - validatePuzzleReferenceImageFile(payload.file); + validatePuzzleReferenceImageUploadFile(payload.file); const contentType = resolvePuzzleImageContentType(payload.file); const uploadedAt = Date.now(); const ticket = await requestJson( @@ -157,7 +154,12 @@ export async function uploadPuzzleReferenceImage(payload: { export const puzzleReferenceAssetTestUtils = { maxUploadBytes: PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES, - validateFile: validatePuzzleReferenceImageFile, + validateFile: validatePuzzleReferenceImageUploadFile, +}; + +export { + PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES, + validatePuzzleReferenceImageUploadFile as validatePuzzleReferenceImageFile, }; /** diff --git a/src/services/puzzleReferenceImage.test.ts b/src/services/puzzleReferenceImage.test.ts index 4bfb71bf..a09c30c2 100644 --- a/src/services/puzzleReferenceImage.test.ts +++ b/src/services/puzzleReferenceImage.test.ts @@ -92,7 +92,7 @@ describe('readPuzzleReferenceImageAsDataUrl', () => { const dataUrl = await readPuzzleReferenceImageAsDataUrl(file); expect(dataUrl).toBe(`data:image/jpeg;base64,${'C'.repeat(1000)}`); - expect(drawImage).toHaveBeenCalledWith(expect.anything(), 0, 0, 1536, 1152); + expect(drawImage).toHaveBeenCalledWith(expect.anything(), 0, 0, 1024, 768); expect(toDataURL).toHaveBeenCalledWith('image/jpeg', 0.84); expect(toDataURL).toHaveBeenCalledWith('image/jpeg', 0.76); expect(toDataURL).toHaveBeenCalledWith('image/jpeg', 0.68); @@ -114,7 +114,7 @@ describe('readPuzzleReferenceImageAsDataUrl', () => { }); await expect(readPuzzleReferenceImageAsDataUrl(file)).rejects.toThrow( - 'å‚考图过大,请æ¢ä¸€å¼ å°ºå¯¸æ›´å°çš„图片。', + 'å‚考图过大,请压缩åŽå†ä¸Šä¼ ï¼ˆå½“å‰ 10.0MB,最多 6MB)。', ); }); }); diff --git a/src/services/puzzleReferenceImage.ts b/src/services/puzzleReferenceImage.ts index 1eac5862..4a9eaa0f 100644 --- a/src/services/puzzleReferenceImage.ts +++ b/src/services/puzzleReferenceImage.ts @@ -1,8 +1,29 @@ const PUZZLE_REFERENCE_IMAGE_MAX_EDGE = 1024; const PUZZLE_REFERENCE_IMAGE_COMPRESS_TRIGGER_BYTES = 1536 * 1024; +export const PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES = 6 * 1024 * 1024; export const PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH = 10 * 1024 * 1024; const PUZZLE_REFERENCE_IMAGE_SQUARE_TOLERANCE = 1; +export function formatPuzzleReferenceImageUploadBytes(bytes: number) { + return `${(bytes / 1024 / 1024).toFixed(1)}MB`; +} + +export function buildPuzzleReferenceImageTooLargeMessage(actualBytes: number) { + return `å‚考图过大,请压缩åŽå†ä¸Šä¼ ï¼ˆå½“å‰ ${formatPuzzleReferenceImageUploadBytes(actualBytes)},最多 6MB)。`; +} + +export function validatePuzzleReferenceImageFile(file: File) { + if (file.size <= 0) { + throw new Error('å‚è€ƒå›¾æ–‡ä»¶ä¸ºç©ºï¼Œè¯·é‡æ–°é€‰æ‹©ã€‚'); + } + if (file.size > PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES) { + throw new Error(buildPuzzleReferenceImageTooLargeMessage(file.size)); + } + if (file.type.trim() && !file.type.trim().startsWith('image/')) { + throw new Error('å‚考图必须是图片文件。'); + } +} + type PuzzleReferenceImageSize = { width: number; height: number; @@ -36,7 +57,7 @@ function readFileAsDataUrl(file: File) { function ensureReferenceImageWithinLimit(dataUrl: string) { if (dataUrl.length > PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH) { - throw new Error('å‚考图过大,请æ¢ä¸€å¼ å°ºå¯¸æ›´å°çš„图片。'); + throw new Error(buildPuzzleReferenceImageTooLargeMessage(dataUrl.length)); } return dataUrl; } @@ -130,6 +151,7 @@ async function compressReferenceImageDataUrl(file: File, dataUrl: string) { } export async function readPuzzleReferenceImageAsDataUrl(file: File) { + validatePuzzleReferenceImageFile(file); const dataUrl = await readFileAsDataUrl(file); try { const compressedDataUrl = await compressReferenceImageDataUrl( @@ -150,6 +172,7 @@ export async function readPuzzleReferenceImageAsDataUrl(file: File) { export async function readPuzzleReferenceImageForUpload( file: File, ): Promise { + validatePuzzleReferenceImageFile(file); const dataUrl = await readFileAsDataUrl(file); const image = await loadReferenceImage(dataUrl); const size = resolveReferenceImageNaturalSize(image);