agent ui: SPA shell — static index.html + app.js, /api/state JSON

This commit is contained in:
müde 2026-05-15 17:15:28 +02:00
parent 6fc9862c3c
commit 124fd97288
4 changed files with 395 additions and 211 deletions

View file

@ -1,7 +1,9 @@
//! Per-container HTTP UI. Phase 6 minimum — a status page on a host port.
//! Containers share the host's network namespace (privateNetwork = false), so
//! each instance must bind a distinct port. `HIVE_PORT` is set per agent by
//! `hive-c0re`'s generated per-agent flake (deterministic from agent name).
//! Per-container HTTP UI. SPA shape: `GET /` returns a static shell;
//! `GET /static/*` serves CSS + JS; `GET /api/state` returns the page
//! state as JSON; the JS app renders. Live events stream on
//! `/events/stream`. Action POSTs (`/send`, `/login/*`) return either a
//! 303 Redirect (for browsers that submit the form normally) or just
//! 200 OK — the JS app re-fetches `/api/state` afterwards.
use std::convert::Infallible;
use std::net::SocketAddr;
@ -12,13 +14,14 @@ use anyhow::{Context, Result};
use axum::{
Form, Router,
extract::State,
http::StatusCode,
response::{
Html, IntoResponse, Redirect, Response,
IntoResponse, Redirect, Response,
sse::{Event, KeepAlive, Sse},
},
routing::{get, post},
};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use tokio_stream::{Stream, StreamExt, wrappers::BroadcastStream};
use crate::client;
@ -66,7 +69,10 @@ pub async fn serve(
flavor,
};
let app = Router::new()
.route("/", get(index))
.route("/", get(serve_index))
.route("/static/agent.css", get(serve_css))
.route("/static/app.js", get(serve_app_js))
.route("/api/state", get(api_state))
.route("/events/stream", get(events_stream))
.route("/send", post(post_send))
.route("/login/start", post(post_login_start))
@ -82,100 +88,83 @@ pub async fn serve(
Ok(())
}
async fn index(State(state): State<AppState>) -> Html<String> {
// ---------------------------------------------------------------------------
// Static assets + state snapshot
// ---------------------------------------------------------------------------
async fn serve_index() -> impl IntoResponse {
(
[("content-type", "text/html; charset=utf-8")],
include_str!("../assets/index.html"),
)
}
async fn serve_css() -> impl IntoResponse {
(
[("content-type", "text/css")],
include_str!("../assets/agent.css"),
)
}
async fn serve_app_js() -> impl IntoResponse {
(
[("content-type", "application/javascript")],
include_str!("../assets/app.js"),
)
}
#[derive(Serialize)]
struct StateSnapshot {
label: String,
dashboard_port: u16,
/// `"online"` | `"needs_login_idle"` | `"needs_login_in_progress"`.
status: &'static str,
/// Present when `status == "needs_login_in_progress"`.
session: Option<SessionView>,
}
#[derive(Serialize)]
struct SessionView {
/// First `https://…` claude emitted on stdout, if any.
url: Option<String>,
/// Accumulated stdout + stderr.
output: String,
finished: bool,
exit_note: Option<String>,
}
async fn api_state(State(state): State<AppState>) -> axum::Json<StateSnapshot> {
drop_if_finished(&state.session);
let login = *state.login.lock().unwrap();
let session_snapshot = state.session.lock().unwrap().clone();
let body = match (login, session_snapshot) {
(LoginState::Online, _) => render_online(&state.label),
(LoginState::NeedsLogin, None) => render_needs_login_idle(),
(LoginState::NeedsLogin, Some(session)) => render_login_in_progress(&session),
let (status, session_view) = match (login, session_snapshot) {
(LoginState::Online, _) => ("online", None),
(LoginState::NeedsLogin, None) => ("needs_login_idle", None),
(LoginState::NeedsLogin, Some(s)) => (
"needs_login_in_progress",
Some(SessionView {
url: s.url(),
output: s.output(),
finished: s.finished(),
exit_note: s.exit_note(),
}),
),
};
let dashboard_port = std::env::var("HIVE_DASHBOARD_PORT")
.ok()
.and_then(|s| s.parse::<u16>().ok())
.unwrap_or(7000);
Html(format!(
"<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>{label} // hyperhive</title>\n{STYLE}\n</head>\n<body>\n<pre class=\"banner\">░▒▓█▓▒░ {label} ░▒▓█▓▒░ hyperhive ag3nt ░▒▓█▓▒░</pre>\n<h2>◆ {label} ◆ <a href=\"#\" id=\"rebuild-btn\" class=\"btn-rebuild\" data-port=\"{dashboard_port}\" data-label=\"{label}\">↻ R3BU1LD</a></h2>\n<div class=\"divider\">══════════════════════════════════════════════════════════════</div>\n{body}\n<script>\n(function() {{\n const b = document.getElementById('rebuild-btn');\n b.addEventListener('click', function(e) {{\n e.preventDefault();\n if (!confirm('rebuild ' + b.dataset.label + '? container will hot-reload.')) return;\n const url = window.location.protocol + '//' + window.location.hostname + ':' + b.dataset.port + '/rebuild/' + b.dataset.label;\n const form = document.createElement('form');\n form.method = 'POST';\n form.action = url;\n document.body.appendChild(form);\n form.submit();\n }});\n}})();\n</script>\n</body>\n</html>\n",
label = state.label,
))
axum::Json(StateSnapshot {
label: state.label.clone(),
dashboard_port,
status,
session: session_view,
})
}
fn render_online(label: &str) -> String {
format!(
"<p class=\"status-online\">▓█▓▒░ harness alive — turn loop running ▓█▓▒░</p>\n\
<form id=\"sendform\" class=\"sendform\">\n \
<input id=\"sendbody\" name=\"body\" placeholder=\"message {label} as operator…\" required autocomplete=\"off\">\n \
<button type=\"submit\" class=\"btn btn-send\">◆ S3ND</button>\n\
</form>\n\
<p class=\"meta\">enqueued with <code>from: operator</code> on this agent's inbox; the next turn picks it up.</p>\n\
<script>\n\
document.getElementById('sendform').addEventListener('submit', async function(e) {{\n \
e.preventDefault();\n \
const input = document.getElementById('sendbody');\n \
const body = input.value.trim();\n \
if (!body) return;\n \
const resp = await fetch('/send', {{\n \
method: 'POST',\n \
headers: {{ 'Content-Type': 'application/x-www-form-urlencoded' }},\n \
body: new URLSearchParams({{ body }}),\n \
redirect: 'manual',\n \
}});\n \
if (resp.type === 'opaqueredirect' || (resp.ok && resp.status < 400)) {{\n \
input.value = '';\n \
}} else {{\n \
alert('send failed: ' + resp.status);\n \
}}\n\
}});\n\
</script>\n\
{LIVE_PANEL}",
)
}
/// Live event tail rendered into every `/` response when the agent is online.
/// JS opens an `EventSource` on `/events/stream` and appends rows; no full-page
/// reload, so the login flow and other forms aren't clobbered.
const LIVE_PANEL: &str = concat!(
"<h3>live</h3>\n",
"<div id=\"live\" class=\"live\"><div class=\"meta\">connecting…</div></div>\n",
"<script>\n",
include_str!("../assets/live.js"),
"</script>",
);
fn render_needs_login_idle() -> String {
"<p class=\"status-needs-login\">▓█▓▒░ NEEDS L0G1N ▓█▓▒░</p>\n<p>No Claude session in <code>~/.claude/</code>. The harness is up but the turn loop is paused until you log in.</p>\n<form method=\"POST\" action=\"/login/start\">\n <button type=\"submit\" class=\"btn btn-login\">◆ ST4RT L0G1N</button>\n</form>\n<p class=\"meta\">Spawns <code>claude auth login</code> over plain stdio pipes. The OAuth URL will appear here when claude emits it; paste the resulting code back into the form below.</p>".into()
}
fn render_login_in_progress(session: &Arc<LoginSession>) -> String {
let url_block = match session.url() {
Some(url) => format!(
"<p>▶ <a href=\"{url}\" target=\"_blank\" rel=\"noreferrer\">{url}</a></p>\n<p class=\"meta\">open this URL in a browser, complete the OAuth flow, paste the resulting code below.</p>",
url = html_escape(&url),
),
None => "<p class=\"meta\">waiting for claude to emit an OAuth URL on stdout… (output below)</p>".into(),
};
let exit_badge = if session.finished() {
let note = session.exit_note().unwrap_or_else(|| "exited".into());
format!(
"<p class=\"status-needs-login\">claude process exited: {note}. Start over if needed.</p>",
note = html_escape(&note),
)
} else {
String::new()
};
let output = session.output();
let code_form = if session.finished() {
String::new()
} else {
"<form method=\"POST\" action=\"/login/code\" class=\"loginform\">\n <input name=\"code\" placeholder=\"paste OAuth code here\" required autocomplete=\"off\">\n <button type=\"submit\" class=\"btn btn-login\">◆ S3ND C0DE</button>\n</form>".into()
};
let cancel_form = "<form method=\"POST\" action=\"/login/cancel\" style=\"margin-top: 0.4em;\">\n <button type=\"submit\" class=\"btn btn-cancel\">cancel + kill</button>\n</form>".to_owned();
format!(
"<p class=\"status-needs-login\">▓█▓▒░ L0G1N 1N PR0GRESS ▓█▓▒░</p>\n{url_block}\n{code_form}\n{cancel_form}\n{exit_badge}\n<h3>output</h3>\n<pre class=\"diff\">{output}</pre>",
output = html_escape(&output),
)
}
// ---------------------------------------------------------------------------
// Action handlers
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
struct SendForm {
@ -278,21 +267,7 @@ async fn post_login_cancel(State(state): State<AppState>) -> Response {
}
fn error_response(message: &str) -> Response {
(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
Html(format!(
"<!doctype html>\n<html><head>{STYLE}</head><body><h2>error</h2><pre class=\"diff\">{msg}</pre><p><a href=\"/\">← back</a></p></body></html>",
msg = html_escape(message),
)),
)
.into_response()
// Plain text — JS app surfaces in `alert()`, HTML wrapping would just
// be noise.
(StatusCode::INTERNAL_SERVER_ERROR, message.to_owned()).into_response()
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
const STYLE: &str = concat!("<style>\n", include_str!("../assets/agent.css"), "</style>",);