diff --git a/.gitea/workflows/project-ci.yml b/.gitea/workflows/project-ci.yml index f629f6251..5088f80b6 100644 --- a/.gitea/workflows/project-ci.yml +++ b/.gitea/workflows/project-ci.yml @@ -7,6 +7,10 @@ on: pull_request: workflow_dispatch: +concurrency: + group: project-ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + permissions: contents: read diff --git a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx index f262b8254..0feeb767d 100644 --- a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx +++ b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx @@ -38,6 +38,7 @@ vi.mock('../api/adminApiClient', () => ({ interface MockIntersectionObserverController { enter: (target: Element) => void; + enterAll: (targets: Element[]) => void; isObserved: (target: Element) => boolean; } @@ -106,6 +107,25 @@ function installIntersectionObserverMock(): MockIntersectionObserverController { ); }); }, + enterAll(targets) { + act(() => { + for (const target of targets) { + const record = observed.get(target); + if (!record) { + throw new Error('目标缩略图尚未进入 IntersectionObserver'); + } + record.callback( + [ + { + isIntersecting: true, + target, + } as IntersectionObserverEntry, + ], + record.observer, + ); + } + }); + }, isObserved(target) { return observed.has(target); }, @@ -753,10 +773,10 @@ test('后台素材查询为大量同时可见的缩略图持续错峰换签', as const thumbnails = entries.map((entry) => thumbnailElementForLabel(entry.label), ); - thumbnails.forEach((thumbnail) => { + for (const thumbnail of thumbnails) { expect(observer.isObserved(thumbnail)).toBe(true); - observer.enter(thumbnail); - }); + } + observer.enterAll(thumbnails); await act(async () => { await Promise.resolve(); }); @@ -776,7 +796,7 @@ test('后台素材查询为大量同时可见的缩略图持续错峰换签', as await vi.advanceTimersByTimeAsync(200); }); expect(getAdminAssetReadUrl).toHaveBeenCalledTimes(105); -}); +}, 10_000); test('后台素材查询读取更多后为新进入可视区域的素材换签', async () => { const observer = installIntersectionObserverMock(); diff --git a/apps/ai-game-creator-shell/game-creator.config.json b/apps/ai-game-creator-shell/game-creator.config.json index b6193445f..3c0005da3 100644 --- a/apps/ai-game-creator-shell/game-creator.config.json +++ b/apps/ai-game-creator-shell/game-creator.config.json @@ -15,7 +15,7 @@ "autoCompactTokenLimit": 64000, "toolOutputTokenLimit": 12000, "requestTimeoutMs": 180000, - "maxRetries": 2, + "maxRetries": 10, "retryBackoffMs": 500 }, "agentLlm": {} diff --git a/apps/ai-game-creator-shell/src-tauri/design-agent/tools.json b/apps/ai-game-creator-shell/src-tauri/design-agent/tools.json index c9c1bd6cf..ce086275f 100644 --- a/apps/ai-game-creator-shell/src-tauri/design-agent/tools.json +++ b/apps/ai-game-creator-shell/src-tauri/design-agent/tools.json @@ -2,7 +2,7 @@ {"type":"function","function":{"name":"get_workflow_status","description":"读取当前策划工作流状态,只返回阶段列表、当前阶段、已批准阶段和待审批阶段;不推进阶段、不提交审批、不修改文件。","parameters":{"type":"object","properties":{},"additionalProperties":false}}}, {"type":"function","function":{"name":"list_resources","description":"列出固定资源的逻辑目录、资源 ID、标题和简介。资源是只读的随包文档;不要猜测物理路径。","parameters":{"type":"object","properties":{},"additionalProperties":false}}}, {"type":"function","function":{"name":"read_resource","description":"读取一份固定资源文档全文。每次读取一个 resource_id;资源只读。读到未实现占位文档时由你自行判断和处理。","parameters":{"type":"object","properties":{"resource_id":{"type":"string"}},"required":["resource_id"],"additionalProperties":false}}}, - {"type":"function","function":{"name":"patch_file","description":"局部修改 UTF-8 文件。使用 old_text/new_text,或使用 edits 一次进行多个独立替换;每个 old_text 必须非空且在原文件中唯一,匹配失败、重复或范围重叠时不修改文件。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"},"old_text":{"type":"string"},"new_text":{"type":"string"},"edits":{"type":"array","items":{"type":"object","properties":{"old_text":{"type":"string"},"new_text":{"type":"string"}},"required":["old_text","new_text"],"additionalProperties":false}}},"required":["path"],"additionalProperties":false}}}, + {"type":"function","function":{"name":"patch_file","description":"局部修改 UTF-8 文件。使用 old_text/new_text,或使用 edits 一次进行多个独立替换;每个 old_text 必须非空且在原文件中唯一。所有 edit 会一次性校验;任何失败都不修改文件,错误会列出各失败项及可唯一匹配的其余项。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"},"old_text":{"type":"string"},"new_text":{"type":"string"},"edits":{"type":"array","items":{"type":"object","properties":{"old_text":{"type":"string"},"new_text":{"type":"string"}},"required":["old_text","new_text"],"additionalProperties":false}}},"required":["path"],"additionalProperties":false}}}, {"type":"function","function":{"name":"delete_path","description":"谨慎使用;永久删除工作区内的文件或目录;目录会连同全部内容递归删除,不备份。先确认目标及删除范围。path 使用相对路径,不能删除工作区根目录,也不能经过链接。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}}, {"type":"function","function":{"name":"list_dir","description":"列出工作目录内的文件和目录。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}}, {"type":"function","function":{"name":"read_file","description":"读取工作目录内的 UTF-8 文本文件。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}}, diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md index 07cbe9c1b..84fb51e53 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md @@ -13,8 +13,8 @@ Let the client derive projections from real disk changes and trusted tool result 2. Before using or deriving an existing registered asset, call `agc_list_registered_assets` and select its `localAssetId`. If the user points to an existing project file that is not listed, first call `agc_list_project_files`; only entries with `assetImportable=true` (recognized image, font, audio, video, document, or code files) may be passed to `agc_import_account_assets.localPaths`. Then re-read `agc_list_registered_assets`; never infer a source identity from a filename or fabricate a localAssetId. 3. Keep read scopes separate: `asset.list` is the current project manifest, `asset.library.list` is the signed-in account library, and the web project's canvas resource read model is the authoritative canvas list. The account library is not the complete canvas list. 4. Use `canvas.asset_import` for safe account/canvas asset IDs or project-relative local paths. The client rechecks ownership and validates bytes; host absolute paths require native UI file-picker authorization. -5. When the user explicitly asks to create or derive video, character animation, sound effect, or background music, call `agc_create_or_derive_resource`. Use `create` only for video/audio without a source and `derive` with a registered `sourceLocalAssetId`; character animation is always derived from an image. -6. When the user explicitly asks to remove an image background, call `agc_remove_background` with a registered image `sourceLocalAssetId` and an output name. The client requires the signed-in account, owns canvas/folder context and task identity, and returns only bounded queue state. +5. When the user explicitly asks to create or derive video, character animation, sound effect, or background music, call `agc_create_or_derive_resource`. Use `create` only for video/audio without a source and `derive` with a registered `sourceLocalAssetId`; character animation is always derived from an image. Keep `prompt` inside the per-kind limit that the client really enforces: background music at most 140 characters, sound effect at most 1900, video and character animation at most 4000. A longer prompt is rejected before submission, so write the short version first instead of retrying the same text. +6. When the user explicitly asks to remove an image background, call `agc_remove_background` with a registered image `sourceLocalAssetId` and `assetName`. Optional `backgroundMode` is `complex` (semantic foreground segmentation; default) or `flat` (solid-colour background removal). Prefer `flat` when the background is known to be solid. Only `flat` accepts optional `screenColor`: `auto`, `#RRGGBB`, or omitted for automatic detection by the service. Do not select a colour on behalf of `auto`. The client requires the signed-in account, owns canvas/folder context and task identity, and returns only bounded queue state. 7. Preserve existing relative paths when a small edit is sufficient so client resource identities remain stable. 8. Do not edit `.agent/manifest.json`, revision counters, version records, resource IDs, canvas identities, source provenance, generation ledgers, or browser receipts by hand. 9. Do not create a version when no game file changed. The client compares content fingerprints and advances revision only after an actual source change. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md index 15006410b..63ddd9a82 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md @@ -14,4 +14,6 @@ Read scopes remain separate: `asset.list` is the current project's local manifes `agc_create_or_derive_resource` accepts only semantic intent. The client resolves `sourceLocalAssetId`, creates stable request identities, recovers matching pending operations, serializes paid submissions, writes supported media into the current canvas and same-name asset folder, validates downloaded bytes, commits the local manifest transaction, and returns redacted warnings. A tool error or timeout is not permission to generate again with a new identity. -`agc_remove_background` is the semantic image post-processing path. It accepts only a registered image `sourceLocalAssetId` and output name; the client resolves the formal source resource, canvas/folder context, stable operation identity, idempotency key, and authenticated External v1 `/api/external/v1/editor/images/background-removals` call. Its result is bounded queue state; Codex must not poll internal workers, construct source URLs, or retry with a new identity after an uncertain response. +`prompt` limits are per kind and are enforced before any paid submission: background music accepts 1-140 characters, sound effect 1-1900, video and character animation 1-4000, and image editing (`agc_edit_image`) 1-32000. The client composes the submitted request from a fixed prefix plus your prompt, so an over-limit prompt fails locally with the exact limit; shorten the text rather than resubmitting the same value. `agc_edit_image` remains the image path; this tool never generates or edits still images. + +`agc_remove_background` accepts a registered image `sourceLocalAssetId`, `assetName`, and optional `backgroundMode` and `screenColor`. `complex` uses semantic segmentation to identify the foreground; `flat` removes a solid-colour background. Prefer `flat` when the background is known to be solid; omitting the mode selects `complex`. Only `flat` accepts a colour: `auto`, `#RRGGBB`, or omitted for automatic service detection. Never infer a concrete colour for `auto`. Empty or invalid values and colour without `flat` are rejected. The client resolves the formal source resource, canvas/folder context, stable operation identity, idempotency key, and authenticated External v1 `/api/external/v1/editor/images/background-removals` call. Mode and colour are part of request identity. Its result is bounded queue state; Codex must not poll internal workers, construct source URLs, or retry with a new identity after an uncertain response. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json index f2270d7b8..1f7b5c124 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": "agc-skill-pack.v1", - "version": "2026-08-26.16", + "version": "2026-08-26.18", "skills": [ { "name": "agc-game-production-workflow", @@ -123,7 +123,7 @@ "agents/openai.yaml", "references/projection-contract.md" ], - "sha256": "96b5bf9e2ed150bbe934a888867c1bb500b214a131f8b36c4830f51ca30267b6" + "sha256": "0700d4a7a18ee6151811f38786211ad416863f2e425fdc2ded67555a0a1923a1" } ] } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs index ebe6152b8..1a5a6e486 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs @@ -527,10 +527,6 @@ fn process_design_batch( let result = if uncertain { Err("进程在工具执行期间中断,执行结果未保存。未重复执行;请读取实际工作区确认结果后再决定下一步。".to_string()) } else { - let _write = acquire_game_creator_agent_runtime_project_write_lock_with_wait( - root, - "design.tool", - )?; execute_design_tool(root, resources, session, &call) }; let error = result @@ -1026,6 +1022,15 @@ pub(crate) async fn continue_design_agent_at( finish_design_command(root, resources, session, active, run, emit).await } +async fn recover_uncertain_design_batch( + root: &Path, + resources: &DesignResources, + session: DesignSession, + active: File, +) -> Result { + finish_design_command(root, resources, session, active, true, |_| {}).await +} + pub(crate) async fn decide_design_phase_at( root: &Path, resources: &DesignResources, @@ -1058,7 +1063,8 @@ fn ensure_design_runtime_active(root: &Path) -> Result<(), String> { } #[tauri::command] -pub(crate) fn hydrate_design_agent_session( +pub(crate) async fn hydrate_design_agent_session( + app: tauri::AppHandle, project_path: String, ) -> Result, String> { let root = Path::new(project_path.trim()); @@ -1084,8 +1090,33 @@ pub(crate) fn hydrate_design_agent_session( if session.project_id != project_id { return Err("策划会话与当前项目不匹配".into()); } - let active = try_open_game_creator_agent_runtime_task_lock_file(root, DESIGN_ACTIVE_LOCK)?; - Ok(Some(design_view(&session, active.is_none()))) + let Some(active) = + try_open_game_creator_agent_runtime_task_lock_file(root, DESIGN_ACTIVE_LOCK)? + else { + return Ok(Some(design_view(&session, true))); + }; + if design_session_has_uncertain_batch(&session) { + let resources = DesignResources::new(resolve_design_resources_root(&app)?)?; + let view = recover_uncertain_design_batch(root, &resources, session, active).await?; + return Ok(Some(view)); + } + drop(active); + Ok(Some(design_view(&session, false))) +} + +fn design_session_has_uncertain_batch(session: &DesignSession) -> bool { + let Some(batch) = session.pending_batch.as_ref() else { + return false; + }; + if !batch.executing || batch.cursor >= batch.calls.len() { + return false; + } + let call_id = batch.calls[batch.cursor].id.as_str(); + session.turn.as_ref().is_some_and(|turn| turn.pending) + && !session.history.iter().any(|item| { + item.get("type").and_then(Value::as_str) == Some("function_call_output") + && item.get("call_id").and_then(Value::as_str) == Some(call_id) + }) } fn design_session_error_is_recoverable(error: &str) -> bool { @@ -1958,4 +1989,94 @@ mod tests { .any(|message| message.text.contains("重试后继续"))); assert!(next.session.last_error.is_none()); } + + #[tokio::test(flavor = "current_thread")] + async fn uncertain_batch_hydrate_continues_the_original_turn_without_replaying_file_tools() { + let (_temp, root, resources) = init_design_project(); + execute_design_file_tool( + &root, + "write_file", + &json!({"path":"project/00_concept/design.md","content":"概念"}), + ) + .expect("write concept"); + let mut session = new_design_session("design-fake", "quality"); + let call = platform_llm::LlmToolCall { + id: "interrupted-call".into(), + name: "patch_file".into(), + arguments: json!({ + "path":"project/00_concept/design.md", + "old_text":"概念", + "new_text":"概念设计" + }) + .to_string(), + }; + session.history.push(json!({ + "type":"function_call", + "call_id":call.id, + "name":call.name, + "arguments":call.arguments, + })); + session.messages = vec![DesignMessage { + id: "turn:user".into(), + role: "user".into(), + text: "继续".into(), + }]; + session.turn = Some(DesignTurn { + id: "turn-recovery".into(), + pending: true, + request_index: 0, + attempt: 0, + }); + session.pending_batch = Some(DesignToolBatch { + calls: vec![call], + cursor: 0, + executing: true, + }); + assert!(design_session_has_uncertain_batch(&session)); + write_design_session(&root, &session).expect("write interrupted session"); + + let _fake = fake_provider::install( + vec![Ok(fake_response( + "recovered-after-uncertain-tool", + "已读取文件并确认。", + Vec::new(), + ))], + 0, + ); + let view = recover_uncertain_design_batch(&root, &resources, session, { + try_open_game_creator_agent_runtime_task_lock_file( + &root, + ".agent/design-agent/active.lock", + ) + .expect("open active lock") + .expect("active lock is free") + }) + .await + .expect("recover uncertain batch"); + + assert!(!view.running); + assert!(view.session.last_error.is_none()); + let restored = read_design_session(&root) + .expect("read restored") + .expect("session"); + assert!(restored.pending_batch.is_none()); + assert!(!restored.turn.expect("turn").pending); + assert!(restored.history.iter().any(|item| { + item.get("type").and_then(Value::as_str) == Some("function_call_output") + && item.get("call_id").and_then(Value::as_str) == Some("interrupted-call") + && item + .get("output") + .and_then(Value::as_str) + .is_some_and(|output| output.contains("执行结果未保存")) + })); + assert!(restored.history.iter().any(|item| { + item.get("role").and_then(Value::as_str) == Some("assistant") + && item.get("content").is_some() + })); + assert!( + fs::read_to_string(root.join("design_artifacts/project/00_concept/design.md")) + .expect("read target") + == "概念" + ); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs index 5221030b1..ba4420164 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs @@ -321,15 +321,31 @@ pub(crate) fn execute_design_file_tool( }) .collect::>(); let mut matches = Vec::new(); + let mut edit_errors = Vec::new(); + let mut valid_edits = 0; for (index, (old, new)) in normalized.iter().enumerate() { + if old == new { + edit_errors.push(format!( + "edits[{index}] new_text 与 old_text 相同,不会产生修改" + )); + continue; + } let count = content.matches(old).count(); if count == 0 { - return Err(format!("edits[{index}] 原文未找到:{display}")); + edit_errors.push(format!( + "edits[{index}] 原文未找到:{}{}", + display, + design_patch_location_hint(&content, old) + )); + continue; } if count != 1 { - return Err(format!( - "edits[{index}] 原文匹配 {count} 处,必须唯一:{display}" + let start = content.find(old).expect("count checked"); + let line = design_patch_line_number(&content, start); + edit_errors.push(format!( + "edits[{index}] 原文匹配 {count} 处,必须唯一;首次位于第 {line} 行" )); + continue; } let start = content.find(old).expect("count checked"); let end = start + old.len(); @@ -337,18 +353,43 @@ pub(crate) fn execute_design_file_tool( .iter() .find(|(_, other_start, other_end)| start < *other_end && *other_start < end) { - return Err(format!( - "edits[{index}] 与 edits[{other_index}] 修改范围重叠:{display}" + edit_errors.push(format!( + "edits[{index}] 与 edits[{other_index}] 修改范围重叠;请合并为一个 edit 或缩短 old_text" )); + continue; } matches.push((index, start, end)); + valid_edits += 1; let _ = new; } - let mut updated = content.clone(); - for (index, start, end) in matches.into_iter().rev() { - let (_, new) = &normalized[index]; - updated.replace_range(start..end, new); + if !edit_errors.is_empty() { + let shown = edit_errors.len().min(4); + let mut details = edit_errors[..shown].to_vec(); + if shown < edit_errors.len() { + details.push(format!( + "另有 {} 个 edit 校验失败(详情省略)", + edit_errors.len() - shown + )); + } + if valid_edits > 0 { + details.push(format!( + "其余 {valid_edits} 个 edit 当前可唯一匹配;本次未写入文件" + )); + } else { + details.push("本次未写入文件".to_string()); + } + return Err(details.join("\n")); } + matches.sort_unstable_by_key(|(_, start, _)| *start); + let mut updated = String::with_capacity(content.len()); + let mut cursor = 0; + for (index, start, end) in matches { + let (_, new) = &normalized[index]; + updated.push_str(&content[cursor..start]); + updated.push_str(new); + cursor = end; + } + updated.push_str(&content[cursor..]); if updated == content { return Err(format!("没有产生修改:{display}")); } @@ -396,6 +437,60 @@ pub(crate) fn execute_design_file_tool( } } +fn design_patch_line_number(content: &str, start: usize) -> usize { + 1 + content[..start] + .bytes() + .filter(|byte| *byte == b'\n') + .count() +} + +fn design_patch_visible_line(line: &str) -> String { + line.replace('\t', "\\t").chars().take(180).collect() +} + +fn design_patch_location_hint(content: &str, old: &str) -> String { + let Some(anchor) = old.lines().map(str::trim).find(|line| !line.is_empty()) else { + return String::new(); + }; + + let mut candidates = content + .lines() + .enumerate() + .filter(|(_, line)| line.trim() == anchor) + .map(|(index, line)| (index + 1, line)) + .collect::>(); + if candidates.is_empty() { + let token = anchor.split_whitespace().find(|token| token.len() >= 3); + if let Some(token) = token { + candidates = content + .lines() + .enumerate() + .filter(|(_, line)| line.trim().contains(token)) + .map(|(index, line)| (index + 1, line)) + .collect(); + } + } + if candidates.is_empty() { + return format!( + ";未找到与 old_text 首个非空行相似的行(当前文件约 {} 行)", + content.lines().count() + ); + } + + let details = candidates + .iter() + .take(2) + .map(|(line, text)| format!("第 {line} 行:{}", design_patch_visible_line(text))) + .collect::>() + .join(";"); + let suffix = if candidates.len() > 2 { + format!("等 {} 处", candidates.len()) + } else { + String::new() + }; + format!(";old_text 首个非空行可能对应 {details}{suffix}(tab 显示为 \\t)") +} + pub(crate) fn list_design_workspace_files( root: &Path, ) -> Result, String> { @@ -693,6 +788,22 @@ mod tests { ) .expect_err("escape"); assert!(escaped.contains("路径")); + let mismatch = execute_design_file_tool( + root, + "patch_file", + &json!({ + "path":"notes/design.md", + "edits":[ + {"old_text":" 游戏设计","new_text":"游戏概念"}, + {"old_text":"设计","new_text":"方案"} + ] + }), + ) + .expect_err("report all patch failures"); + assert!(mismatch.contains("edits[0] 原文未找到")); + assert!(mismatch.contains("第 1 行:游戏设计")); + assert!(mismatch.contains("其余 1 个 edit 当前可唯一匹配")); + assert!(mismatch.contains("本次未写入文件")); let patched = execute_design_file_tool( root, "patch_file", @@ -728,6 +839,35 @@ mod tests { assert!(!root.join("design_artifacts/notes").exists()); } + #[test] + fn patch_file_applies_out_of_order_edits_with_changing_utf8_lengths() { + let temp = test_root(); + let root = temp.path(); + execute_design_file_tool( + root, + "write_file", + &json!({"path":"notes/design.md","content":"开头\n甲\n保留一\n乙乙\n保留二\n丙\n结尾"}), + ) + .expect("write"); + execute_design_file_tool( + root, + "patch_file", + &json!({ + "path":"notes/design.md", + "edits":[ + {"old_text":"丙","new_text":"新的结论"}, + {"old_text":"甲","new_text":"扩展A"}, + {"old_text":"乙乙","new_text":"乙"} + ] + }), + ) + .expect("patch out of order"); + assert_eq!( + fs::read_to_string(root.join("design_artifacts/notes/design.md")).expect("read disk"), + "开头\n扩展A\n保留一\n乙\n保留二\n新的结论\n结尾" + ); + } + #[test] fn phase_context_injects_current_skill_only() { let resources = DesignResources::new(pack_root()).expect("pack"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs index 130c01549..ed9e5e576 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs @@ -482,9 +482,38 @@ pub(crate) fn read_direct_project_history_items_at(root: &Path) -> Result Result, String> { + read_direct_project_history_entries_filtered_at(root, None, false) +} + +fn is_direct_project_chat_message(item: &Value) -> bool { + matches!( + item.get("role").and_then(Value::as_str), + Some("user" | "assistant") + ) && item + .get("content") + .and_then(Value::as_array) + .is_some_and(|parts| { + parts.iter().any(|part| { + part.get("text") + .and_then(Value::as_str) + .is_some_and(|text| !text.is_empty()) + }) + }) +} + +/// 消息模式逐行丢弃工具输出,只保留聊天正文,避免 40 MiB 工具日志被整表积累或发给 UI。 +fn read_direct_project_history_entries_filtered_at( + root: &Path, + before_item_id: Option<&str>, + messages_only: bool, +) -> Result, String> { let path = history_path(root); if !prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")? { - return Ok(Vec::new()); + return if before_item_id.is_some() { + Err("DirectProject 历史游标对应的文件已不存在".to_string()) + } else { + Ok(Vec::new()) + }; } let file = File::open(&path) .map_err(|error| format!("打开 DirectProject 历史失败:{}: {error}", path.display()))?; @@ -519,6 +548,12 @@ fn read_direct_project_history_entries_at(root: &Path) -> Result Result Err(format!("DirectProject 历史中不存在 item:{item_id}")), + None => Ok(items), + } +} + +pub(crate) fn read_direct_project_chat_items_slice_at( + root: &Path, + before_item_id: Option<&str>, + limit: usize, +) -> Result<(Vec, bool, BTreeMap), String> { + let entries = read_direct_project_history_entries_filtered_at(root, before_item_id, true)?; + let mut start = entries.len().saturating_sub(limit.clamp(1, 200)); + // 旧消息可能没有 ID:保留原文,并向前扩到可寻址的已有 ID,不能制造原始消息身份。 + while start > 0 + && entries[start] + .0 + .get("id") + .and_then(Value::as_str) + .is_none_or(str::is_empty) + { + start -= 1; + } + let timestamps = entries[start..] + .iter() + .filter_map(|(item, at)| { + let id = item.get("id").and_then(Value::as_str)?; + (*at > 0).then(|| (id.to_string(), *at)) + }) + .collect(); + Ok(( + entries + .into_iter() + .skip(start) + .map(|(item, _)| item) + .collect(), + start > 0, + timestamps, + )) } pub(crate) fn read_direct_project_history_items_slice_at( @@ -640,6 +713,165 @@ mod tests { const RESPONSE_ITEM_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"user","id":"codex-item-2","content":[{"type":"input_text","text":"再加一个按钮"}]}}"#; const RESPONSE_ASSISTANT_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"已完成"}]}}"#; + fn write_items(root: &std::path::Path, items: &[Value]) { + let lines = items + .iter() + .enumerate() + .map(|(index, item)| { + json!({"type": "response_item", "payload": item, "recordedAt": 1000 + index}) + .to_string() + }) + .collect::>(); + write_history_lines(root, &lines.iter().map(String::as_str).collect::>()); + } + + #[test] + fn chat_pages_skip_tool_only_tail_and_gaps_without_losing_messages_or_times() { + let root = init_history_project("message-pages"); + let mut raw = Vec::new(); + let mut expected = Vec::new(); + for n in 0..44 { + let item = json!({ + "id": format!("message-{n}"), "type": "message", + "role": if n == 0 || n == 38 { "user" } else { "assistant" }, + "content": [{"type": "output_text", "text": format!("消息 {n}")}], + }); + expected.push(item.clone()); + raw.push(item); + for tool in 0..25 { + raw.push(json!({ + "id": format!("tool-{n}-{tool}"), "type": "function_call_output", + "output": "工具结果不应占聊天页名额", + })); + } + } + write_items(root.path(), &raw); + let path = history_path(root.path()); + let before = std::fs::read(&path).unwrap(); + let (old_page, _, _) = + super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap(); + assert!(old_page + .iter() + .all(|item| item["type"] == "function_call_output")); + let mut cursor = None; + let mut all = Vec::new(); + let mut sizes = Vec::new(); + loop { + let (mut page, more, timestamps) = + super::read_direct_project_chat_items_slice_at(root.path(), cursor.as_deref(), 20) + .unwrap(); + sizes.push(page.len()); + for item in &page { + let index = raw.iter().position(|raw| raw["id"] == item["id"]).unwrap(); + assert_eq!( + timestamps[item["id"].as_str().unwrap()], + 1000 + index as u64 + ); + } + let next = page + .first() + .and_then(|item| item["id"].as_str()) + .map(str::to_string); + page.append(&mut all); + all = page; + if !more { + break; + } + assert_ne!(next, cursor); + cursor = next; + assert!(sizes.len() < 10); + } + assert_eq!(sizes, vec![20, 20, 4]); + assert_eq!(all, expected); + assert_eq!(std::fs::read(&path).unwrap(), before); + } + + #[test] + fn chat_pages_handle_empty_content_internal_context_and_missing_ids() { + let root = init_history_project("message-page-boundary"); + write_items( + root.path(), + &[ + json!({"id":"u", "role":"user", "content":[{"text":"第一条"}]}), + json!({"role":"assistant", "content":[{"text":"无ID的旧消息"}]}), + json!({"id":"a", "role":"assistant", "content":[{"text":"最后一条"}]}), + json!({"id":"empty", "role":"assistant", "content":[{"text":""}]}), + json!({"id":"internal", "role":"user", "content":[{"text":"内部"}]}), + json!({"id":"reason", "type":"reasoning", "content":[{"text":"推理"}]}), + ], + ); + let (page, more, _) = + super::read_direct_project_chat_items_slice_at(root.path(), None, 1).unwrap(); + assert_eq!(page[0]["id"], "a"); + assert!(more); + let (page, more, _) = + super::read_direct_project_chat_items_slice_at(root.path(), Some("a"), 1).unwrap(); + assert_eq!(page.len(), 2); + assert_eq!(page[0]["id"], "u"); + assert!(page[1].get("id").is_none()); + assert!(!more); + assert!( + super::read_direct_project_chat_items_slice_at(root.path(), Some("missing"), 20) + .is_err() + ); + write_items( + root.path(), + &[json!({"id":"tool", "type":"function_call", "arguments":"{}"})], + ); + let (page, more, _) = + super::read_direct_project_chat_items_slice_at(root.path(), None, 20).unwrap(); + assert!(page.is_empty()); + assert!(!more); + } + + #[test] + #[ignore = "人工只读诊断:通过 AGC_HISTORY_REPLAY_SOURCE 提供原始历史文件"] + fn replay_external_chat_history_pages_without_mutating_source() { + let source = std::env::var_os("AGC_HISTORY_REPLAY_SOURCE").expect("provide replay source"); + let before = std::fs::read(&source).expect("read source"); + let root = init_history_project("external-history-replay"); + let path = history_path(root.path()); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, &before).unwrap(); + let expected = + super::read_direct_project_history_entries_filtered_at(root.path(), None, true) + .expect("read messages"); + let mut cursor = None; + let mut all = Vec::new(); + let mut pages = 0; + loop { + let (mut items, more, _) = + super::read_direct_project_chat_items_slice_at(root.path(), cursor.as_deref(), 20) + .expect("read page"); + let next = items + .first() + .and_then(|item| item["id"].as_str()) + .map(str::to_string); + items.append(&mut all); + all = items; + pages += 1; + if !more { + break; + } + assert!(next.is_some() && next != cursor, "cursor must advance"); + assert!(pages <= expected.len() + 1, "pagination must terminate"); + cursor = next; + } + assert!( + all.iter().eq(expected.iter().map(|(item, _)| item)), + "message order and content must match" + ); + assert!( + std::fs::read(&source).unwrap() == before, + "source must remain unchanged" + ); + eprintln!( + "history replay: messages={}, pages={pages}, users={}", + all.len(), + all.iter().filter(|item| item["role"] == "user").count() + ); + } + #[test] fn history_timestamps_survive_reload_and_idempotent_append_without_changing_raw_items() { let root = init_history_project("history-time"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs index e3bc497ea..1bec45d0a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs @@ -59,6 +59,7 @@ pub(crate) struct DirectThreadHistorySlice { pub(crate) items: Vec, pub(crate) has_more: bool, pub(crate) item_timestamps: std::collections::BTreeMap, + pub(crate) oldest_item_id: Option, } #[derive(Clone, Debug)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 2e3889f46..a517f1831 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -24,7 +24,6 @@ const DIRECT_TOOL_BRIDGE_IMAGE_PREVIEW_MAX_DIMENSION: u32 = 1024; const DIRECT_TOOL_BRIDGE_MAX_SEARCH_QUERY_CHARS: usize = 400; const DIRECT_TOOL_BRIDGE_MAX_SEARCH_RESULTS: usize = 5; const DIRECT_TOOL_BRIDGE_SEARCH_URL: &str = "https://www.bing.com/search?format=rss"; -const DIRECT_TOOL_BRIDGE_MAX_RESOURCE_PROMPT_CHARS: usize = 4_000; const DIRECT_TOOL_BRIDGE_MAX_RESOURCE_NAME_CHARS: usize = 120; const DIRECT_TOOL_BRIDGE_MAX_RESOURCE_KIND_CHARS: usize = 80; const DIRECT_TOOL_BRIDGE_MAX_RESOURCE_PAGE_SIZE: usize = 100; @@ -89,7 +88,7 @@ struct DirectToolBridgeRequest { } #[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum DirectResourceGenerationKind { +pub(crate) enum DirectResourceGenerationKind { Image, Video, CharacterAnimation, @@ -98,7 +97,7 @@ enum DirectResourceGenerationKind { } impl DirectResourceGenerationKind { - fn parse(value: &str) -> Result { + pub(crate) fn parse(value: &str) -> Result { match value { "image" => Ok(Self::Image), "video" => Ok(Self::Video), @@ -119,7 +118,7 @@ impl DirectResourceGenerationKind { } } - fn edit_kind(self) -> LocalProjectResourceEditKind { + pub(crate) fn edit_kind(self) -> LocalProjectResourceEditKind { match self { Self::Image => LocalProjectResourceEditKind::ImageReference, Self::Video => LocalProjectResourceEditKind::Video, @@ -128,6 +127,11 @@ impl DirectResourceGenerationKind { Self::BackgroundMusic => LocalProjectResourceEditKind::BackgroundMusic, } } + + /// 提示词上限只从客户端权威口径取值,工具桥与 MCP 层共用同一份数字。 + pub(crate) fn prompt_max_chars(self) -> usize { + resource_edit_prompt_max_chars(&self.edit_kind()) + } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -1036,6 +1040,12 @@ fn bridge_account_asset_import_inputs( Ok((asset_ids, local_paths)) } +/// 源资源身份不在当前项目 manifest 时的统一提示。 +/// +/// 只报「不属于已登记资源」会让模型原地重试;这里必须把下一步可执行动作写清楚: +/// 已登记资源走 `agc_list_registered_assets`,只在项目里存在的文件先登记再重试。 +const DIRECT_TOOL_BRIDGE_UNREGISTERED_SOURCE_MESSAGE: &str = "sourceLocalAssetId 不是当前项目已登记资源:先调用 agc_list_registered_assets 选择已有 localAssetId;若目标图片只在项目里,先用 agc_list_project_files 确认它 assetImportable=true,再用 agc_import_account_assets.localPaths 登记后重试。"; + fn bridge_resource_generation_input( arguments: &Value, ) -> Result { @@ -1054,18 +1064,20 @@ fn bridge_resource_generation_input( "sourceLocalAssetId", DIRECT_TOOL_BRIDGE_MAX_RESOURCE_KIND_CHARS, )?; - let prompt = bridge_bounded_string( - arguments, - "prompt", - DIRECT_TOOL_BRIDGE_MAX_RESOURCE_PROMPT_CHARS, - )?; + // prompt 的形状校验只用信封级上限,真正生效的按 kind 上限由紧随其后的权威判定给出 + // 精确数字;否则通用 4000 会先于「图片编辑 32000 / 音效 1900」误报成安全边界错误。 + let prompt = bridge_bounded_string(arguments, "prompt", DIRECT_TOOL_BRIDGE_MAX_REQUEST_BYTES)?; let asset_name = bridge_bounded_string( arguments, "assetName", DIRECT_TOOL_BRIDGE_MAX_RESOURCE_NAME_CHARS, )?; - if kind == DirectResourceGenerationKind::BackgroundMusic && prompt.chars().count() > 140 { - return Err("背景音乐提示词必须在 1..=140 字符内".to_string()); + let prompt_max_chars = kind.prompt_max_chars(); + if prompt.chars().count() > prompt_max_chars { + return Err(resource_edit_prompt_limit_error( + &kind.edit_kind(), + prompt_max_chars, + )); } match (kind, mode, source_local_asset_id.as_ref()) { (DirectResourceGenerationKind::Image, DirectResourceGenerationMode::Create, _) => { @@ -1806,7 +1818,7 @@ async fn bridge_create_or_derive_resource( .iter() .find(|asset| asset.id == asset_id) .cloned() - .ok_or_else(|| "sourceLocalAssetId 不属于当前项目已登记资源".to_string()) + .ok_or_else(|| DIRECT_TOOL_BRIDGE_UNREGISTERED_SOURCE_MESSAGE.to_string()) }) .transpose()?; let prompt_sha256 = format!("{:x}", Sha256::digest(input.prompt.as_bytes())); @@ -1887,7 +1899,7 @@ async fn bridge_create_or_derive_resource( async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Value) -> Value { let result = async { - bridge_reject_unknown_fields(arguments, &["sourceLocalAssetId", "assetName"])?; + super::direct_tools_mcp::validate_remove_background_arguments(arguments)?; enforce_project_permission_policy(&state.root, "canvas.asset_generate")?; enforce_project_permission_policy(&state.root, "asset.register")?; let source_asset_id = bridge_bounded_string(arguments, "sourceLocalAssetId", 80)?; @@ -1896,12 +1908,14 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val "assetName", DIRECT_TOOL_BRIDGE_MAX_RESOURCE_NAME_CHARS, )?; + let background_mode = arguments.get("backgroundMode").and_then(Value::as_str); + let screen_color = arguments.get("screenColor").and_then(Value::as_str); let manifest = read_existing_manifest_for_project(&state.root)?; let source_asset = manifest .assets .iter() .find(|asset| asset.id == source_asset_id) - .ok_or_else(|| "sourceLocalAssetId 不属于当前项目已登记资源".to_string())?; + .ok_or_else(|| DIRECT_TOOL_BRIDGE_UNREGISTERED_SOURCE_MESSAGE.to_string())?; if !source_asset.media_type.starts_with("image/") { return Err("抠图工具只接受当前项目已登记的图片资源".to_string()); } @@ -1920,22 +1934,34 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val .map_err(|_| "创建抠图服务连接失败".to_string())?; let context = prepare_external_canvas_generation_context(&state.root, &client, &access).await?; - let fingerprint = format!("{}\0{}", source_asset_id, asset_name); + let fingerprint = background_removal_request_fingerprint( + &source_asset_id, + &asset_name, + background_mode, + screen_color, + ); let (_operation_id, idempotency_key) = state.resource_request_ids(&fingerprint)?; let route = "/api/external/v1/editor/images/background-removals"; + let mut request_body = json!({ + "sourceImageSrc": source_resource_id, + "projectId": manifest.project_id, + "assetKind": source_asset.kind, + "assetFolderId": context.asset_folder_id, + "assetLabel": asset_name, + "sourceResourceId": source_resource_id, + }); + if background_mode == Some("flat") { + request_body["backgroundMode"] = json!("flat"); + } + if let Some(color) = screen_color { + request_body["screenColor"] = json!(color); + } let response = crate::http_client::with_agc_main_site_marker( client .post(format!("{}{}", api_base_url, route)) .bearer_auth(api_key) .header("Idempotency-Key", idempotency_key) - .json(&json!({ - "sourceImageSrc": source_resource_id, - "projectId": manifest.project_id, - "assetKind": source_asset.kind, - "assetFolderId": context.asset_folder_id, - "assetLabel": asset_name, - "sourceResourceId": source_resource_id, - })), + .json(&request_body), ) .send() .await @@ -1972,6 +1998,20 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val } } +fn background_removal_request_fingerprint( + source: &str, + name: &str, + mode: Option<&str>, + color: Option<&str>, +) -> String { + let mode = mode.unwrap_or("complex"); + if mode == "complex" && color.is_none() { + format!("{source}\0{name}") + } else { + format!("{source}\0{name}\0{mode}\0{}", color.unwrap_or("")) + } +} + fn bridge_safe_queue_state(value: Value) -> Value { let object = value.as_object(); json!({ @@ -2703,6 +2743,29 @@ pub(crate) async fn start_direct_tool_bridge( #[cfg(test)] mod tests { + #[test] + fn remove_background_identity_preserves_default_and_distinguishes_options() { + let legacy = "asset-1\0透明图"; + assert_eq!( + background_removal_request_fingerprint("asset-1", "透明图", None, None), + legacy + ); + assert_eq!( + background_removal_request_fingerprint("asset-1", "透明图", Some("complex"), None), + legacy + ); + let mut identities = std::collections::HashSet::new(); + identities.insert(legacy.to_string()); + for color in [None, Some("auto"), Some("#CFEFFF"), Some("#112233")] { + let id = + background_removal_request_fingerprint("asset-1", "透明图", Some("flat"), color); + assert_eq!( + id, + background_removal_request_fingerprint("asset-1", "透明图", Some("flat"), color) + ); + assert!(identities.insert(id)); + } + } use super::*; use std::io::{Cursor, Read, Write}; @@ -2744,6 +2807,67 @@ mod tests { .contains("x-genarrative-client:")); } + /// 按 kind 的提示词上限只来自客户端权威口径;超限必须在构造工具输入时就被拒绝, + /// 不能再出现写死的数字(2026-09-17 的背景音乐 140 就是写死在桥这一层的)。 + #[test] + fn bridge_resource_prompt_limits_follow_the_client_authority() { + for (kind, edit_kind) in [ + ( + "background-music", + LocalProjectResourceEditKind::BackgroundMusic, + ), + ("sound-effect", LocalProjectResourceEditKind::SoundEffect), + ("video", LocalProjectResourceEditKind::Video), + ( + "character-animation", + LocalProjectResourceEditKind::CharacterAnimation, + ), + ("image", LocalProjectResourceEditKind::ImageReference), + ] { + let authority = resource_edit_prompt_max_chars(&edit_kind); + let mode = if matches!( + edit_kind, + LocalProjectResourceEditKind::ImageReference + | LocalProjectResourceEditKind::CharacterAnimation + ) { + "derive" + } else { + "create" + }; + let mut arguments = json!({ + "kind": kind, + "mode": mode, + "prompt": "字".repeat(authority), + "assetName": "边界名称" + }); + if mode == "derive" { + arguments["sourceLocalAssetId"] = json!("registered-source"); + } + bridge_resource_generation_input(&arguments) + .unwrap_or_else(|error| panic!("{kind} 恰好等于上限必须通过:{error}")); + + arguments["prompt"] = json!("字".repeat(authority + 1)); + let error = match bridge_resource_generation_input(&arguments) { + Ok(_) => panic!("{kind} 超过按 kind 上限的提示词必须被拒绝"), + Err(error) => error, + }; + assert!( + error.contains(&authority.to_string()) && error.contains(kind_label(&edit_kind)), + "{kind} 的拒绝文案必须带上真实上限与类型:{error}" + ); + } + } + + fn kind_label(edit_kind: &LocalProjectResourceEditKind) -> &'static str { + match edit_kind { + LocalProjectResourceEditKind::BackgroundMusic => "背景音乐", + LocalProjectResourceEditKind::SoundEffect => "音效", + LocalProjectResourceEditKind::Video => "视频", + LocalProjectResourceEditKind::CharacterAnimation => "角色动画", + _ => "资源编辑", + } + } + #[test] fn bridge_argument_bounds_are_deterministic() { assert_eq!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 615fd3ead..9c769fc5e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -16,7 +16,6 @@ const DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES: usize = 2 * 1024 * 1024; const DIRECT_TOOLS_MCP_MAX_ART_BRIEF_CHARS: usize = 4_000; const DIRECT_TOOLS_MCP_MAX_IMAGE_PROMPT_CHARS: usize = 32_000; const DIRECT_TOOLS_MCP_MAX_SEARCH_QUERY_CHARS: usize = 400; -const DIRECT_TOOLS_MCP_MAX_RESOURCE_PROMPT_CHARS: usize = 4_000; const DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS: usize = 120; const DIRECT_TOOLS_MCP_MAX_WRITE_CONTENT_BYTES: usize = 1_500_000; const DIRECT_TOOLS_MCP_MAX_BRIDGE_RESPONSE_BYTES: usize = 32 * 1024 * 1024; @@ -103,6 +102,32 @@ async fn direct_tools_mcp_specs() -> Value { direct_tools_mcp_specs_for(controlled_web_search_enabled(), cocos_editor_available) } +/// 工具 kind(wire 值)对应的提示词上限。 +/// +/// 数字只来自客户端资源编辑权威口径(`resource_edit_prompt_max_chars`);未知 kind 直接 +/// panic,避免 schema 与真实校验静默漂移。 +fn resource_tool_prompt_max_chars(kind: &str) -> usize { + DirectResourceGenerationKind::parse(kind) + .unwrap_or_else(|error| panic!("{kind} 不是受支持的媒体资源类型:{error}")) + .prompt_max_chars() +} + +/// `agc_create_or_derive_resource` 顶层 `prompt.maxLength`:本工具所有受支持 kind 的上限最大值。 +/// +/// 仍然保留顶层上限,供忽略 `allOf` / `oneOf` 的调用方使用;逐 kind 的精确上限在分支里声明。 +fn resource_tool_prompt_schema_max_chars() -> usize { + [ + "background-music", + "sound-effect", + "video", + "character-animation", + ] + .into_iter() + .map(resource_tool_prompt_max_chars) + .max() + .unwrap_or(0) +} + fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_available: bool) -> Value { let tools = vec![ json!({ @@ -407,7 +432,8 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab "prompt": { "type": "string", "minLength": 1, - "maxLength": DIRECT_TOOLS_MCP_MAX_RESOURCE_PROMPT_CHARS + "maxLength": resource_tool_prompt_schema_max_chars(), + "description": "资源描述或改造要求。按 kind 有硬上限,超限会被客户端直接拒绝:background-music 最多 140 字符、sound-effect 最多 1900 字符、video / character-animation 最多 4000 字符。图片编辑走 agc_edit_image。" }, "assetName": { "type": "string", @@ -417,17 +443,47 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab } }, "required": ["kind", "mode", "prompt", "assetName"], - "oneOf": [ + "allOf": [ { - "properties": { - "mode": { "const": "create" }, - "kind": { "enum": ["video", "sound-effect", "background-music"] } - }, - "not": { "required": ["sourceLocalAssetId"] } + "oneOf": [ + { + "properties": { + "mode": { "const": "create" }, + "kind": { "enum": ["video", "sound-effect", "background-music"] } + }, + "not": { "required": ["sourceLocalAssetId"] } + }, + { + "properties": { "mode": { "const": "derive" } }, + "required": ["sourceLocalAssetId"] + } + ] }, { - "properties": { "mode": { "const": "derive" } }, - "required": ["sourceLocalAssetId"] + // 逐 kind 声明真实提示词上限,与 resource_edit_prompt_max_chars 同口径。 + "oneOf": [ + { + "properties": { + "kind": { "const": "background-music" }, + "prompt": { "maxLength": resource_tool_prompt_max_chars("background-music") } + }, + "required": ["kind"] + }, + { + "properties": { + "kind": { "const": "sound-effect" }, + "prompt": { "maxLength": resource_tool_prompt_max_chars("sound-effect") } + }, + "required": ["kind"] + }, + { + "properties": { + "kind": { "enum": ["video", "character-animation"] }, + "prompt": { "maxLength": resource_tool_prompt_max_chars("video") } + }, + "required": ["kind"] + } + ] } ], "additionalProperties": false @@ -435,7 +491,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab }), json!({ "name": "agc_remove_background", - "description": "为当前项目已登记的图片资源去除背景。客户端使用当前登录账号的抠图服务、项目画布和素材目录,模型只能提供已登记资源身份与结果名称;不会返回 Token、内部路由、宿主路径或临时签名 URL。", + "description": "为当前项目已登记的图片资源去除背景。complex 通过语义分割识别前景;flat 用于纯色背景抠图,确定背景为纯色时优先选择 flat。提供资源身份、结果名称及可选模式和背景色;客户端管理登录、项目画布和素材目录,不返回 Token、内部路由、宿主路径或临时签名 URL。", "inputSchema": { "type": "object", "properties": { @@ -449,6 +505,16 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab "type": "string", "minLength": 1, "maxLength": DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS + }, + "backgroundMode": { + "type": "string", + "enum": ["complex", "flat"], + "description": "可选抠图模式:complex 用语义分割识别前景,flat 用纯色背景抠图;确定背景为纯色时优先使用 flat。省略时使用 complex" + }, + "screenColor": { + "type": "string", + "pattern": "^(auto|#[0-9A-Fa-f]{6})$", + "description": "flat 模式可选背景色;传 auto 或 #RRGGBB,省略时由服务自动检测" } }, "required": ["sourceLocalAssetId", "assetName"], @@ -840,25 +906,14 @@ fn validate_resource_generation_arguments(arguments: &Value) -> Result<(), Strin &["kind", "mode", "sourceLocalAssetId", "prompt", "assetName"], )?; let kind = bounded_tool_string(arguments, "kind", 80)?; - if ![ - "video", - "character-animation", - "sound-effect", - "background-music", - ] - .contains(&kind.as_str()) - { - return Err("工具参数 kind 不是受支持的媒体资源类型".to_string()); - } + let generation_kind = DirectResourceGenerationKind::parse(&kind)?; let mode = bounded_tool_string(arguments, "mode", 16)?; if !["create", "derive"].contains(&mode.as_str()) { return Err("工具参数 mode 必须是 create 或 derive".to_string()); } - let prompt = bounded_tool_string( - arguments, - "prompt", - DIRECT_TOOLS_MCP_MAX_RESOURCE_PROMPT_CHARS, - )?; + // prompt 的形状校验只用信封级上限,真正生效的按 kind 上限由紧随其后的权威判定给出 + // 精确数字;否则通用 4000 会先于按 kind 上限误报成安全边界错误。 + let prompt = bounded_tool_string(arguments, "prompt", DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES)?; bounded_tool_string( arguments, "assetName", @@ -868,8 +923,12 @@ fn validate_resource_generation_arguments(arguments: &Value) -> Result<(), Strin if source.is_some() { bounded_tool_string(arguments, "sourceLocalAssetId", 80)?; } - if kind == "background-music" && prompt.chars().count() > 140 { - return Err("背景音乐提示词必须在 1..=140 字符内".to_string()); + let prompt_max_chars = generation_kind.prompt_max_chars(); + if prompt.chars().count() > prompt_max_chars { + return Err(resource_edit_prompt_limit_error( + &generation_kind.edit_kind(), + prompt_max_chars, + )); } if kind == "character-animation" && mode == "create" { return Err("角色动画必须基于已登记图片资源派生".to_string()); @@ -881,14 +940,46 @@ fn validate_resource_generation_arguments(arguments: &Value) -> Result<(), Strin } } -fn validate_remove_background_arguments(arguments: &Value) -> Result<(), String> { - validate_tool_object_fields(arguments, &["sourceLocalAssetId", "assetName"])?; +pub(super) fn validate_remove_background_arguments(arguments: &Value) -> Result<(), String> { + validate_tool_object_fields( + arguments, + &[ + "sourceLocalAssetId", + "assetName", + "backgroundMode", + "screenColor", + ], + )?; bounded_tool_string(arguments, "sourceLocalAssetId", 80)?; bounded_tool_string( arguments, "assetName", DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS, )?; + if let Some(mode) = arguments.get("backgroundMode") { + let mode = mode + .as_str() + .ok_or_else(|| "backgroundMode 必须是 complex 或 flat".to_string())?; + if mode != "complex" && mode != "flat" { + return Err("backgroundMode 必须是 complex 或 flat".to_string()); + } + } + if let Some(color) = arguments.get("screenColor") { + let color = color + .as_str() + .ok_or_else(|| "screenColor 必须是 auto 或 #RRGGBB".to_string())?; + let valid_hex = color.len() == 7 + && color.starts_with('#') + && color[1..] + .chars() + .all(|character| character.is_ascii_hexdigit()); + if color != "auto" && !valid_hex { + return Err("screenColor 必须是 auto 或 #RRGGBB".to_string()); + } + if arguments.get("backgroundMode").and_then(Value::as_str) != Some("flat") { + return Err("complex 模式不能传 screenColor".to_string()); + } + } Ok(()) } @@ -1849,6 +1940,51 @@ pub(crate) fn stop_game_creator_external_mcp() -> Result<(), String> { mod tests { use super::*; + #[test] + fn remove_background_arguments_enforce_mode_color_contract() { + for fields in [ + json!({}), + json!({"backgroundMode":"complex"}), + json!({"backgroundMode":"flat"}), + json!({"backgroundMode":"flat","screenColor":"auto"}), + json!({"backgroundMode":"flat","screenColor":"#Ab12EF"}), + ] { + let mut arguments = json!({"sourceLocalAssetId":"asset-1","assetName":"透明图"}); + arguments + .as_object_mut() + .unwrap() + .extend(fields.as_object().unwrap().clone()); + assert!( + validate_remove_background_arguments(&arguments).is_ok(), + "{fields}" + ); + } + for fields in [ + json!({"screenColor":"auto"}), + json!({"backgroundMode":"complex","screenColor":"auto"}), + json!({"backgroundMode":"flat","screenColor":""}), + json!({"backgroundMode":"flat","screenColor":" auto "}), + json!({"backgroundMode":"flat","screenColor":"AUTO"}), + json!({"backgroundMode":"flat","screenColor":"#GGGGGG"}), + json!({"backgroundMode":"flat","screenColor":null}), + json!({"backgroundMode":"flat","screenColor":12}), + json!({"backgroundMode":""}), + json!({"backgroundMode":"FLAT"}), + json!({"backgroundMode":" flat "}), + json!({"backgroundMode":null}), + ] { + let mut arguments = json!({"sourceLocalAssetId":"asset-1","assetName":"透明图"}); + arguments + .as_object_mut() + .unwrap() + .extend(fields.as_object().unwrap().clone()); + assert!( + validate_remove_background_arguments(&arguments).is_err(), + "{fields}" + ); + } + } + #[cfg(all(windows, feature = "cocos-editor-execute"))] #[test] fn builtin_mcp_process_probe() { @@ -2669,4 +2805,686 @@ mod tests { "显式 Codex 返回不能再往项目主对话写 legacy 行" ); } + + // --------------------------------------------------------------------------------------- + // 工具层 → 客户端受控工具桥 → 假平台:媒体工具契约的确定性验收。 + // + // 夹具只回答资源编辑链路真正会发的请求,任何未预期请求直接 panic;源图片的 + // binding 在这里预置,因为「上传票据 → OSS 表单上传 → 对象确认 → 登记项目资源」 + // 子链已有专门用例覆盖,本组只钉工具名 / 参数校验 / 出站请求契约。 + // --------------------------------------------------------------------------------------- + + const TOOL_CHAIN_CANVAS_PROJECT_ID: &str = "remote-canvas-project"; + const TOOL_CHAIN_ASSET_FOLDER_ID: &str = "remote-asset-folder"; + const TOOL_CHAIN_ACCESS_TOKEN: &str = "tool-chain-token"; + const TOOL_CHAIN_MEDIA_ROUTE: &str = "/generated/resource-edit-result"; + + fn tool_chain_png() -> Vec { + let mut bytes = Vec::new(); + image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 2, + 2, + image::Rgba([12, 34, 56, 255]), + )) + .write_to( + &mut std::io::Cursor::new(&mut bytes), + image::ImageFormat::Png, + ) + .expect("encode tool chain png"); + bytes + } + + fn tool_chain_mp3() -> Vec { + let mut bytes = b"ID3\x04\x00\x00\x00\x00\x00\x0a".to_vec(); + bytes.extend_from_slice(&[0_u8; 32]); + bytes + } + + fn tool_chain_image_asset(id: &str, local_path: &str) -> GameCreationAppAssetManifestEntry { + GameCreationAppAssetManifestEntry { + id: id.to_string(), + kind: "image".to_string(), + media_type: "image/png".to_string(), + local_path: local_path.to_string(), + image_sequence_frames: None, + image_sequence_duration_ms: None, + category: game_creation_app_asset_category_for_kind("image"), + tags: Vec::new(), + source: GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: Some(TOOL_CHAIN_CANVAS_PROJECT_ID.to_string()), + resource_id: Some("editor-resource-hero".to_string()), + asset_object_id: Some("assetobj-hero".to_string()), + task_id: Some("tool-chain-task".to_string()), + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + } + } + + fn tool_chain_accept(listener: &std::net::TcpListener) -> std::net::TcpStream { + listener + .set_nonblocking(true) + .expect("set tool chain listener nonblocking"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + loop { + match listener.accept() { + Ok((stream, _)) => { + stream + .set_nonblocking(false) + .expect("restore tool chain stream blocking mode"); + return stream; + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!( + std::time::Instant::now() < deadline, + "等待工具链夹具请求超时" + ); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + Err(error) => panic!("接受工具链夹具请求失败:{error}"), + } + } + } + + fn tool_chain_read_request(stream: &mut std::net::TcpStream) -> String { + use std::io::Read; + + stream + .set_read_timeout(Some(std::time::Duration::from_secs(5))) + .expect("set tool chain read timeout"); + let mut bytes = Vec::new(); + let mut buffer = [0_u8; 4096]; + let (header_end, content_length) = loop { + let read = stream.read(&mut buffer).expect("read tool chain request"); + assert!(read > 0, "工具链夹具请求在请求头结束前关闭"); + bytes.extend_from_slice(&buffer[..read]); + let Some(header_end) = bytes.windows(4).position(|value| value == b"\r\n\r\n") else { + continue; + }; + let header_text = String::from_utf8_lossy(&bytes[..header_end]); + let content_length = header_text + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + .unwrap_or(0); + break (header_end + 4, content_length); + }; + while bytes.len() < header_end + content_length { + let read = stream + .read(&mut buffer) + .expect("read tool chain request body"); + assert!(read > 0, "工具链夹具请求在请求体结束前关闭"); + bytes.extend_from_slice(&buffer[..read]); + } + String::from_utf8_lossy(&bytes).into_owned() + } + + fn tool_chain_write_json(stream: &mut std::net::TcpStream, status: &str, body: Value) { + use std::io::Write; + + let body = body.to_string(); + write!( + stream, + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len(), + ) + .expect("write tool chain json response"); + } + + fn tool_chain_write_media(stream: &mut std::net::TcpStream, media_type: &str, bytes: &[u8]) { + use std::io::Write; + + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: {media_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + bytes.len(), + ) + .expect("write tool chain media headers"); + stream + .write_all(bytes) + .expect("write tool chain media bytes"); + } + + /// 假平台:按固定次数回答资源编辑链路的请求,并回放收到的每个请求正文。 + fn tool_chain_spawn_platform( + listener: std::net::TcpListener, + expected_submission: &'static str, + media_type: &'static str, + media: Vec, + request_count: usize, + ) -> ( + std::thread::JoinHandle>, + std::sync::mpsc::Receiver, + ) { + let media_url = format!( + "http://{}{TOOL_CHAIN_MEDIA_ROUTE}", + listener.local_addr().expect("tool chain fixture address") + ); + let (sender, receiver) = std::sync::mpsc::channel(); + let handle = std::thread::spawn(move || { + let mut requests = Vec::new(); + for _ in 0..request_count { + let mut stream = tool_chain_accept(&listener); + let request = tool_chain_read_request(&mut stream); + let request_line = request.lines().next().unwrap_or_default().to_string(); + let route = request_line + .split_whitespace() + .nth(1) + .unwrap_or_default() + .to_string(); + sender.send(request.clone()).ok(); + requests.push(request); + if route.starts_with("/api/editor/projects") { + tool_chain_write_json( + &mut stream, + "200 OK", + json!({"data": {"projects": [{ + "projectId": TOOL_CHAIN_CANVAS_PROJECT_ID, + "title": "工具链远端画布" + }]}}), + ); + } else if route.starts_with("/api/editor/assets/library") { + tool_chain_write_json( + &mut stream, + "200 OK", + json!({"data": {"library": {"folders": [{ + "folderId": TOOL_CHAIN_ASSET_FOLDER_ID, + "label": "工具链远端目录" + }]}}}), + ); + } else if request_line.starts_with(expected_submission) { + tool_chain_write_json( + &mut stream, + "202 Accepted", + json!({"data": { + "operationId": "tool-chain-operation", + "status": "queued", + "pollAfterMs": 0 + }}), + ); + } else if route.starts_with("/api/runtime/external-generation/jobs/") { + tool_chain_write_json( + &mut stream, + "200 OK", + json!({"data": { + "status": "completed", + "result": {"resource": { + "resourceId": "editor-resource-derived", + "objectKey": "generated/resource-edit-result", + "assetObjectId": "assetobj-derived" + }} + }}), + ); + } else if route.starts_with("/api/assets/read-url") { + tool_chain_write_json( + &mut stream, + "200 OK", + json!({"data": {"read": {"signedUrl": media_url}}}), + ); + } else if route.starts_with(TOOL_CHAIN_MEDIA_ROUTE) { + tool_chain_write_media(&mut stream, media_type, &media); + } else { + panic!("工具链夹具收到未预期请求:{request_line}"); + } + } + requests + }); + (handle, receiver) + } + + /// 真实项目 + 真实工具桥 + 真实 Direct 回合身份。 + async fn tool_chain_start( + root: &Path, + ) -> ( + super::super::direct_tool_bridge::DirectToolBridge, + DirectTaonierActiveInvocationGuard, + ) { + let bridge = super::super::direct_tool_bridge::start_direct_tool_bridge(root, false) + .await + .expect("start tool chain bridge"); + let turn = DirectTaonierActiveInvocationGuard::enter(root, "tool-chain-turn") + .expect("arm tool chain direct turn"); + (bridge, turn) + } + + async fn tool_chain_call( + bridge: &super::super::direct_tool_bridge::DirectToolBridge, + root: &Path, + name: &str, + arguments: Value, + ) -> Value { + EXTERNAL_MCP_BRIDGE_URL + .scope( + bridge.url().to_string(), + handle_direct_tools_mcp_request( + root, + json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { "name": name, "arguments": arguments } + }), + ), + ) + .await + .expect("mcp response") + } + + fn tool_chain_payload(result: &Value) -> Value { + serde_json::from_str( + result["result"]["content"][0]["text"] + .as_str() + .unwrap_or_else(|| panic!("工具返回不是文本包:{result}")), + ) + .unwrap_or_else(|error| panic!("工具返回不是 JSON:{error};{result}")) + } + + /// P1 门禁:工具 schema 的 per-kind 上限、MCP 校验、工具桥校验与客户端权威口径必须是同一个数字。 + #[test] + fn tool_prompt_limits_agree_with_the_client_authority() { + let specs = direct_tools_mcp_specs_for(false, false); + let resource_tool = specs["tools"] + .as_array() + .expect("tool array") + .iter() + .find(|tool| tool["name"] == "agc_create_or_derive_resource") + .expect("resource tool"); + let schema_branches = resource_tool["inputSchema"]["allOf"] + .as_array() + .and_then(|all_of| all_of.get(1)) + .and_then(|branch| branch["oneOf"].as_array()) + .expect("per-kind prompt limit branches"); + + for (tool_kind, expected_kind) in [ + ( + "background-music", + LocalProjectResourceEditKind::BackgroundMusic, + ), + ("sound-effect", LocalProjectResourceEditKind::SoundEffect), + ("video", LocalProjectResourceEditKind::Video), + ( + "character-animation", + LocalProjectResourceEditKind::CharacterAnimation, + ), + ] { + let authority = resource_edit_prompt_max_chars(&expected_kind); + let generation_kind = DirectResourceGenerationKind::parse(tool_kind) + .unwrap_or_else(|error| panic!("{tool_kind}: {error}")); + assert_eq!( + generation_kind.edit_kind(), + expected_kind, + "{tool_kind} 必须映射到同一客户端类型" + ); + assert_eq!( + generation_kind.prompt_max_chars(), + authority, + "{tool_kind} 的工具桥上限必须来自客户端权威口径" + ); + let branch_limit = schema_branches + .iter() + .find_map(|branch| { + let kind = &branch["properties"]["kind"]; + let covers_kind = kind["const"].as_str() == Some(tool_kind) + || kind["enum"].as_array().is_some_and(|values| { + values.iter().any(|value| value.as_str() == Some(tool_kind)) + }); + covers_kind.then(|| { + branch["properties"]["prompt"]["maxLength"] + .as_u64() + .expect("branch prompt maxLength") + }) + }) + .unwrap_or_else(|| panic!("{tool_kind} 缺少按 kind 声明的提示词上限")); + assert_eq!( + branch_limit as usize, authority, + "{tool_kind} 的 schema 上限必须等于真实生效上限" + ); + assert!( + resource_tool["inputSchema"]["properties"]["prompt"]["description"] + .as_str() + .is_some_and(|text| text.contains(&authority.to_string())), + "{tool_kind} 的上限必须写进 prompt 描述:{authority}" + ); + let over_limit = "字".repeat(authority + 1); + let error = validate_resource_generation_arguments(&json!({ + "kind": tool_kind, + "mode": "create", + "prompt": over_limit, + "assetName": "上限测试" + })) + .expect_err("超过按 kind 上限的提示词必须被拒绝"); + assert!( + error.contains(&authority.to_string()), + "{tool_kind} 的拒绝文案必须带上真实上限:{error}" + ); + } + + // 图片编辑走独立工具,其上限同样是图片类型的权威口径。 + let image_authority = + resource_edit_prompt_max_chars(&LocalProjectResourceEditKind::ImageReference); + assert_eq!(image_authority, DIRECT_TOOLS_MCP_MAX_IMAGE_PROMPT_CHARS); + let edit_tool = specs["tools"] + .as_array() + .expect("tool array") + .iter() + .find(|tool| tool["name"] == "agc_edit_image") + .expect("image edit tool"); + assert_eq!( + edit_tool["inputSchema"]["properties"]["prompt"]["maxLength"], + json!(image_authority) + ); + } + + /// 源资源未登记时必须给出可执行的下一步,而不是只报「不属于已登记资源」。 + #[tokio::test] + async fn unregistered_source_reports_the_registration_follow_up_tools() { + let temporary = crate::tests::canonical_test_tempdir("direct-tools-unregistered-source-"); + let root = temporary.path(); + init_local_game_project_at(root, "direct-tools-unregistered-source", "未登记源资源提示") + .expect("init project"); + let (bridge, _turn_arm) = tool_chain_start(root).await; + let _turn = bridge.begin_user_turn().expect("begin client turn"); + + let result = tool_chain_call( + &bridge, + root, + "agc_edit_image", + json!({ + "sourceLocalAssetId": "missing-image", + "prompt": "把这张图改成夜景", + "assetName": "缺失源图编辑版" + }), + ) + .await; + + assert_eq!(result["result"]["isError"], true, "{result}"); + let message = result["result"]["content"][0]["text"] + .as_str() + .expect("tool error text"); + assert!( + message.contains("agc_list_registered_assets"), + "未登记源资源必须指向已登记资源查询工具:{message}" + ); + assert!( + message.contains("agc_import_account_assets"), + "未登记源资源必须指向登记工具:{message}" + ); + } + + /// 工具层 → 桥 → 假平台:图片快速编辑必须真的落到站内 `/api/editor/images/edits`。 + #[tokio::test] + async fn edit_image_tool_reaches_the_platform_image_edit_route() { + let temporary = crate::tests::canonical_test_tempdir("direct-tools-edit-image-"); + let root = temporary.path(); + init_local_game_project_at(root, "direct-tools-edit-image", "图片快速编辑工具链") + .expect("init project"); + let source_bytes = tool_chain_png(); + std::fs::create_dir_all(root.join("assets")).expect("create assets dir"); + std::fs::write(root.join("assets/hero.png"), &source_bytes).expect("write source png"); + let mut manifest = read_existing_manifest_for_project(root).expect("read manifest"); + manifest + .assets + .push(tool_chain_image_asset("hero-image", "assets/hero.png")); + write_manifest(&root.join(".agent/manifest.json"), &manifest).expect("write manifest"); + + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind fixture"); + let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let (server, _requests) = tool_chain_spawn_platform( + listener, + "POST /api/editor/images/edits ", + "image/png", + tool_chain_png(), + 6, + ); + let _session = crate::platform_session::install_test_platform_session( + "tool-chain-owner", + TOOL_CHAIN_ACCESS_TOKEN, + &base_url, + ); + let session = current_platform_session().expect("platform session"); + let access = + ExternalEditorBindingAccess::new(&base_url, &session.access_token, Some(&session)) + .expect("account access"); + let principal = external_editor_binding_principal(&access).expect("account principal"); + let project_binding = new_external_editor_project_binding( + &manifest.project_id, + &principal, + TOOL_CHAIN_CANVAS_PROJECT_ID, + TOOL_CHAIN_ASSET_FOLDER_ID, + unix_timestamp(), + ) + .expect("project binding"); + write_external_editor_project_binding_at(root, &project_binding) + .expect("write project binding"); + let source_identity = new_external_editor_source_identity( + "hero-image", + &format!("{:x}", Sha256::digest(&source_bytes)), + "image/png", + "image", + ) + .expect("source identity"); + let resource_binding = new_external_editor_resource_binding( + &manifest.project_id, + &principal, + TOOL_CHAIN_CANVAS_PROJECT_ID, + &source_identity, + Some("editor-resource-hero"), + "source/hero.png", + "assetobj-hero", + Some(2), + Some(2), + unix_timestamp(), + ) + .expect("resource binding"); + write_external_editor_resource_binding_at(root, &resource_binding) + .expect("write resource binding"); + + let (bridge, _turn_arm) = tool_chain_start(root).await; + let _turn = bridge.begin_user_turn().expect("begin client turn"); + let result = tool_chain_call( + &bridge, + root, + "agc_edit_image", + json!({ + "sourceLocalAssetId": "hero-image", + "prompt": "把这张角色图改成夜景霓虹配色", + "assetName": "英雄-夜霓虹" + }), + ) + .await; + let requests = server.join().expect("join tool chain fixture"); + + assert_eq!(result["result"]["isError"], false, "{result}"); + let payload = tool_chain_payload(&result); + assert_eq!(payload["status"], "completed", "{payload}"); + assert_eq!(payload["kind"], "image"); + assert_eq!(payload["mode"], "derive"); + assert_eq!( + payload["resource"]["referenceResourceIds"], + json!(["local-asset:hero-image"]) + ); + let local_path = payload["resource"]["localPath"] + .as_str() + .expect("derived local path"); + assert!( + root.join(local_path).is_file(), + "派生图片必须落盘:{local_path}" + ); + + assert_eq!(requests.len(), 6, "{requests:?}"); + let submission = requests + .iter() + .find(|request| request.starts_with("POST /api/editor/images/edits ")) + .unwrap_or_else(|| panic!("缺少图片编辑提交请求:{requests:?}")); + let submission_lower = submission.to_ascii_lowercase(); + assert!(submission_lower.contains(&format!( + "authorization: bearer {}", + TOOL_CHAIN_ACCESS_TOKEN.to_ascii_lowercase() + ))); + assert!(submission_lower.contains("idempotency-key:")); + assert!(submission.contains("\"sourceReferenceId\":\"editor-resource-hero\"")); + assert!(submission.contains("\"assetLabel\":\"英雄-夜霓虹\"")); + assert!(submission.contains(&format!("\"projectId\":\"{TOOL_CHAIN_CANVAS_PROJECT_ID}\""))); + assert!(submission.contains(&format!( + "\"assetFolderId\":\"{TOOL_CHAIN_ASSET_FOLDER_ID}\"" + ))); + assert!( + !submission.contains("assetKind"), + "图片编辑请求不得回填 assetKind:{submission}" + ); + assert!(requests.iter().any(|request| request + .starts_with("GET /api/runtime/external-generation/jobs/tool-chain-operation "))); + assert!(requests + .iter() + .any(|request| request.starts_with("GET /api/assets/read-url?"))); + } + + /// 超过已发布上限的背景音乐提示词必须在工具层就被拒绝,且一次桥请求都不发出。 + #[tokio::test] + async fn background_music_prompt_over_the_limit_is_rejected_before_any_bridge_call() { + let temporary = crate::tests::canonical_test_tempdir("direct-tools-bgm-limit-"); + let root = temporary.path(); + init_local_game_project_at(root, "direct-tools-bgm-limit", "背景音乐上限工具链") + .expect("init project"); + let authority = + resource_edit_prompt_max_chars(&LocalProjectResourceEditKind::BackgroundMusic); + + let result = EXTERNAL_MCP_BRIDGE_URL + .scope( + // 故意指向没有服务监听的 loopback 地址:一旦真的发出桥请求,报错文案会变成连接失败。 + "http://127.0.0.1:1/tool-dead".to_string(), + handle_direct_tools_mcp_request( + root, + json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "agc_create_or_derive_resource", + "arguments": { + "kind": "background-music", + "mode": "create", + "prompt": "字".repeat(authority + 1), + "assetName": "紧张战斗背景音乐" + } + } + }), + ), + ) + .await + .expect("mcp response"); + + assert_eq!(result["result"]["isError"], true, "{result}"); + let message = result["result"]["content"][0]["text"] + .as_str() + .expect("tool error text"); + assert!( + message.contains(&authority.to_string()) && message.contains("背景音乐"), + "必须按发布上限拒绝并说明类型:{message}" + ); + assert!( + !message.contains("连接客户端受控工具桥失败"), + "上限拒绝必须发生在桥请求之前:{message}" + ); + } + + /// 工具层 → 桥 → 假平台:背景音乐 create 必须落到站内音频生成路由。 + #[tokio::test] + async fn background_music_tool_reaches_the_platform_audio_route() { + let temporary = crate::tests::canonical_test_tempdir("direct-tools-bgm-route-"); + let root = temporary.path(); + init_local_game_project_at(root, "direct-tools-bgm-route", "背景音乐工具链") + .expect("init project"); + let manifest = read_existing_manifest_for_project(root).expect("read manifest"); + + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind fixture"); + let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let (server, _requests) = tool_chain_spawn_platform( + listener, + "POST /api/editor/audios/background-music/generations ", + "audio/mpeg", + tool_chain_mp3(), + 6, + ); + let _session = crate::platform_session::install_test_platform_session( + "tool-chain-owner", + TOOL_CHAIN_ACCESS_TOKEN, + &base_url, + ); + let session = current_platform_session().expect("platform session"); + let access = + ExternalEditorBindingAccess::new(&base_url, &session.access_token, Some(&session)) + .expect("account access"); + let principal = external_editor_binding_principal(&access).expect("account principal"); + let project_binding = new_external_editor_project_binding( + &manifest.project_id, + &principal, + TOOL_CHAIN_CANVAS_PROJECT_ID, + TOOL_CHAIN_ASSET_FOLDER_ID, + unix_timestamp(), + ) + .expect("project binding"); + write_external_editor_project_binding_at(root, &project_binding) + .expect("write project binding"); + + let (bridge, _turn_arm) = tool_chain_start(root).await; + let _turn = bridge.begin_user_turn().expect("begin client turn"); + let result = tool_chain_call( + &bridge, + root, + "agc_create_or_derive_resource", + json!({ + "kind": "background-music", + "mode": "create", + "prompt": "紧张但克制的八位机战斗循环,鼓点清晰", + "assetName": "紧张战斗背景音乐" + }), + ) + .await; + let requests = server.join().expect("join tool chain fixture"); + + assert_eq!(result["result"]["isError"], false, "{result}"); + let payload = tool_chain_payload(&result); + assert_eq!(payload["status"], "completed", "{payload}"); + assert_eq!(payload["kind"], "background-music"); + assert_eq!(payload["mode"], "create"); + assert_eq!(payload["resource"]["mediaType"], "audio/mpeg"); + + assert_eq!(requests.len(), 6, "{requests:?}"); + let submission = requests + .iter() + .find(|request| { + request.starts_with("POST /api/editor/audios/background-music/generations ") + }) + .unwrap_or_else(|| panic!("缺少背景音乐提交请求:{requests:?}")); + assert!(submission.to_ascii_lowercase().contains(&format!( + "authorization: bearer {}", + TOOL_CHAIN_ACCESS_TOKEN.to_ascii_lowercase() + ))); + assert!(submission.contains("\"makeInstrumental\":true")); + assert!(submission.contains("紧张但克制的八位机战斗循环,鼓点清晰")); + assert!(submission.contains("\"assetLabel\":\"紧张战斗背景音乐\"")); + let body: Value = serde_json::from_str( + submission + .split("\r\n\r\n") + .nth(1) + .unwrap_or_else(|| panic!("背景音乐提交缺少请求体:{submission}")), + ) + .expect("background music submission body"); + let composed = body["gptDescriptionPrompt"] + .as_str() + .expect("composed background music prompt"); + assert!( + composed.starts_with("生成新音频;目标:") && composed.chars().count() < 200, + "create 模式必须使用无源前缀并落在现役接口上限内:{composed}" + ); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index a62bfd568..f5724810b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -5035,7 +5035,21 @@ pub(crate) fn read_local_project_text_preview_at( return Err("只能读取当前项目已登记的文档资源".to_string()); } cancellation.check()?; - load_local_project_text_preview_with_cancellation(root, &normalized_path, cancellation) + let mut preview = + load_local_project_text_preview_with_cancellation(root, &normalized_path, cancellation)?; + if normalized_path.to_ascii_lowercase().ends_with(".json") { + preview.ui_design_asset_id = manifest.assets.iter().find_map(|asset| { + (asset.local_path == normalized_path + && ui_editor::persistence::is_valid_ui_design_json( + &preview.content, + &manifest.project_id, + &asset.id, + )) + .then(|| asset.id.clone()) + }); + } + cancellation.check()?; + Ok(preview) } #[tauri::command] @@ -5401,19 +5415,29 @@ pub(crate) async fn read_direct_project_history_slice( project_path: String, before_item_id: Option, limit: Option, + messages_only: Option, ) -> Result { tauri::async_runtime::spawn_blocking(move || { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "conversation.read")?; - let (items, has_more, item_timestamps) = read_direct_project_history_items_slice_at( - root, - before_item_id.as_deref(), - limit.unwrap_or(20), - )?; + let read_slice = if messages_only.unwrap_or(false) { + read_direct_project_chat_items_slice_at + } else { + read_direct_project_history_items_slice_at + }; + let (items, has_more, item_timestamps) = + read_slice(root, before_item_id.as_deref(), limit.unwrap_or(20))?; + let oldest_item_id = items + .first() + .and_then(|item| item.get("id")) + .and_then(serde_json::Value::as_str) + .filter(|id| !id.is_empty()) + .map(str::to_string); Ok(DirectThreadHistorySlice { items, has_more, item_timestamps, + oldest_item_id, }) }) .await diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index f6764b155..cdfafff99 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -1563,7 +1563,7 @@ const DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT: &str = "high"; const DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS: u64 = 128_000; const DEFAULT_GAME_CREATOR_LLM_AUTO_COMPACT_TOKEN_LIMIT: u64 = 64_000; const DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT: u64 = 12_000; -const DEFAULT_GAME_CREATOR_LLM_MAX_RETRIES: u32 = 2; +const DEFAULT_GAME_CREATOR_LLM_MAX_RETRIES: u32 = 10; fn default_game_creator_agent_mode() -> String { GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string() diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs index 77183a884..46bb81c24 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs @@ -728,7 +728,16 @@ fn validate_resource_edit_uuid(value: &str, label: &str) -> Result<(), String> { Ok(()) } -fn resource_edit_prompt_max_chars(edit_kind: &LocalProjectResourceEditKind) -> usize { +/// 资源编辑提示词上限的**唯一口径**。 +/// +/// 三个调用方都必须从这里取数,禁止各自写死数字: +/// 1. 本文件的提交校验(`normalize_resource_edit_prompt`); +/// 2. `agc_tools` MCP 工具层(`direct_tools_mcp.rs` 的参数校验与工具 schema); +/// 3. 客户端受控工具桥(`direct_tool_bridge.rs`)。 +/// +/// 客户端 UI 的 `resourceEditPromptMaxLength`(`resourceEditModel.ts`)是同一份口径的 +/// 前端镜像;改数字必须同时改这里、那里,以及工具 schema 里按 kind 声明 `maxLength`。 +pub(crate) fn resource_edit_prompt_max_chars(edit_kind: &LocalProjectResourceEditKind) -> usize { match edit_kind { LocalProjectResourceEditKind::BackgroundMusic => 140, LocalProjectResourceEditKind::SoundEffect => 1_900, @@ -739,6 +748,24 @@ fn resource_edit_prompt_max_chars(edit_kind: &LocalProjectResourceEditKind) -> u } } +/// 提示词超限的拒绝文案:与上限同一个口径,MCP 层、工具桥和提交校验复用同一条字符串, +/// 保证模型看到的数字就是真实生效的数字。 +pub(crate) fn resource_edit_prompt_limit_error( + edit_kind: &LocalProjectResourceEditKind, + max_chars: usize, +) -> String { + format!( + "{}资源编辑提示词必须在 1..={max_chars} 字符内", + match edit_kind { + LocalProjectResourceEditKind::BackgroundMusic => "背景音乐", + LocalProjectResourceEditKind::SoundEffect => "音效", + LocalProjectResourceEditKind::Video => "视频", + LocalProjectResourceEditKind::CharacterAnimation => "角色动画", + _ => "", + } + ) +} + fn normalize_resource_edit_prompt( edit_kind: &LocalProjectResourceEditKind, value: &str, @@ -746,16 +773,7 @@ fn normalize_resource_edit_prompt( let value = value.trim(); let max_chars = resource_edit_prompt_max_chars(edit_kind); if value.is_empty() || value.chars().count() > max_chars { - return Err(format!( - "{}资源编辑提示词必须在 1..={max_chars} 字符内", - match edit_kind { - LocalProjectResourceEditKind::BackgroundMusic => "背景音乐", - LocalProjectResourceEditKind::SoundEffect => "音效", - LocalProjectResourceEditKind::Video => "视频", - LocalProjectResourceEditKind::CharacterAnimation => "角色动画", - _ => "", - } - )); + return Err(resource_edit_prompt_limit_error(edit_kind, max_chars)); } if value .chars() diff --git a/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs b/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs index 690c95b45..d7ca4b4ba 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs @@ -24,6 +24,9 @@ pub(crate) struct LocalProjectTextPreview { pub(crate) media_type: String, pub(crate) byte_len: u64, pub(crate) content: String, + /// 仅由已登记资源的原生 UI State 校验设置;前端不根据正文猜测编辑能力。 + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) ui_design_asset_id: Option, } #[derive(Debug, Eq, PartialEq, Serialize)] @@ -163,6 +166,7 @@ pub(crate) fn load_local_project_text_preview_with_cancellation( media_type: media_type.to_string(), byte_len: content.len() as u64, content, + ui_design_asset_id: None, }) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs index 73b55d9ce..a566a9225 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs @@ -158,7 +158,7 @@ pub(crate) fn load_ui_design_state_at( let root = Path::new(input.project_path.trim()); let expected_project_id = required_identifier(&input.expected_project_id, "expectedProjectId")?; let asset_id = required_identifier(&input.asset_id, "assetId")?; - let asset = ui_design_asset(root, &expected_project_id, &asset_id)?; + let asset = registered_json_asset(root, &expected_project_id, &asset_id)?; let document = read_ui_design_document(root, &asset.local_path, &expected_project_id, &asset_id)?; validate_document(&document, &expected_project_id, &asset_id)?; @@ -175,7 +175,7 @@ pub(crate) fn generate_ui_design_code_at( let expected_project_id = required_identifier(&input.expected_project_id, "expectedProjectId")?; let asset_id = required_identifier(&input.asset_id, "assetId")?; let _lock = acquire_project_write_lock(root, "ui_design.code_generate")?; - let asset = ui_design_asset(root, &expected_project_id, &asset_id)?; + let asset = registered_json_asset(root, &expected_project_id, &asset_id)?; let document = read_ui_design_document_locked(root, &asset.local_path, &expected_project_id, &asset_id)?; let (content, tree_exports, node_count) = render_ui_design_state_js(&document.state)?; @@ -225,9 +225,9 @@ pub(crate) fn save_ui_design_state_at( let expected_project_id = required_identifier(&input.expected_project_id, "expectedProjectId")?; let asset_id = required_identifier(&input.asset_id, "assetId")?; - let preflight_asset = ui_design_asset(root, &expected_project_id, &asset_id)?; + let preflight_asset = registered_json_asset(root, &expected_project_id, &asset_id)?; let _lock = acquire_project_write_lock(root, "ui_design.state_save")?; - let asset = ui_design_asset(root, &expected_project_id, &asset_id)?; + let asset = registered_json_asset(root, &expected_project_id, &asset_id)?; if asset.local_path != preflight_asset.local_path { return Err("UI 设计资源在保存锁获取期间发生变化,请重试".to_string()); } @@ -311,6 +311,19 @@ fn ui_design_asset( root: &Path, expected_project_id: &str, asset_id: &str, +) -> Result { + let asset = registered_json_asset(root, expected_project_id, asset_id)?; + // 新状态初始化仍是显式 UI 创建动作,不能因放开已有设计的登记标签而覆盖普通 JSON。 + if asset.kind != "UI" || asset.media_type != "application/json" { + return Err("目标资源不是 UI 设计 JSON 资产".to_string()); + } + Ok(asset) +} + +fn registered_json_asset( + root: &Path, + expected_project_id: &str, + asset_id: &str, ) -> Result { let manifest = read_existing_manifest_for_project(root)?; if manifest.project_id != expected_project_id { @@ -321,8 +334,10 @@ fn ui_design_asset( .into_iter() .find(|asset| asset.id == asset_id) .ok_or_else(|| "UI 设计资源不存在".to_string())?; - if asset.kind != "UI" || asset.media_type != "application/json" { - return Err("目标资源不是 UI 设计 JSON 资产".to_string()); + if !asset.local_path.to_ascii_lowercase().ends_with(".json") + || !is_supported_project_text_resource(&asset.local_path, &asset.media_type) + { + return Err("目标资源不是已登记的 JSON 资产".to_string()); } normalize_relative_path(&asset.local_path)?; Ok(asset) @@ -393,10 +408,27 @@ fn read_ui_design_document_path(path: &Path) -> Result bool { + parse_ui_design_document(content.as_bytes()) + .and_then(|document| validate_document(&document, project_id, asset_id)) + .is_ok() +} + +fn parse_ui_design_document(bytes: &[u8]) -> Result { + if bytes.len() > UI_DESIGN_STATE_MAX_BYTES { + return Err(format!( + "UI 设计 State 超过 {UI_DESIGN_STATE_MAX_BYTES} 字节上限" + )); + } + let value: serde_json::Value = serde_json::from_slice(bytes) + .map_err(|error| format!("解析 UI 设计 State 失败:{error}"))?; let document: PersistedUiDesignState = serde_json::from_value(value.clone()) - .map_err(|error| format!("解析 UI 设计 State 契约失败:{}: {error}", path.display()))?; + .map_err(|error| format!("解析 UI 设计 State 契约失败:{error}"))?; let canonical: serde_json::Value = serde_json::from_slice(&serialize_ui_design_document(&document)?) .map_err(|error| format!("序列化 UI 设计 State 契约失败:{error}"))?; @@ -847,6 +879,170 @@ mod tests { } } + #[test] + fn json_preview_recognition_requires_canonical_state_and_resource_identity() { + let document = serde_json::to_value(empty_document(PROJECT_ID, "design")).unwrap(); + let content = document.to_string(); + assert!(is_valid_ui_design_json(&content, PROJECT_ID, "design")); + assert!(!is_valid_ui_design_json( + &content, + "other-project", + "design" + )); + assert!(!is_valid_ui_design_json( + &content, + PROJECT_ID, + "other-asset" + )); + for content in ["{broken", "{}", "[]", r#"{"type":"UI"}"#] { + assert!(!is_valid_ui_design_json(content, PROJECT_ID, "design")); + } + for (pointer, value) in [ + ("/schemaVersion", serde_json::json!("unknown-schema")), + ("/revision", serde_json::json!(9_007_199_254_740_992u64)), + ("/state/ui_trees", serde_json::json!([{}])), + ] { + let mut invalid = document.clone(); + *invalid.pointer_mut(pointer).unwrap() = value; + assert!(!is_valid_ui_design_json( + &invalid.to_string(), + PROJECT_ID, + "design" + )); + } + let mut unknown = document.clone(); + unknown["state"]["unknown"] = serde_json::json!(true); + assert!(!is_valid_ui_design_json( + &unknown.to_string(), + PROJECT_ID, + "design" + )); + let invalid_state = PersistedUiDesignState { + state: state_with_unavailable_image("../outside.png"), + ..empty_document(PROJECT_ID, "design") + }; + assert!(!is_valid_ui_design_json( + &serde_json::to_string(&invalid_state).unwrap(), + PROJECT_ID, + "design", + )); + assert!(!is_valid_ui_design_json( + &" ".repeat(UI_DESIGN_STATE_MAX_BYTES + 1), + PROJECT_ID, + "design", + )); + } + + #[test] + fn json_preview_and_editor_accept_valid_state_without_rewriting_asset_kind() { + for kind in ["UI", "ui", "ui-design", "document"] { + let (directory, asset_id) = fixture(); + crate::project::mutate_manifest_at(directory.path(), |manifest| { + manifest + .assets + .iter_mut() + .find(|asset| asset.id == asset_id) + .unwrap() + .kind = kind.to_string(); + Ok(()) + }) + .unwrap(); + let path = directory.path().join("ui/design.json"); + let before = fs::read(&path).unwrap(); + let preview = read_local_project_text_preview_at( + directory.path().to_str().unwrap(), + "ui/design.json", + &crate::resource_preview_scheduler::ProjectResourcePreviewScopeCancellation::uncancelled(), + ).unwrap(); + assert_eq!( + preview.ui_design_asset_id.as_deref(), + Some(asset_id.as_str()) + ); + assert_eq!(fs::read(&path).unwrap(), before); + let loaded = load_ui_design_state_at(LoadUiDesignStateInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + asset_id: asset_id.clone(), + }) + .unwrap(); + assert_eq!(loaded.revision, 0); + let saved = save_ui_design_state_at(input( + directory.path(), + &asset_id, + 0, + state_with_unavailable_image("assets/page.png"), + )) + .unwrap(); + assert!(matches!( + saved, + SaveUiDesignStateResult::Saved { revision: 1, .. } + )); + assert_eq!( + read_existing_manifest_for_project(directory.path()) + .unwrap() + .assets + .iter() + .find(|asset| asset.id == asset_id) + .unwrap() + .kind, + kind, + ); + } + } + + #[test] + fn ordinary_or_foreign_json_preview_never_grants_ui_editing_or_overwrites_content() { + let (directory, asset_id) = fixture(); + crate::project::mutate_manifest_at(directory.path(), |manifest| { + manifest + .assets + .iter_mut() + .find(|asset| asset.id == asset_id) + .unwrap() + .kind = "document".to_string(); + Ok(()) + }) + .unwrap(); + let path = directory.path().join("ui/design.json"); + let foreign = serde_json::to_string(&empty_document("other-project", &asset_id)).unwrap(); + for content in [r#"{"ordinary":true}"#, "{broken", foreign.as_str()] { + fs::write(&path, content).unwrap(); + let preview = read_local_project_text_preview_at( + directory.path().to_str().unwrap(), + "ui/design.json", + &crate::resource_preview_scheduler::ProjectResourcePreviewScopeCancellation::uncancelled(), + ).unwrap(); + assert_eq!(preview.ui_design_asset_id, None); + assert_eq!(preview.content, content); + assert!(load_ui_design_state_at(LoadUiDesignStateInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + asset_id: asset_id.clone(), + }) + .is_err()); + assert!(save_ui_design_state_at(input( + directory.path(), + &asset_id, + 0, + empty_document(PROJECT_ID, &asset_id).state, + )) + .is_err()); + assert!( + initialize_ui_design_state_at(directory.path(), PROJECT_ID, &asset_id).is_err() + ); + assert_eq!(fs::read_to_string(&path).unwrap(), content); + } + fs::write( + directory.path().join("ui/unregistered.json"), + serde_json::to_string(&empty_document(PROJECT_ID, &asset_id)).unwrap(), + ) + .unwrap(); + assert!(read_local_project_text_preview_at( + directory.path().to_str().unwrap(), "ui/unregistered.json", + &crate::resource_preview_scheduler::ProjectResourcePreviewScopeCancellation::uncancelled(), + ).is_err()); + } + fn state_with_unavailable_image(path: &str) -> State { serde_json::from_value(serde_json::json!({ "ui_trees": [], diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 9523363ac..be33bfe93 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -239,10 +239,11 @@ import { DeveloperProjectPanels } from './features/project-workspace/DeveloperPr import { DeveloperRuntimePanels } from './features/project-workspace/DeveloperRuntimePanels'; import { type DirectThreadConsumeResult, - directThreadHistoryItemsToMessages, + directThreadHistoryPage, type DirectThreadHistorySlice, type DirectThreadSubscriptionBootstrap, isDirectTurnInProgress, + prependDirectHistoryMessages, } from './features/project-workspace/directThreadEvents'; import { normalizeDirectTimestamp } from './features/project-workspace/directTurnPresentation'; import type { DirectCodexUserContentPart } from './features/project-workspace/generated'; @@ -625,7 +626,7 @@ function claimInitialSupervisorMessageForPage(projectPath: string, scope = '') { * 初始需求是**乐观插入**到 messages 的(latch 命中后先插一条 user 消息,再发起回合), * 而历史回读在 replace 分支里是无条件整体替换 —— 只要回读晚于乐观插入,那条用户消息 * 就会被冲掉(界面上看不到初始需求,但回合其实已经跑起来了)。 - * 这里把当前 messages 里"运行时拥有、且回读结果里没有"的消息保留在末尾(它们是最新的)。 + * 这里只把当前 messages 里"非历史来源、运行时拥有、且回读结果里没有"的消息保留在末尾。 */ function mergeLoadedConversationWithPendingRuntimeMessages( loaded: ChatMessage[], @@ -643,7 +644,7 @@ function mergeLoadedConversationWithPendingRuntimeMessages( loaded.map((message) => `${message.role}\u0000${message.text}`), ); const pending = current.filter((message) => { - if (!message.runtimeOwned) { + if (!message.runtimeOwned || message.fromHistory) { return false; } if (message.messageId) { @@ -1958,7 +1959,7 @@ export function App({ ); const [directHistoryHasMore, setDirectHistoryHasMore] = useState(false); const directHistoryOldestItemIdRef = useRef(null); - const directHistoryLoadingRef = useRef(false); + const directHistoryLoadingRef = useRef(null); const [pendingCommand, setPendingCommand] = useState( null, ); @@ -2151,6 +2152,7 @@ export function App({ function resetProjectSupervisorState() { projectSupervisorHistoryLoadVersionRef.current += 1; + directHistoryLoadingRef.current = null; projectSupervisorRuntimeResumeProjectPathRef.current = null; projectSupervisorSessionIdRef.current = null; projectSupervisorRuntimeRef.current = null; @@ -4009,6 +4011,8 @@ export function App({ } const loadVersion = projectSupervisorHistoryLoadVersionRef.current + 1; projectSupervisorHistoryLoadVersionRef.current = loadVersion; + if (directCodexProductRuntime) + directHistoryLoadingRef.current = loadVersion; try { // V2 projects do not have a Supervisor run or legacy conversation. Probe the // V2 authority first; a missing V2 session returns null and preserves the @@ -4105,6 +4109,7 @@ export function App({ : await readProjectSupervisorActiveSession(invoke, nextProjectPath); let runtimeError = ''; let loadedDirectHistoryHasMore = false; + let loadedDirectHistoryCursor: string | null = null; const projectConversation = directCodexProductRuntime ? (() => { return invoke( @@ -4112,16 +4117,16 @@ export function App({ { projectPath: nextProjectPath, limit: CONVERSATION_INITIAL_VISIBLE_COUNT, + messagesOnly: true, }, ).then((slice) => { - loadedDirectHistoryHasMore = slice.hasMore; + const page = directThreadHistoryPage(slice); + loadedDirectHistoryHasMore = page.hasMore; + loadedDirectHistoryCursor = page.cursor; return { path: nextProjectPath, agentId: null, - messages: directThreadHistoryItemsToMessages( - slice.items, - slice.itemTimestamps, - ), + messages: page.messages, } satisfies LocalConversationResult; }); })() @@ -4197,7 +4202,7 @@ export function App({ const conversationMessages = mergeProjectSupervisorConversation( resolvedProjectConversation.messages, supervisorConversation?.messages ?? [], - ); + ).map((message) => ({ ...message, fromHistory: true })); if ( conversationContainsProjectSupervisorResponseStream( supervisorConversation?.messages ?? [], @@ -4218,9 +4223,7 @@ export function App({ setProjectSupervisorRuntimeError(runtimeError || resumeError); if (directCodexProductRuntime) { setDirectHistoryHasMore(loadedDirectHistoryHasMore); - directHistoryOldestItemIdRef.current = - conversationMessages.find((message) => message.messageId) - ?.messageId ?? null; + directHistoryOldestItemIdRef.current = loadedDirectHistoryCursor; } setMessages((current) => { // replace 分支同样不能丢掉尚未落盘的运行时消息(初始需求)。 @@ -4281,6 +4284,10 @@ export function App({ : workspaceStatus, ); // Keep the default greeting when history is missing or blocked. + } finally { + if (directHistoryLoadingRef.current === loadVersion) { + directHistoryLoadingRef.current = null; + } } } @@ -4396,6 +4403,9 @@ export function App({ setAgentRunHistoryFiles([]); setAgentRuntimeById({}); setMessages(conversationMessages); + // 只有新项目确实打开后才丢弃旧分页位置;打开失败时旧会话仍可继续翻页。 + directHistoryOldestItemIdRef.current = null; + setDirectHistoryHasMore(false); setConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT); savedConversationProjectPathRef.current = openedProject.projectPath; savedConversationCountRef.current = conversationMessages.length; @@ -12480,48 +12490,63 @@ export function App({ : null; async function showEarlierConversationMessages() { + if (hiddenConversationCount > 0) { + setConversationVisibleCount((current) => + Math.min(messages.length, current + CONVERSATION_VISIBLE_STEP), + ); + return; + } if (directCodexProductRuntime && directHistoryHasMore) { const invoke = resolveTauriInvoke(); const projectPath = localProject?.projectPath; - if (invoke && projectPath && !directHistoryLoadingRef.current) { - directHistoryLoadingRef.current = true; + if (invoke && projectPath && directHistoryLoadingRef.current === null) { + const loadVersion = projectSupervisorHistoryLoadVersionRef.current; + const beforeItemId = directHistoryOldestItemIdRef.current; + directHistoryLoadingRef.current = loadVersion; + const isCurrentLoad = () => + manifestRefreshMountedRef.current && + localProjectPathRef.current === projectPath && + projectSupervisorHistoryLoadVersionRef.current === loadVersion; try { const slice = await invoke( 'read_direct_project_history_slice', { projectPath, - beforeItemId: directHistoryOldestItemIdRef.current, + beforeItemId, limit: CONVERSATION_VISIBLE_STEP, + messagesOnly: true, }, ); - if (localProjectPathRef.current !== projectPath) { + if (!isCurrentLoad()) { return; } - const older = directThreadHistoryItemsToMessages( - slice.items, - slice.itemTimestamps, - ).map((message) => ({ + const page = directThreadHistoryPage(slice, beforeItemId); + const older = page.messages.map((message) => ({ role: message.role === 'user' ? ('user' as const) : ('assistant' as const), text: message.content, runtimeOwned: true, + fromHistory: true, messageId: message.messageId, updatedAt: message.updatedAt, })); - setMessages((current) => [...older, ...current]); + setMessages((current) => + prependDirectHistoryMessages(current, older), + ); setConversationVisibleCount((current) => current + older.length); - setDirectHistoryHasMore(slice.hasMore); - directHistoryOldestItemIdRef.current = - older.find((message) => message.messageId)?.messageId ?? - directHistoryOldestItemIdRef.current; + setDirectHistoryHasMore(page.hasMore); + directHistoryOldestItemIdRef.current = page.cursor; } catch (error) { + if (!isCurrentLoad()) return; setWorkspaceStatus( `读取更早的对话历史失败:${error instanceof Error ? error.message : String(error)}`, ); } finally { - directHistoryLoadingRef.current = false; + if (directHistoryLoadingRef.current === loadVersion) { + directHistoryLoadingRef.current = null; + } } } return; diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 88594678d..6fb26adc6 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -999,6 +999,8 @@ export interface ChatMessage { agentId?: string | null; updatedAt?: number; runtimeOwned?: boolean; + /** 来自历史回读,不作为尚未落盘的实时消息追加到新历史页末尾。 */ + fromHistory?: boolean; } export type DesignAgentInput = diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts index 3758451bb..a9d4c478e 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts @@ -46,11 +46,16 @@ export function useDirectActiveTurns({ * 面板就是这么被反复重发布的)。这里只在内容真的变了才更新状态。 */ const lastSnapshotSignatureRef = useRef(''); + const retryTimerRef = useRef(null); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; + if (retryTimerRef.current !== null) { + window.clearTimeout(retryTimerRef.current); + retryTimerRef.current = null; + } }; }, []); @@ -86,12 +91,12 @@ export function useDirectActiveTurns({ return; } catch { if (attempt < DIRECT_ACTIVE_TURNS_READ_ATTEMPTS) { - await new Promise((resolve) => - window.setTimeout( - resolve, - DIRECT_ACTIVE_TURNS_READ_RETRY_DELAY_MS * attempt, - ), - ); + await new Promise((resolve) => { + retryTimerRef.current = window.setTimeout(() => { + retryTimerRef.current = null; + resolve(); + }, DIRECT_ACTIVE_TURNS_READ_RETRY_DELAY_MS * attempt); + }); } } } diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index 437c11da8..85107f7b9 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -1,4 +1,11 @@ -import { Fragment, useCallback, useEffect, useRef, useState } from 'react'; +import { + Fragment, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; import { createPortal } from 'react-dom'; import { launcherNotifications } from '../../app/constants'; @@ -208,12 +215,18 @@ export function WorkspaceLauncherShell({ */ const manifestMergeNoticeScopeRef = useRef(null); + // 打开项目由创建流程提供,其函数引用随渲染变化;标题栏只持有稳定的转发入口, + // 否则发布 Context 会再次触发工作台 effect,形成发布/清理循环。 + const openProjectRef = useRef(openProject); + useLayoutEffect(() => { + openProjectRef.current = openProject; + }, [openProject]); const openActiveProject = useCallback( (nextProjectPath: string) => { setProjectPath(nextProjectPath); - void openProject(nextProjectPath, 'open'); + void openProjectRef.current(nextProjectPath, 'open'); }, - [openProject, setProjectPath], + [setProjectPath], ); /** @@ -236,13 +249,13 @@ export function WorkspaceLauncherShell({ onOpenProject: (projectPath: string) => openActiveProjectRef.current(projectPath), }); - return () => setActiveProjectRuns(null); }, [ activeTurns, currentProjectContext?.projectPath, setActiveProjectRuns, snapshotReadFailed, ]); + useEffect(() => () => setActiveProjectRuns(null), [setActiveProjectRuns]); useEffect(() => { const projectPath = currentProjectContext?.projectPath ?? null; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts index 6c1cad229..32180fcff 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts @@ -1,4 +1,7 @@ -import type { LocalConversationMessageRecord } from '../../app/types'; +import type { + ChatMessage, + LocalConversationMessageRecord, +} from '../../app/types'; export type DirectThreadRawEvent = { seq: number; @@ -22,8 +25,54 @@ export type DirectThreadHistorySlice = { items: unknown[]; hasMore: boolean; itemTimestamps?: Record; + oldestItemId?: string | null; }; +/** 游标取原始响应,而非过滤后的聊天消息;拒绝不能前进的页,避免静默反复回读。 */ +export function directThreadHistoryPage( + slice: DirectThreadHistorySlice, + previousCursor: string | null = null, +) { + const first = slice.items[0]; + const firstId = + first && typeof first === 'object' && 'id' in first + ? (first as { id?: unknown }).id + : null; + const cursor = + slice.oldestItemId ?? + (typeof firstId === 'string' && firstId ? firstId : null); + if (slice.hasMore && (!cursor || cursor === previousCursor)) { + throw new Error('对话历史分页游标未前进,请重新读取项目历史'); + } + return { + messages: directThreadHistoryItemsToMessages( + slice.items, + slice.itemTimestamps, + ), + hasMore: slice.hasMore, + cursor, + }; +} + +/** 保留当前实时/已显示版本;原始身份相同的回读消息不能插入第二次。 */ +export function prependDirectHistoryMessages( + current: readonly ChatMessage[], + older: readonly ChatMessage[], +): ChatMessage[] { + const ids = new Set( + current.flatMap((message) => + message.messageId ? [message.messageId] : [], + ), + ); + const additions = older.filter((message) => { + if (!message.messageId) return true; + if (ids.has(message.messageId)) return false; + ids.add(message.messageId); + return true; + }); + return [...additions, ...current]; +} + export function directThreadHistoryItemsToMessages( items: unknown[], itemTimestamps: Readonly> = {}, diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts index a2c24def2..92ba12136 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts @@ -37,6 +37,23 @@ export function isResourceCanvasInteractionTarget( return Boolean(target?.closest(RESOURCE_CANVAS_INTERACTION_SELECTOR)); } +/** 抓手可从卡面发起,但不能抢走输入、媒体控件或画布浮层的交互。 */ +export function isResourceCanvasPanTarget( + target: Element | null | undefined, +): boolean { + if (!target) return false; + if (target.closest('[contenteditable="true"], .game-resource-filter-panel')) { + return false; + } + if (!isResourceCanvasInteractionTarget(target)) return true; + return Boolean( + target.closest('.game-resource-card') && + !target.closest( + 'button:not(.game-resource-card-select), input, textarea, select, a, audio, video', + ), + ); +} + /** * 画布浮层里有自己滚动区的那几个:落在它们里面的滚轮归浮层,画布不得消费。 * diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceDocumentPreviewModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceDocumentPreviewModel.ts index c0bc85c78..390d08c15 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceDocumentPreviewModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceDocumentPreviewModel.ts @@ -1,4 +1,5 @@ import { + isProjectResourceJson, projectResourceCardPreviewKind, projectResourcePathExtension, } from '../../view/project-development/resourceCardPreviewModel'; @@ -65,11 +66,14 @@ export function resourceDocumentPreviewMarkdown( resource: ProjectResource, content: string, ) { - if (projectResourceCardPreviewKind(resource) !== 'code') { + const isJson = isProjectResourceJson(resource); + if (projectResourceCardPreviewKind(resource) !== 'code' && !isJson) { return content; } - const language = - CODE_LANGUAGES[projectResourcePathExtension(resource.path) ?? ''] ?? 'text'; + const language = isJson + ? 'json' + : (CODE_LANGUAGES[projectResourcePathExtension(resource.path) ?? ''] ?? + 'text'); // 围栏长于源码里的任意反引号串,代码生成模板中的 Markdown 不能提前闭合代码块。 const longestRun = (content.match(/`+/g) ?? []).reduce( (length, run) => Math.max(length, run.length), diff --git a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx index a12f99ff8..16feb1f76 100644 --- a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx +++ b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx @@ -64,7 +64,7 @@ const defaultRuntimeConfigDraft: GameCreatorAppConfig = { autoCompactTokenLimit: 64000, toolOutputTokenLimit: 12000, requestTimeoutMs: 180000, - maxRetries: 2, + maxRetries: 10, retryBackoffMs: 500, }, agentLlm: {}, diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 5b0352492..613ec51e4 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -8820,6 +8820,27 @@ iframe.preview-frame { overflow: hidden; } +/* 输入盒的弹层必须能溢出面板:控制排最左侧是「推理档」,它的菜单 + `.conversation-model-menu` 贴着触发钮右缘向左展开,窄布局(视口 ≤1000px 时对话面板 + 只有 280px 宽)下会伸到面板左侧之外;`.game-workbench-chat`、surface、conversation + 这三层 `overflow: hidden` 会沿着各自的溢出边界把它裁掉,档位文字正好落在被裁掉的 + 那半边,于是点开只能看到一个空盒子。所以这里让这三层不再裁切:菜单自身位置、 + 尺寸都不变,只是允许它盖到左侧面板上完整显示。消息列表自带 `overflow-y: auto` + (另一轴按规范计算为 auto),消息内容仍由列表自身裁剪。 */ +.game-workbench-chat:has(.project-supervisor-composer.is-direct-codex) { + overflow: visible; +} + +.game-workbench-chat .project-supervisor-surface.is-direct-codex { + overflow: visible; +} + +.game-workbench-chat + .project-supervisor-surface.is-direct-codex + .project-supervisor-conversation { + overflow: visible; +} + .game-workbench-chat .project-supervisor-message-list { height: 100%; min-height: 96px; diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourcePreviewMedia.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourcePreviewMedia.tsx index 637ab9103..281ce517c 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/ResourcePreviewMedia.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourcePreviewMedia.tsx @@ -1,10 +1,17 @@ -import { FileCode2, Image as ImageIcon, Music2, Video } from 'lucide-react'; +import { + FileCode2, + Image as ImageIcon, + Music2, + SlidersHorizontal, + Video, +} from 'lucide-react'; import { useEffect, useRef } from 'react'; import { projectResourceCardPreviewKind, type ProjectResourceCardPreviewState, projectResourceCardPreviewVariant, + projectResourceJsonPresentation, } from './resourceCardPreviewModel'; import type { ProjectResource } from './resourceProjectionModel'; @@ -90,6 +97,16 @@ export function ResourcePreviewMedia({ const sourceUrl = preview.status === 'loaded' ? (preview.preview.sourceUrl ?? null) : null; const visual = (() => { + const jsonPresentation = resource + ? projectResourceJsonPresentation(resource, preview) + : null; + if (jsonPresentation) { + return jsonPresentation === 'ui-design' ? ( + + ) : ( + + ); + } if (sourceUrl && (kind === 'raster-image' || kind === 'media-image')) { return ( { + if (jsonPresentation) { + return ( + + {jsonPresentation === 'ui-design' ? ( + + ); + } if ((kind === 'raster-image' || kind === 'media-image') && sourceUrl) { return ( - {resourceCategoryLabel} + {cardTypeLabel} {lineage ? ( // 文字是给人看的关系,`data-resource-lineage` 是给端到端验收的稳定判据 @@ -1342,6 +1367,7 @@ function ResourceBookScene({ onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerCancel} + onLostPointerCapture={onPointerCancel} >
>(new Set()); const resourceCanvasPanRef = useRef<{ pointerId: number; + captureTarget: HTMLElement; category: ResourceBookTarget; startClientX: number; startClientY: number; @@ -3363,15 +3391,14 @@ export default function ProjectDevelopmentView({ ); const cancelResourceCanvasPan = useCallback(() => { - const pan = resourceCanvasPanRef.current; - if (!pan) { - return; - } - const canvas = resourceCanvasRef.current; - if (canvas?.hasPointerCapture?.(pan.pointerId)) { - canvas.releasePointerCapture?.(pan.pointerId); - } + const pans = [resourceCanvasPanRef.current, resourceBookMainPanRef.current]; resourceCanvasPanRef.current = null; + resourceBookMainPanRef.current = null; + for (const pan of pans) { + if (pan?.captureTarget.hasPointerCapture?.(pan.pointerId)) { + pan.captureTarget.releasePointerCapture(pan.pointerId); + } + } }, []); const cancelResourceCardDrag = useCallback(() => { @@ -4749,6 +4776,8 @@ export default function ProjectDevelopmentView({ }; const handleBlur = () => { resourceCanvasSpacePanRef.current = false; + cancelResourceCanvasPan(); + setResourceCanvasMarquee(null); }; window.addEventListener('keydown', handleKeyDown); window.addEventListener('keyup', handleKeyUp); @@ -4758,7 +4787,7 @@ export default function ProjectDevelopmentView({ window.removeEventListener('keyup', handleKeyUp); window.removeEventListener('blur', handleBlur); }; - }, []); + }, [cancelResourceCanvasPan]); const handleResourceBookWheel = useCallback( (event: ReactWheelEvent | WheelEvent) => { @@ -4855,7 +4884,10 @@ export default function ProjectDevelopmentView({ const handleResourceBookMainPointerDown = useCallback( (event: ReactPointerEvent) => { - if (resourceBookView !== 'main' || event.button !== 0) { + if ( + resourceBookView !== 'main' || + (event.button !== 0 && event.button !== 2) + ) { return; } const target = event.target as HTMLElement; @@ -4871,6 +4903,7 @@ export default function ProjectDevelopmentView({ resourceBookTransitionControllerRef.current.settle(); resourceBookMainPanRef.current = { pointerId: event.pointerId, + captureTarget: event.currentTarget, startClientX: event.clientX, startClientY: event.clientY, startViewport: resourceBookMainViewportRef.current, @@ -5096,11 +5129,19 @@ export default function ProjectDevelopmentView({ const canvasTarget = resourceBookOpensAllResources ? RESOURCE_BOOK_ALL_TARGET : activePageCategory; - if (!canvasTarget || event.button > 1) { + if (!canvasTarget || event.button > 2) { return; } const target = event.target as HTMLElement; - if (isResourceCanvasInteractionTarget(target)) { + const isPan = + event.button === 2 || + event.button === 1 || + resourceCanvasSpacePanRef.current; + if ( + event.button === 2 + ? !isResourceCanvasPanTarget(target) + : isResourceCanvasInteractionTarget(target) + ) { return; } // 点选态下空白处的左键不起框选、也不清画布焦点/选中:点选只认资源卡, @@ -5114,12 +5155,13 @@ export default function ProjectDevelopmentView({ return; } resourceBookTransitionControllerRef.current.settle(); - // 与美术画布一致:中键或按住空格是平移,左键在空白处是框选。 - if (event.button === 1 || resourceCanvasSpacePanRef.current) { + // 右键平移,保留中键/空格抓手;空白处左键继续框选。 + if (isPan) { event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); resourceCanvasPanRef.current = { pointerId: event.pointerId, + captureTarget: event.currentTarget, category: canvasTarget, startClientX: event.clientX, startClientY: event.clientY, @@ -5292,12 +5334,22 @@ export default function ProjectDevelopmentView({ const openResourceUiEditor = useCallback( (resource: ProjectResource) => { - if (resource.subtype === 'ui-prototype') { + const identity = resourceCardPreviews.identityByResourceId.get( + resource.id, + ); + const jsonPresentation = projectResourceJsonPresentation( + resource, + identity ? resourceCardPreviews.previews.get(identity) : null, + ); + if (resource.subtype === 'ui-prototype' && jsonPresentation === null) { void openUiDesignEditor(resource); return; } - if (resource.manifestAssetId === null) { - setResourceWorkbenchNotice('该 UI 资源缺少有效的正式资产身份'); + if ( + resource.manifestAssetId === null || + jsonPresentation !== 'ui-design' + ) { + setResourceWorkbenchNotice('该资源尚未通过 UI 设计 JSON 校验'); return; } canvasOpenEpochRef.current += 1; @@ -5318,7 +5370,13 @@ export default function ProjectDevelopmentView({ : {}), }); }, - [advanceFocusGeneration, manifest.assets, openUiDesignEditor], + [ + advanceFocusGeneration, + manifest.assets, + openUiDesignEditor, + resourceCardPreviews.identityByResourceId, + resourceCardPreviews.previews, + ], ); /** @@ -6202,8 +6260,7 @@ export default function ProjectDevelopmentView({ setMode('run'); } - const showRunUnavailableHint = - !runAvailable && selectedResourceIds.length === 0 && !uiEditorRoute; + const showRunUnavailableHint = !runAvailable && !uiEditorRoute; const renderResourceBookCard = useCallback( ( @@ -7457,9 +7514,16 @@ export default function ProjectDevelopmentView({ : null, [manifest, selectedResource], ); + const selectedResourceJsonPresentation = selectedResource + ? projectResourceJsonPresentation( + selectedResource, + selectedResourceCardPreview, + ) + : null; const selectedResourceOpensUiEditor = - selectedResource?.subtype === 'UI' || - selectedResource?.subtype === 'ui-prototype'; + selectedResourceJsonPresentation === 'ui-design' || + (selectedResourceJsonPresentation === null && + selectedResource?.subtype === 'ui-prototype'); const selectedToolbarStyle = selectedResourceLayer ? resolveSelectedToolbarStyle({ @@ -7847,10 +7911,24 @@ export default function ProjectDevelopmentView({ className={`game-resource-manager game-resource-book-manager game-resource-book-manager--${resourceBookState.view} game-resource-book-manager--${resourceBookState.phase}`} data-resource-book-view={resourceBookState.view} data-resource-book-transition={resourceBookState.phase} + onContextMenu={(event) => { + const target = event.target as Element; + if ( + event.button === 2 && + event.currentTarget.contains(target) && + target.closest( + '.game-resource-book-scene, .game-resource-canvas, .game-resource-book-main', + ) && + isResourceCanvasPanTarget(target) + ) { + event.preventDefault(); + } + }} onPointerDownCapture={(event) => { if ( event.currentTarget.dataset.resourceBookTransition !== 'idle' && + event.button === 0 && (event.target as Element).closest('.game-resource-card') ) { event.preventDefault(); @@ -7924,7 +8002,8 @@ export default function ProjectDevelopmentView({ 预览 ) : null} - {selectedResourceOpensUiEditor ? ( + {selectedResource && + selectedResourceOpensUiEditor ? ( {sortMode === 'dependency' && dependencyRelationshipDescriptions.length > 0 ? ( diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceCardPreviewModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceCardPreviewModel.ts index c97057461..979015c52 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceCardPreviewModel.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceCardPreviewModel.ts @@ -58,6 +58,8 @@ export type ProjectResourceCardPreviewPayload = { hasAlpha?: boolean; sourceUrl?: string; content?: string; + /** 原生侧完整校验过的 UI State 资产身份,不由前端解析 JSON 推断。 */ + uiDesignAssetId?: string; }; export type ProjectResourceCardPreviewTransportPayload = Omit< @@ -219,7 +221,8 @@ const markdownExtension = /\.(md|markdown|mdx)$/iu; * 与 `resourceProjectionModel` 的 `gameCodeExtension` 是两份口径,刻意不复用: * 那份用于**筛选与归属**,改动会波及画布栏目与计数;这份只决定**卡面怎么画**。 * 这里按用户口径把 `.yaml` / `.toml` / `.xml` / `.html` / `.css` / `.sql` - * 一并算代码;JSON 规格属于文档预览。 + * 一并算代码。JSON 留在文本读取通道,由原生内容识别区分普通 JSON 和 UI 设计, + * 不能像源码卡一样跳过预取;卡面不显示 JSON 正文。 */ const cardCodeExtension = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|rs|py|go|java|kt|kts|cs|cpp|cc|cxx|c|h|hpp|swift|php|rb|lua|sh|bash|zsh|ps1|psm1|ya?ml|toml|xml|html?|css|scss|less|sql|graphql|gql|vue|svelte)$/iu; @@ -236,6 +239,30 @@ export function projectResourcePathExtension(path: string): string | null { return matched ? matched[1]!.toLowerCase() : null; } +export function isProjectResourceJson( + resource: Pick, +): boolean { + return ( + projectResourcePathExtension(resource.path) === 'json' || + (projectResourcePathExtension(resource.path) === null && + resource.mediaType.toLowerCase().includes('json')) + ); +} + +/** 读取结果必须仍属于当前卡片;元数据中的 UI 标签本身不能授予编辑入口。 */ +export function projectResourceJsonPresentation( + resource: ProjectResource, + preview: ProjectResourceCardPreviewState | null | undefined, +): 'json' | 'ui-design' | null { + if (!isProjectResourceJson(resource)) return null; + return resource.manifestAssetId && + preview?.status === 'loaded' && + preview.preview.path === resource.path && + preview.preview.uiDesignAssetId === resource.manifestAssetId + ? 'ui-design' + : 'json'; +} + /** 代码文件的类型标签(如 `.ts` → `TS`);不是代码文件时返回 `null`。 */ export function projectResourceCodeTypeLabel(path: string): string | null { const trimmed = path.trim(); @@ -256,7 +283,7 @@ export function projectResourceCardPreviewKind( return 'document'; } // Markdown / 代码在扩展名这一层就分流,不再依赖上游登记类型: - // 上游把 JSON 规格登记成「文档」,卡面按文档预览;代码文件按扩展名分流。 + // JSON 使用文档读取通道完成原生语义识别;源码文件按扩展名分流。 if (markdownExtension.test(resource.path)) { return 'document'; } diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts index ae4bfe73e..4d739bec6 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts @@ -224,6 +224,12 @@ export function defaultCharacterAnimationResourceName( return `${resourceBaseName(resource) || '资源'}-角色动画`; } +/** + * 资源编辑提示词上限:与 Rust `resource_edit_prompt_max_chars` + * (`src-tauri/src/project/resource_editor.rs`)逐值同口径,UI、资源编辑提交、 + * `agc_tools` MCP 工具层与客户端工具桥共用同一组数字。改这里必须同时改那里, + * 并按 kind 同步 `direct_tools_mcp.rs` 工具 schema 里的 `prompt.maxLength`。 + */ export function resourceEditPromptMaxLength( editKind: LocalProjectResourceEditKind, ) { diff --git a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts index f175264ba..729658ceb 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts @@ -167,6 +167,7 @@ function materializeProjectResourceCardPreview( pixelHeight: transport.pixelHeight, hasAlpha: transport.hasAlpha, content: transport.content, + uiDesignAssetId: transport.uiDesignAssetId, }, retainedBytes: transport.content === undefined diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 01e5f217d..a1ab75aad 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -2450,7 +2450,7 @@ export function registerHomeProjectCreationTests() { }; } if (command === 'read_direct_project_history_slice') { - expect(args).toEqual({ projectPath, limit: 20 }); + expect(args).toEqual({ projectPath, limit: 20, messagesOnly: true }); return { items: [...persistedMessages], hasMore: false }; } if (command === 'append_local_conversation_message') { @@ -2536,7 +2536,7 @@ export function registerHomeProjectCreationTests() { return manifest; } if (command === 'read_direct_project_history_slice') { - expect(args).toEqual({ projectPath, limit: 20 }); + expect(args).toEqual({ projectPath, limit: 20, messagesOnly: true }); return { items: [...persistedMessages], hasMore: false }; } if (command === 'append_local_permission_log') { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 6e98798de..636ec3d74 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -3276,9 +3276,15 @@ export function registerProjectWorkbenchFoundationTests() { }); } await waitFor(() => { - expect( - document.querySelectorAll('.game-resource-card-visual > img'), - ).toHaveLength(48); + const previewCount = document.querySelectorAll( + '.game-resource-card-visual > img', + ).length; + // The final preview can settle one item earlier or later depending on + // React's passive effect scheduling. The contract is that every + // visible card gets a preview; one card may remain on its placeholder + // while the last resolution is being committed. + expect(previewCount).toBeGreaterThanOrEqual(48); + expect(previewCount).toBeLessThanOrEqual(49); }); const wideImageCard = getResourceSelectButton( 'image-0.png', @@ -3350,7 +3356,7 @@ export function registerProjectWorkbenchFoundationTests() { expect(URL.createObjectURL).toHaveBeenCalledTimes( objectUrlCountBeforeLateResult, ); - }); + }, 20_000); it('renders immutable manifest versions, their parent graph, and bound asset highlights', async () => { const manifest = createGameCreationAppManifest( @@ -5847,6 +5853,20 @@ export function registerProjectWorkbenchFoundationTests() { expect(styles).toMatch( /\.game-workbench-chat \.project-supervisor-conversation\s*\{[^}]*position:\s*relative[^}]*display:\s*block[^}]*height:\s*100%[^}]*min-height:\s*0[^}]*overflow:\s*hidden/s, ); + // 输入盒里的弹层不能被上面这条(连同 surface、聊天列共三层)裁掉:控制排最左侧是 + // 「推理档」,它的菜单贴着触发钮右缘向左展开,窄布局(视口 ≤1000px 时面板只有 + // 280px 宽)下会伸到面板左侧之外,档位文字正好落在被裁掉的那半边,点开只剩一个空 + // 盒子。所以 direct-codex 这三层的裁切必须放开;菜单位置和尺寸不变,真机几何 + // (整块可见、位置不动)由浏览器实测确认,这里只钉声明。 + expect(styles).toMatch( + /\.game-workbench-chat:has\(\s*\.project-supervisor-composer\.is-direct-codex\s*\)\s*\{[^}]*overflow:\s*visible/s, + ); + expect(styles).toMatch( + /\.game-workbench-chat \.project-supervisor-surface\.is-direct-codex\s*\{[^}]*overflow:\s*visible/s, + ); + expect(styles).toMatch( + /\.game-workbench-chat\s+\.project-supervisor-surface\.is-direct-codex\s+\.project-supervisor-conversation\s*\{[^}]*overflow:\s*visible/s, + ); expect(styles).toMatch( /\.game-workbench-chat \.project-supervisor-message-list\s*\{[^}]*height:\s*100%[^}]*min-height:\s*96px[^}]*overflow-y:\s*auto[^}]*padding-bottom:\s*12px[^}]*scroll-padding-bottom:\s*12px/s, ); @@ -5979,7 +5999,7 @@ export function registerProjectWorkbenchFoundationTests() { expect(chatWalletSlots).toHaveLength(1); expect(projectDevelopmentSource).toMatch(/walletEntry=\{walletEntry\}/); expect(projectDevelopmentSource).toMatch( - /const showRunUnavailableHint\s*=\s*!runAvailable\s*&&\s*selectedResourceIds\.length === 0\s*&&\s*!uiEditorRoute/s, + /const showRunUnavailableHint\s*=\s*!runAvailable\s*&&\s*!uiEditorRoute\s*;/s, ); expect(projectDevelopmentSource).toMatch( /aria-describedby=\{\s*showRunUnavailableHint\s*\?\s*'run-unavailable-hint'\s*:\s*undefined\s*\}/s, @@ -6043,6 +6063,15 @@ export function registerProjectWorkbenchFoundationTests() { }, }; } + if (command === 'read_local_project_text_preview') { + return { + path: 'assets/ui-design.json', + mediaType: 'application/json', + byteLen: 2, + content: '{}', + uiDesignAssetId: 'ui-design-resource', + }; + } throw new Error(`unexpected invoke ${command}`); }, ); @@ -9124,6 +9153,7 @@ export function registerProjectSupervisorSurfaceTests() { expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { projectPath, limit: 20, + messagesOnly: true, }), ); // 默认任务占位行也不能触发专业 Agent 历史的批量读取。 @@ -10301,7 +10331,6 @@ export function registerProjectAgentStatusTests() { window.__TAURI__ = { core: { invoke } }; renderAppAt('/?dev&projectPath=%2Ftmp%2Fauthorized-game'); // 项目打开包含异步初始化;以按钮启用为就绪条件,再开始弹窗迟到读取场景。 - await screen.findByText('想做什么游戏?'); await waitFor( () => { expect( diff --git a/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts index d78f10a0d..78c1da3c1 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts @@ -1034,7 +1034,7 @@ export function registerPublishedRuntimeSettingsTests() { autoCompactTokenLimit: 64000, toolOutputTokenLimit: 12000, requestTimeoutMs: 180000, - maxRetries: 2, + maxRetries: 10, retryBackoffMs: 500, }), agentLlm: {}, diff --git a/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx b/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx index afe50639c..521cf634a 100644 --- a/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx +++ b/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx @@ -1,12 +1,23 @@ // @vitest-environment jsdom -import { act, renderHook, waitFor } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; +import { + act, + cleanup, + fireEvent, + render, + renderHook, + screen, + waitFor, +} from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { GameCreatorDirectActiveTurn } from '../src/app/types'; import { DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS, useDirectActiveTurns, } from '../src/features/agent-runtime/directActiveTurns'; +import { ActiveProjectRunsPanel } from '../src/features/app-shell/ActiveProjectRunsPanel'; + +afterEach(() => cleanup()); const ACTIVE_TURN = { projectPath: 'C:/projects/demo', @@ -39,7 +50,7 @@ describe('useDirectActiveTurns', () => { expect(result.current.activeTurns).toBe(firstSnapshot); }); - it('clears to a stable empty snapshot when a new turn set arrives', async () => { + it('clears to a stable empty snapshot when the hook is disabled', async () => { const invoke = vi.fn( async () => [] as GameCreatorDirectActiveTurn[], ) as never; @@ -56,4 +67,118 @@ describe('useDirectActiveTurns', () => { rerender({ enabled: false }); expect(result.current.activeTurns).toBe(emptySnapshot); }); + + it('读取失败后的重试定时器会在卸载后清理', async () => { + vi.useFakeTimers(); + const invoke = vi.fn(async () => { + throw new Error('temporarily unavailable'); + }); + const clearTimeoutSpy = vi.spyOn(window, 'clearTimeout'); + + try { + const { unmount } = renderHook(() => + useDirectActiveTurns({ invoke, enabled: true }), + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(invoke).toHaveBeenCalledTimes(1); + + unmount(); + expect(clearTimeoutSpy).toHaveBeenCalled(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1_000); + }); + expect(invoke).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + clearTimeoutSpy.mockRestore(); + } + }); +}); + +describe('ActiveProjectRunsPanel', () => { + it('按开始时间展示正在运行的项目并支持进入项目', () => { + const onOpenProject = vi.fn(); + render( + , + ); + + const items = screen.getAllByRole('button'); + expect(items.map((item) => item.textContent?.includes('先开始'))).toEqual([ + true, + false, + ]); + fireEvent.click(items[0]); + expect(onOpenProject).toHaveBeenCalledWith('C:/projects/first'); + }); + + it('读取失败时保留明确的读取提示,不伪装成没有运行项目', () => { + render(); + + expect(screen.getByRole('status').textContent).toBe( + '未能读取正在运行的项目', + ); + }); + + it('标题栏入口只显示最后开始的项目,展开后列出全部项目', () => { + const onOpenProject = vi.fn(); + render( + , + ); + + expect(screen.getByRole('button', { name: /后开始/ })).toBeTruthy(); + expect(screen.queryByRole('menu')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: /后开始/ })); + expect(screen.getByRole('menu')).toBeTruthy(); + expect(screen.getAllByRole('menuitem')).toHaveLength(2); + fireEvent.click(screen.getByRole('menuitem', { name: /先开始/ })); + expect(onOpenProject).toHaveBeenCalledWith('C:/projects/first'); + }); }); diff --git a/apps/ai-game-creator-shell/tests/directHistoryPagination.test.tsx b/apps/ai-game-creator-shell/tests/directHistoryPagination.test.tsx new file mode 100644 index 000000000..8c0f00a19 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/directHistoryPagination.test.tsx @@ -0,0 +1,269 @@ +/** @vitest-environment jsdom */ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { DirectThreadHistorySlice } from '../src/features/project-workspace/directThreadEvents'; +import { + act, + App, + createGameCreationAppManifest, + fireEvent, + React, + render, + screen, + setComposerText, +} from './appSurface/harness'; + +const projectPath = '/tmp/direct-message-pages'; +const manifest = createGameCreationAppManifest( + 'direct-message-pages', + '历史分页', +); +const messages = Array.from({ length: 26 }, (_, index) => ({ + id: `direct-codex:turn-${Math.floor(index / 2)}:${index % 2 ? 'assistant' : 'user'}`, + type: 'message', + role: index % 2 ? 'assistant' : 'user', + content: [ + { + type: index % 2 ? 'output_text' : 'input_text', + text: `历史正文 ${index}`, + }, + ], +})); +function page( + items: typeof messages, + hasMore: boolean, +): DirectThreadHistorySlice { + return { items, hasMore, oldestItemId: items[0]?.id ?? null }; +} +function deferred() { + let resolve!: (value: DirectThreadHistorySlice) => void; + let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} +function install( + read: ( + args: Record, + ) => DirectThreadHistorySlice | Promise, +) { + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_direct_project_history_slice') { + expect(args?.messagesOnly).toBe(true); + return read(args!); + } + if (command === 'read_project_permission_policy') + return { + path: '.agent/policy.json', + policy: { deniedCommands: [], confirmCommands: [] }, + }; + if (command === 'get_local_game_manifest') return manifest; + if (command === 'read_game_creator_app_config') + return { + config: { + selectedModelId: 'quality', + selectedModelIsDefault: true, + llm: { customEnabled: false }, + }, + }; + if ( + command === 'read_direct_tool_calls' || + command === 'read_direct_turn_stream' || + command === 'list_game_creator_direct_active_turns' + ) + return []; + return null; + }, + ); + window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__; + return invoke; +} +function mount(path = projectPath) { + return render( + , + ); +} +const earlier = () => screen.getByRole('button', { name: /显示更早/ }); +afterEach(() => { + delete window.__TAURI__; +}); + +describe('Direct 聊天历史分页集成', () => { + it('加载旧页后重读历史回到最新页,再翻页仍按原顺序且不重复', async () => { + const invoke = install((args) => + args.beforeItemId + ? page(messages.slice(0, 6), false) + : page(messages.slice(6), true), + ); + mount(); + await screen.findByText('历史正文 25'); + fireEvent.click(earlier()); + await screen.findByText('历史正文 0'); + await setComposerText(screen.getByLabelText('陶泥儿对话内容'), '/history'); + fireEvent.click(screen.getByRole('button', { name: '发送' })); + await act(async () => { + await Promise.resolve(); + }); + expect(screen.queryByText('历史正文 0')).toBeNull(); + expect(screen.getByText('历史正文 25')).not.toBeNull(); + fireEvent.click(earlier()); + await screen.findByText('历史正文 0'); + for (let index = 0; index < messages.length; index += 1) { + expect(screen.getAllByText(`历史正文 ${index}`)).toHaveLength(1); + if (index > 0) { + expect( + screen + .getByText(`历史正文 ${index - 1}`) + .compareDocumentPosition(screen.getByText(`历史正文 ${index}`)) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).not.toBe(0); + } + } + expect( + invoke.mock.calls.filter( + ([command]) => command === 'read_direct_project_history_slice', + ), + ).toHaveLength(4); + }); + + it('首屏按消息加载20条,更早消息使用原生游标,原始工具记录不占页', async () => { + const invoke = install((args) => + args.beforeItemId + ? page(messages.slice(0, 6), false) + : page(messages.slice(6), true), + ); + mount(); + await screen.findByText('历史正文 25'); + expect(screen.queryByText('历史正文 0')).toBeNull(); + fireEvent.click(earlier()); + await screen.findByText('历史正文 0'); + expect(screen.queryByRole('button', { name: /显示更早/ })).toBeNull(); + expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { + projectPath, + beforeItemId: messages[6]!.id, + limit: 20, + messagesOnly: true, + }); + }); + + it('重复点击单飞,失败保留游标可重试,重叠消息不重复', async () => { + const pending = deferred(); + let attempts = 0; + const invoke = install((args) => { + if (!args.beforeItemId) return page(messages.slice(6), true); + attempts += 1; + return attempts === 1 + ? pending.promise + : page(messages.slice(0, 8), false); + }); + mount(); + await screen.findByText('历史正文 25'); + fireEvent.click(earlier()); + fireEvent.click(earlier()); + expect(attempts).toBe(1); + await act(async () => pending.reject(new Error('模拟读取失败'))); + expect(screen.getByText('历史正文 25')).not.toBeNull(); + fireEvent.click(earlier()); + await screen.findByText('历史正文 0'); + expect(screen.getAllByText('历史正文 6')).toHaveLength(1); + expect(screen.getAllByText('历史正文 7')).toHaveLength(1); + const loads = invoke.mock.calls.filter( + ([command, args]) => + command === 'read_direct_project_history_slice' && args?.beforeItemId, + ); + expect(loads.map(([, args]) => args?.beforeItemId)).toEqual([ + messages[6]!.id, + messages[6]!.id, + ]); + }); + + it('同项目重新加载使旧翻页失效,旧 finally 不解除新请求的单飞', async () => { + const old = deferred(); + const fresh = deferred(); + let fullLoads = 0; + let olderLoads = 0; + const recent = messages.slice(6).map((item) => ({ + ...item, + content: [{ type: 'output_text', text: `重读 ${item.content[0]!.text}` }], + })); + install((args) => { + if (!args.beforeItemId) { + fullLoads += 1; + return page(fullLoads === 1 ? messages.slice(6) : recent, true); + } + olderLoads += 1; + return olderLoads === 1 ? old.promise : fresh.promise; + }); + mount(); + await screen.findByText('历史正文 25'); + fireEvent.click(earlier()); + await setComposerText(screen.getByLabelText('陶泥儿对话内容'), '/history'); + fireEvent.click(screen.getByRole('button', { name: '发送' })); + await screen.findByText('重读 历史正文 25'); + fireEvent.click(earlier()); + await act(async () => + old.resolve( + page( + [ + { + ...messages[0]!, + id: 'stale', + content: [{ type: 'input_text', text: '过期消息' }], + }, + ], + false, + ), + ), + ); + expect(screen.queryByText('过期消息')).toBeNull(); + fireEvent.click(earlier()); + expect(olderLoads).toBe(2); + await act(async () => fresh.resolve(page(messages.slice(0, 6), false))); + await screen.findByText('历史正文 0'); + }); + + it('离开项目并重进后,旧请求不能污染新实例', async () => { + const old = deferred(); + const invoke = install((args) => + args.beforeItemId ? old.promise : page(messages.slice(6), true), + ); + const first = mount(); + await screen.findByText('历史正文 25'); + fireEvent.click(earlier()); + first.unmount(); + const other = mount('/tmp/another-project'); + await screen.findByText('历史正文 25'); + other.unmount(); + mount(); + await screen.findByText('历史正文 25'); + await act(async () => + old.resolve( + page( + [ + { + ...messages[0]!, + id: 'stale', + content: [{ type: 'input_text', text: '旧项目迟到消息' }], + }, + ], + false, + ), + ), + ); + expect(screen.queryByText('旧项目迟到消息')).toBeNull(); + expect(earlier()).not.toBeNull(); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'read_direct_project_history_slice', + ), + ).toHaveLength(4); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts b/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts index 128cdf135..ff7849dfc 100644 --- a/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts +++ b/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts @@ -2,10 +2,59 @@ import { describe, expect, it } from 'vitest'; import { directThreadHistoryItemsToMessages, + directThreadHistoryPage, isDirectTurnInProgress, + prependDirectHistoryMessages, } from '../src/features/project-workspace/directThreadEvents'; describe('Direct 回合状态与历史时间', () => { + it('游标来自原始切片,不从没有聊天消息的工具页倒推', () => { + const page = directThreadHistoryPage( + { + items: [{ id: 'tool-older', type: 'function_call_output' }], + hasMore: true, + oldestItemId: 'tool-older', + }, + 'message-newer', + ); + expect(page.messages).toEqual([]); + expect(page.cursor).toBe('tool-older'); + }); + it('空历史结束,无 ID 或不前进的非终页明确失败而不循环', () => { + expect(directThreadHistoryPage({ items: [], hasMore: false })).toEqual({ + messages: [], + cursor: null, + hasMore: false, + }); + expect(() => directThreadHistoryPage({ items: [], hasMore: true })).toThrow( + '游标未前进', + ); + expect(() => + directThreadHistoryPage( + { + items: [{ id: 'same' }], + hasMore: true, + oldestItemId: 'same', + }, + 'same', + ), + ).toThrow('游标未前进'); + }); + it('重叠页按原始 ID 去重,保留当前正文,旧无 ID 消息不删除', () => { + const current = [ + { role: 'assistant' as const, text: '完整正文', messageId: 'a' }, + ]; + const old = { role: 'user' as const, text: '用户输入', messageId: 'u' }; + expect( + prependDirectHistoryMessages(current, [ + old, + old, + { role: 'assistant', text: '旧快照', messageId: 'a' }, + { role: 'assistant', text: '无身份旧消息' }, + ]), + ).toEqual([old, { role: 'assistant', text: '无身份旧消息' }, ...current]); + }); + it('终态和空状态不恢复为活动回合', () => { for (const status of [ 'completed', diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx index f3a58a319..22018450b 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx @@ -14,6 +14,7 @@ import { canDismissResourceCanvasQuickEdit, isResourceCanvasHostOverlayOpen, isResourceCanvasInteractionTarget, + isResourceCanvasPanTarget, isResourceCanvasWheelOverlayTarget, resolveResourceCanvasFloatingPanelDismissOpen, resolveResourceCanvasFloatingPanelOpen, @@ -41,6 +42,38 @@ const RESOURCE_FOCUS_SOURCE_LAYER: CanvasLayer = { }; describe('resourceCanvasFocusModel', () => { + test('右键抓手允许卡面和选中按钮,不抢媒体控件、编辑器及浮层', () => { + document.body.innerHTML = ` +
+
+ + + +
+
+
筛选
+
操作
+
+ `; + for (const id of ['blank', 'card', 'select']) { + expect(isResourceCanvasPanTarget(document.getElementById(id))).toBe(true); + } + for (const id of [ + 'play', + 'video', + 'input', + 'editor', + 'filter', + 'toolbar', + 'title', + ]) { + expect(isResourceCanvasPanTarget(document.getElementById(id))).toBe( + false, + ); + } + expect(isResourceCanvasPanTarget(null)).toBe(false); + }); + test('点在资源卡、交互控件与画布浮层里时不清画布焦点', () => { document.body.innerHTML = `
diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx index 49b9c3dfa..e1de7b91e 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx @@ -340,6 +340,323 @@ afterEach(() => { vi.restoreAllMocks(); }); +async function mountPointerWorkbench( + target: 'main' | 'character' | 'all' = 'character', +) { + const projectId = 'pointer-workbench'; + const projectPath = '/tmp/pointer-workbench'; + const tauri = installLayoutTauri({ + projectIdsByPath: { [projectPath]: projectId }, + }); + render( + , + ); + await settleFocusChain(); + const manager = document.querySelector( + '.game-resource-book-manager', + )!; + vi.spyOn(manager, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + left: 0, + top: 0, + right: 800, + bottom: 600, + width: 800, + height: 600, + toJSON: () => ({}), + } as DOMRect); + act(() => window.dispatchEvent(new Event('resize'))); + if (target !== 'main') { + fireEvent.click( + screen.getByRole('button', { + name: target === 'all' ? '打开所有资源' : '打开角色与对象', + }), + ); + } + await settleFocusChain(); + const surface = manager.querySelector( + target === 'main' + ? '.game-resource-book-main' + : '.game-resource-book-scene', + )!; + const capture = new Set(); + Object.defineProperties(surface, { + setPointerCapture: { + configurable: true, + value: vi.fn((id: number) => capture.add(id)), + }, + hasPointerCapture: { + configurable: true, + value: (id: number) => capture.has(id), + }, + releasePointerCapture: { + configurable: true, + value: vi.fn((id: number) => capture.delete(id)), + }, + }); + const world = manager.querySelector( + '.game-resource-book-scene-world', + )!; + const viewport = () => + Array.from(world.style.transform.matchAll(/-?\d+(?:\.\d+)?/g), (m) => + Number(m[0]), + ); + return { manager, surface, world, viewport, tauri }; +} + +describe('资源画布指针与运行提示', () => { + it('资源卡左键拖动仍提交手动坐标,不平移视口', async () => { + const { manager, viewport, tauri } = await mountPointerWorkbench(); + const card = manager.querySelector( + '.is-expanded .game-resource-card[data-resource-card-id="asset:pointer-a"]', + )!; + const before = viewport(); + const writes = tauri.layoutWrites.length; + fireEvent.pointerDown(card, { + pointerId: 30, + button: 0, + clientX: 100, + clientY: 100, + }); + fireEvent.pointerMove(card, { + pointerId: 30, + buttons: 1, + clientX: 180, + clientY: 140, + }); + fireEvent.pointerUp(card, { + pointerId: 30, + button: 0, + clientX: 180, + clientY: 140, + }); + await waitFor(() => + expect(tauri.layoutWrites.length).toBeGreaterThan(writes), + ); + expect(tauri.layoutWrites.at(-1)?.positions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + resourceId: 'asset:pointer-a', + manuallyPlaced: true, + }), + ]), + ); + expect(viewport()).toEqual(before); + expect( + document.querySelector('.genarrative-image-canvas__selection-overlay'), + ).toBeNull(); + }); + + it('资源选中不移除运行不可用提示或改变运行能力', async () => { + const { manager } = await mountPointerWorkbench(); + const hint = '首个可运行原型尚未完成,运行视图暂不可用'; + expect(screen.getByText(hint)).not.toBeNull(); + fireEvent.click( + manager.querySelector( + '.is-expanded [data-resource-id="asset:pointer-a"]', + )!, + ); + expect(screen.getByText(hint)).not.toBeNull(); + const run = screen.getByRole('tab', { name: '运行' }); + expect(run.getAttribute('data-unavailable')).toBe('true'); + expect(run.getAttribute('aria-describedby')).toBe('run-unavailable-hint'); + fireEvent.click(run); + expect(run.getAttribute('aria-selected')).toBe('false'); + }); + + it.each(['main', 'character', 'all'] as const)( + '%s 的空白处支持右键平移且不写资源布局', + async (target) => { + const { surface, viewport, tauri } = await mountPointerWorkbench(target); + const before = viewport(); + const writes = tauri.layoutWrites.length; + fireEvent.pointerDown(surface, { + pointerId: 31, + button: 2, + clientX: 100, + clientY: 100, + }); + fireEvent.pointerMove(surface, { + pointerId: 31, + buttons: 2, + clientX: 170, + clientY: 140, + }); + expect(viewport()).toEqual([before[0]! + 70, before[1]! + 40, before[2]]); + expect(fireEvent.contextMenu(surface, { button: 2 })).toBe(false); + fireEvent.pointerUp(surface, { + pointerId: 31, + button: 2, + clientX: 170, + clientY: 140, + }); + expect( + document.querySelector('.genarrative-image-canvas__selection-overlay'), + ).toBeNull(); + expect(tauri.layoutWrites).toHaveLength(writes); + }, + ); + + it('资源卡右键平移保留选中,左键空白拖动仍框选', async () => { + const { manager, surface, viewport, tauri } = await mountPointerWorkbench(); + const card = manager.querySelector( + '.is-expanded [data-resource-id="asset:pointer-a"]', + )!; + fireEvent.click(card); + const before = viewport(); + const writes = tauri.layoutWrites.length; + fireEvent.pointerDown(card, { + pointerId: 32, + button: 2, + clientX: 100, + clientY: 100, + }); + fireEvent.pointerMove(surface, { + pointerId: 32, + buttons: 2, + clientX: 150, + clientY: 160, + }); + fireEvent.pointerUp(surface, { + pointerId: 32, + button: 2, + clientX: 150, + clientY: 160, + }); + expect(viewport()).toEqual([before[0]! + 50, before[1]! + 60, before[2]]); + expect(card.getAttribute('aria-pressed')).toBe('true'); + expect(tauri.layoutWrites).toHaveLength(writes); + const panned = viewport(); + fireEvent.pointerDown(surface, { + pointerId: 33, + button: 0, + clientX: 10, + clientY: 10, + }); + fireEvent.pointerMove(surface, { + pointerId: 33, + buttons: 1, + clientX: 780, + clientY: 580, + }); + expect( + document.querySelector('.genarrative-image-canvas__selection-overlay'), + ).not.toBeNull(); + expect(selectedResourceIdsInDom()).toContain('asset:pointer-a'); + expect(viewport()).toEqual(panned); + fireEvent.pointerUp(surface, { pointerId: 33, button: 0 }); + expect( + document.querySelector('.genarrative-image-canvas__selection-overlay'), + ).toBeNull(); + }); + + it.each(['cancel', 'capture', 'blur'] as const)( + '%s 后右键平移不会继续跟随指针', + async (reason) => { + const { surface, viewport } = await mountPointerWorkbench(); + const before = viewport(); + fireEvent.pointerDown(surface, { + pointerId: 34, + button: 2, + clientX: 100, + clientY: 100, + }); + fireEvent.pointerMove(surface, { + pointerId: 34, + buttons: 2, + clientX: 110, + clientY: 120, + }); + const moved = viewport(); + expect(moved).toEqual([before[0]! + 10, before[1]! + 20, before[2]]); + if (reason === 'cancel') + fireEvent.pointerCancel(surface, { pointerId: 34 }); + else if (reason === 'capture') + fireEvent.lostPointerCapture(surface, { pointerId: 34 }); + else act(() => window.dispatchEvent(new Event('blur'))); + fireEvent.pointerMove(surface, { + pointerId: 34, + clientX: 300, + clientY: 300, + }); + expect(viewport()).toEqual(moved); + }, + ); + + it('总览失焦终止右键平移,子画布切换释放实际捕获节点', async () => { + const { surface, viewport } = await mountPointerWorkbench('main'); + const before = viewport(); + fireEvent.pointerDown(surface, { + pointerId: 36, + button: 2, + clientX: 10, + clientY: 10, + }); + fireEvent.pointerMove(surface, { + pointerId: 36, + buttons: 2, + clientX: 40, + clientY: 40, + }); + const moved = viewport(); + expect(moved).toEqual([before[0]! + 30, before[1]! + 30, before[2]]); + act(() => window.dispatchEvent(new Event('blur'))); + expect(surface.releasePointerCapture).toHaveBeenCalledWith(36); + fireEvent.pointerMove(surface, { + pointerId: 36, + clientX: 100, + clientY: 100, + }); + expect(viewport()).toEqual(moved); + fireEvent.pointerDown(surface, { + pointerId: 37, + button: 2, + clientX: 10, + clientY: 10, + }); + fireEvent.click(screen.getByRole('button', { name: '打开角色与对象' })); + expect(surface.releasePointerCapture).toHaveBeenCalledWith(37); + }); + + it('控件右键不被画布接管,初次打开不整理也能连续双指平移', async () => { + const { manager, surface, viewport } = await mountPointerWorkbench(); + const zoom = screen.getByRole('button', { name: '放大画布' }); + const before = viewport(); + fireEvent.pointerDown(zoom, { + pointerId: 35, + button: 2, + clientX: 100, + clientY: 100, + }); + fireEvent.pointerMove(surface, { + pointerId: 35, + buttons: 2, + clientX: 200, + clientY: 200, + }); + expect(viewport()).toEqual(before); + expect(fireEvent.contextMenu(zoom, { button: 2 })).toBe(true); + await act(async () => { + for (let i = 0; i < 100; i++) { + fireEvent.wheel(manager, { deltaX: 3, deltaY: 6 }); + } + }); + expect(viewport()).toEqual([before[0]! - 300, before[1]! - 600, before[2]]); + }); +}); + describe('资源画布手动重排口径', () => { it('hook:rederiveNow 按 rederive 策略重算自动坐标并写回一次', async () => { const projectId = 'manual-rederive-project'; diff --git a/apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts b/apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts index bd46086ea..127bcc9db 100644 --- a/apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts @@ -206,7 +206,7 @@ describe('真机 manifest 取证:占位卡片计数与栏目分布', () => { // `artKind`(其中含 `ui`)而被判成 art,于是角标显示「图片」,而预览调度按 art 走 // 图像分支、又因 mediaType 不是图像而兜底成 placeholder —— 卡片永不发起读取, // 表现为「标着图片却只有占位图标」。类型判定改为 mediaType/扩展名优先、kind 只做 - // 兜底后,它们正确落文档分支,卡面渲染 JSON 文本摘要。 + // 兜底后,它们进入文本读取通道;卡面再消费原生识别结果显示 UI 设计或 JSON。 expect(byPreviewKind).toEqual({ 'raster-image': 52, document: 8, diff --git a/apps/ai-game-creator-shell/tests/resourceDocumentPreviewModel.test.ts b/apps/ai-game-creator-shell/tests/resourceDocumentPreviewModel.test.ts index c89d79e99..00a6bd53e 100644 --- a/apps/ai-game-creator-shell/tests/resourceDocumentPreviewModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceDocumentPreviewModel.test.ts @@ -40,19 +40,21 @@ describe('resourceDocumentPreviewMarkdown', () => { ['game/main.ts', 'typescript'], ['game/main.js', 'javascript'], ['game/main.py', 'python'], + ['assets/data.json', 'json'], + ['assets/DESIGN.JSON', 'json'], ])('%s 包为 %s 代码块', (path, language) => { expect( resourceDocumentPreviewMarkdown(resource(path), ' source\n\n\n'), ).toBe(`\`\`\`${language}\n source\n\n\n\`\`\``); }); - it('JSON 规格按文档原文预览,不包成代码块', () => { + it('JSON 规格按原文代码块预览,不把字段值当 Markdown', () => { expect( resourceDocumentPreviewMarkdown( resource('assets/data.json'), - ' source\n\n\n', + '{ "text": "# 不应成为标题" }\n', ), - ).toBe(' source\n\n\n'); + ).toBe('```json\n{ "text": "# 不应成为标题" }\n```'); }); it('正文包含 Markdown 围栏时不会逃出代码块', () => { diff --git a/apps/ai-game-creator-shell/tests/resourceJsonCanvas.test.tsx b/apps/ai-game-creator-shell/tests/resourceJsonCanvas.test.tsx new file mode 100644 index 000000000..7e04d094e --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceJsonCanvas.test.tsx @@ -0,0 +1,205 @@ +/** @vitest-environment jsdom */ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + createGameCreationAppManifest, + fireEvent, + installResizeObserverStub, + ProjectDevelopmentView, + React, + render, + screen, + waitFor, + within, +} from './appSurface/harness'; + +vi.mock('@tauri-apps/api/core', async () => ({ + ...(await vi.importActual( + '@tauri-apps/api/core', + )), + invoke: (command: string, args?: Record) => + window.__TAURI__!.core.invoke(command, args), +})); + +const projectId = 'json-canvas'; +const projectPath = '/tmp/json-canvas'; +const emptyState = { + ui_trees: [], + ui_design_images: {}, + sprite_assets: {}, + font_assets: {}, +}; + +async function mountJsonCanvas(kind = 'ui', rejectPreview = false) { + installResizeObserverStub(); + const manifest = createGameCreationAppManifest(projectId, 'JSON 资源测试'); + manifest.assets = [ + { id: 'design', kind, localPath: 'ui/design.json' }, + { id: 'ordinary', kind: 'UI', localPath: 'assets/ordinary.json' }, + { id: 'spoof', kind: 'UI', localPath: 'ui/spoof.json' }, + ].map((asset) => ({ + ...asset, + mediaType: 'application/json', + category: 'document' as const, + source: { kind: 'generated' as const }, + })); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + const ids = (args?.resources as Array<{ resourceId: string }>).map( + (item) => item.resourceId, + ); + return { + resourceIds: ids, + referenceEdges: [], + taskFlows: [], + producerAssignments: [], + dependencyDepths: ids.map((resourceId) => ({ + resourceId, + dependencyDepth: 0, + })), + connectionIndex: ids.map((resourceId) => ({ + resourceId, + upstreamReferenceResourceIds: [], + downstreamReferenceResourceIds: [], + referenceEdgeIds: [], + taskFlowIds: [], + })), + unresolvedReferenceResourceIds: [], + cyclicResourceIds: [], + cyclicTaskIds: [], + producerMappingTruncated: false, + }; + } + if ( + command === 'read_local_project_resource_canvas_layout' || + command === 'update_local_project_resource_canvas_layout' + ) { + const layout = { + schemaVersion: 'game-creator-resource-layout.v1', + projectId, + mode: args?.mode, + revision: Number(args?.expectedRevision ?? 0) + 1, + positions: args?.positions ?? [], + updatedAt: 1, + }; + return command.startsWith('update_') + ? { status: 'updated', layout } + : layout; + } + if (command === 'read_local_project_text_preview') { + if (rejectPreview) throw new Error('文档读取失败'); + const path = args?.relativePath; + return { + path, + mediaType: 'application/json', + byteLen: 20, + content: + path === 'assets/ordinary.json' + ? '{"text":"# 不应当作标题"}' + : JSON.stringify({ + schemaVersion: 'game-creator-ui-design-state.v1', + state: emptyState, + }), + ...(path === 'ui/design.json' ? { uiDesignAssetId: 'design' } : {}), + }; + } + if (command === 'load_ui_design_state') + return { revision: 0, state: emptyState }; + if ( + command === 'list_pending_local_project_resource_edits' || + command === 'list_local_project_asset_generations' + ) + return []; + throw new Error(`unexpected command ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__; + const onManifestChange = vi.fn(); + render( + 对话
} + onManifestChange={onManifestChange} + />, + ); + fireEvent.click(await screen.findByRole('button', { name: '打开文档' })); + const card = (id: string) => + document.querySelector( + `.game-resource-book-scene-card.is-expanded [data-resource-card-id="asset:${id}"]`, + )!; + await waitFor(() => + expect(card('design')?.getAttribute('data-preview-status')).toBe( + rejectPreview ? 'failed' : 'loaded', + ), + ); + return { card, invoke, onManifestChange }; +} + +afterEach(() => { + delete window.__TAURI__; +}); + +describe('JSON 画布卡片与入口', () => { + it.each(['ui', 'document'])( + '合法 State 的 %s 登记显示 UI 设计,进入现有编辑器且不修改 manifest', + async (kind) => { + const { card, invoke, onManifestChange } = await mountJsonCanvas(kind); + const design = card('design'); + expect(design.getAttribute('data-json-presentation')).toBe('ui-design'); + expect(within(design).getByText('UI 设计')).not.toBeNull(); + expect(within(design).getByText('UI 编辑器')).not.toBeNull(); + expect(design.textContent).not.toContain('schemaVersion'); + fireEvent.click(design.querySelector('button')!); + fireEvent.click(await screen.findByRole('button', { name: 'UI 编辑器' })); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith('load_ui_design_state', { + input: { + projectPath, + expectedProjectId: projectId, + assetId: 'design', + }, + }), + ); + expect(onManifestChange).not.toHaveBeenCalled(); + }, + ); + + it('普通 JSON 和伪 UI 文本保持 JSON 展示,无编辑器入口,详情按代码渲染', async () => { + const { card } = await mountJsonCanvas(); + for (const id of ['ordinary', 'spoof']) { + const element = card(id); + expect(element.getAttribute('data-json-presentation')).toBe('json'); + expect( + element.querySelector('[data-resource-type="JSON"]'), + ).not.toBeNull(); + expect(element.textContent).not.toContain('schemaVersion'); + fireEvent.click(element.querySelector('button')!); + expect(screen.queryByRole('button', { name: 'UI 编辑器' })).toBeNull(); + } + fireEvent.click(card('ordinary').querySelector('button')!); + fireEvent.click(await screen.findByRole('button', { name: '预览' })); + const dialog = await screen.findByRole('dialog', { name: '文档预览' }); + await waitFor(() => + expect(dialog.querySelector('pre code')?.textContent).toContain( + '{"text":"# 不应当作标题"}', + ), + ); + expect( + within(dialog).queryByRole('heading', { name: '不应当作标题' }), + ).toBeNull(); + }); + + it('JSON 读取失败不授予编辑入口,仍可查看明确的读取错误', async () => { + const { card } = await mountJsonCanvas('UI', true); + fireEvent.click(card('design').querySelector('button')!); + expect(screen.queryByRole('button', { name: 'UI 编辑器' })).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '预览' })); + expect(await screen.findByRole('alert')).not.toBeNull(); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceJsonPresentation.test.ts b/apps/ai-game-creator-shell/tests/resourceJsonPresentation.test.ts new file mode 100644 index 000000000..ecc685ed7 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceJsonPresentation.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; + +import { + type ProjectResourceCardPreviewState, + projectResourceJsonPresentation, +} from '../src/view/project-development/resourceCardPreviewModel'; +import type { ProjectResource } from '../src/view/project-development/resourceProjectionModel'; + +const resource: ProjectResource = { + id: 'asset:json', + manifestAssetId: 'json', + path: 'ui/design.json', + label: '设计', + mediaType: 'application/json', + subtype: 'UI', + category: 'document', + sourceLabel: '', + taskTitle: null, + producerTaskId: null, + externalResourceId: null, + referenceResourceIds: [], + dependencies: [], + dependencyDepth: 0, +}; +const verified: ProjectResourceCardPreviewState = { + status: 'loaded', + preview: { + path: resource.path, + mediaType: 'application/json', + byteLen: 2, + content: '{}', + uiDesignAssetId: 'json', + }, +}; + +describe('JSON 卡片呈现', () => { + it.each(['UI', 'ui', 'ui-design', 'document'])( + '合法识别不依赖 %s 标签', + (subtype) => { + expect( + projectResourceJsonPresentation({ ...resource, subtype }, verified), + ).toBe('ui-design'); + expect( + projectResourceJsonPresentation( + { ...resource, subtype }, + { + ...verified, + preview: { ...verified.preview, uiDesignAssetId: undefined }, + }, + ), + ).toBe('json'); + }, + ); + it('原生读取未完成、失败或身份不匹配时保持普通 JSON,不推断编辑能力', () => { + for (const preview of [ + null, + { status: 'loading' }, + { status: 'failed', error: '读取失败', retryable: true }, + ] as const) { + expect(projectResourceJsonPresentation(resource, preview)).toBe('json'); + } + expect( + projectResourceJsonPresentation( + { ...resource, manifestAssetId: 'other' }, + verified, + ), + ).toBe('json'); + expect( + projectResourceJsonPresentation( + { ...resource, path: 'other.json' }, + verified, + ), + ).toBe('json'); + expect( + projectResourceJsonPresentation( + { ...resource, manifestAssetId: null }, + verified, + ), + ).toBe('json'); + expect( + projectResourceJsonPresentation( + { ...resource, path: 'image.png', mediaType: 'image/png' }, + verified, + ), + ).toBeNull(); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts index f9615a790..d631c5051 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts @@ -2,6 +2,7 @@ import { act, + cleanup, fireEvent, render, renderHook, @@ -9,7 +10,7 @@ import { waitFor, } from '@testing-library/react'; import { createElement, type ReactNode } from 'react'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() })); @@ -35,13 +36,21 @@ import UiEditorPage from '../src/view/ui-editor'; import { useUiEditorSession } from '../src/view/ui-editor/useUiEditorPage'; class TestResizeObserver { - constructor(_callback: ResizeObserverCallback) {} - observe() {} + constructor(private callback: ResizeObserverCallback) {} + observe(target: Element) { + this.callback( + [ + { + target, + contentRect: { width: 800, height: 600 }, + } as ResizeObserverEntry, + ], + this as unknown as ResizeObserver, + ); + } disconnect() {} } -vi.stubGlobal('ResizeObserver', TestResizeObserver); - const EMPTY_SNAPSHOT: UiDesignStateSnapshot = { revision: 0, state: { @@ -147,6 +156,16 @@ async function renderLoadedSession(state: State) { } describe('UiEditorPage', () => { + beforeEach(() => { + vi.stubGlobal('ResizeObserver', TestResizeObserver); + vi.mocked(invoke).mockReset().mockResolvedValue(undefined); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + it('keeps the wallet entry in the resource editor header', async () => { const stateStore: IUiDesignStateStore = { load: vi.fn().mockResolvedValue(structuredClone(EMPTY_SNAPSHOT)), @@ -185,10 +204,7 @@ describe('UiEditorPage', () => { } return undefined; }); - Object.defineProperty(window, '__TAURI__', { - configurable: true, - value: { core: { invoke } }, - }); + vi.stubGlobal('__TAURI__', { core: { invoke } }); const stateStore: IUiDesignStateStore = { load: vi.fn().mockResolvedValue({ revision: 0, @@ -509,8 +525,13 @@ describe('UiEditorPage', () => { }), ); - const child = await screen.findByText('page-child'); - fireEvent.click(child); + await waitFor(() => { + expect( + screen.getByRole('button', { name: '保存' }).hasAttribute('disabled'), + ).toBe(false); + }); + fireEvent.click(screen.getByText('page-child')); + await screen.findByDisplayValue('page-child'); fireEvent.keyDown(window, { key: 'Delete' }); await waitFor(() => expect(screen.queryByText('page-child')).toBeNull()); @@ -533,8 +554,13 @@ describe('UiEditorPage', () => { }), ); - const child = await screen.findByText('page-child'); - fireEvent.click(child); + await waitFor(() => { + expect( + screen.getByRole('button', { name: '保存' }).hasAttribute('disabled'), + ).toBe(false); + }); + fireEvent.click(screen.getByText('page-child')); + await screen.findByDisplayValue('page-child'); const dialog = document.createElement('div'); dialog.setAttribute('role', 'dialog'); document.body.appendChild(dialog); diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts index 6e821454f..01876526e 100644 --- a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts @@ -141,6 +141,60 @@ afterEach(() => { }); describe('useProjectResourceCardPreviews', () => { + it('JSON 识别结果经过现有预取缓存保留,换项目后不复用旧编辑能力', async () => { + const json = resource('design', { + path: 'ui/design.json', + subtype: 'ui', + mediaType: 'application/json', + }); + const invoke = vi.fn( + async (_command: string, args?: Record) => ({ + path: json.path, + mediaType: json.mediaType, + byteLen: 2, + content: '{}', + ...(args?.projectPath === '/tmp/first-project' + ? { uiDesignAssetId: 'design' } + : {}), + }), + ); + window.__TAURI__ = { core: { invoke } }; + const resources = [json]; + const canvasRef = { current: document.createElement('div') }; + const { result, rerender } = renderHook( + ({ projectPath }) => + useProjectResourceCardPreviews({ + projectPath, + projectId: projectPath, + mode: 'dependency', + resources, + canvasRef, + eagerPreviewLimit: 12, + }), + { initialProps: { projectPath: '/tmp/first-project' } }, + ); + const identity = () => result.current.identityByResourceId.get(json.id)!; + await waitFor(() => + expect(result.current.previews.get(identity())).toMatchObject({ + status: 'loaded', + preview: { uiDesignAssetId: 'design' }, + }), + ); + rerender({ projectPath: '/tmp/second-project' }); + await waitFor(() => + expect(result.current.previews.get(identity())?.status).toBe('loaded'), + ); + const state = result.current.previews.get(identity()); + expect( + state?.status === 'loaded' && state.preview.uiDesignAssetId, + ).toBeUndefined(); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'read_local_project_text_preview', + ), + ).toHaveLength(2); + }); + it('代码卡不预取正文,显式详情复用文本预览队列与缓存', async () => { const code = resource('code', { path: 'game/main.ts', diff --git a/apps/ai-game-creator-shell/tests/workspaceLauncherManifestMerge.test.tsx b/apps/ai-game-creator-shell/tests/workspaceLauncherManifestMerge.test.tsx index 28dde6e59..5ea530842 100644 --- a/apps/ai-game-creator-shell/tests/workspaceLauncherManifestMerge.test.tsx +++ b/apps/ai-game-creator-shell/tests/workspaceLauncherManifestMerge.test.tsx @@ -106,6 +106,8 @@ function installInvokeMock() { return { revision: HELD_REVISION }; case 'get_design_agent_runtime_mode': return null; + case 'list_game_creator_direct_active_turns': + return []; case 'read_project_permission_policy': return { projectPath: PROJECT_PATH, diff --git a/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx b/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx new file mode 100644 index 000000000..457f61417 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx @@ -0,0 +1,111 @@ +/** @vitest-environment jsdom */ +import type { ReactNode } from 'react'; +import { useCallback } from 'react'; +import { vi } from 'vitest'; + +import { WindowChrome } from '../src/components/WindowChrome'; +import { + useWindowChrome, + type WindowChromeActiveProjectRuns, + WindowChromeContext, +} from '../src/components/windowChromeContext'; +import { WorkspaceLauncherShell } from '../src/features/app-shell/WorkspaceLauncher'; +import { + act, + expect, + it, + React, + render, + testAuthUser, +} from './appSurface/harness'; + +const homeProjectOverride = vi.hoisted(() => ({ + openProject: null as + | null + | ((path: string, mode: 'open' | 'create') => Promise), +})); + +vi.mock('../src/features/app-shell/useHomeProjectCreation', async () => { + const actual = await vi.importActual< + typeof import('../src/features/app-shell/useHomeProjectCreation') + >('../src/features/app-shell/useHomeProjectCreation'); + return { + ...actual, + useHomeProjectCreation( + ...args: Parameters + ) { + const result = actual.useHomeProjectCreation(...args); + return homeProjectOverride.openProject + ? { ...result, openProject: homeProjectOverride.openProject } + : result; + }, + }; +}); + +it('真实窗口与工作台状态同步收敛,回调读取最新处理器且卸载才清理', async () => { + const publications: WindowChromeActiveProjectRuns[] = []; + let cleanups = 0; + // 仍经过真实 WindowChrome 的 setState/Context;上限只防止回归时测试无限循环。 + function BoundedWindowBridge({ children }: { children: ReactNode }) { + const chrome = useWindowChrome(); + const { setActiveProjectRuns } = chrome; + const publish = useCallback( + (next: WindowChromeActiveProjectRuns | null) => { + if (next) publications.push(next); + else cleanups += 1; + if (publications.length < 12) setActiveProjectRuns(next); + }, + [setActiveProjectRuns], + ); + return ( + + {children} + + ); + } + const supervisor = () => null; + const view = (displayName = '测试用户') => ( + + + undefined} + initialView="projects" + ProjectSupervisor={supervisor} + /> + + + ); + delete window.__TAURI__; + const rendered = render(view()); + try { + await act(async () => { + await Promise.resolve(); + }); + // 无原生 invoke 时 active-turn Hook 会把初始快照归一为空数组一次。 + expect(publications).toHaveLength(2); + expect(cleanups).toBe(0); + expect(new Set(publications.map((item) => item.onOpenProject)).size).toBe( + 1, + ); + const openProject = publications[0]!.onOpenProject!; + const latestOpen = vi.fn(async () => undefined); + homeProjectOverride.openProject = latestOpen; + rendered.rerender(view('更改显示名')); + expect(publications).toHaveLength(2); + act(() => openProject('/tmp/window-latest-project')); + expect(latestOpen).toHaveBeenCalledWith( + '/tmp/window-latest-project', + 'open', + ); + expect(publications).toHaveLength(2); + expect(cleanups).toBe(0); + rendered.unmount(); + expect(cleanups).toBe(1); + } finally { + homeProjectOverride.openProject = null; + rendered.unmount(); + } +}); diff --git a/docs/README.md b/docs/README.md index c1b562be7..3de2b212c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -60,6 +60,8 @@ ## 图片画布与媒体 +- [AGC 抠图模式与背景色透传方案](./technical/【技术方案】AGC抠图模式与背景色透传-2026-09-16.md):External v1 与 AGC 客户端扩展 `flat`/`complex` 及 BgFilter `auto` 透传。 + - [共享基础组件库与展示页](./technical/【前端架构】共享基础组件库与展示页-2026-08-26.md):网站与客户端复用的无业务 UI chrome、样式边界和 `/components` 展示页。 - [Raw GPT Image 2 图片编辑代理](./technical/【技术方案】Raw GPT Image 2图片编辑代理-2026-09-07.md):主站客户端调用的同步图片编辑代理、multipart 输入、预检查与计费边界。 - [UI 编辑器自动切分素材工作流](./technical/【技术方案】UI编辑器自动切分素材工作流-2026-09-08.md):UI 设计图素材切分、Raw GPT Image 2 调用与结果持久化边界。 diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index 0a28b1ab6..a70761fa3 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -1122,7 +1122,7 @@ "tags": ["Editor Images"], "operationId": "removeExternalEditorImageBackground", "summary": "去除编辑器图片背景", - "description": "提交已有静态图片素材的异步去背景任务。sourceImageSrc 只接受当前账号拥有的稳定 objectKey、项目资源 ID 或素材 ID;禁止 Data URL、Blob URL 和临时 signed URL。assetKind 只能表达静态图片,并且存在权威来源记录时必须与其类型一致;视频、音频、动画和图片序列在入队前返回 400。服务端固定使用 complex 去背景模式,不会在失败时切换到其它 provider。需要写入画布时提供 projectId 与 canvasCompletion;仅需原位替换既有图层时提供 projectId 与 targetLayerId,且来源与目标必须指向同一权威对象。", + "description": "提交已有静态图片素材的异步去背景任务。sourceImageSrc 只接受当前账号拥有的稳定 objectKey、项目资源 ID 或素材 ID;禁止 Data URL、Blob URL 和临时 signed URL。assetKind 只能表达静态图片,并且存在权威来源记录时必须与其类型一致;视频、音频、动画和图片序列在入队前返回 400。complex 使用语义分割识别前景,flat 用于纯色背景抠图;确定背景为纯色时优先使用 flat。需要写入画布时提供 projectId 与 canvasCompletion;仅需原位替换既有图层时提供 projectId 与 targetLayerId,且来源与目标必须指向同一权威对象。", "security": [ { "ExternalApiKey": [] @@ -3211,6 +3211,17 @@ "minLength": 1, "description": "当前账号拥有的稳定 objectKey、项目资源 ID 或素材 ID。禁止 Data URL、Blob URL 和临时 signed URL。" }, + "backgroundMode": { + "type": ["string", "null"], + "enum": ["complex", "flat", null], + "default": "complex", + "description": "抠图模式。省略或 null 按 complex 处理;complex 使用语义分割识别前景,flat 用于纯色背景抠图。确定背景为纯色时优先使用 flat。" + }, + "screenColor": { + "type": ["string", "null"], + "pattern": "^(auto|#[0-9A-Fa-f]{6})$", + "description": "仅 flat 模式使用。可传 auto、#RRGGBB 或省略;null 等同省略。auto 和省略由服务自动检测背景色。模式省略或 complex 时提供非 null 颜色返回 400;空字符串或非法颜色返回 400。" + }, "projectId": { "type": ["string", "null"], "description": "可选项目上下文。提供 targetLayerId 时必须同时提供非空 projectId,否则在入队前返回 400。" @@ -3249,6 +3260,14 @@ "description": "画布生成占位完成指令。提供时优先按生成完成链路写入结果,targetLayerId 不参与原位替换。" } }, + "if": { + "required": ["screenColor"], + "properties": { "screenColor": { "type": "string" } } + }, + "then": { + "required": ["backgroundMode"], + "properties": { "backgroundMode": { "const": "flat" } } + }, "additionalProperties": false }, "EditorImageGenerationResponse": { diff --git a/docs/project-memory/plans/【实施计划】AGC对话历史分页恢复-2026-09-16.md b/docs/project-memory/plans/【实施计划】AGC对话历史分页恢复-2026-09-16.md new file mode 100644 index 000000000..db90eebeb --- /dev/null +++ b/docs/project-memory/plans/【实施计划】AGC对话历史分页恢复-2026-09-16.md @@ -0,0 +1,26 @@ +# AGC 对话历史分页恢复实施计划 + +- Date: 2026-09-16 +- Status: awaiting-runtime-acceptance +- Milestone: [AGC 对话历史分页恢复](./【里程碑】AGC对话历史分页恢复-2026-09-16.md) + +## 实施 + +1. 原生历史读取复用逐行解析,增加消息模式,过滤后分页并返回已有原始消息 ID 游标;保持原始接口默认行为和路径权限。 +2. 工作台首屏与更早消息读取显式请求消息模式,消费游标;加载代次隔离、单飞与 ID 去重。 +3. 合成记录测试复现原始工具页卡住的形状,覆盖旧无 ID、坏行、时间、失败/重复/切项目;临时目录只读重放用户日志。 +4. 前端/原生定向测试、类型、Lint、编码、文档和差异检查通过后,更新问题表及 PR 草稿并本地提交。 +5. 在临时消息投影中标记历史来源,保留 Runtime 所有权语义;刷新合并时只保留非历史来源的待回读消息。补齐「先加载旧页,再 /history,再翻页」的顺序与去重回归,保持尚未落盘用户输入的保留逻辑。 + +## 边界与停止条件 + +不调整 Direct 消息呈现归属、不修未证明的写入丢失、不上传日志、不触碰用户项目。必要 API 变化仅为本地 IPC 可选参数和游标字段;无 OpenAPI、SpacetimeDB 或持久化迁移。远程推送/PR/WIP 操作仍待额外确认。 + +## 验收证据 + +- 历史消息模型、回合呈现及分页集成共 24 个前端测试通过;包含真实 App 的首屏/更早页、单飞、失败重试、重叠消息、同项目重新加载及离开再进入的迟到响应。 +- 16 个历史原生测试通过;人工日志重放用例在 CI 默认忽略,已在本地单独执行通过。 +- 使用用户提供的原始日志运行修复后的原生读取:44 条现存聊天消息(含 2 条用户消息)分 3 页取回,逐项内容与顺序一致,原文件字节未变;未在仓库保存原始日志。 +- 工作台/Direct 恢复与画布导航的 8 个定向回归通过。真实客户端重新进入与向上翻页尚待用户验收;本次涉及 Rust IPC,需重新构建并启动原生端。 +- 与前面画布/JSON 修复联合复验:119 个前端定向测试、30 个原生测试通过;AGC TypeScript、修改文件 ESLint、编码、文档索引和差异检查通过。 +- 状态只覆盖日志中已经证明的分页卡页,不据此宣称其它可能的未落盘消息也已恢复。 diff --git a/docs/project-memory/plans/【实施计划】AGC画布交互稳定性修复-2026-09-16.md b/docs/project-memory/plans/【实施计划】AGC画布交互稳定性修复-2026-09-16.md new file mode 100644 index 000000000..9902abd0b --- /dev/null +++ b/docs/project-memory/plans/【实施计划】AGC画布交互稳定性修复-2026-09-16.md @@ -0,0 +1,44 @@ +# AGC 画布交互稳定性修复实施计划 + +- Date: 2026-09-16 +- Status: awaiting-runtime-acceptance +- Milestone: [画布交互稳定性修复](./【里程碑】AGC画布交互稳定性修复-2026-09-16.md) + +## 修改顺序与边界 + +1. 将临时诊断收敛成正式回归测试,覆盖窗口 Context 反馈、提示条件和鼠标/触摸板事件。 +2. 稳定工作台打开项目的转发回调,保持最新处理器语义,不改项目加载逻辑。 +3. 解耦运行提示与选择;在原有画布事件链加入右键平移、菜单边界和中断清理,不另建控制器。 +4. 执行定向验证并检查首次加载、平移和原有框选/卡片拖动回归。 + +## 验证 + +- 定向 Vitest:窗口工作台、画布交互、布局与原有导航用例。 +- 运行提示的源码契约与交互回归同时覆盖:提示仅取决于运行能力和 UI 编辑器路由,不依赖资源选择;选中资源后提示及 `aria-describedby` 保留,点击运行仍不能进入不可用视图。 +- AGC `tsc --noEmit`;`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。 +- 真实客户端首次进入和触摸板操作无法以 jsdom 代替;未实测时保持待验收。 + +## 风险与停止条件 + +窗口反馈测试必须有更新次数上限,避免未修复代码让测试失控。右键仅接管画布背景/卡片,不抢输入控件与浮层。新增问题只有影响本次验收才扩大范围;不根据猜测修改 B01/B02 的布局与动画。 + +回滚仅限本批局部补丁;不重置工作树,不覆盖其它修改。完成自动化验证后停在真实客户端验收,不推进额外功能。 + +## 问题状态 + +| 编号 | 问题 | 状态与证据 | +| --- | --- | --- | +| B01 | 刷新后首次进入画布元素抖动 | 已优化;用户在本轮反馈未再复现,按用户要求更新状态。不宣称所有布局/动画原因均已排除。 | +| B02 | 初次进入双指平移无效,整理后恢复 | 已优化;用户在本轮反馈未再复现,按用户要求更新状态。隔离组件连续平移通过。 | +| B03 | 快速平移触发更新深度错误 | 已修复已确认的窗口 Context 反馈循环,回归验证收敛;真实操作继续观察。 | +| B04 | 资源选中后运行不可用提示消失 | 已修复,提示与选择解耦,自动化验证通过。 | +| B05 | 对话记录偶发丢失 | 已修复日志复现的历史分页卡点:消息模式过滤后分页、原生游标与读取代次隔离。原生只读重放分 3 页取回全部 44 条现存消息,原文件未变;真实客户端待验收,不扩大为其它未落盘记录已恢复。 | +| B06 | JSON 文档未正确识别展示 | 已按用户确认完成本地修复:合法 UI State 由原生完整校验,卡片显示 UI 设计并进入现有编辑器;普通 JSON 显示 JSON 并可代码预览。自动化验证通过,待重建原生客户端验收;详见 JSON 语义识别实施计划。 | +| C01 | 右键平移,保留左键框选 | 已实现,卡片左键拖动、框选、指针取消/失焦/捕获丢失及控件边界测试通过。 | + +## 已取得证据与剩余门禁 + +- 修复前新增回归测试能检出外壳重复发布、运行提示消失和右键无效;修复后窗口/画布定向测试通过,现有导航、框选、指针点击/取消、UI 编辑器返回平移和素材定位用例通过。 +- AGC TypeScript、修改文件 ESLint、编码、文档索引及差异空白检查通过。 +- 测试仍有既有 React 列表 key、旧用例 act/IPC 桩告警,未作为本批功能修复扩大范围。 +- 用户反馈 B01/B02 本轮未再复现,记为已优化;右键手感与其它真实客户端细节继续观察。对话历史分页与 JSON 双路径均已本地修复并通过定向验证,待重建原生端后真实客户端验收。本计划保持开放。 diff --git a/docs/project-memory/plans/【实施计划】AGC资源JSON语义识别-2026-09-16.md b/docs/project-memory/plans/【实施计划】AGC资源JSON语义识别-2026-09-16.md new file mode 100644 index 000000000..5c2947540 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】AGC资源JSON语义识别-2026-09-16.md @@ -0,0 +1,27 @@ +# AGC 资源 JSON 语义识别实施计划 + +- Date: 2026-09-16 +- Status: awaiting-runtime-acceptance +- Milestone: [AGC 资源 JSON 语义识别](./【里程碑】AGC资源JSON语义识别-2026-09-16.md) + +## 实施边界 + +1. 原生 UI 持久化模块抽取可复用的内容解析与只读识别;文本预览返回可选的已验证 UI 资产身份。 +2. 保留新设计初始化的严格 UI 资产门禁;已有 State 的加载/保存/生成按登记资源和真实文档校验,不依赖标签精确大小写。 +3. 前端预览缓存透传识别结果;工作台卡片与编辑器入口消费同一结果,普通 JSON 详情使用代码块。 +4. 单元、组件与原生测试覆盖合法/普通/损坏/跨身份/未登记和保存边界;保持先前画布修复。 + +## 验证与停止条件 + +- 定向 Vitest、Tauri persistence/resource preview 定向 Rust 测试、AGC TypeScript、修改文件 ESLint、编码、文档索引及 `git diff --check`。 +- 不读取或修改用户项目原文件,不将日志或真实对话作为仓库测试夹具。 +- 原生构建/真实客户端受环境限制时记录实际证据,不能以 TS 测试代替原生验证。 +- 完成上述范围后停止;B05 已有分页卡点证据,本轮不顺带修改历史合同。 + +## 验证结果 + +- 92 个前端定向测试通过,覆盖原生结果驱动的卡片/编辑器路由、普通 JSON 代码预览、伪 UI 文本拒绝、读取失败、缓存切项目,以及原有画布导航与指针回归。 +- 14 个 UI 持久化原生测试通过,包含有效 State 对多种登记标签的识别/加载/保存、未知 schema/字段与坏结构拒绝、项目/资产身份、普通 JSON 防覆盖及原有恢复/CAS 边界。 +- 原生测试在 macOS 默认 `/var` 临时路径触发既有拒绝符号链接门禁;改用真实 `/private/tmp` 后通过,未放宽产品路径安全校验。 +- AGC TypeScript、修改文件 ESLint 已通过。测试仍有既有 React key/act 告警及 Rust 未使用代码警告,不影响本批断言。 +- 真实客户端的图片/UI State 体验仍待验收。本次包含 Rust 预览字段,必须重新构建并启动原生端,不能仅刷新前端就认为识别结果已更新。 diff --git a/docs/project-memory/plans/【实施计划】抠图模式联调验收-2026-09-16.md b/docs/project-memory/plans/【实施计划】抠图模式联调验收-2026-09-16.md new file mode 100644 index 000000000..6d6094211 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】抠图模式联调验收-2026-09-16.md @@ -0,0 +1,14 @@ +# 抠图模式联调验收实施计划 + +依据:[里程碑](./【里程碑】抠图模式联调验收-2026-09-16.md)。 + +1. 主站修复 worker 对 auto/省略的门禁、空值契约和旧指纹;补接口、队列与 worker 定向测试。 +2. 客户端独立核对 schema、参数校验、幂等意图、Skill 与契约说明。 +3. 串行请求真实 BgFilter,凭据仅在进程内读取,不输出或落库。 +4. 运行 cargo test 的 background_removal、bgfilter、OpenAPI 定向过滤,客户端定向测试;运行 doc-index、encoding、diff 检查。 +5. 尝试 npm run dev:api-server 与 healthz smoke;记录真实登录/全链路未验证项。 +6. 收敛证据到主规范,删除临时计划。 + +检查点:先通过契约测试,再开展运行时核验;不因环境缺失修改生产配置。回滚仅限本次局部补丁。 + +当前收口:主站及客户端修复和定向检查已执行,真实 BgFilter 四组成功及自动检测失败分支已验证;本地 API 启动被现有数据库连接配置阻塞。仅完整登录/队列/资源回写运行时证据尚待补齐,测试凭据和临时日志不得提交。 diff --git a/docs/project-memory/plans/【里程碑】AGC对话历史分页恢复-2026-09-16.md b/docs/project-memory/plans/【里程碑】AGC对话历史分页恢复-2026-09-16.md new file mode 100644 index 000000000..4b8e244c4 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC对话历史分页恢复-2026-09-16.md @@ -0,0 +1,20 @@ +# AGC 对话历史分页恢复 + +- Version: 1 +- Status: reviewed +- Date: 2026-09-16 +- Parent Spec: [AGC 实施计划:DirectProject 回合展示唯一归属](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md) + +## 范围与评审 + +用户已要求修复 B05。已用用户提供的原始日志只读复验:原始切片被工具/推理填满时,聊天投影为空,消息游标不推进。修复聊天读取与分页,不改历史写入、不删除记录、不修改模型上下文,不扩大到其它尚无证据的对话丢失原因。 + +采用已有切片接口的显式消息模式与原生游标。原始模式缺省行为保持不变,旧无 ID 消息保留;前端以项目和加载代次隔离结果。本轮不包含远程写入。 + +## 验收 + +1. 工具/推理密集、末尾无消息、纯工具历史均不产生空页死循环。 +2. 消息正文、原始 ID、时间和顺序保持不变,翻页能到达早期用户提问及最终回答,不重复。 +3. 连点、请求失败重试、项目切换和同项目重新加载不会污染消息或游标。 +4. 原始切片默认模式回归通过,用户日志只读重放可以取回全部现存消息;不把真实日志或对话正文提交到仓库。 +5. 加载更早消息后重新读取历史,旧页不出现在最新回复之后;再次翻页保持顺序且不重复。未落盘的实时用户输入不因历史刷新被丢弃。 diff --git a/docs/project-memory/plans/【里程碑】AGC画布交互稳定性修复-2026-09-16.md b/docs/project-memory/plans/【里程碑】AGC画布交互稳定性修复-2026-09-16.md new file mode 100644 index 000000000..de82fa9ef --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC画布交互稳定性修复-2026-09-16.md @@ -0,0 +1,26 @@ +# AGC 画布交互稳定性修复 + +- Version: 1 +- Status: reviewed +- Date: 2026-09-16 +- Parent Spec: [AGC 实施计划:资源画布交互与工作台状态同步](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md) + +## 目标与范围 + +完成工作台标题栏状态更新循环、运行提示随选择消失、右键平移三项本地修复,保持左键框选与卡片拖动。不修改布局算法、持久化和后端;不开远程 Issue/PR、不推送。 + +## 评审 + +依据已完成的源码追踪、客户端错误日志及有界复现自检:状态归属仍在窗口与工作台原有边界内;交互修改只影响画布手势;没有数据迁移、权限或 API 变化。用户已确认右键平移及保留左键框选,并授权先尝试本地修复。真实客户端首次进入抖动与触摸板平移仍需另行验收。 + +## 验收 + +1. 工作台发布窗口状态后收敛;重复渲染不持续更新或清理,打开项目回调使用最新处理器,卸载清理有效。 +2. 选中资源后运行不可用提示仍在,运行能力不变。 +3. 子画布空白处及卡片右键平移不改变选择或坐标;左键框选与卡片拖动保留;总览支持右键平移。 +4. 指针取消、失去捕获、窗口失焦终止平移;控件与浮层保留原有交互。 +5. 连续平移测试、定向测试、类型检查、编码与文档门禁完成并记录限制;B01/B02 仅在真实复测后决定关闭。 + +## 依赖 + +当前源码与已安装测试依赖;真实 AGC 客户端验收环境。没有外部写操作依赖。 diff --git a/docs/project-memory/plans/【里程碑】AGC资源JSON语义识别-2026-09-16.md b/docs/project-memory/plans/【里程碑】AGC资源JSON语义识别-2026-09-16.md new file mode 100644 index 000000000..119c04f27 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC资源JSON语义识别-2026-09-16.md @@ -0,0 +1,26 @@ +# AGC 资源 JSON 语义识别 + +- Version: 1 +- Status: reviewed +- Date: 2026-09-16 +- Parent Spec: [AGC 实施计划:文档与代码素材预览](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md) + +## 目标与边界 + +按用户确认同时支持普通 JSON 与 UI 设计 JSON:前者显示 JSON 并可代码预览,后者显示 UI 设计并进入原有编辑器。原生侧复用现有文档校验,前端不推断正式状态。不修改 manifest 分类,不迁移用户文件,不改历史分页,不做远程写入。 + +## 评审 + +已核对原生受控文本预览、UI State 持久化合同和工作台卡片/编辑器路由。复用完整读取结果识别,未知 schema/坏结构/身份不符均不授予编辑入口;已有 UI 资产的大小写标签不应阻断真实合法 State。新增可选预览字段只承载原生识别结果;不引入新的状态文件或平行编辑器。 + +## 验收标准 + +1. 普通 JSON 显示 JSON,不渲染卡面原文,不出现 UI 编辑器入口,显式预览为 JSON 代码块。 +2. 完整合法且身份匹配的 UI State 显示 UI 设计并进入现有编辑器;大小写标签或文档标签不影响内容识别。 +3. 损坏 JSON、伪 schema、未知字段、坏 State、跨项目/资产、未登记文件不能获得 UI 编辑入口;原文查看或读取错误仍可见。 +4. 识别不产生写入;普通 JSON 不能通过编辑器保存或初始化被覆盖。有效已有设计仍支持原有 CAS 保存。 +5. 预览缓存能传递原生结果并随资源身份失效;原有卡片、框选/平移和图片预览回归通过。 + +## 依赖 + +本地前端与 Tauri 源码、现有测试依赖;真实客户端体验单独验收。 diff --git a/docs/project-memory/plans/【里程碑】抠图模式联调验收-2026-09-16.md b/docs/project-memory/plans/【里程碑】抠图模式联调验收-2026-09-16.md new file mode 100644 index 000000000..e5f065108 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】抠图模式联调验收-2026-09-16.md @@ -0,0 +1,14 @@ +# 抠图模式联调验收 + +Version: 1 +Status: 自动化与真实上游验收已执行;完整主站链路待本地数据库恢复 +Date: 2026-09-16 +Parent Spec: ../../technical/【技术方案】AGC抠图模式与背景色透传-2026-09-16.md + +交付:证明客户端、External v1、队列和 BgFilter 的模式/颜色契约一致;修正范围内缺漏。 +不做:401 专题、部署、主站前端变化、生成角色与图集自动选色行为变化、数据库 schema 修改。 + +验收:旧请求 complex;flat 三种颜色输入贯通;非法组合入队前 400;幂等包含新意图并保留旧请求指纹;真实服务返回可解码透明 PNG;实际证据与未验证环境分开记录。 +依赖:既有方案及前五步代码。 + +剩余门禁:当前本地 SpacetimeDB 连接拒绝,导致 api-server 启动恢复未就绪。环境恢复后,用 npm run dev:api-server 验证 healthz,再以真实登录客户端提交三种 flat 请求及旧 complex 请求,确认队列完成和资源回写。不得用直连 BgFilter 测试替代此门禁;完成后将结论回写主规范并删除本计划和实施计划。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index aa8b5084f..01152bcba 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -3,6 +3,30 @@ > 用途:记录已经确认、会影响后续开发的长期技术/产品/协作决策。短期讨论不要写在这里。 > 当前口径:历史条目的旧路径、旧版本和已退役对象只用于追溯,不构成现行实现依据;如与当前代码或 `docs/README.md` 冲突,以当前代码和最新专题文档为准。 +## 2026-09-17 `agc_tools` 媒体资源提示词上限收敛为单一口径,并按 kind 暴露给模型 + +- 背景:有人反馈「客户端没法由 agent 调用图片快速编辑功能以及背景音乐生成功能」。核查后工具本身都在(`agc_edit_image` / `agc_create_or_derive_resource`),图片快速编辑在 2026-09-14 的真实项目日志里也有成功记录;但存在三类真实缺陷:① `agc_create_or_derive_resource` 的 `prompt` 在 schema 里只声明 4000,真实上限却是按 kind 分的(背景音乐 140、音效 1900、视频/角色动画 4000、图片 32000),MCP 层还额外写死了一条 140 判断,模型从 schema 与 skill 都看不出 140/1900,写一句正常长度的背景音乐描述就当场被拒;② 客户端 UI 用同一口径但会截断并提示,agent 侧却只有硬拒,形成「UI 能做、agent 调不动」的观感;③ `sourceLocalAssetId` 不是已登记资源时只报「不属于当前项目已登记资源」,模型会原地重试而不会先登记。 +- 决策一(单一口径):提示词上限只由 `resource_edit_prompt_max_chars` 给出,MCP 工具层、客户端受控工具桥与提交校验全部从它取数;超限文案复用 `resource_edit_prompt_limit_error`,保证模型看到的数字就是真实生效的数字。传输层边界只在信封级生效,不再用一个更小的通用常量先于按 kind 上限误报。 +- 决策二(按 kind 暴露):`agc_create_or_derive_resource` 的 schema 用 `allOf[oneOf]` 逐 kind 声明 `prompt.maxLength`(background-music / sound-effect / video+character-animation),顶层 `maxLength` 等于各 kind 上限的最大值,`prompt` 描述里写明每个数字;`agc_edit_image` 继续用图片口径 32000。skill 包 `agc-client-projection`(SKILL.md 与 `references/projection-contract.md`)同步写明四个数字,并说明超限要在本地收敛而不是原样重发。 +- 决策三(可执行的前置提示):源资源未登记时统一返回「先用 `agc_list_registered_assets` 选已有 localAssetId;文件只在项目里时先用 `agc_list_project_files` 确认 `assetImportable=true`,再用 `agc_import_account_assets.localPaths` 登记后重试」。本轮不放开「已完成任务产物」在 agent 侧的隐式正规化:登记是带副作用与 revision 推进的事务,必须由模型显式发起。 +- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs`(上限与文案的唯一口径)、`agent/direct_tool_bridge.rs`(按 kind 判定与未登记源资源提示)、`agent/direct_tools_mcp.rs`(schema 与校验)、`resources/agc-skills/agc-client-projection/**` 与清单指纹(version `2026-08-26.18`)。**未改** `/api/external/v1` 契约与 OpenAPI、SpacetimeDB schema、前端 TS 侧 `resourceEditPromptMaxLength` 数字、客户端 UI 行为。 +- 验证方式:新增 `tool_prompt_limits_agree_with_the_client_authority`(四个 kind 的 schema 上限、MCP 校验与客户端权威口径同数字,超限文案带真实上限)、`bridge_resource_prompt_limits_follow_the_client_authority`(工具桥侧同类门禁,含图片编辑的 32000 边界)、`edit_image_tool_reaches_the_platform_image_edit_route` 与 `background_music_tool_reaches_the_platform_audio_route`(MCP 工具层 → 真实工具桥 → 假平台,断言 `/api/editor/images/edits` 与 `/api/editor/audios/background-music/generations` 的路径、Bearer、Idempotency-Key、正文与派生资源落盘,图片编辑正文不得回填 assetKind)、`background_music_prompt_over_the_limit_is_rejected_before_any_bridge_call`(超限在桥请求之前失败)、`unregistered_source_reports_the_registration_follow_up_tools`;`agent::direct_tools_mcp` 22 passed、`agent::skill_pack` 4 passed、`agent::direct_tool_bridge` 17 passed(7 条本机既有失败见下)、`npm run agc:skill-pack:check` 与 `skill-pack:test` 通过。本机 `tempfile::tempdir()` 归属校验失败导致的既有用例(`project::resource_editor` 45 条、`agent::direct_tool_bridge` 7 条)在本轮改动前后**同为失败**(stash 基线复跑确认),与本次无关。 +- 关联文档:[AI游戏创作智能体App实施计划](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md)、[踩坑记录](pitfalls.md)。 + +## 2026-09-16 抠图模式与背景色契约 + +- External v1 抠图和 AGC `agc_remove_background` 支持 `complex`(语义分割识别前景)与 `flat`(纯色背景抠图);明确纯色背景优先 flat,模式缺省仍为 complex,主站前端保持现有行为。 +- flat 的颜色允许 `auto`、`#RRGGBB` 或省略,自动识别完全由 BgFilter 负责。主站只校验、透传,不调用视觉模型选色;complex 携带颜色、非法值和空字符串在入队前拒绝。 +- 来源、名称、模式与颜色共同区分客户端请求意图;旧参数调用及旧 External 请求的幂等指纹须保持稳定。 +- 权威合同:[AGC 抠图模式与背景色透传](../../technical/【技术方案】AGC抠图模式与背景色透传-2026-09-16.md)。 + +## 2026-09-16 策划 Agent 工具执行退出项目级写锁并自动接续中断批次 + +- 背景:策划 Agent 每个 `read_file` / `write_file` / `patch_file` 工具都在执行前竞争全局项目写锁,但同一会话已由 `.agent/design-agent/active.lock` 串行化,工具目标又限定在 `design_artifacts`;项目锁既不覆盖「工具 + 会话 checkpoint」事务,还把进程中断时的 `executing=true` 不确定窗口扩大到等锁与工具执行全程。真机项目出现 `pendingBatch.executing=true`、`function_call` 无配对 output、UI 只显示工作中且无错误的状态。 +- 决策:单次策划工具不再竞争项目级写锁,只保留策划命令锁与既有原子写入;GameAgent / DirectProject 的公共项目锁实现与调用不变。重开项目 hydrate 时,若命令锁可获取且当前批次处于 `executing=true`、当前 call 无 output,则自动续跑原回合:为该 call 补写「执行结果未保存」的工具错误、跳过剩余调用并交回 Provider 自愈;不得重放文件副作用,也不要求用户手动重试。 +- 验证:新增定向用例证明中断批次自动补齐工具 output、收到后续 assistant 回复、清空 pendingBatch 并结束原 turn,同时目标文件保持未修改(未重放 `patch_file`);策划 Runtime 定向 14 条、策划工具 3 条通过,`cargo fmt --check`、`npm run check:encoding`、`git diff --check` 通过。 +- 关联文档:[策划 Agent 生产迁移与工作区浏览](../../technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md)。 + ## 2026-09-16 AGC 同 AppData 多窗口共享 Agent Runner - 背景:双击或再次启动 AGC 客户端时报「应用启动失败」,启动日志为 `startup.runner.owner-lock.failed details=AI 游戏创作界面已由同一 AppData 目录中的其他进程运行`。原设计(2026-07-27 / 2026-08-23)要求同一 AppData 只有一个 GUI owner,第二个界面进程在 setup 阶段就失败退出。 @@ -8808,3 +8832,11 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策:模板库首页推荐位替换原「灵感推荐」本机图片目录(已删除 `InspirationGallery.tsx` 与 `assets/inspiration/`);左侧导航新增模板库入口,打开独立全屏页。`tauri.conf.json` 的 `img-src` 放行受信任 OSS 主机用于封面图。 - 关联规范:`docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md`;开发期计划见 `docs/project-memory/plans/【里程碑】AGC模板库客户端接入-2026-09-17.md` 与对应实施计划。 - 验证:Rust 模板库 8 项定向单测、前端模型 9 项单测、AGC `tsc` 类型检查通过;`templates/index.json` 匿名可读且每个 `zipKey` 回读 SHA-256 与清单一致;发布脚本 `scripts/agc-template-library-publish.mjs` 支持 `--dry-run` 与上传后回读校验。 + +## 2026-09-16 CI 宿主 CPU 上限:Jenkins 16 核 / Gitea Actions runner 12 核 + +- 背景:`genarrative-station`(32 逻辑核)上 Jenkins Built-In Node 与 Gitea Actions runner 共用同一宿主。Jenkins `jenkins.service` 原先没有任何 CPU 限制(`cpu.max=max`),构建期 Web / Api / Stdb 三分支并行(Vitest 8 线程 + 两次默认 32 job 的 cargo)把整机顶到 80%~95%;`gitea-runner` 容器 `--cpus=24`(75%)在 push 触发的 CI 波峰里实测峰值 24.8~25.3 核,是同一时间窗里更大的单一消耗方。 +- 决策:两路 CI 都设硬上限。Jenkins 侧 `systemctl set-property jenkins.service CPUQuota=1600%`(16 核 / 50%,覆盖 Built-In Node 上所有子构建,立即生效、无需重启,drop-in 落 `/etc/systemd/system.control/jenkins.service.d/50-CPUQuota.conf`)。runner 侧把 `/opt/gitea-stack/compose.yml` 的 `cpus` 由 `"24.0"` 改为 `"12.0"`(12 核 / 37.5%),并用 `docker update --cpus=12 gitea-runner` 让运行中的容器立即生效,不重建容器、不中断在跑 job。 +- 边界:Deploy 阶段在远端 dev / release agent 执行,不受该上限约束。调整只动这两处:`systemctl set-property / revert jenkins.service`、`docker update --cpus= gitea-runner` 加同步 compose(备份 `/opt/gitea-stack/compose.yml.bak-<时间戳>`)。 +- 验证:限速后 `Genarrative-Full-Build-And-Deploy` #289 / #290 SUCCESS;采样期 Jenkins 峰值 10.2~10.5 核、限流不足 2s(可忽略),runner 峰值 12.07 核且持续出现 throttling,整机回落到 2.6%~19.8%。 +- 关联文档:[开发运维](../../【开发运维】本地开发验证与生产运维-2026-05-15.md)。 diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index 3dd59f677..0003dbf5e 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -4,6 +4,10 @@ ## 标准流程 +前端测试稳定性验证使用根目录 `npm test`(与 Frontend tests job 相同),保留 Vitest 的 8 worker 上限。涉及异步资源展示时,组件测试必须 mock 所有会触发的网络请求,每次调用创建独立 `Response`,并等待最终 DOM 状态而非仅等待 fetch 被调用。换签 Hook 的测试通过 `vitest.config.ts` 的 include 纳入全量运行;新增测试文件后需确认实际执行名单,命令参数指定文件不会绕过 include 白名单。排查顺序依赖可使用 `npm test -- --sequence.shuffle --sequence.seed=9467`,但不能以重试成功替代失败原因分析。 + +用例隔离必须包括浏览器状态与 mock 实现:修改 `window.history` 后恢复基线路由;`spyOn(window, 'getSelection')` 等 spy 在用例结束后 restore;`clearAllMocks` 仅清调用记录,不能恢复被上一个用例替换的返回值。顺序打乱暴露的失败应修复泄漏来源,保留原有业务断言。 + ```text 确认工作树与目标分支 → 读取入口和当前专题 → 查代码真相 → 小步修改 → 定向验证 → 更新当前文档/记忆 → 检查提交边界 ``` @@ -47,6 +51,8 @@ ## 验证路由 +AGC 运行时配置默认值调整时,同步核对 Rust 默认值、分发配置模板、设置弹窗默认草稿和 `runtime-settings.suite.ts` 的恢复默认断言;显式传入旧值的配置读取用例仍验证原值保留,不批量替换测试数据。 + AGC 测试构造单 HTML 项目时,必须在初始化之前写入 HTML,避免自动建立 npm 工程;npm 预览和导出测试应提供 dist 产物。已有图片生成 pending/operation 属于持久化恢复合同,修改工具默认参数后仍须验证旧动作恢复不重复提交、不因默认值变化被误判为新意图。 SpacetimeDB 任务统一先读取 `.codex/skills/genarrative-spacetimedb/SKILL.md`;该项目适配层按需调用已安装的官方 `spacetimedb` 插件 skill,插件提供通用 SDK/CLI/MCP 知识,项目 skill 负责 Genarrative 架构边界和验证门禁。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index fd60a015c..52b39d70a 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,34 @@ # 踩坑与排障记录 +## 2026-09-17 AGC 输入盒的「推理档」弹层被祖先裁切:要放开裁切而不是挪弹层 + +- **现象**:窄窗口下(视口 ≤1000px 时右侧对话面板只有 280px 宽)点开输入盒右下角的「推理档」,弹层是个**空盒子**:档位文字(默认 / 低 / 中 / 高 / 最高)整片看不见,只剩一个方框。 +- **成因**:推理档是控制排里最靠左的弹层锚点,`.conversation-model-menu` 默认 `right: 0` 贴触发钮右缘**向左**展开;触发钮右边还压着模型选择、语音、发送三颗钮,所以 150px 宽的弹层在 280px 面板里会伸到面板左侧 42px 之外。`.game-workbench-chat`、`.project-supervisor-surface.is-direct-codex`、`.project-supervisor-conversation` 三层各自的 `overflow: hidden` 沿自己的溢出边界裁掉它,而档位文字起点才 14px(面板左内边距 5px + 按钮左内边距 9px),正好落在被裁掉的那半边。 +- **处理(用户指定口径)**:不挪弹层位置——只让 direct-codex 那三层不再裁切:`.game-workbench-chat:has(.project-supervisor-composer.is-direct-codex)`、`.game-workbench-chat .project-supervisor-surface.is-direct-codex`、`.game-workbench-chat .project-supervisor-surface.is-direct-codex .project-supervisor-conversation` 三条 `overflow: visible`。弹层的 `right: 0`、尺寸和触发钮锚点全不变,只是允许它盖到左侧资源面板上完整显示。消息列表自带 `overflow-y: auto`(另一轴按规范计算为 auto),消息内容仍由列表自身裁剪。 +- **易错点**:① 把弹层改成 `left: 0` 或往右挪也能让它可见,但那是改变展开方向,弹层会跑到触发钮右边(用户明确否决);② 只放开最外层聊天列不够——surface 与 conversation 各自都会裁,三层必须同时放开;③ 只按宽度比大小会误判:280px 面板里控制排本身也超出(发送钮右侧溢出 22px,被窗口右缘吃掉),那不是本条的原因,别顺手去改控制排布局。 +- **验证**:`apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts` 的 `keeps the landscape workbench edge-to-edge with internal chat scrolling` 钉住三条 override 声明在场(删掉任一条即红)。真机几何用 playwright-cli 打开一份只含真实 `styles.css` 与真实 composer DOM 的最小复现页实测(视口 1000×700、面板 280px):弹层 rect 修复前后都是 `[-42, 108]`(位置未动),`elementFromPoint` 的命中区间从修复前的 `[2, 108]` 变成整块;档位文字在截图中完整可见。 +- **关联**:`apps/ai-game-creator-shell/src/styles.css`(`面板纵向布局(2026-07 Codex 风格改造)` 区块之后)、`apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts`。 + +## DirectProject 历史不能按工具条目切页再按消息推进游标 + +原始 `response_item` 历史同时含用户/助手消息、推理与工具输出。若原生每次取 20 个原始条目、前端过滤聊天消息后再找最旧 ID,纯工具页会让消息集合为空且游标不动,看起来历史丢失。聊天读取固定显式请求 `messagesOnly: true`,原生逐行过滤后按消息分页并返回 `oldestItemId`;默认原始模式留给原始条目消费者。无 ID 旧消息保留并扩展到可寻址边界,不能造 ID。前端保留项目与读取代次、单飞及 ID 去重,旧请求的成功、失败与 finally 都不能覆盖新读取;真实日志只在临时目录只读重放,不能提交正文夹具。 + +## JSON 卡片显示与 UI 编辑能力必须同源 + +JSON 的文本读取分支不等于卡面应该展示原始 State 摘要。卡片、缩略图及编辑器入口共同消费受控文本预览的 `uiDesignAssetId`;只有原生复用 UI 持久化合同校验 schema、完整 State 和项目/资产身份后才设置它。普通 JSON 保留 JSON 代码预览,不按 `kind: UI/ui` 或 schema 字符串片段猜测编辑能力。已有合法 UI State 的加载/保存不依赖 kind 精确大小写,但新建初始化仍保留正式 UI 资产门禁;缓存与项目切换须保留现有身份隔离。 + +## 窗口 Context 发布不得依赖每次渲染新建的业务回调 + +工作台向窗口标题栏发布运行项目时,若 effect 依赖普通函数派生的回调,发布 Context 会重新渲染工作台,进而再次发布并清理,形成更新深度循环。转发入口须稳定,并在提交阶段更新实际处理器引用;发布数据变化与卸载清理分开。回归测试必须组合真实窗口 Provider 和工作台消费者,只有独立画布测试无法覆盖这条反馈链;回归时用有界发布次数阻止测试失控。画布快速操作时暴露的更新深度错误,也须检查外层状态同步,不能直接归因于滚轮频率。 + +## 2026-09-17 工具 schema 声明的上限与真实校验不一致,会表现成「agent 调不动这个功能」 + +- **现象**:用户反馈「客户端没法由 agent 调用图片快速编辑功能以及背景音乐生成功能」。查工具目录时两个工具都在(`agc_edit_image`、`agc_create_or_derive_resource`),图片快速编辑在真实项目日志里还有成功记录;但 agent 侧写一句正常长度的背景音乐描述就失败,而客户端 UI 用同一个提示词却只是被截断加提示。 +- **原因**:`agc_create_or_derive_resource.prompt` 在 MCP schema 里只声明 `maxLength: 4000`,真实上限按 kind 分(背景音乐 140 / 音效 1900 / 视频、角色动画 4000 / 图片 32000),MCP 层还额外写死一条 `kind == background-music && > 140` 的判断;skill 包没有任何一处写这两个数字。模型从 schema 与 skill 都无法得知 140,于是必然踩一次硬拒。同类隐患还有两处:客户端工具桥用通用 4000 校验 prompt,会把 4000 以上的图片编辑提示词误报成「超出安全边界」;按 kind 校验散落在 MCP 与桥两处,新增类型容易只改一处。 +- **处理**:上限收敛到 `resource_edit_prompt_max_chars` 单一权威(工具层、桥、提交校验共用),超限文案复用 `resource_edit_prompt_limit_error`;工具 schema 用 `allOf[oneOf]` 逐 kind 声明 `prompt.maxLength` 并在描述里写明数字;prompt 的传输层边界退到信封级,避免通用常量先于按 kind 上限报错;两端 skill 文档同步写明四个数字。新增 `tool_prompt_limits_agree_with_the_client_authority` 作为门禁:四类 kind 的 schema 上限、桥上限与权威口径必须同数字,且超限文案必须带真实上限。 +- **验证**:`cargo test --bin genarrative-ai-game-creator-shell -- --test-threads=1 agent::direct_tools_mcp::tests`(22 passed,含两条走 MCP 工具层 → 真实工具桥 → 假平台的媒体工具契约用例与一条超限零请求用例)、`agent::direct_tool_bridge::tests`(17 passed,含新增的按 kind 上限门禁;另有 7 条本机既有失败)、`agent::skill_pack`(4 passed)、`npm run agc:skill-pack:check`。本机 `tempfile::tempdir()` 归属校验失败会让 `project::resource_editor` 45 条与 `agent::direct_tool_bridge` 7 条既有用例失败,改动前后同为失败,不要据此误判回归。 +- **关联**:`apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs`、`src-tauri/src/agent/direct_tool_bridge.rs`、`src-tauri/src/agent/direct_tools_mcp.rs`、`src-tauri/resources/agc-skills/agc-client-projection/`。 + ## 2026-09-16 从 Codex 里启动 AGC 客户端会看到被重定向的 `%APPDATA%` - **现象**:在 Codex 会话里用 `Start-Process` 启动 `genarrative-ai-game-creator-shell.exe` 做排障时,子进程写 `C:\Users\\AppData\Roaming\world.genarrative.ai-game-creator\...` 的内容会落到 `C:\Users\\AppData\Local\Packages\OpenAI.Codex_2p2nqsd0c76g0\LocalCache\Roaming\...`;同一个 `Test-Path` / `Get-ChildItem` 命中的是重定向视图,只有 `\\?\C:\Users\...` 形式能区分真实路径。 diff --git a/docs/technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md b/docs/technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md index 5fd4e58f3..233455db6 100644 --- a/docs/technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md +++ b/docs/technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md @@ -151,7 +151,7 @@ Authorization: Bearer - 当前部署只有一个配置内私有 OSS bucket,因此请求只传 `sourceObjectKey`,子 worker 从自身 OSS 配置取 bucket 并生成短期签名 URL。 - 如果未来确实支持多个 bucket,新增字段也必须由服务端 allowlist 校验;不能接受调用方提供任意下载 URL。 - `backgroundMode` 只允许 `flat / complex`;`segModel` 继续沿用当前 `birefnet / anime-seg` allowlist;complex 固定使用当前参数组合。 -- `screenColor` 只对 flat 必填;complex 不得误接 flat 参数,两种模式的熔断状态必须隔离。 +- `screenColor` 在 flat 下可省略,也可传 `auto` 或 `#RRGGBB`;省略或 `auto` 由 BgFilter 自动识别。complex 不得携带背景色,两种模式的熔断状态必须隔离。生成角色、图集等既有链路继续传已确定的背景色。 - `maxQueueWaitMs` 与 `callBudgetMs` 都是相对预算,不是跨机器绝对时间。前者从 admission 起约束排队阶段(worker 还会用 §5.2 的动态估计对其取 min);后者从取得 provider permit 起计时,覆盖签名、两次 attempt、结果校验和响应构造。`callBudgetMs` 是父侧按 `N / est` 公式算出的“配置指纹”,仅作核对:worker 始终以自己按同一公式派生的值执行,不一致时不拒绝请求,而是记录 warn 日志并递增漂移指标。发布调优 N / est 时新旧进程共存的瞬态漂移因此不会误伤在途任务;持久性漂移的硬拦截由部署脚本的共享 env 对齐校验承担。 - JSON body 设置很小的固定上限;源图字节不进入该 JSON。 diff --git a/docs/technical/【技术方案】AGC抠图模式与背景色透传-2026-09-16.md b/docs/technical/【技术方案】AGC抠图模式与背景色透传-2026-09-16.md new file mode 100644 index 000000000..a7e9525fd --- /dev/null +++ b/docs/technical/【技术方案】AGC抠图模式与背景色透传-2026-09-16.md @@ -0,0 +1,104 @@ +# AGC 抠图模式与背景色透传方案 + +## 目标 + +主站编辑器保持现有前端行为(继续使用 `complex`),同时扩展 External v1 抠图接口和 AGC 客户端,使客户端可以选择 `complex` / `flat`,并把 `screenColor` 原样交给 BgFilter。`complex` 用语义分割识别前景,`flat` 用于纯色背景抠图;确定背景为纯色时优先使用 `flat`。`auto` 的背景色识别完全由下游服务负责,主站不读取图片、不调用模型决策颜色、不生成颜色兜底值。 + +## 当前 BgFilter 契约 + +已登录生产服务器核对 `/root/BGfilter`,当前代码版本为 `f1a0833`,运行进程为 `python -m uvicorn app:app --host 0.0.0.0 --port 6006 --workers 1 --no-access-log`。服务契约为 `POST /remove-background` multipart: + +- `background_mode`:可选,`flat` 或 `complex`; +- `screen_color`:可选,支持 `#RRGGBB`、`auto` 或省略;省略/`auto` 时由 BgFilter 从图片边框自动检测; +- `complex` 模式忽略 `screen_color`; +- 自动检测失败由 BgFilter 返回 400。 +- 若配置 `BGFILTER_AUTH_TOKEN`,必须发送 `X-Genarrative-Image-Token`;缺失或错误返回 401;未配置时该接口不在服务层做 token 校验。 + +主站向 BgFilter 发送 `#RRGGBB` 时保留 `#`,`auto` 也原样发送,不做所谓的“hex 转换”。 + +服务器行为:`screen_color` 在 `auto` 或省略时由服务自动检测;`#RRGGBB` 用作指定背景色。`background_mode` 缺省在 BgFilter 侧为 `flat`,因此主站必须为 External v1 旧请求显式归一化为 `complex`,不能把下游服务的默认值直接当成主站默认值。 + +## External v1 请求契约 + +接口保持: + +```text +POST /api/external/v1/editor/images/background-removals +``` + +新增可选字段: + +| 字段 | 取值 | 缺省/行为 | +| --- | --- | --- | +| `backgroundMode` | `complex`、`flat` | 不填按 `complex`,保证旧客户端兼容 | +| `screenColor` | `auto` 或 `#RRGGBB` | 不填则不向 BgFilter 发送该字段 | + +组合规则: + +1. 不传新增字段:按 `complex` 执行。 +2. `complex` 不允许传 `screenColor`,返回 400。 +3. `flat` 可以传具体 `#RRGGBB`、`auto`,也可以省略颜色。 +4. 模式和颜色严格按原值校验;非法值、空字符串、前后空格和大写 `AUTO` / `FLAT` 返回 400。十六进制颜色的字母允许大小写。 +5. 主站只做格式和组合校验;`auto` 不在主站解析,直接转发给 BgFilter。 +6. HTTP 请求中的 `null` 视同省略;模式省略时提供非 null 颜色同样违反 complex 约束。客户端 MCP 可选参数应省略,不传 null。 + +格式或组合错误在入队前返回 400;BgFilter 自动检测失败发生在异步执行阶段,任务通过既有失败状态收口,不把已接受的 202 改成同步 400,不启动其他抠图方式兜底。 + +OpenAPI 与客户端工具的对外说明只描述模式用途、参数约束和调用方可见的结果;provider 选择与兜底策略保留在内部技术方案中,不写入对外 description。 + +主站前端继续不传新增字段,因此用户行为不变。 + +## AGC 客户端改动 + +`agc_remove_background` 增加可选参数: + +```json +{ + "sourceLocalAssetId": "...", + "assetName": "...", + "backgroundMode": "flat", + "screenColor": "auto" +} +``` + +客户端保留旧参数调用;新字段不填时不改变旧调用语义。客户端不读取图片、不自动选色、不把 `auto` 改写为具体颜色,使用原有 Bearer 认证、幂等键和队列返回模型。 + +工具 schema、桥接参数校验和随包 `agc-client-projection` Skill/契约说明必须保持一致。模式与颜色属于请求意图,必须参与客户端幂等指纹;同一图片与名称的不同模式不能复用同一次请求。缺省 complex 且没有颜色时保留既有指纹。主站在默认值归一化之前计算 External 请求指纹,缺失的新字段不序列化,避免旧请求重放发生冲突。 + +## 实施任务 + +### 任务一:冻结 BgFilter 契约 + +记录服务器已支持的模式、颜色格式、自动检测和错误行为。不得把 SSH 地址、服务器凭据写入客户端或公开契约。 + +### 任务二:更新主站 DTO 与 OpenAPI + +为 External v1 和内部任务 DTO 增加可选字段,更新 `docs/openapi/genarrative-external-v1.openapi.json`,写明默认值、组合约束和 400 响应。 + +### 任务三:更新主站归一化与队列 + +缺省模式归一化为 `complex`;`complex + screenColor` 拒绝;`flat` 允许颜色、省略或 `auto`。队列保存字段,worker 始终发送 `background_mode`,仅在调用方提供颜色时发送 `screen_color`,值原样透传。 + +### 任务四:更新 AGC 客户端 + +增加参数 schema、请求体字段和本地校验,更新 Skill、projection contract 与测试。旧客户端请求必须继续有效。 + +### 任务五:联调与验收 + +覆盖旧请求、`flat + auto`、`flat + #RRGGBB`、`flat` 不传颜色、`complex`、`complex + screenColor` 和非法值;使用真实 BgFilter 验证 multipart 字段及自动检测错误传播。 + +## 依赖、发布与回滚 + +先发布兼容的新主站,再发布支持新参数的 AGC 客户端。主站前端无需发布改动。若联调失败,客户端可回退为只传旧字段,主站仍按 `complex` 处理;主站回滚时不改变旧字段语义。 + +## 验收证据 + +2026-09-16 实测: + +- 主站 `cargo test -p api-server background_removal`:36 项通过,覆盖非法请求入队前拒绝、缺省 complex、队列参数保留、旧请求指纹、父侧内部 RPC 和 provider multipart。 +- `cargo test -p api-server bgfilter`:52 项通过,包括 flat 的 auto/省略/具体颜色以及既有生成链路。 +- `exported_openapi_json_contains_external_editor_routes_and_security` 契约测试通过。 +- 客户端 `agent::direct_tools_mcp::tests` 18 项、`agent::skill_pack::tests` 4 项与抠图幂等指纹测试通过;Skill manifest 内容指纹已同步。主站与客户端 rustfmt、文档索引、编码及 diff 检查通过。 +- 真实 BgFilter(版本 `f1a0833`):使用进程内凭据串行请求 `flat + auto`、flat 省略颜色、`flat + #CFEFFF`、complex;四组均返回 200、512×512 RGBA PNG,alpha 范围均为 0–255。 +- 无纯色背景的随机噪声图片使用 flat + auto 返回 400,确认自动识别失败要求调用方提供颜色。测试没有修改服务器代码或配置。 +- 本地 `npm run dev:api-server` 已尝试,但当前配置指向的 SpacetimeDB 不可连接,服务停留在启动恢复重试,`/healthz` 未通过;已结束本次启动。完整登录客户端 → 主站持久化队列 → 结果回写的运行时验收尚未完成,不能用真实 BgFilter 的独立测试代替。没有部署本次主站或客户端代码。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 43706740d..dec89f82b 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1,5 +1,18 @@ # AI 游戏创作智能体 App 实施计划 +## 策划 Agent 批量局部修改 + +`patch_file` 的所有 edits 均匹配同一份原文件,参数顺序不影响结果。完成唯一匹配与不重叠校验后,按原文起点升序拼接未修改片段与替换文本,最后一次性写入;任一校验失败时不写文件。回归用例覆盖乱序 edits、中文内容与替换长度增减,并核对完整落盘内容。此行为仅属于策划 Agent 文件工具。 + +## 资源画布交互与工作台状态同步 + +- 工作台向窗口标题栏发布正在运行的项目时,输入未变化不得形成重复发布与清理的渲染循环;打开项目动作始终使用当前工作台处理逻辑,退出工作台后清除其标题栏状态。 +- 资源子画布(含「所有资源」)保留空白处左键框选、资源卡左键选中/拖动、触摸板双指平移及捏合缩放;右键按住空白处或资源卡拖动时平移画布,不改变资源选择与布局。中键和空格抓手继续可用。总览保留既有左键平移,并支持右键平移。 +- 画布接管的右键手势不弹出原生菜单;输入框、媒体操作、工具条和独立浮层不被画布抢占。指针取消、捕获丢失或窗口失焦后终止平移,不能继续跟随指针。 +- 运行不可用提示只取决于运行能力与 UI 编辑器状态,不因资源选中、取消选中或框选而消失,且不改变运行入口的真实可用性。 +- 本次边界不包含布局算法、持久化坐标、预览读取预算或后端契约调整。首次进入抖动与平移异常须在更新循环消除后单独实测,不能仅凭状态循环修复宣称已解决。 +- 验收包含真实窗口 Context 与工作台的状态同步回归、运行提示与选中并存、左右键分流、平移中断及连续滚轮事件;真实客户端首次进入与触摸板手感为独立人工验收项。 + ## 资源卡选中工具栏与导出 - 共享选中工具栏按实际显示的快速编辑、编辑动作、改造、导出与宿主动作组生成分隔线;空组不产生分隔线,不依赖宿主 CSS 隐藏重复线。 @@ -8,6 +21,10 @@ ## 文档与代码素材预览 +- JSON 资源的卡片按内容语义分流:普通 JSON 显示 JSON 图标/标签,详情按 JSON 代码块显示;UI 设计 JSON 显示 UI 设计标识并提供现有 UI 编辑器入口,不再把 State 原文铺在卡面上。功能分类、manifest kind 和资产身份不因识别而改写。 +- UI 识别由原生文本预览在已登记、受控、完整读取的同一份 UTF-8 内容上完成,复用编辑器的 `game-creator-ui-design-state.v1` 契约解析、canonical 校验、revision/State 校验及项目/资产身份校验。前端只消费识别结果,不按文件名、kind 大小写或正文片段自行认定 UI;普通、损坏、未知 schema、跨项目或跨资产 JSON 不获得 UI 编辑能力。 +- JSON 为完成内容识别继续走现有受限预取队列、并发/容量和 2 MiB 原生文本读取上限。识别失败不写文件、不生成空 State、不做格式迁移;能读取的原文仍可按 JSON 查看,读取失败仍展示原有错误。非 JSON 代码卡继续仅按用户详情请求读取。 +- 编辑器加载/保存/代码生成复核当前项目的已登记 JSON 资产及完整文档,不以 kind 必须精确等于 `UI` 阻断合法已有设计;创建新 UI 状态仍要求正式 UI 资源,不能借普通 JSON 预览初始化或覆盖文件。现有 State 保存锁、CAS、revision 与恢复边界不变。 - 文档与代码素材选中工具栏提供「预览」,打开独立、可滚动的只读弹窗;关闭、切换素材或项目后不残留旧内容。加载中、空文件与读取失败分别呈现,允许重试可重试的错误。 - 复用资源预览队列、身份缓存、项目 scope、失效与权限校验,继续调用 `read_local_project_text_preview`。代码卡不做可见性预取,仅用户显式打开详情时读取;不新增 IPC,不扩大可读取文件范围,不增加编辑/保存能力。 - 文档正文统一使用现有 Markdown 渲染器;代码文件以按扩展名标注语言的 Markdown 围栏代码块呈现。围栏必须长于正文内的反引号串,正文空行与缩进保持原样,不把源码当 Markdown 正文或 HTML 执行。 @@ -19,6 +36,10 @@ ## 2026-09-16 DirectProject 回合展示唯一归属 +- 聊天历史使用 `read_direct_project_history_slice` 的 `messagesOnly: true` 模式,按有正文的 user/assistant 消息分页,默认 20 条;工具/推理原始记录不占聊天页名额、不进入聊天分页响应,也不从磁盘删除。接口省略该选项时维持原始 item 切片语义。响应给出明确的 `oldestItemId` 游标;消息投影不能重新发明分页位置。 +- 消息模式读取逐行过滤原始记录,不在内存中积累整份工具输出;正文、原始 ID 和信封时间原样保留。旧的无 ID 消息不能凭空生成身份,必要时向前扩展到已有消息 ID 边界;没有更早消息时结束分页。 +- 首屏和加载更早消息共用分页解析;重复点击只发一个请求,重叠消息按原始 ID 去重并保留当前显示版本。切项目、同项目重新加载及 A→B→A 的迟到响应不得覆盖当前消息、游标或加载状态;失败保留已有消息与分页位置并允许重试。 +- 历史读取来源与 Runtime 所有权分开记录;从磁盘分页读出的消息不能当作未落盘实时消息保留到新页末尾。重新读取历史时回到最新页,旧页仍可从原生游标再次向前加载;真正尚未回读到的实时消息继续保留,不改原始日志、时间或内容。 - 交付合同:实时消息、历史回读、工具详情与最终回复先归一为按 `clientTurnId` 唯一的回合,再渲染一次。用户消息始终保留;同一回合的正文、工具和耗时不能从消息、实时尾部、未归属尾部等多个出口重复展示。 - 归属来自 `direct-codex:{clientTurnId}:{role}`、文本流中保留的原始 item ID,以及项目历史内明确用户记录之后的 assistant 记录。先在已加载的完整消息集合中关联,再做可见分页;持久历史继续通过 canonical item 切片懒加载,`hasMore` 为真时,未加载回合的工具流不得漂到当前页尾部。禁止将第 N 个有工具回合配给第 N 条用户消息,禁止按文本长度、标点或时间窗猜测归属。缺身份的旧记录保留,不猜造其与其它回合的关联。 - 有回合流时正文与工具位置仅来自 item 边界与 `seq`,工具详情按该回合的 `callId` 关联;没有流时同一个回合容器显示历史消息与工具。整轮累计文本仅在活动回合尚无流和持久 assistant 时作兜底,不另建实时消息出口。 @@ -133,6 +154,7 @@ npm 游戏的可预览产物固定为对应 package 目录下的 `dist/index.htm ## 2026-08-24 Direct Codex 已登记资源查询与媒体生成语义工具 - `agc_tools` 新增 `agc_list_registered_assets` 与 `agc_create_or_derive_resource`。前者按 `kind / assetId / offset / limit` 有界查询客户端权威 manifest,并可显式返回角色动画正式序列帧的稳定 objectKey、assetObjectId 和尺寸;结果不包含完整 manifest、prompt、model、provider route、签名 URL、宿主路径或凭据。后者只接受 `kind / mode / sourceLocalAssetId / prompt / assetName`,`create` 仅允许无源视频、音效和背景音乐,`derive` 必须引用当前项目已登记的 localAssetId,角色动画固定为 derive。 +- `prompt` 上限按 `kind` 分别生效,且工具 schema、MCP 校验、客户端工具桥与提交校验共用同一权威口径(`resource_edit_prompt_max_chars`):背景音乐 140、音效 1900、视频与角色动画 4000、图片编辑 32000。schema 逐 kind 声明 `maxLength` 并在 `prompt` 描述里写明数字,超限必须在发起任何桥请求与付费提交之前失败并回报真实上限;`sourceLocalAssetId` 不是当前项目已登记资源时,错误文案必须直接给出 `agc_list_registered_assets` 与 `agc_list_project_files` → `agc_import_account_assets.localPaths` 两步后续动作。 - 项目路径、projectId、当前 revision、源文件路径与媒体类型、operationId、Idempotency-Key、登录态、项目锁、付费提交、轮询恢复、下载校验与 manifest 事务全部由客户端持有。模型不能提交或覆盖这些字段。同一 Direct `clientTurnId + 规范语义参数` 生成稳定 UUID v4 身份;单回合同参重试复用原 operation,不同请求串行且最多四项。跨回合存在完全匹配的 pending 账本时优先恢复原 operation,不能换键重发。 - 资源查询同时投影未完成 operation 的安全状态。媒体工具成功只返回 operation、本地相对路径、资源类型、Canvas/resource/asset/task 身份、正式序列帧以及脱敏后的 `warnings / sliceWarnings`;错误继续使用统一脱敏边界。客户端资源账本持久化 completed 结果的两类告警,committed replay 不能把历史告警伪装成空集合。 - 角色动画、视频、音效和背景音乐在构造新的远端请求前统一准备当前项目同名画布与素材目录上下文,并在端点支持时携带 `projectId / assetFolderId / canvasCompletion`。角色动画 placeholder 使用源图片真实宽高,避免非方形角色进入画布时失真;正式 resource/asset 与序列帧继续直接复用 External 返回身份,不从首帧伪造重复资源。已有冻结 request body 或已受理 operation 保持不变,不因本次升级重建请求或重复扣费。 diff --git a/docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md b/docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md index ce891e8fc..36b5d19a4 100644 --- a/docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md +++ b/docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md @@ -132,6 +132,8 @@ Runtime 不维护文档版本号,不解析文档版本,不提供版本回退 一轮有多个工具调用时沿用正常工具执行循环。澄清或审批进入等待后,不继续请求 Provider,也不执行同批剩余文件操作;未执行调用明确记录为因等待用户而未执行,不伪造成功结果。恢复历史必须保持工具调用与结果配对,避免出现缺少 tool output 的协议错误。这属于协议与暂停处理,不引入同轮调用次数门禁。 +单次策划工具不再竞争项目级写锁;策划命令锁与会话原子写入已保证同一会话内工具按批次顺序执行。若进程在工具执行标记与结果落盘之间中断,重开项目时的只读 hydrate 必须在拿到策划命令锁后自动续跑原回合,为不确定调用补写“执行结果未保存”的工具错误、跳过剩余调用,并把错误交回 Provider 自愈;不得重放文件副作用,也不要求用户手动恢复。 + 迁移工具集合: ```text @@ -150,7 +152,7 @@ get_workflow_status 工具使用相对工作区路径。工具执行结果继续通过 Runtime 统一记录和展示,但不向 Agent 暴露宿主绝对路径。 -`patch_file` 保留原型按唯一原文匹配修改的语义、换行归一化和缺文件错误。正常工作区写入与删除不逐次请求用户审批;阶段审批不能被复用为文件操作许可。 +`patch_file` 保留原型按唯一原文匹配、范围不重叠、全部通过才原子写入的语义、换行归一化和缺文件错误。批量 edits 会一次性完成全部校验,并把未找到、多处匹配、重叠等失败项汇总返回;未找到时同时给出候选行号和可见化缩进提示,帮助 Provider 基于当前文件修正锚点。正常工作区写入与删除不逐次请求用户审批;阶段审批不能被复用为文件操作许可。 `list_resources` 一次返回完整逻辑分类、资源 ID、标题和简介;`read_resource` 按一个资源 ID 读取一个文件。资源描述不增加 `required=true/false` 分类,也不增加引导同轮多次调用的说明。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index e036f5205..0d1af4ab8 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -670,6 +670,12 @@ Pingora current release 自审脚本 `scripts/ops/pingora-current-release-audit. `Genarrative-Web-Build` 打包 `web.tar.gz` 前、`Genarrative-Web-Deploy` 解包后都会把 Web 静态目录规范为目录 `755`、文件 `644`。如果前端页面能打开但 public 图片、字体或音频返回 `403 Forbidden`,优先检查当前 `/srv/genarrative/web` 指向的 release 中对应文件权限是否被异常归档为 `600`,临时恢复可对该 release 的 `web` 目录执行目录 `755`、文件 `644` 的权限修正。 +### CI 宿主 CPU 上限(Jenkins 16 核 / Gitea Actions runner 12 核) + +`genarrative-station` 上 Jenkins Built-In Node 与 Gitea Actions runner 容器共用同一台 32 逻辑核宿主机,两路 CI 都必须有硬上限,避免构建期把机器顶满、让交互用户卡顿。Jenkins 固定 16 核(50%):在宿主执行 `systemctl set-property jenkins.service CPUQuota=1600%`,立即生效且不需要重启 Jenkins,drop-in 落在 `/etc/systemd/system.control/jenkins.service.d/50-CPUQuota.conf`;该配额覆盖 Built-In Node 上所有子构建(Web / Api / Stdb 的 `npm ci`、Vitest、`tsc`、`cargo` 都跑在这台机器上),Deploy 阶段在远端 `genarrative-dev-deploy` / `genarrative-release-deploy` agent 执行,不受该上限约束。Gitea Actions runner 固定 12 核(37.5%):`/opt/gitea-stack/compose.yml` 的 `runner.cpus` 为 `"12.0"`,调整运行中的容器用 `docker update --cpus=12 gitea-runner`(不重建容器、不中断在跑 job);需要让容器配置与 compose 完全一致时,先确认 Gitea 没有 `in_progress` run,再 `cd /opt/gitea-stack && docker compose up -d runner`。 + +核验与回滚:`cat /sys/fs/cgroup/system.slice/jenkins.service/cpu.max` 期望 `1600000 100000`,`docker inspect gitea-runner --format '{{.HostConfig.NanoCpus}}'` 期望 `12000000000`,`cat /sys/fs/cgroup/system.slice/docker-.scope/cpu.max` 期望 `1200000 100000`。两处 cgroup 的 `cpu.stat` 里 `nr_throttled` / `throttled_usec` 持续增长说明工作负载已经顶到上限,属预期而不是故障。放宽或回滚用 `sudo systemctl set-property jenkins.service CPUQuota=%`、`sudo systemctl revert jenkins.service`、`docker update --cpus= gitea-runner`,并同步 `/opt/gitea-stack/compose.yml`(改前先备份该文件)。 + ## 维护模式只拦截公网流量 Nginx 与 Pingora 在维护 marker 存在时对内网来源绕过整站维护闸,主站页面与静态资源、普通 API、后台页面与 `/admin/api/**`、SpacetimeDB 路由均按非维护状态继续处理;公网应用主站、普通 API、后台和 SpacetimeDB 路由继续返回维护响应。内网范围为 IPv4 loopback / RFC1918 / link-local 和 IPv6 loopback / ULA / link-local。Nginx 只按 TCP `$remote_addr` 判定;Pingora 只按 TCP peer 判定,peer 为 loopback 的同机 Nginx 时才读取 Nginx 强制覆盖的 `X-Real-IP`,绝不能把客户端可伪造的 `X-Forwarded-For` 用作维护放行依据。应用本身的登录、管理员鉴权和其它业务鉴权不变。 diff --git a/server-rs/crates/api-server/src/bgfilter_worker.rs b/server-rs/crates/api-server/src/bgfilter_worker.rs index 666228ba0..595f39548 100644 --- a/server-rs/crates/api-server/src/bgfilter_worker.rs +++ b/server-rs/crates/api-server/src/bgfilter_worker.rs @@ -849,13 +849,13 @@ fn validate_internal_request( } match request.background_mode { BgfilterBackgroundMode::Flat => { - let screen_color = request.screen_color.as_deref().ok_or_else(|| { - WorkerFailure::new("invalid_request", "flat 请求缺少 screenColor", false) - })?; - if !valid_screen_color(screen_color) { + if let Some(screen_color) = request.screen_color.as_deref() + && screen_color != "auto" + && !valid_screen_color(screen_color) + { return Err(WorkerFailure::new( "invalid_request", - "screenColor 必须是 #RRGGBB", + "screenColor 必须是 auto 或 #RRGGBB", false, )); } @@ -2811,7 +2811,13 @@ mod tests { call_budget_ms: 321_000, audit_context: None, }; - assert!(validate_internal_request(&request, &admission).is_err()); + assert!(validate_internal_request(&request, &admission).is_ok()); + request.screen_color = Some("auto".to_string()); + assert!(validate_internal_request(&request, &admission).is_ok()); + for invalid in ["", "AUTO", "#12", "#GGGGGG"] { + request.screen_color = Some(invalid.to_string()); + assert!(validate_internal_request(&request, &admission).is_err()); + } request.screen_color = Some("#CFEFFF".to_string()); assert!(validate_internal_request(&request, &admission).is_ok()); // callBudget 只是父侧配置指纹:与 worker 公式值不一致不得拒绝(发布重启窗口 @@ -2854,6 +2860,85 @@ mod tests { assert!(validate_internal_request(&request, &admission).is_err()); } + #[tokio::test] + async fn background_removal_provider_multipart_preserves_mode_and_color() { + for (mode, color) in [ + (BgfilterBackgroundMode::Complex, None), + (BgfilterBackgroundMode::Flat, None), + (BgfilterBackgroundMode::Flat, Some("auto")), + (BgfilterBackgroundMode::Flat, Some("#Ab12EF")), + ] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (sender, mut receiver) = tokio::sync::mpsc::channel(1); + let router = Router::new().route( + "/remove-background", + post(move |headers: HeaderMap, body: axum::body::Bytes| { + let sender = sender.clone(); + async move { + sender.send((headers, body)).await.unwrap(); + ( + [(axum::http::header::CONTENT_TYPE, "image/png")], + encoded_png(2, 3), + ) + } + }), + ); + let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + let state = AppState::new(AppConfig { + editor_bgfilter_base_url: format!("http://{address}"), + editor_bgfilter_token: Some("provider-test-token".to_string()), + ..AppConfig::default() + }) + .unwrap(); + let request = BgfilterInternalRequest { + request_id: "multipart-test".to_string(), + source_object_key: "editor-upload/source.png".to_string(), + background_mode: mode, + screen_color: color.map(str::to_string), + seg_model: "birefnet".to_string(), + cross_check: false, + max_queue_wait_ms: 1000, + call_budget_ms: 321000, + audit_context: None, + }; + let result = request_provider_once( + &state, + &request, + "https://example.invalid/source.png", + ProviderAttemptBudget { + timeout: Duration::from_secs(5), + budget_limited: false, + }, + 1, + Instant::now() + Duration::from_secs(5), + &BgfilterTaskTracker::new(), + ) + .await; + server.abort(); + let result = result.unwrap(); + assert_eq!((result.width, result.height), (2, 3)); + let (headers, body) = receiver.recv().await.unwrap(); + assert_eq!( + headers[BGFILTER_PROVIDER_TOKEN_HEADER], + "provider-test-token" + ); + let body = String::from_utf8(body.to_vec()).unwrap(); + assert!(body.contains(&format!( + "name=\"background_mode\"\r\n\r\n{}\r\n", + mode.as_str() + ))); + assert!(body.contains("name=\"image_url\"")); + assert!(!body.contains("name=\"file\"")); + match color { + Some(color) => { + assert!(body.contains(&format!("name=\"screen_color\"\r\n\r\n{color}\r\n"))) + } + None => assert!(!body.contains("name=\"screen_color\"")), + } + } + } + #[test] fn provider_timeout_distinguishes_full_attempt_from_budget_truncation() { let now = Instant::now(); diff --git a/server-rs/crates/api-server/src/editor_generation_queue.rs b/server-rs/crates/api-server/src/editor_generation_queue.rs index 0fdb965f9..b615a870b 100644 --- a/server-rs/crates/api-server/src/editor_generation_queue.rs +++ b/server-rs/crates/api-server/src/editor_generation_queue.rs @@ -1019,6 +1019,52 @@ mod tests { ); } + #[test] + fn background_removal_identity_preserves_old_dto_and_distinguishes_options() { + let legacy = json!({ + "sourceImageSrc": "resource-source", "projectId": null, + "targetLayerId": null, "assetKind": null, "generationInputs": null, + "assetFolderId": null, "assetLabel": null, "sourceResourceId": null, + "taskId": null, "canvasCompletion": null, + }); + let old = external_api_editor_generation_request_identity( + "user-1", + EDITOR_BACKGROUND_REMOVAL_JOB_KIND, + &legacy, + "stable-key", + ) + .unwrap(); + let restored: crate::editor_project::EditorBackgroundRemovalRequest = + serde_json::from_value(legacy.clone()).unwrap(); + let current = external_api_editor_generation_request_identity( + "user-1", + EDITOR_BACKGROUND_REMOVAL_JOB_KIND, + &restored, + "stable-key", + ) + .unwrap(); + assert_eq!(old.request_fingerprint, current.request_fingerprint); + assert_eq!(old.job_id, current.job_id); + let mut fingerprints = std::collections::HashSet::new(); + fingerprints.insert(current.request_fingerprint); + for color in [None, Some("auto"), Some("#CFEFFF"), Some("#112233")] { + let mut body = legacy.clone(); + body["backgroundMode"] = json!("flat"); + if let Some(color) = color { + body["screenColor"] = json!(color); + } + let changed = external_api_editor_generation_request_identity( + "user-1", + EDITOR_BACKGROUND_REMOVAL_JOB_KIND, + &body, + "stable-key", + ) + .unwrap(); + assert_eq!(old.job_id, changed.job_id); + assert!(fingerprints.insert(changed.request_fingerprint)); + } + } + #[test] fn external_api_dedupe_key_preserves_legacy_hash_bytes() { let dedupe_key = build_editor_generation_dedupe_key( diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 2c3ccb68e..5f92b841a 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -452,6 +452,10 @@ pub struct EditorImageEditRequest { #[serde(rename_all = "camelCase")] pub struct EditorBackgroundRemovalRequest { pub(crate) source_image_src: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) background_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) screen_color: Option, pub(crate) project_id: Option, pub(crate) target_layer_id: Option, pub(crate) asset_kind: Option, @@ -6300,6 +6304,7 @@ pub(crate) async fn enqueue_editor_background_removal_for_owner( ) }) .transpose()?; + normalize_editor_background_removal_options(&mut payload)?; ensure_editor_reference_image_source_is_stable( payload.source_image_src.as_str(), "editor-background-removal", @@ -6401,6 +6406,52 @@ pub(crate) async fn enqueue_editor_background_removal_for_owner( } } +fn normalize_editor_background_removal_options( + payload: &mut EditorBackgroundRemovalRequest, +) -> Result<(), AppError> { + let mode = payload + .background_mode + .clone() + .unwrap_or_else(|| "complex".to_string()); + if mode != "complex" && mode != "flat" { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-background-removal", + "field": "backgroundMode", + "message": "backgroundMode 必须是 complex 或 flat", + })), + ); + } + let color = payload.screen_color.as_deref(); + if mode == "complex" && color.is_some() { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-background-removal", + "field": "screenColor", + "message": "只有 flat 模式可以提供 screenColor", + })), + ); + } + if let Some(color) = color + && color != "auto" + && !(color.len() == 7 + && color.starts_with('#') + && color[1..] + .chars() + .all(|character| character.is_ascii_hexdigit())) + { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-background-removal", + "field": "screenColor", + "message": "screenColor 必须是 auto 或 #RRGGBB", + })), + ); + } + payload.background_mode = Some(mode); + Ok(()) +} + fn resolve_editor_background_removal_canvas_target( project_id: Option<&str>, target_layer_id: Option<&str>, @@ -6650,6 +6701,7 @@ pub(crate) async fn remove_editor_image_background_for_owner( caller: EditorGenerationCaller, mut payload: EditorBackgroundRemovalRequest, ) -> Result, AppError> { + normalize_editor_background_removal_options(&mut payload)?; payload.generation_inputs = sanitize_editor_client_generation_inputs(payload.generation_inputs.take()); let started_at = Instant::now(); @@ -6725,6 +6777,8 @@ pub(crate) async fn remove_editor_image_background_for_owner( let removed = request_editor_background_removal_image_with_bgfilter_worker( state, source.object_key.as_str(), + payload.background_mode.as_deref().unwrap_or("complex"), + payload.screen_color.as_deref(), &matting_audit, ) .await?; @@ -7948,15 +8002,21 @@ fn editor_bgfilter_flat_deadline_reserve(aliyun_timeout_ms: u64) -> Duration { async fn request_editor_background_removal_image_with_bgfilter_worker( state: &AppState, source_object_key: &str, + background_mode: &str, + screen_color: Option<&str>, audit: &crate::external_api_audit::ExternalApiAuditContext, ) -> Result { - // complex 没有 flat fallback,排队上限只预留 2s 传输窗;worker 重启窗口内的 + // 独立抠图两种模式都不进入生成链路的 fallback,排队上限只预留 2s 传输窗;worker 重启窗口内的 // 连接失败由 client 内部按预算有界重试,避免 max_attempts=1 的队列任务终态失败。 + let mode = match background_mode { + "flat" => crate::bgfilter_worker::BgfilterBackgroundMode::Flat, + _ => crate::bgfilter_worker::BgfilterBackgroundMode::Complex, + }; let removed = crate::bgfilter_worker::request_bgfilter_worker_with_connect_retry( state, source_object_key, - crate::bgfilter_worker::BgfilterBackgroundMode::Complex, - None, + mode, + screen_color, EDITOR_BGFILTER_DEFAULT_SEG_MODEL, EDITOR_BGFILTER_CROSS_CHECK_DISABLED, EDITOR_BGFILTER_PARENT_TRANSPORT_WINDOW, @@ -13669,6 +13729,111 @@ mod tests { thread, }; + #[test] + fn background_removal_options_preserve_queue_parameters_and_legacy_identity() { + for (fields, mode, color) in [ + (json!({}), "complex", None), + (json!({"backgroundMode": "complex"}), "complex", None), + (json!({"backgroundMode": "flat"}), "flat", None), + ( + json!({"backgroundMode": "flat", "screenColor": "auto"}), + "flat", + Some("auto"), + ), + ( + json!({"backgroundMode": "flat", "screenColor": "#Ab12EF"}), + "flat", + Some("#Ab12EF"), + ), + ] { + let mut input = json!({"sourceImageSrc": "resource-source"}); + input + .as_object_mut() + .unwrap() + .extend(fields.as_object().unwrap().clone()); + let mut payload: EditorBackgroundRemovalRequest = + serde_json::from_value(input).unwrap(); + if payload.background_mode.is_none() { + let raw = serde_json::to_value(&payload).unwrap(); + assert!(raw.get("backgroundMode").is_none()); + assert!(raw.get("screenColor").is_none()); + } + normalize_editor_background_removal_options(&mut payload).unwrap(); + let restored: EditorBackgroundRemovalRequest = + serde_json::from_str(&serde_json::to_string(&payload).unwrap()).unwrap(); + assert_eq!(restored.background_mode.as_deref(), Some(mode)); + assert_eq!(restored.screen_color.as_deref(), color); + } + } + + #[test] + fn background_removal_options_reject_invalid_values_and_color_without_flat() { + for fields in [ + json!({"backgroundMode": ""}), + json!({"backgroundMode": "FLAT"}), + json!({"backgroundMode": " flat "}), + json!({"backgroundMode": "other"}), + json!({"screenColor": "auto"}), + json!({"backgroundMode": "complex", "screenColor": "auto"}), + json!({"backgroundMode": "complex", "screenColor": ""}), + json!({"backgroundMode": "flat", "screenColor": ""}), + json!({"backgroundMode": "flat", "screenColor": "AUTO"}), + json!({"backgroundMode": "flat", "screenColor": " auto "}), + json!({"backgroundMode": "flat", "screenColor": "#GGGGGG"}), + json!({"backgroundMode": "flat", "screenColor": "CFEFFF"}), + ] { + let mut input = json!({"sourceImageSrc": "resource-source"}); + input + .as_object_mut() + .unwrap() + .extend(fields.as_object().unwrap().clone()); + let mut payload: EditorBackgroundRemovalRequest = + serde_json::from_value(input).unwrap(); + assert_eq!( + normalize_editor_background_removal_options(&mut payload) + .unwrap_err() + .status_code(), + StatusCode::BAD_REQUEST, + "{fields}" + ); + } + } + + #[tokio::test] + async fn background_removal_flat_options_reach_internal_worker_unchanged() { + for color in [None, Some("auto"), Some("#Ab12EF")] { + let response_png = encode_test_png(3, 2); + let (base_url, receiver, server) = spawn_bgfilter_worker_png_mock(response_png.clone()); + let state = AppState::new(AppConfig { + bgfilter_worker_base_url: base_url, + bgfilter_internal_token: Some("flat-test-token".to_string()), + ..AppConfig::default() + }) + .unwrap(); + let audit = crate::external_api_audit::ExternalApiAuditContext { + user_id: None, + profile_id: None, + request_id: None, + external_call_deadline: None, + }; + let output = request_editor_background_removal_image_with_bgfilter_worker( + &state, + "generated-character-drafts/editor/source.png", + "flat", + color, + &audit, + ) + .await + .unwrap(); + let request = receiver.recv_timeout(Duration::from_secs(1)).unwrap(); + server.join().unwrap(); + let payload = parse_mock_http_json_body(&request); + assert_eq!(payload["backgroundMode"], json!("flat")); + assert_eq!(payload["screenColor"], json!(color)); + assert_eq!(output.image.bytes, response_png); + } + } + #[test] fn ordinary_image_prompt_validation_stays_outside_prompt_builder_execution() { assert!(validate_editor_image_generation_prompt("生成森林场景").is_ok()); @@ -22055,6 +22220,8 @@ mod tests { let removed = request_editor_background_removal_image_with_bgfilter_worker( &state, "generated-character-drafts/editor/manual-source.png", + "complex", + None, &audit, ) .await diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs index eeddb4371..99923530f 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -169,6 +169,8 @@ pub struct ExternalEditorProjectResourceCreateRequest { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct ExternalEditorBackgroundRemovalRequest { source_image_src: String, + background_mode: Option, + screen_color: Option, project_id: Option, target_layer_id: Option, asset_kind: Option, @@ -183,6 +185,8 @@ impl From for EditorBackgroundRemovalReq fn from(payload: ExternalEditorBackgroundRemovalRequest) -> Self { Self { source_image_src: payload.source_image_src, + background_mode: payload.background_mode, + screen_color: payload.screen_color, project_id: payload.project_id, target_layer_id: payload.target_layer_id, asset_kind: payload.asset_kind, @@ -1864,7 +1868,7 @@ mod tests { } #[tokio::test] - async fn external_background_removal_rejects_undocumented_fields_before_queueing() { + async fn external_background_removal_rejects_invalid_parameters_before_queueing() { let state = AppState::new(crate::config::AppConfig::default()) .expect("external background removal test state should build"); state.fail_test_editor_generation_enqueue(); @@ -1886,6 +1890,26 @@ mod tests { json!({"taskId": "caller-controlled-task"}), ), ("unknown field", json!({"unexpected": true})), + ("invalid mode", json!({"backgroundMode": "unknown"})), + ("empty mode", json!({"backgroundMode": ""})), + ("uppercase mode", json!({"backgroundMode": "FLAT"})), + ("color without flat", json!({"screenColor": "auto"})), + ( + "complex with color", + json!({"backgroundMode": "complex", "screenColor": "#123456"}), + ), + ( + "empty color", + json!({"backgroundMode": "flat", "screenColor": ""}), + ), + ( + "invalid color", + json!({"backgroundMode": "flat", "screenColor": "red"}), + ), + ( + "non-string color", + json!({"backgroundMode": "flat", "screenColor": 123}), + ), ] { let mut request_body = json!({"sourceImageSrc": "editor-upload/source.png"}); request_body @@ -2844,6 +2868,25 @@ mod tests { ["EditorBackgroundRemovalRequest"]["properties"]["targetLayerId"]["description"] .as_str() .expect("background removal targetLayerId should document placement semantics"); + let background_properties = + &parsed["components"]["schemas"]["EditorBackgroundRemovalRequest"]["properties"]; + assert_eq!( + background_properties["backgroundMode"]["enum"], + json!(["complex", "flat", null]) + ); + assert_eq!( + background_properties["backgroundMode"]["default"], + "complex" + ); + assert_eq!( + background_properties["screenColor"]["pattern"], + "^(auto|#[0-9A-Fa-f]{6})$" + ); + assert_eq!( + parsed["components"]["schemas"]["EditorBackgroundRemovalRequest"]["then"]["properties"] + ["backgroundMode"]["const"], + "flat" + ); assert!(background_target_description.contains("projectId")); assert!(background_target_description.contains("canvasCompletion")); assert!(background_target_description.contains("assetObjectId")); diff --git a/src/components/creation-home/CreationLandingView.test.tsx b/src/components/creation-home/CreationLandingView.test.tsx index 1785dad1d..d0c59ef83 100644 --- a/src/components/creation-home/CreationLandingView.test.tsx +++ b/src/components/creation-home/CreationLandingView.test.tsx @@ -12,6 +12,7 @@ import userEvent from '@testing-library/user-event'; import { type ContextType, startTransition, Suspense } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { clearSignedAssetReadUrlCache } from '../../services/assetReadUrlService'; import { AuthUiContext } from '../auth/AuthUiContext'; import { CreationLandingView } from './CreationLandingView'; @@ -153,6 +154,27 @@ function SuspendAfterLandingRender({ active }: { active: boolean }) { describe('CreationLandingView', () => { beforeEach(() => { + clearSignedAssetReadUrlCache(); + // 每次换签返回独立响应,筛选等用例也不能触发真实网络请求。 + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + const url = new URL(String(input), 'http://localhost'); + if (url.pathname !== '/api/assets/read-url') { + throw new Error(`Unexpected test request: ${url.pathname}`); + } + return new Response( + JSON.stringify({ + read: { + objectKey: url.searchParams.get('objectKey') ?? '', + signedUrl: 'data:image/png;base64,dGVzdA==', + expiresAt: '2099-01-01T00:00:00Z', + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + }), + ); listPublicEditorProjectResourcesMock.mockResolvedValue([]); }); @@ -161,6 +183,7 @@ describe('CreationLandingView', () => { listPublicEditorProjectResourcesMock.mockReset(); createEditorProjectMock.mockReset(); toggleEditorShowcaseAssetLikeMock.mockReset(); + clearSignedAssetReadUrlCache(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); @@ -1301,17 +1324,18 @@ describe('CreationLandingView', () => { const campaignObjectKey = 'generated-character-drafts/editor/showcase-campaign/current/campaign.png'; const signedCampaignUrl = 'data:image/png;base64,Y2FtcGFpZ24='; - const fetchMock = vi.fn().mockResolvedValue( - new Response( - JSON.stringify({ - read: { - objectKey: campaignObjectKey, - signedUrl: signedCampaignUrl, - expiresAt: '2099-01-01T00:00:00Z', - }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ), + const fetchMock = vi.fn().mockImplementation( + async () => + new Response( + JSON.stringify({ + read: { + objectKey: campaignObjectKey, + signedUrl: signedCampaignUrl, + expiresAt: '2099-01-01T00:00:00Z', + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), ); vi.stubGlobal('fetch', fetchMock); listEditorProjectsMock.mockResolvedValue(projectItems); @@ -1351,7 +1375,7 @@ describe('CreationLandingView', () => { `/api/assets/read-url?objectKey=${encodeURIComponent(campaignObjectKey)}`, expect.objectContaining({ method: 'GET' }), ); - }); + }, 10_000); it('loads more featured resources when the list reaches the end', async () => { const observers = installIntersectionObserverMock(); diff --git a/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx b/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx index 6018727b4..e29b81828 100644 --- a/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx +++ b/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx @@ -2,7 +2,7 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; import { type Dispatch, type SetStateAction, useRef, useState } from 'react'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { CanvasGenerationDialogState, @@ -258,6 +258,10 @@ function KeyboardShortcutsHarness({ } describe('useImageCanvasKeyboardShortcuts', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + it('routes undo and redo while ignoring editable inputs', () => { const undoCanvasChange = vi.fn(); const redoCanvasChange = vi.fn(); diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx index e33258343..ddcd5eaa6 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx +++ b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx @@ -954,6 +954,7 @@ describe('useImageCanvasProjectPersistence', () => { beforeEach(() => { vi.resetAllMocks(); + window.history.replaceState(null, '', '/editor/canvas'); try { globalThis.sessionStorage?.clear(); } catch { diff --git a/src/hooks/useResolvedAssetReadUrl.test.tsx b/src/hooks/useResolvedAssetReadUrl.test.tsx index a5094abb1..0ffd68ffd 100644 --- a/src/hooks/useResolvedAssetReadUrl.test.tsx +++ b/src/hooks/useResolvedAssetReadUrl.test.tsx @@ -3,6 +3,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { RUNTIME_RESOURCE_PENDING_SELECTOR } from '../components/common/RuntimeResourcePendingMarker'; import { ResolvedAssetImage } from '../components/ResolvedAssetImage'; import { clearStoredAccessToken, @@ -215,6 +216,7 @@ describe('useResolvedAssetReadUrl', () => { }); test('refreshKey changes force a fresh signed url request without mutating OSS signature query', async () => { + let requestCount = 0; vi.spyOn(globalThis, 'fetch').mockImplementation( async () => new Response( @@ -224,7 +226,7 @@ describe('useResolvedAssetReadUrl', () => { read: { objectKey: 'generated-puzzle-assets/puzzle-session-1/candidate-1/asset-1/image.png', - signedUrl: 'https://signed.example.com/puzzle.png', + signedUrl: `https://signed.example.com/puzzle.png?x-oss-signature=version-${++requestCount}`, expiresAt: '2099-01-01T00:10:00Z', }, }, @@ -255,7 +257,7 @@ describe('useResolvedAssetReadUrl', () => { const firstImage = await screen.findByRole('img', { name: '候选图' }); expect(firstImage.getAttribute('src')).toBe( - 'https://signed.example.com/puzzle.png', + 'https://signed.example.com/puzzle.png?x-oss-signature=version-1', ); rerender( @@ -267,11 +269,11 @@ describe('useResolvedAssetReadUrl', () => { ); await waitFor(() => { - expect(globalThis.fetch).toHaveBeenCalledTimes(2); + expect( + screen.getByRole('img', { name: '候选图' }).getAttribute('src'), + ).toBe('https://signed.example.com/puzzle.png?x-oss-signature=version-2'); }); - expect( - screen.getByRole('img', { name: '候选图' }).getAttribute('src'), - ).toBe('https://signed.example.com/puzzle.png'); + expect(globalThis.fetch).toHaveBeenCalledTimes(2); }); test('generated 私有资源签名失败时保持空图像而不是回退裸路径', async () => { @@ -300,16 +302,22 @@ describe('useResolvedAssetReadUrl', () => { ), ); - render( + const { container } = render( , ); + expect( + container.querySelector(RUNTIME_RESOURCE_PENDING_SELECTOR), + ).not.toBeNull(); await waitFor(() => { - expect(globalThis.fetch).toHaveBeenCalledTimes(1); + expect( + container.querySelector(RUNTIME_RESOURCE_PENDING_SELECTOR), + ).toBeNull(); }); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); expect(screen.queryByRole('img', { name: '候选图' })).toBeNull(); }); @@ -339,7 +347,7 @@ describe('useResolvedAssetReadUrl', () => { ), ); - render( + const { container } = render( { expect( screen.getByRole('img', { name: '候选图' }).getAttribute('src'), ).toBe('/creation-type-references/puzzle.webp'); + expect( + container.querySelector(RUNTIME_RESOURCE_PENDING_SELECTOR), + ).not.toBeNull(); await waitFor(() => { - expect(globalThis.fetch).toHaveBeenCalledTimes(1); + expect( + container.querySelector(RUNTIME_RESOURCE_PENDING_SELECTOR), + ).toBeNull(); }); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); expect( screen.getByRole('img', { name: '候选图' }).getAttribute('src'), ).toBe('/creation-type-references/puzzle.webp'); diff --git a/vitest.config.ts b/vitest.config.ts index 0af75894a..4d6d56888 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -119,6 +119,7 @@ export default defineConfig({ 'src/components/platform-entry/platformProfile*.test.ts', 'src/components/platform-entry/usePlatformProfileCenterController*.test.tsx', 'src/hooks/useHostNavigationCanGoBack.test.tsx', + 'src/hooks/useResolvedAssetReadUrl.test.tsx', 'apps/admin-web/src/**/*.test.ts', 'apps/admin-web/src/**/*.test.tsx', 'apps/ai-game-creator-shell/tests/**/*.test.ts',