1use async_trait::async_trait;
2use serde_json::{json, Value};
3use std::path::PathBuf;
4use std::sync::Arc;
5
6use crate::internal::channels::channel::deliver_and_store_bot_message;
7use crate::internal::channels::channel_adapter::ChannelRegistry;
8use crate::internal::storage::db::Database;
9use crate::internal::tool_runtime::media_client::{persist_output, MediaClient};
10use crate::internal::tool_runtime::runtime::auth_context_from_input;
11use microclaw_core::llm_types::ToolDefinition;
12
13use super::{schema_object, Tool, ToolResult};
14use crate::config::{Config, ImageGenConfig, MediaConfig};
15
16const ALLOWED_SIZES: &[&str] = &[
17 "256x256",
18 "512x512",
19 "1024x1024",
20 "1024x1536",
21 "1536x1024",
22 "1024x1792",
23 "1792x1024",
24 "auto",
25];
26
27pub struct GenerateImageTool {
35 data_dir: PathBuf,
36 channels: Arc<ChannelRegistry>,
37 db: Arc<Database>,
38 cfg: ImageGenConfig,
39 media: MediaConfig,
40 openai_api_key: Option<String>,
41 openai_base_url: Option<String>,
42 timeout_secs: u64,
43}
44
45impl GenerateImageTool {
46 pub fn new(config: &Config, channels: Arc<ChannelRegistry>, db: Arc<Database>) -> Self {
47 Self {
48 data_dir: PathBuf::from(&config.data_dir),
49 channels,
50 db,
51 cfg: config.media.image_gen.clone(),
52 media: config.media.clone(),
53 openai_api_key: config.openai_api_key.clone(),
54 openai_base_url: config.openai_base_url.clone(),
55 timeout_secs: config.tool_timeout_secs("generate_image", 120),
56 }
57 }
58
59 fn client(&self) -> Result<MediaClient, String> {
60 let key = self
61 .media
62 .resolve_api_key(self.openai_api_key.as_deref())
63 .ok_or_else(|| {
64 "generate_image requires an API key. Set one of media.api_key, \
65 MICROCLAW_OPENAI_API_KEY, OPENAI_API_KEY, or top-level openai_api_key."
66 .to_string()
67 })?;
68 let base = self.media.resolve_base_url(self.openai_base_url.as_deref());
69 MediaClient::new(base, key, self.timeout_secs)
70 }
71}
72
73#[async_trait]
74impl Tool for GenerateImageTool {
75 fn name(&self) -> &str {
76 "generate_image"
77 }
78
79 fn definition(&self) -> ToolDefinition {
80 ToolDefinition {
81 name: self.name().into(),
82 description: "Generate an image from a natural-language prompt via an \
83 OpenAI-compatible images endpoint. The PNG is saved under the bot's \
84 data directory and — when the active channel supports attachments — \
85 also sent back to the user inline. Returns the file path and a \
86 confirmation of delivery. Disabled by default; requires operator \
87 opt-in via `media.image_gen.enabled`."
88 .into(),
89 input_schema: schema_object(
90 json!({
91 "prompt": {
92 "type": "string",
93 "description": "What to draw. Be specific — style, subject, composition."
94 },
95 "size": {
96 "type": "string",
97 "enum": ALLOWED_SIZES,
98 "description": "Optional image size. Defaults to media.image_gen.default_size."
99 },
100 "model": {
101 "type": "string",
102 "description": "Optional model override (e.g. gpt-image-1, dall-e-3)."
103 },
104 "deliver": {
105 "type": "boolean",
106 "description": "Whether to attempt channel delivery (default true). Set false to only return the path."
107 }
108 }),
109 &["prompt"],
110 ),
111 }
112 }
113
114 async fn execute(&self, input: Value) -> ToolResult {
115 if !self.cfg.enabled {
116 return ToolResult::error(
117 "generate_image is disabled. Set media.image_gen.enabled=true to enable.".into(),
118 );
119 }
120 let prompt = match input.get("prompt").and_then(|v| v.as_str()) {
121 Some(p) if !p.trim().is_empty() => p.trim().to_string(),
122 _ => return ToolResult::error("Missing or empty parameter: prompt".into()),
123 };
124 let size = input
125 .get("size")
126 .and_then(|v| v.as_str())
127 .unwrap_or(self.cfg.default_size.as_str())
128 .to_string();
129 if !ALLOWED_SIZES.iter().any(|s| s == &size.as_str()) {
130 return ToolResult::error(format!(
131 "invalid size '{size}'. Allowed: {}",
132 ALLOWED_SIZES.join(", ")
133 ));
134 }
135 let model = input
136 .get("model")
137 .and_then(|v| v.as_str())
138 .map(|s| s.to_string())
139 .unwrap_or_else(|| self.cfg.model.clone());
140 let deliver = input
141 .get("deliver")
142 .and_then(|v| v.as_bool())
143 .unwrap_or(true);
144
145 let client = match self.client() {
146 Ok(c) => c,
147 Err(e) => return ToolResult::error(e),
148 };
149
150 let body = json!({
151 "prompt": prompt,
152 "size": size,
153 "model": model,
154 "n": 1,
155 "response_format": "b64_json",
159 });
160
161 let resp = match client
162 .post_json("images/generations")
163 .json(&body)
164 .send()
165 .await
166 {
167 Ok(r) => r,
168 Err(e) => return ToolResult::error(format!("images API request failed: {e}")),
169 };
170 if !resp.status().is_success() {
171 let status = resp.status();
172 let text = resp.text().await.unwrap_or_default();
173 return ToolResult::error(format!("images API HTTP {status}: {text}"));
174 }
175 let parsed: Value = match resp.json().await {
176 Ok(v) => v,
177 Err(e) => return ToolResult::error(format!("invalid JSON from images API: {e}")),
178 };
179
180 let datum = parsed
183 .get("data")
184 .and_then(|v| v.as_array())
185 .and_then(|arr| arr.first())
186 .cloned();
187 let bytes: Vec<u8> = if let Some(b64) = datum
188 .as_ref()
189 .and_then(|d| d.get("b64_json"))
190 .and_then(|v| v.as_str())
191 {
192 use base64::Engine;
193 match base64::engine::general_purpose::STANDARD.decode(b64) {
194 Ok(b) => b,
195 Err(e) => return ToolResult::error(format!("bad base64 in image response: {e}")),
196 }
197 } else if let Some(url) = datum
198 .as_ref()
199 .and_then(|d| d.get("url"))
200 .and_then(|v| v.as_str())
201 {
202 let dl = match client
204 .client()
205 .get(url)
206 .send()
207 .await
208 .map_err(|e| e.to_string())
209 {
210 Ok(r) => r,
211 Err(e) => return ToolResult::error(format!("failed to download image URL: {e}")),
212 };
213 if !dl.status().is_success() {
214 return ToolResult::error(format!("image download HTTP {}", dl.status()));
215 }
216 match dl.bytes().await {
217 Ok(b) => b.to_vec(),
218 Err(e) => return ToolResult::error(format!("image body read failed: {e}")),
219 }
220 } else {
221 return ToolResult::error(
222 "images API response missing both b64_json and url fields".into(),
223 );
224 };
225
226 let saved = match persist_output(&self.data_dir, "images", "png", &bytes) {
227 Ok(p) => p,
228 Err(e) => return ToolResult::error(format!("failed to save image: {e}")),
229 };
230
231 let mut summary = format!(
232 "generated {} ({} bytes) -> {}",
233 model,
234 bytes.len(),
235 saved.display()
236 );
237
238 if deliver {
239 if let Some(auth) = auth_context_from_input(&input) {
240 match deliver_attachment(
241 &self.channels,
242 self.db.clone(),
243 auth.caller_chat_id,
244 &saved,
245 &prompt,
246 )
247 .await
248 {
249 Ok(msg) => summary.push_str(&format!("; {msg}")),
250 Err(e) => summary.push_str(&format!("; delivery skipped: {e}")),
251 }
252 }
253 }
254
255 ToolResult::success(summary).with_metadata(json!({
256 "path": saved.to_string_lossy(),
257 "model": model,
258 "size": size,
259 }))
260 }
261}
262
263async fn deliver_attachment(
264 channels: &ChannelRegistry,
265 db: Arc<Database>,
266 chat_id: i64,
267 file: &std::path::Path,
268 caption: &str,
269) -> Result<String, String> {
270 let routing = match crate::internal::channels::channel::get_required_chat_routing(
271 channels,
272 db.clone(),
273 chat_id,
274 )
275 .await
276 {
277 Ok(r) => r,
278 Err(e) => return Err(e),
279 };
280 let Some(adapter) = channels.get(&routing.channel_name) else {
281 return Err(format!("no adapter for channel '{}'", routing.channel_name));
282 };
283 if adapter.is_local_only() {
284 return Ok(format!(
285 "channel '{}' is local-only, path retained at: {}",
286 routing.channel_name,
287 file.display()
288 ));
289 }
290 let external_chat_id = crate::internal::storage::db::call_blocking(db.clone(), move |d| {
291 d.get_chat_external_id(chat_id)
292 })
293 .await
294 .map_err(|e| e.to_string())?
295 .unwrap_or_else(|| chat_id.to_string());
296 let caption_short = caption.chars().take(120).collect::<String>();
297 match adapter
298 .send_attachment(&external_chat_id, file, Some(&caption_short))
299 .await
300 {
301 Ok(_) => {
302 let _ = deliver_and_store_bot_message(
304 channels,
305 db,
306 "bot",
307 chat_id,
308 &format!("[image attached: {}]", file.display()),
309 )
310 .await;
311 Ok(format!("delivered via channel '{}'", routing.channel_name))
312 }
313 Err(e) => Err(e),
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320
321 #[test]
322 fn size_list_includes_defaults() {
323 assert!(ALLOWED_SIZES.contains(&"1024x1024"));
324 assert!(ALLOWED_SIZES.contains(&"auto"));
325 }
326}