//! Web chat UI for agent instances, served at /a/chat/ through `web`. //! GET / -> list agents; GET // -> chat; GET //api/history?before= -> newest history chunk first-page / older chunks. //! GET //api/models -> shared model profiles; POST //send with {text,model?} forwards [web chat] text to the agent. //! The page uses EventSource /events/ for live history events. use sausage_actor::{json, messages, reply, send, Message, Value}; use std::collections::BTreeMap; use std::io::{Read, Seek, SeekFrom}; const PAGE: &str = include_str!("page.html"); const HISTORY_CHUNK: u64 = 256 * 1024; fn valid_agent(id: &str) -> bool { (id == "agent" || id.starts_with("agent-")) && id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_') } fn actor_exists(id: &str) -> bool { valid_agent(id) && std::fs::metadata(format!("/actor/{id}/actor.json")).is_ok() } fn response(status: u16, body: Value) -> Value { json!({"type":"http_response","status":status,"headers":{"Content-Type":"application/json; charset=utf-8","Cache-Control":"no-store"},"body":body}) } fn text_response(status: u16, text: &str) -> Value { json!({"type":"http_response","status":status,"headers":{"Content-Type":"text/plain; charset=utf-8","Cache-Control":"no-store"},"body":text}) } fn html_response(body: &str) -> Value { json!({"type":"http_response","status":200,"headers":{"Content-Type":"text/html; charset=utf-8","Cache-Control":"no-store"},"body":body}) } fn query_param(query: &str, key: &str) -> Option { query.split('&').find_map(|pair| { let (k, v) = pair.split_once('=').unwrap_or((pair, "")); (decode(k) == key).then(|| decode(v)) }) } fn decode(s: &str) -> String { let bytes = s.as_bytes(); let mut out = Vec::with_capacity(bytes.len()); let mut i = 0; while i < bytes.len() { if bytes[i] == b'%' && i + 2 < bytes.len() { if let Ok(hex) = std::str::from_utf8(&bytes[i + 1..i + 3]) { if let Ok(value) = u8::from_str_radix(hex, 16) { out.push(value); i += 3; continue; } } } out.push(if bytes[i] == b'+' { b' ' } else { bytes[i] }); i += 1; } String::from_utf8_lossy(&out).into_owned() } fn project_name(id: &str) -> String { let tail = id.strip_prefix("agent-").unwrap_or(""); let room = match tail.rsplit_once('-') { Some((room, suffix)) if suffix.len() > 1 && (suffix.starts_with('w') || suffix.starts_with('r')) && suffix[1..].bytes().all(|b| b.is_ascii_digit()) => room, _ => tail, }; let board = format!("board-{room}"); std::fs::read_to_string(format!("/actor/{board}/public/board.json")) .ok() .and_then(|s| serde_json::from_str::(&s).ok()) .and_then(|v| v["name"].as_str().filter(|s| !s.is_empty()).or(v["room"].as_str()).map(str::to_string)) .unwrap_or_default() } fn is_lead(id: &str) -> bool { if id == "agent" { return true; } let Some(tail) = id.strip_prefix("agent-") else { return false }; !tail.rsplit_once('-').is_some_and(|(_, suffix)| { suffix.len() > 1 && (suffix.starts_with('w') || suffix.starts_with('r')) && suffix[1..].bytes().all(|b| b.is_ascii_digit()) }) } fn index_page() -> String { let mut ids: Vec = std::fs::read_dir("/actor") .map(|rd| rd.flatten().map(|e| e.file_name().to_string_lossy().into_owned()) .filter(|id| id == "agent" || id.starts_with("agent-")) .filter(|id| actor_exists(id)).collect()) .unwrap_or_default(); ids.sort_by(|a, b| (!is_lead(a), a != "agent", a).cmp(&(!is_lead(b), b != "agent", b))); let mut items = String::new(); for id in ids { let label = project_name(&id); let title = if label.is_empty() { id.clone() } else { format!("{} ยท {}", escape(&label), escape(&id)) }; items.push_str(&format!("{}", escape(&id), title)); } format!("Agent chat{}
๐Ÿ’ฌ Agent chat

Choose an agent instance.

