AnyBot/mobile/index.html

827 lines
58 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="AnyBot">
<meta name="theme-color" content="#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">
<link rel="apple-touch-icon" href="icon.svg">
<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}
/* 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.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}
.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}
.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}
</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>
<div class="av sm" id="chat-av"></div>
<h1 id="chat-name"></h1>
<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>
<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="seg" id="seg"><button data-w="box" class="on">Bot 的電腦</button><button data-w="mac">這台 Mac</button></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>
<button class="btn" id="k-wake">叫醒</button>
</div>
</section>
</div>
<div class="toast" id="toast"></div>
<div class="sheet-bg" id="sheet"><div class="sheet">
<button id="m-notify">開啟通知</button>
<div class="note" id="m-note">Bot 需要你允許、或在你不在時做完事,手機會跳通知。</div>
<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="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>`; }
const $ = (id) => document.getElementById(id);
const S = {
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");
function toast(t) { const e = $("toast"); e.textContent = t; e.classList.add("on"); clearTimeout(toast.t); toast.t = setTimeout(() => e.classList.remove("on"), 2600); }
function show(v) { document.querySelectorAll(".view").forEach((x) => x.classList.toggle("on", x.id === "v-" + v)); S.view = v; if (v !== "screen") stopFrames(); }
function base() { return S.relay ? "h/" + encodeURIComponent(S.host) + "/" : ""; }
function esc(s) { return String(s ?? "").replace(/[&<>"']/g, (c) => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c])); }
async function api(path, opts = {}) {
const headers = Object.assign({}, opts.headers || {});
if (S.token) headers.Authorization = "Bearer " + S.token;
if (opts.json !== undefined) { headers["Content-Type"] = "application/json"; opts.body = JSON.stringify(opts.json); }
const r = await fetch(path, {method: opts.method || (opts.body ? "POST" : "GET"), headers, body: opts.body});
const signingIn = /(^|\/)(login|signup)$/.test(path);
if (r.status === 401 && !signingIn) { signOut(true); throw new Error("請重新登入"); }
if (opts.raw) return r;
const t = await r.text();
let v = {}; try { v = t ? JSON.parse(t) : {}; } catch (_) {}
if (!r.ok) throw new Error(v.error || ("HTTP " + r.status));
return v;
}
// ---------- sign in ----------
async function hello() {
try {
const r = await fetch("api/hello"); const v = await r.json();
S.relay = v.app === "anybot-relay";
} catch (_) { S.relay = false; }
$("f-user").style.display = S.relay ? "" : "none";
$("b-mode").style.display = S.relay ? "" : "none";
$("login-desc").textContent = S.relay ? "用你的 AnyBot 帳號登入。在哪裡都能用,電腦不用開任何連接埠。" : "輸入這台 AnyBot 的手機密碼(在 AnyBot 設定 › 手機與帳號 設定)。";
if (!S.token) return show("login");
if (S.relay) return pickHost();
start();
}
let signingUp = false;
$("b-mode").onclick = () => {
signingUp = !signingUp;
$("f-invite").style.display = signingUp ? "" : "none";
$("b-login").textContent = signingUp ? "建立帳號並登入" : "登入";
$("b-mode").textContent = signingUp ? "已經有帳號?登入" : "還沒有帳號?建立一個";
$("login-title").textContent = signingUp ? "建立 AnyBot 帳號" : "登入 AnyBot";
$("f-pass").placeholder = signingUp ? "密碼(至少 8 個字)" : "密碼";
};
$("b-login").onclick = async () => {
const b = $("b-login"); b.disabled = true; $("login-err").textContent = "";
const device = (navigator.userAgent.match(/iPhone|iPad|Android|Mac|Windows/) || ["手機"])[0];
try {
if (S.relay && signingUp) await api("relay/signup", {json: {user: $("f-user").value.trim(), password: $("f-pass").value, invite: $("f-invite").value.trim()}});
const v = await api(S.relay ? "relay/login" : "api/login", {json: {user: $("f-user").value.trim(), password: $("f-pass").value, device}});
S.token = v.token; localStorage.setItem("anybot.token", S.token); $("f-pass").value = "";
if (S.relay) await pickHost(); else start();
} catch (e) { $("login-err").textContent = e.message; }
b.disabled = false;
};
$("f-pass").addEventListener("keydown", (e) => { if (e.key === "Enter") $("b-login").click(); });
function signOut(expired) {
if (S.token && !expired) api(S.relay ? "relay/logout" : "api/logout", {json: {}}).catch(() => {});
S.token = ""; localStorage.removeItem("anybot.token"); S.version = 0; show("login");
if (expired) $("login-err").textContent = "登入已失效,請重新登入";
}
$("b-out1").onclick = () => signOut();
async function pickHost() {
const me = await api("relay/me");
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("、")) + " 也用這個大腦" : ""; };
$("hosts").innerHTML = 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 = () => { S.host = r.dataset.h; localStorage.setItem("anybot.host", S.host); start(); });
show("hosts");
}
// ---------- live state ----------
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);
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;
// 團隊: 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) { $("chat-name").textContent = b.name; $("chat-av").textContent = (b.name || "?")[0]; $("chat-av").style.background = color(b.color); }
loadChat(); renderAsk();
}
}
function botName(id) { const b = S.bots.find((x) => x.id === id); return b ? b.name : ""; }
function renderList() {
const el = $("bots");
if (!S.bots.length) { el.innerHTML = `<div class="empty">還沒有 Bot。打開電腦上的 AnyBot,照著畫面建立第一個 Bot,這裡就會出現。</div>`; return; }
el.innerHTML = S.bots.map((b) => {
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="row" data-id="${b.id}"><div class="av" style="background:${color(b.color)}">${esc((b.name || "?")[0])}</div><div class="mid"><div class="name">${esc(b.name)}${b.members ? '<span class="pill">群組</span>' : ""}${b.unread ? '<span class="badge"></span>' : ""}</div><div class="last">${state || esc(b.last || b.description || "")}</div></div></div>`;
}).join("");
el.querySelectorAll(".row").forEach((r) => r.onclick = () => openChat(+r.dataset.id));
}
// ---------- chat ----------
function openChat(id) {
S.open = id; S.msgs = []; S.pending = []; renderChips();
const b = S.bots.find((x) => x.id === id) || {};
$("chat-name").textContent = b.name || ""; $("chat-av").textContent = (b.name || "?")[0]; $("chat-av").style.background = color(b.color);
$("msgs").innerHTML = ""; show("chat"); loadChat(true); renderAsk();
api(base() + `api/bots/${id}/read`, {json: {}}).catch(() => {});
}
$("b-back").onclick = () => { S.open = 0; show("list"); };
let loading = false, again = false;
async function loadChat(jump) {
if (loading) { again = true; return; }
loading = true;
try {
const v = await api(base() + "api/bots/" + S.open);
const sc = $("scroller"); const near = sc.scrollHeight - sc.scrollTop - sc.clientHeight < 120;
S.msgs = v.messages || []; renderMsgs();
if (jump || near) sc.scrollTop = sc.scrollHeight;
} catch (e) { toast(e.message); }
loading = false;
if (again) { again = false; loadChat(); }
}
function md(text) {
const blocks = String(text || "").split(/```/);
return blocks.map((part, i) => {
if (i % 2) { const nl = part.indexOf("\n"); return `<pre><code>${esc(nl >= 0 ? part.slice(nl + 1) : part)}</code></pre>`; }
let h = esc(part)
.replace(/`([^`\n]+)`/g, "<code>$1</code>")
.replace(/\*\*([^*\n]+)\*\*/g, "<b>$1</b>")
.replace(/\[([^\]]+)\]\(page:(\d+)\)/g, (_, t, id) => `${GLYPH(14)}<a class="plink" data-page="${id}">${t}</a>`)
.replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
.replace(/(^|[\s(])(https?:\/\/[^\s<)]+)/g, '$1<a href="$2" target="_blank" rel="noopener">$2</a>');
const out = []; let list = null;
for (const line of h.split("\n")) {
const li = line.match(/^\s*(?:[-*•]|\d+[.)])\s+(.*)$/);
if (li) { if (!list) { list = []; } list.push(`<li>${li[1]}</li>`); continue; }
if (list) { out.push(`<ul>${list.join("")}</ul>`); list = null; }
const hd = line.match(/^(#{1,3})\s+(.*)$/);
if (hd) out.push(`<h3>${hd[2]}</h3>`);
else if (/^&gt;\s?/.test(line)) out.push(`<blockquote>${line.replace(/^&gt;\s?/, "")}</blockquote>`);
else out.push(line ? `<p>${line}</p>` : "");
}
if (list) out.push(`<ul>${list.join("")}</ul>`);
return out.join("");
}).join("");
}
const ST = {waiting: ic("circle-alert", 14), running: ic("loader", 14), done: ic("circle-check", 14), denied: ic("minus", 14), failed: ic("circle-x", 14)};
function renderMsgs() {
const busy = (S.bots.find((b) => b.id === S.open) || {}).busy;
const html = S.msgs.map((m, i) => {
if (m.role === "user") {
const atts = (m.attachments || []).map((a) => `<div class="att">${ic(a.image ? "image" : "file", 14)} ${esc(a.name)}</div>`).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 tools = (m.tools || []).map((t) => t.page ? pageCard(t.page) : t.card ? card(t.card) : `<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>`).join("");
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 via = m.text && m.via && !busy ? `<div class="via">${esc(m.via)}${(m.reactions || []).length ? " · " + m.reactions.join(" ") : ""}</div>` : "";
return `<div class="m bot ${m.error ? "err" : ""}">${who}${body}${tools}${last ? "" : ""}${via}</div>`;
}).join("");
$("msgs").innerHTML = html || `<div class="empty">還沒有訊息,說聲嗨吧</div>`;
$("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>`;
}
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(); };
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 = () => { S.back = S.view; show("screen"); $("seg").children[0].textContent = ownComputer(); $("seg").children[1].style.display = S.mac ? "" : "none"; startFrames(); };
} 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 的電腦"; }
$("b-screen").onclick = $("b-screen2").onclick = () => { S.back = S.view; show("screen"); $("seg").children[0].textContent = ownComputer(); $("seg").children[1].style.display = S.mac ? "" : "none"; startFrames(); };
$("b-back2").onclick = () => show(S.back === "chat" ? "chat" : "list");
document.querySelectorAll("#seg button").forEach((b) => b.onclick = () => { document.querySelectorAll("#seg button").forEach((x) => x.classList.toggle("on", x === b)); S.screen = b.dataset.w; $("k-wake").style.display = S.screen === "box" ? "" : "none"; startFrames(); });
let busyFrame = false, lastUrl = null;
function startFrames() { stopFrames(); frame(); S.frameTimer = setInterval(frame, 1000); }
function stopFrames() { clearInterval(S.frameTimer); S.frameTimer = null; }
async function frame() {
if (busyFrame || document.hidden) return; busyFrame = true;
try {
const r = await api(base() + "api/screen/" + S.screen, {raw: true});
if (!r.ok) { const v = await r.json().catch(() => ({})); $("stage").innerHTML = `<div class="msg">${esc(v.error || "看不到畫面")}</div>`; $("screen-live").textContent = ""; }
else {
const sz = (r.headers.get("X-Size") || "0x0").split("x").map(Number); S.size = sz;
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>即時`;
}
} catch (e) { $("screen-live").textContent = ""; }
busyFrame = false;
}
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/" + S.screen, {json: body}); setTimeout(frame, 250); } 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 ? "開" : "關"); };
$("k-wake").onclick = () => { toast("正在叫醒" + ownComputer() + "…"); input2({kind: "wake"}); };
// ---------- menu, notifications ----------
$("b-more").onclick = () => { $("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") frame(); });
hello();
</script>
</body>
</html>