AnyBot/src/app/remote_ui.rs

1466 lines
82 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 手機與帳號 in the window: what the phone sees (published when something changes), carrying out
// what the phone asks, and the settings page (phone password, account at the relay, backups,
// 裝置連結).
use super::*;
use crate::remote::{self, Cmd, Settings};
use serde_json::json;
use std::hash::{Hash, Hasher};
#[derive(Default)]
pub struct RemoteUi {
sig: u64,
last_publish: Option<Instant>,
/// The settings the server threads were last given.
pushed: Option<Settings>,
pw1: String,
pw2: String,
relay_url: String,
user: String,
pass: String,
invite: String,
create: bool,
pub(super) note: Arc<Mutex<String>>,
join_rx: Option<Receiver<Result<(Settings, remote::AccountFind), String>>>,
restore_rx: Option<Receiver<Result<Vec<u8>, String>>>,
busy: Arc<AtomicBool>,
peers: Arc<Mutex<Vec<(String, String, bool)>>>,
last_seen_mark: Option<Instant>,
peers_at: Option<Instant>,
/// 團隊: (user, role) shared with, loaded from the relay; the form to add one.
members: Arc<Mutex<Option<Result<Vec<(String, String)>, String>>>>,
new_member: String,
new_admin: bool,
started: bool,
}
/// Approvals as the phone shows them.
fn approval_json(bot: &Bot, a: &Approval) -> serde_json::Value {
let what = remote::tool_label(&a.tool);
let title = match a.kind {
ApprovalKind::EnableTools => format!("允許 {} 使用電腦工具嗎?", bot.name),
ApprovalKind::EnableThisComputer => format!("允許 {} 在你的電腦上執行任務嗎?", bot.name),
ApprovalKind::Action => format!("{} 想要{what}", bot.name),
ApprovalKind::Handoff => format!("{}:需要你", bot.name),
};
let handoff = a.kind == ApprovalKind::Handoff;
json!({"bot": bot.id, "title": title, "reason": a.reason, "tool": a.tool, "can_always": !handoff && (a.kind != ApprovalKind::Action || !a.grant.is_empty()), "kind": if handoff { "handoff" } else { "ask" }, "computer": if handoff { a.grant.as_str() } else { "" }})
}
impl App {
pub(super) fn remote_start(&mut self, ctx: &egui::Context) {
if self.demo || self.rm.started {
return;
}
self.rm.started = true;
self.ctx_handle = Some(ctx.clone());
let c = ctx.clone();
remote::hub().set_repaint(Arc::new(move || c.request_repaint()));
self.remote_apply_settings();
self.mirror_boot();
}
/// Hand changed settings to the server threads (and restart what depends on them).
fn remote_apply_settings(&mut self) {
let s = self.state.remote.clone();
if self.rm.pushed.as_ref() == Some(&s) {
return;
}
let old = self.rm.pushed.replace(s.clone());
remote::hub().set_settings(&s);
let lan_changed = old.as_ref().map(|o| o.lan) != Some(s.lan);
if lan_changed {
match remote::set_lan(s.lan) {
Ok(_) => {}
Err(e) => *self.rm.note.lock().unwrap() = e,
}
}
let relay_changed = old.as_ref().map(|o| (o.relay_url.clone(), o.host_token.clone())) != Some((s.relay_url.clone(), s.host_token.clone()));
if relay_changed {
remote::relay_restart(&s);
}
}
/// Every frame: run what the phone asked and refresh what it sees.
pub(super) fn remote_tick(&mut self, ctx: &egui::Context) {
if self.demo {
return;
}
self.remote_apply_settings();
for c in remote::hub().take_cmds() {
// A screen of another brain has no bots of its own to run phone requests on.
if self.mir.on && !matches!(c, Cmd::DeviceAdded(_) | Cmd::DeviceSeen(_)) {
continue;
}
self.remote_cmd(ctx, c);
}
self.brain_serve(ctx);
self.brain_watch();
if let Some(rx) = &self.rm.join_rx {
if let Ok(r) = rx.try_recv() {
self.rm.join_rx = None;
match r {
Ok((s, found)) => {
self.state.remote = s;
self.rm.pass.clear();
self.account_sync(found);
self.mark();
}
Err(e) => *self.rm.note.lock().unwrap() = e,
}
}
}
if let Some(rx) = &self.rm.restore_rx {
if let Ok(r) = rx.try_recv() {
self.rm.restore_rx = None;
match r.and_then(|bytes| serde_json::from_slice::<State>(&bytes).map_err(|e| format!("備份內容讀不出來:{e}"))) {
Ok(mut st) => {
// Keep this computer's own phone and account settings.
st.remote = self.state.remote.clone();
let n = st.bots.len();
self.state = st;
self.state.save();
*self.rm.note.lock().unwrap() = format!("已從備份還原 {n} 個 Bot");
}
Err(e) => *self.rm.note.lock().unwrap() = e,
}
}
}
// 自動備份: once a day while signed in.
let s = &self.state.remote;
if !self.mir.on && s.auto_backup && !s.backup_key.is_empty() && !s.relay_url.is_empty() && chrono::Utc::now().timestamp() - s.last_backup > 86_400 && !self.rm.busy.load(Ordering::SeqCst) {
self.remote_backup();
}
// 裝置連結: keep the list of this account's computers fresh (every 15 s: a computer that
// comes on is one the bots can use soon after).
if !self.state.remote.host_token.is_empty() && !self.rm.peers_at.map(|t| t.elapsed().as_secs() < 15).unwrap_or(false) {
self.rm.peers_at = Some(Instant::now());
let (peers, s) = (self.rm.peers.clone(), self.state.remote.clone());
std::thread::spawn(move || {
let v = remote::devices_online(&s);
if !v.is_empty() {
*peers.lock().unwrap() = v;
}
});
}
if !self.mir.on && !self.rm.last_publish.map(|t| t.elapsed().as_millis() < 120).unwrap_or(false) {
self.remote_publish();
}
}
/// 加一台電腦: one line to run on the new computer (a Linux box, a server); it joins as hands for
/// the brain, with no password and nothing to answer.
fn pair_section(&mut self, ui: &mut egui::Ui) {
let p = self.pal;
ui.add_space(6.0);
ui.separator();
ui.horizontal(|ui| {
ui.label(RichText::new(t("加一台電腦")).size(13.5).strong());
help_mark(ui, "讓另一台電腦(例如沒有螢幕的 Linux)也當大腦的手腳:在那台的終端機貼上這一行就好:還沒裝 AnyBot 會先自動裝好,不用帳號密碼、不用回答問題。它會設成開機自動執行,只讓這個大腦使用。", p);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.add(secondary_button("產生配對碼")).clicked() {
let (s, slot) = (self.state.remote.clone(), self.mir.pair.clone());
let for_host = if self.mir.on { s.brain_id.clone() } else { s.host_id.clone() };
*slot.lock().unwrap() = (t("正在產生…"), false, None);
std::thread::spawn(move || {
*slot.lock().unwrap() = match crate::remote::pair_code(&s, &for_host) {
Ok(code) => (crate::remote::pair_line(&s.relay_url, &code), true, Some(Instant::now())),
Err(e) => (e, false, None),
};
});
}
});
});
let (line, ok, at) = self.mir.pair.lock().unwrap().clone();
if line.is_empty() {
return;
}
let left = at.map(|t| 600u64.saturating_sub(t.elapsed().as_secs())).unwrap_or(0);
if ok && left == 0 {
hint(ui, "配對碼過期了,再按一次「產生配對碼」。", p);
return;
}
if !ok {
let c = if line.ends_with('…') { p.text2 } else { p.danger };
ui.label(RichText::new(line).size(12.5).color(c));
return;
}
Frame::new().fill(p.hover).corner_radius(8).inner_margin(Margin::symmetric(10, 8)).show(ui, |ui| {
ui.horizontal(|ui| {
ui.add(egui::Label::new(RichText::new(&line).monospace().size(13.0)).selectable(true).wrap());
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.add(secondary_button("複製")).clicked() {
ui.ctx().copy_text(line.clone());
self.notice = Some(t("已複製,貼到那台電腦的終端機執行"));
}
});
});
});
ui.label(RichText::new(format!("{}(還有 {} 分 {:02} 秒,只能用一次)", t("在那台電腦的終端機貼上執行"), left / 60, left % 60)).size(12.0).color(p.text3));
// Just made: bring it into view.
if at.map(|t| t.elapsed().as_millis() < 800).unwrap_or(false) {
ui.scroll_to_cursor(Some(Align::Max));
}
ui.ctx().request_repaint_after(std::time::Duration::from_secs(1));
}
fn remote_signature(&self) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
self.state.remote.host_name.hash(&mut h);
self.state.timezone.hash(&mut h);
self.scr.cfg_hash.hash(&mut h);
(&self.state.folders, self.state.loose_index).hash(&mut h);
for b in &self.state.bots {
(b.id, &b.name, &b.color, b.unread, b.messages.len()).hash(&mut h);
// (What the phone's home, editor and reactions show.)
(&b.description, &b.shape, b.pinned, &b.folder, b.members.len(), b.instructions.len(), b.memories.len(), b.tools_enabled).hash(&mut h);
b.messages.iter().rev().take(remote::PHONE_TAIL).map(|m| m.reactions.len()).sum::<usize>().hash(&mut h);
if let Some(m) = b.messages.last() {
(m.text.len(), m.thinking.len(), m.tool_calls.len(), m.error).hash(&mut h);
for c in &m.tool_calls {
(c.status as u8).hash(&mut h);
}
}
if let Some(l) = self.streams.get(&b.id) {
(1u8, &l.status, l.paused, l.steer.lock().unwrap().len()).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);
}
h.finish()
}
fn remote_publish(&mut self) {
let sig = self.remote_signature();
if sig == self.rm.sig {
return;
}
self.rm.sig = sig;
self.rm.last_publish = Some(Instant::now());
crate::routines::set_person_timezone(&self.state.timezone);
let names: HashMap<u64, String> = self.state.bots.iter().map(|b| (b.id, b.name.clone())).collect();
let mut bots: Vec<&Bot> = self.state.bots.iter().collect();
// 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(), sections: self.section_order(), ..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<serde_json::Value> = 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);
// 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::<Vec<_>>(),
"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::<Vec<_>>(),
// The sidebar's sections, the colour by name (the editor), what is queued while it works.
"folder": b.folder, "color_key": b.color,
"queued": live.map(|l| l.steer.lock().unwrap().clone()).unwrap_or_default(),
}));
let start = b.messages.len().saturating_sub(remote::PHONE_TAIL);
// Each message with its place in the chat (表情回應), its thinking and, on each step,
// what came back (the desktop's 顯示步驟 / 思考過程), in short.
let results: HashMap<&str, &str> = b.messages[start..].iter().filter(|m| m.role == Role::Tool).map(|m| (m.tool_call_id.as_str(), m.text.as_str())).collect();
let msgs: Vec<serde_json::Value> = b.messages[start..]
.iter()
.enumerate()
.filter_map(|(k, m)| {
let mut v = remote::message_json(m, &names, &self.state.pages)?;
v["index"] = json!(start + k);
if !m.thinking.trim().is_empty() {
v["thinking"] = json!(clip(m.thinking.trim(), 1500));
}
if let Some(tools) = v["tools"].as_array_mut() {
for (t, c) in tools.iter_mut().zip(m.tool_calls.iter()) {
if t.get("label").is_some() {
t["name"] = json!(c.name);
if let Some(r) = results.get(c.id.as_str()) {
t["result"] = json!(clip(r.trim(), 600));
}
}
}
}
Some(v)
})
.collect();
view.messages.insert(b.id, msgs);
if let Some(a) = self.approvals.get(&b.id) {
view.approvals.push(approval_json(b, a));
}
}
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::<Vec<_>>()}))
.collect();
remote::hub().publish(view);
}
fn remote_cmd(&mut self, ctx: &egui::Context, c: Cmd) {
match c {
Cmd::Send { bot, text, attachments, from } => {
if self.state.bot(bot).is_none() {
return;
}
// Typed on one of the account's computers: that one is the person's now (for
// 「我的電腦」 and the model on their network). The phone isn't a computer.
if !from.is_empty() {
remote::set_person_host(&from);
// A computer this brain hasn't heard of yet (it just came on): look the
// account's computers up now, or its 「我的電腦」 wouldn't be known for a minute.
if !self.rm.peers.lock().unwrap().iter().any(|(id, _, online)| *id == from && *online) {
let v = remote::devices_online(&self.state.remote);
if !v.is_empty() {
*self.rm.peers.lock().unwrap() = v;
}
self.rm.peers_at = Some(Instant::now());
}
self.typed_on = Some(from);
}
// Only files the app itself stored in its attachments folder may ride along;
// resolve first, so "../../" paths cannot escape it (the phone can name any path).
let root = crate::model::State::path().parent().map(|p| p.join("attachments")).and_then(|p| p.canonicalize().ok());
let atts: Vec<crate::attach::Attachment> = attachments
.iter()
.filter_map(|p| {
let f = std::path::Path::new(p).canonicalize().ok()?;
if !root.as_ref().map(|r| f.starts_with(r) && f.is_file()).unwrap_or(false) {
return None;
}
let meta = std::fs::metadata(&f).ok()?;
let file = f.file_name()?.to_string_lossy().to_string();
let name = file.split_once('-').map(|x| x.1.to_string()).unwrap_or(file);
// Also on the bot's own computer, where it works (a brain on a server
// has no other computer of its own to read it from).
let on_box = crate::cloudbox::copy_in(&f.display().to_string(), bot, &name).unwrap_or_default();
Some(crate::attach::Attachment { image: crate::attach::is_image_name(&name), name, path: f.display().to_string(), bytes: meta.len(), on_box })
})
.collect();
if let Some(l) = self.streams.get(&bot) {
let (extra, _) = crate::attach::for_model(&atts);
l.steer.lock().unwrap().push(format!("{text}{extra}"));
return;
}
if let Some(b) = self.state.bot_mut(bot) {
b.messages.push(Message { attachments: atts, ..Message::user(&text) });
}
self.start_reply(ctx, bot);
}
Cmd::Stop { bot } => self.stop(bot),
// The server's own zone (UTC) was never the person's choice: take theirs. A zone the
// person set on purpose stays.
Cmd::PersonTimezone(tz) => {
if crate::routines::is_server_default_zone(&self.state.timezone) && tz != self.state.timezone {
self.state.timezone = tz;
self.mark();
}
}
Cmd::SetBot { bot, provider, model, computer } => {
let kind = serde_json::from_value::<crate::providers::ProviderKind>(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) {
self.start_reply(ctx, bot);
}
}
Cmd::RestoreThread { bot, index } => {
self.stop(bot);
if let Some(b) = self.state.bot_mut(bot) {
if index < b.threads.len() {
let (_, msgs) = b.threads.remove(index);
if !b.messages.is_empty() {
let title = b.messages.iter().find(|m| m.role == Role::User).map(|m| first_line(&m.text, 24)).unwrap_or_else(|| "對話".into());
let cur = std::mem::take(&mut b.messages);
b.threads.insert(0, (title, cur));
}
b.messages = msgs;
}
}
self.mark();
}
Cmd::Answer { bot, answer } => {
let grant = self.approvals.get(&bot).map(|a| a.grant.clone()).unwrap_or_default();
let a = match answer.as_str() {
"once" => ApprovalAnswer::Once,
"always" => ApprovalAnswer::Always(grant),
_ => ApprovalAnswer::Reject,
};
self.answer(ctx, bot, a);
}
Cmd::Read { bot } => {
if let Some(b) = self.state.bot_mut(bot) {
if b.unread {
b.unread = false;
self.mark();
}
}
}
Cmd::Clear { bot } => {
self.stop(bot);
if let Some(b) = self.state.bot_mut(bot) {
b.messages.clear();
}
self.mark();
}
Cmd::NewThread { bot } => {
self.stop(bot);
if let Some(b) = self.state.bot_mut(bot) {
if !b.messages.is_empty() {
let title = b.messages.iter().find(|m| m.role == Role::User).map(|m| first_line(&m.text, 24)).unwrap_or_else(|| "對話".into());
let msgs = std::mem::take(&mut b.messages);
b.threads.insert(0, (title, msgs));
b.threads.truncate(50);
}
}
self.mark();
}
Cmd::DeviceAdded(d) => {
self.state.remote.devices.push(d);
self.mark();
}
Cmd::DeviceSeen(h) => {
if let Some(gone) = h.strip_prefix('-') {
self.state.remote.devices.retain(|d| d.token_hash != gone);
self.mark();
} else if !self.rm.last_seen_mark.map(|t| t.elapsed().as_secs() < 60).unwrap_or(false) {
self.rm.last_seen_mark = Some(Instant::now());
if let Some(d) = self.state.remote.devices.iter_mut().find(|d| d.token_hash == h) {
d.last_seen = chrono::Utc::now().timestamp();
self.mark();
}
}
}
}
}
/// 登入後同步: the account's bots and this computer's become one set.
/// • Another AnyBot of the account is the brain → show it, and copy this computer's bots
/// there (once it answers; bots it already has are skipped).
/// • This one is the only one → take in the backup's bots it doesn't have, then back up
/// the whole set, so the account has this computer's bots too.
fn account_sync(&mut self, found: remote::AccountFind) {
if let Some((id, name)) = found.brain {
*self.rm.note.lock().unwrap() = format!("已登入,改用「{name}」當大腦:那邊的 Bot 會同步過來,這台的也會搬過去");
self.mir.want = Some((id, name));
self.mir.move_after_join = true;
// The brain's bots may use this computer (each action still asks).
self.state.remote.device_link = true;
return;
}
let got = found.backup.and_then(|b| serde_json::from_slice::<State>(&b).ok());
let n = got.map(|st| self.state.take_in_bots(&st)).unwrap_or(0);
*self.rm.note.lock().unwrap() = if n > 0 { format!("已登入,帳號裡的 {n} 個 Bot 已同步到這台") } else { "已登入,這台 AnyBot 已加入帳號".into() };
if !self.state.bots.is_empty() && self.state.remote.auto_backup {
self.remote_backup();
}
}
fn remote_backup(&mut self) {
if self.rm.busy.swap(true, Ordering::SeqCst) {
return;
}
self.state.remote.last_backup = chrono::Utc::now().timestamp();
self.mark();
let s = self.state.remote.clone();
let mut st = self.state.clone();
// The backup is for the bots; this computer's own keys to the relay and phones stay here.
st.remote = Default::default();
let bytes = serde_json::to_vec(&st).unwrap_or_default();
let (note, busy) = (self.rm.note.clone(), self.rm.busy.clone());
std::thread::spawn(move || {
*note.lock().unwrap() = match remote::backup_now(&s, &bytes) {
Ok(n) => format!("已備份({},已加密)", crate::cloudbox::human(n as u64)),
Err(e) => e,
};
busy.store(false, Ordering::SeqCst);
});
}
pub(super) fn remote_page(&mut self, ui: &mut egui::Ui, _ctx: &egui::Context) {
let p = self.pal;
let mut changed = false;
setting_card(ui, p, |ui| {
setting_title(ui, "手機與帳號", "在手機上和 Bot 聊天、核准它要做的事、看著它的電腦。", p);
hint(ui, "兩種連法:同一個網路(或 Tailscale)直接連這台電腦;或登入帳號,透過你的帳號伺服器從任何地方連,這台電腦不用開放任何連接埠。", p);
});
// ── 帳號(薄後端) ──
setting_card(ui, p, |ui| {
setting_title(ui, "AnyBot 帳號", "帳號伺服器保管帳號、裝置清單和加密備份,手機從外面連進來也靠它轉送。", p);
let s = &self.state.remote;
if !s.host_token.is_empty() {
let status = remote::hub().relay_status();
ui.horizontal(|ui| {
let ok = status == "已連線";
let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), egui::Sense::hover());
ui.painter().circle_filled(r.center(), 4.0, if ok { Color32::from_rgb(0x14, 0xc3, 0x70) } else { p.text3 });
ui.label(RichText::new(format!("{} · {}", s.relay_user, s.relay_url)).size(FONT_BASE));
ui.label(RichText::new(if status.is_empty() { "—".to_string() } else { status }).size(12.0).color(p.text3));
});
hint(ui, format!("手機打開 {} 用同一個帳號登入就能用。可以「加入主畫面」當成 App。", s.relay_url), p);
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label(RichText::new(t("這台電腦在手機上叫")).size(13.0).color(p.text2));
if ui.add(egui::TextEdit::singleline(&mut self.state.remote.host_name).desired_width(180.0)).lost_focus() {
changed = true;
}
});
if ui.checkbox(&mut self.state.remote.auto_backup, t("每天自動備份到帳號(加密,金鑰由你的密碼產生,伺服器打不開)")).changed() {
changed = true;
}
ui.horizontal(|ui| {
let busy = self.rm.busy.load(Ordering::SeqCst);
if ui.add_enabled(!busy, secondary_button(if busy { "備份中…" } else { "立刻備份" })).clicked() {
self.remote_backup();
}
if self.state.remote.last_backup > 0 {
let t = chrono::DateTime::from_timestamp(self.state.remote.last_backup, 0).map(|d| d.with_timezone(&chrono::Local).format("%m/%d %H:%M").to_string()).unwrap_or_default();
ui.label(RichText::new(format!("上次備份 {t}")).size(12.0).color(p.text3));
}
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.add(egui::Button::new(RichText::new(t("登出帳號")).size(12.5).color(p.danger)).frame(false)).clicked() {
let keep_name = self.state.remote.host_name.clone();
// Say goodbye to the account server: the machine's registration is
// removed, so its key dies with the sign-out.
let (url, token) = (self.state.remote.relay_url.clone(), self.state.remote.host_token.clone());
if !url.is_empty() && !token.is_empty() {
std::thread::spawn(move || {
let _ = remote::relay_post(&format!("{url}/relay/host/remove"), &token, &serde_json::json!({}));
});
}
let s = &mut self.state.remote;
s.host_id.clear();
s.host_token.clear();
s.backup_key.clear();
s.host_name = keep_name;
changed = true;
}
});
});
} else {
let busy = self.rm.join_rx.is_some();
form_row(ui, "帳號伺服器", p, |ui| {
ui.add(egui::TextEdit::singleline(&mut self.rm.relay_url).hint_text(t("https://anybot.你的網域")).desired_width(f32::INFINITY));
});
form_row(ui, "帳號", p, |ui| {
ui.add(egui::TextEdit::singleline(&mut self.rm.user).desired_width(f32::INFINITY));
});
form_row(ui, "密碼", p, |ui| {
ui.add(egui::TextEdit::singleline(&mut self.rm.pass).password(true).desired_width(f32::INFINITY));
});
ui.checkbox(&mut self.rm.create, t("建立新帳號"));
if self.rm.create {
form_row(ui, "邀請碼", p, |ui| {
ui.add(egui::TextEdit::singleline(&mut self.rm.invite).hint_text(t("伺服器設定了才需要")).desired_width(f32::INFINITY));
});
}
ui.horizontal(|ui| {
let ready = !self.rm.relay_url.trim().is_empty() && !self.rm.user.trim().is_empty() && self.rm.pass.len() >= 8;
if ui.add_enabled(ready && !busy, primary_button(if busy { "連線中…" } else if self.rm.create { "建立並登入" } else { "登入" }, p)).clicked() {
let (tx, rx) = channel();
self.rm.join_rx = Some(rx);
let (url, user, pass, create, invite, base) = (self.rm.relay_url.clone(), self.rm.user.trim().to_string(), self.rm.pass.clone(), self.rm.create, self.rm.invite.clone(), self.state.remote.clone());
std::thread::spawn(move || {
// Signed in: look right away for the bots the account already has.
let r = remote::relay_join(&base, &url, &user, &pass, create, &invite).map(|s| {
let found = remote::find_account_bots(&s, &pass);
(s, found)
});
let _ = tx.send(r);
});
}
if ui.add_enabled(ready && self.rm.restore_rx.is_none(), secondary_button("從帳號備份還原")).on_hover_text(t("把這台電腦的 Bot 換成帳號裡最新的備份")).clicked() {
let (tx, rx) = channel();
self.rm.restore_rx = Some(rx);
let (url, user, pass) = (self.rm.relay_url.clone(), self.rm.user.trim().to_string(), self.rm.pass.clone());
std::thread::spawn(move || {
let _ = tx.send(remote::restore(&url, &user, &pass));
});
}
});
hint(ui, "密碼至少 8 個字。帳號伺服器在 relay/ 資料夾,可以放在任何一台小主機(說明見 relay/README.md)。", p);
}
let note = self.rm.note.lock().unwrap().clone();
if !note.is_empty() {
ui.label(RichText::new(note).size(12.5).color(p.text2));
}
});
// ── 大腦 ──
if !self.state.remote.host_token.is_empty() {
setting_card(ui, p, |ui| {
ui.horizontal(|ui| {
ui.label(RichText::new(t("大腦")).size(FONT_BASE).strong());
help_mark(ui, "帳號裡選一台 AnyBot 當大腦(通常是跟帳號伺服器放在一起、一直開著的那台)。所有 Bot、聊天、記憶和例行任務都放在大腦上,由它思考和排程。其他電腦打開 AnyBot 看到的都是同一份,同時也是大腦的手腳:大腦的 Bot 可以透過裝置連結操作它們。大腦連不到時,這裡還能看之前的對話,但不能傳訊息,連回來就恢復。", p);
});
let s = &self.state.remote;
let status = self.mir.status.lock().unwrap().clone();
if self.mir.on {
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), egui::Sense::hover());
ui.painter().circle_filled(r.center(), 4.0, if self.mir.online { Color32::from_rgb(0x14, 0xc3, 0x70) } else { p.text3 });
ui.label(RichText::new(status).size(13.0).color(p.text2));
});
ui.horizontal(|ui| {
let busy = self.mir.moving.load(Ordering::SeqCst);
if ui.add_enabled(self.mir.online && !busy, secondary_button("把這台的 Bot 搬過去")).on_hover_text(t("把這台電腦原本的 Bot(連同聊天)、模型金鑰、技能和連接器複製到大腦。這台的原始資料不會刪掉。")).clicked() {
self.mirror_move_local();
}
if ui.add(secondary_button("改回用這台自己的 Bot")).clicked() {
self.mir.leave = true;
}
});
let note = self.mir.move_note.lock().unwrap().clone();
if !note.is_empty() {
ui.label(RichText::new(note).size(12.5).color(p.text2));
}
} else {
ui.label(RichText::new(t("這台就是大腦:Bot 在這台電腦上思考和工作。")).size(13.0).color(p.text2));
let me = s.host_id.clone();
let peers: Vec<(String, String, bool)> = self.rm.peers.lock().unwrap().iter().filter(|(id, _, _)| !id.is_empty() && *id != me && !remote::is_hands(id)).cloned().collect();
if peers.is_empty() {
hint(ui, "帳號裡還沒有別台可以當大腦的 AnyBot。", p);
}
for (id, name, online) in peers {
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), egui::Sense::hover());
ui.painter().circle_filled(r.center(), 4.0, if online { Color32::from_rgb(0x14, 0xc3, 0x70) } else { p.text3 });
ui.label(RichText::new(&name).size(13.5));
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.add_enabled(online, secondary_button("改用它當大腦")).clicked() {
self.mir.want = Some((id.clone(), name.clone()));
// The brain's bots may use this computer (each action still asks).
self.state.remote.device_link = true;
changed = true;
}
});
});
}
}
self.pair_section(ui);
});
}
// ── 同一個網路 ──
setting_card(ui, p, |ui| {
setting_title(ui, "同一個網路直接連", "手機和這台電腦在同一個 Wi-Fi,或都裝了 Tailscale 時使用。", p);
let has_pw = !self.state.remote.password.is_empty();
if ui.add_enabled(has_pw, egui::Checkbox::new(&mut self.state.remote.lan, t("讓手機直接連這台電腦"))).changed() {
changed = true;
}
if self.state.remote.lan {
if let Some(port) = remote::lan_port() {
for a in remote::addresses(port) {
ui.horizontal(|ui| {
ui.label(RichText::new(&a).monospace().size(13.0));
if ui.small_button(t("複製")).clicked() {
ui.ctx().copy_text(a.clone());
}
});
}
}
}
ui.add_space(4.0);
form_row(ui, if has_pw { "更改手機密碼" } else { "設定手機密碼" }, p, |ui| {
ui.add(egui::TextEdit::singleline(&mut self.rm.pw1).password(true).hint_text(t("至少 8 個字")).desired_width(f32::INFINITY));
});
form_row(ui, "再輸入一次", p, |ui| {
ui.add(egui::TextEdit::singleline(&mut self.rm.pw2).password(true).desired_width(f32::INFINITY));
});
let ok = self.rm.pw1.chars().count() >= 8 && self.rm.pw1 == self.rm.pw2;
if ui.add_enabled(ok, secondary_button("儲存密碼")).clicked() {
self.state.remote.password = crate::httpd::hash_password(&self.rm.pw1);
// A new password signs every phone out.
self.state.remote.devices.clear();
self.rm.pw1.clear();
self.rm.pw2.clear();
changed = true;
}
if !self.rm.pw2.is_empty() && self.rm.pw1 != self.rm.pw2 {
hint(ui, "兩次輸入的密碼不一樣", p);
}
hint(ui, "不要把這個連接埠開放到網際網路上;從外面連請用上面的帳號,或 Tailscale。", p);
});
// ── 允許手機做什麼 ──
setting_card(ui, p, |ui| {
setting_title(ui, "手機可以做什麼", "聊天、核准、看 Bot 的電腦一律可以。下面這些會碰到這台電腦本身,預設關閉。", p);
if ui.checkbox(&mut self.state.remote.mac_from_phone, t("從手機看這台電腦的螢幕並操作(點、打字、按鍵)")).changed() {
changed = true;
}
if ui.checkbox(&mut self.state.remote.device_link, t("裝置連結:讓帳號裡其他的 AnyBot(例如放在伺服器上的)操作這台電腦")).changed() {
changed = true;
}
hint(ui, "裝置連結開著時,伺服器上的 Bot 可以把「你的電腦」的螢幕、檔案、指令和 Mac App 工具交給這台電腦執行。每個動作仍照那邊的權限規則詢問你(手機上也能核准)。", p);
});
// ── 已登入的裝置 ──
setting_card(ui, p, |ui| {
setting_title(ui, "直接連線的裝置", "用手機密碼登入過的手機和瀏覽器", p);
if self.state.remote.devices.is_empty() {
hint(ui, "還沒有", p);
}
let mut remove = None;
for (i, d) in self.state.remote.devices.iter().enumerate() {
ui.horizontal(|ui| {
ui.label(RichText::new(&d.name).size(FONT_BASE));
let seen = chrono::DateTime::from_timestamp(d.last_seen.max(d.created), 0).map(|t| t.with_timezone(&chrono::Local).format("最近 %m/%d %H:%M").to_string()).unwrap_or_default();
ui.label(RichText::new(seen).size(12.0).color(p.text3));
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.add(egui::Button::new(RichText::new(t("登出")).size(12.0).color(p.danger)).frame(false)).clicked() {
remove = Some(i);
}
});
});
}
if let Some(i) = remove {
self.state.remote.devices.remove(i);
changed = true;
}
});
// ── 團隊 ──
if !self.state.remote.host_token.is_empty() {
setting_card(ui, p, |ui| {
setting_title(ui, "分享給團隊", "讓帳號伺服器上的其他帳號也能用這台 AnyBot 的 Bot。", p);
hint(ui, "成員:可以和 Bot 聊天、附加檔案;Bot 會知道是誰在說話。管理員:另外可以核准 Bot 要做的事、停止、看和操作電腦畫面。需要你允許的事只通知你和管理員。", p);
let loaded = self.rm.members.lock().unwrap().clone();
if loaded.is_none() {
*self.rm.members.lock().unwrap() = Some(Ok(vec![]));
let (m, s) = (self.rm.members.clone(), self.state.remote.clone());
std::thread::spawn(move || *m.lock().unwrap() = Some(remote::members(&s)));
}
let mut change: Option<(String, &str)> = None;
match loaded {
Some(Ok(list)) => {
for (user, role) in list {
ui.horizontal(|ui| {
ui.label(RichText::new(&user).size(FONT_BASE));
ui.label(RichText::new(if role == "admin" { "管理員" } else { "成員" }).size(12.0).color(p.text3));
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.add(egui::Button::new(RichText::new(t("移除")).size(12.0).color(p.danger)).frame(false)).clicked() {
change = Some((user.clone(), "remove"));
}
});
});
}
}
Some(Err(e)) => hint(ui, format!("讀不到分享清單:{e}"), p),
None => {}
}
ui.horizontal(|ui| {
ui.add(egui::TextEdit::singleline(&mut self.rm.new_member).hint_text(t("對方的帳號")).desired_width(160.0));
ui.checkbox(&mut self.rm.new_admin, t("管理員"));
if ui.add_enabled(!self.rm.new_member.trim().is_empty(), secondary_button("分享")).clicked() {
change = Some((self.rm.new_member.trim().to_string(), if self.rm.new_admin { "admin" } else { "member" }));
self.rm.new_member.clear();
}
});
// Talk to the relay off the window's thread, then show the new list.
if let Some((user, role)) = change {
let (m, note, s, role) = (self.rm.members.clone(), self.rm.note.clone(), self.state.remote.clone(), role.to_string());
std::thread::spawn(move || {
let r = remote::set_member(&s, &user, &role);
*note.lock().unwrap() = match r {
Ok(()) if role == "remove" => format!("已停止分享給 {user}"),
Ok(()) => format!("已分享給 {user}"),
Err(e) => e,
};
*m.lock().unwrap() = Some(remote::members(&s));
});
}
});
}
// ── 帳號裡的其他電腦 ──
if !self.state.remote.host_token.is_empty() {
setting_card(ui, p, |ui| {
setting_title(ui, "帳號裡的電腦", "登入同一個帳號的 AnyBot。開了裝置連結的電腦,Bot 可以操作它。", p);
if ui.small_button(t("重新整理")).clicked() || (self.rm.peers.lock().unwrap().is_empty() && !self.rm.busy.load(Ordering::SeqCst)) {
let (peers, s) = (self.rm.peers.clone(), self.state.remote.clone());
let first = peers.lock().unwrap().is_empty();
if first {
peers.lock().unwrap().push((String::new(), "讀取中…".into(), false));
}
std::thread::spawn(move || {
let v = remote::devices_online(&s);
*peers.lock().unwrap() = if v.is_empty() { vec![(String::new(), "(沒有)".into(), false)] } else { v };
});
}
for (id, name, online) in self.rm.peers.lock().unwrap().iter() {
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), egui::Sense::hover());
if !id.is_empty() {
ui.painter().circle_filled(r.center(), 4.0, if *online { Color32::from_rgb(0x14, 0xc3, 0x70) } else { p.text3 });
}
let me = *id == self.state.remote.host_id;
ui.label(RichText::new(format!("{name}{}", if me { "(這台)" } else { "" })).size(13.5));
});
}
});
}
if changed {
self.mark();
}
}
/// Other AnyBots of this account that let bots operate them (裝置連結), as computers.
pub(super) fn linked_devices(&self) -> Vec<tools::Target> {
if self.state.remote.host_token.is_empty() {
return vec![];
}
let has_hands = !remote::my_hands(&self.state.remote.host_id).is_empty();
self.rm
.peers
.lock()
.unwrap()
.iter()
.filter(|(id, _, online)| !id.is_empty() && *online && *id != self.state.remote.host_id)
// A brain with hands of its own works on those, not on the account's other brains.
.filter(|(id, _, _)| !has_hands || remote::is_hands(id))
// A computer showing another brain lends its hands to that one, not to this.
.filter(|(id, _, _)| {
let uses = remote::peer_uses(id);
uses.is_empty() || uses == self.state.remote.host_id
})
.map(|(id, name, _)| tools::Target { name: name.clone(), place: tools::Place::Device { host: id.clone(), dir: String::new() } })
.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"));
}
}
// ---------- 手機: everything the desktop does, from the phone ----------
// Each action calls what the desktop's own button calls (appctl for the actions a bot can take
// too, set_pinned, restart_bot, toggle_reaction, enable_direct_mode, box_task…), so the phone
// adds no ways of its own. Members never get here (remote::role_allows); some things are the
// owner's alone: deleting a bot, permissions and rules, channels, connectors and the box's
// runtime, update and schedule. Keys, tokens and the vault's secrets are never sent.
/// Docker facts for the phone's 電腦 settings, refreshed in the background (asking Docker on the
/// app's thread would stall the window): (when, update available, gVisor here, runs on).
static BOX_FACTS: std::sync::Mutex<Option<(Instant, serde_json::Value)>> = std::sync::Mutex::new(None);
static BOX_FACTS_BUSY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
fn box_facts() -> serde_json::Value {
let (stale, v) = {
let g = BOX_FACTS.lock().unwrap_or_else(|e| e.into_inner());
(g.as_ref().map(|(t, _)| t.elapsed().as_secs() > 20).unwrap_or(true), g.as_ref().map(|(_, v)| v.clone()).unwrap_or_else(|| json!({})))
};
// (Unit tests don't ask Docker.)
if stale && !cfg!(test) && !BOX_FACTS_BUSY.swap(true, std::sync::atomic::Ordering::SeqCst) {
std::thread::spawn(|| {
let (n, exec_ms, total_ms) = crate::cloudbox::step_stats();
let v = json!({
"update": crate::cloudbox::update_available(),
"gvisor": crate::cloudbox::host_has_gvisor(),
"running": crate::cloudbox::running_runtime(),
"steps": {"n": n, "exec_ms": exec_ms, "total_ms": total_ms},
});
*BOX_FACTS.lock().unwrap_or_else(|e| e.into_inner()) = Some((Instant::now(), v));
BOX_FACTS_BUSY.store(false, std::sync::atomic::Ordering::SeqCst);
});
}
v
}
/// At most `n` characters (the phone shows excerpts).
fn clip(s: &str, n: usize) -> String {
if s.chars().count() <= n {
s.to_string()
} else {
format!("{}…", s.chars().take(n).collect::<String>())
}
}
impl App {
pub(super) fn phone_act(&mut self, ctx: &egui::Context, action: &str, b: &serde_json::Value, role: &str) -> serde_json::Value {
let owner = role == "owner";
let bot = b["bot"].as_u64().unwrap_or(0);
let s = |k: &str| b[k].as_str().unwrap_or("").trim().to_string();
let fail = |e: &str| json!({"error": e});
let done = |t: &str| json!({"ok": true, "text": t});
let owner_only = || json!({"error": "這件事只有這台 AnyBot 的擁有者能做"});
// 團隊: an admin keeps what the phone always let them do (approve, stop, the screens, the
// model and computer, a new chat, regenerate) plus looking and small chat things; making,
// changing or deleting bots, routines, memories, pages and settings stays the owner's.
let admin_ok = match action {
"detail" | "settings" | "pages" | "restart_bot" | "react" | "reply" => true,
"bot" => b["key"].as_str() == Some("unread"),
"box" => matches!(b["op"].as_str(), Some("wake" | "disk")),
_ => false,
};
if !owner && !admin_ok {
return owner_only();
}
match action {
"detail" => match self.state.bot(bot) {
Some(x) => self.phone_detail(x),
None => fail("找不到這個 Bot"),
},
"settings" => self.phone_settings(owner),
// Every page, newest first (the state the phone polls carries only the latest few).
"pages" => {
let now = crate::model::now_ms();
let mut list: Vec<&crate::pages::Page> = self.state.pages.iter().collect();
list.sort_by_key(|p| std::cmp::Reverse(p.updated));
let pages: Vec<serde_json::Value> = list
.into_iter()
.map(|p| {
let mut c = crate::pages::card_json(p, now);
c["bot"] = json!(self.state.bot(p.bot).map(|x| x.name.clone()).unwrap_or_default());
c
})
.collect();
json!({"ok": true, "pages": pages})
}
// The actions a bot can take on the app (the same appctl path as its tools).
"app" => {
const ALLOWED: [&str; 15] = [
"create_bot", "update_bot", "remember", "forget", "save_skill", "delete_skill", "create_routine", "update_routine",
"delete_routine", "run_routine_now", "create_group", "move_to_group", "rename_group", "delete_group", "update_settings",
];
let name = s("name");
if !ALLOWED.contains(&name.as_str()) {
return fail("手機不能做這件事");
}
let args = match &b["args"] {
serde_json::Value::String(t) => t.clone(),
v if v.is_object() => v.to_string(),
_ => "{}".to_string(),
};
// (The bot it is about; the app-wide ones act as the main bot, as from its chat.)
let id = [bot, self.state.primary_bot].into_iter().find(|i| self.state.bot(*i).is_some()).or_else(|| self.state.bots.first().map(|x| x.id)).unwrap_or(0);
let next = self.state.next_id;
let out = if name == "update_bot" && serde_json::from_str::<serde_json::Value>(&args).map(|v| v["default_computer"].is_string()).unwrap_or(false) {
self.update_bot_computer(id, &args)
} else {
appctl::apply(&mut self.state, id, &name, &args)
};
if out.theme_changed {
self.apply_theme(ctx);
}
for (target, msg) in out.wake {
self.wake(ctx, target, msg);
}
self.dirty = true;
if !out.ok {
return fail(&out.text);
}
let made = (self.state.next_id > next).then_some(next);
json!({"ok": true, "text": out.text, "bot": made})
}
// A bot's switches from its menu and editor.
"bot" => {
if self.state.bot(bot).is_none() {
return fail("找不到這個 Bot");
}
let v = &b["value"];
let key = s("key");
if matches!(key.as_str(), "tools" | "allow_remove") && !owner {
return owner_only();
}
match key.as_str() {
"pinned" => self.set_pinned(bot, v.as_bool().unwrap_or(false), None),
"primary" => self.state.primary_bot = bot,
"members" => {
let ids: Vec<u64> = v.as_array().map(|a| a.iter().filter_map(|x| x.as_u64()).collect()).unwrap_or_default();
let ok: Vec<u64> = ids.into_iter().filter(|i| *i != bot && self.state.bot(*i).map(|x| x.members.is_empty()).unwrap_or(false)).collect();
if ok.is_empty() {
return fail("群組至少要有一個成員");
}
if let Some(x) = self.state.bot_mut(bot) {
x.members = ok;
}
}
k => {
let Some(x) = self.state.bot_mut(bot) else { return fail("找不到這個 Bot") };
match k {
"unread" => x.unread = v.as_bool().unwrap_or(true),
"tools" => x.tools_enabled = v.as_bool().unwrap_or(true),
"shape" if v.as_str().map(|t| crate::avatar::SHAPES.contains(&t)).unwrap_or(false) => x.shape = v.as_str().unwrap_or("").to_string(),
"color" if v.as_str().map(|t| crate::avatar::COLORS.iter().any(|c| c.0 == t)).unwrap_or(false) => x.color = v.as_str().unwrap_or("").to_string(),
"context" => x.context_tokens = v.as_u64().unwrap_or(x.context_tokens).clamp(4_000, 2_000_000),
"allow_remove" => x.allow_rules.retain(|r| Some(r.as_str()) != v.as_str()),
_ => return fail("不認得這個設定"),
}
}
}
self.dirty = true;
done("好了")
}
"group_chat" => {
// 群組聊天, as the desktop's new-bot menu makes it.
let ids: Vec<u64> = b["members"].as_array().map(|a| a.iter().filter_map(|x| x.as_u64()).collect()).unwrap_or_default();
let members: Vec<u64> = ids.into_iter().filter(|i| self.state.bot(*i).map(|x| x.members.is_empty()).unwrap_or(false)).collect();
if members.is_empty() {
return fail("選至少一個 Bot 當成員");
}
let id = self.state.next_id;
let name = Some(s("name")).filter(|n| !n.is_empty()).unwrap_or_else(|| "群組聊天".into());
let sel = self.state.selected;
let out = appctl::apply(&mut self.state, sel, "create_bot", &json!({"name": name, "description": "成員依序回答"}).to_string());
if !out.ok {
return fail(&out.text);
}
if let Some(x) = self.state.bot_mut(id) {
x.members = members;
x.shape = "cloud".into();
}
self.dirty = true;
json!({"ok": true, "text": out.text, "bot": id})
}
"delete_bot" => {
if !owner {
return owner_only();
}
if self.state.bot(bot).is_none() {
return fail("找不到這個 Bot");
}
// As the desktop's 刪除 Bot dialog.
self.stop(bot);
self.state.bots.retain(|x| x.id != bot);
self.state.fix_primary();
self.dirty = true;
done("已刪除")
}
"restart_bot" => {
if self.state.bot(bot).is_none() {
return fail("找不到這個 Bot");
}
self.restart_bot(bot);
done("已重新啟動")
}
"react" => {
let idx = b["index"].as_u64().unwrap_or(u64::MAX) as usize;
let emoji = s("emoji");
if emoji.is_empty() || emoji.chars().count() > 8 || self.state.bot(bot).map(|x| idx >= x.messages.len()).unwrap_or(true) {
return fail("找不到這則訊息");
}
self.toggle_reaction(bot, idx, &emoji);
done("好了")
}
"reply" => {
self.remote_cmd(ctx, Cmd::Reply { bot });
done("重新傳送中…")
}
"clear" => {
self.remote_cmd(ctx, Cmd::Clear { bot });
done("已清除聊天記錄")
}
"delete_page" => {
let id = b["id"].as_u64().unwrap_or(0);
let before = self.state.pages.len();
self.state.pages.retain(|x| x.id != id);
if self.state.pages.len() == before {
return fail("這個頁面已經刪除了");
}
self.mark();
done("已刪除頁面")
}
// 設定 › 一般 / 權限 (the owner's).
"setting" => {
if !owner {
return owner_only();
}
let v = &b["value"];
match s("key").as_str() {
"direct_mode" => {
if v.as_bool().unwrap_or(false) {
self.enable_direct_mode()
} else {
self.disable_direct_mode()
}
}
"auto_review" => self.state.auto_review = v.as_bool().unwrap_or(false),
"notify" => self.state.notify = v.as_bool().unwrap_or(true),
"rule_add" | "rule_remove" => {
let text = v.as_str().unwrap_or("").trim().to_string();
let list = if s("list") == "ask" { &mut self.state.review_ask_rules } else { &mut self.state.review_allow_rules };
if s("key") == "rule_add" {
if text.is_empty() {
return fail("規則是空的");
}
if !list.contains(&text) {
list.push(text);
}
} else {
list.retain(|r| *r != text);
}
}
_ => return fail("不認得這個設定"),
}
self.dirty = true;
done("好了")
}
// 設定 › 模型: a provider's key (written, never read back) and new bots' model.
"provider" => {
if !owner {
return owner_only();
}
let Some(kind) = crate::providers::ALL_PROVIDERS.iter().copied().find(|k| serde_json::to_value(k).ok().as_ref() == Some(&b["provider"])) else { return fail("不認得這個模型服務") };
let ps = self.state.providers.entry(kind.key().to_string()).or_default();
if let Some(k) = b["key"].as_str() {
ps.api_key = k.trim().to_string();
}
if let Some(m) = b["default_model"].as_str() {
ps.default_model = m.trim().to_string();
}
self.dirty = true;
done("好了")
}
// 手機與帳號: sign a direct-connection phone out (as the desktop's list does).
"device_remove" => {
if !owner {
return owner_only();
}
let (name, created) = (s("name"), b["created"].as_i64().unwrap_or(0));
let before = self.state.remote.devices.len();
self.state.remote.devices.retain(|d| !(d.name == name && d.created == created));
if self.state.remote.devices.len() == before {
return fail("找不到這支手機");
}
self.dirty = true;
done("已登出那支手機")
}
"channel" => {
if !owner {
return owner_only();
}
let id = b["id"].as_u64().unwrap_or(0);
if !self.state.channels.iter().any(|c| c.id == id) {
return fail("找不到這個管道");
}
if b["remove"].as_bool().unwrap_or(false) {
self.state.channels.retain(|c| c.id != id);
} else if let Some(c) = self.state.channels.iter_mut().find(|c| c.id == id) {
c.enabled = b["enabled"].as_bool().unwrap_or(c.enabled);
}
self.dirty = true;
self.channels_restart(ctx);
done("好了")
}
"mcp" => {
if !owner {
return owner_only();
}
let name = s("name");
if !self.state.mcp_servers.iter().any(|m| m.name == name) {
return fail("找不到這個連接器");
}
// (mcp_poll connects or drops it when the list changes.)
if b["remove"].as_bool().unwrap_or(false) {
self.state.mcp_servers.retain(|m| m.name != name);
} else if let Some(m) = self.state.mcp_servers.iter_mut().find(|m| m.name == name) {
m.enabled = b["enabled"].as_bool().unwrap_or(m.enabled);
}
self.dirty = true;
done("好了")
}
// 設定 › 電腦's buttons for Bot 的電腦.
"box" => {
let op = s("op");
if matches!(op.as_str(), "update" | "reset" | "runtime" | "auto_update") && !owner {
return owner_only();
}
match op.as_str() {
"wake" => self.box_task(|| crate::cloudbox::ensure_running().map(|_| "Bot 的電腦已經開好了。".to_string())),
"snapshot" => self.box_task(|| crate::cloudbox::save_snapshot().map(|n| format!("已儲存快照({})。", crate::cloudbox::human(n)))),
"disk" => {
let slot = self.cu.disk.clone();
*slot.lock().unwrap() = Some("檢查中…".into());
std::thread::spawn(move || {
let r = crate::cloudbox::disk_report().unwrap_or_else(|e| e);
*slot.lock().unwrap() = Some(r);
});
}
"update" => {
// As the desktop's 更新: the bots stop first, the files and logins stay.
let ids: Vec<u64> = self.streams.keys().copied().collect();
for id in ids {
self.stop(id);
}
*self.cu.note.lock().unwrap() = match crate::cloudbox::rebuild(crate::cloudbox::Rebuild::Update) {
Ok(()) => t("更新已開始。"),
Err(e) => e,
};
}
"reset" => {
// As the desktop's 重設: back to the last snapshot (the current box saved
// first), or a brand-new computer when there is none.
let ids: Vec<u64> = self.streams.keys().copied().collect();
for id in ids {
self.stop(id);
}
let kind = if crate::cloudbox::snapshot_time().is_some() { crate::cloudbox::Rebuild::Restore } else { crate::cloudbox::Rebuild::Reset };
*self.cu.note.lock().unwrap() = match crate::cloudbox::rebuild(kind) {
Ok(()) => t("已開始。"),
Err(e) => e,
};
}
"runtime" => crate::cloudbox::set_box_runtime(&s("value")),
"auto_update" => {
let at = s("value");
if !at.is_empty() && chrono::NaiveTime::parse_from_str(&at, "%H:%M").is_err() {
return fail("時間要寫成 HH:MM");
}
crate::cloudbox::set_auto_update_at((!at.is_empty()).then_some(at));
}
_ => return fail("不認得這個動作"),
}
*BOX_FACTS.lock().unwrap_or_else(|e| e.into_inner()) = None;
done("好了")
}
_ => fail("沒有這個動作"),
}
}
/// A bot as its editor and the panel beside the chat show it.
fn phone_detail(&self, b: &Bot) -> serde_json::Value {
let names: HashMap<u64, String> = self.state.bots.iter().map(|x| (x.id, x.name.clone())).collect();
let (choices, chosen) = self.computer_choices(b);
let trig = crate::triggers::statuses().lock().unwrap().clone();
// 資料庫: the chat's files, newest first (pictures and documents the person or the bot sent).
let mut files: Vec<serde_json::Value> = vec![];
for m in b.messages.iter().rev() {
for a in &m.attachments {
if files.len() < 80 {
files.push(json!({"name": a.name, "path": a.path, "image": a.image, "at": m.at, "mine": m.role == Role::User}));
}
}
}
json!({
"id": b.id, "name": b.name, "description": b.description, "instructions": b.instructions,
"shape": b.shape, "color": b.color, "provider": serde_json::to_value(b.provider).unwrap_or_default(), "model": b.model,
"computer": chosen, "computers": choices.iter().map(|(name, note)| json!({"name": name, "note": note})).collect::<Vec<_>>(),
"tools_enabled": b.tools_enabled, "context_tokens": b.context_tokens, "folder": b.folder, "pinned": b.pinned,
"primary": b.id == self.state.primary_bot, "unread": b.unread,
"members": b.members.iter().map(|i| json!({"id": i, "name": names.get(i).cloned().unwrap_or_default()})).collect::<Vec<_>>(),
"memories": b.memories, "allow_rules": b.allow_rules,
"demos": b.demos.iter().map(|d| json!({"name": d.name, "created": d.created, "steps": d.steps.len()})).collect::<Vec<_>>(),
"routines": b.routines.iter().map(|r| json!({
"id": r.id, "title": r.title, "prompt": r.prompt, "paused": r.paused, "when": appctl::routine_when(r),
"schedule": serde_json::to_value(&r.schedule).unwrap_or_default(), "repeat": crate::routines::describe(&r.schedule),
"trigger": r.trigger.as_ref().map(|t| t.describe()), "status": trig.get(&r.id).cloned(),
"last": r.last_run.map(|t| crate::routines::describe_when(t, &r.schedule.timezone)), "computer": r.computer,
})).collect::<Vec<_>>(),
"threads": b.threads.iter().enumerate().rev().map(|(i, (title, msgs))| json!({"index": i, "title": title, "count": msgs.len(), "at": msgs.last().map(|m| m.at).unwrap_or(0)})).collect::<Vec<_>>(),
"files": files,
})
}
/// 設定 as the phone shows it (nothing secret: keys only as "set or not").
fn phone_settings(&self, owner: bool) -> serde_json::Value {
let st = &self.state;
let ch_status = crate::channels::statuses().lock().unwrap().iter().map(|(k, v)| (*k, (v.ok, v.note.clone(), v.received))).collect::<HashMap<_, _>>();
let mcp = crate::mcp::status();
let mcp_err = self.ch.mcp_errors.lock().unwrap().clone();
let bot_name = |id: u64| st.bot(id).map(|b| b.name.clone()).unwrap_or_else(|| "(Bot 已刪除)".into());
let log = crate::cloudbox::log_lines();
let mut out = json!({
"role": if owner { "owner" } else { "admin" },
"host": st.remote.host_name, "version": version_label(self.exe_built), "newer": self.newer_build.is_some(),
"theme": match st.theme { Theme::Light => "light", Theme::Dark => "dark", Theme::System => "system" },
"accent": serde_json::to_value(st.accent).unwrap_or_default(),
"timezone": st.timezone, "notify": st.notify, "language": st.language,
"direct_mode": st.direct_mode, "direct_locked": !self.managed.direct.allows() || !self.managed.local.allows(),
"auto_review": st.auto_review, "allow_rules": st.review_allow_rules, "ask_rules": st.review_ask_rules,
"providers": crate::providers::ALL_PROVIDERS.iter().map(|&k| {
let ps = st.provider(k);
json!({"key": serde_json::to_value(k).unwrap_or_default(), "label": k.label(), "needs_key": k.needs_key(),
"ready": !k.needs_key() || !crate::providers::resolve_key(k, &ps.api_key).is_empty(),
"default_model": ps.default_model, "custom_url": !ps.base_url.is_empty()})
}).collect::<Vec<_>>(),
"skills": st.skills.iter().map(|k| json!({"name": k.name, "description": k.description, "instructions": k.instructions})).collect::<Vec<_>>(),
"templates": crate::memory::TEMPLATES.iter().map(|t| {
let c = crate::avatar::color(t.color);
json!({"name": t.name, "description": t.description, "shape": t.shape, "color": format!("#{:02x}{:02x}{:02x}", c.r(), c.g(), c.b())})
}).collect::<Vec<_>>(),
"shapes": crate::avatar::SHAPES,
"colors": crate::avatar::COLORS.iter().map(|(k, n, c)| json!({"key": k, "name": n, "hex": format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2])})).collect::<Vec<_>>(),
"folders": st.folders,
"channels": st.channels.iter().map(|c| {
let (ok, note, received) = ch_status.get(&c.id).cloned().unwrap_or((false, String::new(), 0));
json!({"id": c.id, "kind": c.kind.label(), "name": c.name, "bot": bot_name(c.bot_id), "target": c.target, "enabled": c.enabled, "ok": ok, "note": note, "received": received})
}).collect::<Vec<_>>(),
"mcp": st.mcp_servers.iter().map(|m| json!({
"name": m.name, "enabled": m.enabled, "tools": mcp.get(&m.name), "error": mcp_err.iter().find(|e| e.0 == m.name).map(|e| e.1.clone()),
"what": if m.url.is_empty() { m.command.clone() } else { m.url.clone() },
})).collect::<Vec<_>>(),
"box": {
"state": crate::cloudbox::run_state().label(), "available": crate::cloudbox::available(),
"version": crate::cloudbox::wanted_version(), "runtime": crate::cloudbox::box_runtime(),
"snapshot": crate::cloudbox::snapshot_time().map(|t| t.format("%m/%d %H:%M").to_string()),
"auto_update": crate::cloudbox::auto_update_at(),
"note": self.cu.note.lock().unwrap().clone(), "disk": self.cu.disk.lock().unwrap().clone(),
"log": log.iter().rev().take(40).rev().cloned().collect::<Vec<_>>(),
"facts": box_facts(),
},
});
if owner {
out["devices"] = json!(st.remote.devices.iter().map(|d| json!({"name": d.name, "created": d.created, "last_seen": d.last_seen})).collect::<Vec<_>>());
out["docker_host"] = json!(crate::cloudbox::docker_host());
}
out
}
}
#[cfg(test)]
mod phone_act_tests {
use super::*;
/// The demo app (sample bots, nothing read from or saved to disk).
fn demo() -> (egui::Context, App) {
let ctx = egui::Context::default();
let app = App::new(&ctx, Some(std::env::temp_dir().join("anybot-phone-act-test.png")));
(ctx, app)
}
#[test]
fn the_phone_does_what_the_desktop_does() {
let (ctx, mut app) = demo();
assert!(app.demo && app.state.bots.len() >= 2);
let b = app.state.bots[0].id;
let act = |app: &mut App, what: &str, body: serde_json::Value, role: &str| app.phone_act(&ctx, what, &body, role);
// The actions a bot can take, by the same appctl path.
let v = act(&mut app, "app", json!({"bot": b, "name": "create_routine", "args": {"title": "早報", "prompt": "整理新聞", "schedule": {"frequency": "daily", "time": "08:00"}}}), "owner");
assert_eq!(v["ok"], true, "{v}");
let rid = app.state.bot(b).unwrap().routines.iter().find(|r| r.title == "早報").map(|r| r.id).expect("routine made");
assert_eq!(act(&mut app, "app", json!({"bot": b, "name": "update_routine", "args": {"id": rid, "paused": true}}), "owner")["ok"], true);
assert!(app.state.bot(b).unwrap().routines.iter().any(|r| r.id == rid && r.paused));
assert_eq!(act(&mut app, "app", json!({"bot": b, "name": "remember", "args": {"fact": "週末不排工作"}}), "owner")["ok"], true);
assert!(app.state.bot(b).unwrap().memories.contains(&"週末不排工作".to_string()));
// Only the app actions a bot has; nothing else through this door.
assert!(act(&mut app, "app", json!({"bot": b, "name": "write_page", "args": {}}), "owner")["error"].is_string());
assert!(act(&mut app, "nothing", json!({}), "owner")["error"].is_string());
// A bot's switches, checked like the editor checks them.
assert_eq!(act(&mut app, "bot", json!({"bot": b, "key": "shape", "value": "star"}), "owner")["ok"], true);
assert_eq!(app.state.bot(b).unwrap().shape, "star");
assert!(act(&mut app, "bot", json!({"bot": b, "key": "shape", "value": "<script>"}), "owner")["error"].is_string());
assert_eq!(act(&mut app, "bot", json!({"bot": b, "key": "pinned", "value": true}), "owner")["ok"], true);
assert!(app.state.bot(b).unwrap().pinned);
// 團隊: an admin keeps what the phone always let them do, and small chat things…
assert_eq!(act(&mut app, "bot", json!({"bot": b, "key": "unread", "value": true}), "admin")["ok"], true);
assert!(act(&mut app, "detail", json!({"bot": b}), "admin")["id"].is_u64());
// …but making, changing or deleting bots, routines, memories, pages and folders is the owner's.
for (what, body) in [
("app", json!({"bot": b, "name": "update_routine", "args": {"id": rid, "paused": false}})),
("app", json!({"bot": b, "name": "create_bot", "args": {"name": "x"}})),
("app", json!({"bot": b, "name": "remember", "args": {"fact": "x"}})),
("bot", json!({"bot": b, "key": "pinned", "value": false})),
("bot", json!({"bot": b, "key": "shape", "value": "cloud"})),
("group_chat", json!({"members": [b]})),
("clear", json!({"bot": b})),
("delete_page", json!({"id": 1})),
("box", json!({"op": "snapshot"})),
] {
assert!(act(&mut app, what, body.clone(), "admin")["error"].is_string(), "admin did {what} {body}");
}
assert!(app.state.bot(b).unwrap().routines.iter().any(|r| r.id == rid && r.paused));
assert!(app.state.bot(b).unwrap().pinned && app.state.bot(b).unwrap().shape == "star");
// The owner's alone: permissions, a bot's computer tools, deleting a bot.
assert!(act(&mut app, "setting", json!({"key": "auto_review", "value": true}), "admin")["error"].is_string());
assert!(act(&mut app, "bot", json!({"bot": b, "key": "tools", "value": true}), "admin")["error"].is_string());
assert!(act(&mut app, "delete_bot", json!({"bot": b}), "admin")["error"].is_string());
assert_eq!(act(&mut app, "setting", json!({"key": "rule_add", "list": "ask", "value": "付款前先問我"}), "owner")["ok"], true);
assert!(app.state.review_ask_rules.contains(&"付款前先問我".to_string()));
assert_eq!(act(&mut app, "setting", json!({"key": "rule_remove", "list": "ask", "value": "付款前先問我"}), "owner")["ok"], true);
assert!(!app.state.review_ask_rules.contains(&"付款前先問我".to_string()));
// 群組聊天 as the desktop's menu makes it.
let others: Vec<u64> = app.state.bots.iter().filter(|x| x.members.is_empty()).map(|x| x.id).take(2).collect();
let g = act(&mut app, "group_chat", json!({"members": others}), "owner");
let gid = g["bot"].as_u64().expect("group made");
assert_eq!(app.state.bot(gid).unwrap().members, others);
// Deleting (the owner): gone, and there is still a main bot.
assert_eq!(act(&mut app, "delete_bot", json!({"bot": gid}), "owner")["ok"], true);
assert!(app.state.bot(gid).is_none() && app.state.bot(app.state.primary_bot).is_some());
}
#[test]
fn the_phone_never_gets_keys_or_secrets() {
let (ctx, mut app) = demo();
let key = "sk-test-very-secret-1234567890";
app.state.providers.entry("openai".into()).or_default().api_key = key.into();
app.state.channels.push(crate::channels::Channel { id: 9, kind: crate::channels::Kind::Telegram, name: "t".into(), bot_id: app.state.bots[0].id, token: "tg-secret-token".into(), target: String::new(), enabled: true, client_id: String::new(), tenant: String::new() });
for role in ["owner", "admin"] {
let s = app.phone_act(&ctx, "settings", &json!({}), role).to_string();
assert!(!s.contains(key) && !s.contains("tg-secret-token"), "{role}: {s}");
assert!(s.contains("\"ready\":true"), "{s}");
}
// A key goes in (the owner's), and still never comes out.
let openai = serde_json::to_value(crate::providers::ProviderKind::OpenAi).unwrap();
assert!(app.phone_act(&ctx, "provider", &json!({"provider": openai, "key": "sk-new-secret-999"}), "admin")["error"].is_string());
assert_eq!(app.phone_act(&ctx, "provider", &json!({"provider": openai, "key": "sk-new-secret-999", "default_model": "gpt-5"}), "owner")["ok"], true);
assert_eq!(app.state.provider(crate::providers::ProviderKind::OpenAi).api_key, "sk-new-secret-999");
assert!(!app.phone_act(&ctx, "settings", &json!({}), "owner").to_string().contains("sk-new-secret-999"));
assert!(app.phone_act(&ctx, "box", &json!({"op": "reset"}), "admin")["error"].is_string());
// The owner's own lists only to the owner.
assert!(app.phone_act(&ctx, "settings", &json!({}), "admin").get("devices").is_none());
assert!(app.phone_act(&ctx, "settings", &json!({}), "owner").get("devices").is_some());
let b = app.state.bots[0].id;
let d = app.phone_act(&ctx, "detail", &json!({"bot": b}), "owner").to_string();
assert!(!d.contains(key) && d.contains("\"memories\""), "{d}");
}
}