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, "沒有這個動作"), };