mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-18 20:57:05 +02:00
Lot 44 : navigateur piloté tenu par la façade
La façade tient la session Chrome des outils browser_* (src/browser/, port de computer_cdp.go, computer_use.go, browser_grid.go et cu_autoshot.go) : lancement (profil temporaire, mêmes arguments, tué avec la façade), client CDP multiplexé sur websocket, attente du chargement et du calme réseau, scripts injectés copiés à l'octet près, les neuf outils aux textes du Go (snapshot dédoublonné, recherche, clics, saisie et listes déroulantes, touches, défilement, navigation et ses erreurs), capture rangée propre et quadrillée pour le modèle (mêmes pixels), aperçus JPEG en anneau servis par /api/chat/cushot. Un seul navigateur : quand la façade tient la conversation, le Go lui passe les outils de ses sous-agents et tâches IA, ses aperçus et la fermeture (POST /internal/facade/v1/browser) ; façade injoignable, le navigateur du Go prend le relais. Couper l'interrupteur ferme les deux. Parité : journal de 42 étapes tiré du Go contre un faux Chrome (script + faux serveur CDP qui note chaque commande, scripts résumés par leur empreinte, pixels de la grille), rejoué par tests/browser.rs ; un vrai Chromium de bout en bout avec LOKI_CU_SMOKE=1 ; un tour de la façade qui ouvre une page (tests/lot6.rs) ; TestFacadeNavigateur côté Go. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
24 files changed
+3910
-11
No files matched your search
@@ -0,0 +1,126 @@
|
||||
//! Grille de coordonnées sur la capture envoyée au modèle — port de
|
||||
//! `browser_grid.go`. Lignes graduées tous les 100 px et étiquettes en
|
||||
//! police bitmap 3×5 : un petit modèle y lit x/y pour `browser_click_xy`.
|
||||
//! Mêmes pixels que le Go (l'encodeur PNG, lui, diffère).
|
||||
|
||||
use image::{ImageFormat, Rgba, RgbaImage};
|
||||
|
||||
/// `gridStep`.
|
||||
const GRID_STEP: u32 = 100;
|
||||
|
||||
/// `digits5x3`.
|
||||
fn glyph(c: char) -> Option<[&'static str; 5]> {
|
||||
Some(match c {
|
||||
'0' => ["###", "# #", "# #", "# #", "###"],
|
||||
'1' => [" # ", "## ", " # ", " # ", "###"],
|
||||
'2' => ["###", " #", "###", "# ", "###"],
|
||||
'3' => ["###", " #", "###", " #", "###"],
|
||||
'4' => ["# #", "# #", "###", " #", " #"],
|
||||
'5' => ["###", "# ", "###", " #", "###"],
|
||||
'6' => ["###", "# ", "###", "# #", "###"],
|
||||
'7' => ["###", " #", " #", " #", " #"],
|
||||
'8' => ["###", "# #", "###", "# #", "###"],
|
||||
'9' => ["###", "# #", "###", " #", "###"],
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
const LINE: [u8; 4] = [255, 0, 200, 130];
|
||||
const LABEL_BG: [u8; 4] = [0, 0, 0, 200];
|
||||
const LABEL_FG: [u8; 4] = [255, 255, 0, 255];
|
||||
|
||||
/// `overlayGrid` : le PNG avec sa grille ; illisible, rendu tel quel.
|
||||
pub fn overlay_grid(raw: &[u8]) -> Vec<u8> {
|
||||
let Ok(src) = image::load_from_memory_with_format(raw, ImageFormat::Png) else {
|
||||
return raw.to_vec();
|
||||
};
|
||||
let mut img = src.to_rgba8();
|
||||
draw(&mut img);
|
||||
let mut out = std::io::Cursor::new(Vec::new());
|
||||
if img.write_to(&mut out, ImageFormat::Png).is_err() {
|
||||
return raw.to_vec();
|
||||
}
|
||||
out.into_inner()
|
||||
}
|
||||
|
||||
/// La grille et ses graduations, dessinées sur l'image.
|
||||
pub fn draw(img: &mut RgbaImage) {
|
||||
let (w, h) = img.dimensions();
|
||||
let mut x = GRID_STEP;
|
||||
while x < w {
|
||||
for y in 0..h {
|
||||
blend(img, x as i64, y as i64, LINE);
|
||||
}
|
||||
draw_label(img, x as i64 + 2, 2, &x.to_string());
|
||||
x += GRID_STEP;
|
||||
}
|
||||
let mut y = GRID_STEP;
|
||||
while y < h {
|
||||
for x in 0..w {
|
||||
blend(img, x as i64, y as i64, LINE);
|
||||
}
|
||||
draw_label(img, 2, y as i64 + 2, &y.to_string());
|
||||
y += GRID_STEP;
|
||||
}
|
||||
}
|
||||
|
||||
fn inside(img: &RgbaImage, x: i64, y: i64) -> bool {
|
||||
x >= 0 && y >= 0 && (x as u32) < img.width() && (y as u32) < img.height()
|
||||
}
|
||||
|
||||
/// `blend` : couleur translucide sur un pixel (opaque ensuite).
|
||||
fn blend(img: &mut RgbaImage, x: i64, y: i64, c: [u8; 4]) {
|
||||
if !inside(img, x, y) {
|
||||
return;
|
||||
}
|
||||
let a = f64::from(c[3]) / 255.0;
|
||||
let o = img.get_pixel(x as u32, y as u32).0;
|
||||
let mix = |n: u8, o: u8| (f64::from(n) * a + f64::from(o) * (1.0 - a)) as u8;
|
||||
img.put_pixel(
|
||||
x as u32,
|
||||
y as u32,
|
||||
Rgba([mix(c[0], o[0]), mix(c[1], o[1]), mix(c[2], o[2]), 255]),
|
||||
);
|
||||
}
|
||||
|
||||
fn set_opaque(img: &mut RgbaImage, x: i64, y: i64, c: [u8; 4]) {
|
||||
if inside(img, x, y) {
|
||||
img.put_pixel(x as u32, y as u32, Rgba([c[0], c[1], c[2], 255]));
|
||||
}
|
||||
}
|
||||
|
||||
/// `drawLabel` : un nombre en police 3×5 (×2) sur fond sombre.
|
||||
fn draw_label(img: &mut RgbaImage, x: i64, y: i64, s: &str) {
|
||||
const SC: i64 = 2;
|
||||
let (glyph_w, glyph_h, gap) = (3 * SC, 5 * SC, SC);
|
||||
let box_w = s.chars().count() as i64 * (glyph_w + gap) + gap;
|
||||
let box_h = glyph_h + 2 * gap;
|
||||
for dy in -gap..box_h - gap {
|
||||
for dx in -gap..box_w - gap {
|
||||
set_opaque(img, x + dx, y + dy, LABEL_BG);
|
||||
}
|
||||
}
|
||||
let mut cx = x;
|
||||
for c in s.chars() {
|
||||
if let Some(g) = glyph(c) {
|
||||
for (row, line) in g.iter().enumerate() {
|
||||
for (col, b) in line.bytes().enumerate() {
|
||||
if b != b'#' {
|
||||
continue;
|
||||
}
|
||||
for iy in 0..SC {
|
||||
for ix in 0..SC {
|
||||
set_opaque(
|
||||
img,
|
||||
cx + col as i64 * SC + ix,
|
||||
y + row as i64 * SC + iy,
|
||||
LABEL_FG,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cx += glyph_w + gap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//! Scripts injectés dans la page (copiés de `computer_cdp.go`, à l'octet près :
|
||||
//! le journal de parité compare les expressions envoyées).
|
||||
|
||||
/// `cdpSameTabJS` : les navigations « nouvel onglet » restent dans l'onglet.
|
||||
pub const SAME_TAB_JS: &str = r#"(function(){try{
|
||||
window.open=function(u){if(u)location.href=u;return window;};
|
||||
document.addEventListener('click',function(e){
|
||||
var a=e.target&&e.target.closest&&e.target.closest('a[target]');
|
||||
if(a)a.removeAttribute('target');
|
||||
},true);
|
||||
}catch(e){}})();"#;
|
||||
|
||||
/// `cdpHelpersJS` : rôle, nom, actionnable, visible, shadow DOM.
|
||||
pub const HELPERS_JS: &str = r#"
|
||||
function ajRole(el){var r=el.getAttribute('role');if(r)return r;var t=el.tagName.toLowerCase();if(t==='a')return 'link';if(t==='button')return 'button';if(t==='select')return 'combobox';if(t==='textarea')return 'textbox';if(t==='input'){var ty=(el.type||'text').toLowerCase();var m={checkbox:'checkbox',radio:'radio',button:'button',submit:'button',reset:'button',file:'button',range:'slider',password:'password',search:'searchbox'};return m[ty]||'textbox';}return t;}
|
||||
function ajName(el){var t=el.tagName.toLowerCase();var lbl=el.getAttribute('aria-label')||'';if(!lbl){var lb=el.getAttribute('aria-labelledby');if(lb){var e2=document.getElementById(lb.split(' ')[0]);if(e2)lbl=e2.innerText||'';}}if(!lbl&&el.labels&&el.labels.length)lbl=el.labels[0].innerText||'';if(!lbl)lbl=el.getAttribute('placeholder')||el.getAttribute('title')||el.getAttribute('alt')||'';var val='';if((t==='input'||t==='textarea')&&el.type!=='password'&&el.type!=='hidden')val=el.value||'';var n=val?(lbl?(lbl+' = '+val):val):lbl;if(!n&&t!=='input'&&t!=='select'&&t!=='textarea')n=el.innerText||'';if(!n){var c=el.querySelector('img[alt],[aria-label],[title],svg title');if(c)n=c.getAttribute&&(c.getAttribute('alt')||c.getAttribute('aria-label')||c.getAttribute('title'))||c.textContent||'';}if(!n)n=el.getAttribute('name')||el.getAttribute('value')||'';return (n||'').replace(/\s+/g,' ').trim().slice(0,100);}
|
||||
function ajActionable(el){var t=el.tagName.toLowerCase();if(t==='a')return !!el.getAttribute('href');if(t==='button'||t==='input'||t==='select'||t==='textarea'||t==='summary')return el.type!=='hidden';if(el.isContentEditable)return true;if(el.getAttribute('onclick'))return true;var r=(el.getAttribute('role')||'').toLowerCase();return {button:1,link:1,checkbox:1,radio:1,tab:1,menuitem:1,menuitemcheckbox:1,menuitemradio:1,option:1,'switch':1,combobox:1,textbox:1,searchbox:1,slider:1,spinbutton:1}[r]===1;}
|
||||
function ajVis(el){var r=el.getBoundingClientRect();if(r.width<3||r.height<3)return false;var s=getComputedStyle(el);if(s.visibility==='hidden'||s.display==='none'||s.opacity==='0')return false;if(r.bottom<0||r.top>innerHeight||r.right<0||r.left>innerWidth)return false;return true;}
|
||||
var ajSel='a[href],button,input,select,textarea,[role],[onclick],[tabindex],summary,[contenteditable="true"]';
|
||||
// Traversée du SHADOW DOM : les bandeaux cookies et composants web (usercentrics,
|
||||
// cookiebot…) vivent dans des shadow roots que querySelectorAll ne voit pas → le
|
||||
// modèle ne pouvait ni les lister ni les cliquer (test Lady Sushi). ajQueryAll /
|
||||
// ajByRef / ajClear descendent récursivement dans chaque shadowRoot ouvert.
|
||||
function ajQueryAll(){var out=[];function w(r){var e=r.querySelectorAll(ajSel);for(var i=0;i<e.length;i++)out.push(e[i]);var a=r.querySelectorAll('*');for(var j=0;j<a.length;j++)if(a[j].shadowRoot)w(a[j].shadowRoot);}w(document);return out;}
|
||||
function ajByRef(ref){function f(r){var e=r.querySelector('[data-loki-ref="'+ref+'"]');if(e)return e;var a=r.querySelectorAll('*');for(var i=0;i<a.length;i++)if(a[i].shadowRoot){var x=f(a[i].shadowRoot);if(x)return x;}return null;}return f(document);}
|
||||
function ajClear(){function w(r){var o=r.querySelectorAll('[data-loki-ref]');for(var i=0;i<o.length;i++)o[i].removeAttribute('data-loki-ref');var a=r.querySelectorAll('*');for(var j=0;j<a.length;j++)if(a[j].shadowRoot)w(a[j].shadowRoot);}w(document);}
|
||||
"#;
|
||||
|
||||
/// La fin de `cdpSnapshotJS` (préfixée de [`HELPERS_JS`]).
|
||||
pub const SNAPSHOT_TAIL_JS: &str = r#"(function(){
|
||||
ajClear();
|
||||
var els=ajQueryAll().filter(function(el){return ajActionable(el)&&ajVis(el);});
|
||||
var map={},refs=[],i=0;
|
||||
for(var k=0;k<els.length&&i<200;k++){var el=els[k];var nm=ajName(el),rl=ajRole(el);
|
||||
i++;var r=el.getBoundingClientRect();map[i]={x:r.left+r.width/2,y:r.top+r.height/2};
|
||||
el.setAttribute('data-loki-ref',String(i));
|
||||
refs.push('['+i+'] '+rl+(nm?' "'+nm+'"':''));
|
||||
}
|
||||
window.__lokiRefs=map;
|
||||
var de=document.documentElement;
|
||||
var sh=Math.max(de.scrollHeight,document.body?document.body.scrollHeight:0);
|
||||
return {title:document.title||'',url:location.href,refs:refs,
|
||||
above:Math.round(scrollY),below:Math.round(Math.max(0,sh-scrollY-innerHeight))};
|
||||
})()"#;
|
||||
|
||||
/// `cdpFindJS` : recherche non destructive sur toute la page.
|
||||
pub const FIND_JS: &str = r#"function(q){
|
||||
q=(q||'').toLowerCase();
|
||||
var pool=ajQueryAll();
|
||||
var maxRef=0;for(var m=0;m<pool.length;m++){var v=parseInt(pool[m].getAttribute('data-loki-ref'),10)||0;if(v>maxRef)maxRef=v;}
|
||||
var els=pool.filter(ajActionable);
|
||||
var refs=[],count=0,first=null;
|
||||
for(var k=0;k<els.length&&count<60;k++){var el=els[k];var nm=ajName(el),rl=ajRole(el);
|
||||
if((rl+' '+nm).toLowerCase().indexOf(q)<0)continue;
|
||||
var st=getComputedStyle(el);if(st.display==='none'||st.visibility==='hidden')continue;
|
||||
var r=el.getBoundingClientRect();if(r.width<1&&r.height<1)continue;
|
||||
var ref=el.getAttribute('data-loki-ref');
|
||||
if(!ref){maxRef++;ref=String(maxRef);el.setAttribute('data-loki-ref',ref);}
|
||||
if(!first)first=el;count++;
|
||||
refs.push('['+ref+'] '+rl+(nm?' "'+nm+'"':''));
|
||||
}
|
||||
if(first)first.scrollIntoView({block:'center'});
|
||||
return {refs:refs};
|
||||
}"#;
|
||||
|
||||
/// `cdpSnapshotJS`.
|
||||
pub fn snapshot_js() -> String {
|
||||
format!("{HELPERS_JS}{SNAPSHOT_TAIL_JS}")
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,103 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn outils_et_plafond() {
|
||||
for n in [
|
||||
"browser_open",
|
||||
"browser_snapshot",
|
||||
"browser_find",
|
||||
"browser_click",
|
||||
"browser_click_xy",
|
||||
"browser_type",
|
||||
"browser_key",
|
||||
"browser_scroll",
|
||||
"browser_screenshot",
|
||||
] {
|
||||
assert!(is_tool(n), "{n}");
|
||||
}
|
||||
assert!(!is_tool("web_screenshot"));
|
||||
assert!(!auto_shot_tool("browser_snapshot") && !auto_shot_tool("browser_screenshot"));
|
||||
assert!(auto_shot_tool("browser_find"));
|
||||
let s = "é".repeat(MAX_OUTPUT);
|
||||
assert_eq!(cap_output(&s), s);
|
||||
assert_eq!(cap_output(&format!("{s}x")), format!("{s}\n…[tronqué]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nombres_comme_le_go() {
|
||||
assert_eq!(go_num(640.0).to_string(), "640");
|
||||
assert_eq!(go_num(412.5).to_string(), "412.5");
|
||||
assert_eq!(go_num(-3.0).to_string(), "-3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arguments_de_lancement() {
|
||||
let a = launch_args(Path::new("/tmp/p"), false);
|
||||
assert_eq!(a[0], "--headless=new");
|
||||
assert!(a.contains(&"--user-data-dir=/tmp/p".to_string()));
|
||||
assert_eq!(a.last().unwrap(), "about:blank");
|
||||
let h = launch_args(Path::new("/tmp/p"), true);
|
||||
assert_eq!(h.len(), a.len() - 1);
|
||||
assert_eq!(h[0], "--remote-debugging-port=0");
|
||||
}
|
||||
|
||||
/// La grille : lignes translucides tous les 100 px, étiquettes opaques,
|
||||
/// rien d'autre de touché.
|
||||
#[test]
|
||||
fn grille() {
|
||||
let mut img = image::RgbaImage::from_pixel(250, 150, image::Rgba([40, 80, 120, 255]));
|
||||
grid::draw(&mut img);
|
||||
let px = |x, y| img.get_pixel(x, y).0;
|
||||
assert_eq!(px(0, 0), [40, 80, 120, 255]);
|
||||
// 255×130/255 + 40×125/255 → 149 ; 0 + 80×(1−a) → 39 ; 200a + 120(1−a) → 160.
|
||||
assert_eq!(px(100, 50), [149, 39, 160, 255]);
|
||||
assert_eq!(px(200, 140), [149, 39, 160, 255]);
|
||||
assert_eq!(px(50, 100), [149, 39, 160, 255]);
|
||||
assert_eq!(px(101, 50), [40, 80, 120, 255]);
|
||||
// « 100 » en haut de la ligne : fond sombre, chiffre jaune.
|
||||
assert_eq!(px(100, 1), [0, 0, 0, 255]);
|
||||
assert_eq!(px(104, 4), [255, 255, 0, 255]);
|
||||
// Une image illisible passe telle quelle.
|
||||
assert_eq!(grid::overlay_grid(b"pas une image"), b"pas une image");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profils_temporaires() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let p = mkdtemp(tmp.path()).unwrap();
|
||||
assert!(
|
||||
p.file_name()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.starts_with("loki-cu-")
|
||||
);
|
||||
assert!(p.is_dir());
|
||||
// Récent : gardé ; un autre dossier : jamais touché.
|
||||
std::fs::create_dir(tmp.path().join("autre")).unwrap();
|
||||
sweep_stale(tmp.path());
|
||||
assert!(p.is_dir() && tmp.path().join("autre").is_dir());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lancement_impossible() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let b = Browser::with("", tmp.path().to_path_buf());
|
||||
let mut a = Map::new();
|
||||
a.insert("url".into(), json!("x.test"));
|
||||
let o = b.exec("browser_open", &a, false, "").await.unwrap();
|
||||
// Sans binaire imposé ni trouvé, ou introuvable : l'erreur dit quoi faire.
|
||||
assert!(o.result.starts_with("[erreur] "), "{}", o.result);
|
||||
let b = Browser::with("/nulle/part/chrome", tmp.path().to_path_buf());
|
||||
let o = b
|
||||
.exec("browser_snapshot", &Map::new(), false, "")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
o.result,
|
||||
"[erreur] fork/exec /nulle/part/chrome: no such file or directory"
|
||||
);
|
||||
// Le profil du lancement raté est retiré.
|
||||
assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 0);
|
||||
assert_eq!(b.exec("web_open", &a, false, "").await, None);
|
||||
assert!(b.shot("rien").is_none());
|
||||
}
|
||||
@@ -1324,6 +1324,7 @@ impl ChatShared {
|
||||
memory: None,
|
||||
web: Default::default(),
|
||||
jobs: crate::tools::jobs::global(),
|
||||
browser: crate::browser::global(),
|
||||
go_version: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
//! d'écriture du mode Code (état du Go) : `cache_hint` et `preflight` absents.
|
||||
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
@@ -116,6 +116,11 @@ pub struct ImageCache {
|
||||
}
|
||||
|
||||
impl ImageCache {
|
||||
/// `LOKI_HOME` (le parent du dossier des images).
|
||||
pub fn home(&self) -> PathBuf {
|
||||
self.dir.parent().map(Path::to_path_buf).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `dir` : `LOKI_HOME/chatimg`.
|
||||
pub fn new(dir: PathBuf) -> Self {
|
||||
ImageCache {
|
||||
@@ -351,6 +356,8 @@ pub struct ChatShared {
|
||||
pub web: Arc<crate::webtools::Web>,
|
||||
/// Les jobs d'arrière-plan du mode Code (lot 43).
|
||||
pub jobs: Arc<crate::tools::jobs::BgJobs>,
|
||||
/// Le navigateur piloté (lot 44).
|
||||
pub browser: Arc<crate::browser::Browser>,
|
||||
/// La version du Go, annoncée aux serveurs MCP à une reconnexion ; lue
|
||||
/// une fois.
|
||||
pub go_version: tokio::sync::OnceCell<String>,
|
||||
@@ -709,6 +716,34 @@ impl TurnHost for GoTurnHost {
|
||||
..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.
|
||||
if crate::browser::is_tool(name) {
|
||||
let cfg = self.shared.store.config().await;
|
||||
let vision = crate::system::vision_enabled(&cfg);
|
||||
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();
|
||||
if let Some(o) = self.shared.browser.exec(name, args, vision, &ws).await {
|
||||
let live_shot =
|
||||
if !o.result.starts_with("[erreur]") && crate::browser::auto_shot_tool(name) {
|
||||
self.shared.browser.auto_shot().await
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
return ToolOutcome {
|
||||
relay: o.image.map(|img| see_image_message(label, img)),
|
||||
result: o.result,
|
||||
live_shot,
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
}
|
||||
// Jobs d'arrière-plan du mode Code (lot 43), dans le dossier de la
|
||||
// discussion ; contexte indisponible : le Go s'en charge.
|
||||
if name == "bash_tail" {
|
||||
|
||||
@@ -52,6 +52,7 @@ pub fn handles(path: &str) -> bool {
|
||||
| "/api/conversations/rename"
|
||||
| "/api/conversations/delete"
|
||||
| "/api/perf/summary"
|
||||
| "/api/chat/cushot"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -72,6 +73,7 @@ pub fn is_internal(path: &str) -> bool {
|
||||
| "/internal/facade/v1/perf/record"
|
||||
| "/internal/facade/v1/perf/snapshot"
|
||||
| "/internal/facade/v1/bgjobs"
|
||||
| "/internal/facade/v1/browser"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -137,6 +139,49 @@ pub async fn serve_internal(chat: &Arc<ChatService>, req: Request<Incoming>) ->
|
||||
},
|
||||
// L'anneau, de la plus ancienne à la plus récente (poids de
|
||||
// l'optimiseur).
|
||||
// Navigateur piloté (lot 44) : les appels du Go (sous-agents, tâches
|
||||
// IA) passent par la session tenue ici — un seul navigateur.
|
||||
"/internal/facade/v1/browser" => {
|
||||
let v = read_json(req.into_body()).await.unwrap_or_default();
|
||||
let b = &chat.shared.browser;
|
||||
match str_of(&v, "op") {
|
||||
"tool" => {
|
||||
let cfg = chat.shared.store.config().await;
|
||||
let vision = crate::system::vision_enabled(&cfg);
|
||||
let home = chat.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 args = v
|
||||
.get("args")
|
||||
.and_then(Value::as_object)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
match b.exec(str_of(&v, "name"), &args, vision, &ws).await {
|
||||
Some(o) => send_json(
|
||||
StatusCode::OK,
|
||||
&json!({ "result": o.result, "image": o.image }),
|
||||
),
|
||||
None => send_json(
|
||||
StatusCode::BAD_REQUEST,
|
||||
&json!({ "error": "outil du navigateur inconnu" }),
|
||||
),
|
||||
}
|
||||
}
|
||||
"shot" => send_json(StatusCode::OK, &json!({ "id": b.auto_shot().await })),
|
||||
"shutdown" => {
|
||||
b.shutdown().await;
|
||||
send_json(StatusCode::OK, &json!({}))
|
||||
}
|
||||
_ => send_json(
|
||||
StatusCode::BAD_REQUEST,
|
||||
&json!({ "error": "opération inconnue" }),
|
||||
),
|
||||
}
|
||||
}
|
||||
// Jobs d'arrière-plan encore en vie (lot 43) : l'optimiseur du Go
|
||||
// refuse de mesurer à côté.
|
||||
"/internal/facade/v1/bgjobs" => send_json(
|
||||
@@ -276,6 +321,22 @@ pub async fn serve(
|
||||
chat.stop().await;
|
||||
ok()
|
||||
}
|
||||
// `handleCUShot` (lot 44) : un aperçu du navigateur piloté, en base64
|
||||
// (JSON, pour traverser aussi le tunnel du relais).
|
||||
"/api/chat/cushot" => {
|
||||
let id = query_param(&parts.uri, "id").unwrap_or_default();
|
||||
match chat.shared.browser.shot(&id) {
|
||||
Some(b) => send_json(
|
||||
StatusCode::OK,
|
||||
&json!({ "ok": true, "mime": "image/jpeg",
|
||||
"data": base64::Engine::encode(&base64::engine::general_purpose::STANDARD, b) }),
|
||||
),
|
||||
None => send_json(
|
||||
StatusCode::NOT_FOUND,
|
||||
&json!({ "ok": false, "error": "capture expirée" }),
|
||||
),
|
||||
}
|
||||
}
|
||||
"/api/chat/reset" => {
|
||||
chat.reset().await;
|
||||
ok()
|
||||
|
||||
@@ -14,6 +14,7 @@ pub mod api;
|
||||
pub mod auth;
|
||||
pub mod backends;
|
||||
pub mod bench;
|
||||
pub mod browser;
|
||||
pub mod chat;
|
||||
pub mod chatapi;
|
||||
pub mod chatx;
|
||||
|
||||
@@ -13,9 +13,10 @@
|
||||
//!
|
||||
//! Les réglages restent des clés de la base. Ce qui vit dans le processus Go
|
||||
//! reste au Go : son cache de joignabilité (vidé par
|
||||
//! `/internal/v1/web/reach/reset`), le Chromium des outils `browser_*`
|
||||
//! (`/internal/v1/cdp/shutdown`) et le redémarrage du moteur quand la façade ne
|
||||
//! le pilote pas (`/internal/v1/service/action`).
|
||||
//! `/internal/v1/web/reach/reset`), le Chromium qu'il pilote quand la façade
|
||||
//! ne tient pas la conversation (`/internal/v1/cdp/shutdown` ; sinon le
|
||||
//! navigateur est celui de la façade, lot 44) et le redémarrage du moteur
|
||||
//! quand la façade ne le pilote pas (`/internal/v1/service/action`).
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -528,8 +529,11 @@ pub async fn serve(
|
||||
if let Err(e) = put_bool(store, "computer", on).await {
|
||||
return fail(StatusCode::INTERNAL_SERVER_ERROR, e);
|
||||
}
|
||||
// Couper l'interrupteur ferme le navigateur (au Go, qui le
|
||||
// pilote pour les outils browser_*).
|
||||
// Couper l'interrupteur ferme le navigateur : celui de la
|
||||
// façade (lot 44), et celui du Go s'il en a ouvert un.
|
||||
if !on {
|
||||
crate::browser::global().shutdown().await;
|
||||
}
|
||||
if !on
|
||||
&& let Err(e) = store
|
||||
.internal_post("/internal/v1/cdp/shutdown", &json!({}))
|
||||
|
||||
Reference in new issue
Block a user