AnyBot/mobile/index.html

814 lines
57 KiB
HTML
Raw Normal View History

<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="AnyBot">
<meta name="theme-color" content="#121212" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
<link rel="manifest" href="manifest.webmanifest">
<link rel="icon" href="icon.svg">
<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);
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</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; }
$("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").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 ----------
$("b-screen").onclick = $("b-screen2").onclick = () => { S.back = S.view; show("screen"); $("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("正在叫醒 Bot 的電腦…"); 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>