feat(operator): mobile hack-house console — terminal + keys + chat
Rebuild the web UI into a room mirror that looks/feels like the TUI: tabbed Terminal + Chat, a live shared-sandbox terminal pane (polls /api/screen, client-side ANSI/SGR render), keystroke drive with quick keys + a command line (POST /api/keys), roster and a driver/watching badge. Read-only until the room /grants this operator. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+265
-112
@@ -1,22 +1,24 @@
|
|||||||
"""Mobile web chat front-end for the operator bridge (`operator web`).
|
"""Mobile hack-house console for the operator bridge (`operator web`).
|
||||||
|
|
||||||
Turns a running operator daemon into a phone-friendly chat page. The daemon
|
A phone-friendly browser mirror of a live hack-house room. The daemon already
|
||||||
already exposes the room as a local API over its AF_UNIX control socket
|
exposes the room as a local API over its AF_UNIX control socket; this module is
|
||||||
(`read` with long-poll, `say`, `roster`, `status`); this module is a thin,
|
a thin, **stdlib-only** HTTP shim in front of it so a browser gets the same
|
||||||
**stdlib-only** HTTP shim in front of that socket so a browser can drive it:
|
things the Rust TUI shows — the shared sandbox terminal, chat, roster — mobile
|
||||||
|
optimised:
|
||||||
|
|
||||||
GET / the single-page chat UI (self-contained HTML/JS)
|
GET / the single-page console (self-contained HTML/JS)
|
||||||
GET /api/events?since=N long-poll: proxies {"op":"read","wait":true}
|
GET /api/status proxies {"op":"status"} (connection/roster/driver)
|
||||||
POST /api/say {text} proxies {"op":"say"}
|
GET /api/events?since=N long-poll: proxies {"op":"read","wait":true}
|
||||||
GET /api/status proxies {"op":"status"} (roster + connection)
|
POST /api/say {text} proxies {"op":"say"}
|
||||||
|
GET /api/screen?ansi=1 proxies {"op":"screen"} — the relayed shared PTY
|
||||||
|
POST /api/keys {tokens} proxies {"op":"keys"} — drive the shared PTY
|
||||||
|
|
||||||
Why this exists: on Termux the tmux `read --wait` loop is read-only — replying
|
Why this exists: on Termux the tmux `read --wait` loop is read-only and shows
|
||||||
means a second shell typing `operator say …`, which is painful on a touch
|
none of the sandbox. A browser page gives the terminal view + a real input box
|
||||||
keyboard. A browser page gives a real input box, scrollback and live updates
|
+ live updates with zero extra dependencies (no server stack, no JS build).
|
||||||
with zero extra dependencies (no server stack, no JS build — just http.server).
|
|
||||||
|
|
||||||
Binds 127.0.0.1 by default: the control socket grants room-send rights, so the
|
Binds 127.0.0.1 by default: the control socket grants room-send/drive rights,
|
||||||
listener stays loopback-only unless you deliberately pass a routable --bind.
|
so the listener stays loopback-only unless you pass a routable --bind.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -41,130 +43,264 @@ _PAGE = """<!doctype html>
|
|||||||
<title>hack-house · __SESSION__</title>
|
<title>hack-house · __SESSION__</title>
|
||||||
<style>
|
<style>
|
||||||
:root { color-scheme: dark; }
|
:root { color-scheme: dark; }
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||||
html, body { height: 100%; margin: 0; }
|
html, body { height: 100%; margin: 0; }
|
||||||
body {
|
body {
|
||||||
font: 15px/1.4 -apple-system, system-ui, "Segoe UI", Roboto, sans-serif;
|
font: 14px/1.4 -apple-system, system-ui, "Segoe UI", Roboto, sans-serif;
|
||||||
background: #0d1117; color: #e6edf3;
|
background: #0b0e14; color: #cdd6f4; display: flex; flex-direction: column;
|
||||||
display: flex; flex-direction: column;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
header {
|
header {
|
||||||
padding: 10px 14px; background: #161b22; border-bottom: 1px solid #30363d;
|
padding: 8px 12px; background: #11151f; border-bottom: 1px solid #1f2430;
|
||||||
display: flex; align-items: center; gap: 10px; flex: none;
|
display: flex; align-items: center; gap: 8px; flex: none;
|
||||||
}
|
}
|
||||||
header .dot { width: 10px; height: 10px; border-radius: 50%; background: #f85149; flex: none; }
|
header .dot { width: 9px; height: 9px; border-radius: 50%; background: #f38ba8; flex: none; }
|
||||||
header .dot.on { background: #3fb950; }
|
header .dot.on { background: #a6e3a1; box-shadow: 0 0 6px #a6e3a1; }
|
||||||
header .name { font-weight: 600; }
|
header .name { font-weight: 700; letter-spacing: .3px; }
|
||||||
header .roster { color: #8b949e; font-size: 13px; margin-left: auto;
|
header .badge { font-size: 11px; padding: 1px 7px; border-radius: 10px;
|
||||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
background: #313244; color: #9399b2; }
|
||||||
#log { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; }
|
header .badge.drive { background: #1e3a2a; color: #a6e3a1; }
|
||||||
.msg { max-width: 82%; padding: 8px 11px; border-radius: 14px; word-wrap: break-word; white-space: pre-wrap; }
|
header .roster { color: #7f849c; font-size: 12px; margin-left: auto;
|
||||||
.msg .who { font-size: 11px; color: #8b949e; margin-bottom: 2px; }
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 45%; }
|
||||||
.msg.them { background: #21262d; align-self: flex-start; border-bottom-left-radius: 4px; }
|
.tabs { display: flex; flex: none; background: #11151f; border-bottom: 1px solid #1f2430; }
|
||||||
.msg.me { background: #1f6feb; align-self: flex-end; border-bottom-right-radius: 4px; }
|
.tabs button { flex: 1; background: none; border: 0; color: #7f849c; padding: 9px;
|
||||||
.msg.addr { box-shadow: 0 0 0 2px #d29922 inset; }
|
font: inherit; font-weight: 600; border-bottom: 2px solid transparent; }
|
||||||
.sys { align-self: center; color: #8b949e; font-size: 12px; font-style: italic; }
|
.tabs button.active { color: #89b4fa; border-bottom-color: #89b4fa; }
|
||||||
footer { flex: none; display: flex; gap: 8px; padding: 10px; background: #161b22; border-top: 1px solid #30363d; }
|
.pane { flex: 1; min-height: 0; display: none; flex-direction: column; }
|
||||||
#box { flex: 1; resize: none; background: #0d1117; color: #e6edf3; border: 1px solid #30363d;
|
.pane.active { display: flex; }
|
||||||
border-radius: 10px; padding: 10px; font: inherit; max-height: 120px; }
|
|
||||||
#send { flex: none; background: #238636; color: #fff; border: 0; border-radius: 10px;
|
/* terminal */
|
||||||
padding: 0 18px; font: inherit; font-weight: 600; }
|
#term { flex: 1; margin: 0; padding: 10px; overflow: auto; white-space: pre;
|
||||||
|
font: 12px/1.35 "SF Mono", ui-monospace, Menlo, Consolas, monospace;
|
||||||
|
background: #05070c; color: #b9c0d4; }
|
||||||
|
#term .empty { color: #565a70; font-style: italic; }
|
||||||
|
.keys { flex: none; background: #11151f; border-top: 1px solid #1f2430; padding: 6px; }
|
||||||
|
.quick { display: flex; gap: 5px; overflow-x: auto; padding-bottom: 6px; }
|
||||||
|
.quick button { flex: none; background: #1f2430; color: #cdd6f4; border: 1px solid #313244;
|
||||||
|
border-radius: 7px; padding: 6px 9px; font: 12px monospace; }
|
||||||
|
.quick button:active { background: #313244; }
|
||||||
|
.keyrow { display: flex; gap: 6px; }
|
||||||
|
#kbox { flex: 1; background: #05070c; color: #b9c0d4; border: 1px solid #313244;
|
||||||
|
border-radius: 8px; padding: 8px; font: 13px monospace; }
|
||||||
|
#kbox:disabled { opacity: .5; }
|
||||||
|
#ksend { flex: none; background: #89b4fa; color: #0b0e14; border: 0; border-radius: 8px;
|
||||||
|
padding: 0 14px; font: inherit; font-weight: 700; }
|
||||||
|
#ksend:disabled { opacity: .4; }
|
||||||
|
.hint { color: #f9e2af; font-size: 11px; padding: 4px 2px 0; }
|
||||||
|
|
||||||
|
/* chat */
|
||||||
|
#log { flex: 1; overflow-y: auto; padding: 10px; display: flex; flex-direction: column; gap: 7px; }
|
||||||
|
.msg { max-width: 82%; padding: 7px 10px; border-radius: 13px; word-wrap: break-word; white-space: pre-wrap; }
|
||||||
|
.msg .who { font-size: 10px; color: #7f849c; margin-bottom: 2px; }
|
||||||
|
.msg.them { background: #1f2430; align-self: flex-start; border-bottom-left-radius: 4px; }
|
||||||
|
.msg.me { background: #3a4a7a; align-self: flex-end; border-bottom-right-radius: 4px; }
|
||||||
|
.msg.addr { box-shadow: 0 0 0 2px #f9e2af inset; }
|
||||||
|
.sys { align-self: center; color: #7f849c; font-size: 11px; font-style: italic; }
|
||||||
|
footer { flex: none; display: flex; gap: 7px; padding: 8px; background: #11151f; border-top: 1px solid #1f2430; }
|
||||||
|
#box { flex: 1; resize: none; background: #05070c; color: #cdd6f4; border: 1px solid #313244;
|
||||||
|
border-radius: 9px; padding: 9px; font: inherit; max-height: 110px; }
|
||||||
|
#send { flex: none; background: #a6e3a1; color: #0b0e14; border: 0; border-radius: 9px;
|
||||||
|
padding: 0 16px; font: inherit; font-weight: 700; }
|
||||||
#send:disabled { opacity: .5; }
|
#send:disabled { opacity: .5; }
|
||||||
|
/* ANSI palette */
|
||||||
|
.a30{color:#45475a}.a31{color:#f38ba8}.a32{color:#a6e3a1}.a33{color:#f9e2af}
|
||||||
|
.a34{color:#89b4fa}.a35{color:#f5c2e7}.a36{color:#94e2d5}.a37{color:#bac2de}
|
||||||
|
.a90{color:#585b70}.a91{color:#f38ba8}.a92{color:#a6e3a1}.a93{color:#f9e2af}
|
||||||
|
.a94{color:#89b4fa}.a95{color:#f5c2e7}.a96{color:#94e2d5}.a97{color:#e6edf3}
|
||||||
|
.ab{font-weight:700}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<span class="dot" id="dot"></span>
|
<span class="dot" id="dot"></span>
|
||||||
<span class="name">__SESSION__</span>
|
<span class="name">__SESSION__</span>
|
||||||
|
<span class="badge" id="drive">watching</span>
|
||||||
<span class="roster" id="roster">—</span>
|
<span class="roster" id="roster">—</span>
|
||||||
</header>
|
</header>
|
||||||
<div id="log"></div>
|
<div class="tabs">
|
||||||
<footer>
|
<button id="tabTerm" class="active">Terminal</button>
|
||||||
<textarea id="box" rows="1" placeholder="Message the room…" autocomplete="off"></textarea>
|
<button id="tabChat">Chat</button>
|
||||||
<button id="send">Send</button>
|
</div>
|
||||||
</footer>
|
|
||||||
|
<div class="pane active" id="paneTerm">
|
||||||
|
<pre id="term"><span class="empty">no shared sandbox yet — a room member runs /sbx and /grants this operator to drive it here.</span></pre>
|
||||||
|
<div class="keys">
|
||||||
|
<div class="quick">
|
||||||
|
<button data-k="enter">⏎ Enter</button>
|
||||||
|
<button data-k="ctrl-c">^C</button>
|
||||||
|
<button data-k="esc">Esc</button>
|
||||||
|
<button data-k="tab">Tab</button>
|
||||||
|
<button data-k="up">↑</button>
|
||||||
|
<button data-k="down">↓</button>
|
||||||
|
<button data-k="ctrl-l">clear</button>
|
||||||
|
<button data-k="ctrl-d">^D</button>
|
||||||
|
</div>
|
||||||
|
<div class="keyrow">
|
||||||
|
<input id="kbox" placeholder="type a command → Send runs it (adds Enter)" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||||
|
<button id="ksend">Send</button>
|
||||||
|
</div>
|
||||||
|
<div class="hint" id="khint"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pane" id="paneChat">
|
||||||
|
<div id="log"></div>
|
||||||
|
<footer>
|
||||||
|
<textarea id="box" rows="1" placeholder="Message the room…" autocomplete="off"></textarea>
|
||||||
|
<button id="send">Send</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const ME = "__SESSION__";
|
const ME = "__SESSION__";
|
||||||
const log = document.getElementById("log");
|
const $ = id => document.getElementById(id);
|
||||||
const box = document.getElementById("box");
|
let since = 0, granted = false, active = "term", termSeq = -1;
|
||||||
const send = document.getElementById("send");
|
|
||||||
const dot = document.getElementById("dot");
|
|
||||||
const rosterEl = document.getElementById("roster");
|
|
||||||
let since = 0;
|
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
|
|
||||||
function atBottom() { return log.scrollHeight - log.scrollTop - log.clientHeight < 60; }
|
/* ── tabs ── */
|
||||||
function scroll() { log.scrollTop = log.scrollHeight; }
|
function show(tab) {
|
||||||
|
active = tab;
|
||||||
|
$("tabTerm").classList.toggle("active", tab === "term");
|
||||||
|
$("tabChat").classList.toggle("active", tab === "chat");
|
||||||
|
$("paneTerm").classList.toggle("active", tab === "term");
|
||||||
|
$("paneChat").classList.toggle("active", tab === "chat");
|
||||||
|
}
|
||||||
|
$("tabTerm").onclick = () => show("term");
|
||||||
|
$("tabChat").onclick = () => show("chat");
|
||||||
|
|
||||||
|
/* ── ANSI (SGR) → HTML for the terminal pane ── */
|
||||||
|
function esc(s){return s.replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c]));}
|
||||||
|
function ansiToHtml(raw) {
|
||||||
|
let out = "", open = false, cls = [];
|
||||||
|
const re = /\\x1b\\[([0-9;]*)m|\\x1b\\[[0-9;?]*[A-Za-z]|\\x1b\\][^\\x07]*\\x07/g;
|
||||||
|
let last = 0, m;
|
||||||
|
const flush = t => { if (!t) return; out += esc(t); };
|
||||||
|
while ((m = re.exec(raw)) !== null) {
|
||||||
|
flush(raw.slice(last, m.index)); last = re.lastIndex;
|
||||||
|
if (m[1] === undefined) continue; // non-SGR CSI / OSC → drop
|
||||||
|
const codes = m[1] === "" ? [0] : m[1].split(";").map(Number);
|
||||||
|
if (open) { out += "</span>"; open = false; }
|
||||||
|
cls = [];
|
||||||
|
for (const c of codes) {
|
||||||
|
if (c === 0) cls = [];
|
||||||
|
else if (c === 1) cls.push("ab");
|
||||||
|
else if ((c>=30&&c<=37)||(c>=90&&c<=97)) cls.push("a"+c);
|
||||||
|
}
|
||||||
|
if (cls.length) { out += '<span class="'+cls.join(" ")+'">'; open = true; }
|
||||||
|
}
|
||||||
|
flush(raw.slice(last));
|
||||||
|
if (open) out += "</span>";
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── terminal poll ── */
|
||||||
|
async function pollScreen() {
|
||||||
|
for (;;) {
|
||||||
|
try {
|
||||||
|
if (active === "term") {
|
||||||
|
const r = await fetch("/api/screen?ansi=1");
|
||||||
|
const d = await r.json();
|
||||||
|
if (d.ok && d.seq !== termSeq) {
|
||||||
|
termSeq = d.seq;
|
||||||
|
const t = $("term");
|
||||||
|
const bottom = t.scrollHeight - t.scrollTop - t.clientHeight < 40;
|
||||||
|
if (d.text && d.text.length) t.innerHTML = ansiToHtml(d.text);
|
||||||
|
if (bottom) t.scrollTop = t.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
await new Promise(r => setTimeout(r, active === "term" ? 900 : 2000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── keys ── */
|
||||||
|
async function sendKeys(tokens) {
|
||||||
|
if (!granted) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/keys", {method:"POST", headers:{"Content-Type":"application/json"},
|
||||||
|
body: JSON.stringify({tokens})});
|
||||||
|
const j = await r.json();
|
||||||
|
if (!j.ok) $("khint").textContent = "keys: " + (j.error || "?");
|
||||||
|
} catch (e) { $("khint").textContent = "keys: offline"; }
|
||||||
|
}
|
||||||
|
document.querySelectorAll(".quick button").forEach(b =>
|
||||||
|
b.onclick = () => sendKeys([b.dataset.k]));
|
||||||
|
function runLine() {
|
||||||
|
const v = $("kbox").value;
|
||||||
|
if (!granted) return;
|
||||||
|
sendKeys(v ? ["text:" + v, "enter"] : ["enter"]);
|
||||||
|
$("kbox").value = "";
|
||||||
|
}
|
||||||
|
$("ksend").onclick = runLine;
|
||||||
|
$("kbox").addEventListener("keydown", e => { if (e.key === "Enter") { e.preventDefault(); runLine(); } });
|
||||||
|
|
||||||
|
function setGrant(g) {
|
||||||
|
granted = g;
|
||||||
|
$("drive").textContent = g ? "driver" : "watching";
|
||||||
|
$("drive").classList.toggle("drive", g);
|
||||||
|
$("kbox").disabled = !g; $("ksend").disabled = !g;
|
||||||
|
document.querySelectorAll(".quick button").forEach(b => b.disabled = !g);
|
||||||
|
$("khint").textContent = g ? "" : "read-only — a room owner must /grant " + ME + " to drive the sandbox";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── chat ── */
|
||||||
|
const log = $("log"), box = $("box"), send = $("send"), dot = $("dot"), rosterEl = $("roster");
|
||||||
|
function atBottom(){ return log.scrollHeight - log.scrollTop - log.clientHeight < 60; }
|
||||||
function addMsg(from, text, mine, addressed) {
|
function addMsg(from, text, mine, addressed) {
|
||||||
const el = document.createElement("div");
|
const el = document.createElement("div");
|
||||||
el.className = "msg " + (mine ? "me" : "them") + (addressed ? " addr" : "");
|
el.className = "msg " + (mine ? "me" : "them") + (addressed ? " addr" : "");
|
||||||
if (!mine) { const w = document.createElement("div"); w.className = "who"; w.textContent = from; el.appendChild(w); }
|
if (!mine){ const w=document.createElement("div"); w.className="who"; w.textContent=from; el.appendChild(w); }
|
||||||
el.appendChild(document.createTextNode(text));
|
el.appendChild(document.createTextNode(text));
|
||||||
const stick = atBottom();
|
const stick = atBottom(); log.appendChild(el); if (stick||mine) log.scrollTop = log.scrollHeight;
|
||||||
log.appendChild(el);
|
|
||||||
if (stick || mine) scroll();
|
|
||||||
}
|
|
||||||
function addSys(text) {
|
|
||||||
const el = document.createElement("div");
|
|
||||||
el.className = "sys"; el.textContent = text;
|
|
||||||
const stick = atBottom();
|
|
||||||
log.appendChild(el); if (stick) scroll();
|
|
||||||
}
|
}
|
||||||
|
function addSys(text){ const el=document.createElement("div"); el.className="sys"; el.textContent=text;
|
||||||
|
const stick=atBottom(); log.appendChild(el); if(stick) log.scrollTop=log.scrollHeight; }
|
||||||
|
|
||||||
|
function setRoster(u){ rosterEl.textContent = (u||[]).length + " · " + (u||[]).join(", "); }
|
||||||
|
|
||||||
function render(ev) {
|
function render(ev) {
|
||||||
if (seen.has(ev.seq)) return; seen.add(ev.seq);
|
if (seen.has(ev.seq)) return; seen.add(ev.seq);
|
||||||
if (ev.kind === "message") addMsg(ev.from, ev.text, false, ev.addressed);
|
if (ev.kind === "message") addMsg(ev.from, ev.text, false, ev.addressed);
|
||||||
else if (ev.kind === "sent") addMsg(ev.from, ev.text, true, false);
|
else if (ev.kind === "sent") addMsg(ev.from, ev.text, true, false);
|
||||||
else if (ev.kind === "roster") {
|
else if (ev.kind === "roster") setRoster(ev.users);
|
||||||
const u = (ev.users || []); rosterEl.textContent = u.length + " · " + u.join(", ");
|
else if (ev.kind === "system") {
|
||||||
} else if (ev.kind === "system") {
|
if (ev.event === "connected") dot.classList.add("on");
|
||||||
const on = ev.event === "connected"; dot.classList.toggle("on", on);
|
if (ev.event === "reconnecting") dot.classList.remove("on");
|
||||||
addSys("· " + (ev.event || "") + (ev.reason ? " (" + ev.reason + ")" : "") + " ·");
|
addSys("· " + (ev.event||"") + (ev.reason ? " ("+ev.reason+")" : "") + " ·");
|
||||||
} else if (ev.kind === "sandbox") addSys("· sandbox " + (ev.state || "") + " ·");
|
} else if (ev.kind === "acl") { setGrant(!!ev.granted); addSys("· drive "+(ev.granted?"granted":"revoked")+" ·"); }
|
||||||
else if (ev.kind === "acl") addSys("· drive " + (ev.granted ? "granted" : "revoked") + " ·");
|
else if (ev.kind === "sandbox") addSys("· sandbox " + (ev.state||"") + " ·");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshStatus() {
|
async function refreshStatus() {
|
||||||
try {
|
try { const s = await (await fetch("/api/status")).json();
|
||||||
const r = await fetch("/api/status"); const s = await r.json();
|
|
||||||
dot.classList.toggle("on", !!s.connected);
|
dot.classList.toggle("on", !!s.connected);
|
||||||
if (s.users) rosterEl.textContent = s.users.length + " · " + s.users.join(", ");
|
if (s.users) setRoster(s.users);
|
||||||
|
setGrant(!!s.granted);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
async function pollEvents() {
|
||||||
async function poll() {
|
|
||||||
for (;;) {
|
for (;;) {
|
||||||
try {
|
try {
|
||||||
const r = await fetch("/api/events?since=" + since);
|
const d = await (await fetch("/api/events?since=" + since)).json();
|
||||||
const data = await r.json();
|
for (const ev of (d.events || [])) { since = Math.max(since, ev.seq); render(ev); }
|
||||||
for (const ev of (data.events || [])) { since = Math.max(since, ev.seq); render(ev); }
|
} catch (e) { dot.classList.remove("on"); await new Promise(r=>setTimeout(r,2000)); }
|
||||||
} catch (e) {
|
|
||||||
dot.classList.remove("on");
|
|
||||||
await new Promise(res => setTimeout(res, 2000));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doSend() {
|
async function doSend() {
|
||||||
const text = box.value.trim();
|
const text = box.value.trim(); if (!text) return;
|
||||||
if (!text) return;
|
|
||||||
send.disabled = true;
|
send.disabled = true;
|
||||||
try {
|
try {
|
||||||
const r = await fetch("/api/say", {method: "POST", headers: {"Content-Type": "application/json"},
|
const j = await (await fetch("/api/say",{method:"POST",headers:{"Content-Type":"application/json"},
|
||||||
body: JSON.stringify({text})});
|
body:JSON.stringify({text})})).json();
|
||||||
const j = await r.json();
|
if (j.ok){ box.value=""; box.style.height="auto"; } else addSys("· send failed: "+(j.error||"?")+" ·");
|
||||||
if (j.ok) { box.value = ""; box.style.height = "auto"; }
|
} catch(e){ addSys("· send failed (offline) ·"); }
|
||||||
else addSys("· send failed: " + (j.error || "?") + " ·");
|
|
||||||
} catch (e) { addSys("· send failed (offline) ·"); }
|
|
||||||
send.disabled = false; box.focus();
|
send.disabled = false; box.focus();
|
||||||
}
|
}
|
||||||
|
send.onclick = doSend;
|
||||||
|
box.addEventListener("keydown", e => { if (e.key==="Enter" && !e.shiftKey){ e.preventDefault(); doSend(); } });
|
||||||
|
box.addEventListener("input", () => { box.style.height="auto"; box.style.height=Math.min(box.scrollHeight,110)+"px"; });
|
||||||
|
|
||||||
send.addEventListener("click", doSend);
|
refreshStatus(); pollEvents(); pollScreen();
|
||||||
box.addEventListener("keydown", e => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); doSend(); } });
|
|
||||||
box.addEventListener("input", () => { box.style.height = "auto"; box.style.height = Math.min(box.scrollHeight, 120) + "px"; });
|
|
||||||
|
|
||||||
refreshStatus(); poll();
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -175,7 +311,6 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
session_name = "default" # set on the class before serving
|
session_name = "default" # set on the class before serving
|
||||||
protocol_version = "HTTP/1.1"
|
protocol_version = "HTTP/1.1"
|
||||||
|
|
||||||
# ── plumbing ─────────────────────────────────────────────────────────
|
|
||||||
def log_message(self, *a): # silence per-request stderr spam
|
def log_message(self, *a): # silence per-request stderr spam
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -199,7 +334,7 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
# ── routes ───────────────────────────────────────────────────────────
|
# ── routes ───────────────────────────────────────────────────────────
|
||||||
def do_GET(self) -> None:
|
def do_GET(self) -> None:
|
||||||
url = urlparse(self.path)
|
url = urlparse(self.path)
|
||||||
if url.path == "/" or url.path == "/index.html":
|
if url.path in ("/", "/index.html"):
|
||||||
page = _PAGE.replace("__SESSION__", resolve(self.session_name).name)
|
page = _PAGE.replace("__SESSION__", resolve(self.session_name).name)
|
||||||
body = page.encode()
|
body = page.encode()
|
||||||
self.send_response(200)
|
self.send_response(200)
|
||||||
@@ -211,32 +346,50 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
if url.path == "/api/status":
|
if url.path == "/api/status":
|
||||||
self._send_json(self._bridge({"op": "status"}, read_timeout=8))
|
self._send_json(self._bridge({"op": "status"}, read_timeout=8))
|
||||||
return
|
return
|
||||||
|
if url.path == "/api/screen":
|
||||||
|
q = parse_qs(url.query)
|
||||||
|
ansi = (q.get("ansi") or ["0"])[0] not in ("0", "", "false")
|
||||||
|
tail = q.get("tail")
|
||||||
|
req = {"op": "screen", "ansi": ansi}
|
||||||
|
if tail:
|
||||||
|
req["tail"] = int(tail[0])
|
||||||
|
self._send_json(self._bridge(req, read_timeout=8))
|
||||||
|
return
|
||||||
if url.path == "/api/events":
|
if url.path == "/api/events":
|
||||||
q = parse_qs(url.query)
|
q = parse_qs(url.query)
|
||||||
since = int((q.get("since") or ["0"])[0])
|
since = int((q.get("since") or ["0"])[0])
|
||||||
resp = self._bridge(
|
self._send_json(self._bridge(
|
||||||
{"op": "read", "since": since, "wait": True, "timeout": _POLL_TIMEOUT},
|
{"op": "read", "since": since, "wait": True, "timeout": _POLL_TIMEOUT},
|
||||||
read_timeout=_POLL_TIMEOUT + 5.0)
|
read_timeout=_POLL_TIMEOUT + 5.0))
|
||||||
self._send_json(resp)
|
|
||||||
return
|
return
|
||||||
self._send_json({"ok": False, "error": "not found"}, code=404)
|
self._send_json({"ok": False, "error": "not found"}, code=404)
|
||||||
|
|
||||||
def do_POST(self) -> None:
|
def _read_body(self) -> dict:
|
||||||
url = urlparse(self.path)
|
|
||||||
if url.path != "/api/say":
|
|
||||||
self._send_json({"ok": False, "error": "not found"}, code=404)
|
|
||||||
return
|
|
||||||
length = int(self.headers.get("Content-Length") or 0)
|
length = int(self.headers.get("Content-Length") or 0)
|
||||||
raw = self.rfile.read(length) if length else b"{}"
|
raw = self.rfile.read(length) if length else b"{}"
|
||||||
try:
|
try:
|
||||||
text = str(json.loads(raw.decode()).get("text", "")).strip()
|
obj = json.loads(raw.decode())
|
||||||
except (ValueError, AttributeError):
|
return obj if isinstance(obj, dict) else {}
|
||||||
self._send_json({"ok": False, "error": "bad json"}, code=400)
|
except ValueError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def do_POST(self) -> None:
|
||||||
|
url = urlparse(self.path)
|
||||||
|
if url.path == "/api/say":
|
||||||
|
text = str(self._read_body().get("text", "")).strip()
|
||||||
|
if not text:
|
||||||
|
self._send_json({"ok": False, "error": "empty"}, code=400)
|
||||||
|
return
|
||||||
|
self._send_json(self._bridge({"op": "say", "text": text}, read_timeout=15))
|
||||||
return
|
return
|
||||||
if not text:
|
if url.path == "/api/keys":
|
||||||
self._send_json({"ok": False, "error": "empty"}, code=400)
|
tokens = self._read_body().get("tokens")
|
||||||
|
if not isinstance(tokens, list) or not tokens:
|
||||||
|
self._send_json({"ok": False, "error": "no tokens"}, code=400)
|
||||||
|
return
|
||||||
|
self._send_json(self._bridge({"op": "keys", "keys": tokens}, read_timeout=10))
|
||||||
return
|
return
|
||||||
self._send_json(self._bridge({"op": "say", "text": text}, read_timeout=15))
|
self._send_json({"ok": False, "error": "not found"}, code=404)
|
||||||
|
|
||||||
|
|
||||||
def serve(session_name: str, bind: str, port: int) -> int:
|
def serve(session_name: str, bind: str, port: int) -> int:
|
||||||
@@ -249,11 +402,11 @@ def serve(session_name: str, bind: str, port: int) -> int:
|
|||||||
httpd = ThreadingHTTPServer((bind, port), _Handler)
|
httpd = ThreadingHTTPServer((bind, port), _Handler)
|
||||||
httpd.daemon_threads = True
|
httpd.daemon_threads = True
|
||||||
shown = bind if bind not in ("0.0.0.0", "::") else "127.0.0.1"
|
shown = bind if bind not in ("0.0.0.0", "::") else "127.0.0.1"
|
||||||
print(f"operator web UI for session '{sess.name}' → http://{shown}:{port} (Ctrl-C to stop)")
|
print(f"operator web console for session '{sess.name}' → http://{shown}:{port} (Ctrl-C to stop)")
|
||||||
try:
|
try:
|
||||||
httpd.serve_forever()
|
httpd.serve_forever()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
print("\nweb UI stopped")
|
print("\nweb console stopped")
|
||||||
finally:
|
finally:
|
||||||
httpd.server_close()
|
httpd.server_close()
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
Reference in New Issue
Block a user