AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
<!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)}
|
|
|
|
|
|
.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)}
|
|
|
|
|
|
.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>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 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-host" style="display:none">切換到別台 AnyBot</button>
|
|
|
|
|
|
<button class="no" id="m-out">登出這支手機</button>
|
|
|
|
|
|
<button id="m-cancel">取消</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) => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[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");
|
2026-11-04 11:30:32 +00:00
|
|
|
|
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);
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
const live = hosts.filter((h) => h.online);
|
2026-11-04 11:30:32 +00:00
|
|
|
|
if (S.host) {
|
|
|
|
|
|
const t = target(S.host);
|
|
|
|
|
|
if (byId[t] && byId[t].online) { S.host = t; localStorage.setItem("anybot.host", t); return start(); }
|
|
|
|
|
|
}
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
if (live.length === 1) { S.host = live[0].id; localStorage.setItem("anybot.host", S.host); return start(); }
|
2026-11-04 11:30:32 +00:00
|
|
|
|
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("")
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
: `<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(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
|
|
|
|
|
|
.replace(/(^|[\s(])(https?:\/\/[^\s<)]+)/g, '$1<a href="$2" target="_blank" rel="noopener">$2</a>');
|
|
|
|
|
|
const out = []; let list = null;
|
|
|
|
|
|
for (const line of h.split("\n")) {
|
|
|
|
|
|
const li = line.match(/^\s*(?:[-*•]|\d+[.)])\s+(.*)$/);
|
|
|
|
|
|
if (li) { if (!list) { list = []; } list.push(`<li>${li[1]}</li>`); continue; }
|
|
|
|
|
|
if (list) { out.push(`<ul>${list.join("")}</ul>`); list = null; }
|
|
|
|
|
|
const hd = line.match(/^(#{1,3})\s+(.*)$/);
|
|
|
|
|
|
if (hd) out.push(`<h3>${hd[2]}</h3>`);
|
|
|
|
|
|
else if (/^>\s?/.test(line)) out.push(`<blockquote>${line.replace(/^>\s?/, "")}</blockquote>`);
|
|
|
|
|
|
else out.push(line ? `<p>${line}</p>` : "");
|
|
|
|
|
|
}
|
|
|
|
|
|
if (list) out.push(`<ul>${list.join("")}</ul>`);
|
|
|
|
|
|
return out.join("");
|
|
|
|
|
|
}).join("");
|
|
|
|
|
|
}
|
|
|
|
|
|
const ST = {waiting: ic("circle-alert", 14), running: ic("loader", 14), done: ic("circle-check", 14), denied: ic("minus", 14), failed: ic("circle-x", 14)};
|
|
|
|
|
|
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.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>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
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"; 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(); };
|
|
|
|
|
|
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>
|