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