1947 lines
156 KiB
HTML
1947 lines
156 KiB
HTML
<!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) => ({"&": "&", "<": "<", ">": ">", '"': """, "'": "'"}[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>
|