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 41a903d1b..caad4a015 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 @@ -1162,6 +1162,13 @@ fn bridge_project_file_class(path: &str) -> (&'static str, Option<&'static str>) } } +fn bridge_project_file_is_asset_importable(path: &str) -> bool { + matches!( + bridge_project_file_class(path).1, + Some("image/png" | "image/jpeg" | "image/webp") + ) +} + fn bridge_project_file_is_hidden_control_path(path: &str) -> bool { path.split('/').filter(|part| !part.is_empty()).any(|part| { part.eq_ignore_ascii_case(".agent") @@ -1246,6 +1253,7 @@ fn bridge_list_project_files(root: &Path, arguments: &Value) -> Value { "sizeBytes": file.size, "kind": category, "mediaType": media_type, + "assetImportable": bridge_project_file_is_asset_importable(&file.path), "registered": registered_ids.contains_key(&file.path), "localAssetId": registered_ids.get(&file.path), }) @@ -1259,7 +1267,7 @@ fn bridge_list_project_files(root: &Path, arguments: &Value) -> Value { "limit": limit, "nextOffset": next_offset, "files": page, - "next": "未登记图片可把 path 作为项目相对 localPaths 交给 agc_import_account_assets;登记后再用 agc_list_registered_assets 获取 localAssetId。" + "next": "仅把未登记且 assetImportable=true 的 PNG/JPEG/WEBP path 作为项目相对 localPaths 交给 agc_import_account_assets;登记后再用 agc_list_registered_assets 获取 localAssetId。" })) })(); match result { @@ -1416,6 +1424,12 @@ async fn bridge_import_account_assets(state: &DirectToolBridgeState, arguments: Err(error) => failures.push(redact_agent_runtime_error(&state.root, &error, 360)), } } + // Direct tools run outside the normal Runtime action loop. Keep the + // workbench's manifest projection in sync with the durable import so + // an image does not remain visible only through the tool response. + if !imported.is_empty() { + emit_game_creator_manifest_invalidated(&state.root, "agent-asset-import"); + } let status = if failures.is_empty() { "completed" } else if imported.is_empty() { @@ -1959,6 +1973,74 @@ mod tests { )); } + #[test] + fn bridge_project_file_importability_matches_local_image_contract() { + for path in ["assets/hero.png", "assets/hero.jpg", "game/hero.WEBP"] { + assert!( + bridge_project_file_is_asset_importable(path), + "supported raster image should be importable: {path}" + ); + } + for path in [ + "assets/hero.gif", + "assets/hero.svg", + "assets/theme.mp3", + "game/index.html", + ] { + assert!( + !bridge_project_file_is_asset_importable(path), + "unsupported project file must not be advertised as importable: {path}" + ); + } + } + + #[test] + fn bridge_project_file_listing_projects_importability_per_file() { + let temporary = tempfile::tempdir().expect("create project file listing root"); + init_local_game_project_at( + temporary.path(), + "project-file-listing", + "项目文件可导入性测试", + ) + .expect("initialize project file listing root"); + let assets = temporary.path().join("assets"); + fs::create_dir_all(&assets).expect("create project assets directory"); + for name in ["hero.png", "preview.gif", "vector.svg"] { + fs::write(assets.join(name), [0_u8]).expect("write project media file"); + } + + let result = bridge_list_project_files( + temporary.path(), + &json!({ "kind": "image", "offset": 0, "limit": 10 }), + ); + assert_eq!(result.get("isError").and_then(Value::as_bool), Some(false)); + let payload: Value = serde_json::from_str( + result + .pointer("/content/0/text") + .and_then(Value::as_str) + .expect("project file listing text"), + ) + .expect("parse project file listing payload"); + let files = payload + .get("files") + .and_then(Value::as_array) + .expect("project file listing files"); + let importability = files + .iter() + .map(|file| { + ( + file.get("path").and_then(Value::as_str).unwrap_or_default(), + file.get("assetImportable") + .and_then(Value::as_bool) + .expect("assetImportable flag"), + ) + }) + .collect::>(); + assert_eq!(importability.get("assets/hero.png"), Some(&true)); + assert_eq!(importability.get("assets/preview.gif"), Some(&false)); + assert_eq!(importability.get("assets/vector.svg"), Some(&false)); + } + #[test] fn resource_request_uuid_is_stable_v4_and_domain_separated() { let operation = direct_resource_request_uuid("turn-1", "operation", "abc"); 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 df2f3bfb9..085ef68f5 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 @@ -110,7 +110,7 @@ fn direct_tools_mcp_specs() -> Value { }), json!({ "name": "agc_list_project_files", - "description": "列出当前 AGC 项目根下真实存在的安全项目文件,包括尚未登记的本地图片。结果只返回项目相对路径、大小、文件类别和是否已登记;不会读取或返回文件内容、宿主绝对路径、.agent 控制面或凭据。需要把未登记图片作为正式素材使用时,先用此工具取得路径,再把路径交给 agc_import_account_assets 的 localPaths。", + "description": "列出当前 AGC 项目根下真实存在的安全项目文件,包括尚未登记的本地图片。结果只返回项目相对路径、大小、文件类别、是否已登记及 assetImportable;不会读取或返回文件内容、宿主绝对路径、.agent 控制面或凭据。仅把 assetImportable=true 的 PNG/JPEG/WEBP 项目相对路径交给 agc_import_account_assets.localPaths。", "inputSchema": { "type": "object", "properties": { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs index f25a57fef..c1506fa6d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs @@ -159,11 +159,12 @@ pub(crate) fn render_local_asset_prompt_context(root: &Path) -> Result 48 { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/context.rs index 060633ed5..71a033ff8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/context.rs @@ -268,10 +268,12 @@ pub(in crate::agent) fn observe_agent_runtime_conversation( } pub(in crate::agent) fn observe_agent_runtime_assets(root: &Path) -> AgentRuntimeToolObservation { - observation_from_text_result( + observation_from_text_result_with_truncation( "asset.list", render_local_asset_prompt_context(root), "已读取项目资产清单", + AGENT_RUNTIME_FILE_CONTEXT_MAX_CHARS, + false, ) } @@ -703,6 +705,53 @@ pub(in crate::agent) fn observe_agent_runtime_project_search( mod asset_import_input_tests { use super::*; + #[test] + fn asset_list_observation_keeps_candidates_beyond_default_tool_limit() { + let temporary = tempfile::tempdir().expect("create asset context project root"); + init_local_game_project_at( + temporary.path(), + "asset-context-project", + "素材上下文截断测试", + ) + .expect("initialize asset context project"); + let assets = temporary.path().join("assets"); + let game_assets = temporary.path().join("game/assets"); + fs::create_dir_all(&assets).expect("create asset candidate directory"); + fs::create_dir_all(&game_assets).expect("create game asset candidate directory"); + for index in 0..44 { + let directory = if index < 22 { &assets } else { &game_assets }; + fs::write( + directory.join(format!( + "candidate-{index:02}-long-enough-to-cross-the-default-observation-limit.png" + )), + [0_u8], + ) + .expect("write asset candidate"); + } + + let observation = observe_agent_runtime_assets(temporary.path()); + assert_eq!(observation.status, "ok"); + let detail = observation.detail.expect("asset list detail"); + assert!(detail.chars().count() > AGENT_RUNTIME_TOOL_OBSERVATION_MAX_CHARS); + assert!(detail.chars().count() <= AGENT_RUNTIME_FILE_CONTEXT_MAX_CHARS); + assert!(detail.contains("candidate-00-long-enough")); + assert!(detail.contains("candidate-43-long-enough")); + + for (scope, last_candidate) in [ + ("assets", "candidate-21-long-enough"), + ("game/assets", "candidate-43-long-enough"), + ] { + let file_list = observe_agent_runtime_file_list( + temporary.path(), + &serde_json::json!({ "path": scope }), + ); + assert_eq!(file_list.status, "ok"); + let file_detail = file_list.detail.expect("scoped file list detail"); + assert!(file_detail.chars().count() > AGENT_RUNTIME_TOOL_OBSERVATION_MAX_CHARS); + assert!(file_detail.contains(last_candidate)); + } + } + #[test] fn null_import_arrays_are_treated_as_omitted() { let input = serde_json::json!({ diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/helpers.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/helpers.rs index 99b3da82c..92c02d109 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/helpers.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/helpers.rs @@ -94,7 +94,13 @@ pub(in crate::agent) fn observation_from_text_result( result: Result, success_summary: &str, ) -> AgentRuntimeToolObservation { - observation_from_text_result_with_truncation(tool, result, success_summary, false) + observation_from_text_result_with_truncation( + tool, + result, + success_summary, + AGENT_RUNTIME_TOOL_OBSERVATION_MAX_CHARS, + false, + ) } pub(in crate::agent) fn observation_from_text_result_preserving_tail( @@ -102,28 +108,29 @@ pub(in crate::agent) fn observation_from_text_result_preserving_tail( result: Result, success_summary: &str, ) -> AgentRuntimeToolObservation { - observation_from_text_result_with_truncation(tool, result, success_summary, true) + observation_from_text_result_with_truncation( + tool, + result, + success_summary, + AGENT_RUNTIME_TOOL_OBSERVATION_MAX_CHARS, + true, + ) } pub(in crate::agent) fn observation_from_text_result_with_truncation( tool: &str, result: Result, success_summary: &str, + max_chars: usize, preserve_tail: bool, ) -> AgentRuntimeToolObservation { match result { Ok(content) => { let sanitized = sanitize_prompt_context(&content); let detail = if preserve_tail { - truncate_agent_runtime_text_preserving_tail( - sanitized.as_str(), - AGENT_RUNTIME_TOOL_OBSERVATION_MAX_CHARS, - ) + truncate_agent_runtime_text_preserving_tail(sanitized.as_str(), max_chars) } else { - truncate_agent_runtime_text( - sanitized.as_str(), - AGENT_RUNTIME_TOOL_OBSERVATION_MAX_CHARS, - ) + truncate_agent_runtime_text(sanitized.as_str(), max_chars) }; AgentRuntimeToolObservation { tool: tool.to_string(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/project_ops.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/project_ops.rs index 253e64a6e..51df91cea 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/project_ops.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/project_ops.rs @@ -914,7 +914,13 @@ pub(in crate::agent) fn observe_agent_runtime_file_list( .as_deref() .map(|path| format!("已列出 {path}")) .unwrap_or_else(|| "已列出项目文件".to_string()); - observation_from_text_result("file.list", result, &summary) + observation_from_text_result_with_truncation( + "file.list", + result, + &summary, + AGENT_RUNTIME_FILE_CONTEXT_MAX_CHARS, + false, + ) } pub(in crate::agent) fn format_agent_runtime_project_diff(diff: &LocalProjectDiffResult) -> String { 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 37ec8c02b..9652a33ac 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 @@ -1009,6 +1009,104 @@ export function registerProjectWorkbenchFoundationTests() { }); }); + it('renders an imported image as a resource-canvas card after manifest refresh', async () => { + const manifest = createGameCreationAppManifest( + 'workbench-imported-image-canvas', + '导入图片资源画布测试', + ); + manifest.assets = [ + { + id: 'imported-design-doc', + kind: 'design-document', + mediaType: 'text/markdown', + localPath: 'docs/plan.md', + source: { kind: 'generated' }, + }, + ]; + let layoutRevision = 0; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return resourceGraphForInputs(args); + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: args?.expectedProjectId, + mode: args?.mode, + revision: layoutRevision, + positions: [], + updatedAt: layoutRevision, + }; + } + if (command === 'update_local_project_resource_canvas_layout') { + layoutRevision += 1; + return { + status: 'updated', + layout: { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: args?.expectedProjectId, + mode: args?.mode, + revision: layoutRevision, + positions: args?.positions, + updatedAt: layoutRevision, + }, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + const viewProps = { + projectName: manifest.name, + projectPath: '/tmp/workbench-imported-image-canvas', + manifest, + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + supervisor: React.createElement('div', null, '项目总控'), + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), + }; + const rendered = render( + React.createElement(ProjectDevelopmentView, viewProps), + ); + const outline = await screen.findByLabelText('资源栏目大纲'); + expect( + within(outline).getByRole('button', { name: '设计文档' }), + ).not.toBeNull(); + + const refreshedManifest = { + ...manifest, + assets: [ + ...manifest.assets, + { + id: 'imported-image', + kind: 'ui', + mediaType: 'image/png', + localPath: 'assets/uploads/local-imported-image.png', + source: { + kind: 'uploaded' as const, + generationRoute: 'agent.local-asset-import', + }, + }, + ], + }; + rendered.rerender( + React.createElement(ProjectDevelopmentView, { + ...viewProps, + manifest: refreshedManifest, + }), + ); + + fireEvent.click(within(outline).getByRole('button', { name: /美术资源/ })); + expect( + await screen.findByRole('button', { + name: '打开资源详情:美术资源 local-imported-image.png', + }), + ).not.toBeNull(); + }); + it('keeps the empty resource overview identical in both modes', async () => { const manifest = createGameCreationAppManifest( 'workbench-empty-section-overview', diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 7241afb47..fde746b9a 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -4,7 +4,8 @@ - 素材读取区分三类来源:`asset.list` / `agc_list_registered_assets` 是当前项目本地 manifest,`agc_list_project_files` / `file.list` 只发现项目目录中实际存在但可能未登记的文件,`asset.library.list` 是当前登录账号素材库,项目画布资源读取是当前网页项目/画布的完整图片清单;账户素材库不能替代项目画布清单。 - Agent 只接收稳定素材 ID、类型、尺寸和项目相对路径等安全投影。客户端负责重新校验账号/项目归属、换签下载、媒体校验,以及 manifest/画布原子登记;不得向 Agent 暴露绝对路径、签名 URL、objectKey、token 或 Cookie。 -- `canvas.asset_import` 支持账户/画布资源 ID和项目内本地相对路径。拒绝路径穿越、`.agent`、符号链接/reparse point及敏感配置文件;外部宿主文件须由 UI 原生文件选择器授权后导入,不开放任意绝对路径。 +- `canvas.asset_import` 支持账户/画布资源 ID 和项目内本地相对路径。项目文件发现结果以 `assetImportable` 明确区分当前可登记的 PNG/JPEG/WEBP 与仅可发现的 GIF/SVG/其它媒体,Agent 只能提交前者。导入拒绝路径穿越、`.agent`、符号链接/reparse point 及敏感配置文件;外部宿主文件须由 UI 原生文件选择器授权后导入,不开放任意绝对路径。 +- Runtime `asset.list` 与 `file.list` 的详情使用文件上下文上限,而不是普通工具短摘要上限,确保有界候选/目录清单不会因前部内容较长而整体丢失;`asset.list` 超出 48 项或 `file.list` 超出 40 项时仍显式返回剩余数量,Agent 再按候选父目录(例如 `assets`、`game/assets`)缩小范围查询。 - 结果仅返回成功/跳过/失败数量、安全 ID、相对路径、来源、脱敏失败摘要和实际 `revisionAdvanceCount`;幂等跳过不得虚增 revision,部分失败仍须准确记录已发生的 revision 变化。 - 普通 Prompt 上下文与错误诊断必须使用分离的脱敏边界:Prompt 继续对疑似凭据行整体隐藏;错误诊断保留 HTTP 状态以及 `code / field / message / reason / detail` 等安全字段,仅替换 Token、Cookie、私钥、配置名、URL 和宿主路径等敏感值。`agc_create_or_derive_resource.assetName` 是必填的人类可读资源显示名称,不接受项目路径、URL、objectKey、Token 或其它凭据。