Skip to main content

aacode_rs/tools/
multimodal.rs

1// Copyright (c) 2026 xiefujin <490021684@qq.com>
2// Licensed under GPL-3.0, see LICENSE file for full license terms.
3
4//! Multimodal tools — understand_image / understand_video / understand_ui_design
5//! / analyze_image_consistency.
6//!
7//! Supports both OpenAI and Anthropic vision formats based on the configured
8//! model gateway. Uses `reqwest` async HTTP for vision API calls.
9
10use 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
22/// Connect timeout for HTTP client (seconds). Separate from the total request
23/// timeout so DNS/TCP hang is detected quickly rather than waiting for the
24/// full request deadline.
25const CONNECT_TIMEOUT_SECS: u64 = 10;
26
27/// Maximum short-side pixel dimension before an image is downscaled.
28/// Most vision models (GPT-4V, Claude, Kimi) recommend ≤ 1024 px for
29/// cost/performance; sticking to this keeps uploads fast on mobile.
30const MAX_IMAGE_DIMENSION: u32 = 1024;
31
32/// JPEG quality for resized photos (trade-off between size and visual fidelity).
33const JPEG_QUALITY: u8 = 80;
34
35/// Downsize and re-encode an image if it exceeds `max_dim` on its short side.
36/// Photos (JPEG / WebP / HEIC) are re-encoded as JPEG; screenshots / UI mocks
37/// (PNG / BMP) stay as PNG to preserve text sharpness.
38///
39/// Never panics — on any failure the original bytes and MIME are returned
40/// unchanged so downstream processing is not affected.
41fn prepare_image(bytes: &[u8], ext: &str, max_dim: u32) -> (Vec<u8>, &'static str) {
42    let original_mime = ext_to_mime(ext);
43
44    // GIF — don't touch; frames would be lost, and GIFs are typically small.
45    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    // Scale so the short side equals max_dim, preserving aspect ratio.
61    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    // Encoding failed — return originals.
100    (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/// Shared config for the multimodal tools.
114#[derive(Clone)]
115pub struct MultimodalCtx {
116    pub model: Option<ModelConfig>,
117    pub project_path: PathBuf,
118    pub timeout_secs: u64,
119    /// Lazily-initialised shared HTTP client.
120    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
310/// Walk an error's source chain for full diagnostics (e.g. reqwest errors
311/// whose Display only says "error sending request for url" while the real
312/// cause — timeout / DNS / TLS — is buried in the source).
313fn 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    // ── prepare_image tests ──────────────────────────────────────────
622
623    fn make_test_rgba(w: u32, h: u32) -> Vec<u8> {
624        let mut img = image::RgbaImage::new(w, h);
625        // fill with a simple gradient so it's not blank
626        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        // Verify short side is ≤ max_dim after resize
642        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}