AnyBot/mobile/index.html

1157 lines
84 KiB
HTML
Raw Normal View History

<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="AnyBot">
<meta name="theme-color" content="#121212" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
<link rel="manifest" href="manifest.webmanifest">
<link rel="icon" href="icon.svg" type="image/svg+xml">
<link rel="icon" href="icon-192.png" type="image/png" sizes="192x192">
<link rel="apple-touch-icon" href="icon-180.png">
<title>AnyBot</title>
<style>
:root{
--bg:#ffffff;--side:#f7f7f7;--surface:#ffffff;--raise:#f2f2f2;--line:rgba(0,0,0,.08);
--text:#141414;--text2:#5c5c5c;--text3:#8e8e8e;--accent:#1680f3;--accent-ink:#fff;
--user:rgba(22,128,243,.12);--danger:#e5484d;--ok:#2ea44f;--warn:#f5a524;
--radius:18px;--font:-apple-system,BlinkMacSystemFont,"SF Pro Text","PingFang TC","Noto Sans TC",system-ui,sans-serif;
--mono:ui-monospace,SFMono-Regular,Menlo,monospace;
}
@media (prefers-color-scheme: dark){:root:not([data-theme="light"]){
--bg:#121212;--side:#0e0e0e;--surface:#1f1f1f;--raise:#161616;--line:rgba(255,255,255,.08);
--text:#f5f5f5;--text2:#c1c1c1;--text3:#8a8a8a;--user:rgba(255,255,255,.18);
}}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{margin:0;height:100%;background:var(--bg);color:var(--text);font:15px/1.5 var(--font);-webkit-font-smoothing:antialiased}
button{font:inherit;color:inherit;background:none;border:0;padding:0;cursor:pointer}
input,textarea{font:inherit;color:var(--text)}
.app{height:100dvh;display:flex;flex-direction:column;overflow:hidden}
.view{flex:1;display:none;flex-direction:column;min-height:0}
.view.on{display:flex}
header{display:flex;align-items:center;gap:10px;padding:calc(env(safe-area-inset-top) + 10px) 16px 10px;border-bottom:1px solid var(--line);background:var(--bg);min-height:56px}
header h1{font-size:17px;font-weight:600;margin:0;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
header .sub{font-size:12px;color:var(--text3);font-weight:400}
.icon-btn{width:36px;height:36px;border-radius:10px;display:grid;place-items:center;color:var(--text2)}
.icon-btn:active{background:var(--raise)}
.icon-btn svg{width:20px;height:20px}
.scroll{flex:1;overflow-y:auto;-webkit-overflow-scrolling:touch;overscroll-behavior:contain}
/* A list that reaches the bottom of the screen keeps its last row above the iPhone's home bar and
Safari's toolbar (it looked cut off). */
.view > .scroll:last-child{padding-bottom:calc(env(safe-area-inset-bottom) + 16px)}
/* login */
.login{flex:1;display:flex;flex-direction:column;justify-content:center;padding:32px 24px calc(env(safe-area-inset-bottom) + 32px);max-width:420px;margin:0 auto;width:100%}
.logo{width:56px;height:56px;margin-bottom:20px}
.login h2{font-size:26px;margin:0 0 6px;font-weight:650;letter-spacing:-.01em}
.login p{color:var(--text2);margin:0 0 24px}
.field{display:block;width:100%;padding:13px 14px;border-radius:12px;border:1px solid var(--line);background:var(--surface);margin-bottom:10px;outline:none;font-size:16px}
.field:focus{border-color:var(--accent)}
.primary{display:block;width:100%;padding:13px;border-radius:12px;background:var(--text);color:var(--bg);font-weight:600;margin-top:6px}
.primary:disabled{opacity:.45}
.err{color:var(--danger);font-size:13px;min-height:20px;margin-top:8px}
.link{color:var(--accent);font-size:14px;margin-top:14px}
/* list */
.row{display:flex;gap:12px;align-items:center;padding:12px 16px;border-bottom:1px solid var(--line)}
.row:active{background:var(--raise)}
.av{width:42px;height:42px;border-radius:50%;flex:none;display:grid;place-items:center;font-weight:600;color:#fff;font-size:16px;position:relative;box-shadow:inset 0 0 0 1px var(--line)}
.av.face{background:none !important;box-shadow:none;border-radius:0}
.av.face svg{width:100%;height:100%;display:block}
.av.sm{width:26px;height:26px;font-size:12px}
.row .mid{flex:1;min-width:0}
.row .name{font-weight:600;display:flex;gap:6px;align-items:center}
.row .last{color:var(--text3);font-size:13.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.badge{min-width:9px;height:9px;border-radius:5px;background:var(--accent)}
.busy{font-size:12px;color:var(--accent);font-weight:500}
.wait{font-size:12px;color:var(--warn);font-weight:600}
.empty{color:var(--text3);text-align:center;padding:48px 24px}
.pill{font-size:11.5px;padding:2px 8px;border-radius:999px;background:var(--raise);color:var(--text2);font-weight:500}
/* chat */
.msgs{padding:12px 0 8px}
.m{display:flex;flex-direction:column;padding:4px 14px;max-width:100%}
.m.user{align-items:flex-end}
.bubble{max-width:min(85%,560px);padding:7px 12px;border-radius:var(--radius);white-space:pre-wrap;word-wrap:break-word;overflow-wrap:anywhere}
.m.user .bubble{background:var(--user)}
.m.bot .bubble{padding:4px 2px;max-width:100%}
.m.err .bubble{color:var(--danger)}
.who{font-size:12px;color:var(--text3);margin:6px 2px 2px;display:flex;align-items:center;gap:6px}
.via{font-size:11.5px;color:var(--text3);margin:2px 2px 4px}
.bubble p{margin:0 0 .55em}.bubble p:last-child{margin:0}
.bubble pre{background:var(--raise);padding:10px 12px;border-radius:10px;overflow-x:auto;font:12.5px/1.45 var(--mono);white-space:pre;margin:6px 0}
.bubble code{font:13px var(--mono);background:var(--raise);padding:1px 5px;border-radius:5px}
.bubble pre code{background:none;padding:0}
.bubble a{color:var(--accent)}
.bubble ul,.bubble ol{margin:.2em 0 .5em;padding-left:1.3em}
.bubble h1,.bubble h2,.bubble h3{font-size:16px;margin:.6em 0 .3em}
.bubble blockquote{margin:0 0 .5em;padding-left:10px;border-left:3px solid var(--line);color:var(--text2)}
.tool{display:flex;gap:8px;align-items:baseline;font-size:13px;color:var(--text2);padding:1px 4px}
.tool .st{width:14px;text-align:center;color:var(--text3);flex:none;display:inline-flex}
.ic{vertical-align:-2px;flex:none}
.tool.waiting .st{color:var(--accent)}.tool.failed .st{color:var(--danger)}
.tool .d{font:12px var(--mono);color:var(--text3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;flex:1}
.tool>span:nth-child(2){flex:none;white-space:nowrap}
.att{display:inline-flex;gap:6px;align-items:center;font-size:12.5px;background:var(--raise);border-radius:8px;padding:3px 8px;margin:3px 0 0;color:inherit;text-decoration:none;cursor:pointer}
.attimg{margin:6px 0 0;max-width:100%}.attimg img{display:block;max-width:100%;max-height:70vh;border-radius:12px;border:1px solid var(--line);cursor:zoom-in}.attimg .wait{font-size:12.5px;color:var(--text3)}
.card{border:1px solid var(--line);border-radius:14px;padding:12px 14px;margin:6px 0;background:var(--surface);max-width:420px}
.card .t{font-weight:600}.card .s{color:var(--text3);font-size:13px;margin-bottom:6px}
.card .r{display:flex;justify-content:space-between;gap:10px;font-size:13.5px;padding:3px 0;border-top:1px solid var(--line)}
.card .r span:first-child{color:var(--text2)}
/* 頁面: the card in a chat, and the page */
.pcard{border:1px solid var(--line);border-radius:16px;margin:8px 0;background:var(--surface);max-width:520px;overflow:hidden;cursor:pointer;-webkit-tap-highlight-color:transparent}
.pcard:active{background:var(--raise)}
.pcard .top{padding:14px 16px 10px}
.pcard .t{font-weight:700;font-size:17px;margin-bottom:6px}
.pcard .l{font-size:13.5px;color:var(--text2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:1.55}
.pcard .foot{display:flex;gap:12px;align-items:center;padding:10px 16px;border-top:1px solid var(--line)}
.pcard .n{font-size:14px}.pcard .m{font-size:12px;color:var(--text3);padding:0;display:block}
.pcard .note{font-size:12px;color:var(--text2);padding:0 16px 10px}
.pglyph{flex:none}
a.plink{color:var(--accent);text-decoration:underline;text-underline-offset:3px;cursor:pointer}
.page{padding:18px 18px calc(env(safe-area-inset-bottom) + 40px);max-width:760px;margin:0 auto;line-height:1.65;font-size:15.5px;box-sizing:border-box;width:100%;min-width:0;overflow-wrap:anywhere}
#pscroll{overflow-x:hidden}
#v-page header{min-width:0}
.page h1.pt{font-size:25px;line-height:1.3;margin:0 0 6px}
.page .meta{color:var(--text3);font-size:12.5px;margin-bottom:12px}
.page .sum{font-size:16px;margin:0 0 6px}
.page .chg{font-size:12.5px;color:var(--text2);margin-bottom:8px}
.page .old{background:var(--raise);border-radius:8px;padding:8px 10px;font-size:13px;color:var(--text2);margin-bottom:10px}
.page h2{font-size:19px;margin:1.3em 0 .4em}.page h3{font-size:16.5px;margin:1.1em 0 .3em}.page h4{font-size:15px;margin:1em 0 .3em}
.page p{margin:0 0 .75em}
.page ul,.page ol{margin:.2em 0 .8em;padding-left:1.3em}.page li{margin:.15em 0}
.page li.task{list-style:none;margin-left:-1.2em}
.page a{color:var(--accent)}
.page code{font:13px var(--mono);background:var(--raise);padding:1px 5px;border-radius:5px}
.page pre{background:var(--raise);padding:10px 12px;border-radius:10px;overflow-x:auto;font:12.5px/1.45 var(--mono)}
.page pre code{background:none;padding:0}
.page hr{border:0;border-top:1px solid var(--line);margin:1.2em 0}
.page blockquote{margin:0 0 .8em;padding-left:12px;border-left:3px solid var(--line);color:var(--text2)}
.tw{overflow-x:auto;margin:0 0 1em;border:1px solid var(--line);border-radius:10px;max-width:100%}
.page td{min-width:5em;overflow-wrap:normal;word-break:normal}
.page table{border-collapse:collapse;width:100%;font-size:14px}
.page th,.page td{padding:7px 10px;border-bottom:1px solid var(--line);text-align:left;vertical-align:top}
.page th{background:var(--raise);font-weight:600;white-space:nowrap}
.page tr:last-child td{border-bottom:0}
.stats{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:8px;margin:0 0 1em}
.stat{background:var(--raise);border-radius:12px;padding:10px 12px}
.stat .k{font-size:12px;color:var(--text2)}.stat .v{font-size:21px;font-weight:700;line-height:1.3}.stat .v.long{font-size:15.5px}.stat .s{font-size:11.5px;color:var(--text3)}
.steps{margin:0 0 1em;padding:0;list-style:none}
.steps li{position:relative;padding:0 0 14px 30px}
.steps li:before{content:"";position:absolute;left:8px;top:20px;bottom:-2px;width:2px;background:var(--line)}
.steps li:last-child:before{display:none}
.steps li.done:before{background:var(--ok)}
.steps .dot{position:absolute;left:0;top:3px;width:18px;height:18px;border-radius:50%;box-sizing:border-box;border:1.5px solid var(--text3);background:var(--bg);display:grid;place-items:center;color:#fff;font-size:11px;animation:none;margin:0}
.steps .done .dot{background:var(--ok);border-color:var(--ok)}
.steps .now .dot{border:2.5px solid var(--accent)}
.steps .now .dot:after{content:"";width:6px;height:6px;border-radius:50%;background:var(--accent)}
.steps .todo .nm{color:var(--text3)}.steps .now .nm{font-weight:700}
.steps .nt{font-size:13px;color:var(--text2)}
.call{border-radius:10px;padding:10px 12px 10px 14px;margin:0 0 1em;border-left:3px solid var(--accent);background:rgba(22,128,243,.08)}
.call .ct{font-size:12.5px;font-weight:700;color:var(--accent);margin-bottom:2px}
.call.warning{border-color:#e08a00;background:rgba(224,138,0,.10)}.call.warning .ct{color:#e08a00}
.call.important{border-color:#d03b3b;background:rgba(208,59,59,.09)}.call.important .ct{color:#d03b3b}
.call.tip{border-color:var(--ok);background:rgba(46,160,67,.09)}.call.tip .ct{color:var(--ok)}
.call p:last-child{margin:0}
.chart{margin:0 0 1em;text-align:center}.chart img{max-width:100%;height:auto}
.chart .wait{color:var(--text3);font-size:13px;padding:20px}
#pver{border:1px solid var(--line);background:var(--surface);border-radius:8px;padding:4px 6px;font-size:13px;color:var(--text);max-width:42%;flex:none}
.typing{display:inline-flex;gap:4px;padding:8px 4px}
.typing i{width:6px;height:6px;border-radius:50%;background:var(--text3);animation:b 1.2s infinite}
.typing i:nth-child(2){animation-delay:.15s}.typing i:nth-child(3){animation-delay:.3s}
@keyframes b{0%,60%,100%{opacity:.25}30%{opacity:1}}
/* approval */
.ask{margin:8px 12px;border:1px solid var(--line);border-radius:16px;background:var(--surface);padding:14px;box-shadow:0 6px 24px rgba(0,0,0,.12)}
.ask .q{font-weight:600;margin-bottom:4px}
.ask .why{font-size:13.5px;color:var(--text2);white-space:pre-wrap;max-height:30vh;overflow:auto;margin-bottom:12px;font-family:var(--mono)}
.ask .btns{display:flex;gap:8px;flex-wrap:wrap}
.btn{padding:9px 14px;border-radius:10px;background:var(--raise);font-weight:500;font-size:14px}
.btn.main{background:var(--text);color:var(--bg)}
.btn.no{color:var(--danger)}
/* composer */
.composer{display:flex;gap:8px;align-items:flex-end;padding:8px 10px calc(env(safe-area-inset-bottom) + 8px);border-top:1px solid var(--line);background:var(--bg)}
.composer textarea{flex:1;resize:none;border:1px solid var(--line);background:var(--surface);border-radius:20px;padding:9px 14px;max-height:140px;outline:none;font-size:16px;line-height:1.35}
.send{width:38px;height:38px;border-radius:50%;background:var(--text);color:var(--bg);display:grid;place-items:center;flex:none}
.send:disabled{opacity:.3}
.send svg,.clip svg{width:18px;height:18px}
.clip{width:38px;height:38px;border-radius:50%;display:grid;place-items:center;color:var(--text2);flex:none}
.chips{display:flex;gap:6px;flex-wrap:wrap;padding:0 12px}
.chip{font-size:12.5px;background:var(--raise);border-radius:8px;padding:4px 8px;display:flex;gap:6px;align-items:center}
/* screen */
.seg{display:flex;background:var(--raise);border-radius:10px;padding:3px;gap:3px}
.seg button{padding:6px 12px;border-radius:8px;font-size:13.5px;color:var(--text2)}
.seg button.on{background:var(--surface);color:var(--text);font-weight:600;box-shadow:0 1px 3px rgba(0,0,0,.12)}
.stage{flex:1;display:flex;align-items:center;justify-content:center;background:#000;position:relative;overflow:hidden;touch-action:pan-x pan-y pinch-zoom}
.stage img{max-width:100%;max-height:100%;display:block;user-select:none;-webkit-user-select:none;-webkit-touch-callout:none}
.stage .msg{color:#bbb;text-align:center;padding:24px}
.dot{position:absolute;width:26px;height:26px;margin:-13px 0 0 -13px;border-radius:50%;border:2px solid #fff;background:rgba(22,128,243,.45);pointer-events:none;animation:f .5s forwards}
@keyframes f{to{opacity:0;transform:scale(1.6)}}
.tools{display:flex;gap:6px;padding:8px 10px calc(env(safe-area-inset-bottom) + 8px);overflow-x:auto;border-top:1px solid var(--line);background:var(--bg)}
.tools .btn{white-space:nowrap;flex:none}
.typebar{display:none;gap:8px;padding:8px 10px;border-top:1px solid var(--line)}
.typebar.on{display:flex}
.typebar input{flex:1;border:1px solid var(--line);background:var(--surface);border-radius:10px;padding:9px 12px;font-size:16px;outline:none}
.live{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--ok);margin-right:6px}
.sheet-bg{position:fixed;inset:0;background:rgba(0,0,0,.35);display:none;align-items:flex-end;z-index:10}
.sheet-bg.on{display:flex}
.sheet{width:100%;background:var(--surface);border-radius:18px 18px 0 0;padding:8px 8px calc(env(safe-area-inset-bottom) + 10px)}
.sheet button{display:block;width:100%;text-align:left;padding:14px 16px;border-radius:12px;font-size:16px}
.sheet button:active{background:var(--raise)}
.sheet .no{color:var(--danger)}
.pair{padding:18px 16px calc(env(safe-area-inset-bottom) + 12px)}
.pair-t{font-size:18px;font-weight:600;margin:2px 4px 6px}
.pair-code{font:600 34px/1.2 ui-monospace,Menlo,monospace;letter-spacing:6px;text-align:center;margin:16px 0 10px}
.pair-line{display:block;font:13px/1.5 ui-monospace,Menlo,monospace;background:var(--raise);border-radius:10px;padding:10px 12px;word-break:break-all;margin-bottom:12px;user-select:all}
.sheet .pair-copy,.pair .btn.main{text-align:center;padding:13px}
.sheet .note{color:var(--text3);font-size:12.5px;padding:4px 16px 8px}
.toast{position:fixed;left:50%;bottom:calc(env(safe-area-inset-bottom) + 90px);transform:translateX(-50%);background:var(--text);color:var(--bg);padding:9px 16px;border-radius:12px;font-size:14px;opacity:0;transition:opacity .2s;pointer-events:none;max-width:88vw;text-align:center}
.toast.on{opacity:.95}
/* 外觀: chosen by hand (the menu) over the phone's own setting */
:root[data-theme="dark"]{--bg:#121212;--side:#0e0e0e;--surface:#1f1f1f;--raise:#161616;--line:rgba(255,255,255,.08);--text:#f5f5f5;--text2:#c1c1c1;--text3:#8a8a8a;--user:rgba(255,255,255,.18)}
/* home: the main bot, the other bots, the computers, pages and routines */
.hero{display:flex;gap:14px;align-items:center;margin:14px 14px 4px;padding:16px;border:1px solid var(--line);border-radius:20px;background:var(--surface);cursor:pointer}
.hero:active{background:var(--raise)}
.hero .av{width:60px;height:60px}
.hero .mid{flex:1;min-width:0}
.hero .name{font-size:19px;font-weight:700;display:flex;gap:6px;align-items:center}
.hero .model{font-size:12px;color:var(--text3);margin-top:1px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.hero .last{font-size:14px;color:var(--text2);margin-top:5px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.sec{font-size:13px;font-weight:600;color:var(--text3);margin:20px 18px 7px;display:flex;align-items:center;gap:6px}
.sec .n{font-weight:400}
.list{margin:0 14px;border:1px solid var(--line);border-radius:16px;background:var(--surface);overflow:hidden}
.list .row{border-bottom:1px solid var(--line);padding:11px 14px;cursor:pointer}
.list .row:last-child{border-bottom:0}
.list .row.static{cursor:default}.list .row.static:active{background:none}
.row .time{font-size:12px;color:var(--text3);flex:none;align-self:flex-start;margin-top:2px}
.ico{width:36px;height:36px;border-radius:11px;background:var(--raise);display:grid;place-items:center;flex:none;color:var(--text2)}
.st-on,.st-off{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:5px;vertical-align:1px}
.st-on{background:var(--ok)}.st-off{background:#8a8a8a}
.mini{font-size:13px;padding:6px 11px;border-radius:9px;background:var(--raise);font-weight:500;flex:none;display:inline-flex;gap:5px;align-items:center}
.homeend{height:calc(env(safe-area-inset-bottom) + 24px)}
/* chat: the bot's name opens its settings; steps fold up when a turn is done */
#v-chat{position:relative}
.hbtn{display:flex;align-items:center;gap:10px;flex:1;min-width:0;text-align:left;padding:2px 4px;border-radius:10px}
.hbtn:active{background:var(--raise)}
.hbtn .tx{min-width:0;flex:1}
.hbtn h1{font-size:17px}
.hbtn .model{font-size:11.5px;color:var(--text3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;gap:4px;align-items:center}
.fold{display:flex;gap:7px;align-items:center;font-size:13px;color:var(--text3);padding:3px 4px;cursor:pointer;user-select:none}
.fold .ic{color:var(--ok)}
.fold.run .ic{color:var(--accent)}
.acts{display:flex;gap:2px;margin:-2px 0 4px -6px}
.acts button{width:32px;height:28px;border-radius:8px;display:grid;place-items:center;color:var(--text3)}
.acts button:active{background:var(--raise);color:var(--text)}
.sugg{display:flex;flex-direction:column;gap:8px;padding:28px 16px 12px}
.sugg .av{width:64px;height:64px;margin:0 auto 6px}
.sugg .hi{font-size:21px;font-weight:700;text-align:center}
.sugg .sub{font-size:13.5px;color:var(--text3);text-align:center;margin-bottom:12px}
.sugg button{display:flex;gap:11px;align-items:center;text-align:left;padding:13px 14px;border:1px solid var(--line);border-radius:14px;background:var(--surface);font-size:14.5px}
.sugg button:active{background:var(--raise)}
.sugg button .ic{color:var(--accent)}
.down{position:absolute;right:14px;bottom:calc(env(safe-area-inset-bottom) + 74px);width:38px;height:38px;border-radius:50%;background:var(--surface);border:1px solid var(--line);box-shadow:0 4px 16px rgba(0,0,0,.18);display:none;place-items:center;color:var(--text2);z-index:2}
.down.on{display:grid}
/* the bot's settings sheet */
.bsheet{max-height:82vh;overflow-y:auto}
.bsheet .head{display:flex;gap:12px;align-items:center;padding:10px 12px 12px}
.bsheet .head .av{width:46px;height:46px}
.bsheet .head .n{font-size:18px;font-weight:700}.bsheet .head .m{font-size:12.5px;color:var(--text3)}
.bsheet details{border-top:1px solid var(--line)}
.bsheet summary{list-style:none;display:flex;gap:10px;align-items:center;padding:13px 12px;font-size:15.5px;cursor:pointer}
.bsheet summary::-webkit-details-marker{display:none}
.bsheet summary .v{margin-left:auto;color:var(--text3);font-size:13.5px;max-width:55%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bsheet details[open] summary .chev{transform:rotate(90deg)}
.bsheet .chev{transition:transform .15s;color:var(--text3);flex:none}
.bsheet .opt{display:flex;gap:10px;align-items:center;width:100%;text-align:left;padding:10px 12px 10px 40px;font-size:14.5px;border-radius:10px}
.bsheet .opt:active{background:var(--raise)}
.bsheet .opt .ck{margin-left:auto;color:var(--accent)}
.bsheet .grp{font-size:12px;color:var(--text3);padding:8px 12px 2px 40px}
.bsheet .act{display:flex;gap:10px;align-items:center;width:100%;text-align:left;padding:13px 12px;font-size:15.5px;border-top:1px solid var(--line);border-radius:0}
.bsheet .act:active{background:var(--raise)}
.bsheet .act .ic{color:var(--text2)}
</style>
</head>
<body>
<div class="app">
<!-- sign in -->
<section class="view" id="v-login">
<div class="login">
<svg class="logo" viewBox="0 0 96 96"><rect width="96" height="96" rx="22" fill="currentColor" opacity=".08"/><circle cx="48" cy="48" r="24" fill="none" stroke="currentColor" stroke-width="7"/><circle cx="48" cy="48" r="7" fill="currentColor"/></svg>
<h2 id="login-title">登入 AnyBot</h2>
<p id="login-desc">用手機和你的 Bot 聊天、核准它要做的事,也能看著它的電腦。</p>
<input class="field" id="f-user" placeholder="帳號" autocomplete="username" autocapitalize="off">
<input class="field" id="f-pass" type="password" placeholder="密碼" autocomplete="current-password">
<input class="field" id="f-invite" placeholder="邀請碼(伺服器有設定才需要)" autocapitalize="off" style="display:none">
<button class="primary" id="b-login">登入</button>
<div class="err" id="login-err"></div>
<button class="link" id="b-mode" style="display:none">還沒有帳號?建立一個</button>
</div>
</section>
<!-- pick which AnyBot (account with several computers) -->
<section class="view" id="v-hosts">
<header><h1>選擇要連的 AnyBot</h1><button class="icon-btn" id="b-out1" title="登出"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M15 4h3a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-3M10 17l-5-5 5-5M5 12h11"/></svg></button></header>
<div class="scroll" id="hosts"></div>
</section>
<!-- bots -->
<section class="view" id="v-list">
<header>
<h1>AnyBot <span class="sub" id="host-name"></span></h1>
<button class="icon-btn" id="b-screen" title="看電腦畫面"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/></svg></button>
<button class="icon-btn" id="b-more" title="更多"><svg viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.7"/><circle cx="12" cy="12" r="1.7"/><circle cx="19" cy="12" r="1.7"/></svg></button>
</header>
<div class="scroll" id="bots"></div>
</section>
<!-- one chat -->
<section class="view" id="v-chat">
<header>
<button class="icon-btn" id="b-back"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 5l-7 7 7 7"/></svg></button>
<button class="hbtn" id="b-botinfo" title="這個 Bot 的設定"><div class="av sm" id="chat-av"></div><div class="tx"><h1 id="chat-name"></h1><div class="model" id="chat-model"></div></div></button>
<button class="icon-btn" id="b-new" title="開始新對話"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 5v14M5 12h14"/></svg></button>
<button class="icon-btn" id="b-screen2" title="看電腦畫面"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/></svg></button>
</header>
<div class="scroll" id="scroller"><div class="msgs" id="msgs"></div></div>
<button class="down" id="b-down" title="到最新的訊息"></button>
<div id="ask"></div>
<div class="chips" id="chips"></div>
<div class="composer">
<button class="clip" id="b-clip" title="附加照片或檔案"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M21 11.5l-8.5 8.5a5 5 0 0 1-7-7L14 4.5a3.3 3.3 0 0 1 4.7 4.7L10.2 17.7a1.7 1.7 0 0 1-2.4-2.4L15.5 7.6"/></svg></button>
<input type="file" id="file" multiple hidden>
<textarea id="input" rows="1" placeholder="傳訊息…" enterkeyhint="send"></textarea>
<button class="send" id="b-send"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M12 19V5M6 11l6-6 6 6"/></svg></button>
</div>
</section>
<section class="view" id="v-page">
<header>
<button class="icon-btn" id="b-back3"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 5l-7 7 7 7"/></svg></button>
<h1 id="page-name" style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">頁面</h1>
<select id="pver" style="display:none"></select>
</header>
<div class="scroll" id="pscroll"><div class="page" id="page"></div></div>
</section>
<!-- screens -->
<section class="view" id="v-screen">
<header>
<button class="icon-btn" id="b-back2"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 5l-7 7 7 7"/></svg></button>
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
<div class="title">這台 Mac</div>
<span style="flex:1"></span>
<span class="sub" id="screen-live"></span>
</header>
<div class="stage" id="stage"><div class="msg">載入中…</div></div>
<div class="typebar" id="typebar"><input id="typein" placeholder="要打的字(會打進目前的視窗)" enterkeyhint="send"><button class="btn main" id="b-type">送出</button></div>
<div class="tools">
<button class="btn" id="k-type"><svg class="ic" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 8h.01" /> <path d="M12 12h.01" /> <path d="M14 8h.01" /> <path d="M16 12h.01" /> <path d="M18 8h.01" /> <path d="M6 8h.01" /> <path d="M7 16h10" /> <path d="M8 12h.01" /> <rect width="20" height="16" x="2" y="4" rx="2" /></svg> 打字</button>
<button class="btn" data-key="Return">Enter</button>
<button class="btn" data-key="Escape">Esc</button>
<button class="btn" data-key="Tab">Tab</button>
<button class="btn" data-key="BackSpace">刪除</button>
<button class="btn" data-scroll="up">↑ 捲動</button>
<button class="btn" data-scroll="down">↓ 捲動</button>
<button class="btn" id="k-right">右鍵:關</button>
</div>
</section>
</div>
<div class="toast" id="toast"></div>
<div class="sheet-bg" id="sheet"><div class="sheet">
<button id="m-theme">外觀:跟著手機</button>
<button id="m-notify">開啟通知</button>
<div class="note" id="m-note">Bot 需要你允許、或在你不在時做完事,手機會跳通知。</div>
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
<button id="m-mac" style="display:none">看這台 Mac 的螢幕</button>
<button id="m-pair" style="display:none">加一台電腦(Linux、伺服器)</button>
<button id="m-host" style="display:none">切換到別台 AnyBot</button>
<button class="no" id="m-out">登出這支手機</button>
<button id="m-cancel">取消</button>
</div></div>
<div class="sheet-bg" id="botsheet"><div class="sheet bsheet" id="botsheet-in"></div></div>
<div class="sheet-bg" id="pair"><div class="sheet pair">
<div class="pair-t">加一台電腦</div>
<div class="note">在那台電腦(Linux 或 Mac)的終端機貼上這一行執行:還沒裝 AnyBot 會先自動裝好,接著變成「<b id="pair-for"></b>」的手腳,開機自動執行。不用帳號密碼。</div>
<div class="pair-code" id="pair-code">······</div>
<code class="pair-line" id="pair-line"></code>
<button class="btn main" id="pair-copy">複製這一行</button>
<div class="note" id="pair-left"></div>
<button id="pair-close">關閉</button>
</div></div>
<script>
"use strict";
// Lucide icons (ISC licence), the same set as the app.
const LUCIDE = {"image": "<rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\" ry=\"2\" /> <circle cx=\"9\" cy=\"9\" r=\"2\" /> <path d=\"m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21\" />", "file": "<path d=\"M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z\" /> <path d=\"M14 2v5a1 1 0 0 0 1 1h5\" />", "circle-check": "<circle cx=\"12\" cy=\"12\" r=\"10\" /> <path d=\"m16 9-5.5 5.5L8 12\" />", "loader": "<path d=\"M12 2v4\" /> <path d=\"m16.2 7.8 2.9-2.9\" /> <path d=\"M18 12h4\" /> <path d=\"m16.2 16.2 2.9 2.9\" /> <path d=\"M12 18v4\" /> <path d=\"m4.9 19.1 2.9-2.9\" /> <path d=\"M2 12h4\" /> <path d=\"m4.9 4.9 2.9 2.9\" />", "circle-alert": "<circle cx=\"12\" cy=\"12\" r=\"10\" /> <line x1=\"12\" x2=\"12\" y1=\"8\" y2=\"12\" /> <line x1=\"12\" x2=\"12.01\" y1=\"16\" y2=\"16\" />", "minus": "<path d=\"M5 12h14\" />", "circle-x": "<circle cx=\"12\" cy=\"12\" r=\"10\" /> <path d=\"m15 9-6 6\" /> <path d=\"m9 9 6 6\" />", "x": "<path d=\"M18 6 6 18\" /> <path d=\"m6 6 12 12\" />", "keyboard": "<path d=\"M10 8h.01\" /> <path d=\"M12 12h.01\" /> <path d=\"M14 8h.01\" /> <path d=\"M16 12h.01\" /> <path d=\"M18 8h.01\" /> <path d=\"M6 8h.01\" /> <path d=\"M7 16h10\" /> <path d=\"M8 12h.01\" /> <rect width=\"20\" height=\"16\" x=\"2\" y=\"4\" rx=\"2\" />", "paperclip": "<path d=\"m16 6-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551\" />", "alarm-clock": "<circle cx=\"12\" cy=\"13\" r=\"8\" /> <path d=\"M12 9v4l2 2\" /> <path d=\"M5 3 2 6\" /> <path d=\"m22 6-3-3\" /> <path d=\"M6.38 18.7 4 21\" /> <path d=\"M17.64 18.67 20 21\" />"};
function ic(name, size = 16) { return `<svg class="ic" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${LUCIDE[name] || ""}</svg>`; }
// More icons (Lucide), for the home, the bot's settings and the chat.
Object.assign(LUCIDE, {
"copy": "<rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\" /> <path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\" />",
"refresh": "<path d=\"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8\" /> <path d=\"M21 3v5h-5\" /> <path d=\"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16\" /> <path d=\"M8 16H3v5\" />",
"history": "<path d=\"M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8\" /> <path d=\"M3 3v5h5\" /> <path d=\"M12 7v5l4 2\" />",
"monitor": "<rect width=\"20\" height=\"14\" x=\"2\" y=\"3\" rx=\"2\" /> <line x1=\"8\" x2=\"16\" y1=\"21\" y2=\"21\" /> <line x1=\"12\" x2=\"12\" y1=\"17\" y2=\"21\" />",
"laptop": "<path d=\"M18 5a2 2 0 0 1 2 2v8.526a2 2 0 0 0 .212.897l1.068 2.127a1 1 0 0 1-.9 1.45H3.62a1 1 0 0 1-.9-1.45l1.068-2.127A2 2 0 0 0 4 15.526V7a2 2 0 0 1 2-2z\" /> <path d=\"M20.054 15.987H3.946\" />",
"cloud": "<path d=\"M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z\" />",
"server": "<rect width=\"20\" height=\"8\" x=\"2\" y=\"2\" rx=\"2\" ry=\"2\" /> <rect width=\"20\" height=\"8\" x=\"2\" y=\"14\" rx=\"2\" ry=\"2\" /> <line x1=\"6\" x2=\"6.01\" y1=\"6\" y2=\"6\" /> <line x1=\"6\" x2=\"6.01\" y1=\"18\" y2=\"18\" />",
"file-text": "<path d=\"M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z\" /> <path d=\"M14 2v4a2 2 0 0 0 2 2h4\" /> <path d=\"M10 9H8\" /> <path d=\"M16 13H8\" /> <path d=\"M16 17H8\" />",
"chevron-right": "<path d=\"m9 18 6-6-6-6\" />",
"chevron-down": "<path d=\"m6 9 6 6 6-6\" />",
"sparkles": "<path d=\"M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z\" />",
"check": "<path d=\"M20 6 9 17l-5-5\" />",
"arrow-down": "<path d=\"M12 5v14\" /> <path d=\"m19 12-7 7-7-7\" />",
"plus": "<path d=\"M5 12h14\" /> <path d=\"M12 5v14\" />",
"search": "<circle cx=\"11\" cy=\"11\" r=\"8\" /> <path d=\"m21 21-4.3-4.3\" />",
"pen": "<path d=\"M12 20h9\" /> <path d=\"M16.376 3.622a1 1 0 0 1 3.002 3.002L7.368 18.635a2 2 0 0 1-.855.506l-2.872.838a.5.5 0 0 1-.62-.62l.838-2.872a2 2 0 0 1 .506-.854z\" />",
"camera": "<path d=\"M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z\" /> <circle cx=\"12\" cy=\"13\" r=\"3\" />",
"list-checks": "<path d=\"m3 17 2 2 4-4\" /> <path d=\"m3 7 2 2 4-4\" /> <path d=\"M13 6h8\" /> <path d=\"M13 12h8\" /> <path d=\"M13 18h8\" />",
});
// Times as on a phone: today 「下午 7:41」, earlier 「11/5」.
function fmtTime(ms) {
if (!ms) return "";
const d = new Date(ms), now = new Date();
if (d.toDateString() === now.toDateString()) return d.toLocaleTimeString("zh-TW", {hour: "numeric", minute: "2-digit"});
return `${d.getMonth() + 1}/${d.getDate()}`;
}
// 外觀: follow the phone, or light or dark by hand.
const THEMES = [["", "跟著手機"], ["light", "淺色"], ["dark", "深色"]];
function applyTheme() {
let t = ""; try { t = localStorage.getItem("anybot.theme") || ""; } catch (_) {}
if (t) document.documentElement.dataset.theme = t; else delete document.documentElement.dataset.theme;
const el = document.getElementById("m-theme");
if (el) el.textContent = "外觀:" + (THEMES.find((x) => x[0] === t) || THEMES[0])[1];
}
const $ = (id) => document.getElementById(id);
const S = {
unfold: {}, pages: [], models: [],
relay: false, token: localStorage.getItem("anybot.token") || "", host: localStorage.getItem("anybot.host") || "",
version: 0, bots: [], approvals: [], open: 0, msgs: [], screen: "box", right: false, pending: [], hostName: "", mac: false,
polling: false, frameTimer: null, size: [0, 0],
};
// The app sends each bot's colour as #rrggbb (the same palette as the window).
const color = (c) => (/^#[0-9a-f]{6}$/i.test(String(c || "")) ? c : "#1c1c1c");
// A bot's face as in the app (src/avatar.rs): its shape in its colour, two slanted oval eyes.
// Drawn in a -1..1 box; the phone used to show the name's first letter in a circle.
function avatarSvg(shape, hex) {
const col = color(hex), P = Math.PI, f = (n) => n.toFixed(3);
const pts = (a) => `<polygon points="${a.map(([x, y]) => f(x) + "," + f(y)).join(" ")}"/>`;
const circ = (x, y, r) => `<circle cx="${f(x)}" cy="${f(y)}" r="${f(r)}"/>`;
const range = (n) => [...Array(n).keys()];
let body;
switch (shape) {
case "squircle": body = `<rect x="-0.92" y="-0.92" width="1.84" height="1.84" rx="0.42"/>`; break;
case "capsule": body = `<rect x="-0.675" y="-1" width="1.35" height="2" rx="0.66"/>`; break;
case "egg": body = pts(range(48).map((i) => { const t = i / 48 * 2 * P; return [0.8 * Math.cos(t), 0.08 + (Math.sin(t) < 0 ? 1 : 0.86) * Math.sin(t)]; })); break;
case "hex": body = pts(range(6).map((i) => { const a = i / 6 * 2 * P + P / 6; return [Math.cos(a), Math.sin(a)]; })); break;
case "gem": body = pts([[-0.55, -0.8], [0.55, -0.8], [1, -0.2], [0, 1], [-1, -0.2]]); break;
case "arch": body = pts(range(25).map((i) => { const a = P + i / 24 * P; return [0.85 * Math.cos(a), -0.15 + 0.85 * Math.sin(a)]; }).concat([[0.85, 1], [-0.85, 1]])); break;
case "shield": body = pts([[-0.85, -0.9], [0.85, -0.9], [0.85, 0.05], [0.5, 0.65], [0, 1], [-0.5, 0.65], [-0.85, 0.05]]); break;
case "pebble": body = `<ellipse rx="1" ry="0.78" transform="rotate(-14.3)"/>`; break;
case "cloud": body = circ(-0.45, 0.15, 0.55) + circ(0.45, 0.15, 0.55) + circ(0, -0.2, 0.65) + circ(0, 0.35, 0.55); break;
case "heart": body = circ(-0.45, -0.25, 0.55) + circ(0.45, -0.25, 0.55) + pts([[-0.97, -0.1], [0.97, -0.1], [0, 1]]); break;
case "star": body = pts(range(10).map((i) => { const a = -P / 2 + i * P / 5, r = i % 2 ? 0.48 : 1.05; return [r * Math.cos(a), 0.08 + r * Math.sin(a)]; })); break;
default: body = circ(0, 0, 0.95);
}
const rgb = [1, 3, 5].map((i) => parseInt(col.slice(i, i + 2), 16));
const eye = rgb[0] + rgb[1] + rgb[2] > 600 ? "#1c1c1c" : "#fcfcfc";
const eyes = [-0.27, 0.27].map((x) => `<ellipse cx="${x}" cy="0.02" rx="0.11" ry="0.22" transform="rotate(-20 ${x} 0.02)"/>`).join("");
return `<svg viewBox="-1.1 -1.1 2.2 2.2" aria-hidden="true"><g fill="${col}">${body}</g><g fill="${eye}">${eyes}</g></svg>`;
}
function toast(t) { const e = $("toast"); e.textContent = t; e.classList.add("on"); clearTimeout(toast.t); toast.t = setTimeout(() => e.classList.remove("on"), 2600); }
function show(v) { document.querySelectorAll(".view").forEach((x) => x.classList.toggle("on", x.id === "v-" + v)); S.view = v; if (v !== "screen") stopFrames(); }
function base() { return S.relay ? "h/" + encodeURIComponent(S.host) + "/" : ""; }
function esc(s) { return String(s ?? "").replace(/[&<>"']/g, (c) => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c])); }
async function api(path, opts = {}) {
const headers = Object.assign({}, opts.headers || {});
if (S.token) headers.Authorization = "Bearer " + S.token;
if (opts.json !== undefined) { headers["Content-Type"] = "application/json"; opts.body = JSON.stringify(opts.json); }
const r = await fetch(path, {method: opts.method || (opts.body ? "POST" : "GET"), headers, body: opts.body});
const signingIn = /(^|\/)(login|signup)$/.test(path);
if (r.status === 401 && !signingIn) { signOut(true); throw new Error("請重新登入"); }
if (opts.raw) return r;
const t = await r.text();
let v = {}; try { v = t ? JSON.parse(t) : {}; } catch (_) {}
if (!r.ok) throw new Error(v.error || ("HTTP " + r.status));
return v;
}
// ---------- sign in ----------
async function hello() {
try {
const r = await fetch("api/hello"); const v = await r.json();
S.relay = v.app === "anybot-relay";
} catch (_) { S.relay = false; }
$("f-user").style.display = S.relay ? "" : "none";
$("b-mode").style.display = S.relay ? "" : "none";
$("login-desc").textContent = S.relay ? "用你的 AnyBot 帳號登入。在哪裡都能用,電腦不用開任何連接埠。" : "輸入這台 AnyBot 的手機密碼(在 AnyBot 設定 › 手機與帳號 設定)。";
if (!S.token) return show("login");
if (S.relay) return pickHost();
start();
}
let signingUp = false;
$("b-mode").onclick = () => {
signingUp = !signingUp;
$("f-invite").style.display = signingUp ? "" : "none";
$("b-login").textContent = signingUp ? "建立帳號並登入" : "登入";
$("b-mode").textContent = signingUp ? "已經有帳號?登入" : "還沒有帳號?建立一個";
$("login-title").textContent = signingUp ? "建立 AnyBot 帳號" : "登入 AnyBot";
$("f-pass").placeholder = signingUp ? "密碼(至少 8 個字)" : "密碼";
};
$("b-login").onclick = async () => {
const b = $("b-login"); b.disabled = true; $("login-err").textContent = "";
const device = (navigator.userAgent.match(/iPhone|iPad|Android|Mac|Windows/) || ["手機"])[0];
try {
if (S.relay && signingUp) await api("relay/signup", {json: {user: $("f-user").value.trim(), password: $("f-pass").value, invite: $("f-invite").value.trim()}});
const v = await api(S.relay ? "relay/login" : "api/login", {json: {user: $("f-user").value.trim(), password: $("f-pass").value, device}});
S.token = v.token; localStorage.setItem("anybot.token", S.token); $("f-pass").value = "";
if (S.relay) await pickHost(); else start();
} catch (e) { $("login-err").textContent = e.message; }
b.disabled = false;
};
$("f-pass").addEventListener("keydown", (e) => { if (e.key === "Enter") $("b-login").click(); });
function signOut(expired) {
if (S.token && !expired) api(S.relay ? "relay/logout" : "api/logout", {json: {}}).catch(() => {});
S.token = ""; localStorage.removeItem("anybot.token"); S.version = 0; show("login");
if (expired) $("login-err").textContent = "登入已失效,請重新登入";
}
$("b-out1").onclick = () => signOut();
// The list waits for a brain that is restarting (or a computer that woke up): it looks again
// every few seconds and goes in by itself; it used to sit on 「離線」 until tapped.
let hostRetry = null;
async function pickHost() {
clearTimeout(hostRetry);
let me;
try { me = await api("relay/me"); } catch (e) { toast(e.message); if (S.token) hostRetry = setTimeout(pickHost, 3000); return; }
const all = me.hosts || [];
// A computer that shows another one's bots (大腦) leads to that brain.
const byId = Object.fromEntries(all.map((h) => [h.id, h]));
const target = (id) => (byId[id] && byId[id].uses && byId[byId[id].uses]) ? byId[id].uses : id;
const hosts = all.filter((h) => !(h.uses && byId[h.uses]));
const sharers = (id) => all.filter((h) => h.uses === id).map((h) => h.name);
const live = hosts.filter((h) => h.online);
// Several brains on: the one this account's computers use is the one the chats are on.
const used = live.filter((h) => sharers(h.id).length);
// First visit picks the brain this account's computers use; a choice the person made (or the
// host stored from last time) is kept as it is.
if (!S.host && used.length === 1) S.host = used[0].id;
if (S.host) {
const t = target(S.host);
if (byId[t] && byId[t].online) { S.host = t; localStorage.setItem("anybot.host", t); return start(); }
}
if (live.length === 1) { S.host = live[0].id; localStorage.setItem("anybot.host", S.host); return start(); }
const note = (h) => { const n = sharers(h.id); return n.length ? " · " + esc(n.join("、")) + " 也用這個大腦" : ""; };
const wanted = (S.host && byId[target(S.host)]) || (all.find((h) => sharers(h.id).length)) || hosts[0];
const waiting = hosts.length && !live.length && wanted
? `<div class="empty">「${esc(wanted.name)}」暫時沒有連線(可能正在重新啟動,或那台電腦睡著了)。<br>連上後會自動進入,不用重新整理。</div>` : "";
$("hosts").innerHTML = waiting + (hosts.length ? hosts.map((h) => `<div class="row" data-h="${esc(h.id)}"><div class="av" style="background:${h.online ? "var(--ok)" : "#888"}">${esc((h.name || "?")[0])}</div><div class="mid"><div class="name">${esc(h.name)}</div><div class="last">${h.online ? "在線上" : "離線(" + esc(h.last_seen_text || "很久沒連線") + ")"}${note(h)}</div></div></div>`).join("")
: `<div class="empty">這個帳號還沒有連上任何 AnyBot。<br>在電腦上打開 AnyBot › 設定 › 手機與帳號,登入同一個帳號。</div>`);
document.querySelectorAll("#hosts .row").forEach((r) => r.onclick = () => { clearTimeout(hostRetry); S.host = r.dataset.h; localStorage.setItem("anybot.host", S.host); start(); });
show("hosts");
if (S.token && !live.length) hostRetry = setTimeout(pickHost, 3000);
}
// ---------- live state ----------
// The person's time zone, for a brain on a server that only knows UTC.
const TZ = (() => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || ""; } catch (_) { return ""; } })();
async function start() {
if (S.open) openChat(S.open); else show("list");
if (S.polling) return;
S.polling = true;
while (S.token) {
try {
const v = await api(base() + "api/state?since=" + S.version + "&tz=" + encodeURIComponent(TZ));
if (v.version !== S.version) { S.version = v.version; apply(v); }
} catch (e) {
if (!S.token) break;
if (S.relay && /離線|offline|not connected/i.test(e.message)) toast("那台 AnyBot 目前沒有連線");
await new Promise((r) => setTimeout(r, 2500));
}
}
S.polling = false;
}
function apply(v) {
S.bots = v.bots || []; S.approvals = v.approvals || []; S.hostName = v.host || ""; S.mac = !!v.mac; S.box = v.box;
S.pages = v.pages || []; S.models = v.models || [];
// 團隊: a member only chats; the owner and admins approve and use the screens.
S.role = v.role || "owner";
const member = S.role === "member";
["b-screen", "b-screen2", "b-new"].forEach((id) => $(id).style.display = member ? "none" : "");
$("host-name").textContent = S.hostName ? "· " + S.hostName : "";
renderList();
if (S.open) {
const b = S.bots.find((x) => x.id === S.open);
if (b) chatHead(b);
loadChat(); renderAsk();
}
}
function botName(id) { const b = S.bots.find((x) => x.id === id); return b ? b.name : ""; }
function botRow(b, cls = "row") {
const waiting = S.approvals.some((a) => a.bot === b.id);
const state = waiting ? `<span class="wait">等你決定</span>` : b.busy ? `<span class="busy">${esc(b.status || "工作中…")}</span>` : "";
return `<div class="${cls}" data-id="${b.id}"><div class="av face">${avatarSvg(b.shape, b.color)}</div><div class="mid"><div class="name">${esc(b.name)}${b.members ? '<span class="pill">群組</span>' : ""}${b.unread ? '<span class="badge"></span>' : ""}</div>${cls === "hero" ? `<div class="model">${esc(b.via || "")}</div>` : ""}<div class="last">${state || esc(b.last || b.description || "")}</div></div>${cls === "row" && b.time ? `<div class="time">${fmtTime(b.time)}</div>` : ""}</div>`;
}
const KIND = {own: ["cloud", "Bot 自己的電腦"], this: ["monitor", "這台"], device: ["laptop", "你帳號裡的電腦"], ssh: ["server", "SSH"]};
// The home: the main bot big at the top, the other bots, then its computers, its pages and the
// routines — what the desktop shows beside the chat.
function renderList() {
const el = $("bots");
if (!S.bots.length) { el.innerHTML = `<div class="empty">還沒有 Bot。打開電腦上的 AnyBot,照著畫面建立第一個 Bot,這裡就會出現。</div>`; return; }
const main = S.bots.find((b) => b.primary) || S.bots[0];
const others = S.bots.filter((b) => b !== main);
const member = S.role === "member";
let h = botRow(main, "hero");
if (others.length) h += `<div class="sec">Bot <span class="n">${others.length}</span></div><div class="list">${others.map((b) => botRow(b)).join("")}</div>`;
const comps = (main.computers || []);
if (comps.length && !member) {
h += `<div class="sec">電腦</div><div class="list">` + comps.map((c) => {
const [icon, kind] = KIND[c.kind] || ["monitor", ""];
const state = c.kind === "own" ? (c.online ? esc(S.box || "可以用") : "沒有 Docker") : (c.online ? "在線上" : "離線");
const on = c.kind === "own" ? c.online && !/睡|暫停|關/.test(S.box || "") : c.online;
const btn = c.kind === "own" && c.online ? `<button class="mini" data-box="1">${ic("monitor", 14)} 看畫面</button>` : (c.kind === "this" && S.mac ? `<button class="mini" data-mac="1">${ic("monitor", 14)} 看畫面</button>` : "");
return `<div class="row static"><div class="ico">${ic(icon, 18)}</div><div class="mid"><div class="name">${esc(c.name)}${c.name === main.computer ? '<span class="pill">預設</span>' : ""}</div><div class="last"><span class="${on ? "st-on" : "st-off"}"></span>${state} · ${c.person ? "你正在用的" : kind}</div></div>${btn}</div>`;
}).join("") + `</div>`;
}
if ((S.pages || []).length) {
h += `<div class="sec">頁面 <span class="n">${S.pages.length}</span></div><div class="list">` + S.pages.slice(0, 6).map((p) => `<div class="row" data-page="${p.id}"><div class="ico">${ic("file-text", 18)}</div><div class="mid"><div class="name">${esc(p.title)}</div><div class="last">${esc(p.bot ? p.bot + " · " : "")}${esc(p.updated || "")}</div></div></div>`).join("") + `</div>`;
}
const routines = S.bots.flatMap((b) => (b.routines || []).map((r) => ({...r, bot: b})));
if (routines.length) {
h += `<div class="sec">例行任務 <span class="n">${routines.length}</span></div><div class="list">` + routines.map((r) => `<div class="row" data-id="${r.bot.id}"><div class="ico">${ic("alarm-clock", 18)}</div><div class="mid"><div class="name">${esc(r.title)}${r.paused ? '<span class="pill">已暫停</span>' : ""}</div><div class="last">${esc(r.bot.name)} · ${esc(r.when || "")}</div></div></div>`).join("") + `</div>`;
}
el.innerHTML = h + `<div class="homeend"></div>`;
el.querySelectorAll("[data-id]").forEach((r) => r.onclick = () => openChat(+r.dataset.id));
el.querySelectorAll("[data-box]").forEach((b) => b.onclick = (e) => { e.stopPropagation(); S.open = main.id; openBox(false); });
el.querySelectorAll("[data-mac]").forEach((b) => b.onclick = (e) => { e.stopPropagation(); $("m-mac").click(); });
}
// ---------- chat ----------
function chatHead(b) {
$("chat-name").textContent = b.name || ""; $("chat-av").innerHTML = avatarSvg(b.shape, b.color); $("chat-av").classList.add("face");
$("chat-model").innerHTML = b.via ? `${esc(b.via)} ${ic("chevron-down", 12)}` : "";
}
function openChat(id) {
S.open = id; S.msgs = []; S.pending = []; renderChips();
const b = S.bots.find((x) => x.id === id) || {};
chatHead(b); S.unfold = {};
$("msgs").innerHTML = ""; show("chat"); loadChat(true); renderAsk();
api(base() + `api/bots/${id}/read`, {json: {}}).catch(() => {});
}
$("b-back").onclick = () => { S.open = 0; show("list"); };
let loading = false, again = false;
async function loadChat(jump) {
if (loading) { again = true; return; }
loading = true;
try {
const v = await api(base() + "api/bots/" + S.open);
const sc = $("scroller"); const near = sc.scrollHeight - sc.scrollTop - sc.clientHeight < 120;
S.msgs = v.messages || []; renderMsgs();
if (jump || near) sc.scrollTop = sc.scrollHeight;
} catch (e) { toast(e.message); }
loading = false;
if (again) { again = false; loadChat(); }
}
function md(text) {
const blocks = String(text || "").split(/```/);
return blocks.map((part, i) => {
if (i % 2) { const nl = part.indexOf("\n"); return `<pre><code>${esc(nl >= 0 ? part.slice(nl + 1) : part)}</code></pre>`; }
let h = esc(part)
.replace(/`([^`\n]+)`/g, "<code>$1</code>")
.replace(/\*\*([^*\n]+)\*\*/g, "<b>$1</b>")
.replace(/\[([^\]]+)\]\(page:(\d+)\)/g, (_, t, id) => `${GLYPH(14)}<a class="plink" data-page="${id}">${t}</a>`)
.replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
.replace(/(^|[\s(])(https?:\/\/[^\s<)]+)/g, '$1<a href="$2" target="_blank" rel="noopener">$2</a>');
const out = []; let list = null;
for (const line of h.split("\n")) {
const li = line.match(/^\s*(?:[-*•]|\d+[.)])\s+(.*)$/);
if (li) { if (!list) { list = []; } list.push(`<li>${li[1]}</li>`); continue; }
if (list) { out.push(`<ul>${list.join("")}</ul>`); list = null; }
const hd = line.match(/^(#{1,3})\s+(.*)$/);
if (hd) out.push(`<h3>${hd[2]}</h3>`);
else if (/^&gt;\s?/.test(line)) out.push(`<blockquote>${line.replace(/^&gt;\s?/, "")}</blockquote>`);
else out.push(line ? `<p>${line}</p>` : "");
}
if (list) out.push(`<ul>${list.join("")}</ul>`);
return out.join("");
}).join("");
}
const ST = {waiting: ic("circle-alert", 14), running: ic("loader", 14), done: ic("circle-check", 14), denied: ic("minus", 14), failed: ic("circle-x", 14)};
// What a new chat offers: the everyday jobs, one tap to start writing them.
const SUGGEST = [
["search", "海巡 Threads", "幫我在 Threads 海巡「」最近的熱門討論,整理互動最多的 5 則(誰發的、在講什麼、讚數、連結)。"],
["pen", "寫一則貼文", "用我 Threads 帳號平常的語氣,幫我寫一則關於「」的貼文草稿,先給我看,不要發。"],
["camera", "看我電腦的畫面", "截一張我電腦現在的畫面給我看。"],
["list-checks", "幫我研究一件事", "幫我研究「」:查清楚現況和來源,整理成一頁。"],
];
function stepRow(t) { return `<div class="tool ${t.status}"><span class="st">${ST[t.status] || "·"}</span><span>${esc(t.label)}</span><span class="d">${esc(t.detail)}</span></div>`; }
function renderMsgs() {
const bot = S.bots.find((b) => b.id === S.open) || {};
const busy = bot.busy;
// A turn: the bot's messages between two of the person's. Its steps fold into one line once it
// is done (a long job listed dozens of them); while it works, the last few stay in view.
const turns = []; let cur = null;
S.msgs.forEach((m, i) => {
if (m.role === "user" || m.summary) { cur = null; return; }
if (!cur) { cur = {first: i, last: i, key: String(m.at || i), steps: 0}; turns.push(cur); }
cur.last = i; cur.steps += (m.tools || []).filter((t) => !t.page && !t.card).length;
});
const turnOf = {}; turns.forEach((t) => { for (let i = t.first; i <= t.last; i++) turnOf[i] = t; });
const lastTurn = turns[turns.length - 1];
let shownSteps = {};
const html = S.msgs.map((m, i) => {
if (m.role === "user") {
const atts = (m.attachments || []).map(attHtml).join("");
if (m.summary) return "";
return `<div class="m user">${m.wake ? `<div class="via">${ic("alarm-clock", 13)} 例行任務</div>` : ""}${m.text ? `<div class="bubble">${esc(m.text)}</div>` : ""}${atts}</div>`;
}
if (m.summary) return `<div class="m bot"><div class="via">(較早的對話已整理成摘要)</div></div>`;
const t = turnOf[i];
const running = busy && t === lastTurn;
const open = t && (S.unfold[t.key] || t.steps <= 2);
let tools = "";
for (const x of (m.tools || [])) {
if (x.page) { tools += pageCard(x.page); continue; }
if (x.card) { tools += card(x.card); continue; }
const n = (shownSteps[t.key] = (shownSteps[t.key] || 0) + 1);
if (open) { tools += stepRow(x); continue; }
// Folded: one line where the first step was; while working, the newest three still show.
if (n === 1) tools += `<div class="fold ${running ? "run" : ""}" data-turn="${esc(t.key)}">${running ? ST.running : ic("circle-check", 14)} ${running ? "已做了" : "已完成"} ${t.steps} 個步驟 ${ic("chevron-right", 13)}</div>`;
if (running && n > t.steps - 3) tools += stepRow(x);
}
const last = i === S.msgs.length - 1;
const body = m.text ? `<div class="bubble">${md(m.text)}</div>` : (last && busy && !tools ? `<div class="typing"><i></i><i></i><i></i></div>` : "");
const who = m.author ? `<div class="who">${esc(m.author)}</div>` : "";
const end = t && i === t.last && !running;
const via = m.text && m.via && end ? `<div class="via">${esc(m.via)}${m.at ? " · " + fmtTime(m.at) : ""}${(m.reactions || []).length ? " · " + m.reactions.join(" ") : ""}</div>` : "";
const acts = end && m.text && !m.error ? `<div class="acts"><button data-copy="${i}" title="複製">${ic("copy", 15)}</button>${t === lastTurn && S.role !== "member" ? `<button data-regen="1" title="重新產生這個回答">${ic("refresh", 15)}</button>` : ""}</div>` : "";
const atts = (m.attachments || []).map(attHtml).join("");
return `<div class="m bot ${m.error ? "err" : ""}">${who}${body}${tools}${atts}${via}${acts}</div>`;
}).join("");
if (!html) {
const name = esc(bot.name || "Bot");
$("msgs").innerHTML = `<div class="sugg"><div class="av face">${avatarSvg(bot.shape, bot.color)}</div><div class="hi">嗨,我是${name}</div><div class="sub">想做什麼?點一個開始,或直接打字。</div>${SUGGEST.map((x, k) => `<button data-sugg="${k}">${ic(x[0], 18)} ${esc(x[1])}</button>`).join("")}</div>`;
} else {
$("msgs").innerHTML = html;
}
$("b-send").innerHTML = busy && !$("input").value.trim() && !S.pending.length
? `<svg viewBox="0 0 24 24" fill="currentColor"><rect x="7" y="7" width="10" height="10" rx="2"/></svg>`
: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M12 19V5M6 11l6-6 6 6"/></svg>`;
loadFiles();
}
$("msgs").addEventListener("click", async (e) => {
const f = e.target.closest(".fold");
if (f) { S.unfold[f.dataset.turn] = true; renderMsgs(); return; }
const c = e.target.closest("[data-copy]");
if (c) {
const m = S.msgs[+c.dataset.copy];
try { await navigator.clipboard.writeText(m.text || ""); toast("已複製"); } catch (_) { toast("這支手機不讓網頁複製"); }
return;
}
if (e.target.closest("[data-regen]")) { try { await api(base() + `api/bots/${S.open}/regenerate`, {json: {}}); toast("重新產生中…"); } catch (err) { toast(err.message); } return; }
const sg = e.target.closest("[data-sugg]");
if (sg) {
const text = SUGGEST[+sg.dataset.sugg][2];
const input = $("input"); input.value = text; input.focus();
const at = text.indexOf("「」"); if (at >= 0) input.setSelectionRange(at + 1, at + 1);
input.dispatchEvent(new Event("input"));
}
});
// ---------- the bot's settings (tap its name) ----------
// Its model, its computer, its past conversations; the same choices as the desktop's menu.
function openBotSheet() {
const b = S.bots.find((x) => x.id === S.open);
if (!b) return;
const owner = S.role !== "member";
let h = `<div class="head"><div class="av face">${avatarSvg(b.shape, b.color)}</div><div style="min-width:0"><div class="n">${esc(b.name)}</div><div class="m">${esc(b.description || "")}</div></div></div>`;
if (owner && (S.models || []).length) {
h += `<details><summary>${ic("sparkles", 18)} 模型<span class="v">${esc(b.via || "")}</span>${ic("chevron-right", 15).replace('class="ic"', 'class="ic chev"')}</summary>` +
S.models.map((p) => `<div class="grp">${esc(p.label)}</div>` + (p.models || []).map((m) => `<button class="opt" data-provider="${esc(p.provider)}" data-model="${esc(m)}">${esc(m)}${p.provider === b.provider && m === b.model ? `<span class="ck">${ic("check", 16)}</span>` : ""}</button>`).join("")).join("") + `</details>`;
}
if (owner && (b.computers || []).length) {
h += `<details><summary>${ic("monitor", 18)} 用哪台電腦<span class="v">${esc(b.computer || "")}</span>${ic("chevron-right", 15).replace('class="ic"', 'class="ic chev"')}</summary>` +
b.computers.map((c) => `<button class="opt" data-computer="${esc(c.name)}"><span class="${c.online ? "st-on" : "st-off"}"></span>${esc(c.name)}${c.name === b.computer ? `<span class="ck">${ic("check", 16)}</span>` : ""}</button>`).join("") + `</details>`;
}
if ((b.threads || []).length) {
h += `<details><summary>${ic("history", 18)} 過去的對話<span class="v">${b.threads.length} 個</span>${ic("chevron-right", 15).replace('class="ic"', 'class="ic chev"')}</summary>` +
b.threads.map((t) => `<button class="opt" data-thread="${t.index}">${esc(t.title || "對話")}<span class="ck" style="color:var(--text3);font-size:12px">${fmtTime(t.at)}</span></button>`).join("") + `</details>`;
}
if (owner) h += `<button class="act" data-sheet="regen">${ic("refresh", 18)} 重新產生上一個回答</button><button class="act" data-sheet="new">${ic("plus", 18)} 開始新對話</button>`;
h += `<button class="act" data-sheet="close">${ic("x", 18)} 關閉</button>`;
$("botsheet-in").innerHTML = h;
$("botsheet").classList.add("on");
}
$("b-botinfo").onclick = openBotSheet;
$("botsheet").addEventListener("click", async (e) => {
if (e.target.id === "botsheet") { $("botsheet").classList.remove("on"); return; }
const done = (msg) => { $("botsheet").classList.remove("on"); if (msg) toast(msg); };
const o = e.target.closest(".opt, .act");
if (!o) return;
try {
if (o.dataset.model) { await api(base() + `api/bots/${S.open}/set`, {json: {provider: o.dataset.provider, model: o.dataset.model}}); done("之後用 " + o.dataset.model + " 回答"); }
else if (o.dataset.computer) { await api(base() + `api/bots/${S.open}/set`, {json: {computer: o.dataset.computer}}); done("之後預設在「" + o.dataset.computer + "」做事"); }
else if (o.dataset.thread) {
if (!confirm("回到這段對話?現在的對話會留在「過去的對話」裡。")) return;
await api(base() + `api/bots/${S.open}/restore`, {json: {index: +o.dataset.thread}}); done("已回到那段對話"); S.unfold = {}; setTimeout(() => loadChat(true), 800);
}
else if (o.dataset.sheet === "regen") { await api(base() + `api/bots/${S.open}/regenerate`, {json: {}}); done("重新產生中…"); }
else if (o.dataset.sheet === "new") { done(); $("b-new").click(); }
else done();
} catch (err) { toast(err.message); }
});
$("m-theme").onclick = () => {
let t = ""; try { t = localStorage.getItem("anybot.theme") || ""; } catch (_) {}
const next = THEMES[(THEMES.findIndex((x) => x[0] === t) + 1) % THEMES.length][0];
try { localStorage.setItem("anybot.theme", next); } catch (_) {}
applyTheme();
};
applyTheme();
// To the newest message, when scrolled up.
$("b-down").innerHTML = ic("arrow-down", 18);
$("scroller").addEventListener("scroll", () => { const sc = $("scroller"); $("b-down").classList.toggle("on", sc.scrollHeight - sc.scrollTop - sc.clientHeight > 400); });
$("b-down").onclick = () => { const sc = $("scroller"); sc.scrollTo({top: sc.scrollHeight, behavior: "smooth"}); };
function card(c) {
if (!c) return "";
const rows = (c.rows || []).map((r) => `<div class="r"><span>${esc(r.label)}</span><span>${esc(r.value)}</span></div>`).join("");
return `<div class="card"><div class="t">${esc(c.title)}</div>${c.subtitle ? `<div class="s">${esc(c.subtitle)}</div>` : ""}${rows}${c.link ? `<div class="r"><a href="${esc(c.link)}" target="_blank" rel="noopener">來源</a><span></span></div>` : ""}</div>`;
}
// ---------- 頁面 ----------
const GLYPH = (h = 34) => `<svg class="pglyph" width="${Math.round(h * .8)}" height="${h}" viewBox="0 0 24 30" style="vertical-align:-3px"><rect x=".5" y=".5" width="23" height="29" rx="4" fill="var(--raise)" stroke="var(--line)"/><path d="M5 7h9M5 11h6" stroke="var(--text3)" stroke-width="2" stroke-linecap="round"/><rect x="5" y="20" width="2.6" height="5" rx=".6" fill="#34a853"/><rect x="9" y="18" width="2.6" height="7" rx=".6" fill="#fbbc05"/><rect x="13" y="16" width="2.6" height="9" rx=".6" fill="#ea4335"/><rect x="17" y="14" width="2.6" height="11" rx=".6" fill="#4285f4"/></svg>`;
function pageCard(p) {
const lines = (p.preview || []).map((l, i, a) => `<div class="l" style="opacity:${1 - (i / Math.max(a.length, 1)) * .65}">${esc(l)}</div>`).join("");
const meta = `頁面 · ${esc(p.updated)}${p.revision > 1 ? " · 已更新" : ""}`;
return `<div class="pcard" data-page="${p.id}"><div class="top"><div class="t">${esc(p.title)}</div>${lines}</div>${p.revision > 1 && p.note ? `<div class="note">最近更新:${esc(p.note)}</div>` : ""}<div class="foot">${GLYPH()}<div style="min-width:0"><div class="n">${esc(p.title)}</div><span class="m">${meta}</span></div></div></div>`;
}
document.addEventListener("click", (e) => {
const el = e.target.closest && e.target.closest("[data-page]");
if (el) { e.preventDefault(); openPage(+el.dataset.page); }
});
async function openPage(id, rev) {
if (S.view !== "page") S.pageBack = S.view;
S.page = id; show("page"); $("pscroll").scrollTop = 0;
if (!rev) $("page").innerHTML = `<div class="empty">載入中…</div>`;
try {
const v = await api(base() + "api/pages/" + id + (rev ? "?rev=" + rev : ""));
$("page-name").textContent = v.title;
const old = v.revision !== v.latest;
const sel = $("pver");
if ((v.versions || []).length) {
sel.innerHTML = `<option value="">第 ${v.latest} 版(最新)</option>` + v.versions.map((x) => `<option value="${x.revision}">第 ${x.revision} 版 · ${esc(x.at)}</option>`).join("");
sel.value = old ? String(v.revision) : ""; sel.style.display = "";
} else sel.style.display = "none";
let head = `<h1 class="pt">${esc(v.title)}</h1><div class="meta">${esc(v.bot)} · 更新於 ${esc(v.updated)}${v.latest > 1 ? ` · 第 ${v.revision} 版` : ""}</div>`;
if (old) head += `<div class="old">你在看舊的版本。<a class="plink" data-page="${id}">回到最新版</a></div>`;
else if (v.summary) head += `<p class="sum">${esc(v.summary)}</p>`;
if (v.note && v.revision > 1) head += `<div class="chg">這一版更新了:${esc(v.note)}</div>`;
$("page").innerHTML = head + pageMd(v.body, id, old ? v.revision : 0);
loadCharts();
} catch (e) { $("page").innerHTML = `<div class="empty">${esc(e.message)}</div>`; }
}
$("pver").onchange = () => openPage(S.page, +$("pver").value || 0);
$("b-back3").onclick = () => { show(S.pageBack && S.pageBack !== "page" ? S.pageBack : "list"); if (S.view === "chat") loadChat(); };
// Files in a chat: pictures shown in place (a screen the bot showed, a photo), other files open on a tap.
// Fetched with the login (an <img src> can't send it), kept for the next redraw.
const FILES = {};
function attHtml(a) {
if (!a.path) return `<div class="att">${ic(a.image ? "image" : "file", 14)} ${esc(a.name)}</div>`;
if (a.image) return `<div class="attimg" data-file="${esc(a.path)}">${FILES[a.path] ? `<img alt="${esc(a.name)}" src="${FILES[a.path]}">` : `<div class="wait">${ic("image", 14)} ${esc(a.name)}</div>`}</div>`;
return `<a class="att" data-file="${esc(a.path)}" data-name="${esc(a.name)}">${ic("file", 14)} ${esc(a.name)}</a>`;
}
async function fileUrl(path) {
if (!FILES[path]) {
const r = await api(base() + "api/file?path=" + encodeURIComponent(path), {raw: true});
if (!r.ok) throw new Error("找不到這個檔案");
FILES[path] = URL.createObjectURL(await r.blob());
}
return FILES[path];
}
async function loadFiles() {
for (const el of document.querySelectorAll(".attimg[data-file]")) {
if (el.querySelector("img")) continue;
try { const u = await fileUrl(el.dataset.file); el.innerHTML = `<img alt="" src="${u}">`; } catch (_) { el.innerHTML = `<div class="wait">這張圖讀不到</div>`; }
}
}
document.addEventListener("click", async (e) => {
const img = e.target.closest && e.target.closest(".attimg img");
if (img) { window.open(img.src, "_blank"); return; }
const f = e.target.closest && e.target.closest("a.att[data-file]");
if (!f) return;
e.preventDefault();
try { const u = await fileUrl(f.dataset.file); const a = document.createElement("a"); a.href = u; a.download = f.dataset.name || "檔案"; document.body.appendChild(a); a.click(); a.remove(); } catch (err) { toast(err.message); }
});
const dark = () => matchMedia("(prefers-color-scheme: dark)").matches;
async function loadCharts() {
for (const el of document.querySelectorAll(".chart[data-src]")) {
const src = el.dataset.src; el.removeAttribute("data-src");
try {
const r = await api(base() + src + (src.includes("?") ? "&" : "?") + "dark=" + (dark() ? 1 : 0), {raw: true});
if (!r.ok) throw new Error();
el.innerHTML = `<img alt="圖表" src="${URL.createObjectURL(await r.blob())}">`;
} catch (_) { el.innerHTML = `<div class="wait">這張圖畫不出來</div>`; }
}
}
// Inline Markdown: code, bold, italics, strike, links (page:N opens the page).
function inl(s) {
return esc(s)
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>")
.replace(/(^|[^*])\*([^*\s][^*]*)\*/g, "$1<i>$2</i>")
.replace(/~~([^~]+)~~/g, "<s>$1</s>")
.replace(/\[([^\]]+)\]\(page:(\d+)\)/g, (_, t, id) => `<a class="plink" data-page="${id}">${t}</a>`)
.replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
.replace(/(^|[\s((])(https?:\/\/[^\s<))]+)/g, '$1<a href="$2" target="_blank" rel="noopener">$2</a>');
}
const CALL = {note: "說明", tip: "提示", warning: "注意", important: "重要"};
// A page's Markdown: headings, paragraphs, lists and tasks, tables, quotes and call-outs, code,
// Mermaid charts (drawn by the AnyBot as SVG), ```stats tiles and ```steps progress.
function pageMd(text, id, rev) {
const L = String(text || "").replace(/\r/g, "").split("\n");
const out = []; let i = 0, chart = 0;
const cells = (l) => l.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim());
while (i < L.length) {
const l = L[i], t = l.trim();
if (!t) { i++; continue; }
const fence = t.match(/^```\s*([\w-]*)/);
if (fence) {
const lang = fence[1].toLowerCase(); const body = []; i++;
while (i < L.length && !L[i].trim().startsWith("```")) body.push(L[i++]);
i++;
if (lang === "mermaid") { out.push(`<div class="chart" data-src="api/pages/${id}/chart/${chart++}${rev ? "?rev=" + rev : ""}"><div class="wait">圖表載入中…</div></div>`); continue; }
if (lang === "stats") {
const tiles = body.map(cells).filter((c) => c.length >= 2 && c[0]).map((c) => `<div class="stat"><div class="k">${inl(c[0])}</div><div class="v${c[1].length > 10 ? " long" : ""}">${inl(c[1])}</div>${c[2] ? `<div class="s">${inl(c[2])}</div>` : ""}</div>`);
if (tiles.length) { out.push(`<div class="stats">${tiles.join("")}</div>`); continue; }
}
if (lang === "steps") {
const items = body.map((b) => b.trim().replace(/^[-*]\s*/, "").match(/^\[(x|X|✓|>|~|-| )\]\s*(.*)$/)).filter(Boolean).map((m) => {
const st = /[xX✓]/.test(m[1]) ? "done" : /[>~-]/.test(m[1]) ? "now" : "todo";
const [nm, nt] = m[2].split(/\s*\|\s*/, 2);
return `<li class="${st}"><span class="dot">${st === "done" ? "✓" : ""}</span><div class="nm">${inl(nm)}</div>${nt ? `<div class="nt">${inl(nt)}</div>` : ""}</li>`;
});
if (items.length) { out.push(`<ul class="steps">${items.join("")}</ul>`); continue; }
}
out.push(`<pre><code>${esc(body.join("\n"))}</code></pre>`); continue;
}
const h = t.match(/^(#{1,6})\s+(.*)$/);
if (h) { const n = Math.min(h[1].length + 1, 4); out.push(`<h${n}>${inl(h[2])}</h${n}>`); i++; continue; }
if (/^(-{3,}|\*{3,}|_{3,})$/.test(t)) { out.push("<hr>"); i++; continue; }
if (t.startsWith("|") && i + 1 < L.length && /^\|?\s*:?-{2,}/.test(L[i + 1].trim())) {
const head = cells(t); i += 2; const rows = [];
while (i < L.length && L[i].trim().startsWith("|")) rows.push(cells(L[i++]));
out.push(`<div class="tw"><table><thead><tr>${head.map((c) => `<th>${inl(c)}</th>`).join("")}</tr></thead><tbody>${rows.map((r) => `<tr>${r.map((c) => `<td>${inl(c)}</td>`).join("")}</tr>`).join("")}</tbody></table></div>`);
continue;
}
if (t.startsWith(">")) {
const q = []; while (i < L.length && L[i].trim().startsWith(">")) q.push(L[i++].trim().replace(/^>\s?/, ""));
const c = (q[0] || "").match(/^\[!(NOTE|INFO|TIP|WARNING|CAUTION|IMPORTANT)\]\s*(.*)$/i);
if (c) {
const k = {info: "note", caution: "warning"}[c[1].toLowerCase()] || c[1].toLowerCase();
q[0] = c[2];
out.push(`<div class="call ${k}"><div class="ct">${CALL[k]}</div>${pageMd(q.join("\n"), id, rev)}</div>`);
} else out.push(`<blockquote>${pageMd(q.join("\n"), id, rev)}</blockquote>`);
continue;
}
if (/^([-*+•]|\d+[.)])\s+/.test(t)) {
const ordered = /^\d/.test(t); const items = [];
while (i < L.length && /^\s*([-*+•]|\d+[.)])\s+/.test(L[i])) {
let it = L[i++].trim().replace(/^([-*+•]|\d+[.)])\s+/, "");
const task = it.match(/^\[( |x|X)\]\s+(.*)$/);
items.push(task ? `<li class="task">${task[1] === " " ? "☐" : "☑"} ${inl(task[2])}</li>` : `<li>${inl(it)}</li>`);
}
out.push(ordered ? `<ol>${items.join("")}</ol>` : `<ul>${items.join("")}</ul>`); continue;
}
const para = [];
while (i < L.length && L[i].trim() && !/^(#{1,6}\s|```|>|\||([-*+•]|\d+[.)])\s)/.test(L[i].trim())) para.push(L[i++].trim());
if (!para.length) { para.push(t); i++; }
out.push(`<p>${para.map(inl).join("<br>")}</p>`);
}
return out.join("");
}
function renderAsk() {
const a = S.approvals.find((x) => x.bot === S.open);
if (!a) { $("ask").innerHTML = ""; return; }
if (S.role === "member") { $("ask").innerHTML = `<div class="ask"><div class="q">${esc(a.title)}</div><div class="why">等擁有者或管理員決定</div></div>`; return; }
// 需要處理 (a step only you can do on a computer): its screen is one tap away, then 我完成了 / 跳過.
if (a.kind === "handoff") {
$("ask").innerHTML = `<div class="ask"><div class="q">需要處理 · ${esc(a.computer || "")}</div><div class="why">${esc(a.reason)}</div><div class="btns"><button class="btn" data-screen="1">接管</button><button class="btn main" data-a="once">我完成了</button><button class="btn no" data-a="reject" title="取消此要求且不執行該步驟;Bot 會跳過它並繼續">跳過</button></div></div>`;
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
$("ask").querySelector("[data-screen]").onclick = () => openBox(true);
} else {
$("ask").innerHTML = `<div class="ask"><div class="q">${esc(a.title)}</div><div class="why">${esc(a.reason)}</div><div class="btns"><button class="btn main" data-a="once">允許一次</button>${a.can_always ? `<button class="btn" data-a="always">一律允許</button>` : ""}<button class="btn no" data-a="reject">拒絕</button></div></div>`;
}
$("ask").querySelectorAll("button[data-a]").forEach((b) => b.onclick = async () => {
$("ask").innerHTML = "";
try { await api(base() + `api/bots/${S.open}/answer`, {json: {answer: b.dataset.a}}); } catch (e) { toast(e.message); }
});
}
const input = $("input");
input.addEventListener("input", () => { input.style.height = "auto"; input.style.height = Math.min(input.scrollHeight, 140) + "px"; renderMsgs(); });
input.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey && !e.isComposing && matchMedia("(pointer:fine)").matches) { e.preventDefault(); send(); } });
$("b-send").onclick = send;
async function send() {
const text = input.value.trim();
const busy = (S.bots.find((b) => b.id === S.open) || {}).busy;
if (!text && !S.pending.length) {
if (busy && S.role !== "member") { try { await api(base() + `api/bots/${S.open}/stop`, {json: {}}); toast("已停止"); } catch (e) { toast(e.message); } }
return;
}
const attachments = S.pending.map((p) => p.path);
input.value = ""; input.style.height = "auto"; S.pending = []; renderChips();
S.msgs.push({role: "user", text, attachments: []}); renderMsgs(); $("scroller").scrollTop = 1e9;
try { await api(base() + `api/bots/${S.open}/send`, {json: {text, attachments}}); } catch (e) { toast(e.message); input.value = text; }
}
$("b-new").onclick = async () => { if (!confirm("開始新對話?目前的對話會留在討論串裡。")) return; try { await api(base() + `api/bots/${S.open}/new`, {json: {}}); } catch (e) { toast(e.message); } };
$("b-clip").onclick = () => $("file").click();
$("file").onchange = async (e) => {
for (const f of e.target.files) {
toast("上傳 " + f.name + "…");
try {
const r = await api(base() + `api/bots/${S.open}/upload?name=` + encodeURIComponent(f.name), {method: "POST", body: f, headers: {"Content-Type": "application/octet-stream"}});
S.pending.push(r); renderChips();
} catch (err) { toast(err.message); }
}
e.target.value = "";
};
function renderChips() {
$("chips").innerHTML = S.pending.map((p, i) => `<span class="chip">${ic(p.image ? "image" : "file", 14)} ${esc(p.name)} <button data-i="${i}">${ic("x", 12)}</button></span>`).join("");
$("chips").querySelectorAll("button").forEach((b) => b.onclick = () => { S.pending.splice(+b.dataset.i, 1); renderChips(); });
}
// ---------- screens ----------
// Each bot's own computer goes by the bot's name (「Master 的電腦」).
function ownComputer() { const n = botName(S.open); return n ? n + " 的電腦" : "Bot 的電腦"; }
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
// The bots' computer: live in the viewer page (VNC), where you can also take over.
function openBox(control) {
const at = S.relay ? "host=" + encodeURIComponent(S.host) : "lan=1";
location.href = "screen#" + at + "&name=" + encodeURIComponent(ownComputer()) + (control ? "&control=1" : "");
}
$("b-screen").onclick = $("b-screen2").onclick = () => openBox(false);
$("m-mac").onclick = () => { $("sheet").classList.remove("on"); S.back = S.view; S.screen = "mac"; show("screen"); startFrames(); };
$("b-back2").onclick = () => show(S.back === "chat" ? "chat" : "list");
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
// This Mac's screen: a picture at a time.
let lastUrl = null;
const nap = (ms) => new Promise((r) => setTimeout(r, ms));
function startFrames() { stopFrames(); const run = S.frameRun = {}; frames(run); }
function stopFrames() { S.frameRun = null; }
async function frames(run) {
while (S.frameRun === run) {
if (document.hidden) { await nap(800); continue; }
try {
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
const r = await api(base() + "api/screen/mac", {raw: true});
if (S.frameRun !== run) return;
if (!r.ok) { const v = await r.json().catch(() => ({})); $("stage").innerHTML = `<div class="msg">${esc(v.error || "看不到畫面")}</div>`; $("screen-live").textContent = ""; await nap(1500); continue; }
S.size = (r.headers.get("X-Size") || "0x0").split("x").map(Number);
const url = URL.createObjectURL(await r.blob());
let img = $("stage").querySelector("img");
if (!img) { $("stage").innerHTML = ""; img = document.createElement("img"); img.draggable = false; $("stage").appendChild(img); bindStage(img); }
img.src = url; if (lastUrl) URL.revokeObjectURL(lastUrl); lastUrl = url;
$("screen-live").innerHTML = `<span class="live"></span>即時`;
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
await nap(700);
} catch (e) { $("screen-live").textContent = ""; await nap(1500); }
}
}
function point(img, e) {
const r = img.getBoundingClientRect();
const w = S.size[0] || img.naturalWidth, h = S.size[1] || img.naturalHeight;
return {x: Math.round((e.clientX - r.left) / r.width * w), y: Math.round((e.clientY - r.top) / r.height * h), cx: e.clientX - $("stage").getBoundingClientRect().left, cy: e.clientY - $("stage").getBoundingClientRect().top};
}
function bindStage(img) {
let pressTimer = null, long = false;
img.addEventListener("pointerdown", (e) => { long = false; pressTimer = setTimeout(() => { long = true; act(img, e, "right"); }, 550); });
img.addEventListener("pointerup", (e) => { clearTimeout(pressTimer); if (!long) act(img, e, S.right ? "right" : "left"); });
img.addEventListener("pointercancel", () => clearTimeout(pressTimer));
img.addEventListener("dblclick", (e) => { const p = point(img, e); input2({kind: "click", x: p.x, y: p.y, count: 2}); });
}
function act(img, e, button) {
const p = point(img, e);
const d = document.createElement("div"); d.className = "dot"; d.style.left = p.cx + "px"; d.style.top = p.cy + "px"; $("stage").appendChild(d); setTimeout(() => d.remove(), 600);
input2({kind: "click", x: p.x, y: p.y, button});
}
async function input2(body) {
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
try { await api(base() + "api/screen/mac", {json: body}); } catch (e) { toast(e.message); }
}
document.querySelectorAll("[data-key]").forEach((b) => b.onclick = () => input2({kind: "key", key: b.dataset.key}));
document.querySelectorAll("[data-scroll]").forEach((b) => b.onclick = () => input2({kind: "scroll", x: Math.round(S.size[0] / 2), y: Math.round(S.size[1] / 2), direction: b.dataset.scroll, amount: 5}));
$("k-type").onclick = () => { $("typebar").classList.toggle("on"); if ($("typebar").classList.contains("on")) $("typein").focus(); };
$("b-type").onclick = () => { const t = $("typein").value; if (!t) return; $("typein").value = ""; input2({kind: "type", text: t}); };
$("typein").addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.isComposing) { e.preventDefault(); $("b-type").click(); } });
$("k-right").onclick = () => { S.right = !S.right; $("k-right").textContent = "右鍵:" + (S.right ? "開" : "關"); };
// ---------- menu, notifications ----------
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
$("b-more").onclick = () => { $("m-mac").style.display = S.mac ? "" : "none"; $("m-host").style.display = S.relay ? "" : "none"; $("m-pair").style.display = S.relay ? "" : "none"; notifyLabel(); $("sheet").classList.add("on"); };
$("sheet").onclick = (e) => { if (e.target.id === "sheet") $("sheet").classList.remove("on"); };
$("m-cancel").onclick = () => $("sheet").classList.remove("on");
$("m-out").onclick = () => { $("sheet").classList.remove("on"); if (confirm("要登出這支手機嗎?")) signOut(); };
$("m-host").onclick = () => { $("sheet").classList.remove("on"); S.host = ""; localStorage.removeItem("anybot.host"); S.version = 0; pickHost(); };
// 加一台電腦: a code for a new computer (ten minutes, once), as hands for this AnyBot.
$("m-pair").onclick = async () => {
$("sheet").classList.remove("on");
$("pair-for").textContent = $("host-name").textContent || "這台 AnyBot";
$("pair-code").textContent = "······"; $("pair-line").textContent = "正在產生…"; $("pair-left").textContent = "";
$("pair").classList.add("on");
try {
const v = await api("relay/pair", {json: {for: S.host}});
const line = `curl -fsSL https://code.30cm.net/daniel.w/AnyBot/raw/branch/main/install.sh | sh -s -- hands ${location.origin} ${v.code}`;
$("pair-code").textContent = v.code.slice(0, 3) + " " + v.code.slice(3);
$("pair-line").textContent = line;
const end = Date.now() + (v.expires_in || 600) * 1000;
clearInterval(S.pairTimer);
S.pairTimer = setInterval(() => {
const left = Math.max(0, Math.round((end - Date.now()) / 1000));
$("pair-left").textContent = left ? `還有 ${Math.floor(left / 60)} 分 ${String(left % 60).padStart(2, "0")} 秒,只能用一次` : "過期了,關掉再按一次「加一台電腦」";
if (!left) clearInterval(S.pairTimer);
}, 1000);
} catch (e) { $("pair-line").textContent = "沒有拿到配對碼:" + e.message; }
};
$("pair-copy").onclick = async () => {
const line = $("pair-line").textContent;
if (!line.startsWith("curl")) return;
try { await navigator.clipboard.writeText(line); toast("已複製"); } catch (_) { toast("請長按上面那一行複製"); }
};
$("pair-close").onclick = () => { clearInterval(S.pairTimer); $("pair").classList.remove("on"); };
$("pair").onclick = (e) => { if (e.target.id === "pair") $("pair-close").click(); };
const pushOK = () => S.relay && "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
async function notifyLabel() {
const b = $("m-notify"), n = $("m-note");
if (!S.relay) { b.style.display = "none"; n.textContent = "直接連線時沒有通知;用帳號登入(透過帳號伺服器)就能收到通知。"; return; }
b.style.display = "";
if (!pushOK()) { b.disabled = true; b.textContent = "這個瀏覽器不支援通知"; n.textContent = /iPhone|iPad/.test(navigator.userAgent) ? "iPhone 要先按分享 › 加入主畫面,從主畫面打開 AnyBot 才能開通知。" : ""; return; }
const reg = await navigator.serviceWorker.getRegistration();
const sub = reg && await reg.pushManager.getSubscription();
b.textContent = sub ? "關閉通知" : "開啟通知";
}
function keyBytes(b64) { const s = atob(b64.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((b64.length + 3) % 4)); return Uint8Array.from(s, (c) => c.charCodeAt(0)); }
$("m-notify").onclick = async () => {
try {
const reg = await navigator.serviceWorker.register("sw.js");
await navigator.serviceWorker.ready;
const old = await reg.pushManager.getSubscription();
if (old) { await api("relay/push/unsubscribe", {json: {endpoint: old.endpoint}}); await old.unsubscribe(); toast("已關閉通知"); }
else {
if (await Notification.requestPermission() !== "granted") { toast("沒有允許通知(可在系統設定裡打開)"); return; }
const {key} = await api("relay/push/key");
const sub = await reg.pushManager.subscribe({userVisibleOnly: true, applicationServerKey: keyBytes(key)});
await api("relay/push/subscribe", {json: sub.toJSON()});
toast("已開啟通知");
}
} catch (e) { toast("通知設定失敗:" + e.message); }
notifyLabel();
};
if (pushOK()) navigator.serviceWorker.register("sw.js").catch(() => {});
// A tapped notification opens that chat.
function openFromHash() {
const h = new URLSearchParams(location.hash.slice(1));
if (h.get("host")) { S.host = h.get("host"); localStorage.setItem("anybot.host", S.host); }
const bot = +h.get("bot");
if (bot) S.open = bot;
if (location.hash) history.replaceState(null, "", location.pathname);
}
openFromHash();
if ("serviceWorker" in navigator) navigator.serviceWorker.addEventListener("message", (e) => {
const d = e.data && e.data.open; if (!d) return;
if (d.host && d.host !== S.host && S.relay) { S.host = d.host; localStorage.setItem("anybot.host", S.host); S.version = 0; }
if (d.bot) openChat(+d.bot);
});
document.addEventListener("visibilitychange", () => { if (!document.hidden && S.view === "screen") startFrames(); });
hello();
</script>
</body>
</html>