mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-21 22:18:13 +02:00
Lot 45 : outils d'image (see_image, web_screenshot) tenus par la façade
La façade exécute les deux outils qui montrent une image au modèle (src/imagetools.rs, port de chat_vision_tool.go, chat_screenshot.go et prepareImageForModel) : préparation de l'image comme les pièces jointes (orientation EXIF, grand côté sous 1568 px, mêmes pixels), sonde du moteur (/props authentifié, gardée 10 s ; preset externe : sa case), see_image aux textes du Go, web_screenshot par Playwright (adresses locales quand internet est coupé, mêmes arguments, erreur résumée, capture rangée dans le dossier de la discussion, ménage à 20 captures et 40 Mo, relayée au modèle s'il voit les images). Le Go garde les siens pour ses sous-agents et tâches IA. Parité : journal tiré du Go avec ses fichiers, un faux moteur et un faux Playwright (tests/parity_images/images.json), rejoué par tests/imagetools.rs ; un tour de la façade sans passer par le Go (tests/lot6.rs) ; tests unitaires. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
11 files changed
+2090
No files matched your search
@@ -1325,6 +1325,7 @@ impl ChatShared {
|
||||
web: Default::default(),
|
||||
jobs: crate::tools::jobs::global(),
|
||||
browser: crate::browser::global(),
|
||||
vision: Default::default(),
|
||||
go_version: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -358,6 +358,8 @@ pub struct ChatShared {
|
||||
pub jobs: Arc<crate::tools::jobs::BgJobs>,
|
||||
/// Le navigateur piloté (lot 44).
|
||||
pub browser: Arc<crate::browser::Browser>,
|
||||
/// La sonde « le moteur voit-il les images » (lot 45).
|
||||
pub vision: Arc<crate::imagetools::VisionProbe>,
|
||||
/// La version du Go, annoncée aux serveurs MCP à une reconnexion ; lue
|
||||
/// une fois.
|
||||
pub go_version: tokio::sync::OnceCell<String>,
|
||||
@@ -716,6 +718,74 @@ impl TurnHost for GoTurnHost {
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
// Outils d'image (lot 45) : see_image dans le dossier de la
|
||||
// discussion, web_screenshot par Playwright ; l'image relayée au
|
||||
// modèle s'il la voit.
|
||||
if name == "see_image" || name == "web_screenshot" {
|
||||
let cfg = self.shared.store.config().await;
|
||||
let vision = crate::system::vision_enabled(&cfg);
|
||||
if name == "see_image" {
|
||||
let st = crate::tools::GoToolState {
|
||||
store: self.shared.store.clone(),
|
||||
caps: self.caps.clone(),
|
||||
};
|
||||
match crate::tools::ToolState::context(&st).await {
|
||||
Ok(ctx) => {
|
||||
let sees = self.shared.vision.sees(&self.shared.store, &cfg);
|
||||
let (result, img) = crate::imagetools::see_image(
|
||||
label,
|
||||
&ctx.cwd,
|
||||
&ctx.target,
|
||||
vision,
|
||||
sees,
|
||||
)
|
||||
.await;
|
||||
return ToolOutcome {
|
||||
result,
|
||||
relay: img.map(|i| see_image_message(label, i)),
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[outils] contexte indisponible, see_image par le Go : {e}")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let home = self.shared.images.home();
|
||||
let ws = tokio::task::spawn_blocking(move || {
|
||||
let env = std::env::var(crate::paths::WORKSPACE_ENV).ok();
|
||||
crate::paths::agent_workspace(&home, env.as_deref())
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let conv_dir = crate::convfiles::conv_dir(&ws, &self.compact.main_conv);
|
||||
let internet = self.caps.get("Internet").and_then(Value::as_bool) == Some(true);
|
||||
let pw = crate::paths::look_path("playwright").unwrap_or_default();
|
||||
let shot = crate::imagetools::web_screenshot(
|
||||
args,
|
||||
&pw,
|
||||
Path::new(&conv_dir),
|
||||
internet,
|
||||
&crate::imagetools::local_stamp(),
|
||||
)
|
||||
.await;
|
||||
let relay = if !shot.rel.is_empty()
|
||||
&& vision
|
||||
&& self.shared.vision.sees(&self.shared.store, &cfg).await
|
||||
{
|
||||
crate::imagetools::screenshot_message(&shot.abs)
|
||||
.and_then(|m| serde_json::from_value::<Message>(m).ok())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
return ToolOutcome {
|
||||
result: shot.result,
|
||||
image: shot.rel,
|
||||
relay,
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
}
|
||||
// Navigateur piloté (lot 44) : la session est ici ; aperçu de la page
|
||||
// après une action qui la change (jamais après une erreur), capture
|
||||
// relayée au modèle.
|
||||
|
||||
@@ -145,6 +145,17 @@ fn safe_conv_id(id: &str) -> String {
|
||||
id.to_string()
|
||||
}
|
||||
|
||||
/// `convDirFor` : le dossier d'une discussion sous la racine de travail (la
|
||||
/// racine elle-même pour un identifiant douteux).
|
||||
pub fn conv_dir(root: &str, id: &str) -> String {
|
||||
let id = safe_conv_id(id);
|
||||
if id.is_empty() {
|
||||
root.to_string()
|
||||
} else {
|
||||
go_join(&[root, CONV_FILES_ROOT, &id])
|
||||
}
|
||||
}
|
||||
|
||||
/// `exifOrientation` : le tag Orientation (1..8) d'un JPEG, 0 sinon.
|
||||
pub fn exif_orientation(b: &[u8]) -> i32 {
|
||||
if b.len() < 4 || b[0] != 0xFF || b[1] != 0xD8 {
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
//! Outils d'image de l'agent (lot 45) — port de `chat_vision_tool.go`
|
||||
//! (`see_image`), de `chat_screenshot.go` (`web_screenshot`, Playwright) et
|
||||
//! de `prepareImageForModel` (`web_upload_orient.go`).
|
||||
//!
|
||||
//! `see_image` charge une image du disque dans la vision du modèle ;
|
||||
//! `web_screenshot` photographie une page rendue par Playwright, la range
|
||||
//! dans le dossier de la discussion et la montre au modèle s'il voit les
|
||||
//! images. Les deux passent par la même préparation que les pièces jointes :
|
||||
//! orientation EXIF redressée, grand côté ramené sous 1568 px (mêmes pixels
|
||||
//! que le Go ; l'encodeur, lui, diffère).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
use base64::Engine as _;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::convfiles::{Rgba, apply_orientation, exif_orientation, scale_down_to_max};
|
||||
use crate::store::{BK_STATE, Store};
|
||||
|
||||
/// `maxImageDim`.
|
||||
pub const MAX_IMAGE_DIM: usize = 1568;
|
||||
/// `imageJPEGQuality`.
|
||||
const JPEG_QUALITY: u8 = 88;
|
||||
/// `maxVisionBytes`.
|
||||
pub const MAX_VISION_BYTES: i64 = 12 << 20;
|
||||
/// `screenshotTimeout`.
|
||||
const SCREENSHOT_TIMEOUT: Duration = Duration::from_secs(90);
|
||||
/// `maxCaptureFiles`, `maxCaptureBytes`.
|
||||
const MAX_CAPTURE_FILES: usize = 20;
|
||||
const MAX_CAPTURE_BYTES: u64 = 40 << 20;
|
||||
|
||||
/// `imageMimes` : les images qu'on sait montrer au modèle.
|
||||
pub fn image_mime(name: &str) -> &'static str {
|
||||
let ext = Path::new(name)
|
||||
.extension()
|
||||
.map(|e| format!(".{}", e.to_string_lossy().to_lowercase()))
|
||||
.unwrap_or_default();
|
||||
match ext.as_str() {
|
||||
".png" => "image/png",
|
||||
".jpg" | ".jpeg" => "image/jpeg",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
".bmp" => "image/bmp",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// `prepareImageForModel` : orientation cuite dans les pixels, grand côté
|
||||
/// sous [`MAX_IMAGE_DIM`] ; rien à faire ou image indécodable : les octets
|
||||
/// d'origine.
|
||||
pub fn prepare_image_for_model(raw: &[u8], mime: &str) -> (Vec<u8>, String) {
|
||||
let orient = if mime == "image/jpeg" {
|
||||
exif_orientation(raw)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let need_orient = (2..=8).contains(&orient);
|
||||
// Les formats que décode la bibliothèque standard du Go.
|
||||
let fmt = match image::guess_format(raw) {
|
||||
Ok(f @ (image::ImageFormat::Png | image::ImageFormat::Jpeg | image::ImageFormat::Gif)) => f,
|
||||
_ => return (raw.to_vec(), mime.to_string()),
|
||||
};
|
||||
let Ok(src) = image::load_from_memory_with_format(raw, fmt) else {
|
||||
return (raw.to_vec(), mime.to_string());
|
||||
};
|
||||
let (w, h) = (src.width() as usize, src.height() as usize);
|
||||
let need_resize = w > MAX_IMAGE_DIM || h > MAX_IMAGE_DIM;
|
||||
if !need_orient && !need_resize {
|
||||
return (raw.to_vec(), mime.to_string());
|
||||
}
|
||||
let mut img = Rgba {
|
||||
w,
|
||||
h,
|
||||
pix: src.to_rgba8().into_raw(),
|
||||
};
|
||||
if need_orient {
|
||||
img = apply_orientation(&img, orient);
|
||||
}
|
||||
if img.w > MAX_IMAGE_DIM || img.h > MAX_IMAGE_DIM {
|
||||
img = scale_down_to_max(&img, MAX_IMAGE_DIM);
|
||||
}
|
||||
let Some(buf) = image::RgbaImage::from_raw(img.w as u32, img.h as u32, img.pix) else {
|
||||
return (raw.to_vec(), mime.to_string());
|
||||
};
|
||||
let mut out = std::io::Cursor::new(Vec::new());
|
||||
if mime == "image/png" {
|
||||
if buf.write_to(&mut out, image::ImageFormat::Png).is_err() {
|
||||
return (raw.to_vec(), mime.to_string());
|
||||
}
|
||||
return (out.into_inner(), "image/png".into());
|
||||
}
|
||||
let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, JPEG_QUALITY);
|
||||
let rgb = image::DynamicImage::ImageRgba8(buf).to_rgb8();
|
||||
if rgb.write_with_encoder(enc).is_err() {
|
||||
return (raw.to_vec(), mime.to_string());
|
||||
}
|
||||
(out.into_inner(), "image/jpeg".into())
|
||||
}
|
||||
|
||||
/// `imageURLPart`.
|
||||
pub fn image_url_part(b: &[u8], mime: &str) -> Value {
|
||||
json!({
|
||||
"type": "image_url",
|
||||
"image_url": { "url": format!("data:{mime};base64,{}", base64::engine::general_purpose::STANDARD.encode(b)) },
|
||||
})
|
||||
}
|
||||
|
||||
/// `engineSeesImages` : le moteur en service accepte-t-il des images ?
|
||||
/// Sonde `/props` (authentifiée), gardée 10 s ; preset externe : sa case.
|
||||
#[derive(Default)]
|
||||
pub struct VisionProbe {
|
||||
last: tokio::sync::Mutex<Option<(Instant, bool)>>,
|
||||
}
|
||||
|
||||
impl VisionProbe {
|
||||
pub async fn sees(&self, store: &Store, cfg: &crate::serve::Cfg) -> bool {
|
||||
if crate::serve::is_external(cfg) {
|
||||
return crate::system::vision_enabled(cfg);
|
||||
}
|
||||
let mut last = self.last.lock().await;
|
||||
if let Some((at, seen)) = *last
|
||||
&& at.elapsed() < Duration::from_secs(10)
|
||||
{
|
||||
return seen;
|
||||
}
|
||||
*last = Some((Instant::now(), false));
|
||||
crate::chat::engine::install_crypto();
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(2))
|
||||
.build()
|
||||
.unwrap_or_default();
|
||||
let url = format!("http://localhost:{}/props", crate::engstatus::llm_port(cfg));
|
||||
let mut rq = client.get(url);
|
||||
let key = store.get_str(BK_STATE, "api_key").await;
|
||||
if !key.is_empty() {
|
||||
rq = rq.header("Authorization", format!("Bearer {key}"));
|
||||
}
|
||||
let Ok(resp) = rq.send().await else {
|
||||
return false;
|
||||
};
|
||||
if resp.status().as_u16() != 200 {
|
||||
return false;
|
||||
}
|
||||
let v: Value = match resp.bytes().await {
|
||||
Ok(b) => serde_json::from_slice(&b).unwrap_or_default(),
|
||||
Err(_) => return false,
|
||||
};
|
||||
let seen = v["modalities"]["vision"].as_bool().unwrap_or(false);
|
||||
*last = Some((Instant::now(), seen));
|
||||
seen
|
||||
}
|
||||
|
||||
/// Oublie la dernière sonde (tests).
|
||||
pub async fn reset(&self) {
|
||||
*self.last.lock().await = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// `toolSeeImage` : (accusé, partie image). `cwd` et `target` : le contexte
|
||||
/// des outils ; `sees` : la sonde du moteur, appelée en dernier.
|
||||
pub async fn see_image(
|
||||
path: &str,
|
||||
cwd: &Path,
|
||||
target: &str,
|
||||
vision: bool,
|
||||
sees: impl std::future::Future<Output = bool>,
|
||||
) -> (String, Option<Value>) {
|
||||
let err = |m: String| (m, None);
|
||||
if !vision {
|
||||
return err("[erreur] la vision n'est pas active sur ce preset (aucun projecteur MMPROJ configuré) — impossible de voir une image".into());
|
||||
}
|
||||
if !target.is_empty() {
|
||||
return err("[erreur] voir une image n'est pas possible sur un poste distant (le fichier est sur l'autre machine)".into());
|
||||
}
|
||||
if path.is_empty() {
|
||||
return err("[erreur] chemin de fichier manquant".into());
|
||||
}
|
||||
let abs = crate::tools::policy::resolve_agent_path(cwd, path);
|
||||
let mime = image_mime(&abs.to_string_lossy());
|
||||
if mime.is_empty() {
|
||||
return err(format!(
|
||||
"[erreur] format non reconnu comme image (attendu : png, jpg, gif, webp, bmp) : {path}"
|
||||
));
|
||||
}
|
||||
let st = match std::fs::metadata(&abs) {
|
||||
Ok(m) => m,
|
||||
Err(_) => return err(format!("[erreur] fichier introuvable : {path}")),
|
||||
};
|
||||
if st.is_dir() {
|
||||
return err(format!("[erreur] c'est un dossier, pas une image : {path}"));
|
||||
}
|
||||
if st.len() as i64 > MAX_VISION_BYTES {
|
||||
return err(format!(
|
||||
"[erreur] image trop lourde ({}, max {}) : {path}",
|
||||
crate::downloads::human_bytes(st.len() as i64),
|
||||
crate::downloads::human_bytes(MAX_VISION_BYTES)
|
||||
));
|
||||
}
|
||||
if !sees.await {
|
||||
return err(format!(
|
||||
"[erreur] le moteur en service ne traite pas les images (projecteur non chargé ?) — impossible de voir {path}"
|
||||
));
|
||||
}
|
||||
let b = match std::fs::read(&abs) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
return err(format!(
|
||||
"[erreur] lecture impossible : {}",
|
||||
crate::paths::os_err("open", &abs.to_string_lossy(), &e)
|
||||
));
|
||||
}
|
||||
};
|
||||
if b.is_empty() {
|
||||
return err(format!("[erreur] fichier vide : {path}"));
|
||||
}
|
||||
let (b, mime) = prepare_image_for_model(&b, mime);
|
||||
let base = abs
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
(
|
||||
format!("[ok] image chargée : {base}"),
|
||||
Some(image_url_part(&b, &mime)),
|
||||
)
|
||||
}
|
||||
|
||||
// --- web_screenshot -------------------------------------------------------------
|
||||
|
||||
/// Le résultat de `web_screenshot` : le texte, et la capture rangée
|
||||
/// (chemin relatif à la discussion, et absolu).
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
pub struct Shot {
|
||||
pub result: String,
|
||||
pub rel: String,
|
||||
pub abs: PathBuf,
|
||||
}
|
||||
|
||||
/// `safeSlug`.
|
||||
pub fn safe_slug(s: &str) -> String {
|
||||
let re = regex::Regex::new(r"[^a-zA-Z0-9._-]+").expect("regex");
|
||||
let s = re.replace_all(s, "-");
|
||||
let s = s.trim_matches(['-', '.']);
|
||||
let s: String = s.chars().take(40).collect();
|
||||
if s.is_empty() { "page".into() } else { s }
|
||||
}
|
||||
|
||||
/// `isLocalURL`. Le nom d'hôte tel qu'écrit (`url.Hostname()` du Go
|
||||
/// garde la casse : `LOCALHOST` n'est pas local).
|
||||
pub fn is_local_url(raw: &str) -> bool {
|
||||
if url::Url::parse(raw).is_err() {
|
||||
return false;
|
||||
}
|
||||
let host = go_hostname(raw);
|
||||
matches!(host, "localhost" | "127.0.0.1" | "::1" | "0.0.0.0") || host.ends_with(".localhost")
|
||||
}
|
||||
|
||||
/// `url.Hostname()` : l'hôte de l'autorité, sans identifiants ni port,
|
||||
/// sans crochets pour une IPv6.
|
||||
fn go_hostname(raw: &str) -> &str {
|
||||
let Some(i) = raw.find("://") else {
|
||||
return "";
|
||||
};
|
||||
let rest = &raw[i + 3..];
|
||||
let auth = &rest[..rest.find(['/', '?', '#']).unwrap_or(rest.len())];
|
||||
let hp = auth.rfind('@').map_or(auth, |j| &auth[j + 1..]);
|
||||
if let Some(v6) = hp.strip_prefix('[') {
|
||||
return v6.find(']').map_or(v6, |j| &v6[..j]);
|
||||
}
|
||||
match hp.rfind(':') {
|
||||
Some(c) if hp[c + 1..].bytes().all(|b| b.is_ascii_digit()) => &hp[..c],
|
||||
_ => hp,
|
||||
}
|
||||
}
|
||||
|
||||
/// `lastLines` : les n dernières lignes non vides, jointes par « · ».
|
||||
pub fn last_lines(s: &str, n: usize) -> String {
|
||||
let keep: Vec<&str> = s.split('\n').filter(|l| !l.trim().is_empty()).collect();
|
||||
keep[keep.len().saturating_sub(n)..].join(" · ")
|
||||
}
|
||||
|
||||
/// `pruneCaptures` : les plus anciennes captures partent tant que le
|
||||
/// dossier dépasse 20 fichiers ou 40 Mo ; celle qui vient d'être prise reste.
|
||||
pub fn prune_captures(dir: &Path, keep: &Path) {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
let mut shots: Vec<(PathBuf, SystemTime, u64)> = Vec::new();
|
||||
let mut total = 0u64;
|
||||
for e in rd.flatten() {
|
||||
let Ok(m) = e.metadata() else { continue };
|
||||
if m.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let p = e.path();
|
||||
total += m.len();
|
||||
if p == keep {
|
||||
continue;
|
||||
}
|
||||
shots.push((p, m.modified().unwrap_or(SystemTime::UNIX_EPOCH), m.len()));
|
||||
}
|
||||
shots.sort_by_key(|s| s.1);
|
||||
// Tous les fichiers comptent, celle qui reste comprise.
|
||||
let mut nb = shots.len() + usize::from(!keep.as_os_str().is_empty());
|
||||
for (p, _, size) in shots {
|
||||
if nb <= MAX_CAPTURE_FILES && total <= MAX_CAPTURE_BYTES {
|
||||
break;
|
||||
}
|
||||
if std::fs::remove_file(&p).is_ok() {
|
||||
total -= size;
|
||||
nb -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `toolWebScreenshot`. `playwright` : le binaire (vide : absent) ;
|
||||
/// `conv_dir` : le dossier de la discussion ; `internet` : l'accès est-il
|
||||
/// ouvert ; `stamp` : l'heure du nom de fichier.
|
||||
pub async fn web_screenshot(
|
||||
args: &Map<String, Value>,
|
||||
playwright: &str,
|
||||
conv_dir: &Path,
|
||||
internet: bool,
|
||||
stamp: &str,
|
||||
) -> Shot {
|
||||
let fail = |m: &str| Shot {
|
||||
result: m.to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
if playwright.is_empty() {
|
||||
return fail(
|
||||
"[erreur] Playwright n'est pas installé dans cette image (bâtie avec PLAYWRIGHT=0).",
|
||||
);
|
||||
}
|
||||
let url = args
|
||||
.get("url")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
if url.is_empty() {
|
||||
return fail("[erreur] url manquante");
|
||||
}
|
||||
if !url.starts_with("http://") && !url.starts_with("https://") {
|
||||
return fail("[erreur] url invalide : elle doit commencer par http:// ou https://");
|
||||
}
|
||||
if !internet && !is_local_url(&url) {
|
||||
return fail(
|
||||
"[refusé] L'accès internet est coupé : la capture ne marche que sur une adresse locale (localhost / 127.0.0.1), par exemple ton serveur de dev. Active l'accès internet pour le web.",
|
||||
);
|
||||
}
|
||||
let mut width = 1280i64;
|
||||
if let Some(v) = args.get("width").and_then(Value::as_f64)
|
||||
&& (320.0..=3840.0).contains(&v)
|
||||
{
|
||||
width = v as i64;
|
||||
}
|
||||
let full_page = args.get("full_page").and_then(Value::as_bool) == Some(true);
|
||||
let dir = conv_dir.join("captures");
|
||||
if let Err(e) = crate::paths::go_mkdir_all(&dir) {
|
||||
return fail(&format!("[erreur] création du dossier de captures : {e}"));
|
||||
}
|
||||
let mut host = url.as_str();
|
||||
if let Some(i) = host.find("://") {
|
||||
host = &host[i + 3..];
|
||||
}
|
||||
if let Some(i) = host.find(['/', '?', '#']) {
|
||||
host = &host[..i];
|
||||
}
|
||||
let host = host.to_string();
|
||||
let name = format!("{}-{stamp}.jpg", safe_slug(&host));
|
||||
let out = dir.join(&name);
|
||||
let mut cmd_args: Vec<String> = [
|
||||
"screenshot",
|
||||
"--browser",
|
||||
"chromium",
|
||||
"--viewport-size",
|
||||
&format!("{width},800"),
|
||||
"--wait-for-timeout",
|
||||
"3500",
|
||||
]
|
||||
.map(String::from)
|
||||
.to_vec();
|
||||
if full_page {
|
||||
cmd_args.push("--full-page".into());
|
||||
}
|
||||
cmd_args.push(url.clone());
|
||||
cmd_args.push(out.to_string_lossy().into_owned());
|
||||
let run = tokio::process::Command::new(playwright)
|
||||
.args(&cmd_args)
|
||||
.current_dir(&dir)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.kill_on_drop(true)
|
||||
.output();
|
||||
match tokio::time::timeout(SCREENSHOT_TIMEOUT, run).await {
|
||||
Err(_) => return fail("[erreur] la page n'a pas fini de charger en 90 s"),
|
||||
// Lancement impossible : le Go ne rend que la fin de la sortie, vide.
|
||||
Ok(Err(_)) => return fail("[erreur] capture impossible : "),
|
||||
Ok(Ok(o)) if !o.status.success() => {
|
||||
let mut combined = o.stdout.clone();
|
||||
combined.extend_from_slice(&o.stderr);
|
||||
return fail(&format!(
|
||||
"[erreur] capture impossible : {}",
|
||||
last_lines(&crate::system::go_lossy(&combined), 3).trim()
|
||||
));
|
||||
}
|
||||
Ok(Ok(_)) => {}
|
||||
}
|
||||
let size = match std::fs::metadata(&out) {
|
||||
Ok(m) if m.len() > 0 => m.len(),
|
||||
_ => return fail("[erreur] Playwright n'a produit aucune image"),
|
||||
};
|
||||
prune_captures(&dir, &out);
|
||||
let rel = format!("captures/{name}");
|
||||
Shot {
|
||||
result: format!(
|
||||
"Capture enregistrée ({rel}, {} Ko).\nPour la montrer à l'utilisateur, recopie TELLE QUELLE cette ligne markdown dans ta réponse :\n",
|
||||
size / 1024
|
||||
),
|
||||
rel,
|
||||
abs: out,
|
||||
}
|
||||
}
|
||||
|
||||
/// `screenshotImageMessage` : le message qui porte la capture au modèle.
|
||||
pub fn screenshot_message(abs: &Path) -> Option<Value> {
|
||||
let mime = image_mime(&abs.to_string_lossy());
|
||||
if mime.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let b = std::fs::read(abs).ok().filter(|b| !b.is_empty())?;
|
||||
let (b, mime) = prepare_image_for_model(&b, mime);
|
||||
Some(json!({
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "text", "text": "Voici la capture demandée." },
|
||||
image_url_part(&b, &mime),
|
||||
],
|
||||
}))
|
||||
}
|
||||
|
||||
/// `time.Now().Format("20060102-150405")`, heure locale.
|
||||
pub fn local_stamp() -> String {
|
||||
let sec = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or_default();
|
||||
let c = crate::tz::Location::local().civil(sec);
|
||||
format!(
|
||||
"{:04}{:02}{:02}-{:02}{:02}{:02}",
|
||||
c.year, c.month, c.day, c.hour, c.minute, c.second
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,150 @@
|
||||
//! Tests unitaires des outils d'image (lot 45) ; la parité avec le Go est
|
||||
//! dans `tests/imagetools.rs`.
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn types_d_image() {
|
||||
assert_eq!(image_mime("a.PNG"), "image/png");
|
||||
assert_eq!(image_mime("/x/b.jpeg"), "image/jpeg");
|
||||
assert_eq!(image_mime("c.Jpg"), "image/jpeg");
|
||||
assert_eq!(image_mime("d.gif"), "image/gif");
|
||||
assert_eq!(image_mime("e.webp"), "image/webp");
|
||||
assert_eq!(image_mime("f.bmp"), "image/bmp");
|
||||
assert_eq!(image_mime("g.svg"), "");
|
||||
assert_eq!(image_mime("png"), "");
|
||||
assert_eq!(image_mime(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noms_de_capture() {
|
||||
assert_eq!(safe_slug("localhost:3000"), "localhost-3000");
|
||||
assert_eq!(safe_slug("www.exemple.test"), "www.exemple.test");
|
||||
assert_eq!(safe_slug("..--a b--.."), "a-b");
|
||||
assert_eq!(safe_slug("été"), "t");
|
||||
assert_eq!(safe_slug("::"), "page");
|
||||
assert_eq!(safe_slug(""), "page");
|
||||
assert_eq!(safe_slug(&"x".repeat(60)).len(), 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adresses_locales() {
|
||||
for u in [
|
||||
"http://localhost:3000/app",
|
||||
"http://127.0.0.1/",
|
||||
"http://[::1]:8080/",
|
||||
"http://0.0.0.0:5173",
|
||||
"https://app.localhost/",
|
||||
"http://moi:secret@localhost/",
|
||||
] {
|
||||
assert!(is_local_url(u), "{u}");
|
||||
}
|
||||
for u in [
|
||||
"https://exemple.test/",
|
||||
"http://localhost.exemple.test/",
|
||||
"http://LOCALHOST/",
|
||||
"http://127.0.0.2/",
|
||||
"pas une url",
|
||||
"http://localhost:abc/",
|
||||
] {
|
||||
assert!(!is_local_url(u), "{u}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dernieres_lignes() {
|
||||
assert_eq!(last_lines("a\n\nb\n \nc\nd\n", 3), "b · c · d");
|
||||
assert_eq!(last_lines("seule", 6), "seule");
|
||||
assert_eq!(last_lines("", 6), "");
|
||||
assert_eq!(last_lines("\n \n", 6), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_preparee() {
|
||||
// Petite PNG : rien à faire, les octets d'origine.
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::from_pixel(10, 5, image::Rgba([1, 2, 3, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
prepare_image_for_model(&png, "image/png"),
|
||||
(png.clone(), "image/png".into())
|
||||
);
|
||||
// Indécodable : telle quelle.
|
||||
assert_eq!(
|
||||
prepare_image_for_model(b"pas une image", "image/png"),
|
||||
(b"pas une image".to_vec(), "image/png".into())
|
||||
);
|
||||
// Trop grande : réduite sous 1568 px, en PNG.
|
||||
let mut big = Vec::new();
|
||||
image::RgbaImage::from_pixel(3136, 100, image::Rgba([9, 9, 9, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut big), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let (b, mime) = prepare_image_for_model(&big, "image/png");
|
||||
assert_eq!(mime, "image/png");
|
||||
let img = image::load_from_memory(&b).unwrap();
|
||||
assert_eq!((img.width(), img.height()), (1568, 50));
|
||||
let part = image_url_part(b"ab", "image/gif");
|
||||
assert_eq!(part["image_url"]["url"], "data:image/gif;base64,YWI=");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menage_des_captures() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let base = SystemTime::now() - Duration::from_secs(1000);
|
||||
for i in 0..25u64 {
|
||||
let p = d.path().join(format!("c{i:02}.jpg"));
|
||||
std::fs::write(&p, "x").unwrap();
|
||||
std::fs::File::options()
|
||||
.write(true)
|
||||
.open(&p)
|
||||
.unwrap()
|
||||
.set_modified(base + Duration::from_secs(i))
|
||||
.unwrap();
|
||||
}
|
||||
// La plus ancienne est celle qu'on vient de prendre : elle reste.
|
||||
let keep = d.path().join("c00.jpg");
|
||||
prune_captures(d.path(), &keep);
|
||||
let mut left: Vec<String> = std::fs::read_dir(d.path())
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
left.sort();
|
||||
assert_eq!(left.len(), 20);
|
||||
assert_eq!(left[0], "c00.jpg");
|
||||
assert_eq!(left[1], "c06.jpg");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capture_refusee_avant_playwright() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let args = |v: Value| serde_json::from_value::<Map<String, Value>>(v).unwrap();
|
||||
let s = web_screenshot(&args(json!({"url": "http://x/"})), "", d.path(), true, "t").await;
|
||||
assert!(
|
||||
s.result.contains("Playwright n'est pas installé"),
|
||||
"{}",
|
||||
s.result
|
||||
);
|
||||
let s = web_screenshot(&args(json!({"url": 3})), "/bin/false", d.path(), true, "t").await;
|
||||
assert_eq!(s.result, "[erreur] url manquante");
|
||||
assert!(s.rel.is_empty());
|
||||
assert!(!d.path().join("captures").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn voir_sans_poste_distant() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let (r, img) = see_image("a.png", d.path(), "nœud", true, async { true }).await;
|
||||
assert!(r.contains("poste distant"), "{r}");
|
||||
assert!(img.is_none());
|
||||
let (r, _) = see_image("", d.path(), "", true, async { true }).await;
|
||||
assert_eq!(r, "[erreur] chemin de fichier manquant");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horodatage_local() {
|
||||
let s = local_stamp();
|
||||
assert_eq!(s.len(), 15);
|
||||
assert_eq!(&s[8..9], "-");
|
||||
}
|
||||
@@ -29,6 +29,7 @@ pub mod engstatus;
|
||||
pub mod gguf;
|
||||
pub mod gojson;
|
||||
pub mod hf;
|
||||
pub mod imagetools;
|
||||
pub mod mcp;
|
||||
pub mod memory;
|
||||
pub mod models;
|
||||
|
||||
Reference in new issue
Block a user