{}
", CSS, NAV, items) } fn escape(s: &str) -> String { s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """).replace('\'', "'") } fn history(agent: &str, before: Option) -> Value { let path = format!("/actor/{agent}/public/history.jsonl"); let mut file = match std::fs::File::open(&path) { Ok(f) => f, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return response(200, json!({"agent":agent,"lines":[],"before":null,"total":0})), Err(e) => return text_response(500, &format!("cannot open history: {e}")), }; let total = match file.metadata() { Ok(m) => m.len(), Err(e) => return text_response(500, &format!("cannot stat history: {e}")) }; let end = before.unwrap_or(total).min(total); let start = end.saturating_sub(HISTORY_CHUNK); if let Err(e) = file.seek(SeekFrom::Start(start)) { return text_response(500, &format!("cannot seek history: {e}")); } let mut bytes = Vec::new(); if let Err(e) = file.take(end - start).read_to_end(&mut bytes) { return text_response(500, &format!("cannot read history: {e}")); } let mut first = start; if start > 0 { if let Some(nl) = bytes.iter().position(|b| *b == b'\n') { bytes.drain(..=nl); first = start + nl as u64 + 1; } else { bytes.clear(); first = end; } } let lines: Vec = bytes.split(|b| *b == b'\n').filter(|line| !line.is_empty()) .filter_map(|line| serde_json::from_slice::(line).ok()).collect(); let older = if first > 0 { json!(first) } else { Value::Null }; response(200, json!({"agent":agent,"lines":lines,"before":older,"total":total})) } fn request_body(m: &Message) -> Result { if let Some(file) = m.body["body_file"].as_str() { let text = std::fs::read_to_string(file).map_err(|e| format!("cannot read request body: {e}"))?; serde_json::from_str(&text).map_err(|e| format!("invalid JSON body: {e}")) } else if let Some(body) = m.body.get("body") { match body { Value::String(s) => serde_json::from_str(s).map_err(|e| format!("invalid JSON body: {e}")), other => Ok(other.clone()) } } else { Err("request body is required".into()) } } fn http(m: &Message, pending_models: &mut BTreeMap) -> Option { let path = m.body["path"].as_str().unwrap_or("/"); let method = m.body["method"].as_str().unwrap_or("GET"); let path = path.trim_end_matches('/'); if path.is_empty() || path == "/" { return Some(html_response(&index_page())); } let sub = path.strip_prefix('/')?; let (agent, rest) = sub.split_once('/').unwrap_or((sub, "")); if !actor_exists(agent) { return Some(text_response(404, "unknown agent\n")); } match (method, rest) { ("GET", "") => { let page = PAGE.replace("__AGENT__", &escape(agent)).replace("__AGENT_JSON__", &serde_json::to_string(agent).unwrap()); Some(html_response(&page)) } ("GET", "api/history") => { let before = query_param(m.body["query"].as_str().unwrap_or(""), "before").and_then(|s| s.parse::().ok()); Some(history(agent, before)) } ("GET", "api/models") => { let id = send("shared", json!({"type":"list_models"})); pending_models.insert(id, m.clone()); None } ("POST", "send") => { let body = match request_body(m) { Ok(b) => b, Err(e) => return Some(text_response(400, &format!("{e}\n"))) }; let text = body["text"].as_str().unwrap_or("").trim(); if text.is_empty() { return Some(text_response(400, "text is required\n")); } let mut msg = json!({"type":"text","text":format!("[web chat] {text}")}); if let Some(model) = body.get("model").and_then(Value::as_str).filter(|m| !m.is_empty()) { msg["model"] = json!(model); } let id = send(agent, msg); Some(response(202, json!({"type":"sent","agent":agent,"id":id}))) } _ => Some(text_response(404, "not found\n")), } } const NAV: &str = r#" "#; const CSS: &str = r#"*{box-sizing:border-box}body{margin:0;font:15px/1.5 system-ui,-apple-system,sans-serif;background:#fffaf3;color:#2b2117}header{position:sticky;top:30px;background:#fffaf3;border-bottom:1px solid #eadfce;padding:.7rem 1rem;z-index:2}header p{margin:.2rem 0;color:#8a7d6e}main{padding:1rem;max-width:50rem;margin:auto}.agent{display:block;border:1px solid #eadfce;border-radius:8px;padding:.7rem .9rem;margin:.5rem 0;background:#fffdf9;color:#a0461d;text-decoration:none}@media(prefers-color-scheme:dark){body,header{background:#17140f;color:#ece3d6}.agent{background:#221d16;border-color:#2e2820}}"#; fn main() { let mut pending_models: BTreeMap = BTreeMap::new(); for message in messages() { if let Some(reply_to) = message.reply_to.as_ref() { if let Some(request) = pending_models.remove(reply_to) { let models = message.body["models"].clone(); let _ = reply(&request, response(200, json!({"models":models}))); } continue; } if message.kind() == "ping" { let _ = reply(&message, json!({"type":"pong"})); } else if message.kind() == "http_request" { if let Some(r) = http(&message, &mut pending_models) { let _ = reply(&message, r); } } } }