From 01d828d42610780ea36a066765df74b4c3d2daa0 Mon Sep 17 00:00:00 2001 From: Sausage Agent Date: Fri, 16 Oct 2026 14:37:16 +0000 Subject: [PATCH] Add Markdown rendering to chat actor --- actors/chat/Cargo.toml | 8 ++ actors/chat/src/main.rs | 208 +++++++++++++++++++++++++++++++++++ actors/chat/src/page.html | 97 ++++++++++++++++ actors/chat/tests/basic.json | 10 ++ 4 files changed, 323 insertions(+) create mode 100644 actors/chat/Cargo.toml create mode 100644 actors/chat/src/main.rs create mode 100644 actors/chat/src/page.html create mode 100644 actors/chat/tests/basic.json diff --git a/actors/chat/Cargo.toml b/actors/chat/Cargo.toml new file mode 100644 index 0000000..90830de --- /dev/null +++ b/actors/chat/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "chat" +version = "0.1.0" +edition = "2024" + +[dependencies] +sausage-actor = "0.1" +serde_json = "1" diff --git a/actors/chat/src/main.rs b/actors/chat/src/main.rs new file mode 100644 index 0000000..ba10ad8 --- /dev/null +++ b/actors/chat/src/main.rs @@ -0,0 +1,208 @@ +//! 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); } + } + } +} diff --git a/actors/chat/src/page.html b/actors/chat/src/page.html new file mode 100644 index 0000000..7cc9dd2 --- /dev/null +++ b/actors/chat/src/page.html @@ -0,0 +1,97 @@ + + + + + +Chat with __AGENT__ + + + + + + +
💬 __AGENT__connecting…
+
+
+
+
+ + diff --git a/actors/chat/tests/basic.json b/actors/chat/tests/basic.json new file mode 100644 index 0000000..e4def7a --- /dev/null +++ b/actors/chat/tests/basic.json @@ -0,0 +1,10 @@ +{ + "description": "chat index, agent page, history paging and input validation", + "steps": [ + {"send":{"type":"ping"},"expect":{"type":"pong"}}, + {"send":{"type":"http_request","method":"GET","path":"/"},"expect":{"type":"http_response","status":200,"headers":{"Content-Type":"text/html; charset=utf-8"}}}, + {"send":{"type":"http_request","method":"GET","path":"/agent-test"},"expect":{"type":"http_response","status":200,"headers":{"Content-Type":"text/html; charset=utf-8"}}}, + {"send":{"type":"http_request","method":"GET","path":"/agent-test/api/history"},"expect":{"type":"http_response","status":200,"body":{"agent":"agent-test"}}}, + {"send":{"type":"http_request","method":"POST","path":"/agent-test/send","body":{"text":" "}},"expect":{"type":"http_response","status":400}} + ] +}