接入平台美术生成接口
在 AI 游戏创作 Agent loop 中配置 editorApi.apiKey 时调用 External Editor 图片生成 API 将生成结果换签下载到 assets/canvas-generated/ 并登记为 canvas 来源资产 补充平台生图 mock 测试和相关方案决策文档
This commit is contained in:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user