Skip to main content

robit_agent/tool/
generate_image.rs

1//! `generate_image` tool - generates images from text prompts.
2//!
3//! Uses the configured `default_image_model` provider (Wanxiang/DashScope or
4//! any OpenAI-compatible image API). The model is configured server-side and
5//! is not exposed to the LLM. Generated images are downloaded and saved to
6//! disk; the tool returns a JSON summary with saved paths and source URLs.
7
8use 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
21/// Maximum number of images that can be generated in one call.
22const 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        // Validate and clamp n
106        let n = parsed.n.unwrap_or(1).clamp(1, MAX_N);
107
108        // Resolve save directory (default: {working_dir}/images)
109        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        // Determine base filename (default: image_{YYYYMMDD_HHMMSS})
115        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        // The actual generation + download can take 30-60s (or minutes for
123        // video), so it runs in a background task. We validate args above
124        // (cheap, gives immediate feedback on bad input) and move the heavy
125        // work into `work`, returning a pending placeholder.
126        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            // Download and save each image. All images are attempted even if
176            // some fail, so partial results are preserved.
177            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                // All downloads failed - report as error
229                ToolResult::error(content)
230            } else {
231                ToolResult::success(content)
232            }
233        });
234
235        // Submit the background task and return a placeholder. The Agent tracks
236        // the task id and reinjects the final result when `work` completes.
237        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
254/// Generate a timestamp-based default filename: `image_{YYYYMMDD_HHMMSS}`.
255fn 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
263/// Render a saved path relative to the working directory when possible,
264/// otherwise fall back to the absolute path.
265fn display_path(path: &Path, working_dir: &Path) -> String {
266    if let Ok(rel) = path.strip_prefix(working_dir) {
267        // Use forward slashes for display consistency across platforms.
268        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        // image_ + 8 digits + _ + 6 digits
284        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}