修复导入素材后资源画布不刷新

支持账户素材和项目内 PNG/JPEG/WEBP 本地素材导入后的 manifest 失效通知

增加资源画布导入图片回归测试并扩展素材发现上下文

同步更新 Agent 导入契约说明
This commit is contained in:
2026-08-26 01:31:55 +08:00
parent 6b947c5430
commit a8c7cd2f2b
8 changed files with 261 additions and 17 deletions
@@ -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::<BTreeMap<_, _>>();
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");
@@ -110,7 +110,7 @@ fn direct_tools_mcp_specs() -> Value {
}),
json!({
"name": "agc_list_project_files",
"description": "列出当前 AGC 项目根下真实存在的安全项目文件,包括尚未登记的本地图片。结果只返回项目相对路径、大小、文件类别是否已登记;不会读取或返回文件内容、宿主绝对路径、.agent 控制面或凭据。需要把未登记图片作为正式素材使用时,先用此工具取得路径,再把路径交给 agc_import_account_assetslocalPaths。",
"description": "列出当前 AGC 项目根下真实存在的安全项目文件,包括尚未登记的本地图片。结果只返回项目相对路径、大小、文件类别是否已登记及 assetImportable;不会读取或返回文件内容、宿主绝对路径、.agent 控制面或凭据。仅把 assetImportable=true 的 PNG/JPEG/WEBP 项目相对路径交给 agc_import_account_assets.localPaths。",
"inputSchema": {
"type": "object",
"properties": {
@@ -159,11 +159,12 @@ pub(crate) fn render_local_asset_prompt_context(root: &Path) -> Result<String, S
output.push_str(
"# 项目内未登记媒体文件(仅发现,不是正式资产)\n\n\
- 这些文件真实存在于当前项目,但尚未取得 manifest assetId/localAssetId、来源或 provenance。\n\
- 需要正式使用时,先用 `file.list`/`agc_list_project_files` 确认路径,再用受控导入工具登记;不要把路径文本当作已登记资源身份。\n",
- 只有 `assetImportable=true` 的 PNG/JPEG/WEBP 可交给当前图片导入工具;其它媒体只可发现。需要正式使用时,先用 `file.list`/`agc_list_project_files` 确认路径,再用受控导入工具登记;不要把路径文本当作已登记资源身份。\n",
);
for (path, size, media_type) in unregistered.iter().take(48) {
let asset_importable = matches!(*media_type, "image/png" | "image/jpeg" | "image/webp");
output.push_str(&format!(
"- {path} / {media_type} / {size} bytes / registered=false\n"
"- {path} / {media_type} / {size} bytes / registered=false / assetImportable={asset_importable}\n"
));
}
if unregistered.len() > 48 {
@@ -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!({
@@ -94,7 +94,13 @@ pub(in crate::agent) fn observation_from_text_result(
result: Result<String, String>,
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<String, String>,
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<String, String>,
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(),
@@ -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 {
@@ -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<string, unknown>) => {
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',
@@ -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 或其它凭据。