A brain on a server knows which computer is the person's; the phone page opens the brain in use

- On a server brain whose hands are the person's computer, 你的電腦 is that computer;
  the server itself is 大腦伺服器 (its browser too), so 「我的 Mac」 means the Mac.
- A brain with its own hands no longer lists the account's other brains as computers.
- 查看電腦 lists the computers the bot really has, with their real names.
- Phone page: with several brains on, it opens the one the account's computers use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
王性驊 2026-11-05 14:40:01 +08:00
parent 8f79c33b35
commit 94cadca1b8
6 changed files with 96 additions and 7 deletions

View File

@ -388,6 +388,9 @@ async function pickHost() {
const hosts = all.filter((h) => !(h.uses && byId[h.uses])); const hosts = all.filter((h) => !(h.uses && byId[h.uses]));
const sharers = (id) => all.filter((h) => h.uses === id).map((h) => h.name); const sharers = (id) => all.filter((h) => h.uses === id).map((h) => h.name);
const live = hosts.filter((h) => h.online); 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) { if (S.host) {
const t = target(S.host); const t = target(S.host);
if (byId[t] && byId[t].online) { S.host = t; localStorage.setItem("anybot.host", t); return start(); } if (byId[t] && byId[t].online) { S.host = t; localStorage.setItem("anybot.host", t); return start(); }

View File

@ -690,14 +690,15 @@ impl App {
let mut tool_specs = appctl::specs(); let mut tool_specs = appctl::specs();
tool_specs.extend(tools::specs_for(&names, &default_computer)); tool_specs.extend(tools::specs_for(&names, &default_computer));
let devices: Vec<String> = computers.iter().filter(|t| matches!(t.place, tools::Place::Device { .. })).map(|t| t.name.clone()).collect(); let devices: Vec<String> = 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. // Screen tools: the bot's own desktop (when it has one) and this Mac.
let mut screens = vec![]; let mut screens = vec![];
if crate::cloudbox::available() { if crate::cloudbox::available() {
screens.push(crate::model::BOT_COMPUTER_NAME.to_string()); screens.push(crate::model::BOT_COMPUTER_NAME.to_string());
} }
if crate::computer::screen_supported() { 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. // …and the account's other computers (手腳): their own AnyBot looks and clicks there.
screens.extend(devices.iter().cloned()); 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() } })); 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. // 裝置連結: 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::<Vec<_>>()); v.extend(self.linked_devices().into_iter().filter(|d| !v.iter().any(|t| t.name == d.name)).collect::<Vec<_>>());
// 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 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::<Vec<_>>()
.join("\n")
}
/// Record how a turn ended (success / cancelled / error) and clear its live state. /// Record how a turn ended (success / cancelled / error) and clear its live state.
fn settle(&mut self, bot_id: u64, outcome: TurnOutcome) { fn settle(&mut self, bot_id: u64, outcome: TurnOutcome) {
// 群組聊天: the next member answers after this one finishes well. // 群組聊天: 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) { 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. // write_page: remember which page the call made, so its card shows under the reply.
if let Some(pid) = out.page { 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)) { 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!( tools::Place::Local { workdir } => format!(
"- {}:{},起始資料夾 {}{}", "- {}:{},起始資料夾 {}{}",
t.name, 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(), workdir.display(),
if st.local_execution { "" } else { "(還沒預先允許:直接使用工具即可,App 會跳出視窗問使用者)" } if st.local_execution { "" } else { "(還沒預先允許:直接使用工具即可,App 會跳出視窗問使用者)" }
), ),
tools::Place::Ssh { target, .. } => format!("- {}:使用者的另一台電腦(SSH {target})", t.name), 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), tools::Place::Device { .. } => format!("- {}:使用者帳號裡的另一台電腦(裝置連結;可以用檔案、指令和 computer_use_* 工具,computer=「{}」)", t.name, t.name),
}) })
.collect(); .collect();

View File

@ -720,12 +720,15 @@ impl App {
if self.state.remote.host_token.is_empty() { if self.state.remote.host_token.is_empty() {
return vec![]; return vec![];
} }
let has_hands = !remote::my_hands(&self.state.remote.host_id).is_empty();
self.rm self.rm
.peers .peers
.lock() .lock()
.unwrap() .unwrap()
.iter() .iter()
.filter(|(id, _, online)| !id.is_empty() && *online && *id != self.state.remote.host_id) .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() } }) .map(|(id, name, _)| tools::Target { name: name.clone(), place: tools::Place::Device { host: id.clone() } })
.collect() .collect()
} }

View File

@ -125,6 +125,8 @@ fn is_zero(n: &u64) -> bool {
pub const BOT_COMPUTER_NAME: &str = "Bot 的電腦"; pub const BOT_COMPUTER_NAME: &str = "Bot 的電腦";
pub const THIS_COMPUTER_NAME: &str = "你的電腦"; 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 { impl Message {
pub fn user(text: &str) -> Self { pub fn user(text: &str) -> Self {

View File

@ -1222,12 +1222,39 @@ fn hands_ids() -> &'static Mutex<std::collections::HashSet<String>> {
H.get_or_init(Mutex::default) 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<HashMap<String, (String, String, bool)>> {
static P: OnceLock<Mutex<HashMap<String, (String, String, bool)>>> = 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<String> {
if me.is_empty() {
return vec![];
}
let mut v: Vec<String> = 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)> { pub fn devices_online(s: &Settings) -> Vec<(String, String, bool)> {
if s.relay_url.is_empty() { if s.relay_url.is_empty() {
return vec![]; 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 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); 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(); *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() 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()
} }

View File

@ -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 (裝置連結): /// 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. /// then each takes a `computer`, since each computer's AnyBot browser has its own logins.
pub fn specs_for(devices: &[String]) -> Vec<Value> { pub fn specs_for(here: &str, devices: &[String]) -> Vec<Value> {
let local = crate::botbrowser::available(); let local = crate::botbrowser::available();
let mut v = specs_inner(local || !devices.is_empty()); let mut v = specs_inner(local || !devices.is_empty());
if !devices.is_empty() { if !devices.is_empty() {
let mut names: Vec<String> = devices.to_vec(); let mut names: Vec<String> = devices.to_vec();
if local { if local {
names.insert(0, crate::model::THIS_COMPUTER_NAME.to_string()); names.insert(0, here.to_string());
} }
let desc = format!( 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). {}", "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)) { 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}); t["parameters"]["properties"]["computer"] = json!({"type": "string", "enum": names, "description": desc});