Files
sausage/actors/chat/src/main.rs
T
2026-10-16 14:37:16 +00:00

209 lines
10 KiB
Rust

//! Web chat UI for agent instances, served at /a/chat/<agent> through `web`.
//! GET / -> list agents; GET /<agent>/ -> chat; GET /<agent>/api/history?before=<offset> -> newest history chunk first-page / older chunks.
//! GET /<agent>/api/models -> shared model profiles; POST /<agent>/send with {text,model?} forwards [web chat] text to the agent.
//! The page uses EventSource /events/<agent> 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<String> {
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::<Value>(&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<String> = 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!("{} <small>· {}</small>", escape(&label), escape(&id)) };
items.push_str(&format!("<a class=agent href='/a/chat/{}/'>{}</a>", escape(&id), title));
}
format!("<!doctype html><html lang=en><head><meta charset=utf-8><meta name=viewport content='width=device-width,initial-scale=1'><title>Agent chat</title><style>{}</style></head><body>{}<header><b>💬 Agent chat</b><p>Choose an agent instance.</p></header><main>{}</main></body></html>", CSS, NAV, items)
}
fn escape(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;").replace('\'', "&#39;")
}
fn history(agent: &str, before: Option<u64>) -> 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<Value> = bytes.split(|b| *b == b'\n').filter(|line| !line.is_empty())
.filter_map(|line| serde_json::from_slice::<Value>(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<Value, String> {
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<String, Message>) -> Option<Value> {
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::<u64>().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#"<nav class="snav"><a href="/a/files/">📁 Files</a><a href="/a/wiki/">📓 Wiki</a><a href="/a/board/">📋 Boards</a><a href="/a/mesh/">🌭 Mesh</a></nav>
<style>.snav{position:sticky;top:0;z-index:20;display:flex;gap:2px;align-items:center;height:30px;padding:0 6px;background:#2b2117;font:13px/1 system-ui,-apple-system,sans-serif;overflow-x:auto}.snav a{color:#f3e7d8;padding:6px 10px;border-radius:4px;text-decoration:none;white-space:nowrap}.snav a:hover{background:#4a3a2c;text-decoration:none}.snav a.cur{background:#a0461d;color:#fff}</style>
<script>(()=>{const s=(location.pathname.split('/')[2]||'').split('-')[0];for(const a of document.querySelectorAll('.snav a'))if(a.getAttribute('href').split('/')[2]===s)a.classList.add('cur')})()</script>"#;
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<String, Message> = 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); }
}
}
}