1use async_trait::async_trait;
9use serde::Deserialize;
10use serde_json::{json, Value};
11use std::path::Path;
12use time::macros::format_description;
13use time::OffsetDateTime;
14
15use super::async_runner::AsyncTaskWork;
16use super::{resolve_path, Tool, ToolContext, ToolResult};
17use crate::error::Result;
18use crate::image_gen::{ImageGenClient, ImageGenRequest};
19use crate::media::download_media;
20
21const MAX_N: u32 = 4;
23
24#[derive(Debug, Deserialize)]
25struct GenerateImageArgs {
26 prompt: String,
27 #[serde(default)]
28 filename: Option<String>,
29 #[serde(default)]
30 output_path: Option<String>,
31 #[serde(default)]
32 n: Option<u32>,
33}
34
35pub struct GenerateImageTool {
36 client: ImageGenClient,
37}
38
39impl GenerateImageTool {
40 pub fn new(client: ImageGenClient) -> Self {
41 Self { client }
42 }
43}
44
45#[async_trait]
46impl Tool for GenerateImageTool {
47 fn name(&self) -> &str {
48 "generate_image"
49 }
50
51 fn description(&self) -> &str {
52 "Generate images from a text prompt using AI image generation. \
53 The model is configured server-side and cannot be changed by the caller. \
54 Generated images are saved as PNG files and the paths are returned."
55 }
56
57 fn parameters_schema(&self) -> Value {
58 json!({
59 "type": "object",
60 "properties": {
61 "prompt": {
62 "type": "string",
63 "description": "Text description of the image to generate. Supports Chinese and English."
64 },
65 "filename": {
66 "type": "string",
67 "description": "Base filename (without extension) for saved images. \
68 If omitted, a timestamp-based name is generated. \
69 For multiple images, a '-1', '-2' suffix is appended."
70 },
71 "output_path": {
72 "type": "string",
73 "description": "Directory to save images (relative or absolute). \
74 Defaults to {working_dir}/images."
75 },
76 "n": {
77 "type": "integer",
78 "description": "Number of images to generate (1-4). Defaults to 1.",
79 "minimum": 1,
80 "maximum": MAX_N
81 }
82 },
83 "required": ["prompt"]
84 })
85 }
86
87 fn requires_confirmation(&self) -> bool {
88 true
89 }
90
91 fn supports_async(&self) -> bool {
92 true
93 }
94
95 async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<ToolResult> {
96 let parsed: GenerateImageArgs = match serde_json::from_value(args) {
97 Ok(a) => a,
98 Err(e) => return Ok(ToolResult::error(format!("Argument parsing failed: {}", e))),
99 };
100
101 if parsed.prompt.trim().is_empty() {
102 return Ok(ToolResult::error("prompt cannot be empty".to_string()));
103 }
104
105 let n = parsed.n.unwrap_or(1).clamp(1, MAX_N);
107
108 let save_dir = match parsed.output_path.as_deref() {
110 Some(p) => resolve_path(p, &ctx.working_dir),
111 None => ctx.working_dir.join("images"),
112 };
113
114 let base_filename = parsed
116 .filename
117 .as_deref()
118 .filter(|s| !s.trim().is_empty())
119 .map(|s| s.to_string())
120 .unwrap_or_else(default_filename);
121
122 let client = self.client.clone();
127 let working_dir = ctx.working_dir.clone();
128 let prompt = parsed.prompt.clone();
129
130 let work: AsyncTaskWork = Box::pin(async move {
131 let req = ImageGenRequest {
132 prompt,
133 n: Some(n),
134 extra_params: Value::Null,
135 };
136
137 tracing::info!("[generate_image] requesting {} image(s) (background)", n);
138
139 let images = match client.generate(&req).await {
140 Ok(imgs) => imgs,
141 Err(e) => {
142 tracing::error!(
143 "[generate_image] image generation failed: {}. \
144 The error will be reported to the Agent as a task result.",
145 e
146 );
147 let info = e.to_error_info();
148 let err_json = json!({
149 "status": "failed",
150 "error": {
151 "kind": info.kind,
152 "code": info.code,
153 "message": info.message,
154 "retryable": info.retryable,
155 }
156 });
157 return ToolResult::error(
158 serde_json::to_string_pretty(&err_json)
159 .unwrap_or_else(|_| err_json.to_string()),
160 );
161 }
162 };
163
164 if images.is_empty() {
165 let err_json = json!({
166 "status": "failed",
167 "error": "Provider returned no images"
168 });
169 return ToolResult::error(
170 serde_json::to_string_pretty(&err_json)
171 .unwrap_or_else(|_| err_json.to_string()),
172 );
173 }
174
175 let multi = images.len() > 1;
178 let mut results: Vec<Value> = Vec::with_capacity(images.len());
179 let mut success_count: usize = 0;
180
181 for (i, img) in images.iter().enumerate() {
182 let index = i + 1;
183 let filename = if multi {
184 format!("{}-{}.png", base_filename, index)
185 } else {
186 format!("{}.png", base_filename)
187 };
188
189 let saved_path = download_media(&img.url, Some(&filename), &save_dir).await;
190 match saved_path {
191 Ok(path) => {
192 success_count += 1;
193 results.push(json!({
194 "index": index,
195 "file": display_path(&path, &working_dir),
196 "size": img.size.clone().unwrap_or_else(|| "unknown".to_string()),
197 "url": img.url,
198 }));
199 }
200 Err(e) => {
201 results.push(json!({
202 "index": index,
203 "file": null,
204 "size": img.size.clone().unwrap_or_else(|| "unknown".to_string()),
205 "url": img.url,
206 "error": format!("Download failed: {}", e),
207 }));
208 }
209 }
210 }
211
212 let status = if success_count == images.len() {
213 "success"
214 } else {
215 "partial"
216 };
217
218 let response = json!({
219 "status": status,
220 "generated_count": success_count,
221 "images": results,
222 });
223
224 let content = serde_json::to_string_pretty(&response)
225 .unwrap_or_else(|_| response.to_string());
226
227 if success_count == 0 {
228 ToolResult::error(content)
230 } else {
231 ToolResult::success(content)
232 }
233 });
234
235 let task_id = ctx.async_runner.submit(
238 ctx.tool_call_id.clone(),
239 ctx.session_id.clone(),
240 self.name().to_string(),
241 work,
242 ctx.cancel_token.clone(),
243 );
244
245 let placeholder = format!(
246 "图片生成中(异步任务 task_id={})。预计耗时 30-60 秒,完成后会自动通知结果。\
247 你可以继续其他工作,完成后我会收到通知并告知你。",
248 task_id
249 );
250 Ok(ToolResult::pending(placeholder, task_id))
251 }
252}
253
254fn default_filename() -> String {
256 const FMT: &[time::format_description::FormatItem<'_>] =
257 format_description!("image_[year][month][day]_[hour][minute][second]");
258 OffsetDateTime::now_utc()
259 .format(FMT)
260 .unwrap_or_else(|_| "image".to_string())
261}
262
263fn display_path(path: &Path, working_dir: &Path) -> String {
266 if let Ok(rel) = path.strip_prefix(working_dir) {
267 rel.to_string_lossy().replace('\\', "/")
269 } else {
270 path.to_string_lossy().replace('\\', "/")
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277 use std::path::PathBuf;
278
279 #[test]
280 fn test_default_filename_format() {
281 let name = default_filename();
282 assert!(name.starts_with("image_"), "filename was: {name}");
283 assert!(name.len() >= "image_YYYYMMDD_HHMMSS".len(), "filename was: {name}");
285 }
286
287 #[test]
288 fn test_display_path_relative() {
289 let working_dir = PathBuf::from("/home/user/project");
290 let saved = PathBuf::from("/home/user/project/images/cat.png");
291 assert_eq!(display_path(&saved, &working_dir), "images/cat.png");
292 }
293
294 #[test]
295 fn test_display_path_outside_working_dir() {
296 let working_dir = PathBuf::from("/home/user/project");
297 let saved = PathBuf::from("/tmp/images/cat.png");
298 assert_eq!(display_path(&saved, &working_dir), "/tmp/images/cat.png");
299 }
300}