enforce more tool param check
This commit is contained in:
Generated
+2
@@ -4056,6 +4056,8 @@ name = "platform-editor-agent"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"hmac",
|
||||
"platform-audio",
|
||||
"platform-image",
|
||||
"platform-llm",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -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::<Result<Vec<_>, _>>()?;
|
||||
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::<Result<Vec<_>, _>>()?;
|
||||
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<T: serde::de::DeserializeOwned>(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<Vec<String>, 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,
|
||||
|
||||
@@ -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<String>,
|
||||
image_size: Option<String>,
|
||||
reference_image_ids: Vec<ImageId>,
|
||||
) -> 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,
|
||||
|
||||
@@ -4,6 +4,7 @@ mod display_args;
|
||||
pub mod pricing;
|
||||
mod reconcile;
|
||||
mod resp_to_asset;
|
||||
mod tool_args;
|
||||
mod utils;
|
||||
|
||||
pub use api::{
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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<Value, PromptError> {
|
||||
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<T: DeserializeOwned>(tool_name: &str, args: &Value) -> Result<T, PromptError> {
|
||||
serde_json::from_value(args.clone()).map_err(|error| {
|
||||
PromptError::ToolError(format!("invalid args for tool {tool_name}: {error}"))
|
||||
})
|
||||
}
|
||||
|
||||
fn serialize_args<T: Serialize>(tool_name: &str, args: T) -> Result<Value, PromptError> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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<ImageId>,
|
||||
pub prompt: String,
|
||||
// #[serde(default)]
|
||||
// pub tag: Option<String>,
|
||||
#[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<EditImageError> {
|
||||
pub fn validate_args(&self, args: &EditImageToolArgs) -> Option<EditImageError> {
|
||||
if args.model != GPT_IMAGE_2_MODEL {
|
||||
return Some(EditImageError::InvalidModel(args.model.clone()));
|
||||
}
|
||||
if args.prompt.trim().is_empty() {
|
||||
return Some(EditImageError::PromptNotProvided);
|
||||
}
|
||||
|
||||
+34
-11
@@ -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<Output = Result<Self::Output, Self::Error>> + 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,
|
||||
|
||||
@@ -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<ImageId>,
|
||||
#[serde(default = "default_image_aspect_ratio")]
|
||||
pub aspect_ratio: Option<String>,
|
||||
#[serde(default = "default_image_size")]
|
||||
pub image_size: Option<String>,
|
||||
}
|
||||
|
||||
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<Output = Result<Self::Output, Self::Error>> + 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;
|
||||
|
||||
+137
-11
@@ -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<ImageGenerationOptionsError> 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<ImageId>,
|
||||
pub icon_descriptions: Vec<String>,
|
||||
#[serde(default)]
|
||||
#[serde(default = "default_image_aspect_ratio")]
|
||||
pub aspect_ratio: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(default = "default_image_size")]
|
||||
pub image_size: Option<String>,
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
) -> (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::<Vec<_>>();
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ImageGenerationOptionsError> 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<ImageId>,
|
||||
#[serde(default)]
|
||||
// TODO restrict to a set of possible values
|
||||
#[serde(default = "default_image_aspect_ratio")]
|
||||
pub aspect_ratio: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(default = "default_image_size")]
|
||||
pub image_size: Option<String>,
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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::<Vec<_>>()
|
||||
.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<u8>,
|
||||
}
|
||||
|
||||
fn default_sound_effect_model() -> String {
|
||||
VIDU_AUDIO_MODEL.to_string()
|
||||
}
|
||||
|
||||
fn default_sound_effect_duration() -> Option<u8> {
|
||||
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<Output = Result<Self::Output, Self::Error>> + 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<u8>) -> 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
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ImageId>,
|
||||
#[serde(default = "default_image_aspect_ratio")]
|
||||
pub aspect_ratio: Option<String>,
|
||||
#[serde(default = "default_image_size")]
|
||||
pub image_size: Option<String>,
|
||||
}
|
||||
|
||||
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<Output = Result<Self::Output, Self::Error>> + 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ImageId>,
|
||||
#[serde(default)]
|
||||
#[serde(default = "default_video_aspect_ratio")]
|
||||
pub aspect_ratio: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(default = "default_video_duration_seconds")]
|
||||
pub duration_seconds: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub model: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(default = "default_video_model")]
|
||||
pub model: String,
|
||||
#[serde(default = "default_video_resolution")]
|
||||
pub resolution: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(default = "default_video_sound")]
|
||||
pub sound: Option<String>,
|
||||
}
|
||||
|
||||
fn default_video_model() -> String {
|
||||
GenerateVideoTool::DEFAULT_VIDEO_MODEL.to_string()
|
||||
}
|
||||
|
||||
fn default_video_aspect_ratio() -> Option<String> {
|
||||
Some(GenerateVideoTool::DEFAULT_VIDEO_ASPECT_RATIO.to_string())
|
||||
}
|
||||
|
||||
fn default_video_duration_seconds() -> Option<u32> {
|
||||
Some(GenerateVideoTool::DEFAULT_VIDEO_DURATION_SECONDS)
|
||||
}
|
||||
|
||||
fn default_video_resolution() -> Option<String> {
|
||||
Some(GenerateVideoTool::DEFAULT_VIDEO_RESOLUTION.to_string())
|
||||
}
|
||||
|
||||
fn default_video_sound() -> Option<String> {
|
||||
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<Output = Result<Self::Output, Self::Error>> + 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> {
|
||||
Some(DEFAULT_IMAGE_ASPECT_RATIO.to_string())
|
||||
}
|
||||
|
||||
pub fn default_image_size() -> Option<String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user