diff --git a/mobile/index.html b/mobile/index.html index 983d661..d51a803 100644 --- a/mobile/index.html +++ b/mobile/index.html @@ -388,6 +388,9 @@ async function pickHost() { const hosts = all.filter((h) => !(h.uses && byId[h.uses])); const sharers = (id) => all.filter((h) => h.uses === id).map((h) => h.name); const live = hosts.filter((h) => h.online); + // Several brains on: the one this account's computers use is the one the chats are on. + const used = live.filter((h) => sharers(h.id).length); + if (used.length === 1 && !(S.host && sharers(target(S.host)).length)) S.host = used[0].id; if (S.host) { const t = target(S.host); if (byId[t] && byId[t].online) { S.host = t; localStorage.setItem("anybot.host", t); return start(); } diff --git a/src/app.rs b/src/app.rs index 1da9e00..c88f5de 100644 --- a/src/app.rs +++ b/src/app.rs @@ -690,14 +690,15 @@ impl App { let mut tool_specs = appctl::specs(); tool_specs.extend(tools::specs_for(&names, &default_computer)); let devices: Vec = computers.iter().filter(|t| matches!(t.place, tools::Place::Device { .. })).map(|t| t.name.clone()).collect(); - tool_specs.extend(web::specs_for(&devices)); + let here = computers.iter().find(|t| matches!(t.place, tools::Place::Local { .. })).map(|t| t.name.clone()).unwrap_or_else(|| crate::model::THIS_COMPUTER_NAME.into()); + tool_specs.extend(web::specs_for(&here, &devices)); // Screen tools: the bot's own desktop (when it has one) and this Mac. let mut screens = vec![]; if crate::cloudbox::available() { screens.push(crate::model::BOT_COMPUTER_NAME.to_string()); } if crate::computer::screen_supported() { - screens.push(crate::model::THIS_COMPUTER_NAME.to_string()); + screens.extend(computers.iter().filter(|t| matches!(t.place, tools::Place::Local { .. })).map(|t| t.name.clone())); } // …and the account's other computers (手腳): their own AnyBot looks and clicks there. screens.extend(devices.iter().cloned()); @@ -804,9 +805,51 @@ impl App { v.extend(self.state.remote_computers.iter().map(|c| tools::Target { name: c.name.clone(), place: tools::Place::Ssh { target: c.ssh_target.clone(), dir: c.dir.clone() } })); // 裝置連結: other AnyBots of this account that allow it. v.extend(self.linked_devices().into_iter().filter(|d| !v.iter().any(|t| t.name == d.name)).collect::>()); + // A brain on a server whose hands are the person's computer: "你的電腦" is that computer + // (what the person means by "my Mac"), and the server itself goes by its own name. + if !cfg!(target_os = "macos") { + if let Some(hand) = crate::remote::my_hands(&self.state.remote.host_id).into_iter().next() { + if let Some(i) = v.iter().position(|t| matches!(&t.place, tools::Place::Device { host } if *host == hand)) { + for t in v.iter_mut() { + if matches!(t.place, tools::Place::Local { .. }) { + t.name = crate::model::BRAIN_HOST_NAME.into(); + } + } + v[i].name = crate::model::THIS_COMPUTER_NAME.into(); + } + } + } v } + /// 查看電腦: the computers this bot really has, in plain words. + fn computers_text(&self, b: &Bot) -> String { + self.targets_for(b) + .iter() + .map(|t| match &t.place { + tools::Place::Sealed { .. } | tools::Place::Box { .. } => format!("{}:你自己的電腦,一律可用", t.name), + tools::Place::Local { .. } if t.name == crate::model::BRAIN_HOST_NAME => format!("{}:AnyBot 大腦所在的伺服器「{}」(不是使用者的電腦)", t.name, self.state.remote.host_name), + tools::Place::Local { .. } => format!( + "{}:{}「{}」,{}", + t.name, + if cfg!(target_os = "macos") { "使用者的這台 Mac" } else { "使用者的這台電腦" }, + self.state.machine_label, + if self.state.local_execution { "已允許" } else { "還沒預先允許(直接用工具,App 會問使用者)" } + ), + tools::Place::Ssh { target, .. } => format!("{}:透過 SSH 連到 {target}", t.name), + tools::Place::Device { host } => { + let real = crate::remote::peer_name(host); + if t.name == crate::model::THIS_COMPUTER_NAME { + format!("{}:使用者的電腦「{real}」(大腦透過它的 AnyBot 看畫面、點擊、開瀏覽器和跑指令)", t.name) + } else { + format!("{}:帳號裡的另一台電腦(裝置連結)", t.name) + } + } + }) + .collect::>() + .join("\n") + } + /// Record how a turn ended (success / cancelled / error) and clear its live state. fn settle(&mut self, bot_id: u64, outcome: TurnOutcome) { // 群組聊天: the next member answers after this one finishes well. @@ -913,7 +956,12 @@ impl App { } fn run_app_action(&mut self, ctx: &egui::Context, bot_id: u64, call: AppCall) { - let out = appctl::apply(&mut self.state, bot_id, &call.name, &call.args); + let out = if call.name == "list_computers" { + let text = self.state.bot(bot_id).cloned().map(|b| self.computers_text(&b)).unwrap_or_default(); + appctl::Outcome { text, ok: true, wake: vec![], theme_changed: false, page: None } + } else { + appctl::apply(&mut self.state, bot_id, &call.name, &call.args) + }; // write_page: remember which page the call made, so its card shows under the reply. if let Some(pid) = out.page { if let Some(c) = self.state.bot_mut(bot_id).and_then(|b| b.messages.iter_mut().rev().take(12).flat_map(|m| m.tool_calls.iter_mut()).find(|c| c.id == call.call_id)) { @@ -3668,11 +3716,17 @@ fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_compu tools::Place::Local { workdir } => format!( "- {}:{},起始資料夾 {}{}", t.name, - if cfg!(target_os = "macos") { "使用者的這台 Mac" } else if crate::platform::has_desktop() { "使用者的這台 Linux 電腦(快捷鍵用 Ctrl,不是 ⌘)" } else { "AnyBot 所在的伺服器(不是使用者的 Mac;要用使用者的 Mac 請選帳號裡那台電腦的名字)" }, + if t.name == crate::model::BRAIN_HOST_NAME { "你(大腦)所在的伺服器,不是使用者的電腦;只有使用者明確要在伺服器上做事才用" } else if cfg!(target_os = "macos") { "使用者的這台 Mac" } else if crate::platform::has_desktop() { "使用者的這台 Linux 電腦(快捷鍵用 Ctrl,不是 ⌘)" } else { "AnyBot 所在的伺服器(不是使用者的 Mac;要用使用者的 Mac 請選帳號裡那台電腦的名字)" }, workdir.display(), if st.local_execution { "" } else { "(還沒預先允許:直接使用工具即可,App 會跳出視窗問使用者)" } ), tools::Place::Ssh { target, .. } => format!("- {}:使用者的另一台電腦(SSH {target})", t.name), + tools::Place::Device { host } if t.name == crate::model::THIS_COMPUTER_NAME => format!( + "- {}:使用者自己的電腦「{}」(你在伺服器上,透過它的 AnyBot 操作它:檔案、指令、瀏覽器和 computer_use_* 工具,computer=「{}」)。使用者說「我的 Mac」「我的電腦」「這台」都是指它。", + t.name, + crate::remote::peer_name(host), + t.name + ), tools::Place::Device { .. } => format!("- {}:使用者帳號裡的另一台電腦(裝置連結;可以用檔案、指令和 computer_use_* 工具,computer=「{}」)", t.name, t.name), }) .collect(); diff --git a/src/app/remote_ui.rs b/src/app/remote_ui.rs index bdf71d7..f6791e9 100644 --- a/src/app/remote_ui.rs +++ b/src/app/remote_ui.rs @@ -720,12 +720,15 @@ impl App { 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)) .map(|(id, name, _)| tools::Target { name: name.clone(), place: tools::Place::Device { host: id.clone() } }) .collect() } diff --git a/src/model.rs b/src/model.rs index fce0850..edb9e45 100644 --- a/src/model.rs +++ b/src/model.rs @@ -125,6 +125,8 @@ fn is_zero(n: &u64) -> bool { pub const BOT_COMPUTER_NAME: &str = "Bot 的電腦"; pub const THIS_COMPUTER_NAME: &str = "你的電腦"; +/// A brain on a server, when the person's own computer lends it hands (then that one is 你的電腦). +pub const BRAIN_HOST_NAME: &str = "大腦伺服器"; impl Message { pub fn user(text: &str) -> Self { diff --git a/src/remote.rs b/src/remote.rs index 4be9713..3c2814f 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -1222,12 +1222,39 @@ fn hands_ids() -> &'static Mutex> { H.get_or_init(Mutex::default) } +/// The account's computers as last seen: id → (name, the brain it uses, lends its hands). +fn peer_rows() -> &'static Mutex> { + static P: OnceLock>> = OnceLock::new(); + P.get_or_init(Mutex::default) +} + +/// Computers of the account that lend their hands to this brain (`me`), by id. +pub fn my_hands(me: &str) -> Vec { + if me.is_empty() { + return vec![]; + } + let mut v: Vec = peer_rows().lock().unwrap().iter().filter(|(_, (_, uses, hands))| *hands && uses == me).map(|(id, _)| id.clone()).collect(); + v.sort(); + v +} + +/// A computer's own name as the account server knows it. +pub fn peer_name(id: &str) -> String { + peer_rows().lock().unwrap().get(id).map(|r| r.0.clone()).unwrap_or_default() +} + pub fn devices_online(s: &Settings) -> Vec<(String, String, bool)> { if s.relay_url.is_empty() { return vec![]; } let Ok(mut r) = agent(10).get(&format!("{}/relay/host/peers", s.relay_url)).header("Authorization", &format!("Bearer {}", s.host_token)).call() else { return vec![] }; let v: Value = serde_json::from_str(&r.body_mut().read_to_string().unwrap_or_default()).unwrap_or(Value::Null); + *peer_rows().lock().unwrap() = v["hosts"] + .as_array() + .into_iter() + .flatten() + .filter_map(|h| Some((h["id"].as_str()?.to_string(), (h["name"].as_str().unwrap_or("").to_string(), h["uses"].as_str().unwrap_or("").to_string(), h["hands"].as_bool() == Some(true))))) + .collect(); *hands_ids().lock().unwrap() = v["hosts"].as_array().into_iter().flatten().filter(|h| h["hands"].as_bool() == Some(true) || !h["uses"].as_str().unwrap_or("").is_empty()).filter_map(|h| h["id"].as_str().map(String::from)).collect(); v["hosts"].as_array().map(|a| a.iter().map(|h| (h["id"].as_str().unwrap_or("").to_string(), h["name"].as_str().unwrap_or("").to_string(), h["online"].as_bool().unwrap_or(false))).collect()).unwrap_or_default() } diff --git a/src/web.rs b/src/web.rs index 8315ed3..59f4bff 100644 --- a/src/web.rs +++ b/src/web.rs @@ -61,17 +61,17 @@ pub fn read_needs_permission(args: &str) -> bool { /// The browser tools, also when the browser is on another computer of the account (裝置連結): /// then each takes a `computer`, since each computer's AnyBot browser has its own logins. -pub fn specs_for(devices: &[String]) -> Vec { +pub fn specs_for(here: &str, devices: &[String]) -> Vec { let local = crate::botbrowser::available(); let mut v = specs_inner(local || !devices.is_empty()); if !devices.is_empty() { let mut names: Vec = devices.to_vec(); if local { - names.insert(0, crate::model::THIS_COMPUTER_NAME.to_string()); + names.insert(0, here.to_string()); } let desc = format!( "Which computer's AnyBot browser to use. Each computer's browser has its own logins (a site logged in on one isn't on another). {}", - if local { format!("Leave out for {}.", crate::model::THIS_COMPUTER_NAME) } else { "This computer has no browser: always name one.".to_string() } + if local { format!("Leave out for {here}.") } else { "This computer has no browser: always name one.".to_string() } ); for t in v.iter_mut().filter(|t| t["name"].as_str().map(|n| n.starts_with("browser_")).unwrap_or(false)) { t["parameters"]["properties"]["computer"] = json!({"type": "string", "enum": names, "description": desc});