AnyBot/mobile/screen.html

110 lines
6.4 KiB
HTML
Raw Permalink Normal View History

People watch and help on the bots' computer through VNC (noVNC), carried by the account server The core: the bot works the computer; a person watches it like a colleague, and takes over when it needs them. VNC is made for that (only what changed, every mouse move and key, touch on phones), so the pictures sent one by one go, and with them their own input path. - vnc.rs: a VNC proxy to the box's x11vnc through one docker exec (a Python bridge that ends with either side). It signs in to the box with the box's own password (its VNC_PASSWORD) and offers the viewer no password, so the password never leaves the brain. The person's clicks and keys make the bot's steps wait until 8 s after they stop; text typed in the viewer's box (any language) is pasted in the box. - Account server: /screen (the viewer page) and /novnc.js (noVNC 1.6.0, MPL-2.0, bundled); /relay/vnc/<host> takes the browser's WebSocket (account session, or a 10-minute ticket the app asks for with POST /relay/screen-ticket; owners and admins) and carries it over that AnyBot's lasting connection (vopen / vdata / vtext / vclose). Both ends of that connection wait 2 ms instead of 40 ms to read while things move: a request and its answer took up to 160 ms more than needed. - Phone page: 看 Bot 的電腦 and 接管 open the viewer page (also on the local network: /api/vnc on the AnyBot's own phone server). This Mac's own screen moved to the ⋯ menu. - Mac app: 看畫面 opens the viewer in the browser (a screen of a brain: the brain's computer by ticket; else this computer's box, noVNC straight from it). The in-app picture viewer, the live JPEG stream, the person's click/move/scroll/key endpoints and the input shell are gone. - Opening a Mac app uses LaunchServices (`open -a`): agent-desktop's launch waited on the window through accessibility and failed without 輔助使用 now and then. Measured on this Mac (made-up account, local relay + headless brain + the real box): viewer signed in in 0.4–0.7 s, a request and its answer through relay + brain + box 25–58 ms median; headless Chrome shows the desktop live in /screen. remote_e2e 28/28 ×2, 149 unit tests, relay 11. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:05:44 +00:00
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
<meta name="theme-color" content="#16181d">
<title>Bot 的電腦</title>
<link rel="icon" href="icon.svg">
<style>
:root { --bg: #16181d; --bar: #1f2228; --line: #2e323a; --text: #eceef2; --dim: #9aa1ad; --accent: #4f8cff; --warn: #e5a13a; --ok: #3fb67a; }
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text); font: 15px/1.4 -apple-system, BlinkMacSystemFont, "PingFang TC", "Noto Sans TC", sans-serif; overflow: hidden; }
body { display: flex; flex-direction: column; height: 100dvh; }
header { display: flex; align-items: center; gap: 8px; padding: 8px 12px; padding-top: max(8px, env(safe-area-inset-top)); background: var(--bar); border-bottom: 1px solid var(--line); }
header .name { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
header .state { color: var(--dim); font-size: 13px; white-space: nowrap; display: flex; align-items: center; gap: 6px; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--dim); flex: none; }
.dot.live { background: var(--ok); }
.dot.hold { background: var(--warn); }
.grow { flex: 1; min-width: 0; display: flex; flex-direction: column; }
button { font: inherit; color: var(--text); background: #2b2f37; border: 1px solid var(--line); border-radius: 8px; padding: 6px 12px; cursor: pointer; white-space: nowrap; }
button.main { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
button.icon { padding: 6px 10px; }
#screen { flex: 1; min-height: 0; position: relative; background: #000; }
#screen > div { background: #000 !important; }
#msg { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; text-align: center; padding: 24px; color: var(--dim); pointer-events: none; }
#type { display: none; gap: 6px; padding: 8px 12px; padding-bottom: max(8px, env(safe-area-inset-bottom)); background: var(--bar); border-top: 1px solid var(--line); flex-wrap: wrap; }
#type.on { display: flex; }
#type input { flex: 1 1 160px; min-width: 0; font: inherit; color: var(--text); background: #111318; border: 1px solid var(--line); border-radius: 8px; padding: 7px 10px; }
@media (max-width: 420px) { header .hint { display: none; } }
</style>
</head>
<body>
<header>
<button class="icon" id="back" title="返回">←</button>
<div class="grow"><div class="name" id="name">Bot 的電腦</div><div class="state"><span class="dot" id="dot"></span><span id="state">連線中…</span></div></div>
<button id="kb" title="打字(任何語言)">打字</button>
<button class="main" id="take">接管</button>
</header>
<div id="screen"><div id="msg">連線中…</div></div>
<div id="type">
<input id="text" placeholder="要打的字(中文也可以),按送出" autocomplete="off" enterkeyhint="send">
<button class="main" id="send">送出</button>
<button data-k="Return">Enter</button><button data-k="Escape">Esc</button><button data-k="Tab">Tab</button><button data-k="BackSpace">⌫</button>
</div>
<script type="module">
import RFB from "./novnc.js";
// #host=…&name=…&ticket=… (the app's link) or #host=… with this site's sign-in (the phone page);
// #lan=1 on an AnyBot's own phone page. &control=1 starts with control (a bot asked for help).
const q = new URLSearchParams(location.hash.slice(1));
const name = q.get("name") || "Bot 的電腦";
const token = localStorage.getItem("anybot.token") || "";
const wsBase = (location.protocol === "https:" ? "wss://" : "ws://") + location.host;
const url = q.get("lan") === "1"
? `${wsBase}/api/vnc?t=${encodeURIComponent(token)}`
: `${wsBase}/relay/vnc/${encodeURIComponent(q.get("host") || "")}?` + (q.get("ticket") ? "ticket=" + encodeURIComponent(q.get("ticket")) : "t=" + encodeURIComponent(token));
const $ = (id) => document.getElementById(id);
document.title = name;
$("name").textContent = name;
let rfb = null, ws = null, control = q.get("control") === "1", tries = 0, connected = false;
const KEYS = { Return: 0xff0d, Escape: 0xff1b, Tab: 0xff09, BackSpace: 0xff08 };
function state(text, kind) { $("state").textContent = text; $("dot").className = "dot" + (kind ? " " + kind : ""); }
function message(text) { $("msg").textContent = text; $("msg").style.display = text ? "" : "none"; }
function showControl() {
$("take").textContent = control ? "交還給 Bot" : "接管";
$("take").className = control ? "" : "main";
$("kb").style.display = control ? "" : "none";
if (!control) $("type").classList.remove("on");
if (rfb) { rfb.viewOnly = !control; if (control) rfb.focus(); }
if (connected) state(control ? "你在操作(Bot 會等你停下)" : "即時觀看", control ? "hold" : "live");
}
function connect() {
message("連線中…"); state("連線中…");
ws = new WebSocket(url);
rfb = new RFB($("screen"), ws, { shared: true });
rfb.scaleViewport = true;
rfb.resizeSession = false;
rfb.qualityLevel = 6;
rfb.compressionLevel = 2;
rfb.showDotCursor = true;
rfb.viewOnly = !control;
rfb.addEventListener("connect", () => { tries = 0; connected = true; message(""); showControl(); });
rfb.addEventListener("disconnect", () => {
rfb = null; connected = false;
tries += 1;
state("連線中斷,正在重新連線…");
message(tries > 3 ? "連不上這台電腦。它的 AnyBot 可能關了,或連結已過期:回到 AnyBot 再打開一次。" : "連線中斷,正在重新連線…");
setTimeout(connect, Math.min(1000 * tries, 8000));
});
}
$("take").onclick = () => { control = !control; showControl(); };
$("kb").onclick = () => { $("type").classList.toggle("on"); if ($("type").classList.contains("on")) $("text").focus(); };
// Typed text goes over the same connection as words (any language), pasted in the bots' computer.
function send() { const t = $("text").value; if (!t || !ws || ws.readyState !== 1) return; ws.send(t); $("text").value = ""; }
$("send").onclick = send;
$("text").addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.isComposing) { e.preventDefault(); send(); } });
document.querySelectorAll("[data-k]").forEach((b) => b.onclick = () => { if (rfb) rfb.sendKey(KEYS[b.dataset.k], b.dataset.k); });
$("back").onclick = () => { if (history.length > 1) history.back(); else window.close(); };
showControl();
connect();
</script>
</body>
</html>