diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index 0dc45c3b1..e2b6682ad 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -4056,6 +4056,8 @@ name = "platform-editor-agent" version = "0.1.0" dependencies = [ "hmac", + "platform-audio", + "platform-image", "platform-llm", "serde", "serde_json", diff --git a/server-rs/crates/api-server/src/editor_agent/api.rs b/server-rs/crates/api-server/src/editor_agent/api.rs index e26bd6b69..274706e87 100644 --- a/server-rs/crates/api-server/src/editor_agent/api.rs +++ b/server-rs/crates/api-server/src/editor_agent/api.rs @@ -42,7 +42,7 @@ use crate::editor_agent::utils::{ ensure_editor_project_access, normalize_editor_agent_attachments, now_rfc3339, read_messages_document, require_editor_agent_sidebar_enabled, write_messages_document, }; -use crate::editor_agent::{context, display_args, reconcile}; +use crate::editor_agent::{context, display_args, reconcile, tool_args}; use crate::editor_generation_config::EditorGenerationPricingConfig; use crate::editor_generation_queue::{ EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND, EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND, @@ -63,7 +63,9 @@ use platform_editor_agent::agent::tools::edit_image::{EditImageTool, EditImageTo use platform_editor_agent::agent::tools::generate_background_music::{ GenerateBackgroundMusicTool, GenerateBackgroundMusicToolArgs, }; -use platform_editor_agent::agent::tools::generate_character::GenerateCharacterTool; +use platform_editor_agent::agent::tools::generate_character::{ + GenerateCharacterTool, GenerateCharacterToolArgs, +}; use platform_editor_agent::agent::tools::generate_icon_spritesheet::{ GenerateIconSpritesheetTool, GenerateIconSpritesheetToolArgs, }; @@ -73,7 +75,9 @@ use platform_editor_agent::agent::tools::generate_image::{ use platform_editor_agent::agent::tools::generate_sound_effect::{ GenerateSoundEffectTool, GenerateSoundEffectToolArgs, }; -use platform_editor_agent::agent::tools::generate_ui_design::GenerateUiDesignTool; +use platform_editor_agent::agent::tools::generate_ui_design::{ + GenerateUiDesignTool, GenerateUiDesignToolArgs, +}; use platform_editor_agent::agent::tools::generate_video::{ GenerateVideoTool, GenerateVideoToolArgs, }; @@ -567,6 +571,34 @@ mod tests { ); } + #[test] + fn legacy_confirm_args_use_current_defaults_and_ignore_retired_fields() { + let music: GenerateBackgroundMusicToolArgs = parse_confirm_tool_args(&json!({ + "prompt": "温暖舒缓的钢琴背景音乐", + "make_instrumental": false + })) + .expect("旧背景音乐字段不应阻断确认"); + assert_eq!(music.model, GenerateBackgroundMusicTool::DEFAULT_MODEL); + + let ui: GenerateUiDesignToolArgs = parse_confirm_tool_args(&json!({ + "prompt": "生成游戏主界面" + })) + .expect("旧 UI 消息缺少 model 时应使用当前固定模型"); + assert_eq!(ui.model, platform_image::GPT_IMAGE_2_MODEL); + + let video: GenerateVideoToolArgs = parse_confirm_tool_args(&json!({ + "prompt": "镜头向前推进" + })) + .expect("旧视频消息缺少可选字段时应使用当前默认值"); + assert_eq!(video.sound.as_deref(), Some("on")); + + let sound: GenerateSoundEffectToolArgs = parse_confirm_tool_args(&json!({ + "prompt": "按钮点击声" + })) + .expect("旧音效消息缺少时长时应使用当前默认值"); + assert_eq!(sound.duration, Some(5)); + } + #[tokio::test] async fn prompt_timeout_applies_to_the_whole_agent_run() { let error = run_editor_agent_prompt_with_timeout( @@ -624,23 +656,31 @@ fn build_delta_messages( }); } PromptOutput::Tool(tco) => { - let display_args = display_args::build_tool_call_display_args( - tco.tool_call.name.as_str(), + let tool_name = tco.tool_call.name; + let normalized_args = tool_args::normalize_tool_args( + tool_name.as_str(), &tco.tool_call.args, + tool_context, + )?; + let display_args = display_args::build_tool_call_display_args( + tool_name.as_str(), + &normalized_args, document, tool_context, pricing, )?; + let text = + format!("[tool_call:{tool_name}] args: {normalized_args} output: 等待用户确认"); messages.push(EditorAgentMessage { id: absolute_idx, client_message_id: None, role: EditorAgentMessageRole::System, - text: tco.message, + text, attachments: Vec::new(), tool_call: Some(EditorAgentToolCall { - tool_name: tco.tool_call.name, + tool_name, status: EditorAgentToolCallStatus::NotCompleted, - args: tco.tool_call.args, + args: normalized_args, display_args, external_job_id: None, images: Vec::new(), @@ -933,7 +973,7 @@ pub async fn confirm_editor_agent_tool_call( }; let (job_kind, request_label, price_mud_points, payload) = match tool_name.as_str() { - GenerateImageTool::NAME | GenerateCharacterTool::NAME | GenerateUiDesignTool::NAME => { + GenerateImageTool::NAME => { let args: GenerateImageToolArgs = parse_confirm_tool_args(&tool_args)?; let tool = GenerateImageTool { context: context.clone(), @@ -941,22 +981,7 @@ pub async fn confirm_editor_agent_tool_call( tool.validate_args(&args).map_err(|error| { editor_agent_bad_request(format!("invalid tool call args: {error}")) })?; - let kind = match tool_name.as_str() { - GenerateCharacterTool::NAME => Some("character"), - GenerateUiDesignTool::NAME => Some("ui-design"), - _ => None, - }; - let price = match kind { - Some("character") => GenerateCharacterTool { - context: context.clone(), - } - .pricing(&pricing, &args), - Some("ui-design") => GenerateUiDesignTool { - context: context.clone(), - } - .pricing(&pricing, &args), - _ => tool.pricing(&pricing, &args), - }; + let price = tool.pricing(&pricing, &args); let title = args.prompt.clone(); let reference_image_srcs = args .reference_image_ids @@ -973,15 +998,93 @@ pub async fn confirm_editor_agent_tool_call( let payload = EditorImageGenerationRequest { prompt: args.prompt, size: None, - kind: kind.map(ToOwned::to_owned), - model: None, - screen_color: (kind == Some("character")).then(|| "auto".to_string()), - seg_model: (kind == Some("character")).then(|| "birefnet".to_string()), + kind: None, + model: Some(args.model), + screen_color: None, + seg_model: None, aspect_ratio: args.aspect_ratio, image_size: args.image_size, reference_image_srcs: Some(reference_image_srcs), project_id: Some(conversation.project_id.clone()), - asset_kind: Some(kind.unwrap_or("editor_agent_generated_image").to_string()), + asset_kind: Some("editor_agent_generated_image".to_string()), + generation_inputs: generation_inputs("用户指令", &title), + asset_folder_id: Some("project".to_string()), + asset_label: Some(title.clone()), + source_resource_id: None, + canvas_completion: Some(build_editor_agent_canvas_completion( + &project, &tool_name, &title, + )), + }; + ( + EDITOR_IMAGE_GENERATION_JOB_KIND, + "画布 Agent 生成图片", + price, + serde_json::to_value(payload), + ) + } + GenerateCharacterTool::NAME => { + let args: GenerateCharacterToolArgs = parse_confirm_tool_args(&tool_args)?; + let tool = GenerateCharacterTool { + context: context.clone(), + }; + tool.validate_args(&args).map_err(|error| { + editor_agent_bad_request(format!("invalid tool call args: {error}")) + })?; + let price = tool.pricing(&pricing, &args); + let title = args.prompt.clone(); + let reference_image_srcs = + resolve_editor_agent_image_ids(args.reference_image_ids.as_slice(), &context)?; + let payload = EditorImageGenerationRequest { + prompt: args.prompt, + size: None, + kind: Some("character".to_string()), + model: Some(args.model), + screen_color: Some("auto".to_string()), + seg_model: Some("birefnet".to_string()), + aspect_ratio: args.aspect_ratio, + image_size: args.image_size, + reference_image_srcs: Some(reference_image_srcs), + project_id: Some(conversation.project_id.clone()), + asset_kind: Some("character".to_string()), + generation_inputs: generation_inputs("用户指令", &title), + asset_folder_id: Some("project".to_string()), + asset_label: Some(title.clone()), + source_resource_id: None, + canvas_completion: Some(build_editor_agent_canvas_completion( + &project, &tool_name, &title, + )), + }; + ( + EDITOR_IMAGE_GENERATION_JOB_KIND, + "画布 Agent 生成图片", + price, + serde_json::to_value(payload), + ) + } + GenerateUiDesignTool::NAME => { + let args: GenerateUiDesignToolArgs = parse_confirm_tool_args(&tool_args)?; + let tool = GenerateUiDesignTool { + context: context.clone(), + }; + tool.validate_args(&args).map_err(|error| { + editor_agent_bad_request(format!("invalid tool call args: {error}")) + })?; + let price = tool.pricing(&pricing, &args); + let title = args.prompt.clone(); + let reference_image_srcs = + resolve_editor_agent_image_ids(args.reference_image_ids.as_slice(), &context)?; + let payload = EditorImageGenerationRequest { + prompt: args.prompt, + size: None, + kind: Some("ui-design".to_string()), + model: Some(args.model), + screen_color: None, + seg_model: None, + aspect_ratio: args.aspect_ratio, + image_size: args.image_size, + reference_image_srcs: Some(reference_image_srcs), + project_id: Some(conversation.project_id.clone()), + asset_kind: Some("ui-design".to_string()), generation_inputs: generation_inputs("用户指令", &title), asset_folder_id: Some("project".to_string()), asset_label: Some(title.clone()), @@ -999,8 +1102,13 @@ pub async fn confirm_editor_agent_tool_call( } EditImageTool::NAME => { let args: EditImageToolArgs = parse_confirm_tool_args(&tool_args)?; - if args.prompt.trim().is_empty() { - return Err(editor_agent_bad_request("prompt not provided")); + let tool = EditImageTool { + context: context.clone(), + }; + if let Some(error) = tool.validate_args(&args) { + return Err(editor_agent_bad_request(format!( + "invalid tool call args: {error}" + ))); } let source_image_src = context .image_data_key(&args.object_image_id) @@ -1019,15 +1127,12 @@ pub async fn confirm_editor_agent_tool_call( }) .collect::, _>>()?; let title = args.prompt.clone(); - let price = EditImageTool { - context: context.clone(), - } - .pricing(&pricing, &args); + let price = tool.pricing(&pricing, &args); let payload = EditorImageEditRequest { prompt: args.prompt, source_image_src, size: None, - model: None, + model: Some(args.model), aspect_ratio: None, image_size: None, reference_image_srcs: Some(reference_image_srcs), @@ -1056,6 +1161,9 @@ pub async fn confirm_editor_agent_tool_call( let tool = GenerateIconSpritesheetTool { context: context.clone(), }; + tool.validate_args(&args).map_err(|error| { + editor_agent_bad_request(format!("invalid tool call args: {error}")) + })?; let price = tool.pricing(&pricing, &args); let reference_image_src = context .image_data_key(&args.reference_image_id) @@ -1077,7 +1185,7 @@ pub async fn confirm_editor_agent_tool_call( reference_image_src, reference_image_srcs: Some(reference_image_srcs), icon_descriptions: args.icon_descriptions.clone(), - model: None, + model: Some(args.model), screen_color: Some("auto".to_string()), seg_model: Some("birefnet".to_string()), aspect_ratio: args.aspect_ratio, @@ -1103,6 +1211,12 @@ pub async fn confirm_editor_agent_tool_call( } GenerateVideoTool::NAME => { let args: GenerateVideoToolArgs = parse_confirm_tool_args(&tool_args)?; + let tool = GenerateVideoTool { + context: context.clone(), + }; + tool.validate_args(&args).map_err(|error| { + editor_agent_bad_request(format!("invalid tool call args: {error}")) + })?; let reference_image_srcs = args .reference_image_ids .iter() @@ -1116,18 +1230,23 @@ pub async fn confirm_editor_agent_tool_call( }) .collect::, _>>()?; let title = args.prompt.clone(); - let price = GenerateVideoTool { - context: context.clone(), - } - .pricing(&pricing, &args); + let price = tool.pricing(&pricing, &args); let payload = EditorVideoGenerateRequest { prompt: args.prompt, - model: args.model.unwrap_or_else(|| "seedance2.0-fast".to_string()), - aspect_ratio: args.aspect_ratio.unwrap_or_else(|| "16:9".to_string()), - duration_seconds: args.duration_seconds.unwrap_or(4), - resolution: args.resolution.unwrap_or_else(|| "720p".to_string()), + model: args.model, + aspect_ratio: args + .aspect_ratio + .unwrap_or_else(|| GenerateVideoTool::DEFAULT_VIDEO_ASPECT_RATIO.to_string()), + duration_seconds: args + .duration_seconds + .unwrap_or(GenerateVideoTool::DEFAULT_VIDEO_DURATION_SECONDS), + resolution: args + .resolution + .unwrap_or_else(|| GenerateVideoTool::DEFAULT_VIDEO_RESOLUTION.to_string()), mode: "std".to_string(), - sound: args.sound.unwrap_or_else(|| "off".to_string()), + sound: args + .sound + .unwrap_or_else(|| GenerateVideoTool::DEFAULT_VIDEO_SOUND.to_string()), web_search_enabled: false, reference_image_srcs, reference_video_srcs: Vec::new(), @@ -1153,12 +1272,19 @@ pub async fn confirm_editor_agent_tool_call( } GenerateSoundEffectTool::NAME => { let args: GenerateSoundEffectToolArgs = parse_confirm_tool_args(&tool_args)?; + GenerateSoundEffectTool + .validate_args(&args) + .map_err(|error| { + editor_agent_bad_request(format!("invalid tool call args: {error}")) + })?; let price = GenerateSoundEffectTool.pricing(&pricing, &args); let title = args.prompt.clone(); let payload = EditorSoundEffectGenerateRequest { prompt: args.prompt, - model: None, - duration: args.duration.unwrap_or(3), + model: Some(args.model), + duration: args + .duration + .unwrap_or(GenerateSoundEffectTool::DEFAULT_DURATION), project_id: Some(conversation.project_id.clone()), canvas_completion: Some(build_editor_agent_canvas_completion( &project, @@ -1178,11 +1304,16 @@ pub async fn confirm_editor_agent_tool_call( } GenerateBackgroundMusicTool::NAME => { let args: GenerateBackgroundMusicToolArgs = parse_confirm_tool_args(&tool_args)?; + GenerateBackgroundMusicTool + .validate_args(&args) + .map_err(|error| { + editor_agent_bad_request(format!("invalid tool call args: {error}")) + })?; let price = GenerateBackgroundMusicTool.pricing(&pricing, &args); let title = args.prompt.clone(); let payload = EditorBackgroundMusicGenerateRequest { gpt_description_prompt: args.prompt, - make_instrumental: args.make_instrumental, + make_instrumental: true, project_id: Some(conversation.project_id.clone()), canvas_completion: Some(build_editor_agent_canvas_completion( &project, @@ -1246,6 +1377,21 @@ fn parse_confirm_tool_args(value: &Value) -> Res .map_err(|error| editor_agent_bad_request(format!("invalid tool call args: {error}"))) } +fn resolve_editor_agent_image_ids( + image_ids: &[platform_editor_agent::agent::asset::ImageId], + context: &EditorToolContext, +) -> Result, AppError> { + image_ids + .iter() + .map(|image_id| { + context + .image_data_key(image_id) + .map(ToOwned::to_owned) + .ok_or_else(|| editor_agent_bad_request(format!("image {image_id} not found"))) + }) + .collect() +} + fn editor_agent_tool_job_identity( conversation_id: &str, message_id: usize, diff --git a/server-rs/crates/api-server/src/editor_agent/display_args.rs b/server-rs/crates/api-server/src/editor_agent/display_args.rs index df0ca205d..cbf2de4de 100644 --- a/server-rs/crates/api-server/src/editor_agent/display_args.rs +++ b/server-rs/crates/api-server/src/editor_agent/display_args.rs @@ -7,7 +7,9 @@ use platform_editor_agent::agent::tools::edit_image::{EditImageTool, EditImageTo use platform_editor_agent::agent::tools::generate_background_music::{ GenerateBackgroundMusicTool, GenerateBackgroundMusicToolArgs, }; -use platform_editor_agent::agent::tools::generate_character::GenerateCharacterTool; +use platform_editor_agent::agent::tools::generate_character::{ + GenerateCharacterTool, GenerateCharacterToolArgs, +}; use platform_editor_agent::agent::tools::generate_icon_spritesheet::{ GenerateIconSpritesheetTool, GenerateIconSpritesheetToolArgs, }; @@ -17,7 +19,9 @@ use platform_editor_agent::agent::tools::generate_image::{ use platform_editor_agent::agent::tools::generate_sound_effect::{ GenerateSoundEffectTool, GenerateSoundEffectToolArgs, }; -use platform_editor_agent::agent::tools::generate_ui_design::GenerateUiDesignTool; +use platform_editor_agent::agent::tools::generate_ui_design::{ + GenerateUiDesignTool, GenerateUiDesignToolArgs, +}; use platform_editor_agent::agent::tools::generate_video::{ GenerateVideoTool, GenerateVideoToolArgs, }; @@ -47,6 +51,7 @@ pub fn build_tool_call_display_args( } .pricing(pricing, &args); push_string_display_arg(&mut display_args, "prompt", "修改要求", args.prompt); + push_string_display_arg(&mut display_args, "model", "模型", args.model); push_image_display_arg( &mut display_args, document, @@ -63,42 +68,54 @@ pub fn build_tool_call_display_args( )?; price_mud_points } - GenerateImageTool::NAME | GenerateCharacterTool::NAME | GenerateUiDesignTool::NAME => { + GenerateImageTool::NAME => { let args: GenerateImageToolArgs = parse_display_tool_args(tool_name, args)?; - let price_mud_points = match tool_name { - GenerateImageTool::NAME => GenerateImageTool { - context: tool_context.clone(), - } - .pricing(pricing, &args), - GenerateCharacterTool::NAME => GenerateCharacterTool { - context: tool_context.clone(), - } - .pricing(pricing, &args), - GenerateUiDesignTool::NAME => GenerateUiDesignTool { - context: tool_context.clone(), - } - .pricing(pricing, &args), - _ => unreachable!("tool name was matched above"), - }; - push_string_display_arg(&mut display_args, "prompt", "提示词", args.prompt); - push_optional_string_display_arg( - &mut display_args, - "aspect_ratio", - "画面比例", - args.aspect_ratio, - ); - push_optional_string_display_arg( - &mut display_args, - "image_size", - "图片尺寸", - args.image_size, - ); - push_image_display_arg( + let price_mud_points = GenerateImageTool { + context: tool_context.clone(), + } + .pricing(pricing, &args); + push_image_generation_display_args( &mut display_args, document, - "reference_image_ids", - "参考图片", - args.reference_image_ids.as_slice(), + args.prompt, + args.model, + args.aspect_ratio, + args.image_size, + args.reference_image_ids, + )?; + price_mud_points + } + GenerateCharacterTool::NAME => { + let args: GenerateCharacterToolArgs = parse_display_tool_args(tool_name, args)?; + let price_mud_points = GenerateCharacterTool { + context: tool_context.clone(), + } + .pricing(pricing, &args); + push_image_generation_display_args( + &mut display_args, + document, + args.prompt, + args.model, + args.aspect_ratio, + args.image_size, + args.reference_image_ids, + )?; + price_mud_points + } + GenerateUiDesignTool::NAME => { + let args: GenerateUiDesignToolArgs = parse_display_tool_args(tool_name, args)?; + let price_mud_points = GenerateUiDesignTool { + context: tool_context.clone(), + } + .pricing(pricing, &args); + push_image_generation_display_args( + &mut display_args, + document, + args.prompt, + args.model, + args.aspect_ratio, + args.image_size, + args.reference_image_ids, )?; price_mud_points } @@ -114,10 +131,11 @@ pub fn build_tool_call_display_args( "图标描述", args.icon_descriptions.join("\n"), ); + push_string_display_arg(&mut display_args, "model", "模型", args.model); push_optional_string_display_arg( &mut display_args, "aspect_ratio", - "图集比例", + "画面比例", args.aspect_ratio, ); push_optional_string_display_arg( @@ -163,7 +181,7 @@ pub fn build_tool_call_display_args( duration_seconds.to_string(), ); } - push_optional_string_display_arg(&mut display_args, "model", "模型", args.model); + push_string_display_arg(&mut display_args, "model", "模型", args.model); push_optional_string_display_arg( &mut display_args, "resolution", @@ -184,6 +202,7 @@ pub fn build_tool_call_display_args( let args: GenerateSoundEffectToolArgs = parse_display_tool_args(tool_name, args)?; let price_mud_points = GenerateSoundEffectTool.pricing(pricing, &args); push_string_display_arg(&mut display_args, "prompt", "音效描述", args.prompt); + push_string_display_arg(&mut display_args, "model", "模型", args.model); if let Some(duration) = args.duration { push_string_display_arg( &mut display_args, @@ -198,12 +217,7 @@ pub fn build_tool_call_display_args( let args: GenerateBackgroundMusicToolArgs = parse_display_tool_args(tool_name, args)?; let price_mud_points = GenerateBackgroundMusicTool.pricing(pricing, &args); push_string_display_arg(&mut display_args, "prompt", "音乐描述", args.prompt); - push_string_display_arg( - &mut display_args, - "make_instrumental", - "纯音乐", - if args.make_instrumental { "是" } else { "否" }, - ); + push_string_display_arg(&mut display_args, "model", "模型", args.model); price_mud_points } _ => { @@ -229,6 +243,28 @@ where }) } +fn push_image_generation_display_args( + display_args: &mut EditorAgentToolCallDisplayArgs, + document: &EditorAgentConversationMessagesDocument, + prompt: String, + model: String, + aspect_ratio: Option, + image_size: Option, + reference_image_ids: Vec, +) -> Result<(), PromptError> { + push_string_display_arg(display_args, "prompt", "提示词", prompt); + push_string_display_arg(display_args, "model", "模型", model); + push_optional_string_display_arg(display_args, "aspect_ratio", "画面比例", aspect_ratio); + push_optional_string_display_arg(display_args, "image_size", "图片尺寸", image_size); + push_image_display_arg( + display_args, + document, + "reference_image_ids", + "参考图片", + reference_image_ids.as_slice(), + ) +} + fn push_string_display_arg( display_args: &mut EditorAgentToolCallDisplayArgs, name: &str, diff --git a/server-rs/crates/api-server/src/editor_agent/mod.rs b/server-rs/crates/api-server/src/editor_agent/mod.rs index eaa31b0fe..c73b28bb9 100644 --- a/server-rs/crates/api-server/src/editor_agent/mod.rs +++ b/server-rs/crates/api-server/src/editor_agent/mod.rs @@ -4,6 +4,7 @@ mod display_args; pub mod pricing; mod reconcile; mod resp_to_asset; +mod tool_args; mod utils; pub use api::{ diff --git a/server-rs/crates/api-server/src/editor_agent/pricing.rs b/server-rs/crates/api-server/src/editor_agent/pricing.rs index 42bd43e47..75438ebeb 100644 --- a/server-rs/crates/api-server/src/editor_agent/pricing.rs +++ b/server-rs/crates/api-server/src/editor_agent/pricing.rs @@ -9,7 +9,9 @@ use platform_editor_agent::agent::tools::edit_image::{EditImageTool, EditImageTo use platform_editor_agent::agent::tools::generate_background_music::{ GenerateBackgroundMusicTool, GenerateBackgroundMusicToolArgs, }; -use platform_editor_agent::agent::tools::generate_character::GenerateCharacterTool; +use platform_editor_agent::agent::tools::generate_character::{ + GenerateCharacterTool, GenerateCharacterToolArgs, +}; use platform_editor_agent::agent::tools::generate_icon_spritesheet::{ GenerateIconSpritesheetTool, GenerateIconSpritesheetToolArgs, }; @@ -19,7 +21,9 @@ use platform_editor_agent::agent::tools::generate_image::{ use platform_editor_agent::agent::tools::generate_sound_effect::{ GenerateSoundEffectTool, GenerateSoundEffectToolArgs, }; -use platform_editor_agent::agent::tools::generate_ui_design::GenerateUiDesignTool; +use platform_editor_agent::agent::tools::generate_ui_design::{ + GenerateUiDesignTool, GenerateUiDesignToolArgs, +}; use platform_editor_agent::agent::tools::generate_video::{ GenerateVideoTool, GenerateVideoToolArgs, }; @@ -39,6 +43,18 @@ fn context() -> EditorToolContext { fn image_args(image_size: Option<&str>) -> GenerateImageToolArgs { GenerateImageToolArgs { prompt: "生成图片".to_string(), + model: platform_image::NANOBANANA_2_MODEL.to_string(), + reference_image_ids: Vec::new(), + aspect_ratio: Some("1:1".to_string()), + image_size: image_size.map(ToOwned::to_owned), + } +} + +#[cfg(test)] +fn character_args(image_size: Option<&str>) -> GenerateCharacterToolArgs { + GenerateCharacterToolArgs { + prompt: "生成角色".to_string(), + model: platform_image::NANOBANANA_2_MODEL.to_string(), reference_image_ids: Vec::new(), aspect_ratio: Some("1:1".to_string()), image_size: image_size.map(ToOwned::to_owned), @@ -63,6 +79,7 @@ fn every_editor_agent_tool_exposes_argument_based_pricing() { }, reference_image_ids: Vec::new(), prompt: "改成蓝色".to_string(), + model: GPT_IMAGE_2_MODEL.to_string(), }, ), 3 @@ -72,20 +89,29 @@ fn every_editor_agent_tool_exposes_argument_based_pricing() { context: context.clone(), } .pricing(&pricing, &image_args(Some("2K"))), - 5 + 24 ); assert_eq!( GenerateCharacterTool { context: context.clone(), } - .pricing(&pricing, &image_args(None)), - 3 + .pricing(&pricing, &character_args(None)), + 12 ); assert_eq!( GenerateUiDesignTool { context: context.clone(), } - .pricing(&pricing, &image_args(Some("2K"))), + .pricing( + &pricing, + &GenerateUiDesignToolArgs { + prompt: "生成游戏主界面".to_string(), + model: GPT_IMAGE_2_MODEL.to_string(), + reference_image_ids: Vec::new(), + aspect_ratio: Some("1:1".to_string()), + image_size: Some("2K".to_string()), + } + ), 5 ); assert_eq!( @@ -98,13 +124,14 @@ fn every_editor_agent_tool_exposes_argument_based_pricing() { reference_image_id: ImageId { id: "image-1".to_string(), }, + model: platform_image::NANOBANANA_2_MODEL.to_string(), reference_image_ids: Vec::new(), icon_descriptions: vec!["背包".to_string(), "地图".to_string()], aspect_ratio: Some("1:1".to_string()), image_size: Some("2K".to_string()), }, ), - 5 + 24 ); assert_eq!( GenerateVideoTool { @@ -117,7 +144,7 @@ fn every_editor_agent_tool_exposes_argument_based_pricing() { reference_image_ids: Vec::new(), aspect_ratio: None, duration_seconds: Some(6), - model: Some("seedance2.0".to_string()), + model: "seedance2.0".to_string(), resolution: Some("720p".to_string()), sound: None, }, @@ -129,6 +156,7 @@ fn every_editor_agent_tool_exposes_argument_based_pricing() { &pricing, &GenerateSoundEffectToolArgs { prompt: "按钮点击声".to_string(), + model: platform_audio::VIDU_AUDIO_MODEL.to_string(), duration: None, }, ), @@ -139,7 +167,7 @@ fn every_editor_agent_tool_exposes_argument_based_pricing() { &pricing, &GenerateBackgroundMusicToolArgs { prompt: "轻松背景音乐".to_string(), - make_instrumental: true, + model: platform_audio::SUNO_DEFAULT_MODEL.to_string(), }, ), 12 @@ -168,8 +196,10 @@ fn pricing_uses_the_supplied_runtime_snapshot() { .expect("sound pricing should exist") .price = Some(19); + let mut image2_args = image_args(Some("2K")); + image2_args.model = GPT_IMAGE_2_MODEL.to_string(); assert_eq!( - GenerateImageTool { context: context() }.pricing(&pricing, &image_args(Some("2K"))), + GenerateImageTool { context: context() }.pricing(&pricing, &image2_args), 37 ); assert_eq!( @@ -180,7 +210,7 @@ fn pricing_uses_the_supplied_runtime_snapshot() { reference_image_ids: Vec::new(), aspect_ratio: None, duration_seconds: None, - model: None, + model: GenerateVideoTool::DEFAULT_VIDEO_MODEL.to_string(), resolution: None, sound: None, }, @@ -192,6 +222,7 @@ fn pricing_uses_the_supplied_runtime_snapshot() { &pricing, &GenerateSoundEffectToolArgs { prompt: "sound".to_string(), + model: platform_audio::VIDU_AUDIO_MODEL.to_string(), duration: None, }, ), @@ -205,12 +236,7 @@ impl EditorAgentPricedTool for GenerateVideoTool { pricing: &EditorGenerationPricingConfig, args: &GenerateVideoToolArgs, ) -> u32 { - let model = args - .model - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(Self::DEFAULT_VIDEO_MODEL); + let model = args.model.as_str(); let resolution = args .resolution .as_deref() @@ -228,9 +254,14 @@ impl EditorAgentPricedTool for GenerateUiDesignTool { fn pricing( &self, pricing: &EditorGenerationPricingConfig, - args: &GenerateImageToolArgs, + args: &GenerateUiDesignToolArgs, ) -> u32 { - editor_agent_image_mud_points(pricing, Some("ui-design"), args.image_size.as_deref()) + editor_agent_image_mud_points( + pricing, + Some("ui-design"), + args.model.as_str(), + args.image_size.as_deref(), + ) } } @@ -238,9 +269,9 @@ impl EditorAgentPricedTool for GenerateSoundEffectTool { fn pricing( &self, pricing: &EditorGenerationPricingConfig, - _args: &GenerateSoundEffectToolArgs, + args: &GenerateSoundEffectToolArgs, ) -> u32 { - pricing.sound_effect_model_mud_points(None) + pricing.sound_effect_model_mud_points(Some(args.model.as_str())) } } @@ -250,7 +281,12 @@ impl EditorAgentPricedTool for GenerateImageTool { pricing: &EditorGenerationPricingConfig, args: &GenerateImageToolArgs, ) -> u32 { - editor_agent_image_mud_points(pricing, None, args.image_size.as_deref()) + editor_agent_image_mud_points( + pricing, + None, + args.model.as_str(), + args.image_size.as_deref(), + ) } } @@ -260,7 +296,12 @@ impl EditorAgentPricedTool for GenerateIconSpritesheetTool { pricing: &EditorGenerationPricingConfig, args: &GenerateIconSpritesheetToolArgs, ) -> u32 { - editor_agent_image_mud_points(pricing, Some("icon"), args.image_size.as_deref()) + editor_agent_image_mud_points( + pricing, + Some("icon"), + args.model.as_str(), + args.image_size.as_deref(), + ) } } @@ -268,9 +309,14 @@ impl EditorAgentPricedTool for GenerateCharacterTool { fn pricing( &self, pricing: &EditorGenerationPricingConfig, - args: &GenerateImageToolArgs, + args: &GenerateCharacterToolArgs, ) -> u32 { - editor_agent_image_mud_points(pricing, Some("character"), args.image_size.as_deref()) + editor_agent_image_mud_points( + pricing, + Some("character"), + args.model.as_str(), + args.image_size.as_deref(), + ) } } @@ -285,29 +331,24 @@ pub(crate) trait EditorAgentPricedTool: Tool { pub(crate) fn editor_agent_image_mud_points( pricing: &EditorGenerationPricingConfig, kind: Option<&str>, + model: &str, image_size: Option<&str>, ) -> u32 { - // 这些 Agent 工具当前向既有 BFF 传 model=None;BFF 会先归一为 gpt-image-2。 - // 尺寸同样只把精确的 2K 识别为 2K,其余值回落到 1K。 - let normalized_image_size = match image_size.map(str::trim) { - Some("2K") => "2K", - _ => "1K", - }; - pricing.image_generation_mud_points(kind, Some(GPT_IMAGE_2_MODEL), Some(normalized_image_size)) + pricing.image_generation_mud_points(kind, Some(model), image_size) } impl EditorAgentPricedTool for GenerateBackgroundMusicTool { fn pricing( &self, pricing: &EditorGenerationPricingConfig, - _args: &GenerateBackgroundMusicToolArgs, + args: &GenerateBackgroundMusicToolArgs, ) -> u32 { - pricing.background_music_model_mud_points(None) + pricing.background_music_model_mud_points(Some(args.model.as_str())) } } impl EditorAgentPricedTool for EditImageTool { fn pricing(&self, pricing: &EditorGenerationPricingConfig, _args: &EditImageToolArgs) -> u32 { - editor_agent_image_mud_points(pricing, Some("quick-edit"), Some("1K")) + editor_agent_image_mud_points(pricing, Some("quick-edit"), GPT_IMAGE_2_MODEL, Some("1K")) } } diff --git a/server-rs/crates/api-server/src/editor_agent/reconcile.rs b/server-rs/crates/api-server/src/editor_agent/reconcile.rs index 20d21a02f..4b48bd1c0 100644 --- a/server-rs/crates/api-server/src/editor_agent/reconcile.rs +++ b/server-rs/crates/api-server/src/editor_agent/reconcile.rs @@ -8,7 +8,9 @@ use platform_editor_agent::agent::tools::edit_image::{ use platform_editor_agent::agent::tools::generate_background_music::{ GenerateBackgroundMusicTool, GenerateBackgroundMusicToolArgs, }; -use platform_editor_agent::agent::tools::generate_character::GenerateCharacterTool; +use platform_editor_agent::agent::tools::generate_character::{ + GenerateCharacterTool, GenerateCharacterToolArgs, +}; use platform_editor_agent::agent::tools::generate_icon_spritesheet::{ EditorIconSpritesheetResult, GenerateIconSpritesheetTool, GenerateIconSpritesheetToolArgs, }; @@ -18,7 +20,9 @@ use platform_editor_agent::agent::tools::generate_image::{ use platform_editor_agent::agent::tools::generate_sound_effect::{ GenerateSoundEffectTool, GenerateSoundEffectToolArgs, }; -use platform_editor_agent::agent::tools::generate_ui_design::GenerateUiDesignTool; +use platform_editor_agent::agent::tools::generate_ui_design::{ + GenerateUiDesignTool, GenerateUiDesignToolArgs, +}; use platform_editor_agent::agent::tools::generate_video::{ GenerateVideoTool, GenerateVideoToolArgs, }; @@ -202,23 +206,31 @@ fn reconcile_completed_editor_agent_tool_call( let tool_name = tool_call.tool_name.clone(); match tool_name.as_str() { - GenerateImageTool::NAME | GenerateCharacterTool::NAME | GenerateUiDesignTool::NAME => { + GenerateImageTool::NAME => { let args: GenerateImageToolArgs = parse_reconciled_value(&tool_call.args)?; let result: EditorImageGenerationResult = parse_reconciled_value(&response)?; - message.text = match tool_name.as_str() { - GenerateCharacterTool::NAME => GenerateCharacterTool { - context: EditorToolContext::default(), - } - .format_execute_message(&args, &result), - GenerateUiDesignTool::NAME => GenerateUiDesignTool { - context: EditorToolContext::default(), - } - .format_execute_message(&args, &result), - _ => GenerateImageTool { - context: EditorToolContext::default(), - } - .format_execute_message(&args, &result), - }; + message.text = GenerateImageTool { + context: EditorToolContext::default(), + } + .format_execute_message(&args, &result); + tool_call.images = vec![resp_to_asset::editor_agent_generated_image(&result)]; + } + GenerateCharacterTool::NAME => { + let args: GenerateCharacterToolArgs = parse_reconciled_value(&tool_call.args)?; + let result: EditorImageGenerationResult = parse_reconciled_value(&response)?; + message.text = GenerateCharacterTool { + context: EditorToolContext::default(), + } + .format_execute_message(&args, &result); + tool_call.images = vec![resp_to_asset::editor_agent_generated_image(&result)]; + } + GenerateUiDesignTool::NAME => { + let args: GenerateUiDesignToolArgs = parse_reconciled_value(&tool_call.args)?; + let result: EditorImageGenerationResult = parse_reconciled_value(&response)?; + message.text = GenerateUiDesignTool { + context: EditorToolContext::default(), + } + .format_execute_message(&args, &result); tool_call.images = vec![resp_to_asset::editor_agent_generated_image(&result)]; } EditImageTool::NAME => { diff --git a/server-rs/crates/api-server/src/editor_agent/tool_args.rs b/server-rs/crates/api-server/src/editor_agent/tool_args.rs new file mode 100644 index 000000000..7a2784dc9 --- /dev/null +++ b/server-rs/crates/api-server/src/editor_agent/tool_args.rs @@ -0,0 +1,184 @@ +use platform_editor_agent::agent::tools::context::EditorToolContext; +use platform_editor_agent::agent::tools::edit_image::{EditImageTool, EditImageToolArgs}; +use platform_editor_agent::agent::tools::generate_background_music::{ + GenerateBackgroundMusicTool, GenerateBackgroundMusicToolArgs, +}; +use platform_editor_agent::agent::tools::generate_character::{ + GenerateCharacterTool, GenerateCharacterToolArgs, +}; +use platform_editor_agent::agent::tools::generate_icon_spritesheet::{ + GenerateIconSpritesheetTool, GenerateIconSpritesheetToolArgs, +}; +use platform_editor_agent::agent::tools::generate_image::{ + GenerateImageTool, GenerateImageToolArgs, +}; +use platform_editor_agent::agent::tools::generate_sound_effect::{ + GenerateSoundEffectTool, GenerateSoundEffectToolArgs, +}; +use platform_editor_agent::agent::tools::generate_ui_design::{ + GenerateUiDesignTool, GenerateUiDesignToolArgs, +}; +use platform_editor_agent::agent::tools::generate_video::{ + GenerateVideoTool, GenerateVideoToolArgs, +}; +use platform_editor_agent::framework::error::PromptError; +use platform_editor_agent::framework::tool::Tool; +use serde::Serialize; +use serde::de::DeserializeOwned; +use serde_json::Value; + +pub fn normalize_tool_args( + tool_name: &str, + args: &Value, + context: &EditorToolContext, +) -> Result { + match tool_name { + GenerateImageTool::NAME => { + let args: GenerateImageToolArgs = parse_args(tool_name, args)?; + let tool = GenerateImageTool { + context: context.clone(), + }; + tool.validate_args(&args) + .map_err(|error| invalid_args(tool_name, error))?; + serialize_args(tool_name, args) + } + GenerateCharacterTool::NAME => { + let args: GenerateCharacterToolArgs = parse_args(tool_name, args)?; + let tool = GenerateCharacterTool { + context: context.clone(), + }; + tool.validate_args(&args) + .map_err(|error| invalid_args(tool_name, error))?; + serialize_args(tool_name, args) + } + GenerateUiDesignTool::NAME => { + let args: GenerateUiDesignToolArgs = parse_args(tool_name, args)?; + let tool = GenerateUiDesignTool { + context: context.clone(), + }; + tool.validate_args(&args) + .map_err(|error| invalid_args(tool_name, error))?; + serialize_args(tool_name, args) + } + EditImageTool::NAME => { + let args: EditImageToolArgs = parse_args(tool_name, args)?; + let tool = EditImageTool { + context: context.clone(), + }; + if let Some(error) = tool.validate_args(&args) { + return Err(invalid_args(tool_name, error)); + } + serialize_args(tool_name, args) + } + GenerateIconSpritesheetTool::NAME => { + let args: GenerateIconSpritesheetToolArgs = parse_args(tool_name, args)?; + let tool = GenerateIconSpritesheetTool { + context: context.clone(), + }; + tool.validate_args(&args) + .map_err(|error| invalid_args(tool_name, error))?; + serialize_args(tool_name, args) + } + GenerateVideoTool::NAME => { + let args: GenerateVideoToolArgs = parse_args(tool_name, args)?; + let tool = GenerateVideoTool { + context: context.clone(), + }; + tool.validate_args(&args) + .map_err(|error| invalid_args(tool_name, error))?; + serialize_args(tool_name, args) + } + GenerateSoundEffectTool::NAME => { + let args: GenerateSoundEffectToolArgs = parse_args(tool_name, args)?; + GenerateSoundEffectTool + .validate_args(&args) + .map_err(|error| invalid_args(tool_name, error))?; + serialize_args(tool_name, args) + } + GenerateBackgroundMusicTool::NAME => { + let args: GenerateBackgroundMusicToolArgs = parse_args(tool_name, args)?; + GenerateBackgroundMusicTool + .validate_args(&args) + .map_err(|error| invalid_args(tool_name, error))?; + serialize_args(tool_name, args) + } + _ => Err(PromptError::ToolError(format!( + "unsupported editor agent tool: {tool_name}" + ))), + } +} + +fn parse_args(tool_name: &str, args: &Value) -> Result { + serde_json::from_value(args.clone()).map_err(|error| { + PromptError::ToolError(format!("invalid args for tool {tool_name}: {error}")) + }) +} + +fn serialize_args(tool_name: &str, args: T) -> Result { + serde_json::to_value(args).map_err(|error| { + PromptError::InternalError(format!( + "failed to normalize args for tool {tool_name}: {error}" + )) + }) +} + +fn invalid_args(tool_name: &str, error: impl std::fmt::Display) -> PromptError { + PromptError::ToolError(format!("invalid args for tool {tool_name}: {error}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use platform_audio::SUNO_DEFAULT_MODEL; + use platform_image::GPT_IMAGE_2_MODEL; + use serde_json::json; + + #[test] + fn normalizes_defaults_and_drops_unknown_fields_before_persistence() { + let normalized = normalize_tool_args( + GenerateUiDesignTool::NAME, + &json!({ + "prompt": "生成游戏主界面", + "unknown": "drop-me" + }), + &EditorToolContext::default(), + ) + .expect("UI 参数应使用工具默认模型并完成规范化"); + + assert_eq!(normalized["model"], GPT_IMAGE_2_MODEL); + assert_eq!(normalized["aspect_ratio"], "1:1"); + assert_eq!(normalized["image_size"], "1K"); + assert!(normalized.get("unknown").is_none()); + } + + #[test] + fn drops_legacy_background_music_make_instrumental_field() { + let normalized = normalize_tool_args( + GenerateBackgroundMusicTool::NAME, + &json!({ + "prompt": "温暖舒缓的钢琴背景音乐", + "model": SUNO_DEFAULT_MODEL, + "make_instrumental": false + }), + &EditorToolContext::default(), + ) + .expect("旧背景音乐字段应被安全忽略"); + + assert!(normalized.get("make_instrumental").is_none()); + } + + #[test] + fn rejects_invalid_sound_duration_before_persistence() { + let error = normalize_tool_args( + GenerateSoundEffectTool::NAME, + &json!({ + "prompt": "按钮点击声", + "duration": 11 + }), + &EditorToolContext::default(), + ) + .expect_err("非法音效时长必须在待确认消息持久化前失败"); + + assert!(error.to_string().contains("11")); + } +} diff --git a/server-rs/crates/platform-editor-agent/Cargo.toml b/server-rs/crates/platform-editor-agent/Cargo.toml index be86ce3af..b60f96fec 100644 --- a/server-rs/crates/platform-editor-agent/Cargo.toml +++ b/server-rs/crates/platform-editor-agent/Cargo.toml @@ -11,6 +11,8 @@ serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } shared-contracts = { workspace = true, features = ["oss-contracts"] } +platform-image = { workspace = true } +platform-audio = { workspace = true } [dev-dependencies] tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/edit_image.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/edit_image.rs index d54b83b89..d4fcc6df5 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/edit_image.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/edit_image.rs @@ -1,16 +1,19 @@ use crate::agent::asset::ImageId; use crate::agent::tools::context::EditorToolContext; use crate::framework::tool::{Tool, ToolFailure, ToolFailureKind}; +use platform_image::GPT_IMAGE_2_MODEL; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use std::error::Error; use std::fmt::Display; + pub struct EditImageTool { pub context: EditorToolContext, } #[derive(Debug, Clone)] pub enum EditImageError { + InvalidModel(String), ObjectImageNotProvided, PromptNotProvided, AssetNotFound(ImageId), @@ -26,6 +29,12 @@ impl Display for EditImageError { EditImageError::AssetNotFound(image_id) => { write!(f, "asset {image_id} not found in context") } + EditImageError::InvalidModel(model) => { + write!( + f, + "{model} is not a valid model name, only {GPT_IMAGE_2_MODEL} is supported for now." + ) + } } } } @@ -38,8 +47,11 @@ pub struct EditImageToolArgs { #[serde(default)] pub reference_image_ids: Vec, pub prompt: String, - // #[serde(default)] - // pub tag: Option, + #[serde(default = "default_model_name")] + pub model: String, +} +fn default_model_name() -> String { + GPT_IMAGE_2_MODEL.to_string() } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -75,10 +87,12 @@ impl Tool for EditImageTool { "type": "string", "description": "编辑提示词,描述希望如何修改图片。例如「把背景换成红色」、「把人物改成坐着」。" }, - // "tag": { - // "type": "string", - // "description": "为新生成的图片添加标签,用于后续在上下文中引用。" - // } + "model": { + "type": "string", + "enum": [GPT_IMAGE_2_MODEL], + "default": GPT_IMAGE_2_MODEL, + "description": format!("图片编辑固定使用{GPT_IMAGE_2_MODEL}") + } }, "required": ["object_image_id", "prompt"], "additionalProperties": false @@ -108,9 +122,7 @@ impl Tool for EditImageTool { EditImageError::AssetNotFound(_) => { ToolFailure::new(ToolFailureKind::NotFound, error.to_string()) } - EditImageError::ObjectImageNotProvided | EditImageError::PromptNotProvided => { - ToolFailure::invalid_args(error.to_string()) - } + _ => ToolFailure::invalid_args(error.to_string()), } } } @@ -140,7 +152,10 @@ pub struct EditorImageEditResult { impl EditImageTool { /// Validate the semantic correctness of the arguments. - fn validate_args(&self, args: &EditImageToolArgs) -> Option { + pub fn validate_args(&self, args: &EditImageToolArgs) -> Option { + if args.model != GPT_IMAGE_2_MODEL { + return Some(EditImageError::InvalidModel(args.model.clone())); + } if args.prompt.trim().is_empty() { return Some(EditImageError::PromptNotProvided); } diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_background_music.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_background_music.rs index 9c19fe2ef..8cdfb529a 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_background_music.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_background_music.rs @@ -1,4 +1,5 @@ use crate::framework::tool::{Tool, ToolFailure}; +use platform_audio::SUNO_DEFAULT_MODEL; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use shared_contracts::assets::EditorAudioGenerateResponse; @@ -9,11 +10,18 @@ pub struct GenerateBackgroundMusicTool; #[derive(Debug, Clone)] pub enum GenerateBackgroundMusicError { + InvalidModel(String), PromptNotProvided, } impl Display for GenerateBackgroundMusicError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "background music prompt not provided") + match self { + Self::InvalidModel(model) => write!( + f, + "{model} is not a valid background music model; only {SUNO_DEFAULT_MODEL} is supported" + ), + Self::PromptNotProvided => write!(f, "background music prompt not provided"), + } } } impl Error for GenerateBackgroundMusicError {} @@ -21,13 +29,13 @@ impl Error for GenerateBackgroundMusicError {} #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GenerateBackgroundMusicToolArgs { pub prompt: String, - #[serde(default = "default_instrumental")] - pub make_instrumental: bool, -} -fn default_instrumental() -> bool { - true + #[serde(default = "default_background_music_model")] + pub model: String, } +fn default_background_music_model() -> String { + SUNO_DEFAULT_MODEL.to_string() +} #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GenerateBackgroundMusicToolOutput { pub message: String, @@ -39,13 +47,13 @@ impl Tool for GenerateBackgroundMusicTool { type Args = GenerateBackgroundMusicToolArgs; type Output = GenerateBackgroundMusicToolOutput; fn description(&self) -> String { - "根据文字描述生成背景音乐。默认生成纯音乐,除非明确要求歌词或人声。".to_string() + "根据文字描述生成纯音乐背景音乐;当前不支持歌词或人声。".to_string() } fn parameters(&self) -> Value { json!({ "type": "object", "properties": { "prompt": { "type": "string", "description": "音乐风格、情绪、乐器和节奏描述。" }, - "make_instrumental": { "type": "boolean", "description": "是否生成纯音乐,默认 true。" } + "model": { "type": "string", "enum": [SUNO_DEFAULT_MODEL], "default": SUNO_DEFAULT_MODEL, "description": "背景音乐固定使用 Suno。" } }, "required": ["prompt"], "additionalProperties": false }) } @@ -54,9 +62,7 @@ impl Tool for GenerateBackgroundMusicTool { args: Self::Args, ) -> impl Future> + Send { async move { - if args.prompt.trim().is_empty() { - return Err(GenerateBackgroundMusicError::PromptNotProvided); - } + self.validate_args(&args)?; Ok(GenerateBackgroundMusicToolOutput { message: "this tool call is pending user confirmation. if all is pending, just end this turn".to_string(), }) @@ -72,6 +78,23 @@ impl Tool for GenerateBackgroundMusicTool { } impl GenerateBackgroundMusicTool { + pub const DEFAULT_MODEL: &'static str = SUNO_DEFAULT_MODEL; + + pub fn validate_args( + &self, + args: &GenerateBackgroundMusicToolArgs, + ) -> Result<(), GenerateBackgroundMusicError> { + if args.prompt.trim().is_empty() { + return Err(GenerateBackgroundMusicError::PromptNotProvided); + } + if args.model != SUNO_DEFAULT_MODEL { + return Err(GenerateBackgroundMusicError::InvalidModel( + args.model.clone(), + )); + } + Ok(()) + } + pub fn format_execute_message( &self, args: &GenerateBackgroundMusicToolArgs, diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_character.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_character.rs index 0b9227420..70f41692a 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_character.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_character.rs @@ -1,19 +1,39 @@ +use crate::agent::asset::ImageId; use crate::agent::tools::context::EditorToolContext; use crate::agent::tools::generate_image::{ - EditorImageGenerationResult, GenerateImageError, GenerateImageTool, GenerateImageToolArgs, - GenerateImageToolOutput, + EditorImageGenerationResult, GenerateImageError, GenerateImageToolOutput, }; +use crate::agent::tools::image_generation_options::{ + default_image_aspect_ratio, default_image_model, default_image_size, + image_aspect_ratio_parameter_schema, image_model_parameter_schema, image_size_parameter_schema, + validate_image_generation_options, +}; +use crate::framework::tool::ToolFailureKind; use crate::framework::tool::{Tool, ToolFailure}; +use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; pub struct GenerateCharacterTool { pub context: EditorToolContext, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GenerateCharacterToolArgs { + pub prompt: String, + #[serde(default = "default_image_model")] + pub model: String, + #[serde(default)] + pub reference_image_ids: Vec, + #[serde(default = "default_image_aspect_ratio")] + pub aspect_ratio: Option, + #[serde(default = "default_image_size")] + pub image_size: Option, +} + impl Tool for GenerateCharacterTool { const NAME: &'static str = "generate-character"; type Error = GenerateImageError; - type Args = GenerateImageToolArgs; + type Args = GenerateCharacterToolArgs; type Output = GenerateImageToolOutput; fn description(&self) -> String { @@ -26,9 +46,10 @@ impl Tool for GenerateCharacterTool { "type": "object", "properties": { "prompt": { "type": "string", "description": "角色外貌、服装、姿势、画风和构图的完整描述。" }, + "model": image_model_parameter_schema(), "reference_image_ids": { "type": "array", "items": { "type": "string" }, "description": "可选角色或画风参考图 ID。" }, - "aspect_ratio": { "type": "string", "description": "可选比例,例如 2:3 或 9:16。" }, - "image_size": { "type": "string", "description": "可选清晰度,例如 1K 或 2K。" } + "aspect_ratio": image_aspect_ratio_parameter_schema(), + "image_size": image_size_parameter_schema(), }, "required": ["prompt"], "additionalProperties": false @@ -40,10 +61,7 @@ impl Tool for GenerateCharacterTool { args: Self::Args, ) -> impl Future> + Send { async move { - GenerateImageTool { - context: self.context.clone(), - } - .validate_args(&args)?; + self.validate_args(&args)?; Ok(GenerateImageToolOutput { message: "this tool call is pending user confirmation. if all is pending, just end this turn".to_string() }) } } @@ -53,17 +71,39 @@ impl Tool for GenerateCharacterTool { } fn classify_error(&self, error: &Self::Error) -> ToolFailure { - GenerateImageTool { - context: self.context.clone(), + match error { + GenerateImageError::AssetNotFound(_) => { + ToolFailure::new(ToolFailureKind::NotFound, error.to_string()) + } + _ => ToolFailure::invalid_args(error.to_string()), } - .classify_error(error) } } impl GenerateCharacterTool { + pub fn validate_args( + &self, + args: &GenerateCharacterToolArgs, + ) -> Result<(), GenerateImageError> { + validate_image_generation_options( + args.model.as_str(), + args.aspect_ratio.as_deref(), + args.image_size.as_deref(), + )?; + if args.prompt.trim().is_empty() { + return Err(GenerateImageError::PromptNotProvided); + } + for image_id in &args.reference_image_ids { + if !self.context.contains_image(image_id) { + return Err(GenerateImageError::AssetNotFound(image_id.clone())); + } + } + Ok(()) + } + pub fn format_execute_message( &self, - args: &GenerateImageToolArgs, + args: &GenerateCharacterToolArgs, result: &EditorImageGenerationResult, ) -> String { let tool_name = Self::NAME; diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_icon_spritesheet.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_icon_spritesheet.rs index dfd2a008c..9e7cff650 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_icon_spritesheet.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_icon_spritesheet.rs @@ -1,6 +1,12 @@ use crate::agent::asset::ImageId; use crate::agent::tools::context::EditorToolContext; +use crate::agent::tools::image_generation_options::{ + ImageGenerationOptionsError, default_image_aspect_ratio, default_image_model, + default_image_size, image_aspect_ratio_parameter_schema, image_model_parameter_schema, + image_size_parameter_schema, validate_image_generation_options, +}; use crate::framework::tool::{Tool, ToolFailure, ToolFailureKind}; +use platform_image::{GPT_IMAGE_2_MODEL, NANOBANANA_2_MODEL}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use std::error::Error; @@ -12,16 +18,35 @@ pub struct GenerateIconSpritesheetTool { #[derive(Debug, Clone)] pub enum GenerateIconSpritesheetError { + InvalidModel(String), + InvalidAspectRatio(String), + InvalidImageSize { model: String, image_size: String }, ReferenceNotProvided, DescriptionsNotProvided, + TooManyDescriptions(usize), AssetNotFound(ImageId), } impl Display for GenerateIconSpritesheetError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { + Self::InvalidModel(model) => write!( + f, + "{model} is not a valid image model; supported models: {NANOBANANA_2_MODEL}, {GPT_IMAGE_2_MODEL}" + ), + Self::InvalidAspectRatio(aspect_ratio) => { + write!(f, "invalid aspect ratio: {aspect_ratio}") + } + Self::InvalidImageSize { model, image_size } => { + write!(f, "invalid image size {image_size} for model {model}") + } Self::ReferenceNotProvided => write!(f, "reference image not provided"), Self::DescriptionsNotProvided => write!(f, "icon descriptions not provided"), + Self::TooManyDescriptions(count) => write!( + f, + "icon description count must be between 1 and {}, got {count}", + GenerateIconSpritesheetTool::MAX_ICON_DESCRIPTIONS + ), Self::AssetNotFound(image_id) => write!(f, "asset {image_id} not found in context"), } } @@ -29,15 +54,31 @@ impl Display for GenerateIconSpritesheetError { impl Error for GenerateIconSpritesheetError {} +impl From for GenerateIconSpritesheetError { + fn from(error: ImageGenerationOptionsError) -> Self { + match error { + ImageGenerationOptionsError::InvalidModel(model) => Self::InvalidModel(model), + ImageGenerationOptionsError::InvalidAspectRatio(aspect_ratio) => { + Self::InvalidAspectRatio(aspect_ratio) + } + ImageGenerationOptionsError::InvalidImageSize { model, image_size } => { + Self::InvalidImageSize { model, image_size } + } + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GenerateIconSpritesheetToolArgs { pub reference_image_id: ImageId, + #[serde(default = "default_image_model")] + pub model: String, #[serde(default)] pub reference_image_ids: Vec, pub icon_descriptions: Vec, - #[serde(default)] + #[serde(default = "default_image_aspect_ratio")] pub aspect_ratio: Option, - #[serde(default)] + #[serde(default = "default_image_size")] pub image_size: Option, } @@ -84,10 +125,11 @@ impl Tool for GenerateIconSpritesheetTool { "type": "object", "properties": { "reference_image_id": { "type": "string", "description": "必填的图标规范或风格参考图 ID。" }, + "model": image_model_parameter_schema(), "reference_image_ids": { "type": "array", "items": { "type": "string" }, "description": "可选的额外参考图 ID。" }, - "icon_descriptions": { "type": "array", "items": { "type": "string" }, "description": "要生成的多个图标描述。" }, - "aspect_ratio": { "type": "string", "description": "可选图集比例。" }, - "image_size": { "type": "string", "description": "可选清晰度,例如 1K 或 2K。" } + "icon_descriptions": { "type": "array", "items": { "type": "string" }, "minItems": 1, "maxItems": GenerateIconSpritesheetTool::MAX_ICON_DESCRIPTIONS, "description": "要生成的多个图标描述,数量必须在 1 到 100 个之间。" }, + "aspect_ratio": image_aspect_ratio_parameter_schema(), + "image_size": image_size_parameter_schema() }, "required": ["reference_image_id", "icon_descriptions"], "additionalProperties": false @@ -115,8 +157,12 @@ impl Tool for GenerateIconSpritesheetTool { GenerateIconSpritesheetError::AssetNotFound(_) => { ToolFailure::new(ToolFailureKind::NotFound, error.to_string()) } - GenerateIconSpritesheetError::ReferenceNotProvided - | GenerateIconSpritesheetError::DescriptionsNotProvided => { + GenerateIconSpritesheetError::InvalidModel(_) + | GenerateIconSpritesheetError::InvalidAspectRatio(_) + | GenerateIconSpritesheetError::InvalidImageSize { .. } + | GenerateIconSpritesheetError::ReferenceNotProvided + | GenerateIconSpritesheetError::DescriptionsNotProvided + | GenerateIconSpritesheetError::TooManyDescriptions(_) => { ToolFailure::invalid_args(error.to_string()) } } @@ -124,10 +170,17 @@ impl Tool for GenerateIconSpritesheetTool { } impl GenerateIconSpritesheetTool { - fn validate_args( + pub const MAX_ICON_DESCRIPTIONS: usize = 100; + + pub fn validate_args( &self, args: &GenerateIconSpritesheetToolArgs, ) -> Result<(), GenerateIconSpritesheetError> { + validate_image_generation_options( + args.model.as_str(), + args.aspect_ratio.as_deref(), + args.image_size.as_deref(), + )?; if args.reference_image_id.id.trim().is_empty() { return Err(GenerateIconSpritesheetError::ReferenceNotProvided); } @@ -136,13 +189,19 @@ impl GenerateIconSpritesheetTool { args.reference_image_id.clone(), )); } - if args + let description_count = args .icon_descriptions .iter() - .all(|description| description.trim().is_empty()) - { + .filter(|description| !description.trim().is_empty()) + .count(); + if description_count == 0 { return Err(GenerateIconSpritesheetError::DescriptionsNotProvided); } + if description_count > Self::MAX_ICON_DESCRIPTIONS { + return Err(GenerateIconSpritesheetError::TooManyDescriptions( + description_count, + )); + } for image_id in &args.reference_image_ids { if !self.context.contains_image(image_id) { return Err(GenerateIconSpritesheetError::AssetNotFound( @@ -174,3 +233,70 @@ impl GenerateIconSpritesheetTool { ) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::asset::ImageMetadata; + use std::collections::HashMap; + + fn tool_and_args( + icon_descriptions: Vec, + ) -> (GenerateIconSpritesheetTool, GenerateIconSpritesheetToolArgs) { + let reference_image_id = ImageId { + id: "reference-image".to_string(), + }; + let tool = GenerateIconSpritesheetTool { + context: EditorToolContext { + images: HashMap::from([( + reference_image_id.clone(), + ImageMetadata { + tag: "image".to_string(), + data_key: "asset://reference-image".to_string(), + }, + )]), + }, + }; + let args = GenerateIconSpritesheetToolArgs { + reference_image_id, + model: NANOBANANA_2_MODEL.to_string(), + reference_image_ids: Vec::new(), + icon_descriptions, + aspect_ratio: Some("1:1".to_string()), + image_size: Some("1K".to_string()), + }; + (tool, args) + } + + #[test] + fn validates_non_empty_icon_description_count() { + let too_many = (0..=GenerateIconSpritesheetTool::MAX_ICON_DESCRIPTIONS) + .map(|index| format!("图标{index}")) + .collect::>(); + let (tool, args) = tool_and_args(too_many); + assert!(matches!( + tool.validate_args(&args), + Err(GenerateIconSpritesheetError::TooManyDescriptions(101)) + )); + + let (tool, args) = tool_and_args(vec![" ".to_string()]); + assert!(matches!( + tool.validate_args(&args), + Err(GenerateIconSpritesheetError::DescriptionsNotProvided) + )); + } + + #[test] + fn schema_caps_icon_descriptions() { + let (tool, _) = tool_and_args(vec!["背包".to_string()]); + let schema = tool.parameters(); + assert_eq!( + schema["properties"]["icon_descriptions"]["minItems"], + json!(1) + ); + assert_eq!( + schema["properties"]["icon_descriptions"]["maxItems"], + json!(100) + ); + } +} diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_image.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_image.rs index e3c7a687e..09512a42c 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_image.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_image.rs @@ -1,6 +1,12 @@ use crate::agent::asset::ImageId; use crate::agent::tools::context::EditorToolContext; +use crate::agent::tools::image_generation_options::{ + ImageGenerationOptionsError, default_image_aspect_ratio, default_image_model, + default_image_size, image_aspect_ratio_parameter_schema, image_model_parameter_schema, + image_size_parameter_schema, validate_image_generation_options, +}; use crate::framework::tool::{Tool, ToolFailure, ToolFailureKind}; +use platform_image::{GPT_IMAGE_2_MODEL, NANOBANANA_2_MODEL}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use std::error::Error; @@ -12,6 +18,10 @@ pub struct GenerateImageTool { #[derive(Debug, Clone)] pub enum GenerateImageError { + InvalidModel(String), + UnsupportedUiDesignModel(String), + InvalidAspectRatio(String), + InvalidImageSize { model: String, image_size: String }, PromptNotProvided, AssetNotFound(ImageId), } @@ -19,6 +29,20 @@ pub enum GenerateImageError { impl Display for GenerateImageError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { + Self::InvalidModel(model) => write!( + f, + "{model} is not a valid image model; supported models: {NANOBANANA_2_MODEL}, {GPT_IMAGE_2_MODEL}" + ), + Self::UnsupportedUiDesignModel(model) => write!( + f, + "{model} is not supported for UI design generation; required model: {GPT_IMAGE_2_MODEL}" + ), + Self::InvalidAspectRatio(aspect_ratio) => { + write!(f, "invalid aspect ratio: {aspect_ratio}") + } + Self::InvalidImageSize { model, image_size } => { + write!(f, "invalid image size {image_size} for model {model}") + } Self::PromptNotProvided => write!(f, "prompt not provided"), Self::AssetNotFound(image_id) => write!(f, "asset {image_id} not found in context"), } @@ -27,15 +51,30 @@ impl Display for GenerateImageError { impl Error for GenerateImageError {} +impl From for GenerateImageError { + fn from(error: ImageGenerationOptionsError) -> Self { + match error { + ImageGenerationOptionsError::InvalidModel(model) => Self::InvalidModel(model), + ImageGenerationOptionsError::InvalidAspectRatio(aspect_ratio) => { + Self::InvalidAspectRatio(aspect_ratio) + } + ImageGenerationOptionsError::InvalidImageSize { model, image_size } => { + Self::InvalidImageSize { model, image_size } + } + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GenerateImageToolArgs { pub prompt: String, + #[serde(default = "default_image_model")] + pub model: String, #[serde(default)] pub reference_image_ids: Vec, - #[serde(default)] - // TODO restrict to a set of possible values + #[serde(default = "default_image_aspect_ratio")] pub aspect_ratio: Option, - #[serde(default)] + #[serde(default = "default_image_size")] pub image_size: Option, } @@ -63,19 +102,14 @@ impl Tool for GenerateImageTool { "type": "string", "description": "完整的生图提示词,包含主体、场景、风格、构图和背景。" }, + "model": image_model_parameter_schema(), "reference_image_ids": { "type": "array", "items": { "type": "string" }, "description": "可选参考图 ID 列表,用于提供画风或元素参考。" }, - "aspect_ratio": { - "type": "string", - "description": "可选画面比例,例如 1:1、16:9、9:16。" - }, - "image_size": { - "type": "string", - "description": "可选图片清晰度,例如 1K、2K、4K。" - } + "aspect_ratio": image_aspect_ratio_parameter_schema(), + "image_size": image_size_parameter_schema() }, "required": ["prompt"], "additionalProperties": false @@ -103,7 +137,11 @@ impl Tool for GenerateImageTool { GenerateImageError::AssetNotFound(_) => { ToolFailure::new(ToolFailureKind::NotFound, error.to_string()) } - GenerateImageError::PromptNotProvided => ToolFailure::invalid_args(error.to_string()), + GenerateImageError::InvalidModel(_) + | GenerateImageError::UnsupportedUiDesignModel(_) + | GenerateImageError::InvalidAspectRatio(_) + | GenerateImageError::InvalidImageSize { .. } + | GenerateImageError::PromptNotProvided => ToolFailure::invalid_args(error.to_string()), } } } @@ -129,6 +167,11 @@ pub struct EditorImageGenerationResult { impl GenerateImageTool { pub fn validate_args(&self, args: &GenerateImageToolArgs) -> Result<(), GenerateImageError> { + validate_image_generation_options( + args.model.as_str(), + args.aspect_ratio.as_deref(), + args.image_size.as_deref(), + )?; if args.prompt.trim().is_empty() { return Err(GenerateImageError::PromptNotProvided); } diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_sound_effect.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_sound_effect.rs index 59e5c77d9..dba207011 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_sound_effect.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_sound_effect.rs @@ -1,4 +1,5 @@ use crate::framework::tool::{Tool, ToolFailure}; +use platform_audio::VIDU_AUDIO_MODEL; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use shared_contracts::assets::EditorAudioGenerateResponse; @@ -9,12 +10,29 @@ pub struct GenerateSoundEffectTool; #[derive(Debug, Clone)] pub enum GenerateSoundEffectError { + InvalidModel(String), + InvalidDuration(u8), PromptNotProvided, } impl Display for GenerateSoundEffectError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "sound effect prompt not provided") + match self { + Self::InvalidModel(model) => write!( + f, + "{model} is not a valid sound effect model; only {VIDU_AUDIO_MODEL} is supported" + ), + Self::InvalidDuration(duration) => write!( + f, + "{duration} is not a valid sound effect duration; supported durations: {}", + GenerateSoundEffectTool::SUPPORTED_DURATIONS + .iter() + .map(u8::to_string) + .collect::>() + .join(", ") + ), + Self::PromptNotProvided => write!(f, "sound effect prompt not provided"), + } } } @@ -23,10 +41,20 @@ impl Error for GenerateSoundEffectError {} #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GenerateSoundEffectToolArgs { pub prompt: String, - #[serde(default)] + #[serde(default = "default_sound_effect_model")] + pub model: String, + #[serde(default = "default_sound_effect_duration")] pub duration: Option, } +fn default_sound_effect_model() -> String { + VIDU_AUDIO_MODEL.to_string() +} + +fn default_sound_effect_duration() -> Option { + Some(GenerateSoundEffectTool::DEFAULT_DURATION) +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GenerateSoundEffectToolOutput { pub message: String, @@ -47,8 +75,8 @@ impl Tool for GenerateSoundEffectTool { "type": "object", "properties": { "prompt": { "type": "string", "description": "音效内容、材质、节奏和情绪描述。" }, - "duration": { "type": "integer", "description": "可选时长(秒)。" }, - // "model": { "type": "string", "description": "可选音效模型。" } + "model": { "type": "string", "enum": [VIDU_AUDIO_MODEL], "default": VIDU_AUDIO_MODEL, "description": "音效模型。" }, + "duration": { "type": "integer", "enum": GenerateSoundEffectTool::SUPPORTED_DURATIONS, "default": GenerateSoundEffectTool::DEFAULT_DURATION, "description": "音效时长(秒)。" }, }, "required": ["prompt"], "additionalProperties": false @@ -60,9 +88,7 @@ impl Tool for GenerateSoundEffectTool { args: Self::Args, ) -> impl Future> + Send { async move { - if args.prompt.trim().is_empty() { - return Err(GenerateSoundEffectError::PromptNotProvided); - } + self.validate_args(&args)?; Ok(GenerateSoundEffectToolOutput { message: "this tool call is pending user confirmation. if all is pending, just end this turn".to_string(), }) @@ -79,7 +105,27 @@ impl Tool for GenerateSoundEffectTool { } impl GenerateSoundEffectTool { - // pub const DEFAULT_MODEL: &'static str = "audio1.0"; + pub const DEFAULT_MODEL: &'static str = VIDU_AUDIO_MODEL; + pub const DEFAULT_DURATION: u8 = 5; + pub const SUPPORTED_DURATIONS: &'static [u8] = &[2, 3, 4, 5, 6, 7, 8, 9, 10]; + + pub fn validate_args( + &self, + args: &GenerateSoundEffectToolArgs, + ) -> Result<(), GenerateSoundEffectError> { + if args.prompt.trim().is_empty() { + return Err(GenerateSoundEffectError::PromptNotProvided); + } + if args.model != VIDU_AUDIO_MODEL { + return Err(GenerateSoundEffectError::InvalidModel(args.model.clone())); + } + if let Some(duration) = args.duration + && !Self::SUPPORTED_DURATIONS.contains(&duration) + { + return Err(GenerateSoundEffectError::InvalidDuration(duration)); + } + Ok(()) + } pub fn format_execute_message( &self, @@ -94,3 +140,39 @@ impl GenerateSoundEffectTool { ) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn args(duration: Option) -> GenerateSoundEffectToolArgs { + GenerateSoundEffectToolArgs { + prompt: "按钮点击声".to_string(), + model: GenerateSoundEffectTool::DEFAULT_MODEL.to_string(), + duration, + } + } + + #[test] + fn validates_schema_durations_and_default() { + assert_eq!(GenerateSoundEffectTool::DEFAULT_DURATION, 5); + assert!(GenerateSoundEffectTool.validate_args(&args(None)).is_ok()); + for duration in GenerateSoundEffectTool::SUPPORTED_DURATIONS { + assert!( + GenerateSoundEffectTool + .validate_args(&args(Some(*duration))) + .is_ok() + ); + } + } + + #[tokio::test] + async fn planning_rejects_durations_outside_schema() { + for duration in [1, 11, u8::MAX] { + assert!(matches!( + GenerateSoundEffectTool.call(args(Some(duration))).await, + Err(GenerateSoundEffectError::InvalidDuration(value)) if value == duration + )); + } + } +} diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_ui_design.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_ui_design.rs index 49660a6de..d67f9e3eb 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_ui_design.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_ui_design.rs @@ -1,19 +1,43 @@ +use crate::agent::asset::ImageId; use crate::agent::tools::context::EditorToolContext; use crate::agent::tools::generate_image::{ - EditorImageGenerationResult, GenerateImageError, GenerateImageTool, GenerateImageToolArgs, - GenerateImageToolOutput, + EditorImageGenerationResult, GenerateImageError, GenerateImageToolOutput, }; +use crate::agent::tools::image_generation_options::{ + default_image_aspect_ratio, default_image_size, image_aspect_ratio_parameter_schema, + image_size_parameter_schema, validate_image_generation_options, +}; +use crate::framework::tool::ToolFailureKind; use crate::framework::tool::{Tool, ToolFailure}; +use platform_image::GPT_IMAGE_2_MODEL; +use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; pub struct GenerateUiDesignTool { pub context: EditorToolContext, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GenerateUiDesignToolArgs { + pub prompt: String, + #[serde(default = "default_ui_design_model")] + pub model: String, + #[serde(default)] + pub reference_image_ids: Vec, + #[serde(default = "default_image_aspect_ratio")] + pub aspect_ratio: Option, + #[serde(default = "default_image_size")] + pub image_size: Option, +} + +fn default_ui_design_model() -> String { + GPT_IMAGE_2_MODEL.to_string() +} + impl Tool for GenerateUiDesignTool { const NAME: &'static str = "generate-ui-design"; type Error = GenerateImageError; - type Args = GenerateImageToolArgs; + type Args = GenerateUiDesignToolArgs; type Output = GenerateImageToolOutput; fn description(&self) -> String { @@ -25,11 +49,17 @@ impl Tool for GenerateUiDesignTool { "type": "object", "properties": { "prompt": { "type": "string", "description": "完整 UI 画面、信息层级、视觉风格和构图描述。" }, + "model": { + "type": "string", + "enum": [GPT_IMAGE_2_MODEL], + "default": GPT_IMAGE_2_MODEL, + "description": "UI 设计图固定使用 gpt-image-2。" + }, "reference_image_ids": { "type": "array", "items": { "type": "string" }, "description": "可选 UI 风格或布局参考图 ID。" }, - "aspect_ratio": { "type": "string", "description": "可选画面比例。" }, - "image_size": { "type": "string", "description": "可选清晰度,例如 1K 或 2K。" } + "aspect_ratio": image_aspect_ratio_parameter_schema(), + "image_size": image_size_parameter_schema() }, - "required": ["prompt"], + "required": ["prompt", "model"], "additionalProperties": false }) } @@ -39,10 +69,7 @@ impl Tool for GenerateUiDesignTool { args: Self::Args, ) -> impl Future> + Send { async move { - GenerateImageTool { - context: self.context.clone(), - } - .validate_args(&args)?; + self.validate_args(&args)?; Ok(GenerateImageToolOutput { message: "this tool call is pending user confirmation. if all is pending, just end this turn".to_string() }) } } @@ -52,17 +79,41 @@ impl Tool for GenerateUiDesignTool { } fn classify_error(&self, error: &Self::Error) -> ToolFailure { - GenerateImageTool { - context: self.context.clone(), + match error { + GenerateImageError::AssetNotFound(_) => { + ToolFailure::new(ToolFailureKind::NotFound, error.to_string()) + } + _ => ToolFailure::invalid_args(error.to_string()), } - .classify_error(error) } } impl GenerateUiDesignTool { + pub fn validate_args(&self, args: &GenerateUiDesignToolArgs) -> Result<(), GenerateImageError> { + if args.model != GPT_IMAGE_2_MODEL { + return Err(GenerateImageError::UnsupportedUiDesignModel( + args.model.clone(), + )); + } + validate_image_generation_options( + args.model.as_str(), + args.aspect_ratio.as_deref(), + args.image_size.as_deref(), + )?; + if args.prompt.trim().is_empty() { + return Err(GenerateImageError::PromptNotProvided); + } + for image_id in &args.reference_image_ids { + if !self.context.contains_image(image_id) { + return Err(GenerateImageError::AssetNotFound(image_id.clone())); + } + } + Ok(()) + } + pub fn format_execute_message( &self, - args: &GenerateImageToolArgs, + args: &GenerateUiDesignToolArgs, result: &EditorImageGenerationResult, ) -> String { let tool_name = Self::NAME; @@ -80,3 +131,61 @@ impl GenerateUiDesignTool { ) } } + +#[cfg(test)] +mod tests { + use super::*; + use platform_image::NANOBANANA_2_MODEL; + + fn tool() -> GenerateUiDesignTool { + GenerateUiDesignTool { + context: EditorToolContext::default(), + } + } + + fn args(model: &str) -> GenerateUiDesignToolArgs { + GenerateUiDesignToolArgs { + prompt: "生成游戏主界面".to_string(), + model: model.to_string(), + reference_image_ids: Vec::new(), + aspect_ratio: Some("16:9".to_string()), + image_size: Some("1K".to_string()), + } + } + + #[test] + fn schema_and_validation_lock_ui_design_to_gpt_image_2() { + let tool = tool(); + let parameters = tool.parameters(); + + assert_eq!( + parameters["properties"]["model"]["enum"], + json!([GPT_IMAGE_2_MODEL]) + ); + assert_eq!( + parameters["properties"]["model"]["default"], + GPT_IMAGE_2_MODEL + ); + assert!( + parameters["required"] + .as_array() + .is_some_and(|required| required.contains(&json!("model"))) + ); + assert!(tool.validate_args(&args(GPT_IMAGE_2_MODEL)).is_ok()); + assert!(matches!( + tool.validate_args(&args(NANOBANANA_2_MODEL)), + Err(GenerateImageError::UnsupportedUiDesignModel(model)) if model == NANOBANANA_2_MODEL + )); + } + + #[test] + fn legacy_args_without_model_default_to_gpt_image_2() { + let args: GenerateUiDesignToolArgs = serde_json::from_value(json!({ + "prompt": "生成游戏主界面" + })) + .expect("旧版 UI 设计参数应能反序列化"); + + assert_eq!(args.model, GPT_IMAGE_2_MODEL); + assert!(tool().validate_args(&args).is_ok()); + } +} diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_video.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_video.rs index b03ad5c4b..d802aec42 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_video.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_video.rs @@ -13,12 +13,46 @@ pub struct GenerateVideoTool { #[derive(Debug, Clone)] pub enum GenerateVideoError { + InvalidModel(String), + InvalidAspectRatio(String), + InvalidDurationSeconds(u32), + InvalidResolution(String), + InvalidSound(String), + UnsupportedModelResolution { model: String, resolution: String }, + ReferencesUnsupportedForModel(String), + TooManyReferenceImages(usize), PromptNotProvided, AssetNotFound(ImageId), } impl Display for GenerateVideoError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { + Self::InvalidModel(model) => write!(f, "{model} is not a supported video model"), + Self::InvalidAspectRatio(aspect_ratio) => { + write!(f, "{aspect_ratio} is not a supported video aspect ratio") + } + Self::InvalidDurationSeconds(duration_seconds) => write!( + f, + "{duration_seconds} is not a supported video duration in seconds" + ), + Self::InvalidResolution(resolution) => { + write!(f, "{resolution} is not a supported video resolution") + } + Self::InvalidSound(sound) => { + write!(f, "{sound} is not a supported video sound option") + } + Self::UnsupportedModelResolution { model, resolution } => { + write!(f, "{model} does not support {resolution} resolution") + } + Self::ReferencesUnsupportedForModel(model) => write!( + f, + "reference images are not supported by video model {model}" + ), + Self::TooManyReferenceImages(count) => write!( + f, + "video generation accepts at most {} reference images, got {count}", + GenerateVideoTool::MAX_REFERENCE_IMAGES + ), Self::PromptNotProvided => write!(f, "video prompt not provided"), Self::AssetNotFound(image_id) => write!(f, "asset {image_id} not found in context"), } @@ -31,17 +65,37 @@ pub struct GenerateVideoToolArgs { pub prompt: String, #[serde(default)] pub reference_image_ids: Vec, - #[serde(default)] + #[serde(default = "default_video_aspect_ratio")] pub aspect_ratio: Option, - #[serde(default)] + #[serde(default = "default_video_duration_seconds")] pub duration_seconds: Option, - #[serde(default)] - pub model: Option, - #[serde(default)] + #[serde(default = "default_video_model")] + pub model: String, + #[serde(default = "default_video_resolution")] pub resolution: Option, - #[serde(default)] + #[serde(default = "default_video_sound")] pub sound: Option, } + +fn default_video_model() -> String { + GenerateVideoTool::DEFAULT_VIDEO_MODEL.to_string() +} + +fn default_video_aspect_ratio() -> Option { + Some(GenerateVideoTool::DEFAULT_VIDEO_ASPECT_RATIO.to_string()) +} + +fn default_video_duration_seconds() -> Option { + Some(GenerateVideoTool::DEFAULT_VIDEO_DURATION_SECONDS) +} + +fn default_video_resolution() -> Option { + Some(GenerateVideoTool::DEFAULT_VIDEO_RESOLUTION.to_string()) +} + +fn default_video_sound() -> Option { + Some(GenerateVideoTool::DEFAULT_VIDEO_SOUND.to_string()) +} #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GenerateVideoToolOutput { pub message: String, @@ -59,13 +113,35 @@ impl Tool for GenerateVideoTool { json!({ "type": "object", "properties": { "prompt": { "type": "string", "description": "镜头、主体动作、场景和风格描述。" }, - "reference_image_ids": { "type": "array", "items": { "type": "string" }, "description": "可选图片参考图 ID。" }, - "aspect_ratio": { "type": "string", "description": "可选比例,默认 16:9。" }, - "duration_seconds": { "type": "integer", "description": "可选时长,默认 4 秒。" }, - "model": { "type": "string", "description": "可选视频模型。" }, - "resolution": { "type": "string", "description": "可选清晰度,默认 720p。" }, - "sound": { "type": "string", "description": "是否生成声音,默认 off。" } - }, "required": ["prompt"], "additionalProperties": false + "reference_image_ids": { "type": "array", "items": { "type": "string" }, "maxItems": GenerateVideoTool::MAX_REFERENCE_IMAGES, "description": "可选图片参考图 ID,最多 9 张;仅 Seedance 2.0 系列模型支持参考图。" }, + "aspect_ratio": { "type": "string", "enum": GenerateVideoTool::SUPPORTED_ASPECT_RATIOS, "default": GenerateVideoTool::DEFAULT_VIDEO_ASPECT_RATIO, "description": "视频比例。" }, + "duration_seconds": { "type": "integer", "enum": GenerateVideoTool::SUPPORTED_DURATION_SECONDS, "default": GenerateVideoTool::DEFAULT_VIDEO_DURATION_SECONDS, "description": "视频时长(秒)。" }, + "model": { "type": "string", "enum": GenerateVideoTool::SUPPORTED_VIDEO_MODELS, "default": GenerateVideoTool::DEFAULT_VIDEO_MODEL, "description": "视频模型。" }, + "resolution": { "type": "string", "enum": GenerateVideoTool::SUPPORTED_RESOLUTIONS, "default": GenerateVideoTool::DEFAULT_VIDEO_RESOLUTION, "description": "视频分辨率;seedance2.0-fast 仅支持 480p、720p。" }, + "sound": { "type": "string", "enum": GenerateVideoTool::SUPPORTED_SOUND_OPTIONS, "default": GenerateVideoTool::DEFAULT_VIDEO_SOUND, "description": "是否生成声音。" } + }, + "required": ["prompt"], + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { "model": { "const": GenerateVideoTool::SEEDANCE_2_FAST_MODEL } }, + "required": ["model"] + }, + "then": { + "properties": { "resolution": { "enum": GenerateVideoTool::SEEDANCE_2_FAST_RESOLUTIONS } } + } + }, + { + "if": { + "properties": { "reference_image_ids": { "minItems": 1 } }, + "required": ["reference_image_ids"] + }, + "then": { + "properties": { "model": { "enum": GenerateVideoTool::REFERENCE_IMAGE_MODELS } } + } + } + ] }) } fn call( @@ -73,14 +149,7 @@ impl Tool for GenerateVideoTool { args: Self::Args, ) -> impl Future> + Send { async move { - if args.prompt.trim().is_empty() { - return Err(GenerateVideoError::PromptNotProvided); - } - for id in &args.reference_image_ids { - if !self.context.contains_image(id) { - return Err(GenerateVideoError::AssetNotFound(id.clone())); - } - } + self.validate_args(&args)?; Ok(GenerateVideoToolOutput { message: "this tool call is pending user confirmation. if all is pending, just end this turn".to_string(), }) @@ -95,15 +164,98 @@ impl Tool for GenerateVideoTool { GenerateVideoError::AssetNotFound(_) => { ToolFailure::new(ToolFailureKind::NotFound, error.to_string()) } - GenerateVideoError::PromptNotProvided => ToolFailure::invalid_args(error.to_string()), + _ => ToolFailure::invalid_args(error.to_string()), } } } impl GenerateVideoTool { - pub const DEFAULT_VIDEO_MODEL: &'static str = "seedance2.0-fast"; + pub const SEEDANCE_2_FAST_MODEL: &'static str = "seedance2.0-fast"; + pub const SEEDANCE_2_MODEL: &'static str = "seedance2.0"; + pub const DEFAULT_VIDEO_MODEL: &'static str = Self::SEEDANCE_2_FAST_MODEL; + pub const SUPPORTED_VIDEO_MODELS: &'static [&'static str] = &[ + Self::SEEDANCE_2_FAST_MODEL, + Self::SEEDANCE_2_MODEL, + "kling3.0", + "kling3.0-omni", + ]; + pub const REFERENCE_IMAGE_MODELS: &'static [&'static str] = + &[Self::SEEDANCE_2_FAST_MODEL, Self::SEEDANCE_2_MODEL]; + pub const SUPPORTED_ASPECT_RATIOS: &'static [&'static str] = + &["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"]; + pub const SUPPORTED_DURATION_SECONDS: &'static [u32] = + &[4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]; + pub const SUPPORTED_RESOLUTIONS: &'static [&'static str] = &["480p", "720p", "1080p"]; + pub const SEEDANCE_2_FAST_RESOLUTIONS: &'static [&'static str] = &["480p", "720p"]; + pub const SUPPORTED_SOUND_OPTIONS: &'static [&'static str] = &["on", "off"]; + pub const MAX_REFERENCE_IMAGES: usize = 9; + pub const DEFAULT_VIDEO_ASPECT_RATIO: &'static str = "16:9"; pub const DEFAULT_VIDEO_RESOLUTION: &'static str = "720p"; pub const DEFAULT_VIDEO_DURATION_SECONDS: u32 = 4; + pub const DEFAULT_VIDEO_SOUND: &'static str = "on"; + + pub fn validate_args(&self, args: &GenerateVideoToolArgs) -> Result<(), GenerateVideoError> { + if args.prompt.trim().is_empty() { + return Err(GenerateVideoError::PromptNotProvided); + } + if !Self::SUPPORTED_VIDEO_MODELS.contains(&args.model.as_str()) { + return Err(GenerateVideoError::InvalidModel(args.model.clone())); + } + let aspect_ratio = args + .aspect_ratio + .as_deref() + .unwrap_or(Self::DEFAULT_VIDEO_ASPECT_RATIO); + if !Self::SUPPORTED_ASPECT_RATIOS.contains(&aspect_ratio) { + return Err(GenerateVideoError::InvalidAspectRatio( + aspect_ratio.to_string(), + )); + } + let duration_seconds = args + .duration_seconds + .unwrap_or(Self::DEFAULT_VIDEO_DURATION_SECONDS); + if !Self::SUPPORTED_DURATION_SECONDS.contains(&duration_seconds) { + return Err(GenerateVideoError::InvalidDurationSeconds(duration_seconds)); + } + let resolution = args + .resolution + .as_deref() + .unwrap_or(Self::DEFAULT_VIDEO_RESOLUTION); + if !Self::SUPPORTED_RESOLUTIONS.contains(&resolution) { + return Err(GenerateVideoError::InvalidResolution( + resolution.to_string(), + )); + } + let sound = args.sound.as_deref().unwrap_or(Self::DEFAULT_VIDEO_SOUND); + if !Self::SUPPORTED_SOUND_OPTIONS.contains(&sound) { + return Err(GenerateVideoError::InvalidSound(sound.to_string())); + } + if args.model == Self::SEEDANCE_2_FAST_MODEL + && !Self::SEEDANCE_2_FAST_RESOLUTIONS.contains(&resolution) + { + return Err(GenerateVideoError::UnsupportedModelResolution { + model: args.model.clone(), + resolution: resolution.to_string(), + }); + } + if !args.reference_image_ids.is_empty() + && !Self::REFERENCE_IMAGE_MODELS.contains(&args.model.as_str()) + { + return Err(GenerateVideoError::ReferencesUnsupportedForModel( + args.model.clone(), + )); + } + if args.reference_image_ids.len() > Self::MAX_REFERENCE_IMAGES { + return Err(GenerateVideoError::TooManyReferenceImages( + args.reference_image_ids.len(), + )); + } + for id in &args.reference_image_ids { + if !self.context.contains_image(id) { + return Err(GenerateVideoError::AssetNotFound(id.clone())); + } + } + Ok(()) + } pub fn format_execute_message( &self, @@ -119,3 +271,101 @@ impl GenerateVideoTool { ) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn args() -> GenerateVideoToolArgs { + GenerateVideoToolArgs { + prompt: "镜头向前推进".to_string(), + reference_image_ids: Vec::new(), + aspect_ratio: Some("16:9".to_string()), + duration_seconds: Some(4), + model: GenerateVideoTool::SEEDANCE_2_FAST_MODEL.to_string(), + resolution: Some("720p".to_string()), + sound: Some("on".to_string()), + } + } + + fn tool() -> GenerateVideoTool { + GenerateVideoTool { + context: EditorToolContext::default(), + } + } + + #[test] + fn validates_each_video_option() { + let mut invalid_aspect_ratio = args(); + invalid_aspect_ratio.aspect_ratio = Some("2:1".to_string()); + assert!(matches!( + tool().validate_args(&invalid_aspect_ratio), + Err(GenerateVideoError::InvalidAspectRatio(value)) if value == "2:1" + )); + + let mut invalid_duration = args(); + invalid_duration.duration_seconds = Some(16); + assert!(matches!( + tool().validate_args(&invalid_duration), + Err(GenerateVideoError::InvalidDurationSeconds(16)) + )); + + let mut invalid_resolution = args(); + invalid_resolution.resolution = Some("4K".to_string()); + assert!(matches!( + tool().validate_args(&invalid_resolution), + Err(GenerateVideoError::InvalidResolution(value)) if value == "4K" + )); + + let mut invalid_sound = args(); + invalid_sound.sound = Some("auto".to_string()); + assert!(matches!( + tool().validate_args(&invalid_sound), + Err(GenerateVideoError::InvalidSound(value)) if value == "auto" + )); + } + + #[test] + fn rejects_invalid_model_combinations_and_reference_count() { + let mut invalid = args(); + invalid.resolution = Some("1080p".to_string()); + assert!(matches!( + tool().validate_args(&invalid), + Err(GenerateVideoError::UnsupportedModelResolution { model, resolution }) + if model == GenerateVideoTool::SEEDANCE_2_FAST_MODEL && resolution == "1080p" + )); + + let reference = || ImageId { + id: "reference-image".to_string(), + }; + let mut kling = args(); + kling.model = "kling3.0".to_string(); + kling.reference_image_ids = vec![reference()]; + assert!(matches!( + tool().validate_args(&kling), + Err(GenerateVideoError::ReferencesUnsupportedForModel(model)) if model == "kling3.0" + )); + + let mut too_many = args(); + too_many.reference_image_ids = (0..=GenerateVideoTool::MAX_REFERENCE_IMAGES) + .map(|index| ImageId { + id: format!("reference-{index}"), + }) + .collect(); + assert!(matches!( + tool().validate_args(&too_many), + Err(GenerateVideoError::TooManyReferenceImages(10)) + )); + } + + #[test] + fn schema_exposes_cross_field_constraints_and_current_defaults() { + let schema = tool().parameters(); + assert_eq!( + schema["properties"]["reference_image_ids"]["maxItems"], + json!(9) + ); + assert_eq!(schema["properties"]["sound"]["default"], json!("on")); + assert_eq!(schema["allOf"].as_array().map(Vec::len), Some(2)); + } +} diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/image_generation_options.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/image_generation_options.rs new file mode 100644 index 000000000..8cf725664 --- /dev/null +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/image_generation_options.rs @@ -0,0 +1,167 @@ +use platform_image::{GPT_IMAGE_2_MODEL, NANOBANANA_2_MODEL}; +use serde_json::{Value, json}; +use std::error::Error; +use std::fmt::Display; + +pub const DEFAULT_IMAGE_ASPECT_RATIO: &str = "1:1"; +pub const DEFAULT_IMAGE_SIZE: &str = "1K"; +pub const SUPPORTED_IMAGE_ASPECT_RATIOS: &[&str] = &["1:1", "4:3", "3:2", "2:3", "9:16", "16:9"]; +const NANOBANANA_2_IMAGE_SIZES: &[&str] = &["0.5K", "1K", "2K"]; +const GPT_IMAGE_2_IMAGE_SIZES: &[&str] = &["1K", "2K"]; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ImageGenerationOptionsError { + InvalidModel(String), + InvalidAspectRatio(String), + InvalidImageSize { model: String, image_size: String }, +} + +impl Display for ImageGenerationOptionsError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidModel(model) => write!( + f, + "{model} is not a valid image model; supported models: {NANOBANANA_2_MODEL}, {GPT_IMAGE_2_MODEL}" + ), + Self::InvalidAspectRatio(aspect_ratio) => write!( + f, + "{aspect_ratio} is not a valid aspect ratio; supported values: {}", + SUPPORTED_IMAGE_ASPECT_RATIOS.join(", ") + ), + Self::InvalidImageSize { model, image_size } => write!( + f, + "{image_size} is not a valid image size for {model}; supported values: {}", + supported_image_sizes(model).unwrap_or_default().join(", ") + ), + } + } +} + +impl Error for ImageGenerationOptionsError {} + +pub fn default_image_model() -> String { + NANOBANANA_2_MODEL.to_string() +} + +pub fn default_image_aspect_ratio() -> Option { + Some(DEFAULT_IMAGE_ASPECT_RATIO.to_string()) +} + +pub fn default_image_size() -> Option { + Some(DEFAULT_IMAGE_SIZE.to_string()) +} + +pub fn validate_image_generation_options( + model: &str, + aspect_ratio: Option<&str>, + image_size: Option<&str>, +) -> Result<(), ImageGenerationOptionsError> { + let supported_sizes = supported_image_sizes(model) + .ok_or_else(|| ImageGenerationOptionsError::InvalidModel(model.to_string()))?; + let aspect_ratio = aspect_ratio.unwrap_or(DEFAULT_IMAGE_ASPECT_RATIO); + if !SUPPORTED_IMAGE_ASPECT_RATIOS.contains(&aspect_ratio) { + return Err(ImageGenerationOptionsError::InvalidAspectRatio( + aspect_ratio.to_string(), + )); + } + let image_size = image_size.unwrap_or(DEFAULT_IMAGE_SIZE); + if !supported_sizes.contains(&image_size) { + return Err(ImageGenerationOptionsError::InvalidImageSize { + model: model.to_string(), + image_size: image_size.to_string(), + }); + } + Ok(()) +} + +pub fn image_model_parameter_schema() -> Value { + json!({ + "type": "string", + "enum": [NANOBANANA_2_MODEL, GPT_IMAGE_2_MODEL], + "default": NANOBANANA_2_MODEL, + "description": "生图模型。默认 gemini-3.1-flash-image-preview(user may call it nanobanana2);也可选择 gpt-image-2。" + }) +} + +pub fn image_aspect_ratio_parameter_schema() -> Value { + json!({ + "type": "string", + "enum": SUPPORTED_IMAGE_ASPECT_RATIOS, + "default": DEFAULT_IMAGE_ASPECT_RATIO, + "description": "画面宽高比。可选 1:1、4:3、3:2、2:3、9:16、16:9,默认 1:1。" + }) +} + +pub fn image_size_parameter_schema() -> Value { + json!({ + "type": "string", + "enum": ["0.5K", "1K", "2K"], + "default": DEFAULT_IMAGE_SIZE, + "description": "图片尺寸档位。nanobanana2 支持 0.5K、1K、2K;gpt-image-2 仅支持 1K、2K;默认 1K。" + }) +} + +fn supported_image_sizes(model: &str) -> Option<&'static [&'static str]> { + match model { + NANOBANANA_2_MODEL => Some(NANOBANANA_2_IMAGE_SIZES), + GPT_IMAGE_2_MODEL => Some(GPT_IMAGE_2_IMAGE_SIZES), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validates_frontend_image_model_dimension_matrix() { + for aspect_ratio in SUPPORTED_IMAGE_ASPECT_RATIOS { + for image_size in NANOBANANA_2_IMAGE_SIZES { + assert!( + validate_image_generation_options( + NANOBANANA_2_MODEL, + Some(aspect_ratio), + Some(image_size), + ) + .is_ok() + ); + } + for image_size in GPT_IMAGE_2_IMAGE_SIZES { + assert!( + validate_image_generation_options( + GPT_IMAGE_2_MODEL, + Some(aspect_ratio), + Some(image_size), + ) + .is_ok() + ); + } + } + + assert!(matches!( + validate_image_generation_options(GPT_IMAGE_2_MODEL, Some("1:1"), Some("0.5K")), + Err(ImageGenerationOptionsError::InvalidImageSize { .. }) + )); + assert!(matches!( + validate_image_generation_options(NANOBANANA_2_MODEL, Some("21:9"), Some("1K")), + Err(ImageGenerationOptionsError::InvalidAspectRatio(_)) + )); + } + + #[test] + fn image_option_schemas_expose_frontend_values_and_defaults() { + assert_eq!( + image_aspect_ratio_parameter_schema()["enum"], + json!(["1:1", "4:3", "3:2", "2:3", "9:16", "16:9"]) + ); + assert_eq!( + image_aspect_ratio_parameter_schema()["default"], + DEFAULT_IMAGE_ASPECT_RATIO + ); + assert_eq!( + image_size_parameter_schema()["enum"], + json!(["0.5K", "1K", "2K"]) + ); + assert_eq!(image_size_parameter_schema()["default"], DEFAULT_IMAGE_SIZE); + } +} diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/mod.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/mod.rs index f3ea99cc1..57f355e51 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/mod.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/mod.rs @@ -7,3 +7,170 @@ pub mod generate_image; pub mod generate_sound_effect; pub mod generate_ui_design; pub mod generate_video; +pub mod image_generation_options; + +#[cfg(test)] +mod tests { + use super::context::EditorToolContext; + use super::edit_image::{EditImageTool, EditImageToolArgs}; + use super::generate_background_music::{ + GenerateBackgroundMusicTool, GenerateBackgroundMusicToolArgs, + }; + use super::generate_character::{GenerateCharacterTool, GenerateCharacterToolArgs}; + use super::generate_icon_spritesheet::GenerateIconSpritesheetToolArgs; + use super::generate_image::{GenerateImageError, GenerateImageTool, GenerateImageToolArgs}; + use super::generate_sound_effect::{GenerateSoundEffectTool, GenerateSoundEffectToolArgs}; + use super::generate_ui_design::{GenerateUiDesignTool, GenerateUiDesignToolArgs}; + use super::generate_video::{GenerateVideoTool, GenerateVideoToolArgs}; + use crate::framework::tool::Tool; + use platform_audio::{SUNO_DEFAULT_MODEL, VIDU_AUDIO_MODEL}; + use platform_image::{GPT_IMAGE_2_MODEL, NANOBANANA_2_MODEL}; + use serde_json::json; + + #[test] + fn tool_args_apply_stable_default_models() { + let image: GenerateImageToolArgs = serde_json::from_value(json!({ + "prompt": "生成森林场景" + })) + .expect("image args should deserialize"); + let edit: EditImageToolArgs = serde_json::from_value(json!({ + "object_image_id": "image-1", + "prompt": "改成夜景" + })) + .expect("edit args should deserialize"); + let character: GenerateCharacterToolArgs = serde_json::from_value(json!({ + "prompt": "生成冒险者角色" + })) + .expect("character args should deserialize"); + let ui_design: GenerateUiDesignToolArgs = serde_json::from_value(json!({ + "prompt": "生成游戏主界面" + })) + .expect("legacy UI design args should deserialize"); + let icon: GenerateIconSpritesheetToolArgs = serde_json::from_value(json!({ + "reference_image_id": "image-1", + "icon_descriptions": ["背包"] + })) + .expect("icon args should deserialize"); + let video: GenerateVideoToolArgs = serde_json::from_value(json!({ + "prompt": "镜头向前推进" + })) + .expect("video args should deserialize"); + let sound: GenerateSoundEffectToolArgs = serde_json::from_value(json!({ + "prompt": "按钮点击声" + })) + .expect("sound args should deserialize"); + let music: GenerateBackgroundMusicToolArgs = serde_json::from_value(json!({ + "prompt": "轻快冒险音乐" + })) + .expect("music args should deserialize"); + + assert_eq!(image.model, NANOBANANA_2_MODEL); + assert_eq!(image.aspect_ratio.as_deref(), Some("1:1")); + assert_eq!(image.image_size.as_deref(), Some("1K")); + assert_eq!(edit.model, GPT_IMAGE_2_MODEL); + assert_eq!(character.model, NANOBANANA_2_MODEL); + assert_eq!(ui_design.model, GPT_IMAGE_2_MODEL); + assert_eq!(icon.model, NANOBANANA_2_MODEL); + assert_eq!(video.model, GenerateVideoTool::DEFAULT_VIDEO_MODEL); + assert_eq!(video.sound.as_deref(), Some("on")); + assert_eq!(sound.model, VIDU_AUDIO_MODEL); + assert_eq!(sound.duration, Some(5)); + assert_eq!(music.model, SUNO_DEFAULT_MODEL); + } + + #[test] + fn generate_image_accepts_nanobanana_and_image2_only() { + let tool = GenerateImageTool { + context: EditorToolContext::default(), + }; + let args = |model: &str| GenerateImageToolArgs { + prompt: "生成森林场景".to_string(), + model: model.to_string(), + reference_image_ids: Vec::new(), + aspect_ratio: None, + image_size: None, + }; + + assert!(tool.validate_args(&args(NANOBANANA_2_MODEL)).is_ok()); + assert!(tool.validate_args(&args(GPT_IMAGE_2_MODEL)).is_ok()); + assert!(matches!( + tool.validate_args(&args("unknown-image-model")), + Err(GenerateImageError::InvalidModel(_)) + )); + assert_eq!( + tool.parameters()["properties"]["model"]["enum"], + json!([NANOBANANA_2_MODEL, GPT_IMAGE_2_MODEL]) + ); + } + + #[test] + fn character_and_ui_tools_validate_their_own_reference_images() { + let missing_image = crate::agent::asset::ImageId { + id: "missing-image".to_string(), + }; + let character_args = GenerateCharacterToolArgs { + prompt: "生成角色".to_string(), + model: NANOBANANA_2_MODEL.to_string(), + reference_image_ids: vec![missing_image.clone()], + aspect_ratio: Some("2:3".to_string()), + image_size: Some("1K".to_string()), + }; + + assert!(matches!( + GenerateCharacterTool { + context: EditorToolContext::default(), + } + .validate_args(&character_args), + Err(GenerateImageError::AssetNotFound(image_id)) if image_id == missing_image + )); + let ui_args = GenerateUiDesignToolArgs { + prompt: "生成游戏主界面".to_string(), + model: GPT_IMAGE_2_MODEL.to_string(), + reference_image_ids: vec![missing_image.clone()], + aspect_ratio: Some("16:9".to_string()), + image_size: Some("1K".to_string()), + }; + assert!(matches!( + GenerateUiDesignTool { + context: EditorToolContext::default(), + } + .validate_args(&ui_args), + Err(GenerateImageError::AssetNotFound(image_id)) if image_id == missing_image + )); + } + + #[test] + fn confirmation_schemas_expose_all_finite_choices_as_enums() { + let context = EditorToolContext::default(); + let edit = EditImageTool { + context: context.clone(), + } + .parameters(); + let video = GenerateVideoTool { + context: context.clone(), + } + .parameters(); + let sound = GenerateSoundEffectTool.parameters(); + let music = GenerateBackgroundMusicTool.parameters(); + + assert_eq!( + edit["properties"]["model"]["enum"], + json!([GPT_IMAGE_2_MODEL]) + ); + assert_eq!( + video["properties"]["duration_seconds"]["enum"], + json!([4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]) + ); + assert_eq!( + video["properties"]["resolution"]["enum"], + json!(["480p", "720p", "1080p"]) + ); + assert_eq!( + sound["properties"]["duration"]["enum"], + json!([2, 3, 4, 5, 6, 7, 8, 9, 10]) + ); + assert_eq!(sound["properties"]["duration"]["default"], json!(5)); + assert_eq!(video["properties"]["sound"]["default"], json!("on")); + assert!(music["properties"].get("make_instrumental").is_none()); + } +} diff --git a/server-rs/crates/platform-image/src/lib.rs b/server-rs/crates/platform-image/src/lib.rs index 95495b218..ccef9f192 100644 --- a/server-rs/crates/platform-image/src/lib.rs +++ b/server-rs/crates/platform-image/src/lib.rs @@ -3,8 +3,8 @@ pub mod generated_assets; pub mod vector_engine; pub use vector_engine::{ - DownloadedImage, GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, GeneratedImages, PlatformImageError, - PlatformImageFailureAudit, PlatformImageStatusHint, ReferenceImage, + DownloadedImage, GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, GeneratedImages, NANOBANANA_2_MODEL, + PlatformImageError, PlatformImageFailureAudit, PlatformImageStatusHint, ReferenceImage, VECTOR_ENGINE_GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER, VectorEngineImageSettings, build_vector_engine_image_http_client, build_vector_engine_image_request_body, build_vector_engine_nanobanana_generate_content_request_body, create_vector_engine_image_edit, diff --git a/server-rs/crates/platform-image/src/vector_engine/constants.rs b/server-rs/crates/platform-image/src/vector_engine/constants.rs index afbe17d03..2da30eef3 100644 --- a/server-rs/crates/platform-image/src/vector_engine/constants.rs +++ b/server-rs/crates/platform-image/src/vector_engine/constants.rs @@ -1,4 +1,5 @@ pub const GPT_IMAGE_2_MODEL: &str = "gpt-image-2"; pub const GPT_IMAGE_2_C_MODEL: &str = "gpt-image-2-c"; +pub const NANOBANANA_2_MODEL: &str = "gemini-3.1-flash-image-preview"; pub const VECTOR_ENGINE_GPT_IMAGE_2_MODEL: &str = GPT_IMAGE_2_MODEL; pub const VECTOR_ENGINE_PROVIDER: &str = "vector-engine"; diff --git a/server-rs/crates/platform-image/src/vector_engine/mod.rs b/server-rs/crates/platform-image/src/vector_engine/mod.rs index 99511b9ac..f64cba54a 100644 --- a/server-rs/crates/platform-image/src/vector_engine/mod.rs +++ b/server-rs/crates/platform-image/src/vector_engine/mod.rs @@ -20,7 +20,8 @@ pub use client::{ create_vector_engine_nanobanana_generate_content, }; pub use constants::{ - GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, VECTOR_ENGINE_GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER, + GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, NANOBANANA_2_MODEL, VECTOR_ENGINE_GPT_IMAGE_2_MODEL, + VECTOR_ENGINE_PROVIDER, }; pub use error::{PlatformImageError, PlatformImageStatusHint}; pub use image_source::download_remote_image;