AnyBot/mobile/index.html

1947 lines
156 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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="#161616" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#f5f5f5" 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>
/* The desktop app's colours (src/theme.rs, from the original's stylesheet), as on a phone: the
sidebar is the home, the chat is pushed on top, 對話詳細資訊 and 設定 are pushed on that. */
:root{
--pane:#fff;--chrome:#fcfcfc;--side:#f5f5f5;--elev:#fff;--hover:#1c1c1c0f;--active:#1c1c1c14;
--primary:#1c1c1c;--on-primary:#fff;--text:#1c1c1c;--text2:#1c1c1cad;--text3:#1c1c1c8f;--text4:#1c1c1c66;
--line:#1c1c1c1f;--line2:#1c1c1c14;--focus:#1e86fc;--accent:#1680f3;--accent-ink:#0b6dd3;
--danger:#e72e3b;--ok:#10ab61;--warn:#da830e;
--bubble-user:#1c1c1c;--bubble-user-ink:#fff;--bubble-bot:#1c1c1c0f;--card:#fff;
--spec:#ffffff59;--shadow:0 2px 8px -1px #0000000d,0 1px 2px #00000008,0 0 0 1px #e4e4e40a;
--font:-apple-system,BlinkMacSystemFont,"SF Pro Text","PingFang TC","Noto Sans TC","Segoe UI",system-ui,sans-serif;
--mono:ui-monospace,SFMono-Regular,Menlo,monospace;
--safe-b:env(safe-area-inset-bottom);--safe-t:env(safe-area-inset-top);
}
@media (prefers-color-scheme: dark){:root:not([data-theme="light"]){
--pane:#161616;--chrome:#121212;--side:#0e0e0e;--elev:#1f1f1f;--hover:#f5f5f50f;--active:#f5f5f514;
--primary:#f5f5f5;--on-primary:#121212;--text:#f5f5f5;--text2:#f5f5f5a8;--text3:#f5f5f58a;--text4:#f5f5f566;
--line:#f5f5f51a;--line2:#f5f5f514;--focus:#1884fc;--accent:#1884fc;--accent-ink:#4d9bff;
--danger:#fc2c3e;--bubble-user:#f5f5f52e;--bubble-user-ink:#f5f5f5;--bubble-bot:#f5f5f50a;--card:#252525;
--spec:#ffffff0d;--shadow:none;
}}
:root[data-theme="dark"]{
--pane:#161616;--chrome:#121212;--side:#0e0e0e;--elev:#1f1f1f;--hover:#f5f5f50f;--active:#f5f5f514;
--primary:#f5f5f5;--on-primary:#121212;--text:#f5f5f5;--text2:#f5f5f5a8;--text3:#f5f5f58a;--text4:#f5f5f566;
--line:#f5f5f51a;--line2:#f5f5f514;--focus:#1884fc;--accent:#1884fc;--accent-ink:#4d9bff;
--danger:#fc2c3e;--bubble-user:#f5f5f52e;--bubble-user-ink:#f5f5f5;--bubble-bot:#f5f5f50a;--card:#252525;
--spec:#ffffff0d;--shadow:none;
}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{margin:0;height:100%;background:var(--pane);color:var(--text);font:15px/1.47 var(--font);-webkit-font-smoothing:antialiased;overscroll-behavior:none}
button{font:inherit;color:inherit;background:none;border:0;padding:0;cursor:pointer}
input,textarea,select{font:inherit;color:var(--text)}
a{color:var(--accent-ink)}
.ic{flex:none;vertical-align:-3px}
.app{height:100dvh;display:flex;flex-direction:column;overflow:hidden}
.view{flex:1;display:none;flex-direction:column;min-height:0;background:var(--pane)}
.view.on{display:flex}
.scroll{flex:1;overflow-y:auto;-webkit-overflow-scrolling:touch;min-height:0}
/* ---- bars ---- */
.bar{display:flex;align-items:center;gap:8px;padding:calc(var(--safe-t) + 8px) 10px 8px;min-height:56px;background:var(--pane);border-bottom:.5px solid var(--line2);flex:none}
.bar h1{font-size:17px;font-weight:600;margin:0;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bar h1 small{font-size:12px;font-weight:400;color:var(--text3);margin-left:6px}
.ib{width:36px;height:36px;border-radius:10px;display:grid;place-items:center;color:var(--text2);flex:none}
.ib:active,.ib.on{background:var(--hover)}
.ib .ic{vertical-align:0}
.island{flex:1;min-width:0;display:flex;align-items:center;gap:9px;height:40px;padding:0 10px 0 4px;border-radius:12px}
.island:active{background:var(--hover)}
.island .t{min-width:0;flex:1;text-align:left}
.island .n{font-size:15px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;align-items:center;gap:4px}
.island .s{font-size:12px;color:var(--text3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.island .s.busy{color:var(--ok)}
.island .s.wait{color:var(--warn)}
/* ---- avatars ---- */
.av{width:36px;height:36px;flex:none;position:relative;display:grid;place-items:center}
.av svg{width:100%;height:100%}
.av.lg{width:64px;height:64px}
.av.sm{width:30px;height:30px}
.av .dot{position:absolute;right:-1px;bottom:-1px;width:11px;height:11px;border-radius:50%;border:2px solid var(--side)}
.dot.busy{background:var(--ok)}.dot.wait{background:var(--warn)}.dot.unread{background:var(--accent)}.dot.err{background:var(--danger)}
/* ---- home = the sidebar ---- */
#v-home{background:var(--side)}
#v-home .bar{background:var(--side);border-bottom:0}
.me{width:32px;height:32px;border-radius:50%;background:var(--active);display:grid;place-items:center;font-size:13px;font-weight:600;color:var(--text2);flex:none}
.searchbox{margin:0 12px 6px;display:none;align-items:center;gap:8px;background:var(--hover);border-radius:10px;padding:0 10px;height:36px}
.searchbox.on{display:flex}
.searchbox input{flex:1;border:0;background:none;outline:0;font-size:15px;min-width:0}
.quick{display:flex;gap:8px;padding:2px 12px 8px;overflow-x:auto;scrollbar-width:none}
.quick::-webkit-scrollbar{display:none}
.quick button{flex:none;display:flex;align-items:center;gap:6px;height:32px;padding:0 12px;border-radius:16px;background:var(--elev);box-shadow:inset 0 0 0 .5px var(--line);font-size:13px;color:var(--text2)}
.quick button b{font-weight:600;color:var(--text)}
.quick button .st{width:7px;height:7px;border-radius:50%}
.sec{display:flex;align-items:center;gap:6px;padding:14px 18px 4px;font-size:12px;font-weight:600;color:var(--text3)}
.sec .ic{color:var(--text3)}
.sec .n{font-weight:400}
.sec button{margin-left:auto;color:var(--text3);font-size:12px;font-weight:400;display:flex;align-items:center;gap:2px}
.rows{padding:0 8px}
.row{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:10px;min-height:56px;position:relative}
.row:active,.row.sel{background:var(--active)}
.row .mid{flex:1;min-width:0}
.row .nm{font-size:15px;font-weight:500;display:flex;align-items:center;gap:6px;min-width:0}
.row .nm span.t{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.row .pv{font-size:13px;color:var(--text3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.row .pv.busy{color:var(--ok)}.row .pv.wait{color:var(--warn)}
.row .tm{font-size:12px;color:var(--text4);align-self:flex-start;margin-top:4px;flex:none}
.row .more{width:30px;height:30px;border-radius:8px;display:grid;place-items:center;color:var(--text3);flex:none}
.pill{font-size:11px;font-weight:500;padding:1px 7px;border-radius:9px;background:var(--active);color:var(--text2);flex:none}
.pill.acc{background:color-mix(in srgb,var(--accent) 16%,transparent);color:var(--accent-ink)}
.pill.ok{background:color-mix(in srgb,var(--ok) 16%,transparent);color:var(--ok)}
.pill.warn{background:color-mix(in srgb,var(--warn) 18%,transparent);color:var(--warn)}
.empty{padding:40px 24px;text-align:center;color:var(--text3);font-size:14px;line-height:1.6}
/* ---- chat ---- */
#v-chat{position:relative}
.msgs{padding:12px 14px 8px;display:flex;flex-direction:column;gap:2px;max-width:760px;margin:0 auto;width:100%}
.dsep{align-self:center;font-size:11px;color:var(--text3);padding:12px 0 6px}
.m{display:flex;flex-direction:column;max-width:100%;margin:3px 0;position:relative}
.m.user{align-items:flex-end}
.m.bot{align-items:flex-start}
.bub{max-width:min(86%,560px);padding:7px 12px;border-radius:18px;font-size:15px;line-height:22px;word-wrap:break-word;overflow-wrap:anywhere;
background-image:linear-gradient(to bottom,var(--spec),transparent);box-shadow:inset 0 .5px 0 var(--spec),inset 0 0 0 .5px var(--line2)}
.m.user .bub{background-color:var(--bubble-user);color:var(--bubble-user-ink);white-space:pre-wrap}
.m.bot .bub{background-color:var(--bubble-bot)}
.m.err .bub{color:var(--danger)}
.bub p{margin:0 0 8px}.bub p:last-child{margin-bottom:0}
.bub ul,.bub ol{margin:4px 0 8px;padding-left:22px}
.bub li{margin:2px 0}
.bub h2,.bub h3,.bub h4{font-size:15px;margin:10px 0 4px}
.bub code{font-family:var(--mono);font-size:13px;background:var(--hover);padding:1px 4px;border-radius:5px}
.bub pre{background:var(--hover);padding:10px;border-radius:10px;overflow-x:auto;margin:6px 0}
.bub pre code{background:none;padding:0;font-size:12.5px;line-height:1.5}
.bub blockquote{margin:6px 0;padding-left:10px;border-left:3px solid var(--line);color:var(--text2)}
.bub .tw{overflow-x:auto;margin:6px 0}
.bub table{border-collapse:collapse;font-size:13px;line-height:1.4}
.bub th,.bub td{border:.5px solid var(--line);padding:5px 8px;text-align:left;vertical-align:top}
.bub th{background:var(--hover);font-weight:600}
.bub hr{border:0;border-top:.5px solid var(--line);margin:10px 0}
.m.user .bub a{color:inherit;text-decoration:underline}
.who{font-size:12px;color:var(--text3);margin:6px 0 2px 4px}
.tag{font-size:11px;color:var(--text3);margin:4px 4px 2px;display:flex;align-items:center;gap:4px}
.meta{font-size:11px;color:var(--text4);margin:3px 6px 0;display:flex;align-items:center;gap:6px;flex-wrap:wrap}
.meta button{color:var(--text3);display:flex;align-items:center;padding:4px;border-radius:6px}
.meta button:active{background:var(--hover)}
.reacts{display:flex;gap:4px;flex-wrap:wrap;margin:3px 4px 0}
.reacts button{font-size:13px;padding:1px 7px;border-radius:11px;background:var(--elev);box-shadow:inset 0 0 0 .5px var(--line)}
.quote{font-size:13px;color:var(--text2);border-left:3px solid var(--line);padding-left:8px;margin:0 0 4px;white-space:pre-wrap}
/* steps (the desktop's outline rows) */
.steps{display:flex;flex-direction:column;gap:4px;margin:4px 0;max-width:min(92%,560px);width:100%}
.step,.fold{border-radius:9px;background:var(--elev);box-shadow:inset 0 0 0 .5px var(--line);font-size:12.5px;overflow:hidden}
.step>.h,.fold{display:flex;align-items:center;gap:7px;padding:7px 10px;min-height:34px;width:100%;text-align:left}
.step .l{font-weight:600;flex:none;max-width:45%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.step .d{color:var(--text3);flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.step .chev{color:var(--text4);transition:transform .15s}
.step.open .chev{transform:rotate(90deg)}
.step .body{display:none;padding:0 10px 9px;border-top:.5px solid var(--line2)}
.step.open .body{display:block}
.step .body pre{margin:8px 0 0;white-space:pre-wrap;word-break:break-word;font:11.5px/1.5 var(--mono);color:var(--text2);max-height:260px;overflow:auto}
.step .body .k{font-size:11px;color:var(--text3);margin-top:8px}
.st-i{display:grid;place-items:center;width:16px;color:var(--text3)}
.step.done .st-i{color:var(--ok)}.step.failed .st-i{color:var(--danger)}.step.waiting .st-i{color:var(--warn)}.step.running .st-i{color:var(--accent)}
.step.running .st-i svg{animation:spin 1.2s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
.fold{color:var(--text2)}
.fold .ic:last-child{margin-left:auto;color:var(--text4)}
.typing{display:flex;gap:4px;padding:11px 14px;border-radius:14px;background:var(--bubble-bot);width:fit-content}
.typing i{width:5px;height:5px;border-radius:50%;background:var(--text3);animation:blink 1.2s infinite}
.typing i:nth-child(2){animation-delay:.2s}.typing i:nth-child(3){animation-delay:.4s}
@keyframes blink{0%,80%,100%{opacity:.3}40%{opacity:1}}
.presence{font-size:12.5px;color:var(--text3);margin:6px 4px;background:linear-gradient(90deg,var(--text3) 30%,var(--text) 50%,var(--text3) 70%);background-size:200% 100%;-webkit-background-clip:text;background-clip:text;color:transparent;animation:shimmer 2s linear infinite}
@keyframes shimmer{to{background-position:-200% 0}}
.summary{align-self:stretch;margin:8px 0;padding:10px 12px;border-radius:12px;background:var(--hover);font-size:13px;color:var(--text2)}
.summary b{display:flex;align-items:center;gap:6px;color:var(--text);font-weight:500}
.summary .tx{display:none;margin-top:6px;white-space:pre-wrap}
.summary.open .tx{display:block}
/* cards in the chat */
.card,.pcard,.filec{border-radius:12px;background:var(--card);box-shadow:inset 0 0 0 .5px var(--line),var(--shadow);max-width:min(92%,420px);width:100%;margin:4px 0;overflow:hidden}
.card{padding:10px 12px;font-size:13px}
.card .t{font-weight:600;font-size:14px}.card .s{color:var(--text3);margin-bottom:4px}
.card .r{display:flex;justify-content:space-between;gap:10px;padding:3px 0;border-top:.5px solid var(--line2)}
.card .r span:first-child{color:var(--text3)}
.pcard .top{padding:12px 14px 6px}
.pcard .top .t{font-weight:600;margin-bottom:4px}
.pcard .top .l{font-size:12.5px;color:var(--text2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.pcard .foot{display:flex;align-items:center;gap:10px;padding:9px 14px;border-top:.5px solid var(--line2)}
.pcard .foot .n{font-size:13px;font-weight:500}.pcard .foot .x{font-size:11.5px;color:var(--text3)}
.filec{display:flex;align-items:center;gap:8px;padding:8px 10px;min-width:180px;width:auto;text-decoration:none;color:var(--text)}
.filec .fn{font-size:12.5px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.attimg{max-width:min(78%,320px);margin:4px 0;border-radius:12px;overflow:hidden;box-shadow:inset 0 0 0 .5px var(--line)}
.attimg img{display:block;width:100%;max-height:320px;object-fit:cover}
.attimg .wait{padding:20px;font-size:12px;color:var(--text3)}
.plink{cursor:pointer;color:var(--accent-ink);text-decoration:underline;text-decoration-color:color-mix(in srgb,var(--accent-ink) 40%,transparent)}
/* empty chat */
.intro{display:flex;flex-direction:column;align-items:center;text-align:center;padding:36px 16px 10px;gap:6px}
.intro .hi{font-size:20px;font-weight:600;margin-top:8px}
.intro .sub{font-size:14px;color:var(--text3)}
.sugs{display:flex;flex-direction:column;gap:6px;padding:14px 16px;max-width:520px;margin:0 auto;width:100%}
.sug{display:flex;align-items:center;gap:12px;padding:11px 12px;border-radius:12px;background:var(--elev);box-shadow:inset 0 0 0 .5px var(--line);text-align:left}
.sug .ic{color:var(--text2)}
.sug .tt{font-size:14px;font-weight:500}.sug .dd{font-size:12px;color:var(--text3)}
/* the dock: approvals, queued steering, attachments, the composer */
.dock{flex:none;padding:6px 10px calc(var(--safe-b) + 8px);background:linear-gradient(to bottom,transparent,var(--chrome) 40%)}
.ask{max-width:560px;margin:0 auto 8px;padding:14px 16px;border-radius:16px;background:var(--elev);box-shadow:inset 0 0 0 .5px var(--line),0 6px 24px -6px #0000002e}
.ask .q{font-weight:600;font-size:15px}
.ask .why{font-size:13px;color:var(--text2);margin:4px 0 10px;white-space:pre-wrap;max-height:30vh;overflow:auto}
.ask .thumb{width:100%;border-radius:10px;margin:2px 0 10px;display:block;background:var(--hover);min-height:60px}
.ask .btns{display:flex;flex-wrap:wrap;gap:8px}
.ask .all{display:block;margin-top:10px;font-size:13px;color:var(--accent-ink)}
.btn{height:36px;padding:0 14px;border-radius:18px;background:var(--hover);font-weight:500;font-size:14px;display:inline-flex;align-items:center;justify-content:center;gap:6px}
.btn.main{background:var(--primary);color:var(--on-primary)}
.btn.no{color:var(--danger)}
.btn.sm{height:30px;padding:0 11px;font-size:13px;border-radius:15px}
.btn:disabled{opacity:.45}
.queued{max-width:560px;margin:0 auto 6px;font-size:12.5px;color:var(--text2);background:var(--hover);border-radius:12px;padding:7px 11px}
.chips{display:flex;gap:6px;flex-wrap:wrap;max-width:760px;margin:0 auto}
.chip{display:inline-flex;align-items:center;gap:5px;font-size:12.5px;padding:4px 6px 4px 10px;border-radius:14px;background:var(--elev);box-shadow:inset 0 0 0 .5px var(--line);margin-bottom:6px;max-width:100%}
.chip span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px}
.chip button{display:grid;place-items:center;width:20px;height:20px;border-radius:50%;color:var(--text3)}
.quoting{max-width:760px;margin:0 auto 6px;display:flex;align-items:center;gap:8px;font-size:12.5px;color:var(--text2);border-left:3px solid var(--line);padding-left:8px}
.quoting span{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.composer{max-width:760px;margin:0 auto;display:flex;align-items:flex-end;gap:6px;background:var(--elev);border-radius:22px;padding:8px 8px 8px 8px;box-shadow:inset 0 0 0 .5px var(--line),var(--shadow)}
.composer.tall{border-radius:18px}
.composer textarea{flex:1;border:0;outline:0;background:none;resize:none;font-size:16px;line-height:22px;max-height:140px;min-height:28px;padding:3px 4px}
.cbtn{width:28px;height:28px;border-radius:50%;display:grid;place-items:center;flex:none;color:var(--text2)}
.cbtn.plus{background:var(--hover);box-shadow:inset 0 0 0 .5px var(--line)}
.cbtn.go{background:var(--primary);color:var(--on-primary)}
.cbtn.go:disabled{opacity:.35}
.cbtn.rec{color:var(--danger)}
.cbtn .ic{vertical-align:0}
.down{position:absolute;right:16px;bottom:96px;width:36px;height:36px;border-radius:50%;background:var(--elev);box-shadow:inset 0 0 0 .5px var(--line),0 4px 12px #0000001f;display:none;place-items:center;color:var(--text2);z-index:2}
.down.on{display:grid}
/* ---- details, editor, lists ---- */
.hero{display:flex;flex-direction:column;align-items:center;gap:6px;padding:18px 16px 6px;text-align:center}
.hero .n{font-size:20px;font-weight:600}
.hero .d{font-size:13px;color:var(--text3);max-width:320px}
.tabs{display:flex;gap:4px;padding:8px 12px;overflow-x:auto;scrollbar-width:none;flex:none;position:sticky;top:0;background:var(--pane);z-index:1}
.tabs::-webkit-scrollbar{display:none}
.tabs button{flex:none;height:32px;padding:0 13px;border-radius:16px;font-size:14px;color:var(--text2)}
.tabs button.on{background:var(--active);color:var(--text);font-weight:500}
.group{margin:8px 12px 14px;border-radius:14px;background:var(--elev);box-shadow:inset 0 0 0 .5px var(--line);overflow:hidden}
.gt{font-size:12px;font-weight:600;color:var(--text3);padding:12px 18px 4px;display:flex;align-items:center;gap:6px}
.gt button{margin-left:auto;font-weight:500;color:var(--accent-ink);font-size:13px}
.item{display:flex;align-items:center;gap:12px;padding:11px 14px;min-height:48px;border-top:.5px solid var(--line2);width:100%;text-align:left}
.item:first-child{border-top:0}
button.item:active,.item.tap:active{background:var(--hover)}
.item .ic{color:var(--text2)}
.item .mid{flex:1;min-width:0}
.item .k{font-size:15px}
.item .v{font-size:12.5px;color:var(--text3);white-space:pre-wrap;word-break:break-word}
.item .r{font-size:14px;color:var(--text3);flex:none;max-width:45%;text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.item.danger .k,.item.danger .ic{color:var(--danger)}
.item .chev{color:var(--text4)}
.note{font-size:12.5px;color:var(--text3);padding:6px 18px 10px;line-height:1.55}
.sw{position:relative;width:46px;height:28px;flex:none}
.sw input{opacity:0;position:absolute;inset:0;margin:0}
.sw i{position:absolute;inset:0;border-radius:14px;background:var(--active);transition:.15s}
.sw i::after{content:"";position:absolute;left:3px;top:3px;width:22px;height:22px;border-radius:50%;background:#fff;box-shadow:0 1px 3px #0003;transition:.15s}
.sw input:checked+i{background:var(--ok)}
.sw input:checked+i::after{transform:translateX(18px)}
.sw input:disabled+i{opacity:.4}
.field{display:block;width:100%;border:0;outline:0;background:var(--hover);border-radius:10px;padding:10px 12px;font-size:16px;resize:vertical}
.field:focus{box-shadow:0 0 0 2px color-mix(in srgb,var(--focus) 45%,transparent)}
textarea.field{min-height:96px;line-height:1.5}
.form{padding:10px 14px;display:flex;flex-direction:column;gap:8px}
.form label{font-size:12.5px;color:var(--text3);margin-top:4px}
.row2{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.shapes{display:grid;grid-template-columns:repeat(6,1fr);gap:8px;padding:10px 14px}
.shapes button{aspect-ratio:1;border-radius:12px;padding:6px;background:var(--hover);min-width:0}
.shapes button svg{display:block;width:100%;height:100%}
.btnrow{display:flex;flex-wrap:wrap;gap:8px;padding:12px 14px}
.shapes button.on{box-shadow:0 0 0 2px var(--focus)}
.colors{display:flex;gap:10px;flex-wrap:wrap;padding:4px 14px 14px}
.colors button{width:30px;height:30px;border-radius:50%;box-shadow:inset 0 0 0 1px #0000001a}
.colors button.on{box-shadow:0 0 0 2px var(--pane),0 0 0 4px var(--focus)}
.thumbbox{margin:12px 14px 4px;border-radius:12px;overflow:hidden;background:#000;aspect-ratio:16/10;display:grid;place-items:center;color:#fff8;font-size:13px}
.thumbbox img{width:100%;height:100%;object-fit:contain;display:block}
.files{display:grid;grid-template-columns:repeat(3,1fr);gap:4px;padding:6px 12px}
.files .f{aspect-ratio:1;border-radius:8px;overflow:hidden;background:var(--hover);display:grid;place-items:center;font-size:11px;color:var(--text3);padding:6px;text-align:center;word-break:break-all}
.files .f img{width:100%;height:100%;object-fit:cover}
.log{font:11.5px/1.5 var(--mono);color:var(--text2);white-space:pre-wrap;padding:10px 14px;max-height:280px;overflow:auto}
.kv{display:flex;justify-content:space-between;gap:10px;padding:4px 0}
/* ---- sheets ---- */
.sheet-bg{position:fixed;inset:0;background:#0006;display:none;align-items:flex-end;justify-content:center;z-index:20}
.sheet-bg.on{display:flex}
.sheet{width:100%;max-width:560px;max-height:88dvh;overflow-y:auto;background:var(--pane);border-radius:18px 18px 0 0;padding:6px 0 calc(var(--safe-b) + 10px);animation:up .18s ease-out}
@keyframes up{from{transform:translateY(30px);opacity:.6}}
.sheet .grab{width:36px;height:5px;border-radius:3px;background:var(--active);margin:4px auto 6px}
.sheet .st{font-size:13px;color:var(--text3);text-align:center;padding:4px 16px 8px}
.sheet .opt{display:flex;align-items:center;gap:12px;width:100%;padding:13px 20px;font-size:16px;text-align:left}
.sheet .opt:active{background:var(--hover)}
.sheet .opt .ic{color:var(--text2)}
.sheet .opt.danger,.sheet .opt.danger .ic{color:var(--danger)}
.sheet .opt .ck{margin-left:auto;color:var(--accent)}
.sheet .opt .x{margin-left:auto;font-size:13px;color:var(--text3)}
.sheet .grp{font-size:12px;font-weight:600;color:var(--text3);padding:12px 20px 4px}
.emojis{display:flex;gap:6px;padding:6px 16px 10px;flex-wrap:wrap}
.emojis button{font-size:24px;width:44px;height:44px;border-radius:12px;background:var(--hover)}
.toast{position:fixed;left:50%;bottom:calc(var(--safe-b) + 90px);transform:translate(-50%,20px);background:var(--primary);color:var(--on-primary);padding:9px 16px;border-radius:20px;font-size:14px;opacity:0;pointer-events:none;transition:.2s;z-index:40;max-width:86vw;text-align:center}
.toast.on{opacity:1;transform:translate(-50%,0)}
/* ---- sign in ---- */
.login{max-width:360px;margin:auto;padding:24px;display:flex;flex-direction:column;gap:10px;width:100%}
.login .logo{width:64px;height:64px;margin:0 auto 6px;color:var(--text)}
.login h2{text-align:center;margin:0;font-size:22px}
.login p{text-align:center;color:var(--text3);margin:0 0 8px;font-size:14px}
.login .err{color:var(--danger);font-size:13px;text-align:center;min-height:18px}
.link{color:var(--accent-ink);font-size:14px;text-align:center;padding:6px}
/* ---- page viewer ---- */
.page{padding:8px 18px 40px;max-width:760px;margin:0 auto;font-size:15.5px;line-height:1.65}
.page h1.pt{font-size:24px;line-height:1.3;margin:10px 0 4px}
.page .mt{font-size:12.5px;color:var(--text3);margin-bottom:12px}
.page h2{font-size:19px;margin:22px 0 8px}.page h3{font-size:16.5px;margin:18px 0 6px}.page h4{font-size:15px;margin:14px 0 6px}
.page .sum{color:var(--text2);font-size:15px}
.page .old,.page .chg{font-size:13px;background:var(--hover);padding:8px 12px;border-radius:10px;margin:8px 0}
.page .tw{overflow-x:auto;margin:10px 0}
.page table{border-collapse:collapse;font-size:13.5px;line-height:1.45}
.page th,.page td{border:.5px solid var(--line);padding:6px 9px;text-align:left;vertical-align:top}
.page th{background:var(--hover)}
.page pre{background:var(--hover);padding:12px;border-radius:10px;overflow-x:auto;font-size:12.5px}
.page code{font-family:var(--mono);font-size:13px;background:var(--hover);padding:1px 4px;border-radius:5px}
.page pre code{background:none;padding:0}
.page blockquote{margin:10px 0;padding-left:12px;border-left:3px solid var(--line);color:var(--text2)}
.page .call{border-radius:12px;padding:10px 14px;margin:12px 0;background:var(--hover)}
.page .call .ct{font-weight:600;font-size:13px;margin-bottom:4px}
.page .call.warning{background:color-mix(in srgb,var(--warn) 12%,transparent)}
.page .call.tip{background:color-mix(in srgb,var(--ok) 12%,transparent)}
.page .call.important{background:color-mix(in srgb,var(--accent) 12%,transparent)}
.page .stats{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:8px;margin:12px 0}
.page .stat{border-radius:12px;background:var(--hover);padding:10px 12px}
.page .stat .k{font-size:12px;color:var(--text3)}.page .stat .v{font-size:22px;font-weight:600}.page .stat .v.long{font-size:16px}.page .stat .s{font-size:12px;color:var(--text3)}
.page ul.steps{list-style:none;padding:0}
.page ul.steps li{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding:6px 0}
.page ul.steps .dot{width:18px;height:18px;border-radius:50%;border:2px solid var(--line);display:grid;place-items:center;font-size:11px}
.page ul.steps li.done .dot{background:var(--ok);border-color:var(--ok);color:#fff}
.page ul.steps li.now .dot{border-color:var(--accent)}
.page ul.steps .nt{flex-basis:100%;padding-left:26px;font-size:13px;color:var(--text3)}
.page .chart{margin:12px 0;border-radius:12px;overflow:hidden;background:var(--hover)}
.page .chart img{width:100%;display:block}
.page .chart .wait{padding:18px;font-size:13px;color:var(--text3)}
.page li.task{list-style:none;margin-left:-18px}
/* ---- this Mac's screen ---- */
.stage{flex:1;position:relative;background:#000;display:flex;align-items:center;justify-content:center;overflow:hidden;touch-action:none}
.stage img{max-width:100%;max-height:100%;user-select:none;-webkit-user-select:none}
.stage .msg{color:#fff9;font-size:14px;padding:20px;text-align:center}
.stage .tapdot{position:absolute;width:26px;height:26px;margin:-13px 0 0 -13px;border-radius:50%;background:#1884fc88;pointer-events:none;animation:fade .6s forwards}
@keyframes fade{to{opacity:0;transform:scale(1.8)}}
.tools{display:flex;gap:6px;padding:8px 10px calc(var(--safe-b) + 8px);overflow-x:auto;flex:none;background:var(--chrome)}
.tools .btn{flex:none}
.typebar{display:none;gap:6px;padding:8px 10px 0;background:var(--chrome)}
.typebar.on{display:flex}
.typebar input{flex:1}
.live{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--ok);margin-right:5px}
.pair-code{font:600 32px/1.2 var(--mono);text-align:center;letter-spacing:4px;padding:10px}
.pair-line{display:block;font:12px/1.5 var(--mono);background:var(--hover);padding:10px;border-radius:10px;margin:6px 16px;word-break:break-all}
</style>
</head>
<body>
<div class="app">
<!-- sign in -->
<section class="view" id="v-login">
<div class="scroll" style="display:flex">
<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="btn main" id="b-login" style="height:44px;border-radius:12px">登入</button>
<div class="err" id="login-err"></div>
<button class="link" id="b-mode" style="display:none">還沒有帳號?建立一個</button>
</div>
</div>
</section>
<!-- which AnyBot (an account with several) -->
<section class="view" id="v-hosts">
<div class="bar"><h1>選擇要連的 AnyBot</h1><button class="ib" id="b-out1" title="登出"></button></div>
<div class="scroll"><div class="rows" id="hosts" style="padding-top:8px"></div></div>
</section>
<!-- home: the sidebar -->
<section class="view" id="v-home">
<div class="bar">
<button class="me" id="b-me" title="帳戶選單">A</button>
<h1>AnyBot<small id="host-name"></small></h1>
<button class="ib" id="b-search" title="搜尋"></button>
<button class="ib" id="b-newbot" title="新 Bot"></button>
</div>
<div class="searchbox" id="searchbox"><span id="search-ic"></span><input id="search" placeholder="搜尋 Bot" enterkeyhint="search"><button class="ib" id="b-search-x" style="width:28px;height:28px"></button></div>
<div class="quick" id="quick"></div>
<div class="scroll" id="home"></div>
</section>
<!-- one chat -->
<section class="view" id="v-chat">
<div class="bar">
<button class="ib" data-back title="返回"></button>
<button class="island" id="island" title="查看對話詳細資訊"><div class="av sm" id="isl-av"></div><div class="t"><div class="n"><span id="isl-name"></span><span id="isl-chev"></span></div><div class="s" id="isl-sub"></div></div></button>
<button class="ib" id="b-newchat" title="開始新對話"></button>
<button class="ib" id="b-chatmore" title="更多"></button>
</div>
<div class="scroll" id="scroller"><div class="msgs" id="msgs"></div></div>
<button class="down" id="b-down" title="到最新的訊息"></button>
<div class="dock">
<div id="ask"></div>
<div id="queued"></div>
<div class="quoting" id="quoting" style="display:none"></div>
<div class="chips" id="chips"></div>
<div class="composer" id="composer">
<button class="cbtn plus" id="b-plus" title="輸入框操作"></button>
<input type="file" id="file" multiple hidden>
<input type="file" id="camera" accept="image/*" capture="environment" hidden>
<textarea id="input" rows="1" placeholder="傳訊息…" enterkeyhint="send"></textarea>
<button class="cbtn" id="b-mic" title="開始語音輸入"></button>
<button class="cbtn go" id="b-send" title="傳送訊息"></button>
</div>
</div>
</section>
<!-- 對話詳細資訊 -->
<section class="view" id="v-details">
<div class="bar">
<button class="ib" data-back title="關閉"></button>
<h1>對話詳細資訊</h1>
<button class="ib" id="b-botset" title="Bot 設定"></button>
</div>
<div class="scroll" id="details"></div>
</section>
<!-- Bot 設定 -->
<section class="view" id="v-botset">
<div class="bar"><button class="ib" data-back title="返回"></button><h1 id="botset-title">Bot 設定</h1></div>
<div class="scroll" id="botset"></div>
</section>
<!-- a list screen: 例行任務 / 頁面 / 電腦 / 設定 and its sections -->
<section class="view" id="v-list">
<div class="bar"><button class="ib" data-back title="返回"></button><h1 id="list-title"></h1><span id="list-act"></span></div>
<div class="scroll" id="list"></div>
</section>
<!-- a page -->
<section class="view" id="v-page">
<div class="bar">
<button class="ib" data-back title="返回"></button>
<h1 id="page-name">頁面</h1>
<button class="ib" id="b-pagemore" title="更多"></button>
</div>
<div class="scroll" id="pscroll"><div class="page" id="page"></div></div>
</section>
<!-- this Mac's screen -->
<section class="view" id="v-screen">
<div class="bar"><button class="ib" data-back title="返回"></button><h1>這台 Mac</h1><span class="pill" id="screen-live"></span></div>
<div class="stage" id="stage"><div class="msg">載入中…</div></div>
<div class="typebar" id="typebar"><input class="field" id="typein" placeholder="要打的字(會打進目前的視窗)" enterkeyhint="send"><button class="btn main" id="b-type">送出</button></div>
<div class="tools">
<button class="btn sm" id="k-type">打字</button>
<button class="btn sm" data-key="Return">Enter</button>
<button class="btn sm" data-key="Escape">Esc</button>
<button class="btn sm" data-key="Tab">Tab</button>
<button class="btn sm" data-key="BackSpace">刪除</button>
<button class="btn sm" data-scroll="up">↑ 捲動</button>
<button class="btn sm" data-scroll="down">↓ 捲動</button>
<button class="btn sm" id="k-right">右鍵:關</button>
</div>
</section>
</div>
<div class="toast" id="toast"></div>
<!-- one sheet for every menu and picker -->
<div class="sheet-bg" id="sheet"><div class="sheet" id="sheet-in"></div></div>
<script>
"use strict";
// Lucide icons (ISC licence), the set the desktop app uses.
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"/>',
"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"/>',
"circle-check": '<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>',
"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"/>',
"circle-x": '<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>',
"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"/>',
"minus": '<path d="M5 12h14"/>',
"x": '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
"plus": '<path d="M5 12h14"/><path d="M12 5v14"/>',
"check": '<path d="M20 6 9 17l-5-5"/>',
"search": '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
"chevron-left": '<path d="m15 18-6-6 6-6"/>',
"chevron-right": '<path d="m9 18 6-6-6-6"/>',
"chevron-down": '<path d="m6 9 6 6 6-6"/>',
"ellipsis": '<circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/>',
"settings": '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
"star": '<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"/>',
"pin": '<path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/>',
"folder": '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>',
"trash": '<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>',
"reply": '<polyline points="9 14 4 9 9 4"/><path d="M20 20v-7a4 4 0 0 0-4-4H4"/>',
"smile-plus": '<path d="M22 11v1a10 10 0 1 1-9-10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" x2="9.01" y1="9" y2="9"/><line x1="15" x2="15.01" y1="9" y2="9"/><path d="M16 5h6"/><path d="M19 2v6"/>',
"mic": '<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/>',
"arrow-up": '<path d="m5 12 7-7 7 7"/><path d="M12 19V5"/>',
"arrow-down": '<path d="M12 5v14"/><path d="m19 12-7 7-7-7"/>',
"stop": '<rect width="10" height="10" x="7" y="7" rx="1.5" fill="currentColor" stroke="none"/>',
"play": '<polygon points="6 3 20 12 6 21 6 3"/>',
"pause": '<rect x="14" y="4" width="4" height="16" rx="1"/><rect x="6" y="4" width="4" height="16" rx="1"/>',
"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"/>',
"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"/>',
"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"/>',
"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"/>',
"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"/>',
"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"/>',
"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"/>',
"log-out": '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/>',
"bell": '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>',
"moon": '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
"shield": '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>',
"plug": '<path d="M12 22v-5"/><path d="M9 8V2"/><path d="M15 8V2"/><path d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z"/>',
"users": '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
"bookmark": '<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z"/>',
"power": '<path d="M12 2v10"/><path d="M18.4 6.6a9 9 0 1 1-12.77.04"/>',
"download": '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>',
"globe": '<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>',
"key": '<circle cx="7.5" cy="15.5" r="5.5"/><path d="m21 2-9.6 9.6"/><path d="m15.5 7.5 3 3L22 7l-3-3"/>',
"lock": '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
"message": '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
"hand": '<path d="M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v2"/><path d="M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/>',
"zap": '<path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/>',
"lightbulb": '<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/>',
"activity": '<path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2"/>',
"eye": '<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/>',
"hard-drive": '<line x1="22" x2="2" y1="12" y2="12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><line x1="6" x2="6.01" y1="16" y2="16"/><line x1="10" x2="10.01" y1="16" y2="16"/>',
"info": '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
"smartphone": '<rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/>',
"terminal": '<polyline points="4 17 10 11 4 5"/><line x1="12" x2="20" y1="19" y2="19"/>',
"brain": '<path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/><path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"/><path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"/>',
"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"/>',
"bot": '<path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/>',
};
function ic(name, size = 18) { 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>`; }
const $ = (id) => document.getElementById(id);
function esc(s) { return String(s ?? "").replace(/[&<>"']/g, (c) => ({"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"}[c])); }
function toast(t) { const e = $("toast"); e.textContent = t; e.classList.add("on"); clearTimeout(toast.t); toast.t = setTimeout(() => e.classList.remove("on"), 2600); }
const nap = (ms) => new Promise((r) => setTimeout(r, ms));
// Times as the desktop's sidebar shows them: 剛剛 / 5 分鐘 / 3 小時 / 昨天 / 4 天 / 11/5.
function ago(ms) {
if (!ms) return "";
const s = (Date.now() - ms) / 1000;
if (s < 60) return "剛剛";
if (s < 3600) return Math.floor(s / 60) + " 分鐘";
const d = new Date(ms), now = new Date();
if (d.toDateString() === now.toDateString()) return Math.floor(s / 3600) + " 小時";
const y = new Date(now); y.setDate(now.getDate() - 1);
if (d.toDateString() === y.toDateString()) return "昨天";
if (s < 7 * 86400) return Math.ceil(s / 86400) + " 天";
return `${d.getMonth() + 1}/${d.getDate()}`;
}
function clock(ms) { return ms ? new Date(ms).toLocaleTimeString("zh-TW", {hour: "numeric", minute: "2-digit"}) : ""; }
function dayLabel(ms) {
const d = new Date(ms), now = new Date(), y = new Date(now); y.setDate(now.getDate() - 1);
if (d.toDateString() === now.toDateString()) return "今天";
if (d.toDateString() === y.toDateString()) return "昨天";
return d.getFullYear() === now.getFullYear() ? `${d.getMonth() + 1} 月 ${d.getDate()} 日` : `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`;
}
// Plain words of a message for a one-line preview (no Markdown marks).
function plain(t) { return String(t || "").replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").replace(/[*_`#>]/g, "").replace(/\s+/g, " ").trim(); }
// 外觀 of this phone: follow it, or light or dark by hand.
const THEMES = [["", "跟著手機"], ["light", "淺色"], ["dark", "深色"]];
function phoneTheme() { try { return localStorage.getItem("anybot.theme") || ""; } catch (_) { return ""; } }
function applyTheme() { const t = phoneTheme(); if (t) document.documentElement.dataset.theme = t; else delete document.documentElement.dataset.theme; }
applyTheme();
const S = {
relay: false, token: localStorage.getItem("anybot.token") || "", host: localStorage.getItem("anybot.host") || "",
version: 0, bots: [], approvals: [], pages: [], models: [], hostName: "", mac: false, box: "", boxOn: false, role: "owner",
open: 0, msgs: [], pending: [], quote: null, unfold: {}, openSteps: {}, detail: {}, settings: null, polling: false,
search: "", size: [0, 0], right: false,
};
const owner = () => S.role !== "member";
const isOwner = () => S.role === "owner";
const bot = (id) => S.bots.find((b) => b.id === id);
function base() { return S.relay ? "h/" + encodeURIComponent(S.host) + "/" : ""; }
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;
}
// One of the desktop's actions (api/act/<what>), with its answer as a toast.
async function act(what, body, ok) {
try {
const v = await api(base() + "api/act/" + what, {json: body || {}});
if (ok !== false) toast(ok || v.text || "好了");
return v;
} catch (e) { toast(e.message); return null; }
}
const app = (name, args, b, ok) => act("app", {bot: b || S.open || 0, name, args}, ok);
// A bot's face as in the app (src/avatar.rs): its shape in its colour, two slanted oval eyes.
const color = (c) => (/^#[0-9a-f]{6}$/i.test(String(c || "")) ? c : "#1c1c1c");
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;
case "blob": body = pts(range(40).map((i) => { const a = i / 40 * 2 * P, r = 0.92 + 0.08 * Math.sin(3 * a); return [r * Math.cos(a), 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>`;
}
const face = (b, cls = "") => `<div class="av ${cls}">${avatarSvg(b.shape, b.color)}</div>`;
// ---------- the one sheet: menus, pickers, forms ----------
// opts: [{icon, label, x (right text), check, danger, on: fn}] or html; a tap runs on() and closes.
function sheet(title, items, html) {
const el = $("sheet-in");
el.innerHTML = `<div class="grab"></div>${title ? `<div class="st">${esc(title)}</div>` : ""}` + (html || "") +
(items || []).map((o, i) => o.group ? `<div class="grp">${esc(o.group)}</div>` : `<button class="opt ${o.danger ? "danger" : ""}" data-i="${i}">${o.icon ? ic(o.icon, 20) : ""}<span>${esc(o.label)}</span>${o.check ? `<span class="ck">${ic("check", 18)}</span>` : o.x ? `<span class="x">${esc(o.x)}</span>` : ""}</button>`).join("");
el.querySelectorAll(".opt[data-i]").forEach((b) => b.onclick = () => { const o = items[+b.dataset.i]; if (!o.keep) closeSheet(); o.on && o.on(); });
$("sheet").classList.add("on");
return el;
}
function closeSheet() { $("sheet").classList.remove("on"); }
$("sheet").onclick = (e) => { if (e.target.id === "sheet") closeSheet(); };
// A question with a text answer (prompt() looks out of place on a phone).
function ask(title, value = "", placeholder = "", multi = false) {
return new Promise((done) => {
const el = sheet(title, [], `<div class="form">${multi ? `<textarea class="field" id="ask-in" placeholder="${esc(placeholder)}">${esc(value)}</textarea>` : `<input class="field" id="ask-in" value="${esc(value)}" placeholder="${esc(placeholder)}">`}<div class="row2" style="justify-content:flex-end;margin-top:6px"><button class="btn" id="ask-no">取消</button><button class="btn main" id="ask-ok">好</button></div></div>`);
const input = el.querySelector("#ask-in"); setTimeout(() => input.focus(), 50);
el.querySelector("#ask-no").onclick = () => { closeSheet(); done(null); };
el.querySelector("#ask-ok").onclick = () => { closeSheet(); done(input.value.trim()); };
if (!multi) input.onkeydown = (e) => { if (e.key === "Enter" && !e.isComposing) el.querySelector("#ask-ok").click(); };
});
}
function confirmSheet(title, note, yes, danger = true) {
return new Promise((done) => {
sheet(title, [{label: yes, danger, icon: danger ? "trash" : "check", on: () => done(true)}, {label: "取消", on: () => done(false)}], note ? `<div class="note" style="text-align:center">${esc(note)}</div>` : "");
});
}
// ---------- screens: a stack, the phone's back gesture goes back ----------
const NAV = [];
function show(v) { document.querySelectorAll(".view").forEach((x) => x.classList.toggle("on", x.id === "v-" + v)); S.view = v; if (v !== "screen") stopFrames(); if (v !== "list") stopListTimer(); }
function go(v, arg = {}, replace = false) {
if (replace && NAV.length) NAV[NAV.length - 1] = {v, arg}; else NAV.push({v, arg});
if (replace) history.replaceState({n: NAV.length}, ""); else history.pushState({n: NAV.length}, "");
draw(v, arg);
}
function back() { if (NAV.length > 1) history.back(); else go("home", {}, true); }
window.addEventListener("popstate", () => {
if (!S.token) return;
NAV.pop();
if (!NAV.length) { NAV.push({v: "home", arg: {}}); history.pushState({n: 1}, ""); }
const t = NAV[NAV.length - 1];
closeSheet();
draw(t.v, t.arg);
});
document.querySelectorAll("[data-back]").forEach((b) => { b.innerHTML = ic("chevron-left", 24); b.onclick = back; });
function draw(v, arg) {
show(v);
if (v === "home") renderHome();
else if (v === "chat") openChat(arg.bot);
else if (v === "details") renderDetails(arg.bot, arg.tab);
else if (v === "botset") renderBotset(arg.bot);
else if (v === "list") renderList(arg);
else if (v === "page") openPage(arg.id, arg.rev);
else if (v === "screen") startFrames();
}
const cur = () => NAV[NAV.length - 1] || {v: "home", arg: {}};
// ---------- 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}});
if (v.need_2fa) {
// 開了两步验证:密码只是第一步,验证器 App 上的六位数(或一张恢复码)才换来通行证。
const code = (prompt("Two-step 验证:输入 App 上的六位验证码(没有 App 可用一张恢复码)") || "").trim();
if (!code) throw new Error("已取消");
const v2 = await api("relay/2fa/verify", {json: {challenge: v.challenge, code}});
v.token = v2.token;
}
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; NAV.length = 0; show("login");
if (expired) $("login-err").textContent = "登入已失效,請重新登入";
}
$("b-out1").innerHTML = ic("log-out", 20);
$("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.
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 || [];
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);
const used = live.filter((h) => sharers(h.id).length);
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="border-radius:10px;background:var(--active)">${ic(h.online ? "monitor" : "power", 18)}</div><div class="mid"><div class="nm"><span class="t">${esc(h.name)}</span></div><div class="pv ${h.online ? "busy" : ""}">${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 ----------
const TZ = (() => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || ""; } catch (_) { return ""; } })();
async function start() {
if (!NAV.length) {
NAV.push({v: "home", arg: {}}); history.replaceState({n: 1}, "");
if (S.open) go("chat", {bot: S.open}); else draw("home", {});
} else { const t = cur(); draw(t.v, t.arg); }
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 nap(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.boxOn = !!v.box_available; S.pages = v.pages || []; S.models = v.models || []; S.sections = v.sections || null; S.role = v.role || "owner";
const t = cur();
if (t.v === "home") renderHome();
else if (t.v === "chat") { chatHead(); loadChat(); renderDock(); }
else if (t.v === "details" && Date.now() - (S.detailAt || 0) > 1500) renderDetails(t.arg.bot, S.tab, true);
}
// ---------- home: the sidebar ----------
$("b-search").innerHTML = ic("search", 20); $("b-newbot").innerHTML = ic("plus", 22); $("search-ic").innerHTML = ic("search", 16); $("b-search-x").innerHTML = ic("x", 16);
$("b-search").onclick = () => { $("searchbox").classList.add("on"); $("search").focus(); };
$("b-search-x").onclick = () => { $("search").value = ""; S.search = ""; $("searchbox").classList.remove("on"); renderHome(); };
$("search").oninput = () => { S.search = $("search").value.trim().toLowerCase(); renderHome(); };
function botState(b) {
const waiting = S.approvals.find((a) => a.bot === b.id);
if (waiting) return {cls: "wait", dot: "wait", text: "等你處理:" + (waiting.kind === "handoff" ? waiting.reason : waiting.title)};
if (b.busy) return {cls: "busy", dot: "busy", text: b.status || "工作中…"};
return {cls: "", dot: b.unread ? "unread" : "", text: plain(b.last) || b.description || "還沒有訊息"};
}
function botRow(b) {
const st = botState(b);
return `<div class="row" data-bot="${b.id}">${`<div class="av">${avatarSvg(b.shape, b.color)}${st.dot ? `<span class="dot ${st.dot}"></span>` : ""}</div>`}<div class="mid"><div class="nm"><span class="t">${esc(b.name)}</span>${b.members ? '<span class="pill">群組</span>' : ""}</div><div class="pv ${st.cls}">${esc(st.text)}</div></div><div class="tm">${ago(b.time)}</div><button class="more" data-botmenu="${b.id}" title="更多">${ic("ellipsis", 18)}</button></div>`;
}
function renderHome() {
$("host-name").textContent = S.hostName ? "· " + S.hostName : "";
$("b-me").textContent = (S.hostName || "A").slice(0, 1).toUpperCase();
const q = S.search;
const match = (b) => !q || (b.name + " " + (b.description || "")).toLowerCase().includes(q);
const bots = S.bots.filter(match);
// Shortcuts the desktop keeps beside the chat: routines, pages, computers.
const routines = S.bots.reduce((n, b) => n + (b.routines || []).length, 0);
const main = S.bots.find((b) => b.primary) || S.bots[0];
const comps = (main && main.computers) || [];
const on = comps.filter((c) => c.online).length;
$("quick").innerHTML = owner() ? [
`<button data-q="routines">${ic("alarm-clock", 15)} 例行任務 <b>${routines}</b></button>`,
`<button data-q="pages">${ic("file-text", 15)} 頁面 <b>${S.pages.length}</b></button>`,
`<button data-q="computers"><span class="st" style="background:${on ? "var(--ok)" : "var(--text4)"}"></span> 電腦 <b>${on}/${comps.length}</b></button>`,
isOwner() ? `<button data-q="settings">${ic("settings", 15)} 設定</button>` : "",
].join("") : "";
$("b-newbot").style.display = isOwner() ? "" : "none";
$("quick").querySelectorAll("[data-q]").forEach((b) => b.onclick = () => go("list", {kind: b.dataset.q}));
if (!S.bots.length) { $("home").innerHTML = `<div class="empty">還沒有 Bot。按右上角的 + 建立第一個。</div>`; return; }
if (!bots.length) { $("home").innerHTML = `<div class="empty">沒有相符的 Bot</div>`; return; }
// The sidebar's sections: 主要 Bot, 已釘選的 Bot, the person's own sections, then Bot.
const primary = bots.filter((b) => b.primary);
const pinned = bots.filter((b) => !b.primary && b.pinned);
const rest = bots.filter((b) => !b.primary && !b.pinned);
// The desktop's 分區 in its order (null = 未分區), empty ones too.
const order = S.sections || [...new Set(rest.map((b) => b.folder).filter(Boolean)), null];
const folders = order.filter((f) => f != null);
let folded = {}; try { folded = JSON.parse(localStorage.getItem("anybot.folded") || "{}"); } catch (_) {}
const sec = (title, icon, list, key, extra = "", keep = false) => list.length || keep ? `<div class="sec" data-fold="${esc(key)}">${icon ? ic(icon, 13) : ""}${esc(title)} <span class="n">${list.length}</span>${extra}${key ? `<button ${extra ? 'style="margin-left:6px"' : ""}>${ic(folded[key] ? "chevron-right" : "chevron-down", 14)}</button>` : ""}</div>${folded[key] ? "" : `<div class="rows">${list.map(botRow).join("")}</div>`}` : "";
let h = sec("主要 Bot", "star", primary, "") + sec("已釘選的 Bot", "pin", pinned, "");
for (const f of order) {
if (f == null) h += sec(folders.length ? "未分區" : "Bot", "", rest.filter((b) => !folders.includes(b.folder)), folders.length ? "loose" : "");
else h += sec(f, "folder", rest.filter((b) => b.folder === f), "f:" + f, isOwner() ? `<button data-fmenu="${esc(f)}" style="margin-left:auto">${ic("ellipsis", 15)}</button>` : "", !q);
}
$("home").innerHTML = h + `<div style="height:calc(var(--safe-b) + 24px)"></div>`;
$("home").querySelectorAll(".row[data-bot]").forEach((r) => {
r.onclick = (e) => { if (e.target.closest("[data-botmenu]")) return; go("chat", {bot: +r.dataset.bot}); };
let t = null; r.addEventListener("touchstart", () => { t = setTimeout(() => { t = "long"; botMenu(+r.dataset.bot); }, 520); }, {passive: true});
r.addEventListener("touchend", (e) => { if (t === "long") e.preventDefault(); clearTimeout(t); }); r.addEventListener("touchmove", () => clearTimeout(t), {passive: true});
});
$("home").querySelectorAll("[data-botmenu]").forEach((b) => b.onclick = (e) => { e.stopPropagation(); botMenu(+b.dataset.botmenu); });
// 分區's menu: rename it, or delete it (its bots go back to 未分區; nothing is deleted).
$("home").querySelectorAll("[data-fmenu]").forEach((b) => b.onclick = (e) => {
e.stopPropagation();
const f = b.dataset.fmenu;
sheet(f, [
{icon: "pen", label: "重新命名分區", on: async () => { const n = await ask("分區名稱", f); if (n && n !== f) app("rename_group", {group: f, new_name: n}); }},
{icon: "trash", label: "刪除分區", danger: true, on: async () => { if (await confirmSheet(`刪除分區「${f}」?`, "裡面的 Bot 會移到未分區,不會刪除任何 Bot。", "刪除分區")) app("delete_group", {group: f}); }},
]);
});
$("home").querySelectorAll(".sec[data-fold]").forEach((s) => { if (!s.dataset.fold) return; s.onclick = () => { folded[s.dataset.fold] = !folded[s.dataset.fold]; try { localStorage.setItem("anybot.folded", JSON.stringify(folded)); } catch (_) {} renderHome(); }; });
}
// The sidebar row's menu, as on the desktop.
function botMenu(id) {
const b = bot(id); if (!b || !owner()) return;
const items = [
{icon: "message", label: "開啟對話", on: () => go("chat", {bot: id})},
{icon: "info", label: "對話詳細資訊", on: () => go("details", {bot: id})},
{icon: b.unread ? "check" : "bell", label: b.unread ? "標示為已讀" : "標示為未讀", on: () => b.unread ? api(base() + `api/bots/${id}/read`, {json: {}}).then(() => toast("已標示為已讀")).catch((e) => toast(e.message)) : act("bot", {bot: id, key: "unread", value: true}, "已標示為未讀")},
];
// (Changing bots is the owner's; an admin opens, reads and restarts them.)
if (isOwner()) items.push({icon: "pin", label: b.pinned ? "取消釘選" : "釘選", on: () => act("bot", {bot: id, key: "pinned", value: !b.pinned}, b.pinned ? "已取消釘選" : "已釘選")}, {icon: "folder", label: "移至分區", on: () => moveToFolder(b)});
if (!b.primary && isOwner()) items.push({icon: "star", label: "設為主要 Bot", on: () => act("bot", {bot: id, key: "primary", value: true}, "已設為主要 Bot")});
items.push({icon: "refresh", label: "重新啟動這個 Bot", on: () => act("restart_bot", {bot: id})});
if (isOwner()) items.push({icon: "trash", label: "刪除 Bot", danger: true, on: () => deleteBot(b)});
sheet(b.name, items);
}
async function moveToFolder(b) {
if (!S.sections && !S.settings) await loadSettings();
const folders = S.sections ? S.sections.filter((f) => f != null) : (S.settings && S.settings.folders) || [];
sheet("移至分區", [
...folders.map((f) => ({icon: "folder", label: f, check: b.folder === f, on: () => app("move_to_group", {group: f, bots: [b.name]}, b.id)})),
{icon: "minus", label: "未分區", check: !b.folder, on: () => app("move_to_group", {group: "", bots: [b.name]}, b.id)},
{icon: "plus", label: "新增分區…", on: async () => { const n = await ask("新增分區", "", "例如:文宣組"); if (n) app("move_to_group", {group: n, bots: [b.name]}, b.id); }},
]);
}
async function deleteBot(b) {
if (!await confirmSheet(`刪除「${b.name}」?`, "這會刪除它和它的聊天記錄。此操作無法復原。", "刪除")) return;
if (await act("delete_bot", {bot: b.id}, `已刪除「${b.name}」`)) { if (S.open === b.id) { S.open = 0; go("home", {}, true); } }
}
// 新 Bot: 建立 Bot / 從範本建立 / 建立群組聊天 / 新增分區.
$("b-newbot").onclick = async () => {
if (!isOwner()) return;
if (!S.settings) loadSettings();
sheet("新 Bot", [
{icon: "bot", label: "建立 Bot", on: async () => { const n = await ask("新 Bot 的名字", "", "例如:研究助理"); if (n) newBotDone(await app("create_bot", {name: n})); }},
{icon: "sparkles", label: "從範本建立", on: templates},
{icon: "users", label: "建立群組聊天", on: groupChat},
{icon: "folder", label: "新增分區", on: async () => { const n = await ask("新增分區", "", "例如:文宣組"); if (n) app("create_group", {name: n}); }},
]);
};
function newBotDone(v) { if (v && v.bot) setTimeout(() => go("chat", {bot: v.bot}), 400); }
async function templates() {
if (!S.settings) await loadSettings();
const t = (S.settings && S.settings.templates) || [];
const el = sheet("從範本建立", [], t.map((x, i) => `<button class="opt" data-t="${i}"><div class="av sm">${avatarSvg(x.shape, x.color)}</div><span style="min-width:0"><div>${esc(x.name)}</div><div style="font-size:12.5px;color:var(--text3)">${esc(x.description)}</div></span></button>`).join(""));
el.querySelectorAll("[data-t]").forEach((b) => b.onclick = async () => { closeSheet(); newBotDone(await app("create_bot", {template: t[+b.dataset.t].name})); });
}
function groupChat() {
const cands = S.bots.filter((b) => !b.members);
const pick = new Set(cands.slice(0, 3).map((b) => b.id));
const draw = () => {
const el = sheet("建立群組聊天", [], `<div class="note">選幾個 Bot,它們會依序回答同一則訊息。</div>` + cands.map((b) => `<button class="opt" data-m="${b.id}"><div class="av sm">${avatarSvg(b.shape, b.color)}</div><span>${esc(b.name)}</span>${pick.has(b.id) ? `<span class="ck">${ic("check", 18)}</span>` : ""}</button>`).join("") + `<div class="form"><button class="btn main" id="gc-ok" ${pick.size ? "" : "disabled"}>建立</button></div>`);
el.querySelectorAll("[data-m]").forEach((b) => b.onclick = () => { const id = +b.dataset.m; pick.has(id) ? pick.delete(id) : pick.add(id); draw(); });
el.querySelector("#gc-ok").onclick = async () => { closeSheet(); newBotDone(await act("group_chat", {members: [...pick]}, "已建立群組聊天")); };
};
draw();
}
// 帳戶選單 (the desktop's account menu): 設定, this phone's look and notifications, more.
$("b-me").onclick = () => {
const items = [];
if (isOwner()) items.push({icon: "settings", label: "設定", on: () => go("list", {kind: "settings"})});
items.push({icon: "moon", label: "這支手機的外觀", x: (THEMES.find((t) => t[0] === phoneTheme()) || THEMES[0])[1], on: () => {
sheet("這支手機的外觀", THEMES.map(([k, n]) => ({label: n, check: phoneTheme() === k, on: () => { try { localStorage.setItem("anybot.theme", k); } catch (_) {} applyTheme(); }})));
}});
if (S.relay) items.push({icon: "bell", label: "通知", on: notifySheet});
if (S.mac) items.push({icon: "monitor", label: "看這台 Mac 的螢幕", on: () => go("screen")});
if (S.relay && owner()) items.push({icon: "plus", label: "加一台電腦(Linux、伺服器)", on: pair});
if (S.relay) items.push({icon: "refresh", label: "切換到別台 AnyBot", on: () => { S.host = ""; localStorage.removeItem("anybot.host"); S.version = 0; NAV.length = 0; pickHost(); }});
items.push({icon: "log-out", label: "登出這支手機", danger: true, on: async () => { if (await confirmSheet("要登出這支手機嗎?", "", "登出", true)) signOut(); }});
sheet(S.hostName || "AnyBot", items);
};
</script>
<script>
"use strict";
// ---------- chat ----------
$("isl-chev").innerHTML = ic("chevron-right", 14);
$("b-newchat").innerHTML = ic("pen", 19); $("b-chatmore").innerHTML = ic("ellipsis", 20);
$("b-plus").innerHTML = ic("plus", 16); $("b-mic").innerHTML = ic("mic", 18); $("b-down").innerHTML = ic("arrow-down", 18);
const ST = {waiting: "circle-alert", running: "loader", done: "circle-check", denied: "minus", failed: "circle-x"};
// What the bot is doing, in the words the desktop uses (its status line, or a waiting question).
function chatSub(b) {
const a = S.approvals.find((x) => x.bot === b.id);
if (a) return ["wait", a.kind === "handoff" ? "需要你的輸入" : "需要你核准"];
if (b.busy) return ["busy", b.status || "工作中…"];
return ["", b.via || ""];
}
function chatHead() {
const b = bot(S.open); if (!b) return;
$("isl-av").innerHTML = avatarSvg(b.shape, b.color);
$("isl-name").textContent = b.name;
const [cls, text] = chatSub(b);
$("isl-sub").className = "s " + cls; $("isl-sub").textContent = text;
$("input").placeholder = "傳訊息給 " + b.name;
$("b-newchat").style.display = owner() ? "" : "none";
}
$("island").onclick = () => go("details", {bot: S.open});
function openChat(id) {
if (S.open !== id) { S.msgs = []; S.pending = []; S.quote = null; S.unfold = {}; S.openSteps = {}; $("msgs").innerHTML = ""; }
S.open = id;
chatHead(); renderChips(); renderQuote(); loadChat(true); renderDock();
api(base() + `api/bots/${id}/read`, {json: {}}).catch(() => {});
}
let loading = false, again = false;
async function loadChat(jump) {
if (!S.open) return;
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 < 140;
S.msgs = v.messages || []; renderMsgs();
if (jump || near) sc.scrollTop = sc.scrollHeight;
} catch (e) { toast(e.message); }
loading = false;
if (again) { again = false; loadChat(); }
}
// What a new chat offers (the desktop's 建議): the everyday jobs, one tap to start writing them.
const SUGGEST = [
["search", "海巡 Threads", "整理熱門討論和互動數", "幫我在 Threads 海巡「」最近的熱門討論,整理互動最多的 5 則(誰發的、在講什麼、讚數、連結)。"],
["pen", "寫一則貼文", "用你平常的語氣先寫草稿", "用我 Threads 帳號平常的語氣,幫我寫一則關於「」的貼文草稿,先給我看,不要發。"],
["monitor", "看我電腦的畫面", "截一張現在的畫面給你", "截一張我電腦現在的畫面給我看。"],
["list-checks", "幫我研究一件事", "查清楚現況和來源,整理成一頁", "幫我研究「」:查清楚現況和來源,整理成一頁。"],
["alarm-clock", "建立例行任務", "每天、每週自動做", "幫我建立一個例行任務:每天早上 9 點"],
];
function stepHtml(t, key) {
const open = S.openSteps[key];
const body = `<div class="body">${t.detail ? `<div class="k">內容</div><pre>${esc(t.detail)}</pre>` : ""}${t.result ? `<div class="k">結果</div><pre>${esc(t.result)}</pre>` : ""}${!t.detail && !t.result ? `<div class="k">(沒有更多內容)</div>` : ""}</div>`;
return `<div class="step ${t.status} ${open ? "open" : ""}" data-step="${esc(key)}"><button class="h"><span class="st-i">${ic(ST[t.status] || "minus", 14)}</span><span class="l">${esc(t.label)}</span><span class="d">${esc(t.detail || "")}</span><span class="chev">${ic("chevron-right", 13)}</span></button>${body}</div>`;
}
function thinkHtml(m) {
const key = "t" + m.index, open = S.openSteps[key];
return `<div class="steps"><div class="step done ${open ? "open" : ""}" data-step="${key}"><button class="h"><span class="st-i">${ic("brain", 14)}</span><span class="l">思考過程</span><span class="d">${esc(plain(m.thinking).slice(0, 80))}</span><span class="chev">${ic("chevron-right", 13)}</span></button><div class="body"><pre>${esc(m.thinking)}</pre></div></div></div>`;
}
function attHtml(a) {
if (!a.path) return `<div class="filec">${ic(a.image ? "image" : "file", 16)}<span class="fn">${esc(a.name)}</span></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="filec" data-file="${esc(a.path)}" data-name="${esc(a.name)}">${ic("file", 18)}<span style="min-width:0"><div class="fn">${esc(a.name)}</div><div style="font-size:11px;color:var(--text3)">點一下下載</div></span></a>`;
}
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 width="${Math.round(h * .8)}" height="${h}" viewBox="0 0 24 30" style="flex:none"><rect x=".5" y=".5" width="23" height="29" rx="4" fill="var(--hover)" 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)) * .6}">${esc(l)}</div>`).join("");
return `<div class="pcard" data-page="${p.id}"><div class="top"><div class="t">${esc(p.title)}</div>${lines}</div><div class="foot">${GLYPH()}<div style="min-width:0"><div class="n">${esc(p.title)}</div><div class="x">頁面 · ${esc(p.updated)}${p.revision > 1 ? " · 已更新" : ""}</div></div></div></div>`;
}
function renderMsgs() {
const b = bot(S.open) || {};
const busy = b.busy;
// A turn: the bot's messages between two of the person's. Its steps fold into one line once it
// is done; while it works, the newest three stay in view.
const turns = []; let curT = null;
S.msgs.forEach((m, i) => {
if (m.role === "user" || m.summary || m.interrupted) { curT = null; return; }
if (!curT) { curT = {first: i, last: i, key: String(m.at || i), steps: 0}; turns.push(curT); }
curT.last = i; curT.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];
const lastBot = [...S.msgs].reverse().find((m) => m.role !== "user" && m.text);
const lastMsg = S.msgs[S.msgs.length - 1];
const shown = {}; let day = "";
let h = "";
S.msgs.forEach((m, i) => {
if (m.at) { const d = dayLabel(m.at); if (d !== day) { day = d; h += `<div class="dsep">${esc(d)}</div>`; } }
const reacts = (m.reactions || []).length ? `<div class="reacts">${m.reactions.map((e) => `<button data-react="${esc(e)}" data-i="${m.index}">${esc(e)}</button>`).join("")}</div>` : "";
if (m.summary) {
h += `<div class="summary" data-sum="1"><b>${ic("history", 15)} 較早的對話已整理成摘要 ${ic("chevron-down", 14)}</b><div class="tx">${esc(m.text)}</div></div>`;
return;
}
// Where AnyBot stopping cut a turn off: what it was doing, what may have run, and 「繼續」
// while it is the newest thing in the chat.
if (m.interrupted) {
const go = m === lastMsg && !busy && owner() ? `<button class="btn main sm" style="margin-top:8px" data-resume="1">繼續</button>` : "";
h += `<div class="summary open"><div class="tx">${esc(m.text)}</div>${go}</div>`;
return;
}
if (m.role === "user") {
const tag = m.wake ? `<div class="tag">${ic("alarm-clock", 12)} ${esc(m.via || "例行任務")}</div>` : "";
const q = String(m.text || "").match(/^((?:>.*\n?)+)\n*([\s\S]*)$/);
const text = q ? q[2] : m.text;
const quote = q ? `<div class="quote">${esc(q[1].replace(/^> ?/gm, "").trim())}</div>` : "";
h += `<div class="m user" data-msg="${i}">${tag}${m.text ? `<div class="bub">${quote}${esc(text)}</div>` : ""}${(m.attachments || []).map(attHtml).join("")}${reacts}</div>`;
return;
}
const t = turnOf[i];
const running = busy && t === lastTurn;
const unfolded = t && (S.unfold[t.key] || t.steps <= 2);
let steps = "", extra = "";
(m.tools || []).forEach((x, k) => {
if (x.page) { extra += pageCard(x.page); return; }
if (x.card) { extra += card(x.card); return; }
const n = (shown[t.key] = (shown[t.key] || 0) + 1);
if (unfolded) { steps += stepHtml(x, m.index + ":" + k); return; }
if (n === 1) steps += `<button class="fold" data-turn="${esc(t.key)}">${ic(running ? "loader" : "circle-check", 14)} ${running ? "已做了" : "已完成"} ${t.steps} 個步驟 ${ic("chevron-right", 13)}</button>`;
if (running && n > t.steps - 3) steps += stepHtml(x, m.index + ":" + k);
});
const end = t && i === t.last && !running;
const who = m.author ? `<div class="who">${esc(m.author)}</div>` : "";
const think = m.thinking ? thinkHtml(m) : "";
const bub = m.text ? `<div class="bub">${pageMd(m.text, 0)}</div>` : "";
const atts = (m.attachments || []).map(attHtml).join("");
const meta = end && m.text ? `<div class="meta">${esc(m.via || "")}${m.at ? " · " + clock(m.at) : ""}<button data-copy="${i}" title="複製">${ic("copy", 14)}</button>${owner() && m === lastBot && t === lastTurn ? `<button data-regen="1" title="重新產生">${ic("refresh", 14)}</button>` : ""}<button data-more="${i}" title="更多">${ic("ellipsis", 14)}</button></div>` : "";
h += `<div class="m bot ${m.error ? "err" : ""}" data-msg="${i}">${who}${think}${bub}${steps ? `<div class="steps">${steps}</div>` : ""}${extra}${atts}${reacts}${meta}</div>`;
});
// Still working: what it is doing now; a message nobody answered yet can be sent again.
if (busy) h += (lastMsg && lastMsg.role !== "user" && !lastMsg.text && !(lastMsg.tools || []).length) || lastMsg?.role === "user" ? `<div class="m bot"><div class="typing"><i></i><i></i><i></i></div></div>` : "";
if (busy && b.status) h += `<div class="presence">${esc(b.name)} ${esc(b.status)}</div>`;
if (!busy && lastMsg && lastMsg.role === "user" && !lastMsg.wake && owner()) h += `<div class="meta" style="align-self:flex-end">還沒有回覆 <button data-resend="1">${ic("refresh", 14)} 重新傳送</button></div>`;
if (!S.msgs.length) {
h = `<div class="intro">${face(b, "lg")}<div class="hi">嗨,我是${esc(b.name || "Bot")}</div><div class="sub">${esc(b.description || "想做什麼?點一個開始,或直接打字。")}</div></div><div class="sugs">${SUGGEST.map((x, k) => `<button class="sug" data-sugg="${k}">${ic(x[0], 20)}<span><div class="tt">${esc(x[1])}</div><div class="dd">${esc(x[2])}</div></span></button>`).join("")}</div>`;
}
$("msgs").innerHTML = h;
renderSendBtn();
loadFiles();
}
// Taps in the chat: steps, folds, summaries, copy, regenerate, resend, reactions, suggestions, pages.
$("msgs").addEventListener("click", async (e) => {
const st = e.target.closest(".step .h");
if (st) { const k = st.parentElement.dataset.step; S.openSteps[k] = !S.openSteps[k]; st.parentElement.classList.toggle("open"); return; }
const f = e.target.closest(".fold"); if (f) { S.unfold[f.dataset.turn] = true; renderMsgs(); return; }
const sm = e.target.closest("[data-sum]"); if (sm) { sm.classList.toggle("open"); return; }
const c = e.target.closest("[data-copy]"); if (c) { copyText(S.msgs[+c.dataset.copy].text); return; }
if (e.target.closest("[data-regen]")) { regen(); return; }
if (e.target.closest("[data-resend]")) { act("reply", {bot: S.open}); return; }
if (e.target.closest("[data-resume]")) { act("resume", {bot: S.open}, "繼續中…"); return; }
const mo = e.target.closest("[data-more]"); if (mo) { msgMenu(+mo.dataset.more); return; }
const r = e.target.closest("[data-react]"); if (r) { act("react", {bot: S.open, index: +r.dataset.i, emoji: r.dataset.react}, false).then(() => loadChat()); return; }
const sg = e.target.closest("[data-sugg]");
if (sg) {
const text = SUGGEST[+sg.dataset.sugg][3];
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"));
}
});
// Long-press a message: its menu (the desktop's hover row).
(() => {
let t = null, at = null;
$("msgs").addEventListener("touchstart", (e) => {
const m = e.target.closest(".m[data-msg]"); if (!m || e.target.closest("button,a,.step,.pcard")) return;
at = +m.dataset.msg; t = setTimeout(() => { t = "long"; msgMenu(at); }, 480);
}, {passive: true});
$("msgs").addEventListener("touchend", (e) => { if (t === "long") e.preventDefault(); clearTimeout(t); t = null; });
$("msgs").addEventListener("touchmove", () => { clearTimeout(t); t = null; }, {passive: true});
$("msgs").addEventListener("contextmenu", (e) => { const m = e.target.closest(".m[data-msg]"); if (m) { e.preventDefault(); msgMenu(+m.dataset.msg); } });
})();
async function copyText(t) { try { await navigator.clipboard.writeText(t || ""); toast("已複製"); } catch (_) { toast("這支手機不讓網頁複製"); } }
const QUICK = ["👍", "❤️", "😂", "🎉", "👀", "🙏", "👌", "🔥"];
function msgMenu(i) {
const m = S.msgs[i]; if (!m) return;
const items = [];
if (m.text) items.push({icon: "copy", label: "複製", on: () => copyText(m.text)});
if (m.text && owner()) items.push({icon: "reply", label: "引用回覆", on: () => { S.quote = {text: plain(m.text).slice(0, 300), raw: m.text}; renderQuote(); $("input").focus(); }});
const lastBot = [...S.msgs].reverse().find((x) => x.role !== "user" && x.text);
if (m === lastBot && owner()) items.push({icon: "refresh", label: "重新產生這個回答", on: regen});
if (m.role === "user" && i === S.msgs.length - 1 && owner()) items.push({icon: "refresh", label: "重新傳送", on: () => act("reply", {bot: S.open})});
const emojis = m.index != null && owner() ? `<div class="emojis">${QUICK.map((e) => `<button data-e="${e}">${e}</button>`).join("")}</div>` : "";
const el = sheet("", items, emojis);
el.querySelectorAll("[data-e]").forEach((b) => b.onclick = () => { closeSheet(); act("react", {bot: S.open, index: m.index, emoji: b.dataset.e}, false).then(() => loadChat()); });
}
async function regen() { try { await api(base() + `api/bots/${S.open}/regenerate`, {json: {}}); toast("重新產生中…"); } catch (e) { toast(e.message); } }
// The dock: what needs the person (允許 / 需要處理), what is queued, then the composer.
let thumbTimer = null;
function renderDock() {
const b = bot(S.open) || {};
const a = S.approvals.find((x) => x.bot === S.open);
clearInterval(thumbTimer);
if (!a) $("ask").innerHTML = "";
else if (!owner()) $("ask").innerHTML = `<div class="ask"><div class="q">${esc(a.title)}</div><div class="why">等擁有者或管理員決定</div></div>`;
else if (a.kind === "handoff") {
const own = /的電腦$/.test(a.computer || "") && S.boxOn;
$("ask").innerHTML = `<div class="ask"><div class="q">${ic("hand", 16)} 需要處理 · ${esc(a.computer || "")}</div><div class="why">${esc(a.reason)}</div>${own ? `<img class="thumb" id="hand-thumb" alt="">` : ""}<div class="btns"><button class="btn" data-screen="1">${ic("monitor", 15)} 接管</button><button class="btn main" data-a="once">我完成了</button><button class="btn no" data-a="reject">跳過</button></div></div>`;
$("ask").querySelector("[data-screen]").onclick = () => openBox(true);
if (own) { const load = () => boxThumb($("hand-thumb")); load(); thumbTimer = setInterval(load, 3000); }
} else {
$("ask").innerHTML = `<div class="ask"><div class="q">${esc(a.title)}</div><div class="why">${esc(a.reason)}</div><div class="btns">${a.can_always ? `<button class="btn main" data-a="always">一律允許</button><button class="btn" data-a="once">允許一次</button>` : `<button class="btn main" data-a="once">允許</button>`}<button class="btn no" data-a="reject">拒絕</button></div>${isOwner() ? `<a class="all" data-direct="1">${ic("zap", 13)} 全部允許,之後直接操作</a>` : ""}</div>`;
}
$("ask").querySelectorAll("button[data-a]").forEach((x) => x.onclick = async () => {
$("ask").innerHTML = "";
try { await api(base() + `api/bots/${S.open}/answer`, {json: {answer: x.dataset.a}}); } catch (e) { toast(e.message); }
});
const d = $("ask").querySelector("[data-direct]");
if (d) d.onclick = async () => {
if (!await confirmSheet("打開「直接操作」?", "Bot 會直接做事,只有危險操作和你的「一律詢問」規則會停下來問你。", "打開", false)) return;
if (await act("setting", {key: "direct_mode", value: true}, "已打開直接操作")) api(base() + `api/bots/${S.open}/answer`, {json: {answer: a.can_always ? "always" : "once"}}).catch(() => {});
};
const q = b.queued || [];
$("queued").innerHTML = q.length ? `<div class="queued">${ic("history", 13)} 已排入:「${esc(q.join("」「"))}」,${esc(b.name)} 會在下一步看到</div>` : "";
renderSendBtn();
}
const BOXIMG = {};
async function boxThumb(img) {
if (!img) return;
try {
const r = await api(base() + "api/box/thumb", {raw: true});
if (!r.ok) throw new Error();
const u = URL.createObjectURL(await r.blob());
if (BOXIMG.last) URL.revokeObjectURL(BOXIMG.last);
BOXIMG.last = u; img.src = u;
} catch (_) { img.alt = "看不到畫面"; }
}
function renderQuote() {
const q = S.quote;
$("quoting").style.display = q ? "" : "none";
$("quoting").innerHTML = q ? `${ic("reply", 14)}<span>${esc(q.text)}</span><button class="cbtn" id="q-x">${ic("x", 14)}</button>` : "";
if (q) $("q-x").onclick = () => { S.quote = null; renderQuote(); };
}
const input = $("input");
input.addEventListener("input", () => {
input.style.height = "auto"; input.style.height = Math.min(input.scrollHeight, 140) + "px";
$("composer").classList.toggle("tall", input.scrollHeight > 40); renderSendBtn();
});
input.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey && !e.isComposing && matchMedia("(pointer:fine)").matches) { e.preventDefault(); send(); } });
function renderSendBtn() {
const b = bot(S.open) || {};
const empty = !input.value.trim() && !S.pending.length;
const stop = empty && b.busy && owner();
$("b-send").innerHTML = ic(stop ? "stop" : "arrow-up", 16);
$("b-send").title = stop ? "停止" : "傳送訊息";
$("b-send").disabled = empty && !stop;
}
$("b-send").onclick = send;
async function send() {
let text = input.value.trim();
const b = bot(S.open) || {};
if (!text && !S.pending.length) {
if (b.busy && owner()) { try { await api(base() + `api/bots/${S.open}/stop`, {json: {}}); toast("已停止"); } catch (e) { toast(e.message); } }
return;
}
if (S.quote) { text = S.quote.raw.split("\n").map((l) => "> " + l).join("\n") + "\n\n" + text; S.quote = null; renderQuote(); }
const attachments = S.pending.map((p) => p.path);
input.value = ""; input.style.height = "auto"; $("composer").classList.remove("tall"); S.pending = []; renderChips();
if (!b.busy) { S.msgs.push({role: "user", text, attachments: []}); renderMsgs(); $("scroller").scrollTop = 1e9; }
try { await api(base() + `api/bots/${S.open}/send`, {json: {text, attachments}}); if (b.busy) toast("已排入,Bot 會在下一步看到"); } catch (e) { toast(e.message); input.value = text; }
}
$("b-plus").onclick = () => sheet("", [
{icon: "image", label: "照片與檔案", on: () => $("file").click()},
{icon: "camera", label: "拍照", on: () => $("camera").click()},
{icon: "alarm-clock", label: "建立例行任務", on: () => { input.value = "幫我建立一個例行任務:"; input.focus(); input.dispatchEvent(new Event("input")); }},
]);
async function upload(files) {
for (const f of 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); }
}
}
$("file").onchange = (e) => { upload([...e.target.files]); e.target.value = ""; };
$("camera").onchange = (e) => { upload([...e.target.files]); e.target.value = ""; };
function renderChips() {
$("chips").innerHTML = S.pending.map((p, i) => `<span class="chip">${ic(p.image ? "image" : "file", 14)}<span>${esc(p.name)}</span><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(); });
renderSendBtn();
}
// 語音輸入: the phone's own speech recognition writes into the box (nothing is recorded here).
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
let rec = null;
if (!SR) $("b-mic").style.display = "none";
$("b-mic").onclick = () => {
if (rec) { rec.stop(); return; }
rec = new SR(); rec.lang = "zh-TW"; rec.interimResults = true; rec.continuous = true;
const before = input.value ? input.value + " " : "";
rec.onresult = (e) => { input.value = before + [...e.results].map((r) => r[0].transcript).join(""); input.dispatchEvent(new Event("input")); };
rec.onend = () => { rec = null; $("b-mic").classList.remove("rec"); $("b-mic").title = "開始語音輸入"; };
rec.onerror = (e) => { if (e.error !== "aborted") toast(e.error === "not-allowed" ? "沒有允許使用麥克風" : "語音輸入停了"); };
try { rec.start(); $("b-mic").classList.add("rec"); $("b-mic").title = "停止聽寫"; toast("正在聆聽…"); } catch (_) { rec = null; }
};
$("b-newchat").onclick = async () => {
if (!await confirmSheet("開始新對話?", "目前的對話會留在「過去的對話」裡。", "開始新對話", false)) return;
try { await api(base() + `api/bots/${S.open}/new`, {json: {}}); S.unfold = {}; toast("已開始新對話"); setTimeout(() => loadChat(true), 500); } catch (e) { toast(e.message); }
};
$("b-chatmore").onclick = () => {
const b = bot(S.open); if (!b) return;
const items = [
{icon: "info", label: "對話詳細資訊", on: () => go("details", {bot: b.id})},
{icon: "history", label: "過去的對話", x: String((b.threads || []).length || ""), on: () => threads(b)},
{icon: "copy", label: "複製整段對話", on: () => copyText(S.msgs.filter((m) => m.text && !m.summary).map((m) => (m.role === "user" ? "我:" : b.name + ":") + m.text).join("\n\n"))},
];
if (owner()) items.splice(1, 0, {icon: "refresh", label: "重新產生上一個回答", on: regen});
if (isOwner()) items.push({icon: "settings", label: "Bot 設定", on: () => go("botset", {bot: b.id})});
if (isOwner()) items.push({icon: "trash", label: "清除聊天記錄", danger: true, on: async () => { if (await confirmSheet("清除聊天記錄?", "這段對話的訊息會全部刪除,無法復原。", "清除")) { await act("clear", {bot: b.id}); setTimeout(() => loadChat(true), 500); } }});
sheet(b.name, items);
};
function threads(b) {
const list = b.threads || [];
if (!list.length) { toast("還沒有過去的對話"); return; }
sheet("過去的對話", list.map((t) => ({icon: "message", label: t.title || "對話", x: ago(t.at), on: async () => {
if (!await confirmSheet("回到這段對話?", "現在的對話會留在「過去的對話」裡。", "回到這段對話", false)) return;
try { await api(base() + `api/bots/${b.id}/restore`, {json: {index: t.index}}); toast("已回到那段對話"); S.unfold = {}; setTimeout(() => loadChat(true), 700); } catch (e) { toast(e.message); }
}})));
}
$("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"}); };
</script>
<script>
"use strict";
// ---------- 對話詳細資訊 (the desktop's panel beside the chat) ----------
$("b-botset").innerHTML = ic("settings", 20);
$("b-botset").onclick = () => go("botset", {bot: cur().arg.bot});
async function loadDetail(id) {
if (!owner()) return null;
try { S.detail[id] = await api(base() + `api/bots/${id}/detail`); S.detailAt = Date.now(); } catch (e) { toast(e.message); }
return S.detail[id];
}
const item = (o) => `<${o.tap ? "button" : "div"} class="item ${o.danger ? "danger" : ""}" ${o.data || ""}>${o.icon ? ic(o.icon, 19) : ""}<div class="mid"><div class="k">${esc(o.k)}</div>${o.v ? `<div class="v">${esc(o.v)}</div>` : ""}</div>${o.r != null ? `<div class="r">${esc(o.r)}</div>` : ""}${o.sw != null ? `<label class="sw"><input type="checkbox" ${o.sw ? "checked" : ""} ${o.swdata || ""} ${o.disabled ? "disabled" : ""}><i></i></label>` : ""}${o.tap && o.chev !== false ? `<span class="chev">${ic("chevron-right", 16)}</span>` : ""}</${o.tap ? "button" : "div"}>`;
const group = (title, inner, extra = "") => `${title ? `<div class="gt">${esc(title)}${extra}</div>` : ""}<div class="group">${inner}</div>`;
async function renderDetails(id, tab, quiet) {
const b = bot(id); if (!b) { back(); return; }
S.tab = tab || S.tab || "activity";
if (!quiet || !S.detail[id]) { if (!S.detail[id]) $("details").innerHTML = `<div class="empty">載入中…</div>`; await loadDetail(id); }
if (cur().v !== "details") return;
$("b-botset").style.display = isOwner() ? "" : "none";
const d = S.detail[id] || {};
const tabs = [["activity", "動態"], ["routines", "例行任務"], ["library", "資料庫"], ["computer", "電腦"]];
if (b.members) tabs.push(["members", "成員"]);
let h = `<div class="hero">${face(b, "lg")}<div class="n">${esc(b.name)}</div>${b.description ? `<div class="d">${esc(b.description)}</div>` : ""}</div>`;
h += `<div class="tabs">${tabs.map(([k, n]) => `<button data-tab="${k}" class="${S.tab === k ? "on" : ""}">${n}</button>`).join("")}</div>`;
h += `<div id="tab">${tabBody(b, d)}</div><div style="height:calc(var(--safe-b) + 24px)"></div>`;
const sc = $("details").scrollTop;
$("details").innerHTML = h;
if (quiet) $("details").scrollTop = sc;
$("details").querySelectorAll("[data-tab]").forEach((x) => x.onclick = () => { S.tab = x.dataset.tab; renderDetails(id, S.tab, true); });
wireDetails(b, d);
}
function tabBody(b, d) {
const a = S.approvals.find((x) => x.bot === b.id);
if (S.tab === "activity") {
let h = "";
if (b.busy || a) h += group("進行中", (a ? item({icon: "hand", k: a.kind === "handoff" ? "需要你處理" : "需要你核准", v: a.title, tap: 1, data: 'data-d="chat"'}) : "") + (b.busy ? item({icon: "loader", k: b.status || "工作中…", r: "", tap: 0}) + `<div class="form"><button class="btn no" data-d="stop">${ic("stop", 14)} 停止</button></div>` : ""));
h += group("建議", SUGGEST.slice(0, 3).map((x, k) => item({icon: x[0], k: x[1], v: x[2], tap: 1, data: `data-sg="${k}"`})).join(""));
if (owner()) h += group("記得的事", ((d.memories || []).map((m, i) => `<div class="item">${ic("bookmark", 17)}<div class="mid"><div class="v" style="color:var(--text);font-size:14.5px">${esc(m)}</div></div>${isOwner() ? `<button class="ib" data-forget="${i + 1}" title="忘記">${ic("x", 16)}</button>` : ""}</div>`).join("") || `<div class="note" style="padding:12px 16px">還沒有記得的事。在聊天裡說「記住…」,或按右上的新增。</div>`), isOwner() ? `<button data-d="remember">${ic("plus", 14)} 新增</button>` : "");
if (owner()) h += group("這個 Bot", item({icon: "sparkles", k: "模型", r: b.model || "", tap: 1, data: 'data-d="model"'}) + item({icon: "file-text", k: "指示", v: d.instructions ? d.instructions.slice(0, 120) + (d.instructions.length > 120 ? "…" : "") : "還沒有寫", tap: isOwner() ? 1 : 0, data: isOwner() ? 'data-d="instr"' : ""}) + item({icon: "history", k: "過去的對話", r: String((d.threads || []).length), tap: 1, data: 'data-d="threads"'}));
return h;
}
if (S.tab === "routines") {
const rs = d.routines || [];
const list = rs.map((r) => item({icon: r.paused ? "pause" : "alarm-clock", k: r.title, v: (r.trigger ? "事件觸發:" + r.trigger : r.repeat) + " · " + r.when + (r.status ? "\n" + r.status : ""), tap: 1, data: `data-rt="${r.id}"`})).join("");
return group("", list || `<div class="note" style="padding:14px 16px">例行任務是這個 Bot 依排程重複執行的任務。按下面的新增,或在聊天中請它設定一個。</div>`) + (isOwner() ? `<div class="form"><button class="btn main" data-d="newrt">${ic("plus", 15)} 新增例行任務</button></div>` : "");
}
if (S.tab === "library") {
const pages = S.pages.filter((p) => p.bot === b.name);
const files = d.files || [];
const imgs = files.filter((f) => f.image), docs = files.filter((f) => !f.image);
let h = "";
if (pages.length) h += group("頁面", pages.map((p) => item({icon: "file-text", k: p.title, v: p.updated, tap: 1, data: `data-page="${p.id}"`})).join(""));
if (imgs.length) h += `<div class="gt">圖片 <span style="font-weight:400">${imgs.length}</span></div><div class="files">${imgs.map((f) => `<div class="f" data-file="${esc(f.path)}" data-img="1">${FILES[f.path] ? `<img src="${FILES[f.path]}">` : ic("image", 18)}</div>`).join("")}</div>`;
if (docs.length) h += group("檔案", docs.map((f) => `<a class="item tap" data-file="${esc(f.path)}" data-name="${esc(f.name)}">${ic("file", 19)}<div class="mid"><div class="k" style="font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(f.name)}</div><div class="v">${f.mine ? "你傳的" : "Bot 傳的"} · ${ago(f.at)}</div></div>${ic("download", 16)}</a>`).join(""));
return h || `<div class="empty">這段對話還沒有頁面或檔案</div>`;
}
if (S.tab === "computer") {
const comps = b.computers || [];
const own = comps.find((c) => c.kind === "own");
let h = "";
if (own) h += `<div class="gt">${esc(own.name)} <span class="pill ${S.boxOn && !/睡|暫停|關/.test(S.box) ? "ok" : ""}" style="margin-left:4px">${esc(S.boxOn ? S.box || "可以用" : "沒有 Docker")}</span></div><div class="group"><div class="thumbbox" style="margin:0;border-radius:0">${S.boxOn ? `<img id="box-thumb" alt="">` : "沒有畫面"}</div><div class="btnrow"><button class="btn main" data-d="openbox">${ic("monitor", 15)} 開啟電腦</button><button class="btn" data-d="takeover">${ic("hand", 15)} 接管</button><button class="btn" data-d="wake">${ic("power", 15)} 喚醒</button></div></div>`;
h += group("做事的電腦", item({icon: "monitor", k: "預設在這台做事", r: b.computer || "", tap: 1, data: 'data-d="pickcomp"'}));
h += group("電腦", comps.map((c) => { const k = {own: "cloud", this: "monitor", device: "laptop", ssh: "server"}[c.kind] || "monitor"; return item({icon: k, k: c.name, v: (c.online ? "在線上" : "離線") + (c.person ? " · 你正在用的" : "") + (c.name === b.computer ? " · 預設" : ""), tap: c.kind === "this" && S.mac ? 1 : 0, data: c.kind === "this" && S.mac ? 'data-d="mac"' : ""}); }).join(""));
return h;
}
if (S.tab === "members") {
const ids = (d.members || []).map((m) => m.id);
return group("成員", S.bots.filter((x) => !x.members && x.id !== b.id).map((x) => `<button class="item" data-mem="${x.id}"><div class="av sm">${avatarSvg(x.shape, x.color)}</div><div class="mid"><div class="k">${esc(x.name)}</div></div>${ids.includes(x.id) ? `<span style="color:var(--accent)">${ic("check", 18)}</span>` : ""}</button>`).join("")) + `<div class="note">成員依序回答你的訊息;只回答自己擅長的部分。</div>`;
}
return "";
}
function wireDetails(b, d) {
const el = $("details");
const on = (sel, fn) => el.querySelectorAll(sel).forEach((x) => x.onclick = (e) => fn(x, e));
on("[data-d]", async (x) => {
const k = x.dataset.d;
if (k === "chat") back();
else if (k === "stop") { try { await api(base() + `api/bots/${b.id}/stop`, {json: {}}); toast("已停止"); } catch (e) { toast(e.message); } }
else if (k === "remember") { const t = await ask("要它記住什麼?", "", "例如:之後的貼文都用輕鬆的語氣"); if (t && await app("remember", {fact: t}, b.id)) renderDetails(b.id, S.tab); }
else if (k === "model") modelSheet(b);
else if (k === "instr") editInstructions(b, d);
else if (k === "threads") threads(b);
else if (k === "newrt") routineForm(b, null);
else if (k === "openbox") openBox(false);
else if (k === "takeover") openBox(true);
else if (k === "wake") act("box", {op: "wake"}, "正在喚醒…");
else if (k === "pickcomp") pickComputer(b, d);
else if (k === "mac") go("screen");
});
on("[data-forget]", async (x) => { if (await app("forget", {what: x.dataset.forget}, b.id)) renderDetails(b.id, S.tab); });
on("[data-sg]", (x) => { const s = SUGGEST[+x.dataset.sg]; back(); setTimeout(() => { input.value = s[3]; input.focus(); input.dispatchEvent(new Event("input")); }, 60); });
on("[data-rt]", (x) => routineSheet(b, (d.routines || []).find((r) => r.id === +x.dataset.rt)));
on("[data-mem]", async (x) => {
if (!isOwner()) return;
const ids = new Set((d.members || []).map((m) => m.id)); const id = +x.dataset.mem;
ids.has(id) ? ids.delete(id) : ids.add(id);
if (await act("bot", {bot: b.id, key: "members", value: [...ids]}, "已更新成員")) renderDetails(b.id, S.tab);
});
const t = $("box-thumb"); if (t) boxThumb(t);
el.querySelectorAll(".files [data-img]").forEach(async (f) => { if (f.querySelector("img")) return; try { const u = await fileUrl(f.dataset.file); f.innerHTML = `<img src="${u}">`; } catch (_) {} });
el.querySelectorAll(".files [data-img]").forEach((f) => f.onclick = async () => { try { window.open(await fileUrl(f.dataset.file), "_blank"); } catch (e) { toast(e.message); } });
}
// The model, as the desktop's composer picker: each ready provider's chat models, or any name.
function modelSheet(b) {
const items = [];
for (const p of S.models || []) {
items.push({group: p.label});
for (const m of p.models || []) items.push({label: m, check: p.provider === b.provider && m === b.model, on: () => setBot(b, {provider: p.provider, model: m}, "之後用 " + m + " 回答")});
}
items.push({icon: "pen", label: "其他模型…", on: async () => { const m = await ask("模型名稱", b.model || "", "例如:grok-4.7"); if (m) setBot(b, {provider: b.provider, model: m}, "之後用 " + m + " 回答"); }});
sheet("模型", items);
}
async function setBot(b, body, ok) {
try { await api(base() + `api/bots/${b.id}/set`, {json: body}); toast(ok); delete S.detail[b.id]; } catch (e) { toast(e.message); }
}
function pickComputer(b, d) {
const list = (d.computers || []).map((c) => c.name);
sheet("做事的電腦", list.map((n) => ({icon: "monitor", label: n, check: n === b.computer, on: () => setBot(b, {computer: n}, "之後預設在「" + n + "」做事")})));
}
async function editInstructions(b, d) {
const t = await ask("指示(它的工作、口吻、界線)", d.instructions || "", "例如:你負責幫我經營 Threads…", true);
if (t != null && t !== d.instructions && await app("update_bot", {instructions: t}, b.id, "已更新指示")) { delete S.detail[b.id]; if (cur().v === "details") renderDetails(b.id, S.tab); else if (cur().v === "botset") renderBotset(b.id); }
}
// ---------- 例行任務 ----------
function routineSheet(b, r) {
if (!r) return;
const html = `<div class="form"><div style="font-size:17px;font-weight:600">${esc(r.title)}</div><div class="v" style="color:var(--text3);font-size:13px">${esc(r.trigger ? "事件觸發:" + r.trigger : r.repeat)} · ${esc(r.when)}${r.last ? " · 上次 " + esc(r.last) : ""}</div>${r.status ? `<div class="note" style="padding:0">${esc(r.status)}</div>` : ""}<label>每次要做的事</label><div style="white-space:pre-wrap;font-size:14px;background:var(--hover);border-radius:10px;padding:10px 12px">${esc(r.prompt)}</div></div>`;
sheet("", !isOwner() ? [] : [
{icon: "play", label: "立即執行", on: () => app("run_routine_now", {id: r.id}, b.id, "已開始執行")},
{icon: r.paused ? "play" : "pause", label: r.paused ? "繼續" : "暫停", on: async () => { if (await app("update_routine", {id: r.id, paused: !r.paused}, b.id, r.paused ? "已繼續" : "已暫停")) refreshAfter(b); }},
{icon: "pen", label: "編輯", on: () => routineForm(b, r)},
{icon: "trash", label: "刪除", danger: true, on: async () => { if (await confirmSheet(`刪除「${r.title}」?`, "這會停止它之後的執行,無法復原。", "刪除") && await app("delete_routine", {id: r.id}, b.id, "已刪除例行任務")) refreshAfter(b); }},
], html);
}
function refreshAfter(b) { delete S.detail[b.id]; setTimeout(() => { const t = cur(); if (t.v === "details") renderDetails(b.id, S.tab); else if (t.v === "list") renderList(t.arg); }, 300); }
const FREQ = [["daily", "每天"], ["workdays", "每個工作日"], ["weekly", "每週"], ["hourly", "每小時"], ["monthly", "每月"], ["once", "僅一次"]];
const DAYS = [["MO", "一"], ["TU", "二"], ["WE", "三"], ["TH", "四"], ["FR", "五"], ["SA", "六"], ["SU", "日"]];
function routineForm(b, r) {
const sc = (r && r.schedule) || {};
let freq = sc.once ? "once" : sc.frequency || "daily";
const dow = new Set(sc.daysOfWeek || sc.days_of_week || []);
if (freq === "weekly" && dow.size === 5 && !dow.has("SA") && !dow.has("SU")) freq = "workdays";
const el = sheet(r ? "編輯例行任務" : "新增例行任務", [], `<div class="form">
<label>名稱</label><input class="field" id="rt-title" value="${esc(r ? r.title : "")}" placeholder="例如:每天早上海巡">
<label>每次要做的事</label><textarea class="field" id="rt-prompt" placeholder="例如:海巡 Threads 上 AI 工具的熱門討論,整理 5 則">${esc(r ? r.prompt : "")}</textarea>
<label>多久一次</label><select class="field" id="rt-freq">${FREQ.map(([k, n]) => `<option value="${k}" ${k === freq ? "selected" : ""}>${n}</option>`).join("")}</select>
<div id="rt-days" class="row2" style="display:${freq === "weekly" ? "flex" : "none"}">${DAYS.map(([k, n]) => `<button class="btn sm ${dow.has(k) ? "main" : ""}" data-day="${k}">${n}</button>`).join("")}</div>
<div id="rt-mday" style="display:${freq === "monthly" ? "block" : "none"}"><label>每月幾號</label><input class="field" id="rt-md" type="number" min="1" max="31" value="${sc.monthDay || sc.month_day || 1}"></div>
<label>時間</label><input class="field" id="rt-time" type="time" value="${esc(sc.time || "09:00")}">
<div class="row2" style="justify-content:flex-end;margin-top:8px"><button class="btn" id="rt-no">取消</button><button class="btn main" id="rt-ok">${r ? "儲存" : "建立"}</button></div></div>`);
const days = new Set(dow);
el.querySelector("#rt-freq").onchange = (e) => { el.querySelector("#rt-days").style.display = e.target.value === "weekly" ? "flex" : "none"; el.querySelector("#rt-mday").style.display = e.target.value === "monthly" ? "block" : "none"; };
el.querySelectorAll("[data-day]").forEach((x) => x.onclick = () => { const k = x.dataset.day; days.has(k) ? days.delete(k) : days.add(k); x.classList.toggle("main"); });
el.querySelector("#rt-no").onclick = closeSheet;
el.querySelector("#rt-ok").onclick = async () => {
const title = el.querySelector("#rt-title").value.trim(), prompt = el.querySelector("#rt-prompt").value.trim();
const f = el.querySelector("#rt-freq").value, time = el.querySelector("#rt-time").value || "09:00";
if (!title || !prompt) { toast("名稱和要做的事都要填"); return; }
const schedule = {frequency: f === "workdays" ? "weekly" : f === "once" ? "daily" : f, time};
if (f === "workdays") schedule.daysOfWeek = ["MO", "TU", "WE", "TH", "FR"];
if (f === "weekly") { if (!days.size) { toast("選至少一天"); return; } schedule.daysOfWeek = DAYS.map((d) => d[0]).filter((k) => days.has(k)); }
if (f === "monthly") schedule.monthDay = Math.max(1, Math.min(31, +el.querySelector("#rt-md").value || 1));
if (f === "once") schedule.once = true;
closeSheet();
const ok = r ? await app("update_routine", {id: r.id, title, prompt, schedule}, b.id) : await app("create_routine", {title, prompt, schedule}, b.id);
if (ok) refreshAfter(b);
};
}
// ---------- Bot 設定 (the desktop's bot editor) ----------
async function renderBotset(id) {
const b = bot(id); if (!b) { back(); return; }
$("botset-title").textContent = "設定 " + b.name;
if (!S.settings) loadSettings().then(() => { if (cur().v === "botset") renderBotset(id); });
const d = S.detail[id] || await loadDetail(id) || {};
if (cur().v !== "botset") return;
const st = S.settings || {};
const CTX = [[32000, "32K"], [64000, "64K"], [128000, "128K"], [200000, "200K"], [1000000, "1M"]];
let h = `<div class="hero">${face(b, "lg")}<div class="n">${esc(b.name)}</div></div>`;
h += group("基本", item({k: "名稱", r: b.name, tap: 1, data: 'data-e="name"'}) + item({k: "描述", v: d.description || "還沒有寫", tap: 1, data: 'data-e="desc"'}) + item({k: "指示", v: d.instructions ? d.instructions.slice(0, 160) + (d.instructions.length > 160 ? "…" : "") : "還沒有寫", tap: 1, data: 'data-e="instr"'}));
h += `<div class="gt">頭像</div><div class="group"><div class="shapes">${(st.shapes || []).map((s) => `<button data-shape="${s}" class="${s === d.shape ? "on" : ""}">${avatarSvg(s, b.color)}</button>`).join("")}</div><div class="colors">${(st.colors || []).map((c) => `<button data-color="${c.key}" title="${esc(c.name)}" class="${c.key === d.color ? "on" : ""}" style="background:${c.hex}"></button>`).join("")}</div></div>`;
h += group("AI 與電腦", item({icon: "sparkles", k: "模型", r: b.via || b.model, tap: 1, data: 'data-e="model"'}) + item({icon: "monitor", k: "做事的電腦", r: b.computer || "", tap: 1, data: 'data-e="comp"'}) + item({icon: "terminal", k: "電腦操作", v: "關掉時,用電腦前會先問你", sw: d.tools_enabled !== false, swdata: 'data-sw="tools"', disabled: !isOwner()}) + item({icon: "brain", k: "模型可讀長度", r: (CTX.find((c) => c[0] === d.context_tokens) || [0, Math.round((d.context_tokens || 0) / 1000) + "K"])[1], tap: 1, data: 'data-e="ctx"'}));
if (isOwner()) h += group("已設定「一律允許」", (d.allow_rules || []).map((r) => `<div class="item">${ic("shield", 17)}<div class="mid"><div class="v" style="color:var(--text);font-size:14px">${esc(r)}</div></div><button class="btn sm no" data-rule="${esc(r)}">移除</button></div>`).join("") || `<div class="note" style="padding:12px 16px">還沒有。在核准時選「一律允許」就會記在這裡。</div>`);
h += group("整理", item({icon: "pin", k: "釘選", sw: !!d.pinned, swdata: 'data-sw="pinned"'}) + item({icon: "star", k: "主要 Bot", v: d.primary ? "這是主要 Bot" : "", sw: !!d.primary, swdata: 'data-sw="primary"', disabled: !!d.primary}) + item({icon: "folder", k: "分區", r: d.folder || "未分區", tap: 1, data: 'data-e="folder"'}));
if ((d.demos || []).length) h += group("示範教學", d.demos.map((x) => item({icon: "eye", k: x.name, v: `${x.steps} 個步驟 · ${x.created}`})).join("") + `<div class="note" style="padding:8px 16px">示範要在那台電腦上錄;Bot 在對話裡會照著做。</div>`);
h += group("", item({icon: "refresh", k: "重新啟動這個 Bot", v: "卡住時用:停止它正在做的事和背景指令", tap: 1, data: 'data-e="restart"', chev: false}) + item({icon: "trash", k: "清除聊天記錄", tap: 1, data: 'data-e="clear"', danger: true, chev: false}) + (isOwner() ? item({icon: "trash", k: "刪除 Bot", tap: 1, data: 'data-e="delete"', danger: true, chev: false}) : ""));
$("botset").innerHTML = h + `<div style="height:calc(var(--safe-b) + 24px)"></div>`;
const el = $("botset");
const refresh = () => { delete S.detail[id]; setTimeout(() => cur().v === "botset" && renderBotset(id), 300); };
el.querySelectorAll("[data-shape]").forEach((x) => x.onclick = async () => { if (await act("bot", {bot: id, key: "shape", value: x.dataset.shape}, false)) refresh(); });
el.querySelectorAll("[data-color]").forEach((x) => x.onclick = async () => { if (await act("bot", {bot: id, key: "color", value: x.dataset.color}, false)) refresh(); });
el.querySelectorAll("[data-rule]").forEach((x) => x.onclick = async () => { if (await act("bot", {bot: id, key: "allow_remove", value: x.dataset.rule}, "已移除")) refresh(); });
el.querySelectorAll("[data-sw]").forEach((x) => x.onchange = async () => {
const k = x.dataset.sw;
if (k === "primary") { if (await act("bot", {bot: id, key: "primary", value: true}, "已設為主要 Bot")) refresh(); return; }
if (!await act("bot", {bot: id, key: k, value: x.checked}, false)) x.checked = !x.checked; else refresh();
});
el.querySelectorAll("[data-e]").forEach((x) => x.onclick = async () => {
const k = x.dataset.e;
if (k === "name") { const n = await ask("名稱", b.name); if (n && n !== b.name && await app("update_bot", {name: n}, id, "已改名")) refresh(); }
else if (k === "desc") { const n = await ask("描述(一句話說它做什麼)", d.description || ""); if (n != null && await app("update_bot", {description: n}, id, "已更新描述")) refresh(); }
else if (k === "instr") editInstructions(b, d);
else if (k === "model") modelSheet(b);
else if (k === "comp") pickComputer(b, d);
else if (k === "ctx") sheet("模型可讀長度", CTX.map(([n, l]) => ({label: l + " 個 token", check: n === d.context_tokens, on: async () => { if (await act("bot", {bot: id, key: "context", value: n}, "已更新")) refresh(); }})));
else if (k === "folder") moveToFolder(b);
else if (k === "restart") act("restart_bot", {bot: id});
else if (k === "clear") { if (await confirmSheet("清除聊天記錄?", "這段對話的訊息會全部刪除,無法復原。", "清除")) act("clear", {bot: id}); }
else if (k === "delete") deleteBot(b);
});
}
</script>
<script>
"use strict";
// ---------- 設定 and the other lists ----------
async function loadSettings() {
if (!owner()) return null;
try { S.settings = await api(base() + "api/settings"); } catch (e) { toast(e.message); }
return S.settings;
}
let listTimer = null;
function stopListTimer() { clearInterval(listTimer); listTimer = null; }
const TITLES = {routines: "例行任務", pages: "頁面", computers: "電腦", settings: "設定", general: "一般", models: "模型", perms: "權限", integrations: "整合", vault: "保險庫", account: "手機與帳號", updates: "更新"};
async function renderList(arg) {
const kind = arg.kind;
$("list-title").textContent = TITLES[kind] || "";
$("list-act").innerHTML = "";
const el = $("list");
stopListTimer();
if (kind === "routines") return listRoutines(el);
if (kind === "pages") return listPages(el);
if (["computers", "settings", "general", "models", "perms", "integrations", "vault", "account", "updates"].includes(kind)) {
if (!S.settings) { el.innerHTML = `<div class="empty">載入中…</div>`; await loadSettings(); }
if (cur().v !== "list" || cur().arg.kind !== kind) return;
drawSettings(kind);
// Things that change on their own (Bot 的電腦 updating, a channel connecting): look again.
if (["computers", "integrations"].includes(kind)) listTimer = setInterval(async () => { await loadSettings(); if (cur().v === "list" && cur().arg.kind === kind) drawSettings(kind, true); }, 4000);
}
}
async function listRoutines(el) {
el.innerHTML = `<div class="empty">載入中…</div>`;
for (const b of S.bots) if (!S.detail[b.id]) await loadDetail(b.id);
if (cur().arg.kind !== "routines") return;
let h = "";
for (const b of S.bots) {
const rs = (S.detail[b.id] || {}).routines || [];
if (!rs.length) continue;
h += `<div class="gt"><div class="av sm" style="width:20px;height:20px">${avatarSvg(b.shape, b.color)}</div>${esc(b.name)}</div><div class="group">${rs.map((r) => item({icon: r.paused ? "pause" : "alarm-clock", k: r.title, v: (r.trigger ? "事件觸發:" + r.trigger : r.repeat) + " · " + r.when, tap: 1, data: `data-rt="${b.id}:${r.id}"`})).join("")}</div>`;
}
el.innerHTML = (h || `<div class="empty">還沒有例行任務。<br>例行任務是 Bot 依排程重複執行的任務。</div>`) + (isOwner() ? `<div class="form"><button class="btn main" id="rt-new">${ic("plus", 15)} 新增例行任務</button></div>` : "");
el.querySelectorAll("[data-rt]").forEach((x) => x.onclick = () => { const [bid, rid] = x.dataset.rt.split(":").map(Number); const b = bot(bid); routineSheet(b, ((S.detail[bid] || {}).routines || []).find((r) => r.id === rid)); });
if ($("rt-new")) $("rt-new").onclick = () => {
const pick = (b) => routineForm(b, null);
if (S.bots.length === 1) pick(S.bots[0]);
else sheet("交給哪個 Bot?", S.bots.filter((b) => !b.members).map((b) => ({label: b.name, on: () => setTimeout(() => pick(b), 60)})));
};
}
async function listPages(el) {
el.innerHTML = `<div class="empty">載入中…</div>`;
let pages = S.pages;
try { pages = (await act("pages", {}, false) || {}).pages || pages; } catch (_) {}
if (cur().arg.kind !== "pages") return;
el.innerHTML = pages.length ? group("", pages.map((p) => item({icon: "file-text", k: p.title, v: (p.bot ? p.bot + " · " : "") + (p.updated || ""), tap: 1, data: `data-page="${p.id}"`})).join("")) : `<div class="empty">還沒有頁面。請 Bot 研究一件事並「整理成一頁」,頁面會出現在這裡。</div>`;
}
function rulesGroup(title, list, kind) {
return group(title, (list || []).map((r) => `<div class="item">${ic(kind === "ask" ? "hand" : "shield", 17)}<div class="mid"><div class="v" style="color:var(--text);font-size:14px">${esc(r)}</div></div>${isOwner() ? `<button class="btn sm no" data-rmrule="${kind}" data-r="${esc(r)}">移除</button>` : ""}</div>`).join("") || `<div class="note" style="padding:12px 16px">還沒有規則</div>`, isOwner() ? `<button data-addrule="${kind}">${ic("plus", 14)} 新增</button>` : "");
}
function drawSettings(kind, quiet) {
const st = S.settings || {};
const el = $("list");
const sc = el.scrollTop;
let h = "";
if (kind === "settings") {
h += group("", [
["general", "settings", "一般", "外觀、時區、通知"], ["models", "sparkles", "模型", "可以用的 AI 服務"], ["perms", "shield", "權限", st.direct_mode ? "直接操作:開" : "Bot 做事前的規則"],
["integrations", "plug", "整合", "訊息管道、連接器、技能"], ["computers", "monitor", "電腦", "Bot 的電腦和你帳號裡的電腦"], ["vault", "lock", "保險庫", "密碼和卡號"],
["account", "smartphone", "手機與帳號", "登入的手機、通知"], ["updates", "download", "更新", st.version || ""],
].map(([k, i, n, v]) => item({icon: i, k: n, v, tap: 1, data: `data-go="${k}"`})).join(""));
h += `<div class="note" style="text-align:center">${esc(st.host || S.hostName)} · ${esc(st.version || "")}</div>`;
}
if (kind === "general") {
const theme = {system: "跟隨系統", light: "淺色", dark: "深色"}[st.theme] || "";
h += group("電腦上的 AnyBot", item({icon: "moon", k: "主題", r: theme, tap: 1, data: 'data-s="theme"'}) + item({icon: "sparkles", k: "強調色", r: st.accent === "black" ? "黑色" : "Bot 顏色", tap: 1, data: 'data-s="accent"'}));
h += group("", item({icon: "globe", k: "時區", v: "例行任務和頁面的時間照這個時區", r: st.timezone || "", tap: 1, data: 'data-s="tz"'}) + item({icon: "bell", k: "通知", v: "Bot 需要你或做完事時通知你", sw: !!st.notify, swdata: 'data-sset="notify"', disabled: !isOwner()}));
h += group("這支手機", item({icon: "smartphone", k: "外觀", r: (THEMES.find((t) => t[0] === phoneTheme()) || THEMES[0])[1], tap: 1, data: 'data-s="phonetheme"'}));
}
if (kind === "models") {
h += group("AI 服務", (st.providers || []).map((p, i) => item({icon: p.ready ? "circle-check" : "circle-x", k: p.label, v: p.ready ? (p.default_model ? "新 Bot 預設:" + p.default_model : "可以用") : "還沒有設定金鑰", tap: isOwner() ? 1 : 0, data: `data-prov="${i}"`})).join(""));
h += `<div class="note">金鑰只存在這台 AnyBot:手機只能寫入新的金鑰,看不到原本的。每個 Bot 用哪個模型,在 Bot 設定 › 模型 裡選。</div>`;
}
if (kind === "perms") {
h += group("", item({icon: "zap", k: "直接操作", v: st.direct_locked ? "已由管理員關閉" : "Bot 直接做事,只有危險操作和「一律詢問」規則會先問你", sw: !!st.direct_mode, swdata: 'data-sset="direct_mode"', disabled: !isOwner() || st.direct_locked}) + item({icon: "shield", k: "自動審核", v: "照下面的規則自動允許或先問你", sw: !!st.auto_review, swdata: 'data-sset="auto_review"', disabled: !isOwner()}));
h += rulesGroup("一律詢問(優先)", st.ask_rules, "ask") + rulesGroup("無需詢問直接允許", st.allow_rules, "allow");
h += `<div class="note">規則用一般的話寫,例如「發文、寄信、付款前先問我」。兩邊都符合時,「一律詢問」優先。</div>`;
}
if (kind === "integrations") {
const dot = (ok, on) => `<span style="width:8px;height:8px;border-radius:50%;flex:none;background:${!on ? "var(--text4)" : ok ? "var(--ok)" : "var(--warn)"}"></span>`;
h += group("訊息管道", (st.channels || []).map((c) => `<div class="item">${dot(c.ok, c.enabled)}<div class="mid"><div class="k">${esc(c.kind)} · ${esc(c.name)}</div><div class="v">由「${esc(c.bot)}」回覆${c.note ? " · " + esc(c.note) : ""}</div></div>${isOwner() ? `<label class="sw"><input type="checkbox" ${c.enabled ? "checked" : ""} data-ch="${c.id}"><i></i></label><button class="ib" data-chrm="${c.id}" title="移除">${ic("trash", 16)}</button>` : ""}</div>`).join("") || `<div class="note" style="padding:12px 16px">還沒有。Telegram、Slack、Discord… 要在電腦上的 AnyBot 新增(需要輸入 Token)。</div>`);
h += group("連接器(MCP)", (st.mcp || []).map((m) => `<div class="item">${dot(m.tools != null, m.enabled)}<div class="mid"><div class="k">${esc(m.name)}</div><div class="v">${!m.enabled ? "已關閉" : m.tools != null ? `已連上,${m.tools} 個工具` : m.error ? esc(m.error) : "連線中…"}</div></div>${isOwner() ? `<label class="sw"><input type="checkbox" ${m.enabled ? "checked" : ""} data-mcp="${esc(m.name)}"><i></i></label><button class="ib" data-mcprm="${esc(m.name)}" title="移除">${ic("trash", 16)}</button>` : ""}</div>`).join("") || `<div class="note" style="padding:12px 16px">還沒有連接器</div>`);
h += group("技能", (st.skills || []).map((k, i) => item({icon: "sparkles", k: k.name, v: k.description, tap: 1, data: `data-skill="${i}"`})).join("") || `<div class="note" style="padding:12px 16px">還沒有技能。Bot 做完較長的事會自己整理成技能,下次照著做。</div>`);
}
if (kind === "computers") h += computersHtml(st);
if (kind === "vault") h += `<div class="empty">${ic("lock", 28)}<br><br>保險庫裡的密碼、卡號和聯絡資訊,只在電腦上的 AnyBot 管理。<br>Bot 用到時由 AnyBot 直接填進網頁,不會交給模型,也不會經過手機。</div>`;
if (kind === "account") {
h += group("這台 AnyBot", item({icon: "monitor", k: "名稱", r: st.host || S.hostName}) + item({icon: "info", k: "你的角色", r: isOwner() ? "擁有者" : "管理員"}));
if (st.devices) h += group("直接連線的手機", st.devices.map((d, i) => item({icon: "smartphone", k: d.name, v: d.last_seen ? "上次使用 " + ago(d.last_seen * 1000) : "登入於 " + ago(d.created * 1000), tap: 1, data: `data-dev="${i}"`})).join("") || `<div class="note" style="padding:12px 16px">沒有</div>`);
const acts = [];
if (S.relay) acts.push(item({icon: "bell", k: "通知", v: "Bot 需要你允許、或做完事時,手機跳通知", tap: 1, data: 'data-s="push"'}));
if (S.relay && owner()) acts.push(item({icon: "plus", k: "加一台電腦", v: "Linux、伺服器當 Bot 的手腳", tap: 1, data: 'data-s="pair"'}));
if (S.relay) acts.push(item({icon: "refresh", k: "切換到別台 AnyBot", tap: 1, data: 'data-s="host"'}));
acts.push(item({icon: "log-out", k: "登出這支手機", tap: 1, data: 'data-s="out"', danger: true, chev: false}));
h += group("這支手機", acts.join(""));
}
if (kind === "updates") {
h += group("", item({icon: "info", k: "版本", r: st.version || ""}) + (st.newer ? item({icon: "download", k: "有新版", v: "電腦上的 AnyBot 已經下載新版,重新啟動就會換上"}) : item({icon: "circle-check", k: "已是最新版"})));
h += `<div class="note">Bot 的電腦的更新在「電腦」裡。</div>`;
}
el.innerHTML = h + `<div style="height:calc(var(--safe-b) + 24px)"></div>`;
if (quiet) el.scrollTop = sc;
wireSettings(kind, st);
}
function computersHtml(st) {
const box = st.box || {}, f = box.facts || {};
const main = S.bots.find((b) => b.primary) || S.bots[0] || {};
const comps = main.computers || [];
const own = comps.find((c) => c.kind === "own");
const running = box.available && /運作中|可以用|執行中|已開/.test(box.state || "") || (box.available && !/睡|暫停|關|沒有|失敗|建立中|喚醒中|更新/.test(box.state || ""));
let h = `<div class="gt">${esc(own ? own.name : "Bot 的電腦")} <span class="pill ${running ? "ok" : ""}" style="margin-left:4px">${esc(box.state || "")}</span></div><div class="group">`;
if (box.available) h += `<div class="thumbbox" style="margin:0;border-radius:0">${running ? `<img id="box-thumb" alt="">` : esc(box.state || "")}</div>`;
h += `<div class="btnrow"><button class="btn main" data-bx="open">${ic("monitor", 15)} 開啟電腦</button><button class="btn" data-bx="take">${ic("hand", 15)} 接管</button><button class="btn" data-bx="wake">${ic("power", 15)} 喚醒</button>${isOwner() ? `<button class="btn" data-bx="snapshot">${ic("hard-drive", 15)} 儲存快照</button>` : ""}<button class="btn" data-bx="disk">檢查用量</button></div>`;
if (box.note) h += `<div class="note" style="padding:0 16px 12px">${esc(box.note)}</div>`;
h += `</div>`;
if (box.disk) h += group("用量", `<div class="log">${esc(box.disk)}</div>`);
const rt = box.runtime === "runsc" ? "強隔離(gVisor)" : "標準";
const running_rt = f.running ? (f.running === "runsc" ? "強隔離" : "標準") : "";
const steps = f.steps && f.steps.n ? `平均每步 ${Math.round(f.steps.total_ms / f.steps.n)} ms(${f.steps.n} 步)` : "";
h += group("", item({icon: "download", k: "更新 Bot 的電腦", v: f.update == null ? "檢查中…" : f.update ? `有新版(v${box.version})。檔案和登入都會留著` : `已是 v${box.version}`, tap: isOwner() && f.update ? 1 : 0, data: 'data-bx="update"'}) +
item({icon: "alarm-clock", k: "自動更新", r: box.auto_update || "關", tap: isOwner() ? 1 : 0, data: 'data-bx="auto"'}) +
item({icon: "shield", k: "容器隔離", v: running_rt && running_rt !== rt.slice(0, 2) ? `目前是${running_rt};按更新後改成${rt}` : (f.gvisor === false && box.runtime !== "runsc" ? "這台 Docker 主機沒有 gVisor" : ""), r: rt, tap: isOwner() && (f.gvisor || box.runtime === "runsc") ? 1 : 0, data: 'data-bx="runtime"'}) +
item({icon: "hard-drive", k: "上次快照", r: box.snapshot || "還沒有"}) +
(steps ? item({icon: "activity", k: "速度", r: steps}) : "") +
(st.docker_host ? item({icon: "server", k: "Docker 主機", v: st.docker_host}) : ""));
h += group("", item({icon: "sparkles", k: "請 Bot 提出清理方案", v: "先列出用量最大的地方,不會直接刪除", tap: 1, data: 'data-bx="clean"'}) +
(isOwner() ? item({icon: "refresh", k: box.snapshot ? "從快照復原" : "重設電腦", v: box.snapshot ? `回到 ${box.snapshot} 的快照(目前的內容會先存起來)` : "換一台全新的電腦(檔案和登入會清除)", tap: 1, data: 'data-bx="reset"', danger: true, chev: false}) : ""));
if ((box.log || []).length) h += `<div class="gt">紀錄</div><div class="group"><div class="log">${esc(box.log.slice(-25).join("\n"))}</div></div>`;
const others = comps.filter((c) => c.kind !== "own");
if (others.length) h += group("帳號裡的電腦", others.map((c) => item({icon: {this: "monitor", device: "laptop", ssh: "server"}[c.kind] || "monitor", k: c.name, v: (c.online ? "在線上" : "離線") + (c.person ? " · 你正在用的" : ""), tap: c.kind === "this" && S.mac ? 1 : 0, data: c.kind === "this" && S.mac ? 'data-bx="mac"' : ""})).join(""));
return h;
}
function wireSettings(kind, st) {
const el = $("list");
const on = (sel, ev, fn) => el.querySelectorAll(sel).forEach((x) => x[ev] = () => fn(x));
const again = async () => { await loadSettings(); if (cur().v === "list") drawSettings(cur().arg.kind, true); };
on("[data-go]", "onclick", (x) => go("list", {kind: x.dataset.go}));
on("[data-sset]", "onchange", async (x) => {
const k = x.dataset.sset;
if (k === "direct_mode" && x.checked && !await confirmSheet("打開「直接操作」?", "Bot 會直接做事,只有危險操作和你的「一律詢問」規則會停下來問你。", "打開", false)) { x.checked = false; return; }
if (!await act("setting", {key: k, value: x.checked})) x.checked = !x.checked; else again();
});
on("[data-addrule]", "onclick", async (x) => { const t = await ask(x.dataset.addrule === "ask" ? "一律詢問的規則" : "無需詢問的規則", "", x.dataset.addrule === "ask" ? "例如:發文、寄信、付款前先問我" : "例如:讀取網頁和檔案"); if (t && await act("setting", {key: "rule_add", list: x.dataset.addrule, value: t}, "已新增")) again(); });
on("[data-rmrule]", "onclick", async (x) => { if (await act("setting", {key: "rule_remove", list: x.dataset.rmrule, value: x.dataset.r}, "已移除")) again(); });
on("[data-ch]", "onchange", async (x) => { if (!await act("channel", {id: +x.dataset.ch, enabled: x.checked})) x.checked = !x.checked; else again(); });
on("[data-chrm]", "onclick", async (x) => { if (await confirmSheet("移除這個管道?", "", "移除") && await act("channel", {id: +x.dataset.chrm, remove: true}, "已移除")) again(); });
on("[data-mcp]", "onchange", async (x) => { if (!await act("mcp", {name: x.dataset.mcp, enabled: x.checked})) x.checked = !x.checked; else again(); });
on("[data-mcprm]", "onclick", async (x) => { if (await confirmSheet("移除這個連接器?", "", "移除") && await act("mcp", {name: x.dataset.mcprm, remove: true}, "已移除")) again(); });
on("[data-prov]", "onclick", (x) => {
const p = st.providers[+x.dataset.prov];
sheet(p.label, [
...(p.needs_key || p.key === "Custom" ? [{icon: "key", label: p.ready && p.needs_key ? "換一把 API 金鑰…" : "設定 API 金鑰…", on: async () => { const k = await ask(p.label + " 的 API 金鑰", "", "貼上金鑰(存在這台 AnyBot,手機看不到)"); if (k && await act("provider", {provider: p.key, key: k}, "已設定金鑰")) again(); }}] : []),
{icon: "sparkles", label: "新 Bot 預設模型…", x: p.default_model || "", on: async () => { const m = await ask("新 Bot 預設用的模型", p.default_model || ""); if (m != null && await act("provider", {provider: p.key, default_model: m}, "已更新")) again(); }},
...(p.ready && p.needs_key ? [{icon: "trash", label: "清除金鑰", danger: true, on: async () => { if (await confirmSheet(`清除 ${p.label} 的金鑰?`, "用這個服務的 Bot 會沒辦法回答,直到再設定一把。", "清除") && await act("provider", {provider: p.key, key: ""}, "已清除")) again(); }}] : []),
]);
});
on("[data-dev]", "onclick", async (x) => {
const d = st.devices[+x.dataset.dev];
if (await confirmSheet(`登出「${d.name}」?`, "那支手機要重新輸入密碼才能再連。如果是你現在用的這支,也會被登出。", "登出") && await act("device_remove", {name: d.name, created: d.created}, "已登出那支手機")) again();
});
on("[data-skill]", "onclick", (x) => {
const k = st.skills[+x.dataset.skill];
sheet("", isOwner() ? [{icon: "trash", label: "刪除技能", danger: true, on: async () => { if (await confirmSheet(`刪除技能「${k.name}」?`, "", "刪除") && await app("delete_skill", {name: k.name}, 0, "已刪除技能")) again(); }}] : [],
`<div class="form"><div style="font-size:17px;font-weight:600">${esc(k.name)}</div><div style="color:var(--text3);font-size:13px">${esc(k.description)}</div><div style="white-space:pre-wrap;font-size:13.5px;background:var(--hover);border-radius:10px;padding:10px 12px;max-height:50vh;overflow:auto">${esc(k.instructions)}</div></div>`);
});
on("[data-s]", "onclick", async (x) => {
const k = x.dataset.s;
if (k === "theme") sheet("主題", [["system", "跟隨系統"], ["light", "淺色"], ["dark", "深色"]].map(([v, n]) => ({label: n, check: st.theme === v, on: async () => { if (await app("update_settings", {theme: v})) again(); }})));
else if (k === "accent") sheet("強調色", [["bot_color", "Bot 顏色"], ["black", "黑色"]].map(([v, n]) => ({label: n, check: (st.accent || "bot_color") === v, on: async () => { if (await app("update_settings", {accent: v})) again(); }})));
else if (k === "tz") {
const zones = [...new Set([TZ, "Asia/Taipei", "Asia/Tokyo", "Asia/Shanghai", "Asia/Hong_Kong", "Asia/Singapore", "Europe/London", "America/New_York", "America/Los_Angeles", "UTC"].filter(Boolean))];
sheet("時區", zones.map((z) => ({label: z + (z === TZ ? "(這支手機)" : ""), check: st.timezone === z, on: async () => { if (await app("update_settings", {timezone: z})) again(); }})));
}
else if (k === "phonetheme") sheet("這支手機的外觀", THEMES.map(([v, n]) => ({label: n, check: phoneTheme() === v, on: () => { try { localStorage.setItem("anybot.theme", v); } catch (_) {} applyTheme(); drawSettings(kind, true); }})));
else if (k === "push") notifySheet();
else if (k === "pair") pair();
else if (k === "host") { S.host = ""; localStorage.removeItem("anybot.host"); S.version = 0; NAV.length = 0; pickHost(); }
else if (k === "out") { if (await confirmSheet("要登出這支手機嗎?", "", "登出")) signOut(); }
});
on("[data-bx]", "onclick", async (x) => {
const k = x.dataset.bx, box = st.box || {};
if (k === "open") openBox(false);
else if (k === "take") openBox(true);
else if (k === "mac") go("screen");
else if (k === "wake") { await act("box", {op: "wake"}, "正在喚醒…"); again(); }
else if (k === "snapshot") { await act("box", {op: "snapshot"}, "正在儲存快照…"); again(); }
else if (k === "disk") { await act("box", {op: "disk"}, "檢查中…"); setTimeout(again, 2500); }
else if (k === "update") { if (await confirmSheet("更新 Bot 的電腦?", "正在做的事會先停下來;檔案和登入都會留著,要幾分鐘。", "更新", false)) { await act("box", {op: "update"}, "更新已開始"); again(); } }
else if (k === "auto") sheet("自動更新", [{label: "關", check: !box.auto_update, on: async () => { if (await act("box", {op: "auto_update", value: ""})) again(); }}, {label: "每天在這個時間更新…", on: async () => { const t = await ask("每天幾點更新(有新版時)", box.auto_update || "03:00", "HH:MM"); if (t && await act("box", {op: "auto_update", value: t})) again(); }}]);
else if (k === "clean") {
const main = S.bots.find((b) => b.primary) || S.bots[0]; if (!main) return;
go("chat", {bot: main.id});
setTimeout(() => { input.value = "檢查「Bot 的電腦」的磁碟使用情況,並提出安全的清理方案供我核准。先列出用量最大的地方,不要直接刪除任何東西。"; input.dispatchEvent(new Event("input")); input.focus(); }, 300);
}
else if (k === "reset") {
const restore = !!box.snapshot;
if (await confirmSheet(restore ? "從快照復原?" : "重設電腦?", restore ? "會關閉目前的電腦,從上次的快照以最新版本開一台新的。目前電腦的內容會先存成快照,事後可以找回。正在做的事會先停下來。" : "還沒有快照:會換一台全新的電腦,裡面的檔案和登入資訊都會清除。正在做的事會先停下來。", restore ? "復原" : "重設")) { await act("box", {op: "reset"}, "已開始"); again(); }
}
else if (k === "runtime") sheet("容器隔離", [["runc", "標準", "容器和主機共用核心"], ["runsc", "強隔離(gVisor)", "電腦有自己的虛擬核心,Chromium 的沙盒打開"]].map(([v, n, d]) => ({label: n, x: d, check: (box.runtime || "runc") === v, on: async () => { if (await act("box", {op: "runtime", value: v}, "已設定;按「更新」重建後生效")) again(); }})));
});
const t = $("box-thumb"); if (t) boxThumb(t);
}
// ---------- 頁面 ----------
$("b-pagemore").innerHTML = ic("ellipsis", 20);
document.addEventListener("click", (e) => {
const el = e.target.closest && e.target.closest("[data-page]");
if (el && !e.defaultPrevented) { e.preventDefault(); go("page", {id: +el.dataset.page}); }
});
async function openPage(id, rev) {
S.page = id; $("pscroll").scrollTop = 0;
if (!rev) $("page").innerHTML = `<div class="empty">載入中…</div>`;
try {
const v = await api(base() + "api/pages/" + id + (rev ? "?rev=" + rev : ""));
S.pageData = v;
$("page-name").textContent = v.title;
const old = v.revision !== v.latest;
let head = `<h1 class="pt">${esc(v.title)}</h1><div class="mt">${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>`; }
}
$("b-pagemore").onclick = () => {
const v = S.pageData; if (!v) return;
const items = [{icon: "copy", label: "複製 Markdown", on: () => copyText(v.body)}];
if ((v.versions || []).length) items.push({icon: "history", label: "其他版本", on: () => sheet("版本", [{label: `第 ${v.latest} 版(最新)`, check: v.revision === v.latest, on: () => openPage(v.id)}, ...v.versions.map((x) => ({label: `第 ${x.revision} 版`, x: x.at, check: v.revision === x.revision, on: () => openPage(v.id, x.revision)}))])});
if (isOwner()) items.push({icon: "trash", label: "刪除頁面", danger: true, on: async () => { if (await confirmSheet(`刪除「${v.title}」?`, "", "刪除") && await act("delete_page", {id: v.id}, "已刪除頁面")) back(); }});
sheet(v.title, items);
};
// Files in a chat: pictures shown in place, other files saved on a tap (fetched with the login).
const FILES = {};
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[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 isDark = () => document.documentElement.dataset.theme === "dark" || (!document.documentElement.dataset.theme && 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=" + (isDark() ? 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: "重要"};
// Markdown as the desktop draws it (pages and chat replies): headings, lists and tasks, tables,
// quotes and call-outs, code, Mermaid charts (pages: drawn by AnyBot), ```stats and ```steps.
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" && id) { 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])) {
const 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("");
}
// ---------- screens ----------
// The bots' computer: live in the viewer page (VNC), where you can also take over.
function openBox(control) {
const b = bot(S.open) || S.bots.find((x) => x.primary) || {};
const own = (b.computers || []).find((c) => c.kind === "own");
const at = S.relay ? "host=" + encodeURIComponent(S.host) : "lan=1";
location.href = "screen#" + at + "&name=" + encodeURIComponent(own ? own.name : "Bot 的電腦") + (control ? "&control=1" : "");
}
// This Mac's screen: a picture at a time; taps click, long press right-clicks.
let lastUrl = null;
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 {
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>即時`;
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; tap(img, e, "right"); }, 550); });
img.addEventListener("pointerup", (e) => { clearTimeout(pressTimer); if (!long) tap(img, e, S.right ? "right" : "left"); });
img.addEventListener("pointercancel", () => clearTimeout(pressTimer));
img.addEventListener("dblclick", (e) => { const p = point(img, e); screenInput({kind: "click", x: p.x, y: p.y, count: 2}); });
}
function tap(img, e, button) {
const p = point(img, e);
const d = document.createElement("div"); d.className = "tapdot"; d.style.left = p.cx + "px"; d.style.top = p.cy + "px"; $("stage").appendChild(d); setTimeout(() => d.remove(), 600);
screenInput({kind: "click", x: p.x, y: p.y, button});
}
async function screenInput(body) { try { await api(base() + "api/screen/mac", {json: body}); } catch (e) { toast(e.message); } }
document.querySelectorAll("[data-key]").forEach((b) => b.onclick = () => screenInput({kind: "key", key: b.dataset.key}));
document.querySelectorAll("[data-scroll]").forEach((b) => b.onclick = () => screenInput({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 = ""; screenInput({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 ? "開" : "關"); };
// ---------- 加一台電腦, notifications ----------
// A code for a new computer (ten minutes, once), as hands for this AnyBot.
async function pair() {
const el = sheet("加一台電腦", [], `<div class="note">在那台電腦(Linux 或 Mac)的終端機貼上這一行執行:還沒裝 AnyBot 會先自動裝好,接著變成「${esc(S.hostName || "這台 AnyBot")}」的手腳,開機自動執行。不用帳號密碼。</div><div class="pair-code" id="pair-code">······</div><code class="pair-line" id="pair-line">正在產生…</code><div class="form"><button class="btn main" id="pair-copy">複製這一行</button></div><div class="note" id="pair-left" style="text-align:center"></div>`);
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}`;
el.querySelector("#pair-code").textContent = v.code.slice(0, 3) + " " + v.code.slice(3);
el.querySelector("#pair-line").textContent = line;
el.querySelector("#pair-copy").onclick = () => copyText(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));
const e2 = document.getElementById("pair-left"); if (!e2) { clearInterval(S.pairTimer); return; }
e2.textContent = left ? `還有 ${Math.floor(left / 60)} 分 ${String(left % 60).padStart(2, "0")} 秒,只能用一次` : "過期了,關掉再按一次「加一台電腦」";
if (!left) clearInterval(S.pairTimer);
}, 1000);
} catch (e) { el.querySelector("#pair-line").textContent = "沒有拿到配對碼:" + e.message; }
}
const pushOK = () => S.relay && "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
function keyBytes(b64) { const s = atob(b64.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((b64.length + 3) % 4)); return Uint8Array.from(s, (c) => c.charCodeAt(0)); }
async function notifySheet() {
if (!pushOK()) { sheet("通知", [], `<div class="note">${/iPhone|iPad/.test(navigator.userAgent) ? "iPhone 要先按分享 › 加入主畫面,從主畫面打開 AnyBot 才能開通知。" : "這個瀏覽器不支援通知。"}</div>`); return; }
const reg = await navigator.serviceWorker.getRegistration();
const sub = reg && await reg.pushManager.getSubscription();
sheet("通知", [{icon: "bell", label: sub ? "關閉通知" : "開啟通知", on: async () => {
try {
const r = await navigator.serviceWorker.register("sw.js"); await navigator.serviceWorker.ready;
const old = await r.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 s2 = await r.pushManager.subscribe({userVisibleOnly: true, applicationServerKey: keyBytes(key)});
await api("relay/push/subscribe", {json: s2.toJSON()}); toast("已開啟通知");
}
} catch (e) { toast("通知設定失敗:" + e.message); }
}}], `<div class="note">Bot 需要你允許、或在你不在時做完事,手機會跳通知。</div>`);
}
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 b = +h.get("bot");
if (b) S.open = b;
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) go("chat", {bot: +d.bot});
});
document.addEventListener("visibilitychange", () => { if (!document.hidden && S.view === "screen") startFrames(); });
hello();
</script>
</body>
</html>