加一台電腦
在那台電腦(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 ? `
${ic("monitor", 14)} 看畫面 ` : (c.kind === "this" && S.mac ? `
${ic("monitor", 14)} 看畫面 ` : "");
+ 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 ? `
${ic("copy", 15)} ${t === lastTurn && S.role !== "member" ? `${ic("refresh", 15)} ` : ""}
` : "";
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) => `
${ic(x[0], 18)} ${esc(x[1])} `).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) => `${esc(m)}${p.provider === b.provider && m === b.model ? `${ic("check", 16)} ` : ""} `).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) => ` ${esc(c.name)}${c.name === b.computer ? `${ic("check", 16)} ` : ""} `).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) => `${esc(t.title || "對話")}${fmtTime(t.at)} `).join("") + ``;
+ }
+ if (owner) h += `
${ic("refresh", 18)} 重新產生上一個回答 ${ic("plus", 18)} 開始新對話 `;
+ h += `
${ic("x", 18)} 關閉 `;
+ $("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, "沒有這個動作"),
};