修复导入素材后资源画布不刷新
支持账户素材和项目内 PNG/JPEG/WEBP 本地素材导入后的 manifest 失效通知 增加资源画布导入图片回归测试并扩展素材发现上下文 同步更新 Agent 导入契约说明
This commit is contained in:
@@ -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_assets 的 localPaths。",
|
||||
"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 或其它凭据。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user