From 39cd7e85d7e38fab399220f1439eac386d01c087 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Fri, 6 Nov 2026 20:28:08 +0800 Subject: [PATCH] Phone: a home with the computers, pages and routines; each bot's settings a tap away; long jobs fold up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The phone page was bare: one row per bot, a chat listing every step, a menu with four lines. - Home: the main bot large at the top with its model and last words, the other bots with times, then its computers (own box and the account's, online or not, 看畫面), the newest pages and the routines with their next run. - Chat: the bot's name opens its settings — model (chat models only), computer, past conversations (restore), regenerate, new chat. A turn's steps fold into 「已完成 N 個步驟」 when done (the newest three stay while it works); copy and regenerate under replies; four ready starts in a new chat; a jump back to the newest message. - Menu: 外觀 (follow the phone, light, dark). - API: api/state carries pages and models; each bot its model, computers, routines, past conversations; POST api/bots/{id}/set | regenerate | restore. The home's last line skips errors. Checked on a local copy of the brain's data (cut off from the account and Docker) at iPhone size. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4 --- mobile/index.html | 275 ++++++++++++++++++++++++++++++++++++++++--- src/app/remote_ui.rs | 70 ++++++++++- src/appctl.rs | 12 ++ src/remote.rs | 25 ++++ 4 files changed, 366 insertions(+), 16 deletions(-) diff --git a/mobile/index.html b/mobile/index.html index 0682308..90d7572 100644 --- a/mobile/index.html +++ b/mobile/index.html @@ -206,6 +206,68 @@ a.plink{color:var(--accent);text-decoration:underline;text-underline-offset:3px; .sheet .note{color:var(--text3);font-size:12.5px;padding:4px 16px 8px} .toast{position:fixed;left:50%;bottom:calc(env(safe-area-inset-bottom) + 90px);transform:translateX(-50%);background:var(--text);color:var(--bg);padding:9px 16px;border-radius:12px;font-size:14px;opacity:0;transition:opacity .2s;pointer-events:none;max-width:88vw;text-align:center} .toast.on{opacity:.95} +/* 外觀: chosen by hand (the menu) over the phone's own setting */ +:root[data-theme="dark"]{--bg:#121212;--side:#0e0e0e;--surface:#1f1f1f;--raise:#161616;--line:rgba(255,255,255,.08);--text:#f5f5f5;--text2:#c1c1c1;--text3:#8a8a8a;--user:rgba(255,255,255,.18)} +/* home: the main bot, the other bots, the computers, pages and routines */ +.hero{display:flex;gap:14px;align-items:center;margin:14px 14px 4px;padding:16px;border:1px solid var(--line);border-radius:20px;background:var(--surface);cursor:pointer} +.hero:active{background:var(--raise)} +.hero .av{width:60px;height:60px} +.hero .mid{flex:1;min-width:0} +.hero .name{font-size:19px;font-weight:700;display:flex;gap:6px;align-items:center} +.hero .model{font-size:12px;color:var(--text3);margin-top:1px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.hero .last{font-size:14px;color:var(--text2);margin-top:5px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} +.sec{font-size:13px;font-weight:600;color:var(--text3);margin:20px 18px 7px;display:flex;align-items:center;gap:6px} +.sec .n{font-weight:400} +.list{margin:0 14px;border:1px solid var(--line);border-radius:16px;background:var(--surface);overflow:hidden} +.list .row{border-bottom:1px solid var(--line);padding:11px 14px;cursor:pointer} +.list .row:last-child{border-bottom:0} +.list .row.static{cursor:default}.list .row.static:active{background:none} +.row .time{font-size:12px;color:var(--text3);flex:none;align-self:flex-start;margin-top:2px} +.ico{width:36px;height:36px;border-radius:11px;background:var(--raise);display:grid;place-items:center;flex:none;color:var(--text2)} +.st-on,.st-off{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:5px;vertical-align:1px} +.st-on{background:var(--ok)}.st-off{background:#8a8a8a} +.mini{font-size:13px;padding:6px 11px;border-radius:9px;background:var(--raise);font-weight:500;flex:none;display:inline-flex;gap:5px;align-items:center} +.homeend{height:calc(env(safe-area-inset-bottom) + 24px)} +/* chat: the bot's name opens its settings; steps fold up when a turn is done */ +#v-chat{position:relative} +.hbtn{display:flex;align-items:center;gap:10px;flex:1;min-width:0;text-align:left;padding:2px 4px;border-radius:10px} +.hbtn:active{background:var(--raise)} +.hbtn .tx{min-width:0;flex:1} +.hbtn h1{font-size:17px} +.hbtn .model{font-size:11.5px;color:var(--text3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;gap:4px;align-items:center} +.fold{display:flex;gap:7px;align-items:center;font-size:13px;color:var(--text3);padding:3px 4px;cursor:pointer;user-select:none} +.fold .ic{color:var(--ok)} +.fold.run .ic{color:var(--accent)} +.acts{display:flex;gap:2px;margin:-2px 0 4px -6px} +.acts button{width:32px;height:28px;border-radius:8px;display:grid;place-items:center;color:var(--text3)} +.acts button:active{background:var(--raise);color:var(--text)} +.sugg{display:flex;flex-direction:column;gap:8px;padding:28px 16px 12px} +.sugg .av{width:64px;height:64px;margin:0 auto 6px} +.sugg .hi{font-size:21px;font-weight:700;text-align:center} +.sugg .sub{font-size:13.5px;color:var(--text3);text-align:center;margin-bottom:12px} +.sugg button{display:flex;gap:11px;align-items:center;text-align:left;padding:13px 14px;border:1px solid var(--line);border-radius:14px;background:var(--surface);font-size:14.5px} +.sugg button:active{background:var(--raise)} +.sugg button .ic{color:var(--accent)} +.down{position:absolute;right:14px;bottom:calc(env(safe-area-inset-bottom) + 74px);width:38px;height:38px;border-radius:50%;background:var(--surface);border:1px solid var(--line);box-shadow:0 4px 16px rgba(0,0,0,.18);display:none;place-items:center;color:var(--text2);z-index:2} +.down.on{display:grid} +/* the bot's settings sheet */ +.bsheet{max-height:82vh;overflow-y:auto} +.bsheet .head{display:flex;gap:12px;align-items:center;padding:10px 12px 12px} +.bsheet .head .av{width:46px;height:46px} +.bsheet .head .n{font-size:18px;font-weight:700}.bsheet .head .m{font-size:12.5px;color:var(--text3)} +.bsheet details{border-top:1px solid var(--line)} +.bsheet summary{list-style:none;display:flex;gap:10px;align-items:center;padding:13px 12px;font-size:15.5px;cursor:pointer} +.bsheet summary::-webkit-details-marker{display:none} +.bsheet summary .v{margin-left:auto;color:var(--text3);font-size:13.5px;max-width:55%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.bsheet details[open] summary .chev{transform:rotate(90deg)} +.bsheet .chev{transition:transform .15s;color:var(--text3);flex:none} +.bsheet .opt{display:flex;gap:10px;align-items:center;width:100%;text-align:left;padding:10px 12px 10px 40px;font-size:14.5px;border-radius:10px} +.bsheet .opt:active{background:var(--raise)} +.bsheet .opt .ck{margin-left:auto;color:var(--accent)} +.bsheet .grp{font-size:12px;color:var(--text3);padding:8px 12px 2px 40px} +.bsheet .act{display:flex;gap:10px;align-items:center;width:100%;text-align:left;padding:13px 12px;font-size:15.5px;border-top:1px solid var(--line);border-radius:0} +.bsheet .act:active{background:var(--raise)} +.bsheet .act .ic{color:var(--text2)} @@ -245,12 +307,12 @@ a.plink{color:var(--accent);text-decoration:underline;text-underline-offset:3px;
-
-

+
+
@@ -294,6 +356,7 @@ a.plink{color:var(--accent);text-decoration:underline;text-underline-offset:3px;
+
Bot 需要你允許、或在你不在時做完事,手機會跳通知。
@@ -303,6 +366,8 @@ a.plink{color:var(--accent);text-decoration:underline;text-underline-offset:3px;
+
+
加一台電腦
在那台電腦(Linux 或 Mac)的終端機貼上這一行執行:還沒裝 AnyBot 會先自動裝好,接著變成「」的手腳,開機自動執行。不用帳號密碼。
@@ -318,8 +383,45 @@ a.plink{color:var(--accent);text-decoration:underline;text-underline-offset:3px; // Lucide icons (ISC licence), the same set as the app. const LUCIDE = {"image": " ", "file": " ", "circle-check": " ", "loader": " ", "circle-alert": " ", "minus": "", "circle-x": " ", "x": " ", "keyboard": " ", "paperclip": "", "alarm-clock": " "}; function ic(name, size = 16) { return `${LUCIDE[name] || ""}`; } +// More icons (Lucide), for the home, the bot's settings and the chat. +Object.assign(LUCIDE, { + "copy": " ", + "refresh": " ", + "history": " ", + "monitor": " ", + "laptop": " ", + "cloud": "", + "server": " ", + "file-text": " ", + "chevron-right": "", + "chevron-down": "", + "sparkles": "", + "check": "", + "arrow-down": " ", + "plus": " ", + "search": " ", + "pen": " ", + "camera": " ", + "list-checks": " ", +}); +// Times as on a phone: today 「下午 7:41」, earlier 「11/5」. +function fmtTime(ms) { + if (!ms) return ""; + const d = new Date(ms), now = new Date(); + if (d.toDateString() === now.toDateString()) return d.toLocaleTimeString("zh-TW", {hour: "numeric", minute: "2-digit"}); + return `${d.getMonth() + 1}/${d.getDate()}`; +} +// 外觀: follow the phone, or light or dark by hand. +const THEMES = [["", "跟著手機"], ["light", "淺色"], ["dark", "深色"]]; +function applyTheme() { + let t = ""; try { t = localStorage.getItem("anybot.theme") || ""; } catch (_) {} + if (t) document.documentElement.dataset.theme = t; else delete document.documentElement.dataset.theme; + const el = document.getElementById("m-theme"); + if (el) el.textContent = "外觀:" + (THEMES.find((x) => x[0] === t) || THEMES[0])[1]; +} const $ = (id) => document.getElementById(id); const S = { + unfold: {}, pages: [], models: [], relay: false, token: localStorage.getItem("anybot.token") || "", host: localStorage.getItem("anybot.host") || "", version: 0, bots: [], approvals: [], open: 0, msgs: [], screen: "box", right: false, pending: [], hostName: "", mac: false, polling: false, frameTimer: null, size: [0, 0], @@ -468,6 +570,7 @@ async function start() { } function apply(v) { S.bots = v.bots || []; S.approvals = v.approvals || []; S.hostName = v.host || ""; S.mac = !!v.mac; S.box = v.box; + S.pages = v.pages || []; S.models = v.models || []; // 團隊: a member only chats; the owner and admins approve and use the screens. S.role = v.role || "owner"; const member = S.role === "member"; @@ -476,27 +579,59 @@ function apply(v) { renderList(); if (S.open) { const b = S.bots.find((x) => x.id === S.open); - if (b) { $("chat-name").textContent = b.name; $("chat-av").innerHTML = avatarSvg(b.shape, b.color); $("chat-av").classList.add("face"); } + if (b) chatHead(b); loadChat(); renderAsk(); } } function botName(id) { const b = S.bots.find((x) => x.id === id); return b ? b.name : ""; } +function botRow(b, cls = "row") { + const waiting = S.approvals.some((a) => a.bot === b.id); + const state = waiting ? `等你決定` : b.busy ? `${esc(b.status || "工作中…")}` : ""; + return `
${avatarSvg(b.shape, b.color)}
${esc(b.name)}${b.members ? '群組' : ""}${b.unread ? '' : ""}
${cls === "hero" ? `
${esc(b.via || "")}
` : ""}
${state || esc(b.last || b.description || "")}
${cls === "row" && b.time ? `
${fmtTime(b.time)}
` : ""}
`; +} +const KIND = {own: ["cloud", "Bot 自己的電腦"], this: ["monitor", "這台"], device: ["laptop", "你帳號裡的電腦"], ssh: ["server", "SSH"]}; +// The home: the main bot big at the top, the other bots, then its computers, its pages and the +// routines — what the desktop shows beside the chat. function renderList() { const el = $("bots"); if (!S.bots.length) { el.innerHTML = `
還沒有 Bot。打開電腦上的 AnyBot,照著畫面建立第一個 Bot,這裡就會出現。
`; return; } - el.innerHTML = S.bots.map((b) => { - const waiting = S.approvals.some((a) => a.bot === b.id); - const state = waiting ? `等你決定` : b.busy ? `${esc(b.status || "工作中…")}` : ""; - return `
${avatarSvg(b.shape, b.color)}
${esc(b.name)}${b.members ? '群組' : ""}${b.unread ? '' : ""}
${state || esc(b.last || b.description || "")}
`; - }).join(""); - el.querySelectorAll(".row").forEach((r) => r.onclick = () => openChat(+r.dataset.id)); + const main = S.bots.find((b) => b.primary) || S.bots[0]; + const others = S.bots.filter((b) => b !== main); + const member = S.role === "member"; + let h = botRow(main, "hero"); + if (others.length) h += `
Bot ${others.length}
${others.map((b) => botRow(b)).join("")}
`; + const comps = (main.computers || []); + if (comps.length && !member) { + h += `
電腦
` + comps.map((c) => { + const [icon, kind] = KIND[c.kind] || ["monitor", ""]; + const state = c.kind === "own" ? (c.online ? esc(S.box || "可以用") : "沒有 Docker") : (c.online ? "在線上" : "離線"); + const on = c.kind === "own" ? c.online && !/睡|暫停|關/.test(S.box || "") : c.online; + const btn = c.kind === "own" && c.online ? `` : (c.kind === "this" && S.mac ? `` : ""); + return `
${ic(icon, 18)}
${esc(c.name)}${c.name === main.computer ? '預設' : ""}
${state} · ${c.person ? "你正在用的" : kind}
${btn}
`; + }).join("") + `
`; + } + if ((S.pages || []).length) { + h += `
頁面 ${S.pages.length}
` + S.pages.slice(0, 6).map((p) => `
${ic("file-text", 18)}
${esc(p.title)}
${esc(p.bot ? p.bot + " · " : "")}${esc(p.updated || "")}
`).join("") + `
`; + } + const routines = S.bots.flatMap((b) => (b.routines || []).map((r) => ({...r, bot: b}))); + if (routines.length) { + h += `
例行任務 ${routines.length}
` + routines.map((r) => `
${ic("alarm-clock", 18)}
${esc(r.title)}${r.paused ? '已暫停' : ""}
${esc(r.bot.name)} · ${esc(r.when || "")}
`).join("") + `
`; + } + el.innerHTML = h + `
`; + el.querySelectorAll("[data-id]").forEach((r) => r.onclick = () => openChat(+r.dataset.id)); + el.querySelectorAll("[data-box]").forEach((b) => b.onclick = (e) => { e.stopPropagation(); S.open = main.id; openBox(false); }); + el.querySelectorAll("[data-mac]").forEach((b) => b.onclick = (e) => { e.stopPropagation(); $("m-mac").click(); }); } // ---------- chat ---------- +function chatHead(b) { + $("chat-name").textContent = b.name || ""; $("chat-av").innerHTML = avatarSvg(b.shape, b.color); $("chat-av").classList.add("face"); + $("chat-model").innerHTML = b.via ? `${esc(b.via)} ${ic("chevron-down", 12)}` : ""; +} function openChat(id) { S.open = id; S.msgs = []; S.pending = []; renderChips(); const b = S.bots.find((x) => x.id === id) || {}; - $("chat-name").textContent = b.name || ""; $("chat-av").innerHTML = avatarSvg(b.shape, b.color); $("chat-av").classList.add("face"); + chatHead(b); S.unfold = {}; $("msgs").innerHTML = ""; show("chat"); loadChat(true); renderAsk(); api(base() + `api/bots/${id}/read`, {json: {}}).catch(() => {}); } @@ -539,8 +674,28 @@ function md(text) { }).join(""); } const ST = {waiting: ic("circle-alert", 14), running: ic("loader", 14), done: ic("circle-check", 14), denied: ic("minus", 14), failed: ic("circle-x", 14)}; +// What a new chat offers: the everyday jobs, one tap to start writing them. +const SUGGEST = [ + ["search", "海巡 Threads", "幫我在 Threads 海巡「」最近的熱門討論,整理互動最多的 5 則(誰發的、在講什麼、讚數、連結)。"], + ["pen", "寫一則貼文", "用我 Threads 帳號平常的語氣,幫我寫一則關於「」的貼文草稿,先給我看,不要發。"], + ["camera", "看我電腦的畫面", "截一張我電腦現在的畫面給我看。"], + ["list-checks", "幫我研究一件事", "幫我研究「」:查清楚現況和來源,整理成一頁。"], +]; +function stepRow(t) { return `
${ST[t.status] || "·"}${esc(t.label)}${esc(t.detail)}
`; } function renderMsgs() { - const busy = (S.bots.find((b) => b.id === S.open) || {}).busy; + const bot = S.bots.find((b) => b.id === S.open) || {}; + const busy = bot.busy; + // A turn: the bot's messages between two of the person's. Its steps fold into one line once it + // is done (a long job listed dozens of them); while it works, the last few stay in view. + const turns = []; let cur = null; + S.msgs.forEach((m, i) => { + if (m.role === "user" || m.summary) { cur = null; return; } + if (!cur) { cur = {first: i, last: i, key: String(m.at || i), steps: 0}; turns.push(cur); } + cur.last = i; cur.steps += (m.tools || []).filter((t) => !t.page && !t.card).length; + }); + const turnOf = {}; turns.forEach((t) => { for (let i = t.first; i <= t.last; i++) turnOf[i] = t; }); + const lastTurn = turns[turns.length - 1]; + let shownSteps = {}; const html = S.msgs.map((m, i) => { if (m.role === "user") { const atts = (m.attachments || []).map(attHtml).join(""); @@ -548,20 +703,110 @@ function renderMsgs() { return `
${m.wake ? `
${ic("alarm-clock", 13)} 例行任務
` : ""}${m.text ? `
${esc(m.text)}
` : ""}${atts}
`; } if (m.summary) return `
(較早的對話已整理成摘要)
`; - const tools = (m.tools || []).map((t) => t.page ? pageCard(t.page) : t.card ? card(t.card) : `
${ST[t.status] || "·"}${esc(t.label)}${esc(t.detail)}
`).join(""); + const t = turnOf[i]; + const running = busy && t === lastTurn; + const open = t && (S.unfold[t.key] || t.steps <= 2); + let tools = ""; + for (const x of (m.tools || [])) { + if (x.page) { tools += pageCard(x.page); continue; } + if (x.card) { tools += card(x.card); continue; } + const n = (shownSteps[t.key] = (shownSteps[t.key] || 0) + 1); + if (open) { tools += stepRow(x); continue; } + // Folded: one line where the first step was; while working, the newest three still show. + if (n === 1) tools += `
${running ? ST.running : ic("circle-check", 14)} ${running ? "已做了" : "已完成"} ${t.steps} 個步驟 ${ic("chevron-right", 13)}
`; + if (running && n > t.steps - 3) tools += stepRow(x); + } const last = i === S.msgs.length - 1; const body = m.text ? `
${md(m.text)}
` : (last && busy && !tools ? `
` : ""); const who = m.author ? `
${esc(m.author)}
` : ""; - const via = m.text && m.via && !busy ? `
${esc(m.via)}${(m.reactions || []).length ? " · " + m.reactions.join(" ") : ""}
` : ""; + const end = t && i === t.last && !running; + const via = m.text && m.via && end ? `
${esc(m.via)}${m.at ? " · " + fmtTime(m.at) : ""}${(m.reactions || []).length ? " · " + m.reactions.join(" ") : ""}
` : ""; + const acts = end && m.text && !m.error ? `
${t === lastTurn && S.role !== "member" ? `` : ""}
` : ""; const atts = (m.attachments || []).map(attHtml).join(""); - return `
${who}${body}${tools}${atts}${last ? "" : ""}${via}
`; + return `
${who}${body}${tools}${atts}${via}${acts}
`; }).join(""); - $("msgs").innerHTML = html || `
還沒有訊息,說聲嗨吧
`; + if (!html) { + const name = esc(bot.name || "Bot"); + $("msgs").innerHTML = `
${avatarSvg(bot.shape, bot.color)}
嗨,我是${name}
想做什麼?點一個開始,或直接打字。
${SUGGEST.map((x, k) => ``).join("")}
`; + } else { + $("msgs").innerHTML = html; + } $("b-send").innerHTML = busy && !$("input").value.trim() && !S.pending.length ? `` : ``; loadFiles(); } +$("msgs").addEventListener("click", async (e) => { + const f = e.target.closest(".fold"); + if (f) { S.unfold[f.dataset.turn] = true; renderMsgs(); return; } + const c = e.target.closest("[data-copy]"); + if (c) { + const m = S.msgs[+c.dataset.copy]; + try { await navigator.clipboard.writeText(m.text || ""); toast("已複製"); } catch (_) { toast("這支手機不讓網頁複製"); } + return; + } + if (e.target.closest("[data-regen]")) { try { await api(base() + `api/bots/${S.open}/regenerate`, {json: {}}); toast("重新產生中…"); } catch (err) { toast(err.message); } return; } + const sg = e.target.closest("[data-sugg]"); + if (sg) { + const text = SUGGEST[+sg.dataset.sugg][2]; + const input = $("input"); input.value = text; input.focus(); + const at = text.indexOf("「」"); if (at >= 0) input.setSelectionRange(at + 1, at + 1); + input.dispatchEvent(new Event("input")); + } +}); +// ---------- the bot's settings (tap its name) ---------- +// Its model, its computer, its past conversations; the same choices as the desktop's menu. +function openBotSheet() { + const b = S.bots.find((x) => x.id === S.open); + if (!b) return; + const owner = S.role !== "member"; + let h = `
${avatarSvg(b.shape, b.color)}
${esc(b.name)}
${esc(b.description || "")}
`; + if (owner && (S.models || []).length) { + h += `
${ic("sparkles", 18)} 模型${esc(b.via || "")}${ic("chevron-right", 15).replace('class="ic"', 'class="ic chev"')}` + + S.models.map((p) => `
${esc(p.label)}
` + (p.models || []).map((m) => ``).join("")).join("") + `
`; + } + if (owner && (b.computers || []).length) { + h += `
${ic("monitor", 18)} 用哪台電腦${esc(b.computer || "")}${ic("chevron-right", 15).replace('class="ic"', 'class="ic chev"')}` + + b.computers.map((c) => ``).join("") + `
`; + } + if ((b.threads || []).length) { + h += `
${ic("history", 18)} 過去的對話${b.threads.length} 個${ic("chevron-right", 15).replace('class="ic"', 'class="ic chev"')}` + + b.threads.map((t) => ``).join("") + `
`; + } + if (owner) h += ``; + h += ``; + $("botsheet-in").innerHTML = h; + $("botsheet").classList.add("on"); +} +$("b-botinfo").onclick = openBotSheet; +$("botsheet").addEventListener("click", async (e) => { + if (e.target.id === "botsheet") { $("botsheet").classList.remove("on"); return; } + const done = (msg) => { $("botsheet").classList.remove("on"); if (msg) toast(msg); }; + const o = e.target.closest(".opt, .act"); + if (!o) return; + try { + if (o.dataset.model) { await api(base() + `api/bots/${S.open}/set`, {json: {provider: o.dataset.provider, model: o.dataset.model}}); done("之後用 " + o.dataset.model + " 回答"); } + else if (o.dataset.computer) { await api(base() + `api/bots/${S.open}/set`, {json: {computer: o.dataset.computer}}); done("之後預設在「" + o.dataset.computer + "」做事"); } + else if (o.dataset.thread) { + if (!confirm("回到這段對話?現在的對話會留在「過去的對話」裡。")) return; + await api(base() + `api/bots/${S.open}/restore`, {json: {index: +o.dataset.thread}}); done("已回到那段對話"); S.unfold = {}; setTimeout(() => loadChat(true), 800); + } + else if (o.dataset.sheet === "regen") { await api(base() + `api/bots/${S.open}/regenerate`, {json: {}}); done("重新產生中…"); } + else if (o.dataset.sheet === "new") { done(); $("b-new").click(); } + else done(); + } catch (err) { toast(err.message); } +}); +$("m-theme").onclick = () => { + let t = ""; try { t = localStorage.getItem("anybot.theme") || ""; } catch (_) {} + const next = THEMES[(THEMES.findIndex((x) => x[0] === t) + 1) % THEMES.length][0]; + try { localStorage.setItem("anybot.theme", next); } catch (_) {} + applyTheme(); +}; +applyTheme(); +// To the newest message, when scrolled up. +$("b-down").innerHTML = ic("arrow-down", 18); +$("scroller").addEventListener("scroll", () => { const sc = $("scroller"); $("b-down").classList.toggle("on", sc.scrollHeight - sc.scrollTop - sc.clientHeight > 400); }); +$("b-down").onclick = () => { const sc = $("scroller"); sc.scrollTo({top: sc.scrollHeight, behavior: "smooth"}); }; function card(c) { if (!c) return ""; const rows = (c.rows || []).map((r) => `
${esc(r.label)}${esc(r.value)}
`).join(""); diff --git a/src/app/remote_ui.rs b/src/app/remote_ui.rs index 929e1c1..b70a1b2 100644 --- a/src/app/remote_ui.rs +++ b/src/app/remote_ui.rs @@ -220,7 +220,16 @@ impl App { (1u8, &l.status, l.paused).hash(&mut h); } self.approvals.get(&b.id).map(|a| a.call_id.clone()).hash(&mut h); + // The phone's home and bot sheet: its model, computer, routines and past chats. + (b.provider, &b.model, &b.default_computer, b.threads.len(), b.id == self.state.primary_bot).hash(&mut h); + for r in &b.routines { + (r.id, &r.title, r.paused, r.next_run).hash(&mut h); + } } + for (id, _, online) in self.rm.peers.lock().unwrap().iter() { + (id, online).hash(&mut h); + } + crate::cloudbox::run_state().label().hash(&mut h); for pg in &self.state.pages { (pg.id, pg.revision, pg.updated, &pg.title).hash(&mut h); } @@ -239,15 +248,38 @@ impl App { // Pinned first, then the most recently active. bots.sort_by_key(|b| (!b.pinned, -(b.last_turn.as_ref().map(|t| t.settled_at_ms).unwrap_or(0)))); let mut view = remote::View { host: self.state.remote.host_name.clone(), ..Default::default() }; + let peers = self.rm.peers.lock().unwrap().clone(); for b in bots { + // Its computers, each as the phone shows it: what it is and whether it is there now. + let targets = self.targets_for(b); + let roles = self.roles_for(b, &targets); + let computer = Self::chosen_computer(b, &targets, &roles).0; + let computers: Vec = targets.iter().map(|t| { + let (kind, online) = match &t.place { + tools::Place::Box { .. } => ("own", crate::cloudbox::available()), + tools::Place::Sealed { .. } => ("own", false), + tools::Place::Local { .. } => ("this", true), + tools::Place::Device { host, .. } => ("device", peers.iter().any(|(id, _, on)| id == host && *on)), + tools::Place::Ssh { .. } => ("ssh", true), + }; + json!({"name": t.name, "kind": kind, "online": online, "person": roles.person.as_deref() == Some(t.name.as_str())}) + }).collect(); let live = self.streams.get(&b.id); - let last = b.messages.iter().rev().find(|m| !m.text.trim().is_empty() && m.role != Role::Tool).map(|m| first_line(&m.text, 60)).unwrap_or_default(); + // The last thing said (not an error the model service sent, which then sat on the home). + let last = b.messages.iter().rev().find(|m| !m.text.trim().is_empty() && m.role != Role::Tool && !m.error).map(|m| first_line(&m.text, 60)).unwrap_or_default(); let c = crate::avatar::color(&b.color); let hex = format!("#{:02x}{:02x}{:02x}", c.r(), c.g(), c.b()); view.bots.push(json!({ "id": b.id, "name": b.name, "description": b.description, "color": hex, "shape": b.shape, "busy": live.is_some(), "status": live.and_then(|l| l.status.clone()).unwrap_or_default(), "unread": b.unread, "last": last, "members": !b.members.is_empty(), "pinned": b.pinned, + "primary": b.id == self.state.primary_bot, + "time": b.messages.iter().rev().find(|m| m.role != Role::Tool).map(|m| m.at).unwrap_or(0), + "via": format!("{} · {}", b.provider.label(), b.model), + "provider": serde_json::to_value(b.provider).unwrap_or_default(), "model": b.model, + "computer": computer, "computers": computers, + "routines": b.routines.iter().map(|r| json!({"id": r.id, "title": r.title, "paused": r.paused, "when": crate::appctl::routine_when(r)})).collect::>(), + "threads": b.threads.iter().enumerate().rev().take(20).map(|(i, (title, msgs))| json!({"index": i, "title": title, "count": msgs.len(), "at": msgs.last().map(|m| m.at).unwrap_or(0)})).collect::>(), })); let start = b.messages.len().saturating_sub(remote::PHONE_TAIL); view.messages.insert(b.id, b.messages[start..].iter().filter_map(|m| remote::message_json(m, &names, &self.state.pages)).collect()); @@ -256,6 +288,13 @@ impl App { } } view.pages = self.state.pages.clone(); + // The models a bot can be switched to from the phone: each provider that is ready. + view.models = crate::providers::ALL_PROVIDERS + .iter() + .filter(|&&k| !k.needs_key() || !crate::providers::resolve_key(k, &self.state.provider(k).api_key).is_empty()) + // Only models that chat: an image or video generator picked for a bot would break it. + .map(|&k| json!({"provider": serde_json::to_value(k).unwrap_or_default(), "label": k.label(), "models": self.models_for(k).into_iter().filter(|m| chats(m)).collect::>()})) + .collect(); remote::hub().publish(view); } @@ -310,6 +349,20 @@ impl App { self.start_reply(ctx, bot); } Cmd::Stop { bot } => self.stop(bot), + Cmd::SetBot { bot, provider, model, computer } => { + let kind = serde_json::from_value::(json!(provider)).ok(); + if let (Some(k), false) = (kind, model.is_empty()) { + if let Some(b) = self.state.bot_mut(bot) { + b.provider = k; + b.model = model; + } + self.mark(); + } + if !computer.is_empty() { + let _ = self.update_bot_computer(bot, &json!({"default_computer": computer}).to_string()); + self.mark(); + } + } Cmd::Regenerate { bot } => self.regenerate(ctx, bot), Cmd::Reply { bot } => { if !self.streams.contains_key(&bot) && self.state.bot(bot).and_then(|b| b.messages.last()).map(|m| m.role == Role::User).unwrap_or(false) { @@ -780,3 +833,18 @@ impl App { .collect() } } + +/// A model one can chat with (not an image, video, speech or embedding one). +fn chats(model: &str) -> bool { + let m = model.to_lowercase(); + !["imagine", "image", "video", "embed", "tts", "whisper", "audio", "moderation", "dall-e", "sora", "veo"].iter().any(|k| m.contains(k)) +} + +#[cfg(test)] +mod phone_tests { + #[test] + fn only_chat_models_are_offered() { + assert!(super::chats("grok-4.7") && super::chats("claude-opus-5-5") && super::chats("gpt-5-mini")); + assert!(!super::chats("grok-imagine-video-1.5") && !super::chats("text-embedding-3-large") && !super::chats("gpt-4o-mini-tts")); + } +} diff --git a/src/appctl.rs b/src/appctl.rs index 7c9f392..a1128d6 100644 --- a/src/appctl.rs +++ b/src/appctl.rs @@ -172,6 +172,18 @@ fn provider_of(key: &str) -> Option { ALL_PROVIDERS.iter().copied().find(|p| p.key() == key) } +/// When a routine runs next, in words (for the phone's list). +pub fn routine_when(r: &Routine) -> String { + if let Some(t) = &r.trigger { + return format!("事件觸發:{}", t.describe()); + } + match (r.paused, r.next_run) { + (true, _) => "已暫停".to_string(), + (false, Some(t)) => format!("下次 {}", routines::describe_when(t, &r.schedule.timezone)), + _ => "沒有下次".to_string(), + } +} + pub fn routine_line(r: &Routine) -> String { let next = match (r.paused, r.next_run) { (true, _) => "已暫停".to_string(), diff --git a/src/remote.rs b/src/remote.rs index 4a8aa30..803d5a3 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -121,6 +121,8 @@ pub enum Cmd { Answer { bot: u64, answer: String }, Read { bot: u64 }, NewThread { bot: u64 }, + /// The phone changes a bot's model or its computer (empty = unchanged). + SetBot { bot: u64, provider: String, model: String, computer: String }, /// Answer the last message again (重新產生) / reply to what is there (重新傳送). Regenerate { bot: u64 }, Reply { bot: u64 }, @@ -142,6 +144,8 @@ pub struct View { pub messages: HashMap>, /// 頁面, for the phone's page view. pub pages: Vec, + /// The models the phone can switch a bot to: each ready provider with its known models. + pub models: Vec, pub version: u64, } @@ -299,6 +303,7 @@ pub fn message_json(m: &crate::model::Message, names: &HashMap, pag "author": if m.author == 0 { String::new() } else { names.get(&m.author).cloned().unwrap_or_default() }, "reactions": m.reactions, "attachments": m.attachments.iter().map(|a| json!({"name": a.name, "image": a.image, "path": a.path})).collect::>(), + "at": m.at, "tools": calls, })) } @@ -350,12 +355,24 @@ pub fn handle(req: Req, via_relay: bool) -> Resp { ("GET", ["api", "state"]) => { let since: u64 = req.query.get("since").and_then(|v| v.parse().ok()).unwrap_or(0); let v = hub().wait_newer(since, if since == 0 { 0 } else { LONG_POLL_SECS }); + // 頁面 for the phone's home: the newest first, each with its bot. + let now = crate::model::now_ms(); + let mut pages: Vec<&crate::pages::Page> = v.pages.iter().collect(); + pages.sort_by_key(|p| std::cmp::Reverse(p.updated)); + let bot_name = |id: u64| v.bots.iter().find(|b| b["id"].as_u64() == Some(id)).and_then(|b| b["name"].as_str()).unwrap_or("").to_string(); + let pages: Vec = pages.into_iter().take(12).map(|p| { + let mut c = crate::pages::card_json(p, now); + c["bot"] = json!(bot_name(p.bot)); + c + }).collect(); Resp::json(&json!({ "version": v.version, "host": v.host, "bots": v.bots, "approvals": v.approvals, "box": crate::cloudbox::run_state().label(), "box_available": crate::cloudbox::available(), "mac": s.mac_from_phone && cfg!(target_os = "macos") && role != "member", "role": role, + "pages": pages, + "models": if role == "member" { vec![] } else { v.models.clone() }, })) } // 頁面: the whole page (or an older version with ?rev=N), and its charts drawn as SVG. @@ -411,6 +428,14 @@ pub fn handle(req: Req, via_relay: bool) -> Resp { "answer" => Cmd::Answer { bot, answer: b["answer"].as_str().unwrap_or("reject").to_string() }, "read" => Cmd::Read { bot }, "new" => Cmd::NewThread { bot }, + "regenerate" => Cmd::Regenerate { bot }, + "restore" => Cmd::RestoreThread { bot, index: b["index"].as_u64().unwrap_or(0) as usize }, + "set" => Cmd::SetBot { + bot, + provider: b["provider"].as_str().unwrap_or("").to_string(), + model: b["model"].as_str().unwrap_or("").trim().to_string(), + computer: b["computer"].as_str().unwrap_or("").trim().to_string(), + }, "upload" => return upload(bot, &req), _ => return Resp::error(404, "沒有這個動作"), };