1use super::registry::Tool;
11use super::schema::{ParamType, ToolParameter, ToolSchema};
12use crate::config::{Gateway, ModelConfig};
13use crate::error::Result;
14use base64::Engine;
15use image::{GenericImageView, ImageEncoder};
16use serde_json::{json, Value};
17use std::path::PathBuf;
18use std::sync::atomic::AtomicBool;
19use std::sync::{Arc, Mutex};
20use std::time::Duration;
21
22const CONNECT_TIMEOUT_SECS: u64 = 10;
26
27const MAX_IMAGE_DIMENSION: u32 = 1024;
31
32const JPEG_QUALITY: u8 = 80;
34
35fn prepare_image(bytes: &[u8], ext: &str, max_dim: u32) -> (Vec<u8>, &'static str) {
42 let original_mime = ext_to_mime(ext);
43
44 if ext == "gif" {
46 return (bytes.to_vec(), original_mime);
47 }
48
49 let img = match image::load_from_memory(bytes) {
50 Ok(v) => v,
51 Err(_) => return (bytes.to_vec(), original_mime),
52 };
53
54 let (w, h) = img.dimensions();
55 let short = w.min(h);
56 if short <= max_dim {
57 return (bytes.to_vec(), original_mime);
58 }
59
60 let ratio = max_dim as f64 / short as f64;
62 let new_w = (w as f64 * ratio).round() as u32;
63 let new_h = (h as f64 * ratio).round() as u32;
64 let resized = img.resize_exact(new_w, new_h, image::imageops::FilterType::Lanczos3);
65
66 let is_photo = matches!(ext, "jpg" | "jpeg" | "webp" | "heic" | "heif");
67 if is_photo {
68 let rgb = resized.to_rgb8();
69 let mut buf = Vec::new();
70 let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, JPEG_QUALITY);
71 if encoder
72 .write_image(
73 &rgb,
74 rgb.width(),
75 rgb.height(),
76 image::ExtendedColorType::Rgb8,
77 )
78 .is_ok()
79 {
80 return (buf, "image/jpeg");
81 }
82 } else {
83 let rgba = resized.to_rgba8();
84 let mut buf = Vec::new();
85 let encoder = image::codecs::png::PngEncoder::new(&mut buf);
86 if encoder
87 .write_image(
88 &rgba,
89 rgba.width(),
90 rgba.height(),
91 image::ExtendedColorType::Rgba8,
92 )
93 .is_ok()
94 {
95 return (buf, "image/png");
96 }
97 }
98
99 (bytes.to_vec(), original_mime)
101}
102
103fn ext_to_mime(ext: &str) -> &'static str {
104 match ext {
105 "jpg" | "jpeg" => "image/jpeg",
106 "gif" => "image/gif",
107 "webp" => "image/webp",
108 "bmp" => "image/bmp",
109 _ => "image/png",
110 }
111}
112
113#[derive(Clone)]
115pub struct MultimodalCtx {
116 pub model: Option<ModelConfig>,
117 pub project_path: PathBuf,
118 pub timeout_secs: u64,
119 client: Arc<Mutex<Option<reqwest::Client>>>,
121}
122
123impl MultimodalCtx {
124 pub fn new(model: Option<ModelConfig>, project_path: PathBuf, timeout_secs: u64) -> Self {
125 MultimodalCtx {
126 model,
127 project_path,
128 timeout_secs,
129 client: Arc::new(Mutex::new(None)),
130 }
131 }
132 fn resolve_path(&self, p: &str) -> PathBuf {
133 let path = PathBuf::from(p);
134 if path.is_absolute() {
135 path
136 } else {
137 self.project_path.join(p)
138 }
139 }
140
141 async fn image_data_uri(&self, path: &str) -> Option<String> {
142 let full = self.resolve_path(path);
143 let ext = full
144 .extension()
145 .map(|e| e.to_string_lossy().to_lowercase())
146 .unwrap_or_else(|| "png".into());
147 let bytes = tokio::task::spawn_blocking(move || std::fs::read(&full))
148 .await
149 .ok()?
150 .ok()?;
151 let (img_bytes, mime) = prepare_image(&bytes, &ext, MAX_IMAGE_DIMENSION);
152 let b64 = base64::engine::general_purpose::STANDARD.encode(&img_bytes);
153 Some(format!("data:{mime};base64,{b64}"))
154 }
155
156 async fn vision_chat(&self, prompt: &str, image_paths: &[&str]) -> Result<String> {
157 let model = self.model.as_ref().ok_or_else(|| {
158 crate::error::AacodeError::Config(
159 "no multimodal (vision) model configured — set a vision-capable model in Settings (multimodal model) to use image/vision tools"
160 .into(),
161 )
162 })?;
163 let is_anthropic = model.gateway == Gateway::Anthropic;
164 let timeout_secs = model.request_timeout_secs.unwrap_or(self.timeout_secs);
165
166 let endpoint = if is_anthropic {
167 let base = model.resolved_base_url();
168 if base.contains("minimax") || base.contains("deepseek") || base.contains("moonshot") {
169 format!("{}/anthropic/v1/messages", base.trim_end_matches('/'))
170 } else {
171 format!("{}/v1/messages", base.trim_end_matches('/'))
172 }
173 } else {
174 format!(
175 "{}/chat/completions",
176 model.resolved_base_url().trim_end_matches('/')
177 )
178 };
179
180 let (body, auth_name, auth_val, anthropic_ver) = if is_anthropic {
181 self.build_anthropic_body(model, prompt, image_paths)
182 .await?
183 } else {
184 self.build_openai_body(model, prompt, image_paths).await?
185 };
186
187 async fn do_vision(
188 client: &reqwest::Client,
189 endpoint: &str,
190 body: &Value,
191 auth_name: &str,
192 auth_val: &str,
193 anthropic_ver: &str,
194 is_anthropic: bool,
195 ) -> Result<String> {
196 let mut req = client
197 .post(endpoint)
198 .header(auth_name, auth_val)
199 .header("Content-Type", "application/json");
200 if !anthropic_ver.is_empty() {
201 req = req.header("anthropic-version", anthropic_ver);
202 }
203 let resp = req.json(body).send().await.map_err(|e| {
204 crate::error::AacodeError::Network(format!("vision: {}", error_chain_display(&e)))
205 })?;
206 if !resp.status().is_success() {
207 let code = resp.status().as_u16();
208 let msg = resp.text().await.unwrap_or_default();
209 return Err(crate::error::AacodeError::Api(format!(
210 "vision HTTP {code}: {}",
211 truncate(&msg, 300)
212 )));
213 }
214 let v: Value = resp.json().await.map_err(|e| {
215 crate::error::AacodeError::Network(format!(
216 "vision response: {}",
217 error_chain_display(&e)
218 ))
219 })?;
220 Ok(extract_text(&v, is_anthropic))
221 }
222
223 let client = self.get_or_init_http_client(timeout_secs)?;
224 do_vision(
225 &client,
226 &endpoint,
227 &body,
228 &auth_name,
229 &auth_val,
230 &anthropic_ver,
231 is_anthropic,
232 )
233 .await
234 }
235
236 async fn build_openai_body(
237 &self,
238 model: &ModelConfig,
239 prompt: &str,
240 image_paths: &[&str],
241 ) -> Result<(Value, String, String, String)> {
242 let api_key = model
243 .api_key
244 .clone()
245 .filter(|k| !k.trim().is_empty())
246 .ok_or_else(|| {
247 crate::error::AacodeError::Config("multimodal api key missing".into())
248 })?;
249 let mut parts: Vec<Value> = vec![json!({"type":"text","text":prompt})];
250 for p in image_paths {
251 match self.image_data_uri(p).await {
252 Some(uri) => parts.push(json!({"type":"image_url","image_url":{"url":uri}})),
253 None => {
254 return Err(crate::error::AacodeError::Io(format!(
255 "cannot read image: {p}"
256 )))
257 }
258 }
259 }
260 let body = json!({"model":model.name,"messages":[{"role":"user","content":parts}],"max_tokens":model.max_tokens});
261 Ok((
262 body,
263 "Authorization".into(),
264 format!("Bearer {api_key}"),
265 String::new(),
266 ))
267 }
268
269 async fn build_anthropic_body(
270 &self,
271 model: &ModelConfig,
272 prompt: &str,
273 image_paths: &[&str],
274 ) -> Result<(Value, String, String, String)> {
275 let api_key = model
276 .api_key
277 .clone()
278 .filter(|k| !k.trim().is_empty())
279 .ok_or_else(|| {
280 crate::error::AacodeError::Config("multimodal api key missing".into())
281 })?;
282 let mut blocks: Vec<Value> = Vec::new();
283 for p in image_paths {
284 let uri = self
285 .image_data_uri(p)
286 .await
287 .ok_or_else(|| crate::error::AacodeError::Io(format!("cannot read image: {p}")))?;
288 let (media_type, b64) = parse_data_uri(&uri);
289 blocks.push(json!({"type":"image","source":{"type":"base64","media_type":media_type,"data":b64}}));
290 }
291 blocks.push(json!({"type":"text","text":prompt}));
292 let body = json!({"model":model.name,"max_tokens":model.max_tokens,"messages":[{"role":"user","content":blocks}]});
293 Ok((body, "x-api-key".into(), api_key, "2023-06-01".into()))
294 }
295 fn get_or_init_http_client(&self, timeout_secs: u64) -> Result<reqwest::Client> {
296 let mut guard = self.client.lock().unwrap_or_else(|e| e.into_inner());
297 if let Some(c) = guard.as_ref() {
298 return Ok(c.clone());
299 }
300 let c = reqwest::Client::builder()
301 .connect_timeout(Duration::from_secs(CONNECT_TIMEOUT_SECS))
302 .timeout(Duration::from_secs(timeout_secs))
303 .build()
304 .map_err(|e| crate::error::AacodeError::Network(e.to_string()))?;
305 *guard = Some(c.clone());
306 Ok(c)
307 }
308}
309
310fn error_chain_display(e: &dyn std::error::Error) -> String {
314 let mut s = e.to_string();
315 let mut source = e.source();
316 while let Some(src) = source {
317 s.push_str(": ");
318 s.push_str(&src.to_string());
319 source = src.source();
320 }
321 s
322}
323
324fn parse_data_uri(uri: &str) -> (String, String) {
325 if let Some(rest) = uri.strip_prefix("data:") {
326 let parts: Vec<&str> = rest.splitn(2, ';').collect();
327 let media = parts
328 .first()
329 .map(|s| s.to_string())
330 .unwrap_or_else(|| "image/png".into());
331 let b64 = parts
332 .last()
333 .map(|s| s.strip_prefix("base64,").unwrap_or(s))
334 .unwrap_or(uri)
335 .to_string();
336 (media, b64)
337 } else {
338 ("image/png".into(), uri.to_string())
339 }
340}
341
342fn extract_text(v: &Value, is_anthropic: bool) -> String {
343 if is_anthropic {
344 v["content"]
345 .as_array()
346 .and_then(|arr| {
347 arr.iter()
348 .find_map(|b| b["text"].as_str().map(|s| s.to_string()))
349 })
350 .unwrap_or_default()
351 } else {
352 v["choices"][0]["message"]["content"]
353 .as_str()
354 .unwrap_or("")
355 .to_string()
356 }
357}
358
359fn truncate(s: &str, n: usize) -> String {
360 if s.chars().count() <= n {
361 s.to_string()
362 } else {
363 let h: String = s.chars().take(n).collect();
364 format!("{h}...")
365 }
366}
367
368fn split_paths(s: &str) -> Vec<&str> {
369 s.split(',')
370 .map(|p| p.trim())
371 .filter(|p| !p.is_empty())
372 .collect()
373}
374
375pub struct UnderstandImageTool {
376 pub ctx: MultimodalCtx,
377}
378#[async_trait::async_trait]
379impl Tool for UnderstandImageTool {
380 fn schema(&self) -> ToolSchema {
381 ToolSchema::new("understand_image","Understand image content (one or more, comma-separated). Analyze screenshots, photos, design drafts.",
382 vec![
383 ToolParameter::new("image_path",ParamType::String,true,"Image path(s), comma-separated",&["image","path","file"]),
384 ToolParameter::new("prompt",ParamType::String,false,"What to ask about the image",&["question","query"]),
385 ],
386 )
387 }
388 async fn call(&self, args: &Value, _c: &AtomicBool) -> Result<String> {
389 let paths_s = args
390 .get("image_path")
391 .and_then(|v| v.as_str())
392 .unwrap_or("");
393 let prompt = args
394 .get("prompt")
395 .and_then(|v| v.as_str())
396 .unwrap_or("Describe this image in detail.");
397 let paths = split_paths(paths_s);
398 if paths.is_empty() {
399 return Ok(json!({"success":false,"error":"no image_path"}).to_string());
400 }
401 match self.ctx.vision_chat(prompt, &paths).await {
402 Ok(desc) => Ok(
403 json!({"success":true,"description":desc,"images_count":paths.len()}).to_string(),
404 ),
405 Err(e) => Ok(json!({"success":false,"error":e.to_string()}).to_string()),
406 }
407 }
408}
409
410pub struct UnderstandVideoTool {
411 pub ctx: MultimodalCtx,
412}
413#[async_trait::async_trait]
414impl Tool for UnderstandVideoTool {
415 fn schema(&self) -> ToolSchema {
416 ToolSchema::new(
417 "understand_video",
418 "Video understanding is not supported yet.",
419 vec![
420 ToolParameter::new(
421 "video_path",
422 ParamType::String,
423 true,
424 "Video file path",
425 &["video", "path", "file"],
426 ),
427 ToolParameter::new(
428 "prompt",
429 ParamType::String,
430 false,
431 "What to ask about the video",
432 &["question", "query"],
433 ),
434 ],
435 )
436 }
437 async fn call(&self, args: &Value, _c: &AtomicBool) -> Result<String> {
438 let path = args
439 .get("video_path")
440 .and_then(|v| v.as_str())
441 .unwrap_or("");
442 if path.is_empty() {
443 return Ok(json!({"success":false,"error":"no video_path"}).to_string());
444 }
445 Ok(
446 json!({"success":false,"error":"video understanding is not supported yet."})
447 .to_string(),
448 )
449 }
450}
451
452pub struct UnderstandUiDesignTool {
453 pub ctx: MultimodalCtx,
454}
455#[async_trait::async_trait]
456impl Tool for UnderstandUiDesignTool {
457 fn schema(&self) -> ToolSchema {
458 ToolSchema::new(
459 "understand_ui_design",
460 "Analyze UI design mockups/screenshots and generate frontend code.",
461 vec![
462 ToolParameter::new(
463 "design_path",
464 ParamType::String,
465 true,
466 "Path to the design image file",
467 &["design", "image", "path"],
468 ),
469 ToolParameter::new(
470 "prompt",
471 ParamType::String,
472 false,
473 "What to focus on (e.g., 'generate HTML/CSS')",
474 &["question", "focus"],
475 ),
476 ToolParameter::new(
477 "generate_code",
478 ParamType::Boolean,
479 false,
480 "Whether to generate frontend code",
481 &["code", "html"],
482 ),
483 ],
484 )
485 }
486 async fn call(&self, args: &Value, _c: &AtomicBool) -> Result<String> {
487 let path = args
488 .get("design_path")
489 .and_then(|v| v.as_str())
490 .unwrap_or("");
491 if path.is_empty() {
492 return Ok(json!({"success":false,"error":"no design_path"}).to_string());
493 }
494 let gen_code = args
495 .get("generate_code")
496 .and_then(|v| v.as_bool())
497 .unwrap_or(false);
498 let base = args
499 .get("prompt")
500 .and_then(|v| v.as_str())
501 .unwrap_or("Describe this UI design.");
502 let prompt = if gen_code {
503 format!("{base}. Generate HTML/CSS code for this design.")
504 } else {
505 base.to_string()
506 };
507 match self.ctx.vision_chat(&prompt, &[path]).await {
508 Ok(desc) => Ok(
509 json!({"success":true,"description":desc,"code_generated":gen_code}).to_string(),
510 ),
511 Err(e) => Ok(json!({"success":false,"error":e.to_string()}).to_string()),
512 }
513 }
514}
515
516pub struct ImageConsistencyTool {
517 pub ctx: MultimodalCtx,
518}
519#[async_trait::async_trait]
520impl Tool for ImageConsistencyTool {
521 fn schema(&self) -> ToolSchema {
522 ToolSchema::new(
523 "analyze_image_consistency",
524 "Check image consistency (people or objects) across multiple images.",
525 vec![
526 ToolParameter::new(
527 "image_paths",
528 ParamType::String,
529 true,
530 "Comma-separated image paths (at least 2)",
531 &["images", "paths", "files"],
532 ),
533 ToolParameter::new(
534 "prompt",
535 ParamType::String,
536 false,
537 "What aspect to check for consistency",
538 &["question", "aspect"],
539 ),
540 ],
541 )
542 }
543 async fn call(&self, args: &Value, _c: &AtomicBool) -> Result<String> {
544 let paths_s = args
545 .get("image_paths")
546 .and_then(|v| v.as_str())
547 .unwrap_or("");
548 let paths = split_paths(paths_s);
549 if paths.len() < 2 {
550 return Ok(
551 json!({"success":false,"error":"need at least 2 images for consistency check"})
552 .to_string(),
553 );
554 }
555 let prompt = args
556 .get("prompt")
557 .and_then(|v| v.as_str())
558 .unwrap_or("Compare these images and check for consistency.");
559 match self.ctx.vision_chat(prompt, &paths).await {
560 Ok(desc) => Ok(
561 json!({"success":true,"description":desc,"images_count":paths.len()}).to_string(),
562 ),
563 Err(e) => Ok(json!({"success":false,"error":e.to_string()}).to_string()),
564 }
565 }
566}
567
568#[cfg(test)]
569mod tests {
570 use super::*;
571
572 #[test]
573 fn error_chain_display_single() {
574 let e = std::io::Error::new(std::io::ErrorKind::TimedOut, "operation timed out");
575 let out = error_chain_display(&e);
576 assert!(out.contains("operation timed out"));
577 }
578
579 #[test]
580 fn error_chain_display_source_chain() {
581 let inner = std::io::Error::new(std::io::ErrorKind::TimedOut, "operation timed out");
582 let outer = std::io::Error::new(std::io::ErrorKind::Other, inner);
583 let out = error_chain_display(&outer);
584 assert!(
585 out.contains("operation timed out"),
586 "must include source: {out}"
587 );
588 }
589
590 #[test]
591 fn error_chain_no_source() {
592 let e = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
593 let out = error_chain_display(&e);
594 assert_eq!(out, "file not found");
595 }
596
597 #[test]
598 fn truncate_short_enough() {
599 assert_eq!(truncate("hi", 10), "hi");
600 }
601
602 #[test]
603 fn truncate_too_long() {
604 let s = "0123456789".repeat(5);
605 let out = truncate(&s, 5);
606 assert!(out.len() <= 10);
607 assert!(out.ends_with("..."));
608 }
609
610 #[test]
611 fn split_paths_empty() {
612 assert!(split_paths("").is_empty());
613 }
614
615 #[test]
616 fn split_paths_multiple() {
617 let v = split_paths("a.jpg, b.png , c.gif");
618 assert_eq!(v, vec!["a.jpg", "b.png", "c.gif"]);
619 }
620
621 fn make_test_rgba(w: u32, h: u32) -> Vec<u8> {
624 let mut img = image::RgbaImage::new(w, h);
625 for (x, _y, px) in img.enumerate_pixels_mut() {
627 *px = image::Rgba([(x % 256) as u8, 128, 200, 255]);
628 }
629 let mut buf = Vec::new();
630 let enc = image::codecs::png::PngEncoder::new(&mut buf);
631 enc.write_image(&img, w, h, image::ExtendedColorType::Rgba8)
632 .unwrap();
633 buf
634 }
635
636 #[test]
637 fn prepare_image_resizes_large_photo() {
638 let png = make_test_rgba(2048, 1536);
639 let (out, mime) = prepare_image(&png, "jpg", MAX_IMAGE_DIMENSION);
640 assert_eq!(mime, "image/jpeg");
641 let decoded = image::load_from_memory(&out).unwrap();
643 let (w, h) = decoded.dimensions();
644 assert_eq!(w.min(h), MAX_IMAGE_DIMENSION);
645 }
646
647 #[test]
648 fn prepare_image_resizes_large_screenshot() {
649 let png = make_test_rgba(3000, 4000);
650 let (out, mime) = prepare_image(&png, "png", MAX_IMAGE_DIMENSION);
651 assert_eq!(mime, "image/png");
652 let decoded = image::load_from_memory(&out).unwrap();
653 let (w, h) = decoded.dimensions();
654 assert_eq!(w.min(h), MAX_IMAGE_DIMENSION);
655 }
656
657 #[test]
658 fn prepare_image_small_unchanged() {
659 let png = make_test_rgba(200, 150);
660 let (out, mime) = prepare_image(&png, "png", MAX_IMAGE_DIMENSION);
661 assert_eq!(mime, "image/png");
662 assert_eq!(out, png, "small image must be returned unchanged");
663 }
664
665 #[test]
666 fn prepare_image_gif_untouched() {
667 let png = make_test_rgba(2000, 2000);
668 let (out, mime) = prepare_image(&png, "gif", MAX_IMAGE_DIMENSION);
669 assert_eq!(mime, "image/gif");
670 assert_eq!(out, png, "GIF must be returned unchanged");
671 }
672
673 #[test]
674 fn prepare_image_invalid_bytes_fallback() {
675 let garbage = b"not an image at all";
676 let (out, mime) = prepare_image(garbage, "jpg", MAX_IMAGE_DIMENSION);
677 assert_eq!(mime, "image/jpeg");
678 assert_eq!(
679 out,
680 garbage.as_slice(),
681 "invalid bytes must fall back to original"
682 );
683 }
684
685 #[test]
686 fn prepare_image_webp_to_jpeg() {
687 let png = make_test_rgba(3000, 2000);
688 let (out, mime) = prepare_image(&png, "webp", MAX_IMAGE_DIMENSION);
689 assert_eq!(mime, "image/jpeg");
690 let decoded = image::load_from_memory(&out).unwrap();
691 let (w, h) = decoded.dimensions();
692 assert_eq!(w.min(h), MAX_IMAGE_DIMENSION);
693 }
694}