接入平台美术生成接口

在 AI 游戏创作 Agent loop 中配置 editorApi.apiKey 时调用 External Editor 图片生成 API

将生成结果换签下载到 assets/canvas-generated/ 并登记为 canvas 来源资产

补充平台生图 mock 测试和相关方案决策文档
This commit is contained in:
AIGameCreator App
2026-06-30 15:47:10 +08:00
parent ff95dfa62f
commit e72e689f6a
3 changed files with 338 additions and 4 deletions
@@ -197,6 +197,15 @@ struct SyncCanvasProjectAssetsResult {
assets: Vec<UploadLocalAssetResult>,
}
#[derive(Debug, Eq, PartialEq)]
struct GeneratedPlatformArtAsset {
asset: UploadLocalAssetResult,
resource_id: Option<String>,
asset_object_id: Option<String>,
task_id: Option<String>,
model: Option<String>,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct OpenCanvasProjectResult {
@@ -1728,8 +1737,17 @@ async fn run_game_creator_agent_loop_at(
"agent.role_briefs",
&format!("6 组角色 brief 已完成,第 {pass} 轮交给 Generator"),
);
let platform_art_step =
maybe_generate_platform_art_asset_step(root, prompt, &group_briefs, pass, progress)
.await;
append_group_brief_steps(root, pass, &agenda.relative_path, &group_briefs, &mut steps);
let group_briefs_markdown = render_agent_group_briefs_context(&group_briefs);
if let Some(step) = platform_art_step {
steps.push(step);
}
let group_briefs_markdown = append_prompt_context(
&render_agent_group_briefs_context(&group_briefs),
&render_local_asset_prompt_context(root)?,
);
emit_agent_progress(
progress,
"llm.generator",
@@ -2677,6 +2695,76 @@ fn suggested_canvas_tool_call(
})
}
async fn maybe_generate_platform_art_asset_step(
root: &Path,
prompt: &str,
briefs: &[AgentGroupBrief],
pass: u8,
progress: Option<&AgentProgressEmitter<'_>>,
) -> Option<GameCreationAgentRunStep> {
if !needs_platform_art_asset_generation(root, briefs) || !editor_api_key_is_configured() {
return None;
}
emit_agent_progress(
progress,
"editor.image_generate",
"美术组正在通过平台 External Editor API 生成首版素材",
);
let result = generate_platform_art_asset_at(root, prompt, briefs).await;
let (status, output_paths, summary) = match result {
Ok(generated) => (
"completed",
vec![generated.asset.local_path.clone()],
format!(
"已通过平台 External Editor API 生成首版美术素材:{}",
generated.asset.local_path
),
),
Err(error) => (
"failed",
Vec::new(),
format!("平台 External Editor API 生成首版美术素材失败:{error}"),
),
};
Some(with_task_context(
agent_trace_step_owned(
pass,
"美术组 / PlatformImageGenerate",
status,
vec![
".agent/spec.md".to_string(),
".agent/manifest.json".to_string(),
],
output_paths,
&summary,
"agent.tool.platform.editor_image_generate",
),
"art",
"Asset",
Some("art-asset-plan"),
"asset-generation",
))
}
fn needs_platform_art_asset_generation(root: &Path, briefs: &[AgentGroupBrief]) -> bool {
let canvas_asset_media_types = project_canvas_asset_media_types(root);
briefs.iter().any(|brief| {
brief.role_briefs.iter().any(|role_brief| {
role_brief.status == "completed"
&& role_brief.group_definition.id == "art"
&& role_brief.role_definition.id == "asset"
&& !role_has_canvas_assets(role_brief, &canvas_asset_media_types)
})
})
}
fn editor_api_key_is_configured() -> bool {
load_game_creator_app_config()
.ok()
.and_then(|config| trim_config_string(&config.editor_api.api_key))
.is_some()
}
fn role_has_canvas_assets(role_brief: &AgentRoleBrief, media_types: &[String]) -> bool {
match (
role_brief.group_definition.id,
@@ -2694,6 +2782,141 @@ fn role_has_canvas_assets(role_brief: &AgentRoleBrief, media_types: &[String]) -
}
}
async fn generate_platform_art_asset_at(
root: &Path,
prompt: &str,
briefs: &[AgentGroupBrief],
) -> Result<GeneratedPlatformArtAsset, String> {
init_local_game_project_at(root, "local-project-draft", "未命名游戏原型")?;
let api_base_url = resolve_canvas_sync_api_base_url(None)?;
let api_key = resolve_canvas_sync_api_key(None)?;
let client = reqwest::Client::new();
let generation_prompt = build_platform_art_asset_prompt(prompt, briefs);
let response = client
.post(format!(
"{}/api/external/v1/editor/images/generations",
api_base_url
))
.bearer_auth(&api_key)
.json(&serde_json::json!({
"prompt": generation_prompt,
"aspectRatio": "1:1",
"imageSize": "1K",
"assetKind": "game-art",
"assetLabel": "AI 游戏首版美术素材",
}))
.send()
.await
.map_err(|error| format!("请求平台图片生成失败:{error}"))?;
let status = response.status();
if !status.is_success() {
return Err(format!("请求平台图片生成失败:HTTP {}", status.as_u16()));
}
let payload = response
.json::<serde_json::Value>()
.await
.map_err(|error| format!("解析平台图片生成响应失败:{error}"))?;
let generated = payload.get("data").unwrap_or(&payload);
let download = resolve_canvas_resource_download(&client, &api_base_url, &api_key, generated)
.await?
.ok_or_else(|| "平台图片生成响应缺少可下载图片".to_string())?;
let null = serde_json::Value::Null;
let resource = generated.get("resource").unwrap_or(&null);
let asset = generated.get("asset").unwrap_or(&null);
let resource_id = json_string_field(resource, "resourceId");
let task_id =
json_string_field(generated, "taskId").or_else(|| json_string_field(resource, "taskId"));
let asset_object_id = json_string_field(generated, "assetObjectId")
.or_else(|| json_string_field(resource, "assetObjectId"))
.or_else(|| json_string_field(asset, "assetObjectId"));
let generated_prompt = json_string_field(generated, "actualPrompt")
.or_else(|| json_string_field(generated, "prompt"))
.or_else(|| json_string_field(resource, "actualPrompt"))
.or_else(|| json_string_field(resource, "prompt"));
let model =
json_string_field(generated, "model").or_else(|| json_string_field(resource, "model"));
let provider = json_string_field(generated, "provider")
.or_else(|| json_string_field(resource, "provider"));
let asset_kind = json_string_field(resource, "assetKind")
.or_else(|| json_string_field(asset, "assetKind"))
.unwrap_or_else(|| "game-art".to_string());
let source_hint = json_string_field(generated, "objectKey")
.or_else(|| json_string_field(generated, "imageSrc"));
let extension = infer_file_extension(source_hint.as_deref(), &download.media_type);
let file_stem = resource_id
.as_deref()
.or(task_id.as_deref())
.unwrap_or("platform-art");
let local_path = format!(
"assets/canvas-generated/{}-{}.{}",
unix_millis(),
sanitize_file_name(file_stem),
extension
);
let absolute_path = root.join(&local_path);
if let Some(parent) = absolute_path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建平台生成素材目录失败:{}: {error}", parent.display()))?;
}
fs::write(&absolute_path, &download.bytes)
.map_err(|error| format!("写入平台生成素材失败:{}: {error}", absolute_path.display()))?;
let canvas_project_id = json_string_field(resource, "projectId")
.or_else(|| json_string_field(generated, "projectId"));
let registered = register_local_asset_entry(
root,
&local_path,
&asset_kind,
&download.media_type,
"platform-art",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
canvas_project_id,
resource_id: resource_id.clone(),
asset_object_id: asset_object_id.clone(),
task_id: task_id.clone(),
prompt: generated_prompt.clone(),
model: model.clone(),
},
)?;
append_agent_db_record(
root,
serde_json::json!({
"recordType": "canvas.asset_generate",
"assetId": registered.id.clone(),
"localPath": registered.local_path.clone(),
"resourceId": resource_id.clone(),
"assetObjectId": asset_object_id.clone(),
"taskId": task_id.clone(),
"model": model.clone(),
"provider": provider.clone(),
}),
)?;
Ok(GeneratedPlatformArtAsset {
asset: registered,
resource_id,
asset_object_id,
task_id,
model,
})
}
fn build_platform_art_asset_prompt(prompt: &str, briefs: &[AgentGroupBrief]) -> String {
let art_asset_brief = briefs
.iter()
.flat_map(|brief| brief.role_briefs.iter())
.find(|role_brief| {
role_brief.group_definition.id == "art" && role_brief.role_definition.id == "asset"
})
.map(|role_brief| role_brief.markdown.trim())
.filter(|markdown| !markdown.is_empty())
.unwrap_or("需要一张可直接用于 Web 小游戏首版原型的核心美术素材。");
format!(
"为 Web 小游戏首版原型生成一张核心美术素材,适合放入本地 assets 并被 canvas 游戏直接引用。\n用户需求:{}\n美术资产 brief:{}",
truncate_inline(prompt, 240),
truncate_prompt_context(art_asset_brief)
)
}
fn render_agent_group_brief_markdown(role_briefs: &[AgentRoleBrief]) -> String {
let mut output = String::new();
for role_brief in role_briefs {
@@ -7015,6 +7238,41 @@ mod tests {
}
})
.to_string();
let generation_body = serde_json::json!({
"imageSrc": "/generated/canvas/hero.png",
"objectKey": "generated/canvas/hero.png",
"assetObjectId": "asset-object-1",
"width": 64,
"height": 64,
"sourceType": "generated",
"prompt": "像素月光主角",
"actualPrompt": "透明 PNG 像素月光主角",
"model": "gpt-image-2",
"provider": "VectorEngine",
"taskId": "task-1",
"resource": {
"resourceId": "resource-1",
"projectId": "canvas-project-1",
"imageSrc": "/generated/canvas/hero.png",
"objectKey": "generated/canvas/hero.png",
"assetObjectId": "asset-object-1",
"width": 64,
"height": 64,
"sourceType": "generated",
"prompt": "像素月光主角",
"actualPrompt": "透明 PNG 像素月光主角",
"model": "gpt-image-2",
"provider": "VectorEngine",
"taskId": "task-1",
"assetKind": "character"
},
"asset": {
"assetId": "asset-1",
"assetObjectId": "asset-object-1",
"assetKind": "character"
}
})
.to_string();
let read_body = serde_json::json!({
"read": {
"provider": "aliyun-oss",
@@ -7033,13 +7291,19 @@ mod tests {
let mut request_buffer = [0_u8; 8192];
let read_len = stream.read(&mut request_buffer).unwrap_or(0);
let request = String::from_utf8_lossy(&request_buffer[..read_len]);
let normalized_request = request.to_ascii_lowercase();
let (content_type, body) = if request
.starts_with("GET /api/external/v1/editor/projects/canvas-project-1 ")
{
assert!(normalized_request.contains("authorization: bearer "));
("application/json", project_body.as_bytes().to_vec())
} else if request.starts_with("POST /api/external/v1/editor/images/generations ") {
assert!(normalized_request.contains("authorization: bearer "));
("application/json", generation_body.as_bytes().to_vec())
} else if request.starts_with(
"GET /api/external/v1/assets/read-url?objectKey=generated%2Fcanvas%2Fhero.png ",
) {
assert!(normalized_request.contains("authorization: bearer "));
("application/json", read_body.as_bytes().to_vec())
} else if request.starts_with("GET /signed/hero.png ") {
("image/png", b"fake-png".to_vec())
@@ -8475,6 +8739,75 @@ mod tests {
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn generate_platform_art_asset_downloads_and_registers_external_image() {
let root = unique_project_path();
let config_dir = unique_project_path();
let base_url = spawn_mock_external_canvas_api_server();
fs::create_dir_all(&config_dir).expect("runtime config dir");
fs::write(
config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME),
serde_json::json!({
"editorApi": {
"baseUrl": base_url,
"apiKey": "editor-unit-key"
}
})
.to_string(),
)
.expect("write runtime config");
let _guard = use_test_runtime_config_dir(config_dir.clone());
let art_role = AgentRoleBrief {
group_definition: GAME_CREATOR_AGENT_GROUP_DEFINITIONS[2],
role_definition: ART_AGENT_ROLES[1],
markdown: "需要像素月光主角和厨房场景素材。".to_string(),
relative_path: ".agent/passes/pass-1/groups/art/asset.md".to_string(),
status: "completed".to_string(),
tool_id: "agent.role.brief.art.asset".to_string(),
summary: "规划首版美术资产".to_string(),
};
let briefs = vec![AgentGroupBrief {
definition: GAME_CREATOR_AGENT_GROUP_DEFINITIONS[2],
markdown: "美术组汇总。".to_string(),
relative_path: ".agent/passes/pass-1/groups/art.md".to_string(),
role_briefs: vec![art_role],
}];
let result = generate_platform_art_asset_at(&root, "月光弹幕厨房", &briefs)
.await
.expect("platform art generation");
assert!(result
.asset
.local_path
.starts_with("assets/canvas-generated/"));
assert_eq!(fs::read(&result.asset.absolute_path).unwrap(), b"fake-png");
assert_eq!(result.resource_id.as_deref(), Some("resource-1"));
assert_eq!(result.asset_object_id.as_deref(), Some("asset-object-1"));
assert_eq!(result.task_id.as_deref(), Some("task-1"));
assert_eq!(result.model.as_deref(), Some("gpt-image-2"));
let manifest: Value =
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
.expect("manifest json");
let asset = &manifest["assets"][0];
assert_eq!(asset["kind"], "character");
assert_eq!(asset["mediaType"], "image/png");
assert_eq!(asset["source"]["kind"], "canvas");
assert_eq!(asset["source"]["canvasProjectId"], "canvas-project-1");
assert_eq!(asset["source"]["resourceId"], "resource-1");
assert_eq!(asset["source"]["assetObjectId"], "asset-object-1");
assert_eq!(asset["source"]["taskId"], "task-1");
assert_eq!(asset["source"]["model"], "gpt-image-2");
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db");
assert!(agent_db.contains("\"recordType\":\"canvas.asset_generate\""));
assert!(!agent_db.contains("editor-unit-key"));
fs::remove_dir_all(root).ok();
fs::remove_dir_all(config_dir).ok();
}
#[test]
fn canvas_project_url_defaults_to_local_editor_route() {
let url = build_canvas_project_url(None, Some("canvas-project-1")).expect("canvas url");