1466 lines
82 KiB
Rust
1466 lines
82 KiB
Rust
// 手機與帳號 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}");
|
||
}
|
||
}
|