补充本地资源注册类型
扩展 AGC 本地资源发现与登记支持的图片、字体、音频、视频、文档和代码类型 同步 Direct/MCP 工具契约、Prompt、Skill 文档与资源扩展名回归测试 保留路径安全、字体校验、幂等登记及 manifest/revision 语义
This commit is contained in:
+1
-1
@@ -10,7 +10,7 @@ Let the client derive projections from real disk changes and trusted tool result
|
||||
## Workflow
|
||||
|
||||
1. Write executable source to `index.html`, `style.css`, and `game.js` in the current cwd. Use only relative paths returned by approved tools for media.
|
||||
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` (PNG/JPEG/WEBP) 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.
|
||||
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.
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ The client projects three distinct facts:
|
||||
|
||||
Do not collapse these facts. A playable file can exist before projection refresh, a registered image can exist without being used by the game, and browser success does not create platform provenance.
|
||||
|
||||
`agc_list_project_files` is the bounded Direct discovery path for real project files. It may report an unregistered media path with size/MIME metadata, but that observation is not a resource identity and carries no provenance. Its `assetImportable` field is true only for PNG/JPEG/WEBP files accepted by the current local-image registration contract; GIF/SVG and non-image files remain discoverable but must not be passed to the image importer. `agc_import_account_assets.localPaths` is the controlled bridge that validates and registers an importable project-local image. `agc_list_registered_assets` remains the authoritative Direct read path for manifest resource identity; only its stable identifiers may be passed to generation/derivation tools.
|
||||
`agc_list_project_files` is the bounded Direct discovery path for real project files. It may report an unregistered project-relative path with size/MIME metadata, but that observation is not a resource identity and carries no provenance. Its `assetImportable` field is true for the file types accepted by the current local registration contract: PNG/JPEG/WEBP/GIF/SVG/AVIF/BMP images, TTF/OTF/WOFF fonts, MP3/WAV/OGG/FLAC/M4A/AAC/OPUS audio, MP4/WEBM/MOV video, recognized text documents, and recognized source-code files. `agc_import_account_assets.localPaths` is the controlled bridge that validates and registers an importable project-local resource. `agc_list_registered_assets` remains the authoritative Direct read path for manifest resource identity; only its stable identifiers may be passed to generation/derivation tools.
|
||||
|
||||
Read scopes remain separate: `asset.list` is the current project's local manifest, `asset.library.list` is the signed-in account library, and the web project's canvas resource read model is authoritative for resources visible on that canvas. A library result must not be presented as the complete canvas list. `canvas.asset_import` accepts safe account/canvas asset IDs or project-relative local paths; receipts expose only bounded counts, safe IDs, relative paths, sources, redacted failures, and `revisionAdvanceCount`.
|
||||
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ Treat the current working directory as the only project root.
|
||||
1. Inspect the existing files needed for the request before editing.
|
||||
2. The current working directory is the selected project root. Read and edit `index.html`, `style.css`, `game.js`, and `assets/` there unless the existing project deliberately uses a `game/` subdirectory for its source.
|
||||
3. To discover media or other existing project files, call `agc_list_project_files` with an optional project-relative scope. It returns safe project-relative paths (including `assets/` and `game/`) plus bounded metadata; an unregistered file is only a discovery candidate, not a manifest asset.
|
||||
4. Platform media and project-local media are exposed read-only through approved `agc_tools`; when a user asks to use an unregistered PNG/JPEG/WEBP, pass the returned project-relative path to `agc_import_account_assets.localPaths`, then re-read `agc_list_registered_assets` for the formal identity. Do not infer provenance or fabricate an asset ID from a filename.
|
||||
4. Platform media and project-local media are exposed read-only through approved `agc_tools`; when a user asks to use an unregistered recognized image, font, audio, video, document, or code file, pass the returned project-relative path to `agc_import_account_assets.localPaths`, then re-read `agc_list_registered_assets` for the formal identity. Do not infer provenance or fabricate an asset ID from a filename.
|
||||
5. Treat the parent `.agent/` directory as client-owned durable state. Do not read it with native file or shell tools; use the approved AGC tools when project identity or registered asset evidence is needed. Never hand-edit manifests, revisions, versions, ledgers, receipts, or provenance records.
|
||||
6. Reuse existing files and asset identities. Do not create a second project root, hidden harness, Supervisor workspace, or parallel implementation.
|
||||
7. Make the smallest coherent change that satisfies the user request, then inspect the actual changed files.
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
| `index.html` | Game source in the current cwd | Read and edit |
|
||||
| `style.css` | Game source in the current cwd | Read and edit |
|
||||
| `game.js` | Game source in the current cwd | Read and edit |
|
||||
| `assets/` | Project media in the current cwd | Read and edit; import an unregistered PNG/JPEG/WEBP through `agc_import_account_assets.localPaths`; formal identity comes only after manifest registration |
|
||||
| `assets/` | Project media in the current cwd | Read and edit; import an unregistered recognized resource through `agc_import_account_assets.localPaths`; formal identity comes only after manifest registration |
|
||||
| Other project-root-relative files | Existing project files | Discover with `agc_list_project_files` or `file.list`; do not treat a path as a registered asset or expose sensitive/control paths |
|
||||
| `.agent/` | AGC client state | Do not read or write with native tools |
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"agents/openai.yaml",
|
||||
"references/structure-contract.md"
|
||||
],
|
||||
"sha256": "2556b40c4e73c8af5c027d1222b569d34129880dfeabff38b50ad248307b5c0c"
|
||||
"sha256": "85dd861201d7b9a5d34b702b7b79ce18012e06784dddf2fb8e836e085fee4b00"
|
||||
},
|
||||
{
|
||||
"name": "taonier-art-assets",
|
||||
@@ -106,7 +106,7 @@
|
||||
"agents/openai.yaml",
|
||||
"references/projection-contract.md"
|
||||
],
|
||||
"sha256": "2e11baf232bd1a786cc3189a9183e3a687b846c4e0816393e5b5687551a5eeb7"
|
||||
"sha256": "7800e8ee4b5baa6f473b904f262197f5320a46150ae5e51c56a298fc21674e30"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3969,7 +3969,7 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials(
|
||||
DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error)
|
||||
})?;
|
||||
if prepare_art {
|
||||
system_prompt.push_str("\n本回合已由陶泥儿平台准备并登记真实美术资源。请按需读取当前 cwd 的游戏源码;正式素材先用 `agc_list_registered_assets` 选择。如果发现项目中实际存在但清单没有的 PNG/JPEG/WEBP,先用 `agc_list_project_files` 发现,再把项目相对路径交给 `agc_import_account_assets.localPaths` 登记,随后重新读取 `agc_list_registered_assets`;不要从文件名伪造 assetId/localAssetId,也不要假设四切片一定存在或伪造缺失衍生物。客户端会在回合后启动真实 desktop/mobile 浏览器试玩,把结构化截图、Canvas、控制台、网络和交互证据发回同一会话;请依据证据自行决定是否继续修复。");
|
||||
system_prompt.push_str("\n本回合已由陶泥儿平台准备并登记真实资源。请按需读取当前 cwd 的游戏源码;正式素材先用 `agc_list_registered_assets` 选择。如果发现项目中实际存在但清单没有的已识别图片、字体、音频、视频、文档或代码文件,先用 `agc_list_project_files` 发现,再把项目相对路径交给 `agc_import_account_assets.localPaths` 登记,随后重新读取 `agc_list_registered_assets`;不要从文件名伪造 assetId/localAssetId,也不要假设四切片一定存在或伪造缺失衍生物。客户端会在回合后启动真实 desktop/mobile 浏览器试玩,把结构化截图、Canvas、控制台、网络和交互证据发回同一会话;请依据证据自行决定是否继续修复。");
|
||||
emit_direct_game_creator_progress(root, "codex.start", "美术素材已准备,正在生成游戏代码");
|
||||
} else {
|
||||
system_prompt.push_str("\n这是已有游戏的继续编辑回合:不要生成、下载或请求任何新美术,也不要创建新项目。直接读取当前 cwd 的游戏源码,并按用户需求最小修改;随后通过 `agc_browser_playtest` 获取真实 desktop/mobile 浏览器证据。客户端会把结构化证据回灌同一会话。");
|
||||
|
||||
@@ -1268,6 +1268,8 @@ fn bridge_project_file_class(path: &str) -> (&'static str, Option<&'static str>)
|
||||
"png" => ("image", Some("image/png")),
|
||||
"jpg" | "jpeg" => ("image", Some("image/jpeg")),
|
||||
"webp" => ("image", Some("image/webp")),
|
||||
"avif" => ("image", Some("image/avif")),
|
||||
"bmp" => ("image", Some("image/bmp")),
|
||||
"gif" => ("image", Some("image/gif")),
|
||||
"svg" => ("image", Some("image/svg+xml")),
|
||||
"ttf" => ("font", Some("font/ttf")),
|
||||
@@ -1279,15 +1281,26 @@ fn bridge_project_file_class(path: &str) -> (&'static str, Option<&'static str>)
|
||||
"ogg" => ("audio", Some("audio/ogg")),
|
||||
"flac" => ("audio", Some("audio/flac")),
|
||||
"m4a" => ("audio", Some("audio/mp4")),
|
||||
"aac" => ("audio", Some("audio/aac")),
|
||||
"opus" => ("audio", Some("audio/opus")),
|
||||
"mp4" => ("video", Some("video/mp4")),
|
||||
"webm" => ("video", Some("video/webm")),
|
||||
"mov" => ("video", Some("video/quicktime")),
|
||||
"md" | "markdown" | "mdx" | "txt" | "json" | "yaml" | "yml" | "toml"
|
||||
| "csv" | "ini" | "conf" | "xml" => ("document", Some(match extension.as_str() {
|
||||
"json" => "application/json",
|
||||
"yaml" | "yml" => "application/yaml",
|
||||
"xml" => "application/xml",
|
||||
_ => "text/plain",
|
||||
})),
|
||||
"html" | "htm" => ("code", Some("text/html")),
|
||||
"css" => ("code", Some("text/css")),
|
||||
"css" | "scss" | "less" => ("code", Some("text/css")),
|
||||
"js" | "mjs" | "cjs" | "ts" | "tsx" => ("code", Some("text/javascript")),
|
||||
"json" => ("code", Some("application/json")),
|
||||
"md" | "txt" => ("code", Some("text/plain")),
|
||||
"gd" => ("code", Some("text/plain")),
|
||||
"gd" | "rs" | "py" | "go" | "java" | "kt" | "kts" | "c" | "cc" | "cpp"
|
||||
| "h" | "hpp" | "cs" | "swift" | "php" | "rb" | "lua" | "sh" | "bash"
|
||||
| "zsh" | "sql" | "graphql" | "gql" | "vue" | "svelte" => {
|
||||
("code", Some("text/plain"))
|
||||
}
|
||||
_ => ("other", None),
|
||||
}
|
||||
}
|
||||
@@ -1295,7 +1308,7 @@ 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")
|
||||
Some(_)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1332,7 +1345,7 @@ fn bridge_list_project_files(root: &Path, arguments: &Value) -> Value {
|
||||
.map(|value| value.to_lowercase());
|
||||
let requested_kind = bridge_optional_bounded_string(arguments, "kind", 16)?
|
||||
.unwrap_or_else(|| "all".to_string());
|
||||
if !["all", "image", "font", "audio", "video", "code"].contains(&requested_kind.as_str()) {
|
||||
if !["all", "image", "font", "audio", "video", "document", "code"].contains(&requested_kind.as_str()) {
|
||||
return Err("工具参数 kind 不是受支持的项目文件类别".to_string());
|
||||
}
|
||||
let (offset, limit) = bridge_account_asset_page(arguments)?;
|
||||
@@ -1396,7 +1409,7 @@ fn bridge_list_project_files(root: &Path, arguments: &Value) -> Value {
|
||||
"limit": limit,
|
||||
"nextOffset": next_offset,
|
||||
"files": page,
|
||||
"next": "仅把未登记且 assetImportable=true 的 PNG/JPEG/WEBP path 作为项目相对 localPaths 交给 agc_import_account_assets;登记后再用 agc_list_registered_assets 获取 localAssetId。"
|
||||
"next": "仅把未登记且 assetImportable=true 的项目相对路径交给 agc_import_account_assets;登记后再用 agc_list_registered_assets 获取 localAssetId。"
|
||||
}))
|
||||
})();
|
||||
match result {
|
||||
@@ -1542,7 +1555,7 @@ async fn bridge_list_account_assets(state: &DirectToolBridgeState, arguments: &V
|
||||
"limit": limit,
|
||||
"nextOffset": next_offset,
|
||||
"assets": page,
|
||||
"next": "账户素材或 project-canvas 资源使用返回的 assetId/resourceId 调用 agc_import_account_assets;项目内本地图片先用 agc_list_project_files;不要提交 objectKey、URL 或宿主绝对路径"
|
||||
"next": "账户素材或 project-canvas 资源使用返回的 assetId/resourceId 调用 agc_import_account_assets;项目内本地资源先用 agc_list_project_files;不要提交 objectKey、URL 或宿主绝对路径"
|
||||
}))
|
||||
}
|
||||
.await;
|
||||
@@ -1590,7 +1603,7 @@ async fn bridge_import_account_assets(state: &DirectToolBridgeState, arguments:
|
||||
}
|
||||
}
|
||||
if !local_paths.is_empty() {
|
||||
match import_local_project_image_assets_for_agent(&state.root, &local_paths) {
|
||||
match import_local_project_assets_for_agent(&state.root, &local_paths) {
|
||||
Ok(result) => imported.extend(result.assets.into_iter().map(|asset| {
|
||||
json!({
|
||||
"id": asset.id,
|
||||
@@ -2407,15 +2420,22 @@ mod tests {
|
||||
"supported raster image should be importable: {path}"
|
||||
);
|
||||
}
|
||||
for path in ["assets/theme.bin", "assets/unknown.xyz"] {
|
||||
assert!(
|
||||
!bridge_project_file_is_asset_importable(path),
|
||||
"unsupported project file must not be advertised as importable: {path}"
|
||||
);
|
||||
}
|
||||
for path in [
|
||||
"assets/hero.gif",
|
||||
"assets/hero.svg",
|
||||
"assets/theme.mp3",
|
||||
"game/index.html",
|
||||
"assets/design.md",
|
||||
] {
|
||||
assert!(
|
||||
!bridge_project_file_is_asset_importable(path),
|
||||
"unsupported project file must not be advertised as importable: {path}"
|
||||
bridge_project_file_is_asset_importable(path),
|
||||
"recognized project file should be advertised as importable: {path}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2463,8 +2483,8 @@ mod tests {
|
||||
})
|
||||
.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));
|
||||
assert_eq!(importability.get("assets/preview.gif"), Some(&true));
|
||||
assert_eq!(importability.get("assets/vector.svg"), Some(&true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -212,7 +212,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value {
|
||||
}),
|
||||
json!({
|
||||
"name": "agc_list_project_files",
|
||||
"description": "列出当前 AGC 项目根下真实存在的安全项目文件,包括尚未登记的本地图片。结果只返回项目相对路径、大小、文件类别、是否已登记及 assetImportable;不会读取或返回文件内容、宿主绝对路径、.agent 控制面或凭据。仅把 assetImportable=true 的 PNG/JPEG/WEBP 项目相对路径交给 agc_import_account_assets.localPaths。",
|
||||
"description": "列出当前 AGC 项目根下真实存在的安全项目文件,包括尚未登记的本地资源。结果只返回项目相对路径、大小、文件类别、是否已登记及 assetImportable;不会读取或返回文件内容、宿主绝对路径、.agent 控制面或凭据。仅把 assetImportable=true 的项目相对路径交给 agc_import_account_assets.localPaths。",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -229,7 +229,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value {
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": ["all", "image", "font", "audio", "video", "code"]
|
||||
"enum": ["all", "image", "font", "audio", "video", "document", "code"]
|
||||
},
|
||||
"offset": { "type": "integer", "minimum": 0, "maximum": 500 },
|
||||
"limit": { "type": "integer", "minimum": 1, "maximum": 100 }
|
||||
@@ -239,7 +239,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value {
|
||||
}),
|
||||
json!({
|
||||
"name": "agc_list_account_assets",
|
||||
"description": "查询当前登录账户网页/云端素材库,以及当前项目已绑定网页画布 project.resources 中的静态图片安全投影。客户端重新校验当前账号并隐藏 objectKey、URL、签名地址、宿主路径和凭据;结果中的 assetId/resourceId 可交给 agc_import_account_assets;项目内本地图片另用 agc_list_project_files 发现。",
|
||||
"description": "查询当前登录账户网页/云端素材库,以及当前项目已绑定网页画布 project.resources 中的静态图片安全投影。客户端重新校验当前账号并隐藏 objectKey、URL、签名地址、宿主路径和凭据;结果中的 assetId/resourceId 可交给 agc_import_account_assets;项目内本地资源另用 agc_list_project_files 发现。",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -253,7 +253,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value {
|
||||
}),
|
||||
json!({
|
||||
"name": "agc_import_account_assets",
|
||||
"description": "导入账户图片、已绑定网页项目画布图片或项目内本地图片。assetIds 必须使用 agc_list_account_assets 返回的账户 assetId 或 project-canvas resourceId;本地图片只能使用 agc_list_project_files 返回的项目根相对 localPaths(包括 assets/ 与 game/),不得使用 .agent、父级穿越或宿主绝对路径。assetIds 与 localPaths 可混合提交;客户端负责账号/项目归属、换签下载、格式/大小校验、项目锁、manifest 与 revision,模型不得提交 objectKey、URL 或凭据。",
|
||||
"description": "导入账户图片、已绑定网页项目画布图片或项目内本地资源。assetIds 必须使用 agc_list_account_assets 返回的账户 assetId 或 project-canvas resourceId;本地资源只能使用 agc_list_project_files 返回的项目根相对 localPaths(包括 assets/ 与 game/),不得使用 .agent、父级穿越或宿主绝对路径。assetIds 与 localPaths 可混合提交;客户端负责账号/项目归属、换签下载、格式/大小校验、项目锁、manifest 与 revision,模型不得提交 objectKey、URL 或凭据。",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -569,7 +569,9 @@ fn validate_project_file_list_arguments(arguments: &Value) -> Result<(), String>
|
||||
}
|
||||
if arguments.get("kind").is_some() {
|
||||
let kind = bounded_tool_string(arguments, "kind", 16)?;
|
||||
if !["all", "image", "font", "audio", "video", "code"].contains(&kind.as_str()) {
|
||||
if !["all", "image", "font", "audio", "video", "document", "code"]
|
||||
.contains(&kind.as_str())
|
||||
{
|
||||
return Err("工具参数 kind 不是受支持的项目文件类别".to_string());
|
||||
}
|
||||
}
|
||||
@@ -1344,6 +1346,11 @@ mod tests {
|
||||
"limit": 100
|
||||
}))
|
||||
.is_ok());
|
||||
assert!(validate_project_file_list_arguments(&json!({
|
||||
"path": "assets",
|
||||
"kind": "document"
|
||||
}))
|
||||
.is_ok());
|
||||
assert!(validate_project_file_list_arguments(&json!({
|
||||
"path": "../outside"
|
||||
}))
|
||||
|
||||
@@ -159,10 +159,10 @@ pub(crate) fn render_local_asset_prompt_context(root: &Path) -> Result<String, S
|
||||
output.push_str(
|
||||
"# 项目内未登记媒体文件(仅发现,不是正式资产)\n\n\
|
||||
- 这些文件真实存在于当前项目,但尚未取得 manifest assetId/localAssetId、来源或 provenance。\n\
|
||||
- 只有 `assetImportable=true` 的 PNG/JPEG/WEBP 可交给当前图片导入工具;其它媒体只可发现。需要正式使用时,先用 `file.list`/`agc_list_project_files` 确认路径,再用受控导入工具登记;不要把路径文本当作已登记资源身份。\n",
|
||||
- 只有 `assetImportable=true` 的已识别图片、字体、音频、视频、文档或代码文件可交给当前资源导入工具;其它文件只可发现。需要正式使用时,先用 `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");
|
||||
let asset_importable = !media_type.is_empty();
|
||||
output.push_str(&format!(
|
||||
"- {path} / {media_type} / {size} bytes / registered=false / assetImportable={asset_importable}\n"
|
||||
));
|
||||
@@ -200,6 +200,8 @@ fn prompt_context_media_type(path: &str) -> Option<&'static str> {
|
||||
"jpg" | "jpeg" => Some("image/jpeg"),
|
||||
"webp" => Some("image/webp"),
|
||||
"gif" => Some("image/gif"),
|
||||
"avif" => Some("image/avif"),
|
||||
"bmp" => Some("image/bmp"),
|
||||
"svg" => Some("image/svg+xml"),
|
||||
"ttf" => Some("font/ttf"),
|
||||
"otf" => Some("font/otf"),
|
||||
@@ -210,13 +212,48 @@ fn prompt_context_media_type(path: &str) -> Option<&'static str> {
|
||||
"ogg" => Some("audio/ogg"),
|
||||
"flac" => Some("audio/flac"),
|
||||
"m4a" => Some("audio/mp4"),
|
||||
"aac" => Some("audio/aac"),
|
||||
"opus" => Some("audio/opus"),
|
||||
"mp4" => Some("video/mp4"),
|
||||
"webm" => Some("video/webm"),
|
||||
"mov" => Some("video/quicktime"),
|
||||
"json" => Some("application/json"),
|
||||
"yaml" | "yml" => Some("application/yaml"),
|
||||
"xml" => Some("application/xml"),
|
||||
"html" | "htm" => Some("text/html"),
|
||||
"md" | "markdown" | "mdx" | "txt" | "toml" | "csv" | "ini" | "conf" => {
|
||||
Some("text/plain")
|
||||
}
|
||||
"css" | "scss" | "less" => Some("text/css"),
|
||||
"js" | "mjs" | "cjs" | "ts" | "tsx" | "gd" | "rs" | "py" | "go"
|
||||
| "java" | "kt" | "kts" | "c" | "cc" | "cpp" | "h" | "hpp" | "cs"
|
||||
| "swift" | "php" | "rb" | "lua" | "sh" | "bash" | "zsh" | "sql"
|
||||
| "graphql" | "gql" | "vue" | "svelte" => Some("text/plain"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::prompt_context_media_type;
|
||||
|
||||
#[test]
|
||||
fn prompt_context_media_type_covers_all_importable_resource_categories() {
|
||||
for (path, expected) in [
|
||||
("assets/icon.avif", "image/avif"),
|
||||
("assets/font.woff2", "font/woff2"),
|
||||
("assets/theme.opus", "audio/opus"),
|
||||
("assets/intro.mov", "video/quicktime"),
|
||||
("assets/data.json", "application/json"),
|
||||
("game/index.html", "text/html"),
|
||||
("game/main.rs", "text/plain"),
|
||||
] {
|
||||
assert_eq!(prompt_context_media_type(path), Some(expected), "{path}");
|
||||
}
|
||||
assert_eq!(prompt_context_media_type("assets/unknown.bin"), None);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn render_local_conversation_prompt_context(
|
||||
root: &Path,
|
||||
agent_id: Option<&str>,
|
||||
|
||||
+2
-2
@@ -384,8 +384,8 @@ fn build_game_creator_agent_background_tool_plan_request_at(
|
||||
"file.list 使用 {{\"path\":\"\"}},path 为空字符串时列出项目摘要;file.read 使用 {{\"path\":\"项目内相对路径\",\"startLine\":1,\"maxLines\":120}};file.write 使用 {{\"path\":\"项目内相对路径\",\"content\":\"完整文件内容\"}};file.patch 使用 {{\"path\":\"项目内相对路径\",\"oldText\":\"必须精确匹配的原文\",\"newText\":\"替换后的文本\",\"expectedReplacements\":1}};file.delete 使用 {{\"path\":\"项目内相对路径\"}},只删除项目内普通文件,不删除目录或任何 .agent 控制面文件。\n",
|
||||
"task.create 使用 {{\"taskId\":null,\"title\":\"任务标题\",\"group\":\"design|art|code|balance|audio|publishing\",\"role\":\"角色名\",\"dependencies\":[],\"artifacts\":[],\"acceptanceCriteria\":[\"验收标准\"],\"status\":\"pending|running|waiting-for-confirmation|completed|failed\"}},需要自定义 taskId 时把 null 替换为合法 ID;task.update 使用 {{\"taskId\":\"manifest taskId\",\"status\":\"pending|running|waiting-for-confirmation|completed|failed\"}};{limited_command_contract}\n",
|
||||
"canvas.asset_generate 使用 {{\"prompt\":\"图片描述\",\"outputPath\":null,\"aspectRatio\":null,\"imageSize\":null,\"assetKind\":null,\"assetLabel\":null,\"replaceExisting\":false}};需要指定时,aspectRatio 只允许 1:1|2:3|3:2|9:16|16:9,imageSize 只允许 0.5K|1K|2K,assetKind 只允许 {canvas_asset_kind_catalog}。replaceExisting 只能在带 repairOfDelegationId 的唯一返工委派中设为 true,普通生成必须为 false,并通过配置的 External Editor API 同时写入画布、同名素材库目录和本地 assets。\n",
|
||||
"asset.library.list 使用 {{\"folderId\":null,\"query\":null,\"offset\":0,\"limit\":100}} 查询当前登录账户的网页/云端静态图片,以及当前项目已绑定网页画布的 project.resources 图片;结果只含 assetId/resourceId 与安全展示元数据,不含 URL、objectKey、签名地址或凭据。账户或画布图片必须先查询再导入。file.list/asset.list 仍用于发现项目内尚未登记的本地图片。\n",
|
||||
"canvas.asset_import 使用 {{\"assetIds\":[],\"localPaths\":[]}};assetIds 必须来自最近一次 asset.library.list(账户素材或 project-canvas 资源均可),localPaths 必须是 file.list 返回的项目根内相对 PNG/JPEG/WEBP 路径(包括 assets/ 与 game/),不能提交 objectKey、URL、绝对路径或凭据。两类数组可以混合提交;导入成功后 Runtime 会下载/校验或登记本地图片、更新 manifest 并推进 revision。\n",
|
||||
"asset.library.list 使用 {{\"folderId\":null,\"query\":null,\"offset\":0,\"limit\":100}} 查询当前登录账户的网页/云端静态图片,以及当前项目已绑定网页画布的 project.resources 图片;结果只含 assetId/resourceId 与安全展示元数据,不含 URL、objectKey、签名地址或凭据。账户或画布图片必须先查询再导入。file.list/asset.list 仍用于发现项目内尚未登记的本地资源。\n",
|
||||
"canvas.asset_import 使用 {{\"assetIds\":[],\"localPaths\":[]}};assetIds 必须来自最近一次 asset.library.list(账户素材或 project-canvas 资源均可),localPaths 必须是 file.list 返回的项目根内相对已识别资源路径(包括 assets/ 与 game/),不能提交 objectKey、URL、绝对路径或凭据。两类数组可以混合提交;导入成功后 Runtime 会下载/校验或登记本地资源、更新 manifest 并推进 revision。\n",
|
||||
"blackboard.write 使用 {{\"title\":\"标题\",\"content\":\"要共享给所有 Agent 的稳定结论\"}};agent.message 使用 {{\"agentId\":\"目标 taskId\",\"content\":\"给目标 Agent 的定向消息\"}};agent.delegate 使用 {{\"agentId\":\"目标 taskId\",\"task\":\"要委派的后台任务\",\"acceptanceCriteria\":[\"可核对的语义验收条件\"],\"expectedArtifacts\":[],\"repairOfDelegationId\":null,\"runId\":null}},expectedArtifacts 无产物时传空数组且不接受 glob;返工时 repairOfDelegationId 指向已认领原 delivery 且 runId 必须为 null;agent.schedule_ready 使用 {{\"limit\":1}};agent.run_status 使用 {{\"agentId\":null,\"scope\":\"all\",\"delegationId\":null}},指定目标 Agent 或已认领 delegation 时把对应 null 替换为实际 ID;当前可信父 Run 传 delegationId 时读取自己已认领的未截断权威返工合同。\n",
|
||||
"只有 conversation.read、asset.list、project.index、project.checkpoint、task.list、preview.start 的 arguments.input 使用空对象 {{}};asset.library.list 也允许使用其广告 schema 中的全 null/分页默认值;其他函数必须提交实际广告 schema 的全部 required 字段。如果已有观察足够,必须调用 respond_to_user 交付最终回复。"
|
||||
),
|
||||
|
||||
@@ -475,7 +475,7 @@ fn runtime_asset_import_string_array(
|
||||
.any(|part| part.eq_ignore_ascii_case(".codex"))
|
||||
|| reject_sensitive_project_file_read(text).is_err()
|
||||
{
|
||||
return Err("localPaths 只能使用受控项目根内的项目相对图片路径".to_string());
|
||||
return Err("localPaths 只能使用受控项目根内的项目相对资源路径".to_string());
|
||||
}
|
||||
}
|
||||
Ok(text.to_string())
|
||||
@@ -579,7 +579,7 @@ pub(in crate::agent) async fn observe_agent_runtime_asset_import(
|
||||
}
|
||||
}
|
||||
if !local_paths.is_empty() {
|
||||
match import_local_project_image_assets_for_agent(root, &local_paths) {
|
||||
match import_local_project_assets_for_agent(root, &local_paths) {
|
||||
Ok(result) => imported.extend(result.assets.into_iter().map(|asset| {
|
||||
serde_json::json!({
|
||||
"id": asset.id,
|
||||
|
||||
@@ -1356,7 +1356,7 @@ fn runtime_tool_description(tool: &str) -> &'static str {
|
||||
"读取当前登录账户的云端/网页素材库静态图片,以及当前项目已绑定网页画布 project.resources 图片安全投影;不返回 URL、objectKey、签名地址或凭据。"
|
||||
}
|
||||
"canvas.asset_import" => {
|
||||
"把账户素材库/绑定网页项目画布中的 assetId(resourceId)或项目内 localPaths 图片导入当前项目并登记 manifest;两类输入可混合。账户与画布素材先用 asset.library.list 查询,本地图片先用 file.list 发现;客户端内部负责归属校验、换签下载、格式/大小校验、项目锁和 revision。"
|
||||
"把账户素材库/绑定网页项目画布中的 assetId(resourceId)或项目内 localPaths 资源导入当前项目并登记 manifest;两类输入可混合。账户与画布素材先用 asset.library.list 查询,本地资源先用 file.list 发现;客户端内部负责归属校验、换签下载、格式/大小校验、项目锁和 revision。"
|
||||
}
|
||||
"project.index" => "刷新并读取有界仓库启动上下文。",
|
||||
"project.search" => "在项目文本文件中做有界字面量搜索。",
|
||||
|
||||
@@ -1428,26 +1428,81 @@ pub(crate) fn percent_encode_query_component(value: &str) -> String {
|
||||
pub(crate) fn infer_file_extension(source: Option<&str>, media_type: &str) -> &'static str {
|
||||
if let Some(source) = source {
|
||||
let path = source.split('?').next().unwrap_or(source);
|
||||
if path.ends_with(".png") {
|
||||
return "png";
|
||||
}
|
||||
if path.ends_with(".jpg") || path.ends_with(".jpeg") {
|
||||
return "jpg";
|
||||
}
|
||||
if path.ends_with(".webp") {
|
||||
return "webp";
|
||||
}
|
||||
if path.ends_with(".gif") {
|
||||
return "gif";
|
||||
}
|
||||
if path.ends_with(".mp3") {
|
||||
return "mp3";
|
||||
}
|
||||
if path.ends_with(".wav") {
|
||||
return "wav";
|
||||
}
|
||||
if path.ends_with(".mp4") {
|
||||
return "mp4";
|
||||
match Path::new(path)
|
||||
.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.map(|extension| extension.to_ascii_lowercase())
|
||||
.as_deref()
|
||||
{
|
||||
Some("png") => return "png",
|
||||
Some("jpg" | "jpeg") => return "jpg",
|
||||
Some("webp") => return "webp",
|
||||
Some("gif") => return "gif",
|
||||
Some("svg") => return "svg",
|
||||
Some("avif") => return "avif",
|
||||
Some("bmp") => return "bmp",
|
||||
Some("ttf") => return "ttf",
|
||||
Some("otf") => return "otf",
|
||||
Some("woff") => return "woff",
|
||||
Some("woff2") => return "woff2",
|
||||
Some("mp3") => return "mp3",
|
||||
Some("wav") => return "wav",
|
||||
Some("ogg") => return "ogg",
|
||||
Some("flac") => return "flac",
|
||||
Some("m4a") => return "m4a",
|
||||
Some("aac") => return "aac",
|
||||
Some("opus") => return "opus",
|
||||
Some("mp4") => return "mp4",
|
||||
Some("webm") => return "webm",
|
||||
Some("mov") => return "mov",
|
||||
Some("md") => return "md",
|
||||
Some("markdown") => return "markdown",
|
||||
Some("mdx") => return "mdx",
|
||||
Some("txt") => return "txt",
|
||||
Some("json") => return "json",
|
||||
Some("yaml") => return "yaml",
|
||||
Some("yml") => return "yml",
|
||||
Some("toml") => return "toml",
|
||||
Some("csv") => return "csv",
|
||||
Some("ini") => return "ini",
|
||||
Some("conf") => return "conf",
|
||||
Some("xml") => return "xml",
|
||||
Some("html") => return "html",
|
||||
Some("htm") => return "htm",
|
||||
Some("css") => return "css",
|
||||
Some("scss") => return "scss",
|
||||
Some("less") => return "less",
|
||||
Some("js") => return "js",
|
||||
Some("mjs") => return "mjs",
|
||||
Some("cjs") => return "cjs",
|
||||
Some("ts") => return "ts",
|
||||
Some("tsx") => return "tsx",
|
||||
Some("gd") => return "gd",
|
||||
Some("rs") => return "rs",
|
||||
Some("py") => return "py",
|
||||
Some("go") => return "go",
|
||||
Some("java") => return "java",
|
||||
Some("kt") => return "kt",
|
||||
Some("kts") => return "kts",
|
||||
Some("c") => return "c",
|
||||
Some("cc") => return "cc",
|
||||
Some("cpp") => return "cpp",
|
||||
Some("h") => return "h",
|
||||
Some("hpp") => return "hpp",
|
||||
Some("cs") => return "cs",
|
||||
Some("swift") => return "swift",
|
||||
Some("php") => return "php",
|
||||
Some("rb") => return "rb",
|
||||
Some("lua") => return "lua",
|
||||
Some("sh") => return "sh",
|
||||
Some("bash") => return "bash",
|
||||
Some("zsh") => return "zsh",
|
||||
Some("sql") => return "sql",
|
||||
Some("graphql") => return "graphql",
|
||||
Some("gql") => return "gql",
|
||||
Some("vue") => return "vue",
|
||||
Some("svelte") => return "svelte",
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
match media_type.split(';').next().unwrap_or(media_type).trim() {
|
||||
@@ -1455,9 +1510,29 @@ pub(crate) fn infer_file_extension(source: Option<&str>, media_type: &str) -> &'
|
||||
"image/jpeg" => "jpg",
|
||||
"image/webp" => "webp",
|
||||
"image/gif" => "gif",
|
||||
"image/svg+xml" => "svg",
|
||||
"image/avif" => "avif",
|
||||
"image/bmp" => "bmp",
|
||||
"font/ttf" => "ttf",
|
||||
"font/otf" => "otf",
|
||||
"font/woff" => "woff",
|
||||
"font/woff2" => "woff2",
|
||||
"audio/mpeg" => "mp3",
|
||||
"audio/wav" | "audio/x-wav" => "wav",
|
||||
"audio/ogg" => "ogg",
|
||||
"audio/flac" => "flac",
|
||||
"audio/mp4" => "m4a",
|
||||
"audio/aac" => "aac",
|
||||
"audio/opus" => "opus",
|
||||
"video/mp4" => "mp4",
|
||||
"video/webm" => "webm",
|
||||
"video/quicktime" => "mov",
|
||||
"application/json" => "json",
|
||||
"application/yaml" => "yaml",
|
||||
"application/xml" => "xml",
|
||||
"text/html" => "html",
|
||||
"text/css" => "css",
|
||||
"text/plain" => "txt",
|
||||
_ => "bin",
|
||||
}
|
||||
}
|
||||
@@ -1722,6 +1797,14 @@ mod tests {
|
||||
use super::*;
|
||||
use std::io::{Read, Write};
|
||||
|
||||
#[test]
|
||||
fn infer_file_extension_preserves_supported_local_resource_extensions() {
|
||||
assert_eq!(infer_file_extension(Some("game/Index.HTML"), "text/html"), "html");
|
||||
assert_eq!(infer_file_extension(Some("assets/theme.MP3"), "audio/mpeg"), "mp3");
|
||||
assert_eq!(infer_file_extension(Some("assets/font.woff2"), "font/woff2"), "woff2");
|
||||
assert_eq!(infer_file_extension(None, "application/json"), "json");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_session_maps_external_shaped_calls_to_first_party_routes() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -2818,7 +2818,7 @@ mod agent_asset_import_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_project_image_import_registers_in_place_and_is_idempotent() {
|
||||
fn local_project_asset_import_registers_multiple_types_and_is_idempotent() {
|
||||
let project = tempfile::tempdir().expect("create project directory");
|
||||
let root = project.path();
|
||||
init_local_game_project_at(root, "agent-local-import", "Agent local import")
|
||||
@@ -2827,34 +2827,53 @@ mod agent_asset_import_tests {
|
||||
fs::create_dir_all(root.join("game")).expect("create game directory");
|
||||
fs::write(root.join("assets/in-place.png"), tiny_png()).expect("write in-place image");
|
||||
fs::write(root.join("game/copied.png"), tiny_png()).expect("write game image");
|
||||
fs::write(root.join("assets/theme.mp3"), b"fake-mp3").expect("write audio");
|
||||
fs::write(root.join("assets/intro.mp4"), b"fake-mp4").expect("write video");
|
||||
fs::write(root.join("game/index.html"), b"<html></html>").expect("write code");
|
||||
fs::write(root.join("assets/design.md"), b"# design").expect("write document");
|
||||
|
||||
let revision_before = read_game_creator_agent_runtime_project_revision(root)
|
||||
.expect("read initial revision")
|
||||
.revision;
|
||||
let first = import_local_project_image_assets_for_agent(
|
||||
let first = import_local_project_assets_for_agent(
|
||||
root,
|
||||
&[
|
||||
"assets/in-place.png".to_string(),
|
||||
"game/copied.png".to_string(),
|
||||
"assets/theme.mp3".to_string(),
|
||||
"assets/intro.mp4".to_string(),
|
||||
"game/index.html".to_string(),
|
||||
"assets/design.md".to_string(),
|
||||
],
|
||||
)
|
||||
.expect("import local images");
|
||||
assert_eq!(first.assets.len(), 2);
|
||||
.expect("import local assets");
|
||||
assert_eq!(first.assets.len(), 6);
|
||||
assert_eq!(first.assets[0].local_path, "assets/in-place.png");
|
||||
assert!(first.assets[1]
|
||||
.local_path
|
||||
.starts_with("assets/uploads/local-"));
|
||||
assert!(first.assets[1].local_path.ends_with(".png"));
|
||||
assert_eq!(first.assets[0].asset_kind.as_deref(), Some("ui"));
|
||||
assert_eq!(first.assets[2].asset_kind.as_deref(), Some("audio"));
|
||||
assert_eq!(first.assets[3].asset_kind.as_deref(), Some("video"));
|
||||
assert_eq!(first.assets[4].asset_kind.as_deref(), Some("code"));
|
||||
assert_eq!(first.assets[5].asset_kind.as_deref(), Some("document"));
|
||||
assert!(first.assets[4].local_path.ends_with(".html"));
|
||||
assert!(root.join(&first.assets[1].local_path).is_file());
|
||||
let revision_after_first = read_game_creator_agent_runtime_project_revision(root)
|
||||
.expect("read imported revision")
|
||||
.revision;
|
||||
assert_eq!(revision_after_first, revision_before + 2);
|
||||
assert_eq!(revision_after_first, revision_before + 6);
|
||||
|
||||
let second = import_local_project_image_assets_for_agent(
|
||||
let second = import_local_project_assets_for_agent(
|
||||
root,
|
||||
&[
|
||||
"assets/in-place.png".to_string(),
|
||||
"game/copied.png".to_string(),
|
||||
"assets/theme.mp3".to_string(),
|
||||
"assets/intro.mp4".to_string(),
|
||||
"game/index.html".to_string(),
|
||||
"assets/design.md".to_string(),
|
||||
],
|
||||
)
|
||||
.expect("reimport local images");
|
||||
@@ -2868,17 +2887,17 @@ mod agent_asset_import_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_project_image_import_rejects_absolute_and_case_insensitive_agent_paths() {
|
||||
fn local_project_asset_import_rejects_absolute_and_case_insensitive_agent_paths() {
|
||||
let project = tempfile::tempdir().expect("create project directory");
|
||||
let root = project.path();
|
||||
init_local_game_project_at(root, "agent-local-import", "Agent local import")
|
||||
.expect("initialize project");
|
||||
assert!(import_local_project_image_assets_for_agent(
|
||||
assert!(import_local_project_assets_for_agent(
|
||||
root,
|
||||
&[root.join("image.png").to_string_lossy().into_owned()]
|
||||
)
|
||||
.is_err());
|
||||
assert!(import_local_project_image_assets_for_agent(
|
||||
assert!(import_local_project_assets_for_agent(
|
||||
root,
|
||||
&[".AGENT/manifest.json".to_string()]
|
||||
)
|
||||
@@ -2886,7 +2905,7 @@ mod agent_asset_import_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_project_image_import_rejects_hidden_and_build_tree_sources() {
|
||||
fn local_project_asset_import_rejects_hidden_and_build_tree_sources() {
|
||||
let project = tempfile::tempdir().expect("create project directory");
|
||||
let root = project.path();
|
||||
init_local_game_project_at(root, "agent-local-import", "Agent local import")
|
||||
@@ -2909,11 +2928,32 @@ mod agent_asset_import_tests {
|
||||
.expect("create forbidden source directory");
|
||||
fs::write(&source, tiny_png()).expect("write forbidden source image");
|
||||
assert!(
|
||||
import_local_project_image_assets_for_agent(root, &[relative.clone()]).is_err(),
|
||||
import_local_project_assets_for_agent(root, &[relative.clone()]).is_err(),
|
||||
"forbidden source path should be rejected: {relative}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_project_asset_import_rejects_unknown_and_invalid_text_files() {
|
||||
let project = tempfile::tempdir().expect("create project directory");
|
||||
let root = project.path();
|
||||
init_local_game_project_at(root, "agent-local-import", "Agent local import")
|
||||
.expect("initialize project");
|
||||
fs::create_dir_all(root.join("assets")).expect("create assets directory");
|
||||
fs::write(root.join("assets/unknown.bin"), b"bytes").expect("write unknown file");
|
||||
fs::write(root.join("assets/broken.js"), [0xff, 0xfe]).expect("write invalid source");
|
||||
assert!(import_local_project_assets_for_agent(
|
||||
root,
|
||||
&["assets/unknown.bin".to_string()]
|
||||
)
|
||||
.is_err());
|
||||
assert!(import_local_project_assets_for_agent(
|
||||
root,
|
||||
&["assets/broken.js".to_string()]
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_asset_local_path(asset_id: &str, extension: &str) -> String {
|
||||
@@ -3406,10 +3446,18 @@ pub(crate) async fn list_editor_assets_for_agent_at(
|
||||
"status": "completed",
|
||||
"total": assets.len(),
|
||||
"assets": assets,
|
||||
"next": "账户素材使用 assetId;网页项目画布资源也使用返回的 resourceId/assetId;本地图片先用 file.list,再把项目相对路径交给 canvas.asset_import;不要提交 objectKey、URL 或本地绝对路径"
|
||||
"next": "账户素材使用 assetId;网页项目画布资源也使用返回的 resourceId/assetId;本地资源先用 file.list,再把项目相对路径交给 canvas.asset_import;不要提交 objectKey、URL 或本地绝对路径"
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
struct AgentLocalProjectFileType {
|
||||
category: &'static str,
|
||||
asset_kind: &'static str,
|
||||
media_type: &'static str,
|
||||
max_file_size: u64,
|
||||
}
|
||||
|
||||
fn agent_image_media_type(bytes: &[u8]) -> Option<&'static str> {
|
||||
if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||
Some("image/png")
|
||||
@@ -3422,6 +3470,169 @@ fn agent_image_media_type(bytes: &[u8]) -> Option<&'static str> {
|
||||
}
|
||||
}
|
||||
|
||||
fn agent_local_project_file_type(
|
||||
relative_path: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<AgentLocalProjectFileType, String> {
|
||||
let extension = Path::new(relative_path)
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
let file_type = match extension.as_str() {
|
||||
"png" | "jpg" | "jpeg" | "webp" | "gif" | "svg" | "avif" | "bmp" => {
|
||||
let media_type = match extension.as_str() {
|
||||
"png" => agent_image_media_type(bytes),
|
||||
"jpg" | "jpeg" => {
|
||||
bytes.starts_with(&[0xff, 0xd8, 0xff]).then_some("image/jpeg")
|
||||
}
|
||||
"webp" => (bytes.len() >= 12
|
||||
&& &bytes[..4] == b"RIFF"
|
||||
&& &bytes[8..12] == b"WEBP")
|
||||
.then_some("image/webp"),
|
||||
"gif" => (bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a"))
|
||||
.then_some("image/gif"),
|
||||
"svg" => std::str::from_utf8(bytes)
|
||||
.ok()
|
||||
.filter(|text| text.to_ascii_lowercase().contains("<svg"))
|
||||
.map(|_| "image/svg+xml"),
|
||||
"avif" => (!bytes.is_empty()).then_some("image/avif"),
|
||||
"bmp" => bytes.starts_with(b"BM").then_some("image/bmp"),
|
||||
_ => None,
|
||||
};
|
||||
media_type.map(|media_type| AgentLocalProjectFileType {
|
||||
category: "image",
|
||||
asset_kind: "ui",
|
||||
media_type,
|
||||
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
|
||||
})
|
||||
}
|
||||
"ttf" | "otf" | "woff" | "woff2" => Some(AgentLocalProjectFileType {
|
||||
category: "font",
|
||||
asset_kind: "font",
|
||||
media_type: match extension.as_str() {
|
||||
"ttf" => "font/ttf",
|
||||
"otf" => "font/otf",
|
||||
"woff" => "font/woff",
|
||||
_ => "font/woff2",
|
||||
},
|
||||
max_file_size: UI_EDITOR_FONT_MAX_FILE_SIZE,
|
||||
}),
|
||||
"mp3" => Some(AgentLocalProjectFileType {
|
||||
category: "audio",
|
||||
asset_kind: "audio",
|
||||
media_type: "audio/mpeg",
|
||||
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
|
||||
}),
|
||||
"wav" => Some(AgentLocalProjectFileType {
|
||||
category: "audio",
|
||||
asset_kind: "audio",
|
||||
media_type: "audio/wav",
|
||||
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
|
||||
}),
|
||||
"ogg" => Some(AgentLocalProjectFileType {
|
||||
category: "audio",
|
||||
asset_kind: "audio",
|
||||
media_type: "audio/ogg",
|
||||
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
|
||||
}),
|
||||
"flac" => Some(AgentLocalProjectFileType {
|
||||
category: "audio",
|
||||
asset_kind: "audio",
|
||||
media_type: "audio/flac",
|
||||
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
|
||||
}),
|
||||
"m4a" => Some(AgentLocalProjectFileType {
|
||||
category: "audio",
|
||||
asset_kind: "audio",
|
||||
media_type: "audio/mp4",
|
||||
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
|
||||
}),
|
||||
"aac" => Some(AgentLocalProjectFileType {
|
||||
category: "audio",
|
||||
asset_kind: "audio",
|
||||
media_type: "audio/aac",
|
||||
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
|
||||
}),
|
||||
"opus" => Some(AgentLocalProjectFileType {
|
||||
category: "audio",
|
||||
asset_kind: "audio",
|
||||
media_type: "audio/opus",
|
||||
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
|
||||
}),
|
||||
"mp4" => Some(AgentLocalProjectFileType {
|
||||
category: "video",
|
||||
asset_kind: "video",
|
||||
media_type: "video/mp4",
|
||||
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
|
||||
}),
|
||||
"webm" => Some(AgentLocalProjectFileType {
|
||||
category: "video",
|
||||
asset_kind: "video",
|
||||
media_type: "video/webm",
|
||||
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
|
||||
}),
|
||||
"mov" => Some(AgentLocalProjectFileType {
|
||||
category: "video",
|
||||
asset_kind: "video",
|
||||
media_type: "video/quicktime",
|
||||
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
|
||||
}),
|
||||
"md" | "markdown" | "mdx" | "txt" | "json" | "yaml" | "yml" | "toml"
|
||||
| "csv" | "ini" | "conf" | "xml" => Some(AgentLocalProjectFileType {
|
||||
category: "document",
|
||||
asset_kind: "document",
|
||||
media_type: if extension == "json" {
|
||||
"application/json"
|
||||
} else if matches!(extension.as_str(), "yaml" | "yml") {
|
||||
"application/yaml"
|
||||
} else if extension == "xml" {
|
||||
"application/xml"
|
||||
} else {
|
||||
"text/plain"
|
||||
},
|
||||
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
|
||||
}),
|
||||
"html" | "htm" | "css" | "scss" | "less" | "js" | "mjs" | "cjs" | "ts"
|
||||
| "tsx" | "gd" | "rs" | "py" | "go" | "java" | "kt" | "kts" | "c" | "cc"
|
||||
| "cpp" | "h" | "hpp" | "cs" | "swift" | "php" | "rb" | "lua" | "sh"
|
||||
| "bash" | "zsh" | "sql" | "graphql" | "gql" | "vue" | "svelte" => {
|
||||
Some(AgentLocalProjectFileType {
|
||||
category: "code",
|
||||
asset_kind: "code",
|
||||
media_type: if matches!(extension.as_str(), "html" | "htm") {
|
||||
"text/html"
|
||||
} else if matches!(extension.as_str(), "css" | "scss" | "less") {
|
||||
"text/css"
|
||||
} else {
|
||||
"text/plain"
|
||||
},
|
||||
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let file_type = file_type.ok_or_else(|| format!("本地文件类型不受支持:{relative_path}"))?;
|
||||
if file_type.category == "font" {
|
||||
FontAsset::from_verified_bytes(
|
||||
"font-validation",
|
||||
"assets/fonts/validation.ttf",
|
||||
Path::new(relative_path)
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("font"),
|
||||
bytes,
|
||||
)?;
|
||||
} else if bytes.is_empty() {
|
||||
return Err(format!("本地文件不能为空:{relative_path}"));
|
||||
} else if matches!(file_type.category, "document" | "code")
|
||||
&& std::str::from_utf8(bytes).is_err()
|
||||
{
|
||||
return Err(format!("本地文本文件不是有效 UTF-8:{relative_path}"));
|
||||
}
|
||||
Ok(file_type)
|
||||
}
|
||||
|
||||
fn local_agent_asset_destination(relative_path: &str, bytes: &[u8], extension: &str) -> String {
|
||||
let digest = format!("{:x}", Sha256::digest(bytes));
|
||||
let stem = Path::new(relative_path)
|
||||
@@ -3437,30 +3648,30 @@ fn local_agent_asset_destination(relative_path: &str, bytes: &[u8], extension: &
|
||||
)
|
||||
}
|
||||
|
||||
fn reject_agent_local_image_source_path(normalized_path: &str) -> Result<(), String> {
|
||||
fn reject_agent_local_resource_source_path(normalized_path: &str) -> Result<(), String> {
|
||||
if should_skip_project_snapshot_path(normalized_path)
|
||||
|| normalized_path
|
||||
.split('/')
|
||||
.any(|part| part.eq_ignore_ascii_case(".codex"))
|
||||
{
|
||||
return Err("本地图片导入不得访问隐藏、构建或工具控制目录".to_string());
|
||||
return Err("本地资源导入不得访问隐藏、构建或工具控制目录".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 从当前项目根目录内的相对路径导入未登记图片。Agent 不能提交宿主绝对路径,
|
||||
/// 从当前项目根目录内的相对路径导入未登记资源。Agent 不能提交宿主绝对路径,
|
||||
/// 也不能穿越项目根;路径外文件仍由 UI 原生文件选择器导入。
|
||||
pub(crate) fn import_local_project_image_assets_for_agent(
|
||||
pub(crate) fn import_local_project_assets_for_agent(
|
||||
root: &Path,
|
||||
relative_paths: &[String],
|
||||
) -> Result<RemoteImportResult, String> {
|
||||
enforce_project_permission_policy(root, "canvas.asset_import")?;
|
||||
validate_project_root(root)?;
|
||||
if relative_paths.is_empty() {
|
||||
return Err("本地图片导入至少需要一个项目相对路径".to_string());
|
||||
return Err("本地资源导入至少需要一个项目相对路径".to_string());
|
||||
}
|
||||
if relative_paths.len() > UI_EDITOR_IMAGE_MAX_COUNT {
|
||||
return Err(format!("一次最多导入 {} 张图片", UI_EDITOR_IMAGE_MAX_COUNT));
|
||||
return Err(format!("一次最多导入 {} 个本地资源", UI_EDITOR_IMAGE_MAX_COUNT));
|
||||
}
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let mut destinations = std::collections::BTreeSet::new();
|
||||
@@ -3470,31 +3681,30 @@ pub(crate) fn import_local_project_image_assets_for_agent(
|
||||
let normalized = normalize_relative_path(raw_path.trim())?;
|
||||
reject_agent_runtime_private_control_path(&normalized)?;
|
||||
reject_sensitive_project_file_read(&normalized)?;
|
||||
reject_agent_local_image_source_path(&normalized)?;
|
||||
reject_agent_local_resource_source_path(&normalized)?;
|
||||
let source = resolve_local_project_path(root, &normalized)?;
|
||||
prepare_game_creator_private_path_for_read(&source, false, "本地图片")?;
|
||||
prepare_game_creator_private_path_for_read(&source, false, "本地资源")?;
|
||||
let metadata =
|
||||
fs::symlink_metadata(&source).map_err(|_| format!("本地图片不存在:{normalized}"))?;
|
||||
fs::symlink_metadata(&source).map_err(|_| format!("本地资源不存在:{normalized}"))?;
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
return Err(format!("本地素材只能是普通图片文件:{normalized}"));
|
||||
return Err(format!("本地素材只能是普通文件:{normalized}"));
|
||||
}
|
||||
if metadata.len() > UI_EDITOR_IMAGE_MAX_FILE_SIZE {
|
||||
let bytes = fs::read(&source).map_err(|_| format!("读取本地资源失败:{normalized}"))?;
|
||||
let file_type = agent_local_project_file_type(&normalized, &bytes)?;
|
||||
if metadata.len() > file_type.max_file_size {
|
||||
return Err(format!(
|
||||
"本地图片超过单文件 {} 字节限制",
|
||||
UI_EDITOR_IMAGE_MAX_FILE_SIZE
|
||||
"本地资源超过单文件 {} 字节限制:{normalized}",
|
||||
file_type.max_file_size
|
||||
));
|
||||
}
|
||||
let bytes = fs::read(&source).map_err(|_| format!("读取本地图片失败:{normalized}"))?;
|
||||
let media_type = agent_image_media_type(&bytes)
|
||||
.ok_or_else(|| format!("本地文件不是受支持的 PNG/JPEG/WEBP 图片:{normalized}"))?;
|
||||
total_size = total_size
|
||||
.checked_add(bytes.len() as u64)
|
||||
.filter(|size| *size <= UI_EDITOR_IMAGE_MAX_TOTAL_SIZE)
|
||||
.ok_or_else(|| "本地图片批次总量超过 256 MiB 限制".to_string())?;
|
||||
.ok_or_else(|| "本地资源批次总量超过 256 MiB 限制".to_string())?;
|
||||
if !seen.insert(normalized.clone()) {
|
||||
continue;
|
||||
}
|
||||
let extension = infer_file_extension(Some(&normalized), media_type);
|
||||
let extension = infer_file_extension(Some(&normalized), file_type.media_type);
|
||||
let local_path = if normalized.starts_with("assets/") {
|
||||
normalized.clone()
|
||||
} else {
|
||||
@@ -3503,13 +3713,18 @@ pub(crate) fn import_local_project_image_assets_for_agent(
|
||||
if !destinations.insert(local_path.clone()) {
|
||||
continue;
|
||||
}
|
||||
inputs.push((normalized, local_path, media_type.to_string(), bytes));
|
||||
inputs.push((
|
||||
local_path,
|
||||
file_type.asset_kind.to_string(),
|
||||
file_type.media_type.to_string(),
|
||||
bytes,
|
||||
));
|
||||
}
|
||||
|
||||
let _lock = acquire_project_write_lock(root, "canvas.asset_import")?;
|
||||
let manifest = read_existing_manifest_for_project(root)?;
|
||||
let mut imported = Vec::with_capacity(inputs.len());
|
||||
for (source_path, local_path, media_type, bytes) in inputs {
|
||||
for (local_path, asset_kind, media_type, bytes) in inputs {
|
||||
let target = resolve_local_project_path(root, &local_path)?;
|
||||
if let Some(existing) = manifest
|
||||
.assets
|
||||
@@ -3524,15 +3739,15 @@ pub(crate) fn import_local_project_image_assets_for_agent(
|
||||
continue;
|
||||
}
|
||||
if target.exists() {
|
||||
prepare_game_creator_private_path_for_read(&target, false, "目标图片")?;
|
||||
let existing_bytes = fs::read(&target).map_err(|_| "读取目标图片失败".to_string())?;
|
||||
prepare_game_creator_private_path_for_read(&target, false, "目标资源")?;
|
||||
let existing_bytes = fs::read(&target).map_err(|_| "读取目标资源失败".to_string())?;
|
||||
if existing_bytes != bytes {
|
||||
return Err(format!("本地图片目标已存在且内容不同:{local_path}"));
|
||||
return Err(format!("本地资源目标已存在且内容不同:{local_path}"));
|
||||
}
|
||||
} else {
|
||||
if let Some(parent) = target.parent() {
|
||||
ensure_game_creator_private_directory_tree(parent, "本地图片导入目录")?;
|
||||
prepare_game_creator_private_path_for_read(parent, true, "本地图片导入目录")?;
|
||||
ensure_game_creator_private_directory_tree(parent, "本地资源导入目录")?;
|
||||
prepare_game_creator_private_path_for_read(parent, true, "本地资源导入目录")?;
|
||||
}
|
||||
let mut options = fs::OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
@@ -3543,8 +3758,8 @@ pub(crate) fn import_local_project_image_assets_for_agent(
|
||||
}
|
||||
let mut file = options
|
||||
.open(&target)
|
||||
.map_err(|error| format!("写入本地图片失败:{}: {error}", target.display()))?;
|
||||
if let Err(error) = harden_new_game_creator_private_path(&target, false, "目标图片")
|
||||
.map_err(|error| format!("写入本地资源失败:{}: {error}", target.display()))?;
|
||||
if let Err(error) = harden_new_game_creator_private_path(&target, false, "目标资源")
|
||||
{
|
||||
drop(file);
|
||||
let _ = fs::remove_file(&target);
|
||||
@@ -3552,13 +3767,13 @@ pub(crate) fn import_local_project_image_assets_for_agent(
|
||||
}
|
||||
file.write_all(&bytes)
|
||||
.and_then(|_| file.sync_all())
|
||||
.map_err(|error| format!("写入本地图片失败:{}: {error}", target.display()))?;
|
||||
.map_err(|error| format!("写入本地资源失败:{}: {error}", target.display()))?;
|
||||
drop(file);
|
||||
}
|
||||
let registered = register_local_asset_entry(
|
||||
root,
|
||||
&local_path,
|
||||
"ui",
|
||||
&asset_kind,
|
||||
&media_type,
|
||||
"local",
|
||||
GameCreationAppAssetSource {
|
||||
@@ -3577,10 +3792,10 @@ pub(crate) fn import_local_project_image_assets_for_agent(
|
||||
imported.push(ImportedAsset {
|
||||
id: registered.id,
|
||||
local_path: registered.local_path,
|
||||
asset_kind: Some("ui".to_string()),
|
||||
asset_kind: Some(asset_kind),
|
||||
});
|
||||
advance_agent_runtime_project_revision_locked(root).map_err(|error| {
|
||||
format!("reconciliation-required: 本地图片已登记,但项目 revision 未能推进:{error}")
|
||||
format!("reconciliation-required: 本地资源已登记,但项目 revision 未能推进:{error}")
|
||||
})?;
|
||||
}
|
||||
Ok(RemoteImportResult { assets: imported })
|
||||
@@ -4008,7 +4223,7 @@ pub(crate) fn list_local_project_files(
|
||||
list_local_project_files_at(root)
|
||||
}
|
||||
|
||||
/// 登记当前项目中已经存在、但尚未写入 manifest 的本地图片。
|
||||
/// 登记当前项目中已经存在、但尚未写入 manifest 的本地资源。
|
||||
///
|
||||
/// 该入口只接受项目根相对路径;实际文件签名、大小、敏感路径、项目锁和
|
||||
/// manifest/revision 更新统一复用 Agent 的受控导入实现。未登记文件在调用前
|
||||
@@ -4020,10 +4235,10 @@ pub(crate) async fn import_local_project_image_assets(
|
||||
) -> Result<LocalImportResult, String> {
|
||||
let root = PathBuf::from(project_path.trim());
|
||||
tokio::task::spawn_blocking(move || {
|
||||
import_local_project_image_assets_for_agent(&root, &relative_paths)
|
||||
import_local_project_assets_for_agent(&root, &relative_paths)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("项目内图片登记任务意外终止:{error}"))
|
||||
.map_err(|error| format!("项目内资源登记任务意外终止:{error}"))
|
||||
.and_then(|result| result)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
- 素材读取区分三类来源:`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 和项目内本地相对路径。项目文件发现结果以 `assetImportable` 明确区分当前可登记的 PNG/JPEG/WEBP 与仅可发现的 GIF/SVG/其它媒体,Agent 只能提交前者。导入拒绝路径穿越、`.agent`、符号链接/reparse point 及敏感配置文件;外部宿主文件须由 UI 原生文件选择器授权后导入,不开放任意绝对路径。
|
||||
- `canvas.asset_import` 支持账户/画布资源 ID 和项目内本地相对路径。项目文件发现结果以 `assetImportable` 明确区分当前可登记的已识别图片、字体、音频、视频、文档和代码文件与其它文件;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