@@ -1,8 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "chat"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2024"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
sausage-actor = "0.1"
|
|
||||||
serde_json = "1"
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
//! 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); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
||||||
<title>Chat with __AGENT__</title>
|
|
||||||
<style>
|
|
||||||
:root{color-scheme:light dark;--bg:#fffaf3;--fg:#2b2117;--muted:#8a7d6e;--line:#eadfce;--card:#fffdf9;--accent:#a0461d;--user:#f5ecdf;--agent:#fffdf9;--code:#fffdf9;--kw:#a0461d;--str:#3f7a2b;--num:#1f5fa8;--com:#9a8d7c;--ty:#7a3e9d;--fn:#8a5a00}
|
|
||||||
@media(prefers-color-scheme:dark){:root{--bg:#17140f;--fg:#ece3d6;--muted:#9a8f80;--line:#2e2820;--card:#120f0b;--accent:#f08a4b;--user:#30251a;--agent:#221d16;--code:#120f0b;--kw:#f08a4b;--str:#9fcf7a;--num:#79b8ff;--com:#7a705f;--ty:#d2a8ff;--fn:#e6c07b}}
|
|
||||||
*{box-sizing:border-box}html,body{height:100%;margin:0}body{font:15px/1.5 system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--fg);display:flex;flex-direction:column}a{color:var(--accent);text-decoration:none}
|
|
||||||
nav.snav{flex:none;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}nav.snav a{color:#f3e7d8;padding:6px 10px;border-radius:4px;white-space:nowrap}nav.snav a:hover{background:#4a3a2c}nav.snav a.cur{background:#a0461d;color:#fff}
|
|
||||||
header{flex:none;position:sticky;top:30px;z-index:2;background:var(--bg);border-bottom:1px solid var(--line);padding:.45rem .75rem;display:flex;align-items:center;gap:.7rem}header strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}header .state{font-size:12px;color:var(--muted);margin-left:auto}
|
|
||||||
#models{max-width:11rem;font:13px system-ui;padding:.25rem;border:1px solid var(--line);border-radius:6px;background:var(--card);color:var(--fg)}
|
|
||||||
.messages{flex:1;overflow:auto;padding:.7rem max(.7rem,calc((100vw - 54rem)/2));scroll-behavior:auto}.row{display:flex;flex-direction:column;max-width:min(100%,54rem);margin:.55rem auto}.row.user{align-items:flex-end}.row.assistant{align-items:flex-start}.bubble{max-width:min(92%,52rem);padding:.65rem .85rem;border:1px solid var(--line);border-radius:14px;background:var(--agent);overflow-wrap:anywhere}.user .bubble{background:var(--user);border-radius:14px 14px 4px 14px}.assistant .bubble{border-left:3px solid var(--accent);border-radius:14px 14px 14px 4px}.meta{font-size:11px;color:var(--muted);margin:0 .4rem .15rem}.user .meta{text-align:right}.tool,.thinking,.other{max-width:54rem;margin:.25rem auto;font-size:13px;color:var(--muted);border-bottom:1px solid var(--line);padding:.25rem 0}.tool summary,.thinking summary,.other summary{cursor:pointer}.tool pre,.thinking pre,.other pre{white-space:pre-wrap;overflow-wrap:anywhere;font:12px/1.45 ui-monospace,Menlo,monospace;color:var(--fg);margin:.4rem 0;padding:.4rem;background:var(--card);border-radius:5px}.system{font-size:12px;color:var(--muted);padding:.25rem 0}.toolbar{display:flex;gap:.5rem;justify-content:center;padding:.3rem}.toolbar button,button{font:inherit;color:var(--fg);background:var(--card);border:1px solid var(--line);border-radius:7px;padding:.35rem .65rem}button{cursor:pointer}button:disabled{opacity:.5;cursor:default}
|
|
||||||
/* Markdown and highlight.js share files-viewer palette and CDN builds. */
|
|
||||||
.md{max-width:100%;overflow-wrap:anywhere}.md>*:first-child{margin-top:0}.md>*:last-child{margin-bottom:0}.md h1,.md h2,.md h3,.md h4{line-height:1.25;margin:.9em 0 .4em}.md h1{font-size:1.45em}.md h2{font-size:1.25em}.md h3{font-size:1.1em}.md p,.md ul,.md ol,.md blockquote{margin:.5em 0}.md ul,.md ol{padding-left:1.5em}.md li+li{margin-top:.15em}.md blockquote{padding:.05rem .8rem;border-left:3px solid var(--line);color:var(--muted)}.md a{color:var(--accent);text-decoration:underline;text-underline-offset:2px}.md pre{margin:.6em 0;padding:.6rem .7rem;background:var(--code);border:1px solid var(--line);border-radius:7px;overflow:auto;font:13px/1.5 ui-monospace,Menlo,Consolas,monospace}.md code{font: .9em ui-monospace,Menlo,Consolas,monospace;background:var(--code);padding:.08em .3em;border-radius:4px}.md pre code{padding:0;background:none;font:inherit}.md table{display:block;border-collapse:collapse;max-width:100%;overflow-x:auto;font-size:.93em}.md th,.md td{padding:.25rem .5rem;border:1px solid var(--line);text-align:left}.md th{background:var(--user)}.md img{max-width:100%;height:auto;border-radius:5px}.md hr{border:0;border-top:1px solid var(--line)}
|
|
||||||
.hljs-keyword,.hljs-selector-tag,.hljs-meta .hljs-keyword,.hljs-doctag,.hljs-section{color:var(--kw)}.hljs-string,.hljs-regexp,.hljs-addition,.hljs-attribute,.hljs-meta .hljs-string{color:var(--str)}.hljs-number,.hljs-literal,.hljs-symbol,.hljs-bullet,.hljs-variable.constant_{color:var(--num)}.hljs-comment,.hljs-quote,.hljs-meta{color:var(--com);font-style:italic}.hljs-type,.hljs-built_in,.hljs-class .hljs-title,.hljs-title.class_,.hljs-tag,.hljs-name{color:var(--ty)}.hljs-title,.hljs-title.function_,.hljs-function .hljs-title,.hljs-attr,.hljs-selector-class,.hljs-selector-id,.hljs-property{color:var(--fn)}.hljs-deletion{color:#c33}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}
|
|
||||||
form{flex:none;position:sticky;bottom:0;display:flex;gap:.5rem;padding:.55rem max(.55rem,calc((100vw - 54rem)/2));padding-bottom:max(.55rem,env(safe-area-inset-bottom));background:var(--bg);border-top:1px solid var(--line)}textarea{font:inherit;line-height:1.4;resize:vertical;min-height:2.7rem;max-height:10rem;flex:1;border:1px solid var(--line);border-radius:8px;background:var(--card);color:var(--fg);padding:.55rem}form button{min-width:4rem;background:var(--accent);color:white;border-color:var(--accent)}#notice{min-height:1.2em;font-size:12px;color:var(--muted);padding:0 .8rem}@media(max-width:600px){header{gap:.35rem;padding:.4rem .55rem}#models{max-width:8.5rem}.messages{padding:.55rem}.bubble{padding:.45rem .6rem}}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<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>
|
|
||||||
<header><strong>💬 __AGENT__</strong><select id="models" aria-label="Model"><option value="">Agent default</option></select><span class="state" id="live">connecting…</span></header>
|
|
||||||
<div class="toolbar"><button id="older" type="button" hidden>Load older messages</button></div>
|
|
||||||
<main id="messages" class="messages" aria-live="polite"></main>
|
|
||||||
<div id="notice" role="status"></div>
|
|
||||||
<form id="send"><textarea id="text" placeholder="Message __AGENT__…" aria-label="Message"></textarea><button id="submit">Send</button></form>
|
|
||||||
<script>
|
|
||||||
'use strict';
|
|
||||||
const agent=__AGENT_JSON__, base='/a/chat/'+encodeURIComponent(agent), messages=document.querySelector('#messages'), seen=new Map();
|
|
||||||
let before=null,loading=false,hasOlder=false;
|
|
||||||
const txt=(v)=>v==null?'':typeof v==='string'?v:JSON.stringify(v,null,2);
|
|
||||||
const CDN='https://cdn.jsdelivr.net/', HLJS=CDN+'gh/highlightjs/cdn-release@11.9.0/build/';
|
|
||||||
const MARKED=CDN+'npm/marked@12.0.2/marked.min.js', DOMPURIFY=CDN+'npm/dompurify@3.1.6/dist/purify.min.js';
|
|
||||||
const scripts={};
|
|
||||||
function loadScript(src){return scripts[src] ||= new Promise((ok,bad)=>{const s=document.createElement('script');s.src=src;s.onload=ok;s.onerror=()=>{delete scripts[src];bad(new Error('cannot load '+src))};document.head.append(s)})}
|
|
||||||
const LANG={rs:'rust',toml:'ini',lock:'ini',ini:'ini',cfg:'ini',conf:'ini',json:'json',jsonl:'json',ndjson:'json',js:'javascript',mjs:'javascript',jsx:'javascript',ts:'typescript',tsx:'typescript',html:'xml',htm:'xml',xml:'xml',svg:'xml',css:'css',nix:'nix',md:'markdown',markdown:'markdown',sh:'bash',bash:'bash',zsh:'bash',py:'python',rb:'ruby',go:'go',c:'c',h:'c',cpp:'cpp',hpp:'cpp',cc:'cpp',java:'java',kt:'kotlin',swift:'swift',yaml:'yaml',yml:'yaml',sql:'sql',diff:'diff',patch:'diff',wat:'wasm',dockerfile:'dockerfile',makefile:'makefile'};
|
|
||||||
async function renderMarkdown(host,source){
|
|
||||||
host.classList.add('md');
|
|
||||||
try{await Promise.all([loadScript(MARKED),loadScript(DOMPURIFY)]);host.innerHTML=window.DOMPurify.sanitize(window.marked.parse(source,{gfm:true,breaks:true}),{USE_PROFILES:{html:true}})}
|
|
||||||
catch(e){host.textContent=source;host.style.whiteSpace='pre-wrap';document.querySelector('#notice').textContent='Markdown renderer unavailable; showing plain text: '+e.message;return}
|
|
||||||
for(const a of host.querySelectorAll('a[href]')){try{const u=new URL(a.href,location.href);if(u.protocol==='http:'||u.protocol==='https:'){a.target='_blank';a.rel='noopener noreferrer'}}catch(_){}}
|
|
||||||
const blocks=host.querySelectorAll('pre code');if(!blocks.length)return;
|
|
||||||
try{await loadScript(HLJS+'highlight.min.js')}catch(e){document.querySelector('#notice').textContent='Syntax highlighting unavailable: '+e.message;return}
|
|
||||||
for(const code of blocks){const m=/language-([\w-]+)/.exec(code.className||'');let lang=m&&((LANG[m[1]]||m[1]).toLowerCase());
|
|
||||||
if(lang&&!window.hljs.getLanguage(lang))await loadScript(HLJS+'languages/'+lang+'.min.js').catch(()=>{});
|
|
||||||
if(lang&&window.hljs.getLanguage(lang))code.className='language-'+lang;else code.className='';
|
|
||||||
window.hljs.highlightElement(code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function thinkingText(x){
|
|
||||||
const item=x.item;
|
|
||||||
if(item&&typeof item.thinking==='string'&&item.thinking.trim())return item.thinking;
|
|
||||||
if(item&&Array.isArray(item.summary)){
|
|
||||||
const summary=item.summary.map(part=>typeof part==='string'?part:part&&typeof part.text==='string'?part.text:'').filter(Boolean).join('\n');
|
|
||||||
if(summary.trim())return summary;
|
|
||||||
}
|
|
||||||
return txt(item||x.text||x);
|
|
||||||
}
|
|
||||||
function norm(line){return line&&line.entry?{...line.entry,seq:line.seq,ts:line.ts}:line||{}}
|
|
||||||
function el(tag,cls,text){const n=document.createElement(tag);if(cls)n.className=cls;if(text!=null)n.textContent=text;return n}
|
|
||||||
function show(line){const x=norm(line),kind=x.kind||'event',seq=x.seq;
|
|
||||||
if(seq!=null&&seen.has(String(seq)))return;if(seq!=null)seen.set(String(seq),x);else seen.set(JSON.stringify(x),x);
|
|
||||||
const row=el('section','row '+kind);if(seq!=null)row.dataset.seq=String(seq);if(x.ts){const date=new Date(Number(x.ts));row.append(el('div','meta',date.toLocaleString()))}
|
|
||||||
if(kind==='user'||kind==='assistant') {const source=txt(x.text||''),b=el('div','bubble');row.append(b);void renderMarkdown(b,source)}
|
|
||||||
else if(kind==='tool_call'){const d=el('details','tool'),s=el('summary','',`🔧 ${x.name||'tool'}`),p=el('pre','',x.arguments||'');d.append(s,p);row.append(d)}
|
|
||||||
else if(kind==='tool_result'){const d=el('details','tool'),s=el('summary','',`↳ ${x.name||'result'}`),p=el('pre','',x.output||'');d.append(s,p);row.append(d)}
|
|
||||||
else if(kind==='opaque'){const d=el('details','thinking'),s=el('summary','','💭 Thinking (collapsed)'),p=el('pre','',thinkingText(x));d.append(s,p);row.append(d)}
|
|
||||||
else if(kind==='error'){row.append(el('div','bubble',`⚠ ${x.text||'Agent error'}`))}
|
|
||||||
else if(kind==='compaction'){const d=el('details','other'),s=el('summary','','Conversation compacted'),p=el('pre','',x.summary||'');d.append(s,p);row.append(d)}
|
|
||||||
else if(kind==='turn_end'){row.append(el('div','system',`Turn finished${x.profile?' · '+x.profile:''}${x.model?' · '+x.model:''}`))}
|
|
||||||
else {const d=el('details','other'),s=el('summary','',kind),p=el('pre','',txt(x));d.append(s,p);row.append(d)}
|
|
||||||
// Keep the transcript chronological even when older chunks are inserted above newer messages.
|
|
||||||
const key=seq==null?null:Number(seq);let next=null;if(key!=null)for(const child of messages.children){const n=Number(child.dataset.seq);if(Number.isFinite(n)&&n>key){next=child;break}}
|
|
||||||
if(next)messages.insertBefore(row,next);else messages.append(row);
|
|
||||||
}
|
|
||||||
function addLines(lines){for(const line of lines||[])show(line)}
|
|
||||||
async function loadHistory(older=false){if(loading)return;loading=true;const btn=document.querySelector('#older');btn.disabled=true;
|
|
||||||
try{const url=base+'/api/history'+(older&&before!=null?'?before='+before:'');const r=await fetch(url);if(!r.ok)throw new Error(await r.text());const data=await r.json();
|
|
||||||
const oldHeight=messages.scrollHeight;addLines(data.lines);before=data.before;hasOlder=before!=null;btn.hidden=!hasOlder;
|
|
||||||
if(older)messages.scrollTop+=messages.scrollHeight-oldHeight;
|
|
||||||
}catch(e){document.querySelector('#notice').textContent='Could not load history: '+e.message}
|
|
||||||
finally{loading=false;btn.disabled=false}}
|
|
||||||
document.querySelector('#older').onclick=()=>loadHistory(true);
|
|
||||||
messages.addEventListener('scroll',()=>{if(messages.scrollTop<80&&hasOlder&&!loading)loadHistory(true)});
|
|
||||||
(async()=>{const es=new EventSource('/events/'+encodeURIComponent(agent));es.onopen=()=>document.querySelector('#live').textContent='live';es.onerror=()=>document.querySelector('#live').textContent='reconnecting…';es.onmessage=e=>{try{const event=JSON.parse(e.data);if(event.kind)show(event)}catch(err){document.querySelector('#notice').textContent='Live event could not be parsed: '+err.message}};
|
|
||||||
try{const r=await fetch(base+'/api/models');if(r.ok){const d=await r.json(),sel=document.querySelector('#models');for(const m of d.models||[]){const o=document.createElement('option');o.value=m.name;o.textContent=m.name+(m.description?' · '+m.description:'');sel.append(o)}}}catch(_){}await loadHistory();messages.scrollTop=messages.scrollHeight;
|
|
||||||
})();
|
|
||||||
document.querySelector('#send').addEventListener('submit',async e=>{e.preventDefault();const input=document.querySelector('#text'),text=input.value.trim();if(!text)return;const btn=document.querySelector('#submit');btn.disabled=true;document.querySelector('#notice').textContent='Sending…';
|
|
||||||
try{const body={text},model=document.querySelector('#models').value;if(model)body.model=model;const r=await fetch(base+'/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});if(!r.ok)throw new Error(await r.text());input.value='';document.querySelector('#notice').textContent='Sent';}
|
|
||||||
catch(err){document.querySelector('#notice').textContent='Send failed: '+err.message}
|
|
||||||
finally{btn.disabled=false;input.focus()}});
|
|
||||||
document.querySelector('#text').addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();document.querySelector('#send').requestSubmit()}});
|
|
||||||
</script>
|
|
||||||
</body></html>
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"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}}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user