impl image gen tool

This commit is contained in:
2026-07-10 16:13:40 +08:00
parent e1ae49f114
commit 10d7f09cbf
9 changed files with 365 additions and 9 deletions
@@ -32,6 +32,7 @@ use crate::auth::AuthenticatedAccessToken;
use crate::editor_agent::agent::LlmChatAgentBuilder;
use crate::editor_agent::editor_tools::common::EditorToolContext;
use crate::editor_agent::editor_tools::edit_image::{EditImageTool, EditImageToolArgs};
use crate::editor_agent::editor_tools::generate_image::{GenerateImageTool, GenerateImageToolArgs};
use crate::editor_agent::utils::{
EditorAgentMessageResponse, ImageId, ImageMetadata, IntoImageId,
build_editor_agent_canvas_completion, conversation_detail_from_record,
@@ -146,7 +147,10 @@ pub async fn editor_agent_message(
let mut agent = LlmChatAgentBuilder::new()
.with_client(llm_client)
.tool(EditImageTool { context: tool_context })
.tool(EditImageTool {
context: tool_context.clone(),
})
.tool(GenerateImageTool { context: tool_context })
.max_turns(3)
.memory(memory)
.build();
@@ -500,8 +504,76 @@ pub async fn confirm_editor_agent_tool_call(
let tool_name = tc.tool_name.clone();
let tool_args = tc.args.clone();
// TODO dont use match
// Each confirmed tool reuses its existing editor-generation entry point.
match tool_name.as_str() {
GenerateImageTool::NAME => {
let args: GenerateImageToolArgs = serde_json::from_value(tc.args.clone())
.map_err(|e| editor_agent_bad_request(format!("invalid tool call args: {e}")))?;
let project = state
.spacetime_client()
.get_editor_project(EditorProjectGetRecordInput {
project_id: conversation.project_id.clone(),
owner_user_id: conversation.owner_user_id.clone(),
})
.await
.map_err(|e| {
AppError::from_status(axum::http::StatusCode::NOT_FOUND)
.with_details(json!({ "message": format!("project not found: {e}") }))
})?;
let caller = EditorGenerationCaller {
owner_user_id: conversation.owner_user_id.clone(),
audit_subject_user_id: Some(conversation.owner_user_id.clone()),
audit_project_id: Some(conversation.project_id.clone()),
};
let title = args.prompt.clone();
let canvas_completion =
build_editor_agent_canvas_completion(&project, GenerateImageTool::NAME, &title);
let tool_context = build_tool_context(&document);
let generate_tool = GenerateImageTool { context: tool_context };
let result = generate_tool
.execute(
&state,
&request_context,
caller,
args.clone(),
conversation.project_id.clone(),
Some(json!({
"source": "editor-agent",
"conversationId": conversation.conversation_id,
"toolCallMessageId": message_id,
"fields": [{ "title": "用户指令", "value": args.prompt }],
})),
Some(title),
canvas_completion,
)
.await?;
let generated_images = vec![EditorAgentGeneratedImage {
resource_id: result
.resource
.as_ref()
.and_then(|resource| resource.get("resourceId"))
.and_then(Value::as_str)
.map(ToOwned::to_owned),
object_key: result.object_key.clone(),
asset_object_id: result.asset_object_id.clone(),
image_src: result.image_src.clone(),
thumbnail_src: None,
width: Some(result.width),
height: Some(result.height),
}];
let formatted_text = generate_tool.format_execute_message(&args, &result);
let msg = &mut document.messages[message_id];
msg.text = formatted_text;
if let Some(tool_call) = &mut msg.tool_call {
tool_call.status = EditorAgentToolCallStatus::Completed;
tool_call.images = generated_images;
}
write_messages_document(&state, &conversation, &document).await?;
Ok(json_success_body(
Some(&request_context),
&document.messages[message_id],
))
}
EditImageTool::NAME => {
let args: EditImageToolArgs = serde_json::from_value(tool_args)
.map_err(|e| editor_agent_bad_request(format!("invalid tool call args: {e}")))?;
@@ -0,0 +1,290 @@
use crate::editor_agent::editor_tools::common::EditorToolContext;
use crate::editor_agent::utils::ImageId;
use crate::editor_project::{
EditorGenerationCaller, EditorImageGenerationRequest, generate_editor_image_for_owner,
};
use crate::http_error::AppError;
use crate::request_context::RequestContext;
use crate::state::AppState;
use axum::http::StatusCode;
use module_editor_agent::agent::tool::{Tool, ToolFailure, ToolFailureKind};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use shared_contracts::api::ApiSuccessEnvelope;
use shared_contracts::assets::EditorCanvasGenerationCompletionPayload;
use std::error::Error;
use std::fmt::Display;
pub struct GenerateImageTool {
pub context: EditorToolContext,
}
#[derive(Debug, Clone)]
pub enum GenerateImageError {
PromptNotProvided,
AssetNotFound(ImageId),
}
impl Display for GenerateImageError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::PromptNotProvided => write!(f, "prompt not provided"),
Self::AssetNotFound(image_id) => write!(f, "asset {image_id} not found in context"),
}
}
}
impl Error for GenerateImageError {}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GenerateImageToolArgs {
pub prompt: String,
#[serde(default)]
pub reference_image_ids: Vec<ImageId>,
#[serde(default)]
// TODO restrict to a set of possible values
pub aspect_ratio: Option<String>,
#[serde(default)]
pub image_size: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GenerateImageToolOutput {
pub message: String,
}
impl Tool for GenerateImageTool {
const NAME: &'static str = "generate-image";
type Error = GenerateImageError;
type Args = GenerateImageToolArgs;
type Output = GenerateImageToolOutput;
fn description(&self) -> String {
"根据文字描述生成一张新图片;可选参考图仅用于借鉴画风、构图或元素,不会修改参考图本身。".to_string()
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"prompt": {
"type": "string",
"description": "完整的生图提示词,包含主体、场景、风格、构图和背景。"
},
"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。"
}
},
"required": ["prompt"],
"additionalProperties": false
})
}
fn call(
&self,
args: Self::Args,
) -> impl Future<Output = Result<Self::Output, Self::Error>> + Send {
async move {
self.validate_args(&args)?;
Ok(GenerateImageToolOutput {
message: "this tool call is pending user confirmation. if all is pending, just end this turn".to_string(),
})
}
}
fn classify_error(&self, error: &Self::Error) -> ToolFailure {
match error {
GenerateImageError::AssetNotFound(_) => {
ToolFailure::new(ToolFailureKind::NotFound, error.to_string())
}
GenerateImageError::PromptNotProvided => ToolFailure::invalid_args(error.to_string()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorImageGenerationResult {
pub image_src: String,
pub object_key: Option<String>,
pub asset_object_id: Option<String>,
pub width: u32,
pub height: u32,
pub source_type: String,
pub prompt: String,
pub actual_prompt: Option<String>,
pub model: String,
pub provider: String,
pub task_id: String,
pub resource: Option<Value>,
pub asset: Option<Value>,
pub project: Option<Value>,
}
impl GenerateImageTool {
fn validate_args(&self, args: &GenerateImageToolArgs) -> Result<(), GenerateImageError> {
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,
result: &EditorImageGenerationResult,
) -> String {
let args = serde_json::to_string(args).unwrap_or_default();
let image_id = result
.object_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.trim_start_matches('/').to_string())
.unwrap_or_else(|| result.image_src.clone());
format!(
"[tool_call:{}] args: {args} output: generated result saved as image: {image_id}",
Self::NAME,
)
}
pub async fn execute(
&self,
state: &AppState,
request_context: &RequestContext,
caller: EditorGenerationCaller,
args: GenerateImageToolArgs,
project_id: String,
generation_inputs: Option<Value>,
asset_label: Option<String>,
canvas_completion: EditorCanvasGenerationCompletionPayload,
) -> Result<EditorImageGenerationResult, AppError> {
self.validate_args(&args).map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-agent",
"message": error.to_string(),
}))
})?;
let result = generate_editor_image_for_owner(
state,
request_context,
caller,
EditorImageGenerationRequest {
prompt: args.prompt,
size: None,
kind: None,
model: None,
screen_color: None,
seg_model: None,
aspect_ratio: args.aspect_ratio,
image_size: args.image_size,
reference_image_srcs: Some(
args.reference_image_ids
.into_iter()
.map(|image_id| image_id.id)
.collect(),
),
project_id: Some(project_id),
asset_kind: Some("editor_agent_generated_image".to_string()),
generation_inputs,
asset_folder_id: Some("project".to_string()),
asset_label,
source_resource_id: None,
canvas_completion: Some(canvas_completion),
},
)
.await?
.0;
let data = if request_context.wants_envelope() {
serde_json::from_value::<ApiSuccessEnvelope<Value>>(result)
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"message": format!("failed to parse success envelope: {error}"),
}))
})?
.data
} else {
result
};
serde_json::from_value::<EditorImageGenerationResult>(data).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"message": format!("failed to deserialize image generation result: {error}"),
}))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn tool() -> GenerateImageTool {
GenerateImageTool {
context: EditorToolContext {
images: HashMap::from([(
ImageId { id: "reference.png".to_string() },
crate::editor_agent::utils::ImageMetadata { tag: String::new() },
)]),
},
}
}
#[tokio::test]
async fn proposal_accepts_prompt_and_known_references() {
let output = tool()
.call(GenerateImageToolArgs {
prompt: "雨夜的霓虹街道".to_string(),
reference_image_ids: vec![ImageId { id: "reference.png".to_string() }],
aspect_ratio: Some("16:9".to_string()),
image_size: Some("2K".to_string()),
})
.await
.expect("proposal should be valid");
assert!(output.message.contains("pending user confirmation"));
}
#[tokio::test]
async fn proposal_rejects_missing_prompt_and_unknown_reference() {
let prompt_error = tool()
.call(GenerateImageToolArgs {
prompt: " ".to_string(),
reference_image_ids: Vec::new(),
aspect_ratio: None,
image_size: None,
})
.await
.expect_err("blank prompt must fail");
assert!(matches!(prompt_error, GenerateImageError::PromptNotProvided));
let reference_error = tool()
.call(GenerateImageToolArgs {
prompt: "一棵树".to_string(),
reference_image_ids: vec![ImageId { id: "missing.png".to_string() }],
aspect_ratio: None,
image_size: None,
})
.await
.expect_err("unknown reference must fail");
assert!(matches!(reference_error, GenerateImageError::AssetNotFound(_)));
}
}
@@ -1,9 +1,3 @@
pub mod common;
pub mod edit_image;
mod generate_ui_design;
mod generate_character;
mod generate_image;
mod generate_icon_spritesheet;
mod generate_sound_effect;
mod generate_background_music;
mod generate_video;
pub mod generate_image;