209 lines
10 KiB
Rust
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('&', "&").replace('<', "<").replace('>', ">").replace('"', """).replace('\'', "'")
|
|
}
|
|
|
|
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); }
|
|
}
|
|
}
|
|
}
|