From 591626408f68f84f455c3e9b00bb81e4938f05d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Thu, 5 Nov 2026 16:03:34 +0800 Subject: [PATCH 01/19] A server brain without hands calls its server the brain server, and tells the model which computers really exist --- src/app.rs | 32 ++++++++++++++++++++++++++++++-- src/appctl.rs | 15 +++++++++++++-- src/computer.rs | 19 +++++++++++++++++-- src/tools.rs | 10 +++++++++- 4 files changed, 69 insertions(+), 7 deletions(-) diff --git a/src/app.rs b/src/app.rs index 2391d59..a1f0c33 100644 --- a/src/app.rs +++ b/src/app.rs @@ -817,6 +817,15 @@ impl App { } v[i].name = crate::model::THIS_COMPUTER_NAME.into(); } + } else if self.headless { + // A server brain with no hands connected: the machine it runs on is a server, + // not anyone's computer. Wearing the name 你的電腦 here made bots "work on your + // computer" on the server while the person watched their own untouched desktop. + for t in v.iter_mut() { + if matches!(t.place, tools::Place::Local { .. }) { + t.name = crate::model::BRAIN_HOST_NAME.into(); + } + } } } v @@ -827,7 +836,7 @@ impl App { self.targets_for(b) .iter() .map(|t| match &t.place { - tools::Place::Sealed { .. } | tools::Place::Box { .. } => format!("{}:你自己的電腦,一律可用", t.name), + tools::Place::Sealed { .. } | tools::Place::Box { .. } => format!("{}:這個 Bot 自己的 Linux 桌面(AI 的電腦,不是使用者的電腦),一律可用", 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!( "{}:{}「{}」,{}", @@ -3278,6 +3287,7 @@ impl App { } pub fn serve(ctx: &egui::Context) -> ! { + crate::appctl::SERVING.store(true, std::sync::atomic::Ordering::Relaxed); let mut app = App::new(ctx, None); app.headless = true; if let Ok(pw) = std::env::var("ANYBOT_PHONE_PASSWORD") { @@ -3679,6 +3689,24 @@ fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_compu s.push_str("你是使用者的主要 Bot:主要聯絡窗口,可以主動幫忙,也能用 message_bot 把工作交給其他 Bot。"); } let now = crate::routines::now_in(&st.timezone); + // Which screens can really be operated, named as the model must name them: the fixed + // wording promised "你的電腦(使用者的 Mac)" on server brains that have no such computer, + // and bots then "worked on your computer" on the server itself. + let mut screens: Vec = Vec::new(); + for t in computers { + match &t.place { + tools::Place::Box { .. } => screens.push(format!("{}(AI 自己的 Linux 桌面,不是使用者的電腦)", t.name)), + tools::Place::Local { .. } => { + if crate::computer::screen_supported() { + screens.push(format!("{}(這台電腦自己的畫面)", t.name)); + } + } + tools::Place::Device { host } if t.name == crate::model::THIS_COMPUTER_NAME => screens.push(format!("{}(使用者自己的電腦「{}」的畫面)", t.name, crate::remote::peer_name(host))), + tools::Place::Device { .. } => screens.push(format!("{}(帳號裡另一台電腦的畫面)", t.name)), + tools::Place::Ssh { .. } | tools::Place::Sealed { .. } => {} + } + } + let screens = if screens.is_empty() { "目前沒有任何可操作的螢幕".to_string() } else { screens.join(";") }; s.push_str(&format!( "\n\n現在是 {}({},時區 {})。\n\ 你在 AnyBot 這個 App 裡,可以直接操作 App:\n\ @@ -3694,7 +3722,7 @@ fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_compu - 絕對不要直接呼叫網站的內部 API、下載網站的程式碼來研究,或用程式大量抓資料:網站會把帳號當成機器人(例如跳出拼圖驗證、封鎖帳號)。出現驗證碼、拼圖或簡訊驗證時,停下來請使用者在 AnyBot 瀏覽器視窗完成。 - 如果讀到的是登入頁,請使用者在跳出來的 AnyBot 瀏覽器視窗登入一次(之後會記住),登入好再讀。絕對不要叫使用者去改瀏覽器的設定或選單(例如「允許來自 Apple 事件的 JavaScript」),那不需要。也不要說要從 Chrome 複製登入或 Cookie,沒有這個功能。\n\ - AnyBot 瀏覽器有設定檔(profile),每個有自己的登入。使用者說要換 profile/設定檔/另一個帳號時,用 browser_profiles:list 看有哪些,use 切換,add 新增(新的要登入一次)。也可以在 browser_open / browser_read 帶 profile 參數。使用者想用自己平常的 Chrome(自己的 Chrome 設定檔、已經登入的帳號)時,用 profile「你的瀏覽器」:請使用者在 Chrome 切到想要的設定檔,再 browser_read。\n\ - - 你也能像人一樣操作螢幕:你自己的電腦(Bot 的電腦,Linux 桌面)或使用者的 Mac(你的電腦,用在使用者自己的 App)。computer_use_screenshot 看畫面,再用 computer_use_click / computer_use_typing / computer_use_press_key / computer_use_scroll / computer_use_drag 操作;座標用最新截圖上的像素。每做一步就再截圖確認。用在檔案和網頁工具做不到的事(例如操作其他 App、在 Chrome 切換設定檔)。\n\ + - 你也能像人一樣操作螢幕({screens})。computer_use_screenshot 看畫面,再用 computer_use_click / computer_use_typing / computer_use_press_key / computer_use_scroll / computer_use_drag 操作;座標用最新截圖上的像素。每做一步就再截圖確認。用在檔案和網頁工具做不到的事(例如操作其他 App、在 Chrome 切換設定檔)。\n\ - 使用者 Mac 上的「提醒事項、行事曆、聯絡人、備忘錄、郵件、訊息」有專門的工具(ios_read_reminders、mobile_read_calendar、mobile_read_contacts、mac_read_notes、mac_read_mail 等),比操作畫面快又準;寄信或傳訊息每次都會先請使用者確認,起草郵件(mac_draft_mail)則讓使用者自己按寄出。\n\ - 不要說「我沒有瀏覽器或工具」:先試這些工具。需要權限時 App 會自己跳出視窗問使用者;工具回報要開什麼設定時,照實轉告。", now.format("%Y-%m-%d %H:%M"), diff --git a/src/appctl.rs b/src/appctl.rs index 5d5a1d8..aa0fa46 100644 --- a/src/appctl.rs +++ b/src/appctl.rs @@ -145,6 +145,10 @@ pub fn needs_approval(name: &str, args: &str, state: &State) -> Option { None } +/// True while running as a headless brain (`anybot --serve`): "this computer" is a server, +/// not a person's desktop, and the computer-wording tools must say so. Set in App::serve. +pub static SERVING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + pub struct Outcome { pub text: String, pub ok: bool, @@ -543,9 +547,16 @@ pub fn apply(state: &mut State, bot_id: u64, name: &str, args: &str) -> Outcome Outcome { text: format!("已交給「{name}」,它的回覆會出現在它自己的聊天裡。"), ok: true, wake: vec![(target, msg)], theme_changed: false, page: None } } "list_computers" => { + // On a server brain (serve), Local is the server, not the person's computer — + // matching what the app's own list_computers says after its rename. + let serving = SERVING.load(std::sync::atomic::Ordering::Relaxed); let mut lines = vec![ - format!("{}:你自己的電腦(獨立的資料夾),一律可用", model::BOT_COMPUTER_NAME), - format!("{}:{},{}", model::THIS_COMPUTER_NAME, if cfg!(target_os = "macos") { "這台 Mac" } else { "這台電腦" }, if state.local_execution { "已允許" } else { "使用者還沒在設定裡允許(設定 → 電腦 → 在這台電腦上執行)" }), + format!("{}:{}", model::BOT_COMPUTER_NAME, if serving { "這個 Bot 自己的 Linux 電腦(AI 的電腦,不是使用者的電腦,獨立資料夾),一律可用" } else { "你自己的電腦(獨立的資料夾),一律可用" }), + if serving { + format!("{}:AnyBot 大腦所在的伺服器(不是使用者的電腦)。使用者的電腦要在它上面裝 AnyBot 並登入同一帳號,才會以「{}」出現", model::BRAIN_HOST_NAME, model::THIS_COMPUTER_NAME) + } else { + format!("{}:{},{}", model::THIS_COMPUTER_NAME, if cfg!(target_os = "macos") { "這台 Mac" } else { "這台電腦" }, if state.local_execution { "已允許" } else { "使用者還沒在設定裡允許(設定 → 電腦 → 在這台電腦上執行)" }) + }, ]; lines.extend(state.remote_computers.iter().map(|c| format!("{}:透過 SSH 連到 {}", c.name, c.ssh_target))); ok(lines.join("\n")) diff --git a/src/computer.rs b/src/computer.rs index 8dbbd35..918a1df 100644 --- a/src/computer.rs +++ b/src/computer.rs @@ -224,9 +224,24 @@ pub fn specs_for(computers: &[String], default: &str) -> Vec { // (a 手腳 computer): the bot should say whose screen it means. let own = [crate::model::BOT_COMPUTER_NAME, crate::model::THIS_COMPUTER_NAME]; if computers.len() > 1 || computers.iter().any(|c| !own.contains(&c.as_str())) { + // Explain each name that is actually offered (the old text promised "the person's Mac" + // on machines where it does not exist, and the bot grabbed the next best thing). + let mut parts = Vec::new(); + if computers.iter().any(|c| c == crate::model::BOT_COMPUTER_NAME) { + parts.push(format!("{} is a private Linux desktop for the bot (the AI's own computer, not the person's).", crate::model::BOT_COMPUTER_NAME)); + } + if computers.iter().any(|c| c == crate::model::THIS_COMPUTER_NAME) { + parts.push(format!("{} is the person's own computer (use it for the person's own apps).", crate::model::THIS_COMPUTER_NAME)); + } + if computers.iter().any(|c| c == crate::model::BRAIN_HOST_NAME) { + parts.push(format!("{} is the server the brain runs on — not a person's screen; a person asking about \"my screen\" never means it.", crate::model::BRAIN_HOST_NAME)); + } + if computers.iter().any(|c| c != crate::model::BOT_COMPUTER_NAME && c != crate::model::THIS_COMPUTER_NAME && c != crate::model::BRAIN_HOST_NAME) { + parts.push("Any other name is another computer of the account.".to_string()); + } + let desc = format!("Whose screen. Default: {default}. {}", parts.join(" ")); for t in v.iter_mut() { - t["parameters"]["properties"]["computer"] = json!({"type": "string", "enum": computers, - "description": format!("Whose screen. Default: {default}. {} is the bot's own Linux desktop; {} is the person's Mac (use it for the person's own apps).", crate::model::BOT_COMPUTER_NAME, crate::model::THIS_COMPUTER_NAME)}); + t["parameters"]["properties"]["computer"] = json!({"type": "string", "enum": computers, "description": desc}); } } v diff --git a/src/tools.rs b/src/tools.rs index f5b6cb6..041fc69 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -629,10 +629,18 @@ pub fn specs_for(computers: &[String], default: &str) -> Vec { if t["name"] == "read_terminal" || t["name"] == "write_terminal" { continue; } + // None of these being the person's own computer (a server brain with no hands): + // a bot that "helped on your computer" then worked on the server, while the person + // watched their own desktop stay untouched. Say what is true instead. + let desc = if computers.iter().any(|c| c == crate::model::THIS_COMPUTER_NAME) { + format!("Which computer to work on. Default: {default}. Use the one the person names (\"在我的 Mac 上\" = {}).", crate::model::THIS_COMPUTER_NAME) + } else { + format!("Which computer to work on. Default: {default}. None of these is the person's own computer (it is not connected): use the default, and when the person means their own computer, tell them it is not connected (App › 設定 › 電腦) instead of working on another machine and calling it theirs.") + }; t["parameters"]["properties"]["computer"] = json!({ "type": "string", "enum": computers, - "description": format!("Which computer to work on. Default: {default}. Use the one the person names (\"在我的 Mac 上\" = {}).", crate::model::THIS_COMPUTER_NAME) + "description": desc }); } v From cd3f7b5c72327d55d3c6a1f0620cae28058e4813 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Thu, 5 Nov 2026 16:06:53 +0800 Subject: [PATCH 02/19] A bot that is unsure which computer the person means asks first instead of picking one --- src/app.rs | 1 + src/tools.rs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app.rs b/src/app.rs index a1f0c33..6c48ab5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3773,6 +3773,7 @@ fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_compu s.push_str(&format!( "\n\n你可以用工具在電腦上做事。每個工具都有 computer 參數,預設是「{default_computer}」。\n{}\n\ - 使用者替你選的電腦是「{default_computer}」:檔案、指令、看畫面、點擊和瀏覽器都在這台做,不要自己換到別台。只有使用者在訊息裡指名另一台(「在我的 Mac 上」「在 Bot 的電腦上」),才把 computer 設成那台。\n\ + - 不確定使用者指的是哪台電腦時(例如他說「我的電腦」而那台不在清單上、有幾台都說得通、或他的說法和你手上的預設不一致),先用一句話問他要哪台,問清楚再動手;不要擅作主張選一台就開始做,也不要把一台說成另一台。\n\ - 先用 list_dir、search、read_file 看清楚,再動手。\n\ - 需要權限時(電腦工具還沒打開、要用使用者的電腦、會改變東西的操作),直接呼叫工具,App 會自己跳出視窗請使用者允許;不要叫使用者自己去改設定。被拒絕就換個方式或問使用者。\n\ - 跑很久的指令會移到背景,用 read_terminal 追蹤,不要重複執行;它在等輸入時(例如問 y/n),用 write_terminal 回答。\n\ diff --git a/src/tools.rs b/src/tools.rs index 041fc69..24c3feb 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -635,7 +635,7 @@ pub fn specs_for(computers: &[String], default: &str) -> Vec { let desc = if computers.iter().any(|c| c == crate::model::THIS_COMPUTER_NAME) { format!("Which computer to work on. Default: {default}. Use the one the person names (\"在我的 Mac 上\" = {}).", crate::model::THIS_COMPUTER_NAME) } else { - format!("Which computer to work on. Default: {default}. None of these is the person's own computer (it is not connected): use the default, and when the person means their own computer, tell them it is not connected (App › 設定 › 電腦) instead of working on another machine and calling it theirs.") + format!("Which computer to work on. Default: {default}. None of these is the person's own computer (it is not connected): when the person means their own computer, ask which computer they want and tell them theirs is not connected (App › 設定 › 電腦) — never work on another machine and call it theirs.") }; t["parameters"]["properties"]["computer"] = json!({ "type": "string", From 1a80bfdcc226a8ed575eedad1e2aa65aa4dbd4db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Thu, 5 Nov 2026 16:11:25 +0800 Subject: [PATCH 03/19] An open browser on the box buys it four times the idle time, and an open noVNC viewer keeps it awake: a bot that opened the browser left the person a frozen screen --- src/cloudbox.rs | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/cloudbox.rs b/src/cloudbox.rs index d512d50..191e7ab 100644 --- a/src/cloudbox.rs +++ b/src/cloudbox.rs @@ -709,7 +709,13 @@ pub fn start_background() { let s = shared().lock().unwrap(); (s.last_used.elapsed().as_secs(), s.run.is_running(), s.rebuilding) }; - if running && !rebuilding && !is_busy() && !HELD_BY_PERSON.load(Ordering::SeqCst) && idle >= IDLE_SLEEP_SECS && !viewer_open() { + let awake = running && !rebuilding && !is_busy() && !HELD_BY_PERSON.load(Ordering::SeqCst) && !viewer_open(); + // A browser on the desktop was left open on purpose (a login page the person walks + // back to): it buys the box four times the idle time before parking. Freezing the + // container froze Chromium and websockify together, so a bot that "opened the + // browser" left the person a viewer that never showed anything. + let limit = if awake && browser_running() { IDLE_SLEEP_SECS * 4 } else { IDLE_SLEEP_SECS }; + if awake && idle >= limit && vnc_clients() == 0 { let _ = sleep_now(); } if !rebuilding { @@ -731,6 +737,20 @@ fn viewer_open() -> bool { VIEWER_OPEN.load(Ordering::SeqCst) } +/// Viewer tabs open on the box's desktop: every noVNC page keeps a websockify connection to +/// x11vnc on :5900 (:170C hex), so a live connection means the person is looking at it now. +fn vnc_clients() -> usize { + dk(&["exec", CONTAINER, "sh", "-c", + "awk '$4==\"01\" && ($2 ~ /:170C$/ || $3 ~ /:170C$/) {n++} END {print n+0}' /proc/net/tcp /proc/net/tcp6 2>/dev/null"]) + .ok() + .and_then(|s| parse_count(&s)) + .unwrap_or(0) +} + +fn parse_count(s: &str) -> Option { + s.trim().lines().last()?.trim().parse().ok() +} + // ---------- snapshots (the durable store) ---------- /// Save the bot's home folder to this Mac. Returns its size in bytes. @@ -1461,6 +1481,14 @@ pub fn shutdown() { mod tests { use super::*; + #[test] + fn vnc_probe_parses_only_a_count() { + assert_eq!(parse_count("3"), Some(3)); + assert_eq!(parse_count(" 2 \n"), Some(2)); + assert_eq!(parse_count(""), None); + assert_eq!(parse_count("Error response from daemon: container paused"), None); + } + #[test] fn secret_names_follow_the_original_rule() { assert!(valid_secret_name("CRM_API_TOKEN")); From 2a24606024297bbba698ef0a8367398802a28c57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Thu, 5 Nov 2026 16:16:25 +0800 Subject: [PATCH 04/19] A multi-step turn answers once: no narrating between tool calls --- src/app.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app.rs b/src/app.rs index 6c48ab5..0c8bc84 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3778,6 +3778,7 @@ fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_compu - 需要權限時(電腦工具還沒打開、要用使用者的電腦、會改變東西的操作),直接呼叫工具,App 會自己跳出視窗請使用者允許;不要叫使用者自己去改設定。被拒絕就換個方式或問使用者。\n\ - 跑很久的指令會移到背景,用 read_terminal 追蹤,不要重複執行;它在等輸入時(例如問 y/n),用 write_terminal 回答。\n\ - 只有工具結果證明做完了,才說做完;失敗就照實說。\n\ + - 連續執行多步驟工具時不要每步都發訊息(先說「好,我先…」,做完一個工具播一段,最後再總結——使用者打一句就看到三四則回覆)。執行時就安靜地連續呼叫工具,全部做完後用一段話講結果;只有卡住、需要使用者動手(例如要他輸入密碼)、或要先問清哪台電腦時,才中途說話。\n\ - 回覆使用者時,用簡單的話說明你做了什麼、在哪台電腦、結果如何。", list.join("\n") )); From 3132f8ce91f57f0a89c4788436f82cf042feaf98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Thu, 5 Nov 2026 16:47:28 +0800 Subject: [PATCH 05/19] Every computer goes by its own name; a server brain works on the computer the person uses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A bot's own computer is 「 的電腦」; this one and the account's others go by their names. The server brain picked 你的電腦 by sorted host id, so a desktop left on the server won. - 「我的電腦」 is the computer the person typed on (the relay names it); unknown means ask. - A step moved back to the chosen computer tells the bot so. - Composer chip and bot settings choose the computer by name; a screen asks the brain. - A screen's 電腦 card and viewer show the brain's computer, not this one's box. - A screen's chat copy that falls short asks for everything again instead of appending; 清除聊天記錄 on a screen clears the brain's chat. - Model requests to a private address go through the person's computer first. - Phone typing on the box sends the text the tool reads. Co-Authored-By: Claude Opus 5.5 --- mobile/index.html | 6 +- src/agent.rs | 288 +++++++++++++++++-------- src/app.rs | 451 ++++++++++++++++++++++++++++------------ src/app/chat_ui.rs | 13 +- src/app/computers_ui.rs | 145 +++++++++++-- src/app/mirror_ui.rs | 52 +++++ src/app/panel_ui.rs | 27 ++- src/app/remote_ui.rs | 20 +- src/appctl.rs | 13 +- src/attach.rs | 2 +- src/botbrowser.rs | 2 +- src/cloudbox.rs | 8 +- src/computer.rs | 26 +-- src/i18n_table.rs | 14 ++ src/memory.rs | 2 +- src/model.rs | 33 ++- src/platform.rs | 9 - src/remote.rs | 102 ++++++++- src/tools.rs | 24 ++- src/web.rs | 30 +-- 20 files changed, 941 insertions(+), 326 deletions(-) diff --git a/mobile/index.html b/mobile/index.html index d799b4d..e8f1d71 100644 --- a/mobile/index.html +++ b/mobile/index.html @@ -687,7 +687,9 @@ function renderChips() { } // ---------- screens ---------- -$("b-screen").onclick = $("b-screen2").onclick = () => { S.back = S.view; show("screen"); $("seg").children[1].style.display = S.mac ? "" : "none"; startFrames(); }; +// Each bot's own computer goes by the bot's name (「Master 的電腦」). +function ownComputer() { const n = botName(S.open); return n ? n + " 的電腦" : "Bot 的電腦"; } +$("b-screen").onclick = $("b-screen2").onclick = () => { S.back = S.view; show("screen"); $("seg").children[0].textContent = ownComputer(); $("seg").children[1].style.display = S.mac ? "" : "none"; startFrames(); }; $("b-back2").onclick = () => show(S.back === "chat" ? "chat" : "list"); document.querySelectorAll("#seg button").forEach((b) => b.onclick = () => { document.querySelectorAll("#seg button").forEach((x) => x.classList.toggle("on", x === b)); S.screen = b.dataset.w; $("k-wake").style.display = S.screen === "box" ? "" : "none"; startFrames(); }); let busyFrame = false, lastUrl = null; @@ -735,7 +737,7 @@ $("k-type").onclick = () => { $("typebar").classList.toggle("on"); if ($("typeba $("b-type").onclick = () => { const t = $("typein").value; if (!t) return; $("typein").value = ""; input2({kind: "type", text: t}); }; $("typein").addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.isComposing) { e.preventDefault(); $("b-type").click(); } }); $("k-right").onclick = () => { S.right = !S.right; $("k-right").textContent = "右鍵:" + (S.right ? "開" : "關"); }; -$("k-wake").onclick = () => { toast("正在叫醒 Bot 的電腦…"); input2({kind: "wake"}); }; +$("k-wake").onclick = () => { toast("正在叫醒" + ownComputer() + "…"); input2({kind: "wake"}); }; // ---------- menu, notifications ---------- $("b-more").onclick = () => { $("m-host").style.display = S.relay ? "" : "none"; $("m-pair").style.display = S.relay ? "" : "none"; notifyLabel(); $("sheet").classList.add("on"); }; $("sheet").onclick = (e) => { if (e.target.id === "sheet") $("sheet").classList.remove("on"); }; diff --git a/src/agent.rs b/src/agent.rs index 094707c..2733b65 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -34,6 +34,8 @@ pub struct Job { /// Computers this bot may work on (empty when computer tools are off). pub computers: Vec, pub default_computer: String, + /// Which computer is the bot's own, which one the person is using, and whether this is a server. + pub roles: tools::Roles, /// 在這台電腦上執行 is on. pub local_execution: bool, /// This bot's computer tools are on (when off, using them asks the person first). @@ -121,23 +123,29 @@ enum BrowserAt { Device(String), } -/// Whose browser a browser step uses: the computer it names, else the bot's chosen computer. -fn browser_place(job: &Job, name: &str, args: &str) -> BrowserAt { +/// Whose browser a browser step uses: the computer it names, else the bot's chosen computer; a +/// computer without a browser (a server, an SSH one) hands it to the person's computer. +fn browser_place(job: &Job, name: &str, args: &str) -> (BrowserAt, String) { if name == "web_fetch" { - return BrowserAt::Here; + return (BrowserAt::Here, String::new()); } let v = tools::args_of(args); let want = v.get("computer").and_then(|c| c.as_str()).map(str::trim).filter(|c| !c.is_empty()).unwrap_or(&job.default_computer).to_string(); - let any_device = || job.computers.iter().find_map(|t| match &t.place { - tools::Place::Device { host } => Some(host.clone()), - _ => None, - }); - match job.computers.iter().find(|t| t.name == want).map(|t| &t.place) { - Some(tools::Place::Device { host }) => BrowserAt::Device(host.clone()), - Some(tools::Place::Box { .. }) if crate::cloudbox::available() => BrowserAt::Box, - // No browser here (a brain on a server): the 手腳 computer's. - _ if !crate::botbrowser::available() => any_device().map(BrowserAt::Device).unwrap_or(BrowserAt::Here), - _ => BrowserAt::Here, + let at = |t: &tools::Target| -> Option { + match &t.place { + tools::Place::Device { host } => Some(BrowserAt::Device(host.clone())), + tools::Place::Box { .. } if crate::cloudbox::available() => Some(BrowserAt::Box), + tools::Place::Local { .. } if crate::botbrowser::available() => Some(BrowserAt::Here), + _ => None, + } + }; + if let Some((b, n)) = job.computers.iter().find(|t| t.name == want).and_then(|t| at(t).map(|b| (b, t.name.clone()))) { + return (b, n); + } + let person = job.roles.person.as_ref().and_then(|p| job.computers.iter().find(|t| &t.name == p)); + match person.and_then(|t| at(t).map(|b| (b, t.name.clone()))).or_else(|| job.computers.iter().find_map(|t| at(t).map(|b| (b, t.name.clone())))) { + Some(x) => x, + None => (BrowserAt::Here, String::new()), } } @@ -172,54 +180,72 @@ fn finds(hay: &str, needle: &str) -> bool { false } -/// Words that mean a computer in the person's message. -fn mentions(text: &str, t: &tools::Target) -> bool { +/// Words that mean a computer in the person's message: its name (or the part after 「的」, so +/// 「Mac mini」 names 「王小明的Mac mini」), and for the bot's own computer and the one the person +/// is using, the everyday words for them. +fn mentions(text: &str, t: &tools::Target, roles: &tools::Roles) -> bool { let low = text.to_lowercase(); - if finds(&low, &t.name.to_lowercase()) { + let name = t.name.to_lowercase(); + if finds(&low, &name) { return true; } - let words: &[&str] = match &t.place { - _ if t.name == crate::model::BOT_COMPUTER_NAME => &["bot 的電腦", "bot的電腦", "你自己的電腦", "你的虛擬", "虛擬電腦", "雲端電腦"], - _ if t.name == crate::model::THIS_COMPUTER_NAME => &["mac", "我的電腦", "我電腦", "我這台", "這台電腦", "本機", "我的筆電"], - tools::Place::Device { host } => return !crate::remote::peer_name(host).is_empty() && finds(&low, &crate::remote::peer_name(host).to_lowercase()), - _ => &[], + if let Some((_, tail)) = name.rsplit_once('的') { + let tail = tail.trim(); + if tail.chars().count() >= 3 && tail != "電腦" && finds(&low, tail) { + return true; + } + } + if finds(&name, "mac") && finds(&low, "mac") { + return true; + } + let words: &[&str] = if t.name == roles.own { + &["bot 的電腦", "bot的電腦", "你的電腦", "你自己的電腦", "你的虛擬", "虛擬電腦", "雲端電腦"] + } else if roles.person.as_deref() == Some(t.name.as_str()) { + &["我的電腦", "我電腦", "我這台", "這台電腦", "本機", "我的筆電", "my computer"] + } else if roles.server && matches!(t.place, tools::Place::Local { .. }) { + &["伺服器", "server"] + } else { + &[] }; + if t.name == roles.own && finds(&low, &name.replace(' ', "")) { + return true; + } words.iter().any(|w| finds(&low, w)) } /// A step naming a computer other than the chosen one, which the person didn't ask for, goes -/// to the chosen one. -fn keep_to_chosen(job: &Job, history: &[Message], args: &str) -> String { +/// to the chosen one — and the bot is told so (quietly moving it made a bot "ask your Mac" six +/// times while every step landed on its own computer). +fn keep_to_chosen(job: &Job, history: &[Message], args: &str) -> (String, Option) { let mut v = tools::args_of(args); - let Some(named) = v.get("computer").and_then(|c| c.as_str()).map(|c| c.trim().to_string()).filter(|c| !c.is_empty()) else { return args.to_string() }; + let Some(named) = v.get("computer").and_then(|c| c.as_str()).map(|c| c.trim().to_string()).filter(|c| !c.is_empty()) else { return (args.to_string(), None) }; if named == job.default_computer || !job.computers.iter().any(|t| t.name == job.default_computer) { - return args.to_string(); + return (args.to_string(), None); } let said = history.iter().rev().filter(|m| m.role == Role::User && !m.summary).take(3).map(|m| m.text.as_str()).collect::>().join("\n"); match job.computers.iter().find(|t| t.name == named) { - Some(t) if mentions(&said, t) => args.to_string(), + Some(t) if mentions(&said, t, &job.roles) => (args.to_string(), None), _ => { v["computer"] = serde_json::Value::String(job.default_computer.clone()); - v.to_string() + let note = format!("(這一步你指定了「{named}」,但使用者選的電腦是「{}」,而且他沒有說要換,所以這一步在「{}」上做。真的需要別台,先問使用者。)\n", job.default_computer, job.default_computer); + (v.to_string(), Some(note)) } } } +/// A step's result, with a word first when the step was moved back to the chosen computer. +fn noted(moved: &std::collections::HashMap, id: &str, text: &str) -> String { + match moved.get(id) { + Some(n) => format!("{n}{text}"), + None => text.to_string(), + } +} + /// Does the call name a computer? fn names_a_computer(args: &str) -> bool { tools::args_of(args).get("computer").and_then(|c| c.as_str()).map(|c| !c.trim().is_empty()).unwrap_or(false) } -/// The other computer (裝置連結) a call names, if it names one. -fn device_named(job: &Job, args: &str) -> Option { - let v = tools::args_of(args); - let n = v.get("computer").and_then(|c| c.as_str()).map(str::trim).filter(|c| !c.is_empty())?; - job.computers.iter().find(|t| t.name == n).and_then(|t| match &t.place { - tools::Place::Device { host } => Some(host.clone()), - _ => None, - }) -} - /// Rough token count: about 4 Latin characters or 1 Chinese character per token (estimated). /// Which screen a computer_use call is for: the one named, else the bot's default when it has a /// screen, else the bot's own desktop when there is one, else this Mac. @@ -228,10 +254,10 @@ fn screen_target(job: &Job, args: &str) -> Result { let has_screen = |t: &tools::Target| matches!(t.place, tools::Place::Box { .. } | tools::Place::Local { .. } | tools::Place::Device { .. }); let named = v.get("computer").and_then(|c| c.as_str()).map(str::trim).filter(|c| !c.is_empty()); if let Some(n) = named { - let t = job.computers.iter().find(|t| t.name == n).ok_or_else(|| format!("沒有「{n}」這台電腦"))?; + let t = job.computers.iter().find(|t| t.name == n).ok_or_else(|| format!("沒有「{n}」這台電腦。可以用:{}", job.computers.iter().map(|t| t.name.as_str()).collect::>().join("、")))?; if !has_screen(t) { - return Err(if n == crate::model::BOT_COMPUTER_NAME { - if cfg!(target_os = "macos") { "Bot 的電腦現在沒有螢幕:這台 Mac 沒有 Docker(安裝 OrbStack 或 Docker Desktop 就會有)。可以改用「你的電腦」。".into() } else { "Bot 的電腦現在沒有螢幕:這台電腦沒有 Docker(安裝 Docker Engine 就會有)。可以改用「你的電腦」。".into() } + return Err(if n == job.roles.own { + format!("{n}現在沒有螢幕:AnyBot 所在的電腦沒有 Docker({})。要用別台請使用者指名。", if cfg!(target_os = "macos") { "安裝 OrbStack 或 Docker Desktop 就會有" } else { "安裝 Docker Engine 就會有" }) } else { format!("「{n}」不能遠端操作螢幕,只能用檔案和指令工具。") }); @@ -383,8 +409,14 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< return; } let mut out = out; + // Steps moved back to the chosen computer: what the bot is told with each result. + let mut moved: std::collections::HashMap = std::collections::HashMap::new(); for c in out.tool_calls.iter_mut() { - c.args = keep_to_chosen(&job, &history, &c.args); + let (args, note) = keep_to_chosen(&job, &history, &c.args); + c.args = args; + if let Some(n) = note { + moved.insert(c.id.clone(), n); + } } let msg = Message { role: Role::Assistant, @@ -425,7 +457,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< } }; send(StreamEvent::ToolStatus(call.id.clone(), if ok { ToolStatus::Done } else { ToolStatus::Failed })); - let result = Message::tool_result(&call.id, &call.name, &output); + let result = Message::tool_result(&call.id, &call.name, ¬ed(&moved, &call.id, &output)); send(StreamEvent::ToolResult(result.clone())); history.push(result); continue; @@ -435,7 +467,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running)); let (output, ok, img) = crate::teach::view_step(&job.demos, &call.args); send(StreamEvent::ToolStatus(call.id.clone(), if ok { ToolStatus::Done } else { ToolStatus::Failed })); - let mut result = Message::tool_result(&call.id, &call.name, &output); + let mut result = Message::tool_result(&call.id, &call.name, ¬ed(&moved, &call.id, &output)); result.images.extend(img); send(StreamEvent::ToolResult(result.clone())); history.push(result); @@ -446,7 +478,8 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< if call.name == "web_fetch" || (web::is_web_tool(&call.name) && !web::uses_this_computer(&call.name, &call.args)) { send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running)); // 裝置連結: the AnyBot browser of another computer (its own logins). - let (output, ok, img) = match browser_place(&job, &call.name, &call.args) { + let (place, on) = browser_place(&job, &call.name, &call.args); + let (output, ok, img) = match place { BrowserAt::Device(host) => match crate::remote::run_on_device(&host, &call.name, &call.args, &job.via_name) { Ok(v) => (v["text"].as_str().unwrap_or("").to_string(), v["ok"].as_bool().unwrap_or(false), v["image"].as_str().map(String::from)), Err(e) => (e, false, None), @@ -454,8 +487,10 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< BrowserAt::Box => crate::botbrowser::in_box(|| web::run_full(&call.name, &call.args, &cancel)), BrowserAt::Here => web::run_full(&call.name, &call.args, &cancel), }; + // Which computer's browser it was, like every other step. + let output = if on.is_empty() { output } else { format!("[{on}] {output}") }; send(StreamEvent::ToolStatus(call.id.clone(), if ok { ToolStatus::Done } else { ToolStatus::Failed })); - let mut result = Message::tool_result(&call.id, &call.name, &output); + let mut result = Message::tool_result(&call.id, &call.name, ¬ed(&moved, &call.id, &output)); result.images.extend(img); send(StreamEvent::ToolResult(result.clone())); history.push(result); @@ -483,7 +518,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< // 生活卡片: drawn by the window from the call itself. if call.name == "show_card" { send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Done)); - let result = Message::tool_result(&call.id, &call.name, "卡片已顯示給使用者。"); + let result = Message::tool_result(&call.id, &call.name, ¬ed(&moved, &call.id, "卡片已顯示給使用者。")); send(StreamEvent::ToolResult(result.clone())); history.push(result); continue; @@ -493,7 +528,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< let emoji: String = tools::args_of(&call.args)["emoji"].as_str().unwrap_or("👍").chars().take(4).collect(); send(StreamEvent::React(emoji.clone())); send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Done)); - let result = Message::tool_result(&call.id, &call.name, &format!("已在使用者的訊息上按了 {emoji}。")); + let result = Message::tool_result(&call.id, &call.name, ¬ed(&moved, &call.id, &format!("已在使用者的訊息上按了 {emoji}。"))); send(StreamEvent::ToolResult(result.clone())); history.push(result); continue; @@ -510,7 +545,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< }, Ok(tools::Target { place: tools::Place::Sealed { root }, .. }) => Ok(tools::resolve(&root, &path)), Ok(tools::Target { place: tools::Place::Local { workdir }, .. }) => Ok(tools::resolve(&workdir, &path)), - Ok(_) => Err("這台電腦上的檔案不能直接傳,請先複製到 Bot 的電腦。".to_string()), + Ok(_) => Err(format!("這台電腦上的檔案不能直接傳,請先複製到「{}」。", job.roles.own)), Err(e) => Err(e), }; // Secrets (SSH keys, the app's own data folder, .env…) never ride into the chat, @@ -525,7 +560,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< Err(e) => (e, false), }; send(StreamEvent::ToolStatus(call.id.clone(), if ok { ToolStatus::Done } else { ToolStatus::Failed })); - let result = Message::tool_result(&call.id, &call.name, &output); + let result = Message::tool_result(&call.id, &call.name, ¬ed(&moved, &call.id, &output)); send(StreamEvent::ToolResult(result.clone())); history.push(result); continue; @@ -542,12 +577,34 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< }, Err(_) => None, }; + // On a server nobody sits at its screen: a link opens in the browser of the + // computer the person is using; a file is sent into the chat instead. + let person_host = job.roles.person.as_ref().and_then(|n| job.computers.iter().find(|t| &t.name == n)).and_then(|t| match &t.place { + tools::Place::Device { host } => Some(host.clone()), + _ => None, + }); + if job.roles.server { + let web = path.starts_with("http://") || path.starts_with("https://"); + let (output, ok) = match (&person_host, web) { + (Some(host), true) => match crate::remote::run_on_device(host, "browser_open", &serde_json::json!({"url": path}).to_string(), &job.via_name) { + Ok(v) => (format!("[{}] {}", job.roles.person.clone().unwrap_or_default(), v["text"].as_str().unwrap_or("")), v["ok"].as_bool().unwrap_or(false)), + Err(e) => (e, false), + }, + (None, true) => ("不知道使用者現在在用哪台電腦,沒辦法打開給他看。把網址直接寫在回覆裡。".to_string(), false), + (_, false) => ("AnyBot 在伺服器上,檔案沒辦法直接在使用者的螢幕打開。改用 send_file 把檔案傳到聊天裡,使用者點一下就能看。".to_string(), false), + }; + send(StreamEvent::ToolStatus(call.id.clone(), if ok { ToolStatus::Done } else { ToolStatus::Failed })); + let result = Message::tool_result(&call.id, &call.name, ¬ed(&moved, &call.id, &output)); + send(StreamEvent::ToolResult(result.clone())); + history.push(result); + continue; + } let local = match (&on_box, target_for(&job, &call.args)) { (Some(p), _) => crate::cloudbox::copy_out(p), (None, Ok(tools::Target { place: tools::Place::Sealed { root }, .. })) => Ok(tools::resolve(&root, &path)), (None, Ok(tools::Target { place: tools::Place::Local { workdir }, .. })) => Ok(tools::resolve(&workdir, &path)), (None, _) if path.starts_with("http://") || path.starts_with("https://") => Ok(std::path::PathBuf::from(&path)), - _ => Err("這台電腦上的檔案不能直接打開給使用者看,請先複製到 Bot 的電腦或這台 Mac。".to_string()), + _ => Err(format!("這台電腦上的檔案不能直接打開給使用者看,請先複製到「{}」或這台電腦。", job.roles.own)), }; // Secrets never open on the person's screen either. let local = local.and_then(|p| if tools::is_ignored(&p) || tools::is_ignored(std::path::Path::new(&path)) { Err("這個檔案是機密的,不能打開。".to_string()) } else { Ok(p) }); @@ -560,7 +617,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< Err(e) => (e, false), }; send(StreamEvent::ToolStatus(call.id.clone(), if ok { ToolStatus::Done } else { ToolStatus::Failed })); - let result = Message::tool_result(&call.id, &call.name, &output); + let result = Message::tool_result(&call.id, &call.name, ¬ed(&moved, &call.id, &output)); send(StreamEvent::ToolResult(result.clone())); history.push(result); continue; @@ -583,7 +640,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< } }; send(StreamEvent::ToolStatus(call.id.clone(), status)); - let result = Message::tool_result(&call.id, &call.name, &output); + let result = Message::tool_result(&call.id, &call.name, ¬ed(&moved, &call.id, &output)); send(StreamEvent::ToolResult(result.clone())); history.push(result); continue; @@ -618,7 +675,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< Ok(tools::Target { place: tools::Place::Box { dir }, .. }) => crate::cloudbox::copy_out(&if path.starts_with('/') { path.clone() } else { format!("{dir}/{path}") }), Ok(tools::Target { place: tools::Place::Sealed { root }, .. }) => Ok(tools::resolve(&root, &path)), Ok(tools::Target { place: tools::Place::Local { workdir }, .. }) => Ok(tools::resolve(&workdir, &path)), - Ok(_) => Err("這台電腦上的檔案不能直接發布,請先複製到 Bot 的電腦。".to_string()), + Ok(_) => Err(format!("這台電腦上的檔案不能直接發布,請先複製到「{}」。", job.roles.own)), Err(e) => Err(e), }; match local.and_then(|p| crate::remote::publish_site(&name, &p)) { @@ -627,7 +684,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< } }; send(StreamEvent::ToolStatus(call.id.clone(), status)); - let result = Message::tool_result(&call.id, &call.name, &output); + let result = Message::tool_result(&call.id, &call.name, ¬ed(&moved, &call.id, &output)); send(StreamEvent::ToolResult(result.clone())); history.push(result); continue; @@ -638,7 +695,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< if call.name == crate::vault::LIST_TOOL { let output = crate::vault::list_for_bot(&call.args); send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Done)); - let result = Message::tool_result(&call.id, &call.name, &output); + let result = Message::tool_result(&call.id, &call.name, ¬ed(&moved, &call.id, &output)); send(StreamEvent::ToolResult(result.clone())); history.push(result); continue; @@ -696,7 +753,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< } }; send(StreamEvent::ToolStatus(call.id.clone(), status)); - let result = Message::tool_result(&call.id, &call.name, &output); + let result = Message::tool_result(&call.id, &call.name, ¬ed(&moved, &call.id, &output)); send(StreamEvent::ToolResult(result.clone())); history.push(result); continue; @@ -731,29 +788,30 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< ("使用者拒絕了這個操作。請不要再試同樣的操作,改問使用者想怎麼做。".to_string(), ToolStatus::Denied, None) }; send(StreamEvent::ToolStatus(call.id.clone(), status)); - let mut result = Message::tool_result(&call.id, &call.name, &output); + let mut result = Message::tool_result(&call.id, &call.name, ¬ed(&moved, &call.id, &output)); result.images.extend(img); send(StreamEvent::ToolResult(result.clone())); history.push(result); continue; } - // The browser tools use the person's own browser, so always this Mac. - // …and so does screen control: it's the person's own screen. - let person_device = job.computers.iter().find(|t| t.name == crate::model::THIS_COMPUTER_NAME && matches!(t.place, tools::Place::Device { .. })).cloned(); + // The person's own apps and everyday browser are on the computer they are using: + // the one they name, else the one they typed on (else this one, when it is theirs). + let person_target = || -> Result { + if names_a_computer(&call.args) { + return target_for(&job, &call.args); + } + job.roles + .person + .as_ref() + .and_then(|n| job.computers.iter().find(|t| &t.name == n)) + .cloned() + .ok_or_else(|| format!("不知道使用者現在在用哪台電腦。請問他要在哪台做(可以用:{})。", job.computers.iter().map(|t| t.name.as_str()).collect::>().join("、"))) + }; let target = if is_screen { screen_target(&job, &call.args) - } else if is_web && device_named(&job, &call.args).is_some() { - // The browser on another computer of the account, as named. - target_for(&job, &call.args) - } else if is_web && call.name.starts_with("browser_") && person_device.is_some() && !names_a_computer(&call.args) { - // A brain on a server: the browser is the person's computer's (the one they - // can see), unless the server's own is named. - Ok(person_device.clone().unwrap()) - } else if is_web && call.name.starts_with("browser_") && !crate::botbrowser::available() && job.computers.iter().any(|t| matches!(t.place, tools::Place::Device { .. })) { - // No browser here (a brain on a server): the 手腳 computer's. - job.computers.iter().find(|t| matches!(t.place, tools::Place::Device { .. })).cloned().ok_or_else(|| "找不到這台電腦".to_string()) - } else if is_mac || web::uses_this_computer(&call.name, &call.args) { - job.computers.iter().find(|t| t.name == crate::model::THIS_COMPUTER_NAME).cloned().ok_or_else(|| "找不到這台電腦".to_string()) + } else if is_mac || is_web { + // (Other browser steps went their own way above; this is the person's own Chrome.) + person_target() } else { target_for(&job, &call.args) }; @@ -790,17 +848,17 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< } // An admin lock (policy) refuses outright instead of asking. if gate.is_ok() && matches!(t.place, tools::Place::Local { .. }) && job.local_locked { - gate = Err(format!("「在這台電腦上執行」設成了永不允許(或被管理員關閉),不能在「{}」上執行。請改用「{}」。", t.name, crate::model::BOT_COMPUTER_NAME)); + gate = Err(format!("「在這台電腦上執行」設成了永不允許(或被管理員關閉),不能在「{}」上執行。請改用「{}」。", t.name, job.roles.own)); } if gate.is_ok() && matches!(t.place, tools::Place::Local { .. }) && !local_on { - let reason = format!("在你的電腦上執行任務,而不只是在它自己的電腦上:{}{what}\n(在 {} 上)", if is_web { format!("{} ", web::label(&call.name)) } else { String::new() }, t.name); + let reason = format!("在「{}」上執行任務,而不只是在它自己的電腦上:{}{what}", t.name, if is_web { format!("{} ", web::label(&call.name)) } else { String::new() }); match ask_person(ApprovalKind::EnableThisComputer, reason, "local:on".into()) { ApprovalAnswer::Once => said_yes = true, ApprovalAnswer::Always(_) => { local_on = true; said_yes = true; } - ApprovalAnswer::Reject => gate = Err(format!("使用者沒有允許在「{}」上執行。可以改用「{}」,或問使用者。", t.name, crate::model::BOT_COMPUTER_NAME)), + ApprovalAnswer::Reject => gate = Err(format!("使用者沒有允許在「{}」上執行。可以改用「{}」,或問使用者。", t.name, job.roles.own)), } } } @@ -934,9 +992,9 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< (Ok(()), Ok(t)) => { send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running)); let ids = tools::RunIds { conversation: bot_id.to_string(), request: call.id.clone() }; - let (out, ok) = if is_mac { + let (out, ok) = if is_mac && on_device.is_none() { crate::macapps::run(&call.name, &call.args) - } else if let (true, Some(host)) = (is_screen || is_web, on_device.as_ref()) { + } else if let (true, Some(host)) = (is_screen || is_web || is_mac, on_device.as_ref()) { match crate::remote::run_on_device(host, &call.name, &call.args, &job.via_name) { Ok(v) => { image = v["image"].as_str().map(String::from); @@ -967,7 +1025,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< (Err(x), _) => x, }; send(StreamEvent::ToolStatus(call.id.clone(), status)); - let mut result = Message::tool_result(&call.id, &call.name, &output); + let mut result = Message::tool_result(&call.id, &call.name, ¬ed(&moved, &call.id, &output)); result.images.extend(image); send(StreamEvent::ToolResult(result.clone())); history.push(result); @@ -988,6 +1046,28 @@ mod tests { use crate::model::ToolCall; use crate::providers::ProviderKind; + + /// A job with nothing in it, for tests that only need a few fields. + fn test_job() -> Job { + Job { + req: ChatRequest { kind: ProviderKind::Custom, base_url: String::new(), api_key: String::new(), model: String::new(), system: String::new(), history: vec![], tools: vec![], session: String::new() }, + computers: vec![], + default_computer: String::new(), + roles: tools::Roles::default(), + local_execution: false, + grants: vec![], + context_tokens: DEFAULT_CONTEXT_TOKENS, + via: String::new(), + via_name: String::new(), + tools_enabled: true, + direct: Arc::new(AtomicBool::new(false)), + local_locked: false, + steer: Arc::new(Mutex::new(vec![])), + review: None, + demos: vec![], + } + } + #[test] fn computer_words_need_word_boundaries() { // "mac" as its own word names this computer; inside longer words (macOS, a device @@ -1000,9 +1080,51 @@ mod tests { assert!(finds("我的電腦好慢", "我的電腦")); assert!(!finds("你的虛擬電腦很吵", "bot 的電腦")); assert!(finds("在 bot 的電腦跑", "bot 的電腦")); - let t = tools::Target { name: crate::model::THIS_COMPUTER_NAME.into(), place: tools::Place::Local { workdir: Default::default() } }; - assert!(mentions("幫我在 Mac 截圖", &t)); - assert!(!mentions("在 macOS 的登入機制下,幫 Bot 的電腦截圖", &t)); + let roles = tools::Roles { own: "Master 的電腦".into(), person: Some("王小明的Mac mini".into()), server: true }; + let mac = tools::Target { name: "王小明的Mac mini".into(), place: tools::Place::Device { host: "m".into() } }; + assert!(mentions("幫我在 Mac 截圖", &mac, &roles)); + assert!(!mentions("在 macOS 的登入機制下,幫 Bot 的電腦截圖", &mac, &roles)); + } + + #[test] + fn every_computer_answers_to_its_own_name() { + // One name per computer: the bot's own goes by the bot's name, the others by theirs, and + // the everyday words point at the right one (「我的電腦」 = where the person typed). + let roles = tools::Roles { own: "Master 的電腦".into(), person: Some("王小明的Mac mini".into()), server: true }; + let own = tools::Target { name: "Master 的電腦".into(), place: tools::Place::Box { dir: String::new() } }; + let mac = tools::Target { name: "王小明的Mac mini".into(), place: tools::Place::Device { host: "m".into() } }; + let desk = tools::Target { name: "ai".into(), place: tools::Place::Device { host: "a".into() } }; + let server = tools::Target { name: "雲端大腦".into(), place: tools::Place::Local { workdir: Default::default() } }; + assert!(mentions("在 Master 的電腦打開 Threads", &own, &roles)); + assert!(mentions("用master的電腦", &own, &roles)); + assert!(mentions("你都用 Bot的電腦", &own, &roles)); + assert!(mentions("在 Mac mini 上開", &mac, &roles)); + assert!(mentions("截圖我的電腦", &mac, &roles)); + assert!(!mentions("截圖我的電腦", &desk, &roles)); + assert!(!mentions("截圖我的電腦", &own, &roles)); + assert!(mentions("在 ai 上跑", &desk, &roles)); + assert!(!mentions("用 AI 幫我想標題", &mac, &roles)); + assert!(mentions("在伺服器上看看磁碟", &server, &roles)); + } + + #[test] + fn a_step_moved_back_to_the_chosen_computer_says_so() { + let roles = tools::Roles { own: "Master 的電腦".into(), person: Some("王小明的Mac mini".into()), server: true }; + let job = Job { + computers: vec![ + tools::Target { name: "Master 的電腦".into(), place: tools::Place::Box { dir: String::new() } }, + tools::Target { name: "王小明的Mac mini".into(), place: tools::Place::Device { host: "m".into() } }, + ], + default_computer: "Master 的電腦".into(), + roles, + ..test_job() + }; + // Not asked for: back to the chosen one, with a word for the bot. + let (args, note) = keep_to_chosen(&job, &[Message::user("幫我截圖")], r#"{"computer":"王小明的Mac mini"}"#); + assert!(args.contains("Master 的電腦") && note.unwrap().contains("王小明的Mac mini")); + // Named by the person: it stays. + let (args, note) = keep_to_chosen(&job, &[Message::user("看一下我的電腦畫面")], r#"{"computer":"王小明的Mac mini"}"#); + assert!(args.contains("王小明的Mac mini") && note.is_none()); } #[test] @@ -1069,6 +1191,7 @@ mod tests { req: ChatRequest { kind: ProviderKind::Custom, base_url: format!("http://127.0.0.1:{port}/v1"), api_key: String::new(), model: "m".into(), system: String::new(), history: vec![Message::user("有什麼檔案?")], tools: tools::specs(), session: String::new() }, computers: vec![tools::Target { name: crate::model::THIS_COMPUTER_NAME.into(), place: tools::Place::Local { workdir: w } }], default_computer: crate::model::THIS_COMPUTER_NAME.into(), + roles: tools::Roles { own: crate::model::BOT_COMPUTER_NAME.into(), person: Some(crate::model::THIS_COMPUTER_NAME.into()), server: false }, local_execution: true, grants: vec![], context_tokens: DEFAULT_CONTEXT_TOKENS, @@ -1140,6 +1263,7 @@ mod tests { req: ChatRequest { kind: ProviderKind::Custom, base_url: format!("http://127.0.0.1:{port}/v1"), api_key: String::new(), model: "m".into(), system: String::new(), history: vec![Message::user("打招呼")], tools: vec![], session: String::new() }, computers: vec![], default_computer: crate::model::BOT_COMPUTER_NAME.into(), + roles: tools::Roles { own: crate::model::BOT_COMPUTER_NAME.into(), person: Some(crate::model::THIS_COMPUTER_NAME.into()), server: false }, local_execution: false, grants: vec![], context_tokens: DEFAULT_CONTEXT_TOKENS, @@ -1217,6 +1341,7 @@ mod tests { req: ChatRequest { kind: ProviderKind::Custom, base_url: format!("http://127.0.0.1:{port}/v1"), api_key: String::new(), model: "m".into(), system: String::new(), history: vec![Message::user("看看我的電腦")], tools: tools::specs(), session: String::new() }, computers: vec![tools::Target { name: crate::model::THIS_COMPUTER_NAME.into(), place: tools::Place::Local { workdir: w } }], default_computer: crate::model::THIS_COMPUTER_NAME.into(), + roles: tools::Roles { own: crate::model::BOT_COMPUTER_NAME.into(), person: Some(crate::model::THIS_COMPUTER_NAME.into()), server: false }, local_execution: false, tools_enabled: false, direct: Arc::new(AtomicBool::new(false)), @@ -1290,6 +1415,7 @@ mod tests { req: ChatRequest { kind: ProviderKind::Custom, base_url: format!("http://127.0.0.1:{port}/v1"), api_key: String::new(), model: "m".into(), system: String::new(), history: vec![Message::user("做一個檔案")], tools: tools::specs(), session: String::new() }, computers: vec![tools::Target { name: crate::model::THIS_COMPUTER_NAME.into(), place: tools::Place::Local { workdir: w.clone() } }], default_computer: crate::model::THIS_COMPUTER_NAME.into(), + roles: tools::Roles { own: crate::model::BOT_COMPUTER_NAME.into(), person: Some(crate::model::THIS_COMPUTER_NAME.into()), server: false }, local_execution: false, tools_enabled: false, direct: Arc::new(AtomicBool::new(true)), diff --git a/src/app.rs b/src/app.rs index 0c8bc84..8f2b66b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -180,6 +180,9 @@ pub struct App { rm: remote_ui::RemoteUi, /// Running as `anybot --serve` (no window; the phone is the screen). headless: bool, + /// The computer the person last typed to a bot on: an account computer's id, "" for this + /// one, None when not known (the phone isn't a computer). It is "我的電腦" to the bots. + typed_on: Option, /// 右側面板 and its tab (None = closed). panel: Option, /// When each emoji reaction appeared (for its pop) and when each message first showed (fade-in). @@ -277,6 +280,7 @@ impl App { msg_index: 0, rm: Default::default(), headless: false, + typed_on: None, panel: None, popped: HashMap::new(), appeared: HashMap::new(), @@ -638,6 +642,8 @@ impl App { return; } self.drafts.insert(bot_id, String::new()); + // Typed here: this computer is the person's. + self.typed_on = Some(String::new()); // While it works, a new message steers the running turn (the original's `turnSteer`: // it doesn't count as a new turn) and reaches the model at its next step. if let Some(l) = self.streams.get(&bot_id) { @@ -685,24 +691,42 @@ impl App { // Computer tools are always offered; if they're off for this bot (or this Mac isn't // allowed yet), using one pops up a request instead of failing quietly. let computers = self.targets_for(&b); + let roles = self.roles_for(&b, &computers); let names: Vec = computers.iter().map(|t| t.name.clone()).collect(); - let default_computer = if names.contains(&b.default_computer) { b.default_computer.clone() } else { crate::model::BOT_COMPUTER_NAME.to_string() }; + let (default_computer, missing_computer) = Self::chosen_computer(&b, &computers, &roles); 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(); - 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.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()); - if cfg!(target_os = "macos") { + // Browsers: the bot's own computer's Chromium, this computer's AnyBot browser, and the + // account's other computers' ones (each with its own logins). + let browsers: Vec = computers + .iter() + .filter(|t| match t.place { + tools::Place::Box { .. } => crate::cloudbox::available(), + tools::Place::Local { .. } => crate::botbrowser::available(), + tools::Place::Device { .. } => true, + _ => false, + }) + .map(|t| t.name.clone()) + .collect(); + let browser_default = if browsers.contains(&default_computer) { default_computer.clone() } else { roles.person.clone().filter(|p| browsers.contains(p)).or_else(|| browsers.first().cloned()).unwrap_or_default() }; + tool_specs.extend(web::specs_for(&browsers, &browser_default)); + // Screens: the bot's own desktop (when it has one), this computer's (when it has one), and + // the account's other computers (their own AnyBot looks and clicks there). + let screens: Vec = computers + .iter() + .filter(|t| match t.place { + tools::Place::Box { .. } => crate::cloudbox::available(), + tools::Place::Local { .. } => crate::computer::screen_supported(), + tools::Place::Device { .. } => true, + _ => false, + }) + .map(|t| t.name.clone()) + .collect(); + // The person's Mac apps (mail, notes, calendar…): here on a Mac, or on the computer the + // person is using when this is a server. + let person_is_device = roles.person.as_ref().map(|n| computers.iter().any(|t| &t.name == n && matches!(t.place, tools::Place::Device { .. }))).unwrap_or(false); + let mac_apps = cfg!(target_os = "macos") || (roles.server && person_is_device); + if mac_apps { tool_specs.extend(crate::macapps::specs().into_iter().filter(|t| self.state.messages_send || t["name"] != crate::macapps::SEND_MESSAGE)); } tool_specs.extend(crate::mcp::specs()); @@ -741,7 +765,7 @@ impl App { api_key: providers::resolve_key(b.provider, &ps.api_key), model: b.model.clone(), system: { - let mut sys = system_prompt(&b, &self.state, &computers, &default_computer); + let mut sys = system_prompt(&b, &self.state, &computers, &default_computer, &roles, missing_computer.as_deref(), &self.computer_lines(&computers, &roles), mac_apps); if speaker.is_some() { let names: Vec = conv.members.iter().filter_map(|m| self.state.bot(*m)).map(|x| x.name.clone()).collect(); sys.push_str(&format!("\n\n你在群組聊天「{}」裡,成員依序回答:{}。其他成員的發言以【名字】標示。只回答你擅長的部分,補充不同的觀點,不要重複別人說過的;沒有要補充就簡短說一句。", conv.name, names.join("、"))); @@ -767,6 +791,7 @@ impl App { req, computers, default_computer, + roles, local_execution: self.local_on(), local_locked: !self.managed.local.allows() || self.state.local_never, tools_enabled: b.tools_enabled, @@ -790,73 +815,202 @@ impl App { self.mark(); } - /// The computers a bot can be told to use (its own, this Mac, then SSH ones). - fn targets_for(&self, b: &Bot) -> Vec { - let mut v = vec![ - tools::Target { - name: crate::model::BOT_COMPUTER_NAME.into(), - place: if crate::cloudbox::available() { tools::Place::Box { dir: crate::cloudbox::bot_dir(b.id) } } else { tools::Place::Sealed { root: crate::model::bot_computer_dir(b.id) } }, - }, - tools::Target { - name: crate::model::THIS_COMPUTER_NAME.into(), - place: tools::Place::Local { workdir: tools::expand_home(if b.workdir.trim().is_empty() { "~" } else { b.workdir.trim() }) }, - }, - ]; - 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(); - } - } else if self.headless { - // A server brain with no hands connected: the machine it runs on is a server, - // not anyone's computer. Wearing the name 你的電腦 here made bots "work on your - // computer" on the server while the person watched their own untouched desktop. - for t in v.iter_mut() { - if matches!(t.place, tools::Place::Local { .. }) { - t.name = crate::model::BRAIN_HOST_NAME.into(); - } - } + /// The computers a bot can be told to use, each under its own name: the bot's own + /// (「Master 的電腦」), this computer, the SSH ones, then the account's other computers. + pub(super) fn targets_for(&self, b: &Bot) -> Vec { + let mut v = vec![tools::Target { + name: crate::model::own_computer_name(&b.name), + place: if crate::cloudbox::available() { tools::Place::Box { dir: crate::cloudbox::bot_dir(b.id) } } else { tools::Place::Sealed { root: crate::model::bot_computer_dir(b.id) } }, + }]; + // Two computers never share a name (the bot and the person name them to choose). + let add = |v: &mut Vec, mut t: tools::Target| { + let base = t.name.clone(); + let mut n = 2; + while v.iter().any(|x| x.name == t.name) { + t.name = format!("{base} ({n})"); + n += 1; } + v.push(t); + }; + add(&mut v, tools::Target { name: self.state.this_computer_name(), place: tools::Place::Local { workdir: tools::expand_home(if b.workdir.trim().is_empty() { "~" } else { b.workdir.trim() }) } }); + for c in &self.state.remote_computers { + add(&mut v, 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. + for d in self.linked_devices() { + add(&mut v, d); } 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!("{}:這個 Bot 自己的 Linux 桌面(AI 的電腦,不是使用者的電腦),一律可用", 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 會問使用者)" } + /// Which of these computers is the bot's own, which one the person is using, and whether + /// this AnyBot is a server. The person's computer is the one they last typed on; a server + /// that hasn't heard from any yet takes the account's only other computer, if there is one. + pub(super) fn roles_for(&self, b: &Bot, targets: &[tools::Target]) -> tools::Roles { + let server = self.headless; + let local = targets.iter().find(|t| matches!(t.place, tools::Place::Local { .. })).map(|t| t.name.clone()); + let device = |h: &str| targets.iter().find(|t| matches!(&t.place, tools::Place::Device { host } if host == h)).map(|t| t.name.clone()); + let person = match self.typed_on.as_deref() { + Some(h) if !h.is_empty() => device(h).or_else(|| if server { None } else { local.clone() }), + _ if !server => local, + // Not heard yet (or only the phone): when the account has one other computer, that + // is it; with more, nobody guesses (the bot asks which one). + _ => { + let mut devices = targets.iter().filter(|t| matches!(t.place, tools::Place::Device { .. })); + match (devices.next(), devices.next()) { + (Some(only), None) => Some(only.name.clone()), + _ => None, + } + } + }; + tools::Roles { own: crate::model::own_computer_name(&b.name), person, server } + } + + /// The computer the person chose for this bot, by its name now (the bot's own is kept under a + /// fixed key so renaming the bot keeps it), and the chosen one when it isn't connected. + pub(super) fn chosen_computer(b: &Bot, targets: &[tools::Target], roles: &tools::Roles) -> (String, Option) { + let want = b.default_computer.trim(); + if want.is_empty() || want == crate::model::BOT_COMPUTER_NAME || want == roles.own { + return (roles.own.clone(), None); + } + // Kept by older versions: the person's own computer. + if want == crate::model::THIS_COMPUTER_NAME { + return (roles.person.clone().unwrap_or_else(|| roles.own.clone()), None); + } + if targets.iter().any(|t| t.name == want) { + return (want.to_string(), None); + } + (roles.own.clone(), Some(want.to_string())) + } + + /// What to keep in `default_computer` for a computer chosen by name. + pub(super) fn computer_to_keep(b: &Bot, name: &str) -> String { + if name == crate::model::own_computer_name(&b.name) { + crate::model::BOT_COMPUTER_NAME.to_string() + } else { + name.to_string() + } + } + + /// The computers to choose from for a bot (name, what it is) and the chosen one. A screen of a + /// brain asks the brain (only it knows the account's computers and which one is chosen). + pub(super) fn computer_choices(&self, b: &Bot) -> (Vec<(String, String)>, String) { + if self.mir.on { + let own = crate::model::own_computer_name(&b.name); + return match self.mir.computers.get(&b.id) { + Some(c) => ( + c["names"].as_array().map(|a| a.iter().filter_map(|x| Some((x["name"].as_str()?.to_string(), x["kind"].as_str().unwrap_or("").to_string()))).collect()).unwrap_or_default(), + c["current"].as_str().unwrap_or(&own).to_string(), ), - 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) + None => (vec![(own.clone(), "own".into())], own), + }; + } + let t = self.targets_for(b); + let r = self.roles_for(b, &t); + let (current, _) = Self::chosen_computer(b, &t, &r); + (t.iter().map(|x| (x.name.clone(), mirror_ui::computer_kind(x, &r).to_string())).collect(), current) + } + + /// Choose the computer a bot works on (a screen sends it to the brain with its other edits). + pub(super) fn set_computer(&mut self, bot_id: u64, name: &str) { + if let Some(b) = self.state.bot_mut(bot_id) { + let keep = Self::computer_to_keep(b, name); + if b.default_computer != keep { + b.default_computer = keep; + self.mark(); + } + } + // Shown at once on a screen (the brain's answer follows). + if let Some(c) = self.mir.computers.get_mut(&bot_id) { + c["current"] = serde_json::json!(name); + } + } + + /// 查看電腦 and the instructions: each computer by its name, what it is, in plain words. + pub(super) fn computer_lines(&self, targets: &[tools::Target], roles: &tools::Roles) -> Vec { + targets + .iter() + .map(|t| { + let yours = roles.person.as_deref() == Some(t.name.as_str()); + match &t.place { + tools::Place::Box { dir } => { + let secrets = crate::cloudbox::secret_names(); + format!( + "「{}」:你自己的 Linux 電腦(Debian,有桌面和 Chromium;你的資料夾是 {dir},可以 sudo 安裝套件),不是使用者的電腦,一律可用,需要時會自動喚醒。{}", + t.name, + if secrets.is_empty() { String::new() } else { format!("使用者設好的環境密鑰(已是環境變數,值不要印出來):{}。", secrets.join("、")) } + ) } + tools::Place::Sealed { root } => format!("「{}」:你自己的電腦(獨立資料夾 {},看不到使用者的檔案;沒有螢幕),一律可用。", t.name, root.display()), + tools::Place::Local { workdir } if roles.server => format!("「{}」:AnyBot 大腦所在的伺服器(起始資料夾 {}),不是使用者在用的電腦;只有使用者指名它才用。", t.name, workdir.display()), + tools::Place::Local { workdir } => format!( + "「{}」:AnyBot 開著的這台{}(起始資料夾 {}){},{}。", + t.name, + if cfg!(target_os = "macos") { " Mac" } else { "電腦(快捷鍵用 Ctrl,不是 ⌘)" }, + workdir.display(), + if yours { ",使用者現在就在用這台" } else { "" }, + if self.state.local_execution { "已允許" } else { "還沒預先允許(直接用工具,App 會問使用者)" } + ), + tools::Place::Ssh { target, .. } => format!("「{}」:使用者的另一台電腦,透過 SSH 連到 {target}。", t.name), + tools::Place::Device { .. } => format!( + "「{}」:使用者帳號裡的電腦,透過它上面開著的 AnyBot 操作(檔案、指令、瀏覽器、看畫面和點擊){}。", + t.name, + if yours { ",使用者現在就是在這台跟你說話" } else { "" } + ), } }) - .collect::>() - .join("\n") + .collect() + } + + /// 查看電腦: the computers this bot really has, in plain words. + fn computers_text(&self, b: &Bot) -> String { + let targets = self.targets_for(b); + let roles = self.roles_for(b, &targets); + let (chosen, missing) = Self::chosen_computer(b, &targets, &roles); + let mut lines = self.computer_lines(&targets, &roles); + lines.push(format!("使用者選的電腦:「{chosen}」")); + if let Some(m) = missing { + lines.push(format!("(使用者原本選的「{m}」現在沒有連線:那台的 AnyBot 沒開或沒登入。)")); + } + lines.join("\n") + } + + /// update_bot with a computer: the names are this bot's computers (the account's ones too), + /// which only the app knows; the rest of the change goes through appctl as usual. + fn update_bot_computer(&mut self, bot_id: u64, args: &str) -> appctl::Outcome { + let mut v: serde_json::Value = serde_json::from_str(args).unwrap_or_default(); + let want = v["default_computer"].as_str().unwrap_or("").trim().to_string(); + let fail = |text: String| appctl::Outcome { text, ok: false, wake: vec![], theme_changed: false, page: None }; + let target = match v["bot"].as_str().map(str::trim).filter(|n| !n.is_empty()) { + Some(n) => match self.state.bots.iter().find(|b| b.name == n) { + Some(b) => b.id, + None => return fail(format!("找不到叫「{n}」的 Bot")), + }, + None => bot_id, + }; + let Some(b) = self.state.bot(target).cloned() else { return fail("找不到這個 Bot".into()) }; + let targets = self.targets_for(&b); + let roles = self.roles_for(&b, &targets); + let name = if want == crate::model::BOT_COMPUTER_NAME || want == crate::model::THIS_COMPUTER_NAME { Self::chosen_computer(&Bot { default_computer: want.clone(), ..b.clone() }, &targets, &roles).0 } else { want.clone() }; + if !targets.iter().any(|t| t.name == name) { + return fail(format!("沒有「{want}」這台電腦。可以用:{}", targets.iter().map(|t| t.name.as_str()).collect::>().join("、"))); + } + if let Some(bm) = self.state.bot_mut(target) { + bm.default_computer = Self::computer_to_keep(&b, &name); + } + self.mark(); + if let Some(o) = v.as_object_mut() { + o.remove("default_computer"); + } + let more = v.as_object().map(|o| o.keys().any(|k| k != "bot")).unwrap_or(false); + let note = format!("已把「{}」的電腦改成「{name}」。", b.name); + if more { + let mut out = appctl::apply(&mut self.state, bot_id, "update_bot", &v.to_string()); + out.text = format!("{note}{}", out.text); + out + } else { + appctl::Outcome { text: note, ok: true, wake: vec![], theme_changed: false, page: None } + } } /// Record how a turn ended (success / cancelled / error) and clear its live state. @@ -977,6 +1131,8 @@ impl App { 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 if call.name == "update_bot" && serde_json::from_str::(&call.args).map(|v| v["default_computer"].is_string()).unwrap_or(false) { + self.update_bot_computer(bot_id, &call.args) } else { appctl::apply(&mut self.state, bot_id, &call.name, &call.args) }; @@ -1737,11 +1893,17 @@ impl App { ui.close(); } if menu_ui::item(ui, p, Icon::Eraser, "清除聊天記錄").clicked() { - self.stop(id); - if let Some(b) = self.state.bot_mut(id) { - b.messages.clear(); + if self.mir.on { + // The chat lives on the brain: clearing only this copy left the brain sure this + // screen still had it, and its next changes landed in the wrong place. + self.mirror_act(serde_json::json!({"bot": id, "action": "clear"})); + } else { + self.stop(id); + if let Some(b) = self.state.bot_mut(id) { + b.messages.clear(); + } + self.mark(); } - self.mark(); ui.close(); } if menu_ui::danger(ui, p, Icon::Trash, "刪除").clicked() { @@ -2169,6 +2331,10 @@ impl App { ui.label(RichText::new(format!("{}{}", t("成員:"), names.join("、"))).size(FONT_XS).color(p.text2)); } }); + // 在哪台電腦做事: always in sight, one click to change. + if bot.members.is_empty() { + self.computer_chip(ui, bot); + } let right_w = 28.0 * 2.0 + 8.0 + 4.0; let text_w = (ui.available_width() - right_w).max(60.0); let hint = if self.mir.on && !self.mir.online { t("連不到大腦,現在只能看") } else { t("傳訊息給 {name}").replace("{name}", &bot.name) }; @@ -2437,6 +2603,38 @@ impl App { } } + /// The computer the bot works on, as a small button next to the message box; it opens the + /// list of computers (each by its name) to choose another. + fn computer_chip(&mut self, ui: &mut egui::Ui, bot: &Bot) { + let p = self.pal; + let (choices, current) = self.computer_choices(bot); + let short: String = if current.chars().count() > 14 { format!("{}…", current.chars().take(13).collect::()) } else { current.clone() }; + let chip = crate::icons::pill(ui, crate::icons::Icon::Monitor, &short, p.hover, p.text2, 28.0).on_hover_text(t("在「{name}」上做事 · 點一下換電腦").replace("{name}", ¤t)); + let mut pick: Option = None; + egui::Popup::menu(&chip).show(|ui| { + ui.set_min_width(menu_ui::MENU_W + 40.0); + ui.label(RichText::new(t("要 {name} 在哪台電腦做事").replace("{name}", &bot.name)).size(FONT_XS).color(p.text3)); + for (name, kind) in &choices { + let what = t(match kind.as_str() { + "own" => "它自己的電腦", + "person" => "你正在用的這台", + "this" => "這台電腦", + "server" => "大腦所在的伺服器", + "device" => "帳號裡的電腦", + _ => "SSH 電腦", + }); + let icon = if *name == current { crate::icons::Icon::Check } else { crate::icons::Icon::Monitor }; + if menu_ui::item_text(ui, p, Some(icon), &format!("{name} · {what}")).clicked() { + pick = Some(name.clone()); + ui.close(); + } + } + }); + if let Some(n) = pick { + self.set_computer(bot.id, &n); + } + } + fn model_picker(&mut self, ui: &mut egui::Ui, bot: &Bot) { let p = self.pal; let label = format!("{} · {}", short_provider(bot.provider), bot.model); @@ -2583,15 +2781,21 @@ impl App { ui.checkbox(&mut b.tools_enabled, t("讓這個 Bot 查看、修改檔案和執行指令")); if b.tools_enabled { ui.add_space(6.0); - form_row(ui, "預設電腦", p, |ui| { - let names = self.state.computer_names(); - egui::ComboBox::from_id_salt("default_computer").selected_text(b.default_computer.clone()).width(150.0).show_ui(ui, |ui| { - for n in names { - let label = if n == crate::model::THIS_COMPUTER_NAME && !self.state.local_execution { format!("{n}(要先在設定裡允許)") } else { n.clone() }; - ui.selectable_value(&mut b.default_computer, n, label); + form_row(ui, "做事的電腦", p, |ui| { + let (choices, current) = self.computer_choices(&b); + let mut pick = None; + egui::ComboBox::from_id_salt("default_computer").selected_text(current.clone()).width(190.0).show_ui(ui, |ui| { + for (n, kind) in choices { + let label = if kind == "this" && !self.state.local_execution { format!("{n}(要先在設定裡允許)") } else { n.clone() }; + if ui.selectable_label(n == current, label).clicked() { + pick = Some(n); + } } }); - ui.label(RichText::new(t("聊天時說「在我的 Mac 上」就會換")).size(11.5).color(p.text3)); + if let Some(n) = pick { + b.default_computer = Self::computer_to_keep(&b, &n); + } + ui.label(RichText::new(t("聊天時指名另一台(例如「在 Mac mini 上」)就會換")).size(11.5).color(p.text3)); }); form_row(ui, "起始資料夾", p, |ui| { ui.add(egui::TextEdit::singleline(&mut b.workdir).hint_text(t("在你的電腦上,例如 ~/Documents/專案")).desired_width(ui.available_width())); @@ -3680,7 +3884,8 @@ fn transcript_markdown(b: &Bot) -> String { } /// What the bot is told about itself, the app it lives in and the computers it can use. -fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_computer: &str) -> String { +#[allow(clippy::too_many_arguments)] +fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_computer: &str, roles: &tools::Roles, missing: Option<&str>, lines: &[String], mac_apps: bool) -> String { let mut s = format!("你是「{}」。", b.name); if !b.description.trim().is_empty() { s.push_str(&format!("你的角色:{}。", b.description.trim())); @@ -3689,24 +3894,17 @@ fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_compu s.push_str("你是使用者的主要 Bot:主要聯絡窗口,可以主動幫忙,也能用 message_bot 把工作交給其他 Bot。"); } let now = crate::routines::now_in(&st.timezone); - // Which screens can really be operated, named as the model must name them: the fixed - // wording promised "你的電腦(使用者的 Mac)" on server brains that have no such computer, - // and bots then "worked on your computer" on the server itself. + // Which screens can really be operated, each by its computer's name. let mut screens: Vec = Vec::new(); for t in computers { match &t.place { - tools::Place::Box { .. } => screens.push(format!("{}(AI 自己的 Linux 桌面,不是使用者的電腦)", t.name)), - tools::Place::Local { .. } => { - if crate::computer::screen_supported() { - screens.push(format!("{}(這台電腦自己的畫面)", t.name)); - } - } - tools::Place::Device { host } if t.name == crate::model::THIS_COMPUTER_NAME => screens.push(format!("{}(使用者自己的電腦「{}」的畫面)", t.name, crate::remote::peer_name(host))), - tools::Place::Device { .. } => screens.push(format!("{}(帳號裡另一台電腦的畫面)", t.name)), - tools::Place::Ssh { .. } | tools::Place::Sealed { .. } => {} + tools::Place::Box { .. } if crate::cloudbox::available() => screens.push(format!("「{}」", t.name)), + tools::Place::Local { .. } if crate::computer::screen_supported() => screens.push(format!("「{}」", t.name)), + tools::Place::Device { .. } => screens.push(format!("「{}」", t.name)), + _ => {} } } - let screens = if screens.is_empty() { "目前沒有任何可操作的螢幕".to_string() } else { screens.join(";") }; + let screens = if screens.is_empty() { "目前沒有任何可操作的螢幕".to_string() } else { format!("可操作的螢幕:{}", screens.join("、")) }; s.push_str(&format!( "\n\n現在是 {}({},時區 {})。\n\ 你在 AnyBot 這個 App 裡,可以直接操作 App:\n\ @@ -3723,11 +3921,12 @@ fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_compu - 如果讀到的是登入頁,請使用者在跳出來的 AnyBot 瀏覽器視窗登入一次(之後會記住),登入好再讀。絕對不要叫使用者去改瀏覽器的設定或選單(例如「允許來自 Apple 事件的 JavaScript」),那不需要。也不要說要從 Chrome 複製登入或 Cookie,沒有這個功能。\n\ - AnyBot 瀏覽器有設定檔(profile),每個有自己的登入。使用者說要換 profile/設定檔/另一個帳號時,用 browser_profiles:list 看有哪些,use 切換,add 新增(新的要登入一次)。也可以在 browser_open / browser_read 帶 profile 參數。使用者想用自己平常的 Chrome(自己的 Chrome 設定檔、已經登入的帳號)時,用 profile「你的瀏覽器」:請使用者在 Chrome 切到想要的設定檔,再 browser_read。\n\ - 你也能像人一樣操作螢幕({screens})。computer_use_screenshot 看畫面,再用 computer_use_click / computer_use_typing / computer_use_press_key / computer_use_scroll / computer_use_drag 操作;座標用最新截圖上的像素。每做一步就再截圖確認。用在檔案和網頁工具做不到的事(例如操作其他 App、在 Chrome 切換設定檔)。\n\ - - 使用者 Mac 上的「提醒事項、行事曆、聯絡人、備忘錄、郵件、訊息」有專門的工具(ios_read_reminders、mobile_read_calendar、mobile_read_contacts、mac_read_notes、mac_read_mail 等),比操作畫面快又準;寄信或傳訊息每次都會先請使用者確認,起草郵件(mac_draft_mail)則讓使用者自己按寄出。\n\ +{}\ - 不要說「我沒有瀏覽器或工具」:先試這些工具。需要權限時 App 會自己跳出視窗問使用者;工具回報要開什麼設定時,照實轉告。", now.format("%Y-%m-%d %H:%M"), ["週一", "週二", "週三", "週四", "週五", "週六", "週日"][now.weekday().num_days_from_monday() as usize], - st.timezone + st.timezone, + if mac_apps { " - 使用者 Mac 上的「提醒事項、行事曆、聯絡人、備忘錄、郵件、訊息」有專門的工具(ios_read_reminders、mobile_read_calendar、mobile_read_contacts、mac_read_notes、mac_read_mail 等),比操作畫面快又準,會在使用者正在用的那台 Mac 上做;寄信或傳訊息每次都會先請使用者確認,起草郵件(mac_draft_mail)則讓使用者自己按寄出。\n" } else { "" } )); if !b.routines.is_empty() { s.push_str("\n\n你目前的例行任務:\n"); @@ -3740,47 +3939,29 @@ fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_compu s.push_str(&mine.join("\n")); } if !computers.is_empty() { - let list: Vec = computers - .iter() - .map(|t| match &t.place { - tools::Place::Sealed { root } => format!("- {}:你自己的電腦(獨立資料夾 {},看不到使用者的檔案;沒有螢幕)", t.name, root.display()), - tools::Place::Box { dir } => { - let secrets = crate::cloudbox::secret_names(); - format!( - "- {}:你自己的 Linux 電腦(Debian,有桌面和 Chromium,所有 Bot 共用;你的資料夾是 {dir},可以 sudo 安裝套件)。需要時會自動喚醒。可以用 computer_use_* 工具(computer=「{}」)看它的螢幕和操作滑鼠鍵盤,例如開 chromium 上網、測網頁。{}", - t.name, - t.name, - if secrets.is_empty() { String::new() } else { format!("使用者設好的環境密鑰(已是環境變數,值不要印出來):{}。", secrets.join("、")) } - ) - } - tools::Place::Local { workdir } => format!( - "- {}:{},起始資料夾 {}{}", - t.name, - 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(); + let list: Vec = lines.iter().map(|l| format!("- {l}")).collect(); + let mine = match &roles.person { + Some(p) => format!("使用者說「我的電腦」「這台」「我的 Mac」時,指的是「{p}」(他正在用的那台)。"), + None => "現在不知道使用者在用哪台電腦:他說「我的電腦」「這台」時,先問他是哪一台。".to_string(), + }; + let gone = match missing { + Some(m) => format!("\n- 使用者選的「{m}」現在沒有連線(那台的 AnyBot 沒開或沒登入)。要在那台做事時,告訴使用者先打開它;要改用別台,先問使用者。"), + None => String::new(), + }; s.push_str(&format!( - "\n\n你可以用工具在電腦上做事。每個工具都有 computer 參數,預設是「{default_computer}」。\n{}\n\ - - 使用者替你選的電腦是「{default_computer}」:檔案、指令、看畫面、點擊和瀏覽器都在這台做,不要自己換到別台。只有使用者在訊息裡指名另一台(「在我的 Mac 上」「在 Bot 的電腦上」),才把 computer 設成那台。\n\ - - 不確定使用者指的是哪台電腦時(例如他說「我的電腦」而那台不在清單上、有幾台都說得通、或他的說法和你手上的預設不一致),先用一句話問他要哪台,問清楚再動手;不要擅作主張選一台就開始做,也不要把一台說成另一台。\n\ + "\n\n你可以用工具在電腦上做事。每台電腦都用它自己的名字稱呼,工具的 computer 參數就填那個名字:\n{}\n\ + - 使用者替你選的電腦是「{default_computer}」:檔案、指令、看畫面、點擊和瀏覽器都在這台做,不要自己換到別台。只有使用者在訊息裡指名另一台,才把 computer 設成那台。{gone}\n\ + - 「{own}」是你自己的電腦;使用者說「你的電腦」「Bot 的電腦」指的就是它。{mine}\n\ + - 回覆使用者時,說到電腦一律用上面的名字(例如「我在「{own}」打開了 Threads」),不要說「你的電腦」「Bot 的電腦」這種會混淆的說法。\n\ + - 不確定使用者指的是哪台電腦時(他說的那台不在清單上、有幾台都說得通、或他的說法和你手上的預設不一致),先用一句話問他要哪台,問清楚再動手;不要擅作主張選一台就開始做,也不要把一台說成另一台。\n\ - 先用 list_dir、search、read_file 看清楚,再動手。\n\ - 需要權限時(電腦工具還沒打開、要用使用者的電腦、會改變東西的操作),直接呼叫工具,App 會自己跳出視窗請使用者允許;不要叫使用者自己去改設定。被拒絕就換個方式或問使用者。\n\ - 跑很久的指令會移到背景,用 read_terminal 追蹤,不要重複執行;它在等輸入時(例如問 y/n),用 write_terminal 回答。\n\ - 只有工具結果證明做完了,才說做完;失敗就照實說。\n\ - 連續執行多步驟工具時不要每步都發訊息(先說「好,我先…」,做完一個工具播一段,最後再總結——使用者打一句就看到三四則回覆)。執行時就安靜地連續呼叫工具,全部做完後用一段話講結果;只有卡住、需要使用者動手(例如要他輸入密碼)、或要先問清哪台電腦時,才中途說話。\n\ - 回覆使用者時,用簡單的話說明你做了什麼、在哪台電腦、結果如何。", - list.join("\n") + list.join("\n"), + own = roles.own, )); } let chans: Vec = st.channels.iter().filter(|c| c.bot_id == b.id && c.enabled).map(|c| format!("{}「{}」", c.kind.label(), c.name)).collect(); diff --git a/src/app/chat_ui.rs b/src/app/chat_ui.rs index 0e412df..80ac997 100644 --- a/src/app/chat_ui.rs +++ b/src/app/chat_ui.rs @@ -459,10 +459,19 @@ impl App { let waiting = self.approvals.contains_key(&bot.id); let last = bot.messages.iter().rev().find(|m| m.role == Role::Assistant); let running = last.and_then(|m| m.tool_calls.iter().rev().find(|c| matches!(c.status, ToolStatus::Running | ToolStatus::Waiting))); - let on_box = running.map(|c| tools::args_of(&c.args)["computer"].as_str().map(|n| n == crate::model::BOT_COMPUTER_NAME).unwrap_or(true)).unwrap_or(false); + // Where it works, by the computer's name (its own computer is 「 的電腦」). + let named = running.and_then(|c| tools::args_of(&c.args)["computer"].as_str().map(String::from)); + let own = crate::model::own_computer_name(&bot.name); + let on_box = running.is_some() && named.as_deref().map(|n| n == own || n == crate::model::BOT_COMPUTER_NAME).unwrap_or(true); let has_text = last.map(|m| !m.text.trim().is_empty()).unwrap_or(false); let seed = live.turn_id.bytes().fold(0u64, |a, b| a.wrapping_mul(31).wrapping_add(b as u64)); - let phrase = live.status.clone().unwrap_or_else(|| t(&status_phrase(running.map(|c| c.name.as_str()), on_box, waiting, has_text, seed))); + let phrase = live.status.clone().unwrap_or_else(|| { + let ph = status_phrase(running.map(|c| c.name.as_str()), on_box, waiting, has_text, seed); + match &named { + Some(n) if ph == "正在你的電腦上工作" => t("正在「{name}」上工作").replace("{name}", n), + _ => t(&ph), + } + }); ui.add_space(4.0); ui.horizontal(|ui| { ui.add_space(6.0); diff --git a/src/app/computers_ui.rs b/src/app/computers_ui.rs index 6a5be14..2330287 100644 --- a/src/app/computers_ui.rs +++ b/src/app/computers_ui.rs @@ -9,6 +9,8 @@ use crate::cloudbox::{self, Rebuild, Run}; /// How often the viewer asks the box for a new picture (estimated). const VIEWER_FRAME_MS: u64 = 450; +/// The brain's computer comes over the account server: a picture about every 0.7 s. +const REMOTE_FRAME_MS: u64 = 700; /// The floating bar stays this long after the bot's last mouse/keyboard action on this Mac. const LOCAL_BAR_SECS: u64 = 8; @@ -61,6 +63,8 @@ pub struct ComputerUi { thumb_img: Arc>>, thumb_busy: Arc, thumb_at: Option, + /// The viewer shows the brain's computer (this window is a screen of a brain): how to reach it. + remote: Option, } enum Input { @@ -356,7 +360,13 @@ impl App { sec_title(ui, "Bot 的電腦", p, |_| {}); sec_card(ui, p, |ui| { let name = self.state.remote.brain_name.clone(); - sec_row(ui, p, "在大腦上", &format!("Bot 用的電腦在大腦「{name}」上,要在那台管理。這台電腦是大腦的手腳:上面的權限決定 Bot 能不能看這台的畫面、操作這台。"), |_| {}); + let mut view = false; + sec_row(ui, p, "在大腦上", &format!("每個 Bot 自己的電腦(例如「Master 的電腦」)在大腦「{name}」上,要在那台管理;這裡可以看它的畫面。這台電腦是大腦的手腳:上面的權限決定 Bot 能不能看這台的畫面、操作這台。"), |ui| { + view = ui.add(primary_button("開啟電腦", p)).clicked(); + }); + if view { + self.open_viewer(); + } }); } else { sec_title(ui, "Bot 的電腦", p, |ui| status_pill(ui, &run, p)); @@ -919,12 +929,19 @@ impl App { None => self.cu.thumb = Some(ctx.load_texture("box-thumb", img, egui::TextureOptions::LINEAR)), } } - let every = if cloudbox::run_state().is_running() { 3 } else { 30 }; + // On a screen of a brain the bots' computer is the brain's: its picture comes from there. + let brain = self.mir.on.then(|| self.state.remote.clone()); + let running = if brain.is_some() { self.mir.box_state["running"].as_bool().unwrap_or(false) } else { cloudbox::run_state().is_running() }; + let every = if running { 3 } else { 30 }; if !self.cu.thumb_at.map(|t| t.elapsed().as_secs() < every).unwrap_or(false) && !self.cu.thumb_busy.swap(true, Ordering::SeqCst) { self.cu.thumb_at = Some(Instant::now()); let (slot, busy, c) = (self.cu.thumb_img.clone(), self.cu.thumb_busy.clone(), ctx.clone()); std::thread::spawn(move || { - if let Some(img) = cloudbox::thumbnail(320) { + let img = match &brain { + Some(s) => crate::remote::sync_screen(s, true).ok().and_then(|b| image::load_from_memory(&b).ok()).map(|i| i.to_rgba8()), + None => cloudbox::thumbnail(320), + }; + if let Some(img) = img { let size = [img.width() as usize, img.height() as usize]; *slot.lock().unwrap() = Some(egui::ColorImage::from_rgba_unmultiplied(size, img.as_raw())); c.request_repaint(); @@ -939,8 +956,28 @@ impl App { pub(super) fn open_viewer(&mut self) { self.cu.viewer_open = true; self.cu.fit = true; - cloudbox::set_viewer_open(true); self.show_settings = false; + // A screen of a brain watches the brain's computer (where its bots really work). + if self.mir.on { + let s = self.state.remote.clone(); + self.cu.remote = Some(s.clone()); + if let Some(stop) = self.cu.worker.take() { + stop.store(true, Ordering::SeqCst); + } + self.cu.texture = None; + std::thread::spawn(move || { + let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "viewer", "on": true})); + let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "wake"})); + }); + return; + } + if self.cu.remote.take().is_some() { + if let Some(stop) = self.cu.worker.take() { + stop.store(true, Ordering::SeqCst); + } + self.cu.texture = None; + } + cloudbox::set_viewer_open(true); if !cloudbox::run_state().is_running() && !self.demo { self.box_task(|| cloudbox::ensure_running().map(|_| String::new())); } @@ -949,8 +986,15 @@ impl App { fn close_viewer(&mut self) { self.cu.viewer_open = false; self.cu.control = false; - cloudbox::HELD_BY_PERSON.store(false, Ordering::SeqCst); - cloudbox::set_viewer_open(false); + if let Some(s) = self.cu.remote.clone() { + std::thread::spawn(move || { + let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "hold", "on": false})); + let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "viewer", "on": false})); + }); + } else { + cloudbox::HELD_BY_PERSON.store(false, Ordering::SeqCst); + cloudbox::set_viewer_open(false); + } if let Some(stop) = self.cu.worker.take() { stop.store(true, Ordering::SeqCst); } @@ -966,6 +1010,54 @@ impl App { let slot = self.cu.frame.clone(); let c = ctx.clone(); let s2 = stop.clone(); + let brain = self.cu.remote.clone(); + if let Some(s) = brain.clone() { + // The brain's computer: pictures and the person's input go through the account server. + let s_frames = s.clone(); + std::thread::spawn(move || { + let s = s_frames; + let mut seq = 0u64; + while !s2.load(Ordering::SeqCst) { + match crate::remote::sync_screen(&s, false).ok().and_then(|b| image::load_from_memory(&b).ok()) { + Some(img) => { + let img = img.to_rgba8(); + seq += 1; + let size = [img.width() as usize, img.height() as usize]; + *slot.lock().unwrap() = Some((seq, egui::ColorImage::from_rgba_unmultiplied(size, img.as_raw()))); + c.request_repaint(); + std::thread::sleep(std::time::Duration::from_millis(REMOTE_FRAME_MS)); + } + None => { + std::thread::sleep(std::time::Duration::from_millis(1500)); + c.request_repaint(); + } + } + } + }); + let (tx, rx) = channel::(); + self.cu.input_tx = Some(tx); + std::thread::spawn(move || { + let mut last_move = Instant::now(); + while let Ok(i) = rx.recv() { + let v = match i { + Input::Click(x, y, b, n) => serde_json::json!({"kind": "click", "x": x, "y": y, "button": b, "count": n}), + // Hovering sends many moves; a few a second are enough over the network. + Input::Move(x, y) => { + if last_move.elapsed().as_millis() < 250 { + continue; + } + last_move = Instant::now(); + serde_json::json!({"kind": "move", "x": x, "y": y}) + } + Input::Scroll(x, y, down, n) => serde_json::json!({"kind": "scroll", "x": x, "y": y, "down": down, "steps": n}), + Input::Key(k) => serde_json::json!({"kind": "key", "key": k}), + Input::Text(t) => serde_json::json!({"kind": "text", "text": t}), + }; + let _ = crate::remote::sync_post(&s, "screen", &v); + } + }); + return; + } std::thread::spawn(move || { let mut seq = 0u64; while !s2.load(Ordering::SeqCst) { @@ -1014,13 +1106,25 @@ impl App { } } } - let run = cloudbox::run_state(); + let brain = self.cu.remote.is_some(); + // The brain's computer reports its state with the brain's answers. + let run = if brain { + match (self.mir.box_state["running"].as_bool().unwrap_or(false), self.mir.box_state["available"].as_bool().unwrap_or(true)) { + (true, _) => Run::Running, + (false, false) => Run::NoDocker("大腦那台沒有 Docker".into()), + _ => Run::Asleep, + } + } else { + cloudbox::run_state() + }; let screen = ctx.content_rect(); let open = true; let default = vec2((screen.width() * 0.62).min(900.0), (screen.width() * 0.62).min(900.0) * 0.625 + 52.0); - let busy = cloudbox::is_busy(); + let busy = if brain { self.mir.box_state["busy"].as_bool().unwrap_or(false) } else { cloudbox::is_busy() }; + let title = self.state.bot(self.state.selected).map(|b| crate::model::own_computer_name(&b.name)).unwrap_or_else(|| crate::model::BOT_COMPUTER_NAME.to_string()); + let title = if brain { format!("{title}(在大腦「{}」上)", self.state.remote.brain_name) } else { title }; let mut close_clicked = false; - egui::Window::new("Bot 的電腦") + egui::Window::new("box-viewer") .id(egui::Id::unique("box-viewer")) .title_bar(false) .default_size(default) @@ -1039,7 +1143,7 @@ impl App { .show(ctx, |ui| { ui.horizontal(|ui| { monitor_icon(ui, 20.0, p.text); - ui.label(RichText::new(crate::model::BOT_COMPUTER_NAME).strong().size(FONT_LG)); + ui.label(RichText::new(&title).strong().size(FONT_LG)); status_pill(ui, &run, p); if busy && !self.cu.control { crate::icons::label(ui, crate::icons::Icon::Bot, &t("Bot 正在操作"), FONT_XS, p.accent); @@ -1060,7 +1164,7 @@ impl App { close_clicked = true; } ui.add_space(4.0); - if ui.add(secondary_button("全螢幕")).on_hover_text(t("在瀏覽器用 noVNC 全螢幕開啟")).clicked() { + if !brain && ui.add(secondary_button("全螢幕")).on_hover_text(t("在瀏覽器用 noVNC 全螢幕開啟")).clicked() { let control = self.cu.control; self.box_task(move || { let url = cloudbox::viewer_url(control)?; @@ -1076,7 +1180,15 @@ impl App { let btn = egui::Button::new(RichText::new(label).strong().color(col)).fill(fill).corner_radius(8).min_size(vec2(96.0, 30.0)); if ui.add_enabled(run.is_running(), btn).on_hover_text(if self.cu.control { "Bot 可以繼續操作" } else { "你自己點擊和輸入;Bot 的操作會先暫停" }).clicked() { self.cu.control = !self.cu.control; - cloudbox::HELD_BY_PERSON.store(self.cu.control, Ordering::SeqCst); + match self.cu.remote.clone() { + Some(s) => { + let on = self.cu.control; + std::thread::spawn(move || { + let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "hold", "on": on})); + }); + } + None => cloudbox::HELD_BY_PERSON.store(self.cu.control, Ordering::SeqCst), + } } }); }); @@ -1114,7 +1226,14 @@ impl App { if matches!(run, Run::Asleep | Run::Absent) { let b = egui::Rect::from_center_size(rect.center() + vec2(0.0, 34.0), vec2(90.0, 30.0)); if ui.put(b, primary_button("喚醒", p)).clicked() { - self.box_task(|| cloudbox::ensure_running().map(|_| String::new())); + match self.cu.remote.clone() { + Some(s) => { + std::thread::spawn(move || { + let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "wake"})); + }); + } + None => self.box_task(|| cloudbox::ensure_running().map(|_| String::new())), + } } } } diff --git a/src/app/mirror_ui.rs b/src/app/mirror_ui.rs index 84fcf9c..724e615 100644 --- a/src/app/mirror_ui.rs +++ b/src/app/mirror_ui.rs @@ -393,6 +393,27 @@ pub struct Mirror { fetching: std::collections::HashSet, /// 加一台電腦: the line to run on the new computer (or why there is none), and when it was made. pub(super) pair: Arc)>>, + /// The bots' own computer on the brain: {label, running, available, busy}. + pub(super) box_state: Value, + /// Each bot's computers on the brain, by bot id: {names: [{name, kind}], current, missing}. + pub(super) computers: HashMap, +} + +/// What a computer is, in a word a screen can show next to its name. +pub(super) fn computer_kind(t: &crate::tools::Target, r: &crate::tools::Roles) -> &'static str { + use crate::tools::Place; + if t.name == r.own { + "own" + } else if r.person.as_deref() == Some(t.name.as_str()) { + "person" + } else { + match t.place { + Place::Local { .. } if r.server => "server", + Place::Local { .. } => "this", + Place::Device { .. } => "device", + _ => "ssh", + } + } } /// Where a screen keeps its copy of a file the brain has. @@ -516,6 +537,23 @@ impl App { json!({"bot": id, "call_id": a.call_id, "tool": a.tool, "reason": a.reason, "grant": a.grant, "kind": kind}) }) .collect(); + // The bots' computer lives here; a screen shows its state, not its own computer's. + let run = crate::cloudbox::run_state(); + out["box"] = json!({"label": run.label(), "running": run.is_running(), "available": crate::cloudbox::available(), "busy": crate::cloudbox::is_busy()}); + // Which computers each bot has (only the brain knows the account's other computers). + out["computers"] = self + .state + .bots + .iter() + .map(|b| { + let t = self.targets_for(b); + let r = self.roles_for(b, &t); + let (current, missing) = Self::chosen_computer(b, &t, &r); + let names: Vec = t.iter().map(|x| json!({"name": x.name, "kind": computer_kind(x, &r)})).collect(); + (b.id.to_string(), json!({"names": names, "current": current, "missing": missing})) + }) + .collect::>() + .into(); out } @@ -932,6 +970,14 @@ impl App { let mut msgs: Vec = serde_json::from_value(d["messages"].clone()).unwrap_or_default(); self.mirror_files(&mut msgs); if let Some(b) = self.state.bot_mut(id) { + // The brain sends what changed from message `from` on, sure this copy has the + // ones before. A copy that is shorter (cleared here, or an answer lost on the + // way) would get them appended in the wrong place — the same message twice and + // half-written replies piling up. Ask for the whole chat again instead. + if b.messages.len() < from { + self.mir.resync.store(true, Ordering::SeqCst); + continue; + } b.messages.truncate(from); b.messages.extend(msgs); } @@ -982,6 +1028,12 @@ impl App { } } } + if v["box"].is_object() { + self.mir.box_state = v["box"].clone(); + } + if let Some(c) = v["computers"].as_object() { + self.mir.computers = c.iter().filter_map(|(k, x)| Some((k.parse().ok()?, x.clone()))).collect(); + } let keep: Vec = v["approvals"].as_array().into_iter().flatten().filter_map(|a| a["bot"].as_u64()).collect(); self.approvals.retain(|id, _| keep.contains(id)); if self.state.bot(self.state.selected).is_none() { diff --git a/src/app/panel_ui.rs b/src/app/panel_ui.rs index 4e81b11..3290135 100644 --- a/src/app/panel_ui.rs +++ b/src/app/panel_ui.rs @@ -159,16 +159,29 @@ impl App { // 電腦: the bot's own computer with a live picture of its screen (like the original). panel_title(ui, "電腦", p); let mut view = false; - let thumb = if crate::cloudbox::available() { self.box_thumbnail(ctx) } else { None }; + // On a screen of a brain, the bot's computer is on the brain: its state and picture come + // from there (this computer's own box is a different machine). + let brain = self.mir.on; + let available = if brain { self.mir.box_state["available"].as_bool().unwrap_or(true) } else { crate::cloudbox::available() }; + let thumb = if available { self.box_thumbnail(ctx) } else { None }; + let own_name = crate::model::own_computer_name(&bot.name); + let brain_name = self.state.remote.brain_name.clone(); panel_card(ui, p, |ui| { let run = crate::cloudbox::run_state(); - let running = run.is_running(); - let state = if crate::cloudbox::available() { t(&run.label()) } else { t("沒有 Docker") }; + let running = if brain { self.mir.box_state["running"].as_bool().unwrap_or(false) } else { run.is_running() }; + let state = if brain { + let label = self.mir.box_state["label"].as_str().map(t).unwrap_or_else(|| t("連線中…")); + format!("{label} · {}", t("在大腦「{name}」上").replace("{name}", &brain_name)) + } else if available { + t(&run.label()) + } else { + t("沒有 Docker") + }; let (rect, r) = ui.allocate_exact_size(vec2(ui.available_width(), 64.0), egui::Sense::click()); if r.hovered() { ui.painter().rect_filled(rect, 9, p.hover); } - ui.painter().text(rect.left_top() + vec2(10.0, 15.0), egui::Align2::LEFT_TOP, t("Bot 的電腦"), egui::FontId::proportional(13.0), p.text); + ui.painter().text(rect.left_top() + vec2(10.0, 15.0), egui::Align2::LEFT_TOP, &own_name, egui::FontId::proportional(13.0), p.text); ui.painter().text(rect.left_top() + vec2(10.0, 33.0), egui::Align2::LEFT_TOP, state, egui::FontId::proportional(12.0), p.text3); // The screen: 16:10, rounded, the real desktop when there is a picture. let box_r = egui::Rect::from_min_size(rect.right_top() + vec2(-86.0, 8.0), vec2(78.0, 48.0)); @@ -192,11 +205,11 @@ impl App { if running { ui.painter().circle_filled(box_r.right_top() + vec2(-6.0, 6.0), 3.5, Color32::from_rgb(0x14, 0xc3, 0x70)); } - let tip = if running { "看 Bot 的電腦的螢幕,也可以取得控制權自己操作" } else { "叫醒 Bot 的電腦並看它的螢幕" }; - view = r.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(t(tip)).clicked(); + let tip = if running { t("看「{name}」的螢幕,也可以取得控制權自己操作") } else { t("叫醒「{name}」並看它的螢幕") }.replace("{name}", &own_name); + view = r.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(tip).clicked(); }); if view { - if crate::cloudbox::available() { + if available { self.open_viewer(); } else { self.show_settings = true; diff --git a/src/app/remote_ui.rs b/src/app/remote_ui.rs index 4c2c7a0..5eaaf9a 100644 --- a/src/app/remote_ui.rs +++ b/src/app/remote_ui.rs @@ -258,10 +258,16 @@ impl App { fn remote_cmd(&mut self, ctx: &egui::Context, c: Cmd) { match c { - Cmd::Send { bot, text, attachments } => { + 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); + 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()); @@ -327,6 +333,13 @@ impl App { } } } + 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) { @@ -743,6 +756,11 @@ impl App { .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() } }) .collect() } diff --git a/src/appctl.rs b/src/appctl.rs index aa0fa46..298798e 100644 --- a/src/appctl.rs +++ b/src/appctl.rs @@ -547,18 +547,19 @@ pub fn apply(state: &mut State, bot_id: u64, name: &str, args: &str) -> Outcome Outcome { text: format!("已交給「{name}」,它的回覆會出現在它自己的聊天裡。"), ok: true, wake: vec![(target, msg)], theme_changed: false, page: None } } "list_computers" => { - // On a server brain (serve), Local is the server, not the person's computer — - // matching what the app's own list_computers says after its rename. + // (The window answers this with the account's computers too; this is the plain list.) let serving = SERVING.load(std::sync::atomic::Ordering::Relaxed); + let own = state.bot(bot_id).map(|b| model::own_computer_name(&b.name)).unwrap_or_else(|| model::BOT_COMPUTER_NAME.into()); + let here = state.this_computer_name(); let mut lines = vec![ - format!("{}:{}", model::BOT_COMPUTER_NAME, if serving { "這個 Bot 自己的 Linux 電腦(AI 的電腦,不是使用者的電腦,獨立資料夾),一律可用" } else { "你自己的電腦(獨立的資料夾),一律可用" }), + format!("「{own}」:你自己的電腦(AI 的電腦,不是使用者的電腦,獨立資料夾),一律可用"), if serving { - format!("{}:AnyBot 大腦所在的伺服器(不是使用者的電腦)。使用者的電腦要在它上面裝 AnyBot 並登入同一帳號,才會以「{}」出現", model::BRAIN_HOST_NAME, model::THIS_COMPUTER_NAME) + format!("「{here}」:AnyBot 大腦所在的伺服器(不是使用者在用的電腦)") } else { - format!("{}:{},{}", model::THIS_COMPUTER_NAME, if cfg!(target_os = "macos") { "這台 Mac" } else { "這台電腦" }, if state.local_execution { "已允許" } else { "使用者還沒在設定裡允許(設定 → 電腦 → 在這台電腦上執行)" }) + format!("「{here}」:AnyBot 開著的這台{},{}", if cfg!(target_os = "macos") { " Mac" } else { "電腦" }, if state.local_execution { "已允許" } else { "使用者還沒在設定裡允許(設定 → 電腦 → 在這台電腦上執行)" }) }, ]; - lines.extend(state.remote_computers.iter().map(|c| format!("{}:透過 SSH 連到 {}", c.name, c.ssh_target))); + lines.extend(state.remote_computers.iter().map(|c| format!("「{}」:透過 SSH 連到 {}", c.name, c.ssh_target))); ok(lines.join("\n")) } "update_settings" => { diff --git a/src/attach.rs b/src/attach.rs index 3969e4d..5dac729 100644 --- a/src/attach.rs +++ b/src/attach.rs @@ -159,7 +159,7 @@ pub fn for_model(atts: &[Attachment]) -> (String, Vec) { let mut text = String::new(); let mut images = vec![]; for a in atts { - let where_ = if a.on_box.is_empty() { format!("檔案在使用者的 Mac:{}", a.path) } else { format!("檔案在使用者的 Mac:{};也已放進 Bot 的電腦:{}", a.path, a.on_box) }; + let where_ = if a.on_box.is_empty() { format!("檔案存在 AnyBot 所在的電腦:{}", a.path) } else { format!("檔案存在 AnyBot 所在的電腦:{};也已放進你自己的電腦:{}", a.path, a.on_box) }; if a.image { match image_b64(&a.path) { Some(b) => { diff --git a/src/botbrowser.rs b/src/botbrowser.rs index d5a7115..881f262 100644 --- a/src/botbrowser.rs +++ b/src/botbrowser.rs @@ -264,7 +264,7 @@ pub fn ensure(profile: &str, first_url: Option<&str>) -> Result<(&'static str, u let _ = local().put(&endpoint(port, &format!("/json/new?{}", encode_component(u)))).send_empty(); } } - return Ok(("Bot 的電腦的 Chromium", port, fresh && first_url.is_some())); + return Ok(("它桌面上的 Chromium", port, fresh && first_url.is_some())); } let Some((name, bin)) = engine() else { return Err("這台電腦沒有 Chrome、Brave、Edge 或 Chromium,沒辦法開 AnyBot 自己的瀏覽器。".into()) }; if profile == PERSON_PROFILE { diff --git a/src/cloudbox.rs b/src/cloudbox.rs index 191e7ab..ca6f246 100644 --- a/src/cloudbox.rs +++ b/src/cloudbox.rs @@ -625,7 +625,7 @@ pub fn browser_port() -> Result { } std::thread::sleep(Duration::from_millis(300)); } - Err("Bot 的電腦的瀏覽器沒有及時打開,請再試一次。".into()) + Err("這台電腦的瀏覽器沒有及時打開,請再試一次。".into()) } static START_LOCK: Mutex<()> = Mutex::new(()); @@ -1248,7 +1248,7 @@ pub fn computer_run(name: &str, v: &Value) -> Result<(String, Option), S ensure_running()?; let _busy = Busy::new(true); if HELD_BY_PERSON.load(Ordering::SeqCst) && name != "computer_use_screenshot" && name != "computer_use_check_permissions" { - return Err("使用者正在自己操作 Bot 的電腦(已取得控制權)。請等他交還控制權後再操作,或先截圖看看畫面。".into()); + return Err("使用者正在自己操作這台電腦(已取得控制權)。請等他交還控制權後再操作,或先截圖看看畫面。".into()); } let scale = || shared().lock().unwrap().scale.ok_or_else(|| "請先用 computer_use_screenshot 看一下畫面,座標要用截圖上的位置。".to_string()); let pt = |v: &Value, s: (f64, f64)| -> Result<(String, String), String> { @@ -1275,7 +1275,7 @@ pub fn computer_run(name: &str, v: &Value) -> Result<(String, Option), S r }; match name { - "computer_use_check_permissions" => Ok(("Bot 的電腦不需要另外的權限:可以截圖,也可以操作滑鼠和鍵盤。".into(), None)), + "computer_use_check_permissions" => Ok(("這台電腦不需要另外的權限:可以截圖,也可以操作滑鼠和鍵盤。".into(), None)), "computer_use_screenshot" => { let img = grab()?; let (w, h) = img.dimensions(); @@ -1283,7 +1283,7 @@ pub fn computer_run(name: &str, v: &Value) -> Result<(String, Option), S let img = if resize { image::imageops::resize(&img, fw, fh, image::imageops::FilterType::Triangle) } else { img }; let jpeg = crate::computer::encode_jpeg(&img).ok_or("截圖太大了,沒辦法送出")?; shared().lock().unwrap().scale = Some((w as f64 / fw.max(1) as f64, h as f64 / fh.max(1) as f64)); - Ok((format!("Bot 的電腦的螢幕截圖({fw}×{fh},Linux 桌面)。之後的座標請用這張圖上的像素位置。"), Some(base64::engine::general_purpose::STANDARD.encode(jpeg)))) + Ok((format!("螢幕截圖({fw}×{fh},Linux 桌面)。之後的座標請用這張圖上的像素位置。"), Some(base64::engine::general_purpose::STANDARD.encode(jpeg)))) } "computer_use_click" => { let (x, y) = pt(v, scale()?)?; diff --git a/src/computer.rs b/src/computer.rs index 918a1df..d75d535 100644 --- a/src/computer.rs +++ b/src/computer.rs @@ -188,7 +188,7 @@ pub fn describe(name: &str, args: &str) -> String { #[allow(dead_code)] pub fn specs() -> Vec { - specs_for(&[crate::model::THIS_COMPUTER_NAME.to_string()], crate::model::THIS_COMPUTER_NAME) + specs_for(&[], "") } /// The screen tools, with a `computer` choice when the bot has its own desktop too. @@ -220,26 +220,10 @@ pub fn specs_for(computers: &[String], default: &str) -> Vec { json!({"name": "computer_use_check_permissions", "description": "Check whether the screen can be seen and the mouse and keyboard used on that computer.", "parameters": {"type": "object", "properties": {}}}), ]; - // Named whenever there is a choice, or the one screen isn't this computer or the bot's own - // (a 手腳 computer): the bot should say whose screen it means. - let own = [crate::model::BOT_COMPUTER_NAME, crate::model::THIS_COMPUTER_NAME]; - if computers.len() > 1 || computers.iter().any(|c| !own.contains(&c.as_str())) { - // Explain each name that is actually offered (the old text promised "the person's Mac" - // on machines where it does not exist, and the bot grabbed the next best thing). - let mut parts = Vec::new(); - if computers.iter().any(|c| c == crate::model::BOT_COMPUTER_NAME) { - parts.push(format!("{} is a private Linux desktop for the bot (the AI's own computer, not the person's).", crate::model::BOT_COMPUTER_NAME)); - } - if computers.iter().any(|c| c == crate::model::THIS_COMPUTER_NAME) { - parts.push(format!("{} is the person's own computer (use it for the person's own apps).", crate::model::THIS_COMPUTER_NAME)); - } - if computers.iter().any(|c| c == crate::model::BRAIN_HOST_NAME) { - parts.push(format!("{} is the server the brain runs on — not a person's screen; a person asking about \"my screen\" never means it.", crate::model::BRAIN_HOST_NAME)); - } - if computers.iter().any(|c| c != crate::model::BOT_COMPUTER_NAME && c != crate::model::THIS_COMPUTER_NAME && c != crate::model::BRAIN_HOST_NAME) { - parts.push("Any other name is another computer of the account.".to_string()); - } - let desc = format!("Whose screen. Default: {default}. {}", parts.join(" ")); + // Always named: each computer goes by its own name (the instructions say which is which), + // so the bot says whose screen it means and the person can tell where it worked. + if !computers.is_empty() { + let desc = format!("Whose screen, by the computer's name. Default: {default} (the one the person chose). Use another only when the person names it."); for t in v.iter_mut() { t["parameters"]["properties"]["computer"] = json!({"type": "string", "enum": computers, "description": desc}); } diff --git a/src/i18n_table.rs b/src/i18n_table.rs index a554317..2fa684b 100644 --- a/src/i18n_table.rs +++ b/src/i18n_table.rs @@ -598,4 +598,18 @@ pub const ROWS: &[(&str, &str, &str)] = &[ ("發起語音聊天:你講完它就回答並唸出來,再繼續聽", "Start voice chat: when you finish speaking it answers out loud, then keeps listening", "ボイスチャットを開始:話し終えると声で答え、また聞き取ります"), ("幫我建立一個例行任務:", "Set up a routine for me: ", "ルーティンを作成して:"), ("你好,我是你的 Bot。", "Hi, I'm your bot.", "こんにちは、あなたの Bot です。"), + ("在大腦「{name}」上", "On the brain “{name}”", "ブレイン「{name}」上"), + ("連線中…", "Connecting…", "接続中…"), + ("看「{name}」的螢幕,也可以取得控制權自己操作", "Watch the screen of “{name}”; you can also take control", "「{name}」の画面を見ます。操作を代わることもできます"), + ("叫醒「{name}」並看它的螢幕", "Wake “{name}” and watch its screen", "「{name}」を起こして画面を見ます"), + ("要 {name} 在哪台電腦做事", "Which computer {name} works on", "{name} が作業するコンピューター"), + ("它自己的電腦", "its own computer", "自分のコンピューター"), + ("你正在用的這台", "the one you're using", "今使っているコンピューター"), + ("這台電腦", "this computer", "このコンピューター"), + ("大腦所在的伺服器", "the brain's server", "ブレインのサーバー"), + ("SSH 電腦", "SSH computer", "SSH コンピューター"), + ("做事的電腦", "Works on", "作業するコンピューター"), + ("聊天時指名另一台(例如「在 Mac mini 上」)就會換", "Name another one in the chat (e.g. “on the Mac mini”) to switch", "チャットで別のコンピューターを指名すると切り替わります(例:「Mac mini で」)"), + ("正在「{name}」上工作", "Working on “{name}”", "「{name}」で作業中"), + ("在「{name}」上做事 · 點一下換電腦", "Works on “{name}” · click to switch computers", "「{name}」で作業 · クリックで切り替え"), ]; diff --git a/src/memory.rs b/src/memory.rs index 9600cec..7b9db8b 100644 --- a/src/memory.rs +++ b/src/memory.rs @@ -28,7 +28,7 @@ pub struct Template { pub const TEMPLATES: [Template; 5] = [ Template { name: "研究助理", description: "上網找資料、整理重點、附上來源", instructions: "先釐清問題,再用 web_fetch 或瀏覽器找至少兩個可靠來源。回答先給結論,再列重點,每個重點附來源網址。不確定的地方直接說不確定。", shape: "circle", color: "green" }, Template { name: "寫作夥伴", description: "幫你構思、改寫和潤稿", instructions: "先問清楚讀者和語氣。改稿時保留作者的意思,列出改了什麼和為什麼。用繁體中文、台灣用語。", shape: "cloud", color: "purple" }, - Template { name: "電腦助手", description: "在 Bot 的電腦上跑指令、整理檔案、操作網頁", instructions: "預設在「Bot 的電腦」上做事。動手前先看清楚(list_dir、read_file、截圖),每一步確認結果。要動到使用者的 Mac 時先說明要做什麼。", shape: "gem", color: "blue" }, + Template { name: "電腦助手", description: "在自己的電腦上跑指令、整理檔案、操作網頁", instructions: "預設在你自己的電腦上做事。動手前先看清楚(list_dir、read_file、截圖),每一步確認結果。要動到使用者的 Mac 時先說明要做什麼。", shape: "gem", color: "blue" }, Template { name: "客服助理", description: "在 Slack、Discord、Telegram 或 Teams 回覆客人", instructions: "回答簡短、有禮貌。不確定的事不要編,說會請同事確認。不要透露內部資料或使用者的私人資訊。", shape: "pill", color: "orange" }, Template { name: "開發助手", description: "看程式、跑測試、追 GitHub 上的事", instructions: "改程式前先讀相關檔案和測試。每次修改後跑測試,回報結果。PR 或 CI 的事件來時,先看紀錄再下結論。", shape: "hexagon", color: "black" }, ]; diff --git a/src/model.rs b/src/model.rs index b6e4633..536b5cb 100644 --- a/src/model.rs +++ b/src/model.rs @@ -123,10 +123,23 @@ fn is_zero(n: &u64) -> bool { *n == 0 } +/// A bot's own computer as kept in `Bot::default_computer` (it survives renaming the bot). It is +/// shown and offered under the bot's name: see [`own_computer_name`]. pub const BOT_COMPUTER_NAME: &str = "Bot 的電腦"; +/// What older versions kept for "the person's computer". Read as the computer the person is using +/// now; every computer is shown under its own name. 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 = "大腦伺服器"; + +/// What a bot calls its own computer: 「Master 的電腦」 for the bot Master. One plain name per +/// computer — 「Bot 的電腦」 and 「你的電腦」 meant different machines to the person and the bot. +pub fn own_computer_name(bot_name: &str) -> String { + let n = bot_name.trim(); + if n.is_empty() { + BOT_COMPUTER_NAME.to_string() + } else { + format!("{n} 的電腦") + } +} impl Message { pub fn user(text: &str) -> Self { @@ -382,13 +395,25 @@ fn default_sidebar_width() -> f32 { } impl State { - /// Every computer a bot can be told to use: its own, this Mac, then the SSH ones. + /// Every computer name a bot can be told to use here: its own (by its stored name), this + /// computer, then the SSH ones. pub fn computer_names(&self) -> Vec { - let mut v = vec![BOT_COMPUTER_NAME.to_string(), THIS_COMPUTER_NAME.to_string()]; + let mut v = vec![BOT_COMPUTER_NAME.to_string(), self.this_computer_name()]; v.extend(self.remote_computers.iter().map(|c| c.name.clone())); v } + /// This computer's own name: the one given in 設定 › 電腦, else its name in the account, else + /// the name the system gives it. + pub fn this_computer_name(&self) -> String { + for n in [&self.machine_label, &self.remote.host_name] { + if !n.trim().is_empty() { + return n.trim().to_string(); + } + } + crate::remote::default_host_name() + } + pub fn routine_owner(&self, routine_id: u64) -> Option { self.bots.iter().find(|b| b.routines.iter().any(|r| r.id == routine_id)).map(|b| b.id) } diff --git a/src/platform.rs b/src/platform.rs index 48ffa2f..2a5c862 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -3,15 +3,6 @@ // `localize` is applied to every translated label (i18n::t) and to what the bot is told (its // instructions and tool descriptions), so the wording follows the system in one place. -/// A screen the person sits at (not a server with no display). -pub fn has_desktop() -> bool { - if cfg!(target_os = "linux") { - std::env::var("DISPLAY").map(|v| !v.is_empty()).unwrap_or(false) || std::env::var("WAYLAND_DISPLAY").map(|v| !v.is_empty()).unwrap_or(false) - } else { - true - } -} - const PAIRS: &[(&str, &str)] = &[ ("使用者的這台 Mac", "使用者的這台電腦"), ("這台 Mac", "這台電腦"), diff --git a/src/remote.rs b/src/remote.rs index c4213dc..1a4d62e 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -92,6 +92,12 @@ impl Default for Settings { } pub fn default_host_name() -> String { + // Asked of the system once (it runs a program). + static N: OnceLock = OnceLock::new(); + N.get_or_init(system_host_name).clone() +} + +fn system_host_name() -> String { if !cfg!(target_os = "macos") { let pretty = std::process::Command::new("hostnamectl").arg("--pretty").output().ok().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default(); let name = if pretty.is_empty() { std::fs::read_to_string("/etc/hostname").unwrap_or_default().trim().to_string() } else { pretty }; @@ -109,7 +115,8 @@ pub fn default_host_name() -> String { /// What the phone asks AnyBot to do; the app carries these out on its own thread. #[derive(Clone, Debug, PartialEq)] pub enum Cmd { - Send { bot: u64, text: String, attachments: Vec }, + /// `from`: the account computer the person typed it on (empty: the phone, or this one). + Send { bot: u64, text: String, attachments: Vec, from: String }, Stop { bot: u64 }, Answer { bot: u64, answer: String }, Read { bot: u64 }, @@ -119,6 +126,8 @@ pub enum Cmd { Reply { bot: u64 }, /// Bring back an earlier conversation (過去的對話). RestoreThread { bot: u64, index: usize }, + /// 清除聊天記錄 from a screen of this brain (the chat lives here). + Clear { bot: u64 }, /// A new phone signed in (to remember), or one was signed out. DeviceAdded(Device), DeviceSeen(String), @@ -393,7 +402,7 @@ pub fn handle(req: Req, via_relay: bool) -> Resp { // 團隊: the bot knows who is talking when it isn't the owner. let who = req.header("x-anybot-user"); let text = if role != "owner" && !who.is_empty() { format!("【{who}】{text}") } else { text }; - Cmd::Send { bot, text, attachments } + Cmd::Send { bot, text, attachments, from: String::new() } } "stop" => Cmd::Stop { bot }, "answer" => Cmd::Answer { bot, answer: b["answer"].as_str().unwrap_or("reject").to_string() }, @@ -425,6 +434,52 @@ pub fn handle(req: Req, via_relay: bool) -> Resp { Err(e) => Resp::error(503, &e), }, ("POST", ["api", "sync", "upload", id]) => upload(id.parse().unwrap_or(0), &req), + // A screen of this brain watches (and may take over) the bots' own computer, which lives + // here: its picture on that screen used to be its own computer's, not this one's. + ("GET", ["api", "sync", "screen"]) => { + if req.query.get("thumb").map(|t| t == "1").unwrap_or(false) { + match crate::cloudbox::thumbnail(320) { + Some(img) => { + let rgb = image::DynamicImage::ImageRgba8(img).to_rgb8(); + match crate::computer::encode_jpeg(&rgb) { + Some(j) => Resp::bytes("image/jpeg", j), + None => Resp::error(500, "畫面編碼失敗"), + } + } + None => Resp::error(404, "還沒有畫面"), + } + } else { + screen("box", &s) + } + } + ("POST", ["api", "sync", "screen"]) => { + let v = req.json(); + match v["kind"].as_str().unwrap_or("") { + "hold" => { + crate::cloudbox::HELD_BY_PERSON.store(v["on"].as_bool().unwrap_or(false), Ordering::SeqCst); + Resp::json(&json!({"ok": true})) + } + "viewer" => { + crate::cloudbox::set_viewer_open(v["on"].as_bool().unwrap_or(false)); + Resp::json(&json!({"ok": true})) + } + // The person's own hands on the picture (frame pixels, like this computer's own + // viewer; not the bot's tools, which stop while the person holds it). + "click" | "move" | "scroll" | "key" | "text" => { + crate::cloudbox::touch(); + let (x, y) = (v["x"].as_i64().unwrap_or(0), v["y"].as_i64().unwrap_or(0)); + match v["kind"].as_str().unwrap_or("") { + "click" => crate::cloudbox::viewer_click(x, y, if v["button"].as_str() == Some("right") { "right" } else { "left" }, v["count"].as_u64().unwrap_or(1).clamp(1, 3) as u32), + "move" => crate::cloudbox::viewer_move(x, y), + "scroll" => crate::cloudbox::viewer_scroll(x, y, v["down"].as_bool().unwrap_or(true), v["steps"].as_u64().unwrap_or(1).clamp(1, 20) as u32), + "key" => crate::cloudbox::viewer_key(v["key"].as_str().unwrap_or("")), + _ => crate::cloudbox::viewer_text(v["text"].as_str().unwrap_or("")), + } + Resp::json(&json!({"ok": true})) + } + _ => screen_input("box", &v, &s), + } + } ("POST", ["api", "sync", "models"]) => match hub().ask_sync_for(SyncKind::Models(req.json()["provider"].as_str().unwrap_or("").to_string()), 45) { Ok(v) => Resp::json(&v), Err(e) => Resp::error(503, &e), @@ -449,6 +504,8 @@ pub fn handle(req: Req, via_relay: bool) -> Resp { bot, text: b["text"].as_str().unwrap_or("").trim().to_string(), attachments: b["attachments"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default(), + // The relay names the computer that asked: the one the person is using. + from: req.header("x-anybot-from").to_string(), }, "stop" => Cmd::Stop { bot }, "answer" => Cmd::Answer { bot, answer: b["answer"].as_str().unwrap_or("reject").to_string() }, @@ -457,6 +514,7 @@ pub fn handle(req: Req, via_relay: bool) -> Resp { "regenerate" => Cmd::Regenerate { bot }, "reply" => Cmd::Reply { bot }, "restore" => Cmd::RestoreThread { bot, index: b["index"].as_u64().unwrap_or(0) as usize }, + "clear" => Cmd::Clear { bot }, _ => return Resp::error(404, "沒有這個動作"), }; hub().push(cmd); @@ -590,7 +648,7 @@ fn screen_input(which: &str, v: &Value, s: &Settings) -> Resp { } let (name, args) = match kind { "click" => ("computer_use_click", json!({"x": v["x"], "y": v["y"], "button": v["button"].as_str().unwrap_or("left"), "count": v["count"].as_u64().unwrap_or(1)})), - "type" => ("computer_use_typing", json!({"text": v["text"]})), + "type" => ("computer_use_typing", json!({"value": v["text"]})), "key" => ("computer_use_press_key", json!({"key": v["key"]})), "scroll" => ("computer_use_scroll", json!({"x": v["x"], "y": v["y"], "direction": v["direction"].as_str().unwrap_or("down"), "amount": v["amount"].as_i64().unwrap_or(3)})), _ => return Resp::error(400, "不認得的操作"), @@ -693,7 +751,11 @@ fn host_port(url: &str) -> Option<(String, u16)> { /// can't open a connection to, and the person's computer lends this brain its hands. pub fn model_hands_for(url: &str) -> Option { let s = hub().settings(); - let hand = my_hands(&s.host_id).into_iter().next()?; + // The person's own computer (the one they type on) is the one on their network; the first + // hands by id was a desktop in the server room that can't reach a model at their office. + let hands = my_hands(&s.host_id); + let person = person_host(); + let hand = hands.iter().find(|h| **h == person).or_else(|| hands.first()).cloned()?; let (h, p) = host_port(url)?; if !private_host(&h) || h == "localhost" || h.starts_with("127.") { return None; @@ -716,7 +778,7 @@ pub fn model_via_hands(hand: &str, url: &str, headers: &[(String, String)], body let args = json!({"url": url, "headers": headers, "body": body}).to_string(); let req = json!({"tool": MODEL_FETCH, "args": args, "who": "大腦"}).to_string(); let name = peer_name(hand); - let whom = if name.is_empty() { "你的電腦".to_string() } else { format!("「{name}」") }; + let whom = if name.is_empty() { "使用者的電腦".to_string() } else { format!("「{name}」") }; let r = crate::tunnel::call(hand, "POST", "/api/device/run", &[], req.as_bytes(), 600).ok_or_else(|| format!("模型在只有{whom}連得到的網路上,但大腦現在和帳號伺服器沒有連線"))?; let a = r.map_err(|e| format!("透過{whom}連模型失敗:{e}"))?; let v = answer_value(a.status, &a.body, &whom)?; @@ -1155,6 +1217,15 @@ pub fn sync_upload(s: &Settings, bot: u64, name: &str, bytes: &[u8]) -> Result Result, String> { + let (st, bytes) = to_brain_raw(s, "GET", "/screen", &[("thumb", if thumb { "1" } else { "0" }.to_string())], &[], 30)?; + if !(200..300).contains(&st) { + return Err(why_refused(st, &serde_json::from_slice(&bytes).unwrap_or(Value::Null), "大腦")); + } + Ok(bytes) +} + /// A file the brain keeps (an attachment shown in a chat), as bytes. pub fn sync_file(s: &Settings, path: &str) -> Result, String> { let (st, bytes) = to_brain_raw(s, "GET", "/file", &[("path", path.to_string())], &[], 120)?; @@ -1350,6 +1421,25 @@ pub fn my_hands(me: &str) -> Vec { v } +fn person_cell() -> &'static Mutex { + static P: OnceLock> = OnceLock::new(); + P.get_or_init(Mutex::default) +} + +/// The account computer the person last typed on (they are sitting at it). +pub fn set_person_host(id: &str) { + *person_cell().lock().unwrap() = id.to_string(); +} + +pub fn person_host() -> String { + person_cell().lock().unwrap().clone() +} + +/// The brain an account computer shows (empty: it thinks for itself), as last seen. +pub fn peer_uses(id: &str) -> String { + peer_rows().lock().unwrap().get(id).map(|r| r.1.clone()).unwrap_or_default() +} + /// 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() @@ -1466,7 +1556,7 @@ mod tests { assert_eq!(handle(send, false).status, 200); let cmds = hub().take_cmds(); assert!(cmds.iter().any(|c| matches!(c, Cmd::DeviceAdded(d) if d.name == "iPhone"))); - assert!(cmds.contains(&Cmd::Send { bot: 1, text: "你好".into(), attachments: vec![] })); + assert!(cmds.contains(&Cmd::Send { bot: 1, text: "你好".into(), attachments: vec![], from: String::new() })); // The Mac's own screen stays closed unless allowed. let mut mac = Req { method: "GET".into(), path: "/api/screen/mac".into(), ..Default::default() }; mac.headers = req.headers.clone(); diff --git a/src/tools.rs b/src/tools.rs index 24c3feb..f3c3a87 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -622,6 +622,17 @@ pub struct Target { pub place: Place, } +/// Which of a bot's computers is which, by name (every computer goes by one plain name). +#[derive(Clone, Debug, Default)] +pub struct Roles { + /// The bot's own computer: 「Master 的電腦」. + pub own: String, + /// The computer the person is using now (where they typed to the bot), when known. + pub person: Option, + /// This AnyBot runs on a server: the computer it is on is nobody's desk. + pub server: bool, +} + /// The tools with a `computer` choice added to each, listing the computers this bot can use. pub fn specs_for(computers: &[String], default: &str) -> Vec { let mut v = specs(); @@ -629,14 +640,9 @@ pub fn specs_for(computers: &[String], default: &str) -> Vec { if t["name"] == "read_terminal" || t["name"] == "write_terminal" { continue; } - // None of these being the person's own computer (a server brain with no hands): - // a bot that "helped on your computer" then worked on the server, while the person - // watched their own desktop stay untouched. Say what is true instead. - let desc = if computers.iter().any(|c| c == crate::model::THIS_COMPUTER_NAME) { - format!("Which computer to work on. Default: {default}. Use the one the person names (\"在我的 Mac 上\" = {}).", crate::model::THIS_COMPUTER_NAME) - } else { - format!("Which computer to work on. Default: {default}. None of these is the person's own computer (it is not connected): when the person means their own computer, ask which computer they want and tell them theirs is not connected (App › 設定 › 電腦) — never work on another machine and call it theirs.") - }; + // Each computer goes by its own name; the instructions say which is which. Working on + // one and calling it another (the server as "your computer") is what confused people. + let desc = format!("Which computer to work on, by its name. Default: {default} (the one the person chose). Use another only when the person names it; when unsure which one they mean, ask."); t["parameters"]["properties"]["computer"] = json!({ "type": "string", "enum": computers, @@ -686,7 +692,7 @@ pub fn decide_on(name: &str, args: &str, target: &Target, grants: &[String]) -> } let p = resolve(root, &arg_str(&v, "path")); if !inside(root, &p) { - return Decision::Deny(format!("{} 看不到這台電腦上的其他檔案({})。要處理你的檔案,請用「{}」。", target.name, p.display(), crate::model::THIS_COMPUTER_NAME)); + return Decision::Deny(format!("{} 看不到這台電腦上的其他檔案({})。要處理使用者的檔案,請在使用者的電腦上做(不確定是哪台就先問)。", target.name, p.display())); } Decision::Allow } diff --git a/src/web.rs b/src/web.rs index e3ecdbc..b860586 100644 --- a/src/web.rs +++ b/src/web.rs @@ -59,30 +59,14 @@ pub fn read_needs_permission(args: &str) -> bool { uses_person_browser(args) } -/// 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(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(); - // A brain on a server with the person's computer as its hands: that one's browser first. - let person = devices.iter().any(|d| d == crate::model::THIS_COMPUTER_NAME); - if local { - names.insert(if person { names.len() } else { 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 person { - format!("Leave out for {} (the person's own computer, where they can see the window).", crate::model::THIS_COMPUTER_NAME) - } else if local { - format!("Leave out for {here}.") - } else { - "This computer has no browser: always name one.".to_string() - } - ); +/// The browser tools. `browsers`: the computers with an AnyBot browser this bot can use (its own, +/// this one, the account's other computers), by name; each has its own logins. +pub fn specs_for(browsers: &[String], default: &str) -> Vec { + let mut v = specs_inner(!browsers.is_empty()); + if !browsers.is_empty() { + let desc = format!("Which computer's AnyBot browser to use, by the computer's name. Each computer's browser has its own logins (a site logged in on one isn't on another). Default: {default}. Use another only when the person names it."); 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": browsers, "description": desc}); } } v From 6b0a0babf17683c5ecc00f414543d8009a4f5e74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Thu, 5 Nov 2026 16:49:39 +0800 Subject: [PATCH 06/19] Bot settings: a short hint keeps the computer row inside its card Co-Authored-By: Claude Opus 5.5 --- src/app.rs | 4 ++-- src/i18n_table.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app.rs b/src/app.rs index 8f2b66b..4919503 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2795,7 +2795,7 @@ impl App { if let Some(n) = pick { b.default_computer = Self::computer_to_keep(&b, &n); } - ui.label(RichText::new(t("聊天時指名另一台(例如「在 Mac mini 上」)就會換")).size(11.5).color(p.text3)); + ui.label(RichText::new(t("聊天時指名別台就會換")).size(11.5).color(p.text3)); }); form_row(ui, "起始資料夾", p, |ui| { ui.add(egui::TextEdit::singleline(&mut b.workdir).hint_text(t("在你的電腦上,例如 ~/Documents/專案")).desired_width(ui.available_width())); @@ -2809,7 +2809,7 @@ impl App { ui.label(RichText::new(t("用到 70% 時會把舊對話整理成摘要")).size(11.5).color(p.text3)); }); ui.add_space(2.0); - hint(ui, "在 Bot 的電腦上可以自由做事(看不到你的檔案);在你的電腦上,改檔案和會改變東西的指令會先問你。密碼和金鑰檔一律不碰。", p); + hint(ui, "在它自己的電腦上可以自由做事(看不到你的檔案);在你的電腦上,改檔案和會改變東西的指令會先問你。密碼和金鑰檔一律不碰。", p); if !b.allow_rules.is_empty() { ui.add_space(6.0); ui.label(RichText::new(t("已設定「一律允許」")).size(FONT_XS).color(p.text2)); diff --git a/src/i18n_table.rs b/src/i18n_table.rs index 2fa684b..ed98ab2 100644 --- a/src/i18n_table.rs +++ b/src/i18n_table.rs @@ -119,7 +119,7 @@ pub const ROWS: &[(&str, &str, &str)] = &[ ("連接器", "Connectors", "コネクタ"), ("例如:「每個工作日早上 9 點整理新聞給我」", "e.g. “Every weekday at 9 am, send me a news summary”", "例:「平日の毎朝 9 時にニュースをまとめて」"), ("要新增或修改,直接在聊天裡告訴 Bot。", "To add or change one, just tell the bot in the chat.", "追加や変更は、チャットで Bot に伝えるだけです。"), - ("在 Bot 的電腦上可以自由做事(看不到你的檔案);在你的電腦上,改檔案和會改變東西的指令會先問你。密碼和金鑰檔一律不碰。", "On the bot's computer it can work freely (it can't see your files); on your computer, changing files and commands that change things ask you first. Password and key files are never touched.", "Bot のコンピューターでは自由に作業できます(あなたのファイルは見えません)。あなたのコンピューターでは、ファイルの変更や変更を伴うコマンドは先に確認します。パスワードや鍵のファイルには一切触れません。"), + ("在它自己的電腦上可以自由做事(看不到你的檔案);在你的電腦上,改檔案和會改變東西的指令會先問你。密碼和金鑰檔一律不碰。", "On the bot's computer it can work freely (it can't see your files); on your computer, changing files and commands that change things ask you first. Password and key files are never touched.", "Bot のコンピューターでは自由に作業できます(あなたのファイルは見えません)。あなたのコンピューターでは、ファイルの変更や変更を伴うコマンドは先に確認します。パスワードや鍵のファイルには一切触れません。"), ("還沒有。跟 Bot 說「記住我喜歡靠窗的座位」試試看。", "Nothing yet. Try telling the bot “remember I like window seats”.", "まだありません。Bot に「窓側の席が好きだと覚えて」と言ってみてください。"), ("選單會用英文顯示;Bot 會用你選的語言回答。", "Menus are shown in English; bots answer in the language you chose.", "メニューは英語で表示されます。Bot は選んだ言語で答えます。"), ("視窗不在最前面,或 90 秒沒有動鍵盤滑鼠,就會通知。登入帳號後也會推播到手機。", "You're notified when the window isn't in front, or after 90 s without keyboard or mouse. Once signed in, your phone is notified too.", "ウィンドウが前面にないとき、または 90 秒間操作がないときに通知します。ログインするとスマホにも通知されます。"), @@ -609,7 +609,7 @@ pub const ROWS: &[(&str, &str, &str)] = &[ ("大腦所在的伺服器", "the brain's server", "ブレインのサーバー"), ("SSH 電腦", "SSH computer", "SSH コンピューター"), ("做事的電腦", "Works on", "作業するコンピューター"), - ("聊天時指名另一台(例如「在 Mac mini 上」)就會換", "Name another one in the chat (e.g. “on the Mac mini”) to switch", "チャットで別のコンピューターを指名すると切り替わります(例:「Mac mini で」)"), + ("聊天時指名別台就會換", "Name another in the chat to switch", "チャットで別の名前を言うと切り替わります"), ("正在「{name}」上工作", "Working on “{name}”", "「{name}」で作業中"), ("在「{name}」上做事 · 點一下換電腦", "Works on “{name}” · click to switch computers", "「{name}」で作業 · クリックで切り替え"), ]; From 5b2c974f3cd703d82464ec59442f798f54255f81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Thu, 5 Nov 2026 16:57:44 +0800 Subject: [PATCH 07/19] Mac permissions: ask at most every ten minutes; one fixed signing key keeps them across updates; open apps by their shown name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Every untrusted step popped macOS's prompt and opened Settings; a remote brain's steps covered the screen. Now once per pane per ten minutes. - Ad hoc signing made each build a new app to macOS, so 輔助使用/螢幕錄製 silently stopped working after updates (agent-desktop PERM_DENIED). deploy/mac/sign.sh signs with a local key kept in its own keychain under AnyBot's folder. - computer_use_open_app finds 「計算機」 by display name and says the app opened even when its window can't be read. Co-Authored-By: Claude Opus 5.5 --- deploy/mac/bundle.sh | 5 ++-- deploy/mac/sign.sh | 57 ++++++++++++++++++++++++++++++++++++ src/axread.rs | 4 +-- src/computer.rs | 70 ++++++++++++++++++++++++++++++++++++++++---- 4 files changed, 126 insertions(+), 10 deletions(-) create mode 100755 deploy/mac/sign.sh diff --git a/deploy/mac/bundle.sh b/deploy/mac/bundle.sh index 8639713..1f928f0 100755 --- a/deploy/mac/bundle.sh +++ b/deploy/mac/bundle.sh @@ -2,7 +2,7 @@ # Make AnyBot.app from the release build and zip it (run from app/ on a Mac, after # `cargo build --release`): deploy/mac/bundle.sh 1.2.0 → dist/AnyBot-1.2.0-macos-.zip # With KEEP_APP=1 it leaves dist/AnyBot.app instead of zipping it (make install). -# Signed ad hoc (no Apple developer account): the first time, right-click › 打開. +# Signed with this Mac's own fixed key (no Apple developer account): the first time, right-click › 打開. set -e VERSION=${1:?version} BIN=${CARGO_TARGET_DIR:-target}/release/anybot @@ -39,7 +39,8 @@ cat > "$APP/Contents/Info.plist" <NSAppleEventsUsageDescription讓 Bot 在你允許時操作其他 App。 PLIST -codesign --force --deep -s - "$APP" +# The same signature every build, so macOS keeps the permissions it gave (see sign.sh). +sh "$(dirname "$0")/sign.sh" "$APP" if [ "${KEEP_APP:-}" = 1 ]; then exit 0 fi diff --git a/deploy/mac/sign.sh b/deploy/mac/sign.sh new file mode 100755 index 0000000..9d9afb9 --- /dev/null +++ b/deploy/mac/sign.sh @@ -0,0 +1,57 @@ +#!/bin/sh +# Sign AnyBot.app with this Mac's own signing key, the same one every build: +# deploy/mac/sign.sh dist/AnyBot.app +# macOS keeps 輔助使用 / 螢幕錄製 for an app by its signature. Signed "ad hoc", every build was a +# new app to it, so each update quietly lost those permissions and the bots could no longer read +# or operate other apps (agent-desktop answered PERM_DENIED). One fixed self-signed key, kept in +# its own keychain under AnyBot's folder (the login keychain is not touched), keeps them. +# Anything going wrong falls back to ad hoc signing. +set -u +APP=${1:?app} +DIR="$HOME/Library/Application Support/AnyBot/signing" +KC="$DIR/anybot-signing.keychain-db" +PWF="$DIR/keychain-password" +NAME="AnyBot Local Signing" + +make_key() { + mkdir -p "$DIR" && chmod 700 "$DIR" || return 1 + head -c 24 /dev/urandom | base64 > "$PWF" && chmod 600 "$PWF" || return 1 + PW=$(cat "$PWF") + T=$(mktemp -d) + cat > "$T/cert.cnf" </dev/null 2>&1 \ + && /usr/bin/openssl pkcs12 -export -inkey "$T/key.pem" -in "$T/cert.pem" -out "$T/id.p12" -passout pass:"$PW" >/dev/null 2>&1 \ + && security create-keychain -p "$PW" "$KC" >/dev/null \ + && security set-keychain-settings "$KC" \ + && security unlock-keychain -p "$PW" "$KC" \ + && security import "$T/id.p12" -k "$KC" -P "$PW" -T /usr/bin/codesign >/dev/null \ + && security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$PW" "$KC" >/dev/null 2>&1 \ + && ok=0 + rm -rf "$T" + return $ok +} + +signed=1 +if [ -f "$KC" ] || make_key; then + if security unlock-keychain -p "$(cat "$PWF")" "$KC" 2>/dev/null \ + && codesign --force --deep --keychain "$KC" -s "$NAME" "$APP" 2>/dev/null; then + signed=0 + echo "已用這台 Mac 固定的簽章簽好(更新後「輔助使用」「螢幕錄製」權限會保留)" + fi +fi +if [ $signed -ne 0 ]; then + codesign --force --deep -s - "$APP" + echo "注意:用臨時簽章(更新後要重新允許「輔助使用」「螢幕錄製」)" +fi diff --git a/src/axread.rs b/src/axread.rs index a794bfa..701f261 100644 --- a/src/axread.rs +++ b/src/axread.rs @@ -303,8 +303,8 @@ pub fn read_person_browser(exe: &str, max_chars: usize) -> Result if !cfg!(target_os = "macos") { return Err("只支援 macOS".into()); } - if !trusted(true) { - let _ = crate::computer::spawn_reaped(&mut std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")); + if !trusted(false) { + crate::computer::ask_permission("Privacy_Accessibility"); return Err("macOS 還沒讓 AnyBot 使用「輔助使用」,所以看不到你的瀏覽器。我已經打開「系統設定 → 隱私權與安全性 → 輔助使用」:把 AnyBot(或「終端機」)打開,然後再叫我試一次。只要開一次。".into()); } let ps = std::process::Command::new("ps").args(["-axo", "pid=,args="]).output().map(|o| String::from_utf8_lossy(&o.stdout).into_owned()).unwrap_or_default(); diff --git a/src/computer.rs b/src/computer.rs index d75d535..6890f34 100644 --- a/src/computer.rs +++ b/src/computer.rs @@ -394,6 +394,53 @@ fn run_text_mode_name(name: &str, v: &Value) -> Option<()> { } } +/// Ask macOS for a permission (its own prompt, then 系統設定 at that pane) at most once every ten +/// minutes. Asking on every step meant a bot working through several steps — or a brain far away +/// working on this computer — covered the screen with prompts and Settings windows, and the +/// person could do nothing else. +pub fn ask_permission(pane: &str) { + static LAST: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + { + let mut l = LAST.lock().unwrap_or_else(|e| e.into_inner()); + if l.iter().any(|(p, t)| p == pane && t.elapsed() < std::time::Duration::from_secs(600)) { + return; + } + l.retain(|(p, _)| p != pane); + l.push((pane.to_string(), std::time::Instant::now())); + } + if !cfg!(target_os = "macos") { + return; + } + match pane { + "Privacy_Accessibility" => { + let _ = crate::axread::trusted(true); + } + #[cfg(target_os = "macos")] + "Privacy_ScreenCapture" => { + let _ = mac::screen_capture_allowed(); + } + _ => {} + } + let _ = spawn_reaped(&mut std::process::Command::new("open").arg(format!("x-apple.systempreferences:com.apple.preference.security?{pane}"))); +} + +/// The app a shown name stands for (「計算機」 → Calculator), from the apps' display names. +#[cfg(target_os = "macos")] +fn app_named(name: &str) -> Option { + if name.is_empty() || name.contains(['"', '\\', '\'']) { + return None; + } + let q = format!("kMDItemDisplayName == \"{name}\"cd && kMDItemContentType == \"com.apple.application-bundle\""); + let out = std::process::Command::new("mdfind").arg(q).output().ok()?; + let path = String::from_utf8_lossy(&out.stdout).lines().next()?.trim().to_string(); + std::path::Path::new(&path).file_stem().map(|s| s.to_string_lossy().to_string()) +} + +#[cfg(not(target_os = "macos"))] +fn app_named(_name: &str) -> Option { + None +} + /// The accessibility (text) way: agent-desktop, by element number, in the background. fn run_text_mode(name: &str, v: &Value) -> Option), String>> { let rf = v["ref"].as_str().unwrap_or("").trim().to_string(); @@ -407,9 +454,20 @@ fn run_text_mode(name: &str, v: &Value) -> Option }), "computer_use_open_app" => { let app = v["app"].as_str().unwrap_or("").trim().to_string(); - crate::desktop::run("launch", &json!({"app": app})).and_then(|_| { + // A name as the person sees it (「計算機」 on a Chinese Mac) isn't one the system + // launches by; find the app it is and launch that. + let launched = crate::desktop::run("launch", &json!({"app": app})).or_else(|e| match app_named(&app) { + Some(real) if real != app => crate::desktop::run("launch", &json!({"app": real})), + _ => Err(e), + }); + launched.map(|_| { std::thread::sleep(std::time::Duration::from_millis(800)); - crate::desktop::read_app(&app).map(|(a, lines)| format!("已打開「{a}」。\n{}", lines.join("\n"))) + // Open is open, even when its window can't be read (that's a separate problem, + // and the bot said "couldn't open" while the app sat there on the screen). + match crate::desktop::read_app(&app) { + Ok((a, lines)) => format!("已打開「{a}」。\n{}", lines.join("\n")), + Err(e) => format!("已打開「{app}」,但讀不到它的畫面:{e}"), + } }) } "computer_use_select" => crate::desktop::act("select", &rf, &json!({"value": v["value"]})), @@ -464,8 +522,8 @@ fn run_inner(name: &str, v: &Value) -> Result<(String, Option), String> if HELD_BY_PERSON.load(std::sync::atomic::Ordering::SeqCst) { return Err("使用者正在自己操作這台電腦(已取得控制權)。請等他交還控制權後再操作;可以先截圖看看。".into()); } - if cfg!(target_os = "macos") && !crate::axread::trusted(true) { - let _ = spawn_reaped(&mut std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")); + if cfg!(target_os = "macos") && !crate::axread::trusted(false) { + ask_permission("Privacy_Accessibility"); return Err("macOS 還沒讓 AnyBot 使用「輔助使用」,所以不能動滑鼠和鍵盤。我已經打開「系統設定 → 隱私權與安全性 → 輔助使用」:把 AnyBot(或「終端機」)打開,再叫我試一次。只要開一次。".into()); } let scale = || SCALE.lock().unwrap_or_else(|e| e.into_inner()).ok_or_else(|| "請先用 computer_use_screenshot 看一下畫面,座標要用截圖上的位置。".to_string()); @@ -631,8 +689,8 @@ pub fn app_at_point(x: f64, y: f64) -> Option { } fn screenshot() -> Result<(String, String), String> { - if !mac::screen_capture_allowed() { - let _ = spawn_reaped(&mut std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture")); + if !mac::screen_capture_preflight() { + ask_permission("Privacy_ScreenCapture"); return Err("macOS 還沒讓 AnyBot 錄製螢幕,所以看不到畫面。我已經打開「系統設定 → 隱私權與安全性 → 螢幕與系統錄音」:把 AnyBot(或「終端機」)打開,再叫我試一次。只要開一次。".into()); } let img = capture_screen()?; From ab564f706f5a6acaae59702b5be998f4d752093f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Thu, 5 Nov 2026 17:07:04 +0800 Subject: [PATCH 08/19] The bots' computer never sleeps; a server brain doesn't work on the server; the computer choice lives in the side panel; bots default to their own computer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - No idle pause: an asleep box is woken; its files are saved once a day instead of before sleep. - A headless brain offers no Local computer; attachments it receives go into the bot's own one. - 「你的電腦」 kept by older versions means the bot's own computer; only an explicit choice moves it. - The composer chip moved to the panel's 電腦 card (做事的電腦). Co-Authored-By: Claude Opus 5.5 --- src/app.rs | 47 +++++---------------------- src/app/computers_ui.rs | 14 +++------ src/app/panel_ui.rs | 23 ++++++++++++++ src/app/remote_ui.rs | 5 ++- src/appctl.rs | 13 +++----- src/attach.rs | 2 +- src/cloudbox.rs | 70 +++++++++++++---------------------------- src/i18n_table.rs | 2 ++ 8 files changed, 68 insertions(+), 108 deletions(-) diff --git a/src/app.rs b/src/app.rs index 4919503..86b7144 100644 --- a/src/app.rs +++ b/src/app.rs @@ -832,7 +832,11 @@ impl App { } v.push(t); }; - add(&mut v, tools::Target { name: self.state.this_computer_name(), place: tools::Place::Local { workdir: tools::expand_home(if b.workdir.trim().is_empty() { "~" } else { b.workdir.trim() }) } }); + // A brain on a server works on the bots' own computer and the person's computers, never on + // the server itself (the person asked for it to be off the list). + if !self.headless { + add(&mut v, tools::Target { name: self.state.this_computer_name(), place: tools::Place::Local { workdir: tools::expand_home(if b.workdir.trim().is_empty() { "~" } else { b.workdir.trim() }) } }); + } for c in &self.state.remote_computers { add(&mut v, tools::Target { name: c.name.clone(), place: tools::Place::Ssh { target: c.ssh_target.clone(), dir: c.dir.clone() } }); } @@ -873,9 +877,10 @@ impl App { if want.is_empty() || want == crate::model::BOT_COMPUTER_NAME || want == roles.own { return (roles.own.clone(), None); } - // Kept by older versions: the person's own computer. + // Unless the person chose another computer, a bot works on its own computer (in the + // cloud). 「你的電腦」 from older versions named no computer in particular: the bot's own. if want == crate::model::THIS_COMPUTER_NAME { - return (roles.person.clone().unwrap_or_else(|| roles.own.clone()), None); + return (roles.own.clone(), None); } if targets.iter().any(|t| t.name == want) { return (want.to_string(), None); @@ -2331,10 +2336,6 @@ impl App { ui.label(RichText::new(format!("{}{}", t("成員:"), names.join("、"))).size(FONT_XS).color(p.text2)); } }); - // 在哪台電腦做事: always in sight, one click to change. - if bot.members.is_empty() { - self.computer_chip(ui, bot); - } let right_w = 28.0 * 2.0 + 8.0 + 4.0; let text_w = (ui.available_width() - right_w).max(60.0); let hint = if self.mir.on && !self.mir.online { t("連不到大腦,現在只能看") } else { t("傳訊息給 {name}").replace("{name}", &bot.name) }; @@ -2603,38 +2604,6 @@ impl App { } } - /// The computer the bot works on, as a small button next to the message box; it opens the - /// list of computers (each by its name) to choose another. - fn computer_chip(&mut self, ui: &mut egui::Ui, bot: &Bot) { - let p = self.pal; - let (choices, current) = self.computer_choices(bot); - let short: String = if current.chars().count() > 14 { format!("{}…", current.chars().take(13).collect::()) } else { current.clone() }; - let chip = crate::icons::pill(ui, crate::icons::Icon::Monitor, &short, p.hover, p.text2, 28.0).on_hover_text(t("在「{name}」上做事 · 點一下換電腦").replace("{name}", ¤t)); - let mut pick: Option = None; - egui::Popup::menu(&chip).show(|ui| { - ui.set_min_width(menu_ui::MENU_W + 40.0); - ui.label(RichText::new(t("要 {name} 在哪台電腦做事").replace("{name}", &bot.name)).size(FONT_XS).color(p.text3)); - for (name, kind) in &choices { - let what = t(match kind.as_str() { - "own" => "它自己的電腦", - "person" => "你正在用的這台", - "this" => "這台電腦", - "server" => "大腦所在的伺服器", - "device" => "帳號裡的電腦", - _ => "SSH 電腦", - }); - let icon = if *name == current { crate::icons::Icon::Check } else { crate::icons::Icon::Monitor }; - if menu_ui::item_text(ui, p, Some(icon), &format!("{name} · {what}")).clicked() { - pick = Some(name.clone()); - ui.close(); - } - } - }); - if let Some(n) = pick { - self.set_computer(bot.id, &n); - } - } - fn model_picker(&mut self, ui: &mut egui::Ui, bot: &Bot) { let p = self.pal; let label = format!("{} · {}", short_provider(bot.provider), bot.model); diff --git a/src/app/computers_ui.rs b/src/app/computers_ui.rs index 2330287..9c97895 100644 --- a/src/app/computers_ui.rs +++ b/src/app/computers_ui.rs @@ -377,7 +377,6 @@ impl App { Download, Docker, Browser, - Sleep, Wake, View, UpdateClick, @@ -444,16 +443,12 @@ impl App { } _ => { // The screen: AnyBot has no per-bot overview, so it opens from here. - sec_row(ui, p, "Bot 的電腦", "一台 Linux 電腦,你所有的 Bot 共用,每個 Bot 有自己的資料夾。閒置一陣子會自動休眠,需要時自動喚醒。", |ui| { + sec_row(ui, p, "Bot 的電腦", "一台 Linux 電腦,你所有的 Bot 共用,每個 Bot 有自己的資料夾(例如「Master 的電腦」)。一直開著,不會休眠。", |ui| { if ui.add(primary_button("開啟電腦", p)).on_hover_text(t("看 Bot 的電腦的螢幕,也可以取得控制權自己操作")).clicked() { act = Act::View; } match run { - Run::Running => { - if ui.add(secondary_button("休眠")).clicked() { - act = Act::Sleep; - } - } + Run::Running => {} _ => { if ui.add(secondary_button("喚醒電腦")).clicked() { act = Act::Wake; @@ -506,11 +501,11 @@ impl App { // 重設 Bot 的電腦: from the last snapshot (red, asks first). let snap = cloudbox::snapshot_time().map(|t| t.format("%m/%d %H:%M").to_string()); let desc = match &snap { Some(t) => format!("上次快照:{t}"), None => t("還沒有快照") }; - sec_row_s(ui, p, "重設 Bot 的電腦", &desc, "電腦卡住時可重新開始。它會從你上次儲存的快照重建,最近的變更可能會遺失;沒有快照時會換一台全新的電腦。「儲存快照」把 Bot 的檔案備份到這台電腦,休眠前也會自動存一份。", |ui| { + sec_row_s(ui, p, "重設 Bot 的電腦", &desc, "電腦卡住時可重新開始。它會從你上次儲存的快照重建,最近的變更可能會遺失;沒有快照時會換一台全新的電腦。「儲存快照」把 Bot 的檔案備份到這台電腦,每天也會自動存一份。", |ui| { if ui.add_enabled(!busy_rebuild, danger_button("重設", p)).clicked() { act = Act::Reset; } - if ui.add_enabled(run.is_running(), secondary_button("儲存快照")).on_hover_text(t("把 Bot 的檔案備份到這台電腦;休眠前也會自動存一份")).clicked() { + if ui.add_enabled(run.is_running(), secondary_button("儲存快照")).on_hover_text(t("把 Bot 的檔案備份到這台電腦;每天也會自動存一份")).clicked() { act = Act::Snapshot; } }); @@ -545,7 +540,6 @@ impl App { Act::Download => crate::preview::open_with_system("https://orbstack.dev/download"), Act::Docker => crate::preview::open_with_system("https://docs.docker.com/engine/install/"), Act::View => self.open_viewer(), - Act::Sleep => self.box_task(|| cloudbox::sleep_now().map(|_| "已休眠。".to_string())), Act::Wake => self.box_task(|| cloudbox::ensure_running().map(|_| "Bot 的電腦已經開好了。".to_string())), Act::Browser => self.box_task(|| { let url = cloudbox::viewer_url(false)?; diff --git a/src/app/panel_ui.rs b/src/app/panel_ui.rs index 3290135..aac19fa 100644 --- a/src/app/panel_ui.rs +++ b/src/app/panel_ui.rs @@ -166,6 +166,9 @@ impl App { let thumb = if available { self.box_thumbnail(ctx) } else { None }; let own_name = crate::model::own_computer_name(&bot.name); let brain_name = self.state.remote.brain_name.clone(); + // 做事的電腦: which computer this bot works on (its own unless the person picks another). + let (choices, current) = if bot.members.is_empty() { self.computer_choices(bot) } else { (vec![], String::new()) }; + let mut pick: Option = None; panel_card(ui, p, |ui| { let run = crate::cloudbox::run_state(); let running = if brain { self.mir.box_state["running"].as_bool().unwrap_or(false) } else { run.is_running() }; @@ -207,7 +210,27 @@ impl App { } let tip = if running { t("看「{name}」的螢幕,也可以取得控制權自己操作") } else { t("叫醒「{name}」並看它的螢幕") }.replace("{name}", &own_name); view = r.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(tip).clicked(); + if !choices.is_empty() { + panel_select(ui, p, "做事的電腦", ("computer", bot.id), ¤t, |ui| { + for (name, kind) in &choices { + let what = t(match kind.as_str() { + "own" => "它自己的電腦", + "person" => "你正在用的這台", + "this" => "這台電腦", + "server" => "大腦所在的伺服器", + "device" => "帳號裡的電腦", + _ => "SSH 電腦", + }); + if ui.selectable_label(*name == current, format!("{name} · {what}")).clicked() { + pick = Some(name.clone()); + } + } + }); + } }); + if let Some(n) = pick { + self.set_computer(bot.id, &n); + } if view { if available { self.open_viewer(); diff --git a/src/app/remote_ui.rs b/src/app/remote_ui.rs index 5eaaf9a..d1c3a20 100644 --- a/src/app/remote_ui.rs +++ b/src/app/remote_ui.rs @@ -281,7 +281,10 @@ impl App { 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); - Some(crate::attach::Attachment { image: crate::attach::is_image_name(&name), name, path: f.display().to_string(), bytes: meta.len(), on_box: String::new() }) + // 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) { diff --git a/src/appctl.rs b/src/appctl.rs index 298798e..9e4226a 100644 --- a/src/appctl.rs +++ b/src/appctl.rs @@ -551,14 +551,11 @@ pub fn apply(state: &mut State, bot_id: u64, name: &str, args: &str) -> Outcome let serving = SERVING.load(std::sync::atomic::Ordering::Relaxed); let own = state.bot(bot_id).map(|b| model::own_computer_name(&b.name)).unwrap_or_else(|| model::BOT_COMPUTER_NAME.into()); let here = state.this_computer_name(); - let mut lines = vec![ - format!("「{own}」:你自己的電腦(AI 的電腦,不是使用者的電腦,獨立資料夾),一律可用"), - if serving { - format!("「{here}」:AnyBot 大腦所在的伺服器(不是使用者在用的電腦)") - } else { - format!("「{here}」:AnyBot 開著的這台{},{}", if cfg!(target_os = "macos") { " Mac" } else { "電腦" }, if state.local_execution { "已允許" } else { "使用者還沒在設定裡允許(設定 → 電腦 → 在這台電腦上執行)" }) - }, - ]; + let mut lines = vec![format!("「{own}」:你自己的電腦(AI 的電腦,不是使用者的電腦,獨立資料夾),一律可用")]; + // A brain on a server doesn't work on the server itself. + if !serving { + lines.push(format!("「{here}」:AnyBot 開著的這台{},{}", if cfg!(target_os = "macos") { " Mac" } else { "電腦" }, if state.local_execution { "已允許" } else { "使用者還沒在設定裡允許(設定 → 電腦 → 在這台電腦上執行)" })); + } lines.extend(state.remote_computers.iter().map(|c| format!("「{}」:透過 SSH 連到 {}", c.name, c.ssh_target))); ok(lines.join("\n")) } diff --git a/src/attach.rs b/src/attach.rs index 5dac729..94d6d10 100644 --- a/src/attach.rs +++ b/src/attach.rs @@ -159,7 +159,7 @@ pub fn for_model(atts: &[Attachment]) -> (String, Vec) { let mut text = String::new(); let mut images = vec![]; for a in atts { - let where_ = if a.on_box.is_empty() { format!("檔案存在 AnyBot 所在的電腦:{}", a.path) } else { format!("檔案存在 AnyBot 所在的電腦:{};也已放進你自己的電腦:{}", a.path, a.on_box) }; + let where_ = if a.on_box.is_empty() { format!("檔案存在 AnyBot 所在的電腦:{}", a.path) } else { format!("檔案已放進你自己的電腦:{}", a.on_box) }; if a.image { match image_b64(&a.path) { Some(b) => { diff --git a/src/cloudbox.rs b/src/cloudbox.rs index ca6f246..25d94f6 100644 --- a/src/cloudbox.rs +++ b/src/cloudbox.rs @@ -50,8 +50,11 @@ pub const COPY_IN_POLL_MS: u64 = 2_000; pub const RESUME_FRESH_MS: u64 = 600_000; /// Boot: longest wait for the desktop to come up (estimated). pub const BOOT_WAIT_MS: u64 = 60_000; -/// Idle time before the box goes to sleep (estimated; the original parks an idle box). -pub const IDLE_SLEEP_SECS: u64 = 900; +/// Quiet time before the daily snapshot is taken (nobody using the box for this long). +pub const QUIET_SECS: u64 = 900; +/// The box's files are saved to this computer about once a day (it no longer sleeps, which is +/// when they used to be saved). +pub const SNAPSHOT_EVERY_SECS: i64 = 24 * 3600; /// Secret names: an upper-case letter, then A–Z, 0–9, _ (original rule); size limits estimated. pub const SECRET_MAX_VALUE_BYTES: usize = 32_768; pub const SECRET_MAX_COUNT: usize = 100; @@ -681,20 +684,10 @@ pub fn ensure_running() -> Result<(), String> { result } -/// Put the box to sleep now (saves a snapshot first, like the original's flush before parking). -pub fn sleep_now() -> Result<(), String> { - if !matches!(probe(), Run::Running) { - return Ok(()); - } - if is_busy() { - return Err("有 Bot 正在用這台電腦,等它做完再休眠。".into()); - } - let _ = save_snapshot(); - log("電腦進入休眠。"); - dk(&["pause", CONTAINER]).map(|_| set_run(Run::Asleep)) -} - -/// Started once: puts an idle box to sleep, and replays a rebuild cut short by a restart. +/// Started once: keeps the box awake (wakes it when it was asleep), saves its files once a day, +/// and replays a rebuild cut short by a restart. The person asked for the bots' computer to be +/// always there: an idle box used to be paused after 15 minutes, and the person — and a bot +/// working from a brain far away — kept finding it "asleep". pub fn start_background() { static STARTED: AtomicBool = AtomicBool::new(false); if STARTED.swap(true, Ordering::SeqCst) { @@ -704,24 +697,24 @@ pub fn start_background() { set_run(probe()); resume_interrupted(); loop { - std::thread::sleep(Duration::from_secs(20)); - let (idle, running, rebuilding) = { + let (idle, rebuilding) = { let s = shared().lock().unwrap(); - (s.last_used.elapsed().as_secs(), s.run.is_running(), s.rebuilding) + (s.last_used.elapsed().as_secs(), s.rebuilding) }; - let awake = running && !rebuilding && !is_busy() && !HELD_BY_PERSON.load(Ordering::SeqCst) && !viewer_open(); - // A browser on the desktop was left open on purpose (a login page the person walks - // back to): it buys the box four times the idle time before parking. Freezing the - // container froze Chromium and websockify together, so a bot that "opened the - // browser" left the person a viewer that never showed anything. - let limit = if awake && browser_running() { IDLE_SLEEP_SECS * 4 } else { IDLE_SLEEP_SECS }; - if awake && idle >= limit && vnc_clients() == 0 { - let _ = sleep_now(); - } if !rebuilding { + if matches!(probe(), Run::Asleep) { + log("Bot 的電腦不休眠:叫醒它。"); + let _ = ensure_running(); + } + let quiet = idle >= QUIET_SECS && !is_busy() && !HELD_BY_PERSON.load(Ordering::SeqCst) && !viewer_open(); + let due = snapshot_time().map(|t| (chrono::Local::now() - t).num_seconds() >= SNAPSHOT_EVERY_SECS).unwrap_or(true); + if quiet && due && run_state().is_running() { + let _ = save_snapshot(); + } refresh(); maybe_scheduled_update(); } + std::thread::sleep(Duration::from_secs(20)); } }); } @@ -737,19 +730,6 @@ fn viewer_open() -> bool { VIEWER_OPEN.load(Ordering::SeqCst) } -/// Viewer tabs open on the box's desktop: every noVNC page keeps a websockify connection to -/// x11vnc on :5900 (:170C hex), so a live connection means the person is looking at it now. -fn vnc_clients() -> usize { - dk(&["exec", CONTAINER, "sh", "-c", - "awk '$4==\"01\" && ($2 ~ /:170C$/ || $3 ~ /:170C$/) {n++} END {print n+0}' /proc/net/tcp /proc/net/tcp6 2>/dev/null"]) - .ok() - .and_then(|s| parse_count(&s)) - .unwrap_or(0) -} - -fn parse_count(s: &str) -> Option { - s.trim().lines().last()?.trim().parse().ok() -} // ---------- snapshots (the durable store) ---------- @@ -1481,14 +1461,6 @@ pub fn shutdown() { mod tests { use super::*; - #[test] - fn vnc_probe_parses_only_a_count() { - assert_eq!(parse_count("3"), Some(3)); - assert_eq!(parse_count(" 2 \n"), Some(2)); - assert_eq!(parse_count(""), None); - assert_eq!(parse_count("Error response from daemon: container paused"), None); - } - #[test] fn secret_names_follow_the_original_rule() { assert!(valid_secret_name("CRM_API_TOKEN")); diff --git a/src/i18n_table.rs b/src/i18n_table.rs index ed98ab2..43e08a1 100644 --- a/src/i18n_table.rs +++ b/src/i18n_table.rs @@ -612,4 +612,6 @@ pub const ROWS: &[(&str, &str, &str)] = &[ ("聊天時指名別台就會換", "Name another in the chat to switch", "チャットで別の名前を言うと切り替わります"), ("正在「{name}」上工作", "Working on “{name}”", "「{name}」で作業中"), ("在「{name}」上做事 · 點一下換電腦", "Works on “{name}” · click to switch computers", "「{name}」で作業 · クリックで切り替え"), + ("一台 Linux 電腦,你所有的 Bot 共用,每個 Bot 有自己的資料夾(例如「Master 的電腦」)。一直開著,不會休眠。", "A Linux computer all your bots share; each bot has its own folder (e.g. “Master's computer”). Always on, it never sleeps.", "すべての Bot が共有する Linux コンピューター。Bot ごとにフォルダーがあります(例:「Master のコンピューター」)。常に起動していて、スリープしません。"), + ("把 Bot 的檔案備份到這台電腦;每天也會自動存一份", "Back up the bots' files to this computer; a copy is also saved every day", "Bot のファイルをこのコンピューターにバックアップします。毎日自動でも保存します"), ]; From c3e2e28ec402cdb1f4aa446548838db42fcd24a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Thu, 5 Nov 2026 17:10:05 +0800 Subject: [PATCH 09/19] Only the brain keeps its box awake: a computer showing another brain leaves its own box alone Co-Authored-By: Claude Opus 5.5 --- src/app/mirror_ui.rs | 3 +++ src/cloudbox.rs | 12 ++++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/app/mirror_ui.rs b/src/app/mirror_ui.rs index 724e615..6708af2 100644 --- a/src/app/mirror_ui.rs +++ b/src/app/mirror_ui.rs @@ -762,6 +762,8 @@ impl App { self.mir.base = Value::Null; self.mir.local = Some(local); self.mir.on = true; + // The bots' computer is the brain's now; this one's box isn't kept awake. + crate::cloudbox::set_brain(false); self.mir.online = false; self.mir.version.store(0, Ordering::SeqCst); self.mir.pending.lock().unwrap().clear(); @@ -777,6 +779,7 @@ impl App { fn mirror_leave(&mut self) { self.mir.gen.fetch_add(1, Ordering::SeqCst); self.mir.on = false; + crate::cloudbox::set_brain(true); self.mir.online = false; self.mir.outbox = None; self.streams.clear(); diff --git a/src/cloudbox.rs b/src/cloudbox.rs index 25d94f6..10540a9 100644 --- a/src/cloudbox.rs +++ b/src/cloudbox.rs @@ -697,12 +697,14 @@ pub fn start_background() { set_run(probe()); resume_interrupted(); loop { + // (First wait a little: a computer showing another brain says so right after start.) + std::thread::sleep(Duration::from_secs(20)); let (idle, rebuilding) = { let s = shared().lock().unwrap(); (s.last_used.elapsed().as_secs(), s.rebuilding) }; if !rebuilding { - if matches!(probe(), Run::Asleep) { + if IS_BRAIN.load(Ordering::SeqCst) && matches!(probe(), Run::Asleep) { log("Bot 的電腦不休眠:叫醒它。"); let _ = ensure_running(); } @@ -714,11 +716,17 @@ pub fn start_background() { refresh(); maybe_scheduled_update(); } - std::thread::sleep(Duration::from_secs(20)); } }); } +/// This AnyBot is the brain (its box is the bots' computer). A computer showing another brain +/// doesn't use its own box, so it isn't kept awake there. +static IS_BRAIN: AtomicBool = AtomicBool::new(true); +pub fn set_brain(on: bool) { + IS_BRAIN.store(on, Ordering::SeqCst); +} + static VIEWER_OPEN: AtomicBool = AtomicBool::new(false); pub fn set_viewer_open(on: bool) { VIEWER_OPEN.store(on, Ordering::SeqCst); From 46c0684cb433ce5dbc688bdad1bd1f6df5ef48e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Thu, 5 Nov 2026 17:29:55 +0800 Subject: [PATCH 10/19] =?UTF-8?q?=E9=9C=80=E8=A6=81=E8=99=95=E7=90=86:=20a?= =?UTF-8?q?=20bot=20hands=20a=20step=20to=20the=20person=20with=20its=20co?= =?UTF-8?q?mputer's=20live=20screen=20in=20the=20chat;=20the=20window=20fi?= =?UTF-8?q?lls=20to=20the=20top;=20the=20=E9=9B=BB=E8=85=A6=20card=20and?= =?UTF-8?q?=20viewer=20keep=20their=20text=20in=20place?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - hand_over_computer (the original's box hand-off card): instruction, live screen with 接管, 跳過 / 我完成了 / 接管; 我完成了 also in the viewer; a typed message answers it too. Header, sidebar and notifications say 等你處理 / 「{name}:需要你」; the phone shows it with 接管. - Mac: the content reaches the top (no grey title bar); the top strip drags the window. - Panel 電腦 card: each line stops before the picture; the brain's name on its own line. - Viewer: buttons first, the title ends in … when narrow; the picture is centred. Co-Authored-By: Claude Opus 5.5 --- mobile/index.html | 8 +- src/agent.rs | 105 +++++++++++++++++++++++++ src/app.rs | 166 +++++++++++++++++++++++++++++++++++++--- src/app/computers_ui.rs | 85 +++++++++++++++++--- src/app/mirror_ui.rs | 2 + src/app/panel_ui.rs | 25 ++++-- src/app/remote_ui.rs | 4 +- src/app/sidebar_ui.rs | 2 +- src/i18n_table.rs | 9 +++ src/main.rs | 21 ++--- src/providers.rs | 4 + src/tools.rs | 4 + 12 files changed, 393 insertions(+), 42 deletions(-) diff --git a/mobile/index.html b/mobile/index.html index e8f1d71..5bcf3fa 100644 --- a/mobile/index.html +++ b/mobile/index.html @@ -647,8 +647,14 @@ function renderAsk() { const a = S.approvals.find((x) => x.bot === S.open); if (!a) { $("ask").innerHTML = ""; return; } if (S.role === "member") { $("ask").innerHTML = `
${esc(a.title)}
等擁有者或管理員決定
`; return; } + // 需要處理 (a step only you can do on a computer): its screen is one tap away, then 我完成了 / 跳過. + if (a.kind === "handoff") { + $("ask").innerHTML = `
需要處理 · ${esc(a.computer || "")}
${esc(a.reason)}
`; + $("ask").querySelector("[data-screen]").onclick = () => { S.back = S.view; show("screen"); $("seg").children[0].textContent = ownComputer(); $("seg").children[1].style.display = S.mac ? "" : "none"; startFrames(); }; + } else { $("ask").innerHTML = `
${esc(a.title)}
${esc(a.reason)}
${a.can_always ? `` : ""}
`; - $("ask").querySelectorAll("button").forEach((b) => b.onclick = async () => { + } + $("ask").querySelectorAll("button[data-a]").forEach((b) => b.onclick = async () => { $("ask").innerHTML = ""; try { await api(base() + `api/bots/${S.open}/answer`, {json: {answer: b.dataset.a}}); } catch (e) { toast(e.message); } }); diff --git a/src/agent.rs b/src/agent.rs index 2733b65..0c31819 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -515,6 +515,46 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< send(StreamEvent::NeedApproval(Approval { call_id: call.id.clone(), tool: call.name.clone(), reason, grant, reply: atx, kind })); wait_answer(arx, &cancel) }; + // 需要處理 (the original's box hand-off): a step only the person can do — signing + // in, a captcha, a code from their phone. A card with the computer's live screen + // appears in the chat; they take over, do it, and say 「我完成了」 or 「跳過」. A + // message typed meanwhile answers it too. + if call.name == tools::HANDOFF_TOOL { + let instruction = tools::args_of(&call.args)["instruction"].as_str().unwrap_or("").trim().to_string(); + let (output, status) = match screen_target(&job, &call.args) { + Err(e) => (e, ToolStatus::Failed), + Ok(_) if instruction.is_empty() => ("請在 instruction 寫清楚要使用者做什麼。".to_string(), ToolStatus::Failed), + Ok(t) => { + let (atx, arx) = channel(); + send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Waiting)); + send(StreamEvent::NeedApproval(Approval { call_id: call.id.clone(), tool: call.name.clone(), reason: instruction.clone(), grant: t.name.clone(), reply: atx, kind: ApprovalKind::Handoff })); + let answer = loop { + if cancel.load(Ordering::Relaxed) { + return; + } + match arx.recv_timeout(Duration::from_millis(200)) { + Ok(a) => break Some(a), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + if !job.steer.lock().unwrap().is_empty() { + break None; + } + } + Err(_) => break Some(ApprovalAnswer::Reject), + } + }; + match answer { + Some(ApprovalAnswer::Reject) => ("使用者選擇跳過這一步。不要再要求同一件事:沒有它就繼續其餘的工作,做不到的照實說。".to_string(), ToolStatus::Denied), + Some(_) => (format!("[{}] 使用者按了「我完成了」。先截圖確認畫面,再接著做。", t.name), ToolStatus::Done), + None => ("使用者沒有按按鈕,而是傳了訊息(就在下面)。照他說的做。".to_string(), ToolStatus::Done), + } + } + }; + send(StreamEvent::ToolStatus(call.id.clone(), status)); + let result = Message::tool_result(&call.id, &call.name, ¬ed(&moved, &call.id, &output)); + send(StreamEvent::ToolResult(result.clone())); + history.push(result); + continue; + } // 生活卡片: drawn by the window from the call itself. if call.name == "show_card" { send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Done)); @@ -1374,6 +1414,71 @@ mod tests { assert!(result.contains("note.txt"), "{result}"); } + /// 需要處理: the model hands a step to the person; the card's request carries the instruction + /// and the computer, 「我完成了」 tells the bot to carry on, 「跳過」 that the step is skipped. + #[test] + fn a_hand_off_waits_for_the_person_and_says_what_they_chose() { + use std::io::{Read, Write}; + use std::net::TcpListener; + for (answer, expect, status) in [(ApprovalAnswer::Once, "我完成了", ToolStatus::Done), (ApprovalAnswer::Reject, "跳過", ToolStatus::Denied)] { + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = l.local_addr().unwrap().port(); + std::thread::spawn(move || { + let replies = [ + "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"h1\",\"function\":{\"name\":\"hand_over_computer\",\"arguments\":\"{\\\"instruction\\\":\\\"請登入 Threads\\\"}\"}}]}}]}\n\ndata: [DONE]\n\n", + "data: {\"choices\":[{\"delta\":{\"content\":\"好\"}}]}\n\ndata: [DONE]\n\n", + ]; + for body in replies { + let (mut s, _) = l.accept().unwrap(); + let mut req = Vec::new(); + let mut buf = [0u8; 8192]; + loop { + let n = s.read(&mut buf).unwrap_or(0); + if n == 0 { + break; + } + req.extend_from_slice(&buf[..n]); + let t = String::from_utf8_lossy(&req).to_string(); + if let Some(end) = t.find("\r\n\r\n") { + let len = t[..end].lines().find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::().unwrap_or(0))).unwrap_or(0); + if req.len() >= end + 4 + len { + break; + } + } + } + let _ = s.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n"); + let _ = s.write_all(body.as_bytes()); + } + }); + let job = Job { + req: ChatRequest { kind: ProviderKind::Custom, base_url: format!("http://127.0.0.1:{port}/v1"), api_key: String::new(), model: "m".into(), system: String::new(), history: vec![Message::user("幫我登入 Threads")], tools: vec![], session: String::new() }, + computers: vec![tools::Target { name: "王小明的Mac mini".into(), place: tools::Place::Device { host: "m".into() } }], + default_computer: "王小明的Mac mini".into(), + roles: tools::Roles { own: "Master 的電腦".into(), person: Some("王小明的Mac mini".into()), server: true }, + ..test_job() + }; + let (tx, rx) = channel(); + spawn(job, 1, tx, Arc::new(AtomicBool::new(false)), eframe::egui::Context::default()); + let (mut asked, mut result, mut last) = (None, String::new(), None); + loop { + match rx.recv_timeout(Duration::from_secs(10)).expect("loop stalled") { + (_, StreamEvent::NeedApproval(a)) => { + asked = Some((a.kind, a.reason.clone(), a.grant.clone())); + let _ = a.reply.send(answer.clone()); + } + (_, StreamEvent::ToolStatus(_, st)) => last = Some(st), + (_, StreamEvent::ToolResult(m)) => result = m.text, + (_, StreamEvent::Done) => break, + (_, StreamEvent::Error(e)) => panic!("{e}"), + _ => {} + } + } + assert_eq!(asked, Some((ApprovalKind::Handoff, "請登入 Threads".to_string(), "王小明的Mac mini".to_string()))); + assert!(result.contains(expect), "{result}"); + assert_eq!(last, Some(status)); + } + } + /// 直接操作: tools off, this Mac off, a command that changes files — and still no question. /// Dangerous commands stay blocked. #[test] diff --git a/src/app.rs b/src/app.rs index 86b7144..10fb1a8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -44,6 +44,9 @@ pub const SIDEBAR_DEFAULT_WIDTH: f32 = 280.0; pub const SIDEBAR_MIN_WIDTH: f32 = 240.0; pub const SIDEBAR_MAX_WIDTH: f32 = 400.0; pub const SIDEBAR_RAIL_WIDTH: f32 = 88.0; +/// Mac: the window has no title bar of its own (the content reaches the top); this strip at the +/// top moves the window, and the traffic lights sit in its left corner. +pub const TITLEBAR_H: f32 = 28.0; pub const SIDEBAR_COLLAPSE_BELOW: f32 = 210.0; pub const SIDEBAR_EXPAND_FROM: f32 = 220.0; /// `--sand-chat-min-width:424px` (index CSS, class sand-1fiktjy). @@ -491,6 +494,13 @@ impl App { } } } + // 需要處理: the bot waits for the person on its own computer (the hand-off card). + Ok("handoff") => { + app.state.selected = 1; + let (reply, _) = channel(); + let own = app.state.bot(1).map(|b| crate::model::own_computer_name(&b.name)).unwrap_or_default(); + app.approvals.insert(1, Approval { call_id: "h1".into(), tool: tools::HANDOFF_TOOL.into(), reason: "請在 Threads 登入頁輸入帳號和密碼後登入,好了按「我完成了」。".into(), grant: own, reply, kind: ApprovalKind::Handoff }); + } Ok("panel") => { // The real box, so the 電腦 card shows its screen. crate::cloudbox::start_background(); @@ -550,6 +560,13 @@ impl App { app.show_settings = true; app.settings_tab = SettingsTab::Remote; } + // …or the side panel, or the computer viewer, as they look with that data. + if app.screenshot.is_some() && !app.demo { + match std::env::var("ANYBOT_SHOT_VIEW").as_deref() { + Ok("panel") => app.panel = Some(panel_ui::PanelTab::Activity), + _ => {} + } + } app } @@ -756,6 +773,13 @@ impl App { if !screens.is_empty() { let default_screen = if screens.contains(&default_computer) { default_computer.clone() } else { screens[0].clone() }; tool_specs.extend(crate::computer::specs_for(&screens, &default_screen)); + // 需要處理: hand a step to the person on that screen (the original's box hand-off). + tool_specs.push(serde_json::json!({"name": tools::HANDOFF_TOOL, + "description": "Ask the person to do one step themselves on a computer's screen: signing in (you never type their passwords), a captcha or puzzle, a code sent to their phone or email, approving a payment, or anything else only they may do. First get the computer to that point (open the page, click into the sign-in form), then call this with a short instruction. A card with that computer's live screen appears in the chat; they take over, do it, and press 「我完成了」 (or 「跳過」). The result says which; after 「我完成了」 take a screenshot and carry on. Don't ask them in words to find a window themselves.", + "parameters": {"type": "object", "properties": { + "instruction": {"type": "string", "description": "What they should do, one or two short sentences in their language, e.g. 「請在 Threads 登入頁輸入帳號和密碼後登入」."}, + "computer": {"type": "string", "enum": screens.clone(), "description": format!("Which computer's screen. Default: {default_screen}.")}}, + "required": ["instruction"]}})); } // 看圖小幫手: the person's models that may see pictures, for a bot whose model can't. providers::set_eyes(self.eyes()); @@ -1293,6 +1317,7 @@ impl App { } } ApprovalKind::EnableThisComputer => self.state.local_execution = true, + ApprovalKind::Handoff => {} ApprovalKind::Action => { if let Some(b) = self.state.bot_mut(bot_id) { if !g.is_empty() && !b.allow_rules.contains(g) { @@ -1428,6 +1453,10 @@ impl App { if let Some(c) = b.messages.iter_mut().rev().flat_map(|m| m.tool_calls.iter_mut()).find(|c| c.id == call_id) { c.status = st; } + // The step moved on without the buttons (a message answered a hand-off). + if st != ToolStatus::Waiting && self.approvals.get(&id).map(|a| a.call_id == call_id).unwrap_or(false) { + self.approvals.remove(&id); + } } other => { let Some(m) = b.messages.last_mut() else { continue }; @@ -1734,6 +1763,10 @@ impl App { fn rail(&mut self, ui: &mut egui::Ui) { let p = self.pal; ui.vertical_centered(|ui| { + // (The Mac's window buttons sit in the top corner.) + if cfg!(target_os = "macos") { + ui.add_space(TITLEBAR_H - 6.0); + } if icon_button(ui, "◨", "展開側邊欄", p).clicked() { self.state.sidebar_collapsed = false; self.mark(); @@ -2020,9 +2053,11 @@ impl App { self.voice_toggle(&ctx, bot.id); } } - if self.approvals.contains_key(&bot.id) { - if crate::icons::pill(&mut right, crate::icons::Icon::Shield, &t("需要你允許"), p.accent, Color32::WHITE, 36.0).on_hover_text(t("打開請求視窗")).clicked() { + if let Some(kind) = self.approvals.get(&bot.id).map(|a| a.kind) { + let label = if kind == ApprovalKind::Handoff { t("等你處理") } else { t("需要你允許") }; + if crate::icons::pill(&mut right, crate::icons::Icon::Shield, &label, p.accent, Color32::WHITE, 36.0).on_hover_text(t("打開請求視窗")).clicked() { self.popup_hidden.remove(&bot.id); + self.stick_bottom = true; } } let right_w = bar.right() - right.min_rect().left(); @@ -2096,11 +2131,12 @@ impl App { ApprovalKind::EnableThisComputer => format!("允許 {} 在你的電腦上執行任務嗎?", bot.name), ApprovalKind::Action if machine.is_empty() => format!("允許 {} {}嗎?", bot.name, what), ApprovalKind::Action => format!("允許 {} 在 {machine} 上{what}嗎?", bot.name), + ApprovalKind::Handoff => format!("{}:需要你", bot.name), }; let note = match kind { ApprovalKind::EnableTools => Some("這個 Bot 的電腦工具還沒打開。它想做的事:"), ApprovalKind::EnableThisComputer => Some("「在這台電腦上執行」還沒打開,Bot 平常只在它自己的電腦上做事。它想在你的電腦上:"), - ApprovalKind::Action => None, + ApprovalKind::Action | ApprovalKind::Handoff => None, }; let mut answer = None; ui.label(RichText::new(title).strong().size(if big { 17.0 } else { 14.0 })); @@ -2128,7 +2164,7 @@ impl App { ApprovalKind::EnableTools => Some("一律允許這個 Bot 使用電腦".to_string()), ApprovalKind::EnableThisComputer => Some("一律允許(打開「在這台電腦上執行」)".to_string()), ApprovalKind::Action if !grant.is_empty() => Some(format!("一律允許{}", grant_label(&grant))), - ApprovalKind::Action => None, + ApprovalKind::Action | ApprovalKind::Handoff => None, }; if let Some(label) = always { if ui.button(label).clicked() { @@ -2153,7 +2189,9 @@ impl App { fn approval_card(&mut self, ui: &mut egui::Ui, ctx: &egui::Context, bot: &Bot) { let p = self.pal; - if !self.approvals.contains_key(&bot.id) { + let Some(kind) = self.approvals.get(&bot.id).map(|a| a.kind) else { return }; + if kind == ApprovalKind::Handoff { + self.handoff_card(ui, ctx, bot); return; } let mut answer = None; @@ -2173,6 +2211,91 @@ impl App { } } + /// 需要處理 (the original's box hand-off card): what the bot needs the person to do, the + /// computer's live screen with 「接管」 on it, and 跳過 / 我完成了 / 接管. + fn handoff_card(&mut self, ui: &mut egui::Ui, ctx: &egui::Context, bot: &Bot) { + let p = self.pal; + let Some(a) = self.approvals.get(&bot.id) else { return }; + let (instruction, computer) = (a.reason.clone(), a.grant.clone()); + // Its own computer shows here (on a screen of a brain, the brain's); another computer of + // the person's is the one in front of them. + let own = computer == crate::model::own_computer_name(&bot.name) || computer == crate::model::BOT_COMPUTER_NAME; + let shows = own && (self.mir.on || crate::cloudbox::available()); + let thumb = if shows { self.box_thumbnail(ctx) } else { None }; + let width = (ui.available_width() - 32.0 - 28.0).clamp(200.0, 560.0); + enum Act { + Skip, + Done, + TakeOver, + } + let mut act = None; + ui.horizontal_top(|ui| { + ui.add_space(32.0); + Frame::new().fill(p.elevated).stroke(Stroke::new(2.0, p.accent)).corner_radius(14).inner_margin(Margin::symmetric(14, 12)).show(ui, |ui| { + ui.vertical(|ui| { + ui.set_width(width); + ui.horizontal(|ui| { + crate::icons::label(ui, crate::icons::Icon::Monitor, &t("電腦"), FONT_XS, p.text3); + ui.label(RichText::new(sidebar_ui::shorten_width(ui, &format!("· {computer}"), (ui.available_width() - 4.0).max(40.0), FONT_XS)).size(FONT_XS).color(p.text3)); + }); + ui.add_space(2.0); + ui.label(RichText::new(t("需要處理")).strong().size(15.0)); + ui.add(egui::Label::new(RichText::new(&instruction).size(13.5).color(p.text2)).wrap()); + ui.add_space(8.0); + // The screen, 16:10, with 「接管」 on it. + let sw = width.min(420.0); + let (rect, r) = ui.allocate_exact_size(vec2(sw, sw * 0.625), if shows { egui::Sense::click() } else { egui::Sense::hover() }); + match thumb { + Some((tex, size)) => { + egui::Image::new((tex, size)).fit_to_exact_size(rect.size()).corner_radius(10).paint_at(ui, rect); + } + None => { + ui.painter().rect_filled(rect, 10, Color32::from_rgb(0x2b, 0x2f, 0x36)); + let text = if shows { t("連結中") } else { t("請到「{name}」上操作").replace("{name}", &computer) }; + ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, sidebar_ui::shorten_width(ui, &text, rect.width() - 20.0, FONT_BASE), egui::FontId::proportional(FONT_BASE), Color32::from_gray(220)); + } + } + ui.painter().rect_stroke(rect, 10, Stroke::new(1.0, p.border), egui::StrokeKind::Inside); + if shows { + let label = t("接管"); + let g = ui.painter().layout_no_wrap(label.clone(), egui::FontId::proportional(13.0), Color32::WHITE); + let pill = egui::Rect::from_min_size(rect.right_bottom() - vec2(g.size().x + 28.0, 38.0), vec2(g.size().x + 20.0, 28.0)); + ui.painter().rect_filled(pill, 14, Color32::from_black_alpha(if r.hovered() { 200 } else { 160 })); + ui.painter().galley(pill.center() - g.size() / 2.0, g, Color32::WHITE); + if r.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(t("接管電腦")).clicked() { + act = Some(Act::TakeOver); + } + } + ui.add_space(10.0); + ui.horizontal(|ui| { + if ui.button(t("跳過")).on_hover_text(t("取消此要求且不執行該步驟;Bot 會跳過它並繼續")).clicked() { + act = Some(Act::Skip); + } + if ui.button(t("我完成了")).clicked() { + act = Some(Act::Done); + } + if shows && ui.add(egui::Button::new(RichText::new(t("接管")).color(p.on_primary)).fill(p.primary).corner_radius(8)).clicked() { + act = Some(Act::TakeOver); + } + }); + }); + }); + }); + ui.add_space(10.0); + match act { + Some(Act::Skip) => { + self.give_back_computer(); + self.answer(ctx, bot.id, ApprovalAnswer::Reject); + } + Some(Act::Done) => { + self.give_back_computer(); + self.answer(ctx, bot.id, ApprovalAnswer::Once); + } + Some(Act::TakeOver) => self.take_over_computer(), + None => {} + } + } + /// A new request: show it as a pop-up (even over another bot's chat) and get the person's /// attention (the Dock icon bounces when AnyBot isn't in front). fn on_new_approval(&mut self, ctx: &egui::Context, bot_id: u64) { @@ -2183,10 +2306,12 @@ impl App { if self.state.notify && self.away() { if let (Some(b), Some(a)) = (self.state.bot(bot_id), self.approvals.get(&bot_id)) { let what = a.reason.split('\n').next().unwrap_or("").to_string(); + // 「{callee} 需要你」 for a step only the person can do. + let title = if a.kind == ApprovalKind::Handoff { format!("{}:需要你", b.name) } else { format!("{} 需要你允許", b.name) }; if !self.headless { - notify::banner(&format!("{} 需要你允許", b.name), &what); + notify::banner(&title, &what); } - crate::remote::notify(&format!("{} 需要你允許", b.name), &what, bot_id); + crate::remote::notify(&title, &what, bot_id); } } } @@ -2213,7 +2338,8 @@ impl App { /// The pop-up: a dimmed window with the request in the middle, so it can't be missed. fn approval_popup(&mut self, ctx: &egui::Context) { let selected = self.state.selected; - let mut ids: Vec = self.approvals.keys().copied().filter(|id| !self.popup_hidden.contains(id)).collect(); + // (A hand-off is the card in the chat, with the live screen, like the original's.) + let mut ids: Vec = self.approvals.iter().filter(|(id, a)| !self.popup_hidden.contains(id) && a.kind != ApprovalKind::Handoff).map(|(id, _)| *id).collect(); ids.sort_by_key(|id| (*id != selected, *id)); let Some(&bot_id) = ids.first() else { return }; let Some(bot) = self.state.bot(bot_id).cloned() else { return }; @@ -3291,6 +3417,11 @@ impl App { ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(vec2(w, h))); } } + // Real data: the computer viewer opens a little after start, as a person would open it (a + // screen of a brain has said so by then, so it shows the brain's computer). + if self.frames == 10 && !self.demo && std::env::var("ANYBOT_SHOT_VIEW").as_deref() == Ok("viewer") { + self.open_viewer(); + } // ANYBOT_SHOT_PAGE=id: open that page late (a screen gets its pages from the brain first). if self.frames == need * 3 / 4 { if let Some(id) = std::env::var("ANYBOT_SHOT_PAGE").ok().and_then(|v| v.parse().ok()) { @@ -3605,6 +3736,20 @@ impl eframe::App for App { if ctx.input(|i| i.modifiers.command && i.key_pressed(Key::Comma)) { self.show_settings = true; } + // Mac: the top strip moves the window like a title bar would (buttons drawn over it still + // take their clicks: they come later, so they are on top); a double click zooms. + if cfg!(target_os = "macos") { + let full = ui.max_rect(); + let strip = egui::Rect::from_min_max(full.min, egui::pos2(full.max.x, full.min.y + TITLEBAR_H)); + let r = ui.interact(strip, egui::Id::unique("titlebar-drag"), egui::Sense::click_and_drag()); + if r.drag_started_by(egui::PointerButton::Primary) { + ctx.send_viewport_cmd(egui::ViewportCommand::StartDrag); + } + if r.double_clicked() { + let zoomed = ctx.input(|i| i.viewport().maximized.unwrap_or(false)); + ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(!zoomed)); + } + } self.sidebar(ui); self.main_pane(ui); self.bot_editor(ctx); @@ -3886,8 +4031,9 @@ fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_compu - 公開網頁:web_fetch。\n\ - 要登入才看得到的(Threads、Instagram、Facebook、Gmail、銀行…):用 browser_open 在 AnyBot 自己的瀏覽器視窗打開,再用 browser_read 讀分頁內容。例如看 Threads 帳號:browser_open(\"https://www.threads.net\") 然後 browser_read。\n\ - 在網頁上做事(後台、報表、表單)要像人一樣操作:先 browser_screenshot 看一眼(圖上每個可點的東西都有編號),再用 browser_click/browser_type/browser_select/browser_press 照編號操作;表格用 browser_table 讀,找字用 browser_find,要等載入用 browser_wait,匯出報表用 browser_download。不要猜網址;找不到想要的功能(例如排序、篩選),就看欄位標題、篩選器、下拉選單或分頁標籤,還是沒有就照頁面現有的資料自己整理,並說明網站沒有這個功能。 - - 絕對不要直接呼叫網站的內部 API、下載網站的程式碼來研究,或用程式大量抓資料:網站會把帳號當成機器人(例如跳出拼圖驗證、封鎖帳號)。出現驗證碼、拼圖或簡訊驗證時,停下來請使用者在 AnyBot 瀏覽器視窗完成。 - - 如果讀到的是登入頁,請使用者在跳出來的 AnyBot 瀏覽器視窗登入一次(之後會記住),登入好再讀。絕對不要叫使用者去改瀏覽器的設定或選單(例如「允許來自 Apple 事件的 JavaScript」),那不需要。也不要說要從 Chrome 複製登入或 Cookie,沒有這個功能。\n\ + - 絕對不要直接呼叫網站的內部 API、下載網站的程式碼來研究,或用程式大量抓資料:網站會把帳號當成機器人(例如跳出拼圖驗證、封鎖帳號)。 + - 需要使用者親手做的步驟(登入、輸入密碼、驗證碼、拼圖、簡訊或 email 驗證、確認付款):先在電腦上把那一頁打開、點到要輸入的地方,再呼叫 hand_over_computer,instruction 寫清楚要他做什麼。聊天裡會出現那台電腦的即時畫面,他點「接管」做完後按「我完成了」,你再截圖確認、接著做。不要只用文字叫他自己去找視窗;也絕對不要替他打密碼。 + - 如果讀到的是登入頁:把登入頁開在那台電腦上,用 hand_over_computer 請使用者登入一次(之後會記住),他按「我完成了」再讀。絕對不要叫使用者去改瀏覽器的設定或選單(例如「允許來自 Apple 事件的 JavaScript」),那不需要。也不要說要從 Chrome 複製登入或 Cookie,沒有這個功能。\n\ - AnyBot 瀏覽器有設定檔(profile),每個有自己的登入。使用者說要換 profile/設定檔/另一個帳號時,用 browser_profiles:list 看有哪些,use 切換,add 新增(新的要登入一次)。也可以在 browser_open / browser_read 帶 profile 參數。使用者想用自己平常的 Chrome(自己的 Chrome 設定檔、已經登入的帳號)時,用 profile「你的瀏覽器」:請使用者在 Chrome 切到想要的設定檔,再 browser_read。\n\ - 你也能像人一樣操作螢幕({screens})。computer_use_screenshot 看畫面,再用 computer_use_click / computer_use_typing / computer_use_press_key / computer_use_scroll / computer_use_drag 操作;座標用最新截圖上的像素。每做一步就再截圖確認。用在檔案和網頁工具做不到的事(例如操作其他 App、在 Chrome 切換設定檔)。\n\ {}\ diff --git a/src/app/computers_ui.rs b/src/app/computers_ui.rs index 9c97895..8ef0f23 100644 --- a/src/app/computers_ui.rs +++ b/src/app/computers_ui.rs @@ -977,6 +977,40 @@ impl App { } } + /// 接管: open the computer and take control (the bot waits for the person anyway). + pub(super) fn take_over_computer(&mut self) { + self.open_viewer(); + if self.cu.control { + return; + } + self.cu.control = true; + match self.cu.remote.clone() { + Some(s) => { + std::thread::spawn(move || { + let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "hold", "on": true})); + }); + } + None => cloudbox::HELD_BY_PERSON.store(true, Ordering::SeqCst), + } + } + + /// 我完成了 / 跳過: the bot gets its computer back (its steps would otherwise wait for the + /// person to let go). + pub(super) fn give_back_computer(&mut self) { + if !self.cu.control { + return; + } + self.cu.control = false; + match self.cu.remote.clone() { + Some(s) => { + std::thread::spawn(move || { + let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "hold", "on": false})); + }); + } + None => cloudbox::HELD_BY_PERSON.store(false, Ordering::SeqCst), + } + } + fn close_viewer(&mut self) { self.cu.viewer_open = false; self.cu.control = false; @@ -1116,8 +1150,11 @@ impl App { let default = vec2((screen.width() * 0.62).min(900.0), (screen.width() * 0.62).min(900.0) * 0.625 + 52.0); let busy = if brain { self.mir.box_state["busy"].as_bool().unwrap_or(false) } else { cloudbox::is_busy() }; let title = self.state.bot(self.state.selected).map(|b| crate::model::own_computer_name(&b.name)).unwrap_or_else(|| crate::model::BOT_COMPUTER_NAME.to_string()); - let title = if brain { format!("{title}(在大腦「{}」上)", self.state.remote.brain_name) } else { title }; + let mut close_clicked = false; + let mut done_clicked = false; + let selected = self.state.selected; + let handoff = self.approvals.get(&selected).map(|a| a.kind == crate::providers::ApprovalKind::Handoff).unwrap_or(false); egui::Window::new("box-viewer") .id(egui::Id::unique("box-viewer")) .title_bar(false) @@ -1135,16 +1172,9 @@ impl App { .shadow(egui::epaint::Shadow { offset: [0, 14], blur: 40, spread: 0, color: Color32::from_black_alpha(60) }), ) .show(ctx, |ui| { + // Buttons first, from the right; the title and state get what is left and end in + // … when it's short (the state used to slide under 「取得控制權」 in a narrow window). ui.horizontal(|ui| { - monitor_icon(ui, 20.0, p.text); - ui.label(RichText::new(&title).strong().size(FONT_LG)); - status_pill(ui, &run, p); - if busy && !self.cu.control { - crate::icons::label(ui, crate::icons::Icon::Bot, &t("Bot 正在操作"), FONT_XS, p.accent); - } - if self.cu.control { - crate::icons::label(ui, crate::icons::Icon::Users, &t("你正在操作,Bot 已暫停"), FONT_XS, p.accent); - } ui.with_layout(Layout::right_to_left(Align::Center), |ui| { let (rect, r) = ui.allocate_exact_size(vec2(28.0, 28.0), egui::Sense::click()); if r.hovered() { @@ -1158,6 +1188,10 @@ impl App { close_clicked = true; } ui.add_space(4.0); + // The bot is waiting for the person here: done, and it carries on. + if handoff && ui.add(egui::Button::new(RichText::new(t("我完成了")).strong().color(p.on_primary)).fill(p.primary).corner_radius(8).min_size(vec2(84.0, 30.0))).clicked() { + done_clicked = true; + } if !brain && ui.add(secondary_button("全螢幕")).on_hover_text(t("在瀏覽器用 noVNC 全螢幕開啟")).clicked() { let control = self.cu.control; self.box_task(move || { @@ -1184,6 +1218,19 @@ impl App { None => cloudbox::HELD_BY_PERSON.store(self.cu.control, Ordering::SeqCst), } } + ui.add_space(8.0); + ui.with_layout(Layout::left_to_right(Align::Center), |ui| { + monitor_icon(ui, 20.0, p.text); + let note = if self.cu.control { Some(t("你正在操作,Bot 已暫停")) } else if busy { Some(t("Bot 正在操作")) } else { None }; + let room = (ui.available_width() - 96.0 - if note.is_some() { 120.0 } else { 0.0 }).max(60.0); + ui.label(RichText::new(super::sidebar_ui::shorten_width(ui, &title, room, FONT_LG)).strong().size(FONT_LG)); + if ui.available_width() > 80.0 { + status_pill(ui, &run, p); + } + if let Some(n) = note.filter(|_| ui.available_width() > 110.0) { + crate::icons::label(ui, if self.cu.control { crate::icons::Icon::Users } else { crate::icons::Icon::Bot }, &n, FONT_XS, p.accent); + } + }); }); }); ui.add_space(6.0); @@ -1200,7 +1247,17 @@ impl App { }; egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| { let sense = if self.cu.control { egui::Sense::click_and_drag() } else { egui::Sense::hover() }; - let resp = ui.add(egui::Image::new((tex_id, size)).corner_radius(8).sense(sense)); + // Centred in the window (it sat at the left with an empty band beside it). + let resp = if self.cu.fit { + let pad = ((ui.available_width() - size.x) / 2.0).max(0.0); + ui.horizontal(|ui| { + ui.add_space(pad); + ui.add(egui::Image::new((tex_id, size)).corner_radius(8).sense(sense)) + }) + .inner + } else { + ui.add(egui::Image::new((tex_id, size)).corner_radius(8).sense(sense)) + }; if self.cu.control { ui.painter().rect_stroke(resp.rect, 8, Stroke::new(2.0, p.accent), egui::StrokeKind::Outside); self.forward_input(ui, &resp, (fw, fh)); @@ -1233,7 +1290,11 @@ impl App { } } }); - if close_clicked || !open { + if done_clicked { + self.give_back_computer(); + self.answer(ctx, selected, crate::providers::ApprovalAnswer::Once); + self.close_viewer(); + } else if close_clicked || !open { self.close_viewer(); } ctx.request_repaint_after(std::time::Duration::from_millis(VIEWER_FRAME_MS)); diff --git a/src/app/mirror_ui.rs b/src/app/mirror_ui.rs index 6708af2..db17f4c 100644 --- a/src/app/mirror_ui.rs +++ b/src/app/mirror_ui.rs @@ -533,6 +533,7 @@ impl App { ApprovalKind::Action => "action", ApprovalKind::EnableTools => "tools", ApprovalKind::EnableThisComputer => "local", + ApprovalKind::Handoff => "handoff", }; json!({"bot": id, "call_id": a.call_id, "tool": a.tool, "reason": a.reason, "grant": a.grant, "kind": kind}) }) @@ -1015,6 +1016,7 @@ impl App { let kind = match a["kind"].as_str().unwrap_or("") { "tools" => ApprovalKind::EnableTools, "local" => ApprovalKind::EnableThisComputer, + "handoff" => ApprovalKind::Handoff, _ => ApprovalKind::Action, }; let (reply, _) = channel(); diff --git a/src/app/panel_ui.rs b/src/app/panel_ui.rs index aac19fa..df01a27 100644 --- a/src/app/panel_ui.rs +++ b/src/app/panel_ui.rs @@ -105,7 +105,8 @@ impl App { let status = self.streams.get(&bot.id).and_then(|l| l.status.clone()).unwrap_or_else(|| t("工作中…")); let mut clicked = false; panel_card(ui, p, |ui| { - clicked = if waiting { panel_row(ui, p, Some(crate::icons::Icon::Shield), &t("需要你允許"), "", true).clicked() } else { panel_row(ui, p, Some(crate::icons::Icon::Stop), &status, &t("停止"), false).clicked() }; + let label = if self.approvals.get(&bot.id).map(|a| a.kind == crate::providers::ApprovalKind::Handoff).unwrap_or(false) { t("等你處理") } else { t("需要你允許") }; + clicked = if waiting { panel_row(ui, p, Some(crate::icons::Icon::Shield), &label, "", true).clicked() } else { panel_row(ui, p, Some(crate::icons::Icon::Stop), &status, &t("停止"), false).clicked() }; }); if clicked { if waiting { @@ -173,21 +174,28 @@ impl App { let run = crate::cloudbox::run_state(); let running = if brain { self.mir.box_state["running"].as_bool().unwrap_or(false) } else { run.is_running() }; let state = if brain { - let label = self.mir.box_state["label"].as_str().map(t).unwrap_or_else(|| t("連線中…")); - format!("{label} · {}", t("在大腦「{name}」上").replace("{name}", &brain_name)) + self.mir.box_state["label"].as_str().map(t).unwrap_or_else(|| t("連線中…")) } else if available { t(&run.label()) } else { t("沒有 Docker") }; - let (rect, r) = ui.allocate_exact_size(vec2(ui.available_width(), 64.0), egui::Sense::click()); + // Where it is, on its own line (it ran into the picture when it shared the state's). + let place = brain.then(|| t("在大腦「{name}」上").replace("{name}", &brain_name)); + let (rect, r) = ui.allocate_exact_size(vec2(ui.available_width(), if place.is_some() { 72.0 } else { 64.0 }), egui::Sense::click()); if r.hovered() { ui.painter().rect_filled(rect, 9, p.hover); } - ui.painter().text(rect.left_top() + vec2(10.0, 15.0), egui::Align2::LEFT_TOP, &own_name, egui::FontId::proportional(13.0), p.text); - ui.painter().text(rect.left_top() + vec2(10.0, 33.0), egui::Align2::LEFT_TOP, state, egui::FontId::proportional(12.0), p.text3); + // Every line stops before the picture (long names end in …). + let text_w = (rect.width() - 86.0 - 10.0 - 8.0).max(40.0); + let top = if place.is_some() { 9.0 } else { 15.0 }; + ui.painter().text(rect.left_top() + vec2(10.0, top), egui::Align2::LEFT_TOP, super::sidebar_ui::shorten_width(ui, &own_name, text_w, 13.0), egui::FontId::proportional(13.0), p.text); + ui.painter().text(rect.left_top() + vec2(10.0, top + 18.0), egui::Align2::LEFT_TOP, super::sidebar_ui::shorten_width(ui, &state, text_w, 12.0), egui::FontId::proportional(12.0), p.text3); + if let Some(place) = &place { + ui.painter().text(rect.left_top() + vec2(10.0, top + 35.0), egui::Align2::LEFT_TOP, super::sidebar_ui::shorten_width(ui, place, text_w, 12.0), egui::FontId::proportional(12.0), p.text3); + } // The screen: 16:10, rounded, the real desktop when there is a picture. - let box_r = egui::Rect::from_min_size(rect.right_top() + vec2(-86.0, 8.0), vec2(78.0, 48.0)); + let box_r = egui::Rect::from_min_size(egui::pos2(rect.right() - 86.0, rect.center().y - 24.0), vec2(78.0, 48.0)); match thumb { Some((tex, size)) => { let mut img = egui::Image::new((tex, size)).fit_to_exact_size(box_r.size()).corner_radius(7); @@ -211,7 +219,8 @@ impl App { let tip = if running { t("看「{name}」的螢幕,也可以取得控制權自己操作") } else { t("叫醒「{name}」並看它的螢幕") }.replace("{name}", &own_name); view = r.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(tip).clicked(); if !choices.is_empty() { - panel_select(ui, p, "做事的電腦", ("computer", bot.id), ¤t, |ui| { + let shown = super::sidebar_ui::shorten_width(ui, ¤t, (ui.available_width() - 120.0).max(60.0), 13.0); + panel_select(ui, p, "做事的電腦", ("computer", bot.id), &shown, |ui| { for (name, kind) in &choices { let what = t(match kind.as_str() { "own" => "它自己的電腦", diff --git a/src/app/remote_ui.rs b/src/app/remote_ui.rs index d1c3a20..7585c6b 100644 --- a/src/app/remote_ui.rs +++ b/src/app/remote_ui.rs @@ -41,8 +41,10 @@ fn approval_json(bot: &Bot, a: &Approval) -> serde_json::Value { ApprovalKind::EnableTools => format!("允許 {} 使用電腦工具嗎?", bot.name), ApprovalKind::EnableThisComputer => format!("允許 {} 在你的電腦上執行任務嗎?", bot.name), ApprovalKind::Action => format!("{} 想要{what}", bot.name), + ApprovalKind::Handoff => format!("{}:需要你", bot.name), }; - json!({"bot": bot.id, "title": title, "reason": a.reason, "tool": a.tool, "can_always": a.kind != ApprovalKind::Action || !a.grant.is_empty()}) + 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 { diff --git a/src/app/sidebar_ui.rs b/src/app/sidebar_ui.rs index 30d0472..0ebf064 100644 --- a/src/app/sidebar_ui.rs +++ b/src/app/sidebar_ui.rs @@ -745,7 +745,7 @@ impl App { }; painter.galley(egui::pos2(tx, rect.top() + 9.0), one_line(b.name.clone(), 15.0, p.text), p.text); let sub = if waiting { - t("需要你允許") + if self.approvals.get(&b.id).map(|a| a.kind == crate::providers::ApprovalKind::Handoff).unwrap_or(false) { t("等你處理") } else { t("需要你允許") } } else if streaming { t("正在工作…") } else { diff --git a/src/i18n_table.rs b/src/i18n_table.rs index 43e08a1..e98af91 100644 --- a/src/i18n_table.rs +++ b/src/i18n_table.rs @@ -614,4 +614,13 @@ pub const ROWS: &[(&str, &str, &str)] = &[ ("在「{name}」上做事 · 點一下換電腦", "Works on “{name}” · click to switch computers", "「{name}」で作業 · クリックで切り替え"), ("一台 Linux 電腦,你所有的 Bot 共用,每個 Bot 有自己的資料夾(例如「Master 的電腦」)。一直開著,不會休眠。", "A Linux computer all your bots share; each bot has its own folder (e.g. “Master's computer”). Always on, it never sleeps.", "すべての Bot が共有する Linux コンピューター。Bot ごとにフォルダーがあります(例:「Master のコンピューター」)。常に起動していて、スリープしません。"), ("把 Bot 的檔案備份到這台電腦;每天也會自動存一份", "Back up the bots' files to this computer; a copy is also saved every day", "Bot のファイルをこのコンピューターにバックアップします。毎日自動でも保存します"), + ("等你處理", "Waiting for you", "対応待ち"), + ("需要處理", "Action needed", "対応が必要です"), + ("接管", "Take over", "操作を代わる"), + ("接管電腦", "Take over the computer", "コンピューターの操作を代わる"), + ("我完成了", "I'm done", "完了しました"), + ("跳過", "Skip", "スキップ"), + ("取消此要求且不執行該步驟;Bot 會跳過它並繼續", "Cancel this request without doing the step; the Bot continues without it", "この依頼を取り消し、その手順は行いません。Bot はそれを飛ばして続けます"), + ("連結中", "Connecting", "接続中"), + ("請到「{name}」上操作", "Please do it on “{name}”", "「{name}」で操作してください"), ]; diff --git a/src/main.rs b/src/main.rs index 2d2619e..e1b1e19 100644 --- a/src/main.rs +++ b/src/main.rs @@ -235,15 +235,18 @@ fn main() -> eframe::Result<()> { app::App::serve(&ctx); } - let options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() - .with_title("AnyBot") - .with_inner_size(shot_size()) - .with_min_inner_size([app::SIDEBAR_MIN_WIDTH + app::CHAT_MIN_WIDTH, 480.0]) - .with_app_id("anybot") - .with_icon(app_icon()), - ..Default::default() - }; + let mut viewport = egui::ViewportBuilder::default() + .with_title("AnyBot") + .with_inner_size(shot_size()) + .with_min_inner_size([app::SIDEBAR_MIN_WIDTH + app::CHAT_MIN_WIDTH, 480.0]) + .with_app_id("anybot") + .with_icon(app_icon()); + // On a Mac the window is one surface to the very top (the grey system title bar didn't match + // a dark window); the traffic lights sit over the sidebar's empty corner. + if cfg!(target_os = "macos") { + viewport = viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false); + } + let options = eframe::NativeOptions { viewport, ..Default::default() }; eframe::run_native( "AnyBot", options, diff --git a/src/providers.rs b/src/providers.rs index 594951f..2d04023 100644 --- a/src/providers.rs +++ b/src/providers.rs @@ -223,6 +223,10 @@ pub enum ApprovalKind { EnableTools, /// 在這台電腦上執行 is off: let it work on this Mac? ("always" turns the setting on.) EnableThisComputer, + /// 需要處理: a step only the person can do on a computer (signing in, a captcha…). `reason` + /// is what to do, `grant` the computer's name; Once = 「我完成了」, Reject = 「跳過」 (the + /// original's box hand-off card). + Handoff, } pub struct AppCall { diff --git a/src/tools.rs b/src/tools.rs index f3c3a87..2550aab 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -60,8 +60,12 @@ pub fn specs() -> Vec { ] } +/// 請你接手: the step that asks the person to do something by hand on a computer. +pub const HANDOFF_TOOL: &str = "hand_over_computer"; + pub fn label(name: &str) -> &'static str { match name { + HANDOFF_TOOL => "請你接手電腦", "list_dir" => "查看資料夾", "search" => "搜尋檔案", "read_file" => "讀取檔案", From 06801f549d8719732a87fcc7e336c23a900d6e90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Thu, 5 Nov 2026 18:14:23 +0800 Subject: [PATCH 11/19] Remote brain: a computer that just came on is known at once; an offline computer is said, not swapped; snapshots survive an open browser; end-to-end test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The brain looked the account's computers up once a minute: for up to a minute after the Mac came on, 「我的電腦」 was unknown and steps went elsewhere. Now every 15 s, and at once when a message comes from a computer it doesn't know yet. - A step naming a computer that is off says so in words instead of running on the chosen one with a note that the person hadn't asked. - Snapshots: tar's 'file changed' (the open browser) is not a failure; browser caches are left out; ten minutes for a large home folder. - box_end_to_end clicks the terminal where it opened; restore only with ANYBOT_TEST_RESTORE=1. - deploy/test/remote_e2e.py: account server + headless brain + hands + scripted model, 27 checks (computers, redirect, agent-desktop on the hands, hand-off 3 ways, phone view, sync copy, edits, hands offline/back, brain restart, clear). anybot --sample-state for such tests. Co-Authored-By: Claude Opus 5.5 --- deploy/test/remote_e2e.py | 352 ++++++++++++++++++++++++++++++++++++++ src/agent.rs | 14 +- src/app/remote_ui.rs | 14 +- src/cloudbox.rs | 37 +++- src/main.rs | 11 ++ 5 files changed, 414 insertions(+), 14 deletions(-) create mode 100644 deploy/test/remote_e2e.py diff --git a/deploy/test/remote_e2e.py b/deploy/test/remote_e2e.py new file mode 100644 index 0000000..43ff88e --- /dev/null +++ b/deploy/test/remote_e2e.py @@ -0,0 +1,352 @@ +# End-to-end test of a brain on a server working on a person's computer, all on this machine and +# all made up for the test: an account server, a headless brain, a headless "hands" computer (it +# also plays the person's screen, as a Mac showing the brain does), and a scripted fake model. +# Never touches a real account, and no Docker box (the bot's own computer is a sealed folder). +# Build first (cargo build --release; and in relay/), then, from app/: +# python3 -I deploy/test/remote_e2e.py . +# On a Mac the terminal needs 輔助使用 (the hands open Calculator through agent-desktop). +import json, os, shutil, socket, subprocess, sys, tempfile, threading, time, urllib.request, urllib.error +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +APP = os.path.abspath(sys.argv[1]) +BIN = os.path.join(APP, 'target/release/anybot') +RELAY = os.path.join(APP, 'relay/target/release/anybot-relay') +WORK = tempfile.mkdtemp(prefix='anybot-e2e-') +USER, PW = 'e2euser', 'e2e-pass-12345' +HANDS = 'E2E手腳' +results = [] + +def free_port(): + s = socket.socket(); s.bind(('127.0.0.1', 0)); p = s.getsockname()[1]; s.close(); return p + +def check(name, ok, detail=''): + results.append((name, bool(ok), detail)) + print(('PASS ' if ok else 'FAIL ') + name + ('' if ok else f' — {detail}'), flush=True) + +# ---------- the scripted model ---------- +def tool_call(name, args): + return {'tool': (name, json.dumps(args, ensure_ascii=False))} + +SCRIPTS = { + 'SC_LIST': [tool_call('list_computers', {})], + 'SC_OPEN': [tool_call('computer_use_open_app', {'app': 'Calculator', 'computer': HANDS})], + 'SC_SHELL': [tool_call('shell', {'command': 'echo e2e-$((6*7))', 'computer': HANDS})], + 'SC_REDIRECT': [tool_call('shell', {'command': 'echo moved-here', 'computer': HANDS})], + 'SC_HANDOFF': [tool_call('hand_over_computer', {'instruction': 'E2E:請按「我完成了」', 'computer': HANDS})], + 'SC_OWN': [tool_call('shell', {'command': 'echo on-own-$((2+3))'})], +} + +class Model(BaseHTTPRequestHandler): + def log_message(self, *a): + pass + def do_GET(self): + body = json.dumps({'data': [{'id': 'e2e'}]}).encode() + self.send_response(200); self.send_header('Content-Type', 'application/json'); self.send_header('Content-Length', str(len(body))); self.end_headers(); self.wfile.write(body) + def do_POST(self): + n = int(self.headers.get('Content-Length', '0') or 0) + req = json.loads(self.rfile.read(n) or b'{}') + msgs = req.get('messages', []) + last_user = max((i for i, m in enumerate(msgs) if m.get('role') == 'user'), default=-1) + text = msgs[last_user].get('content', '') if last_user >= 0 else '' + if isinstance(text, list): + text = ' '.join(p.get('text', '') for p in text if isinstance(p, dict)) + done = sum(1 for m in msgs[last_user + 1:] if m.get('role') == 'tool') + last_tool = next((m.get('content', '') for m in reversed(msgs) if m.get('role') == 'tool'), '') + script = next((v for k, v in SCRIPTS.items() if k in text), []) + if done < len(script): + name, args = script[done]['tool'] + chunk = {'choices': [{'delta': {'tool_calls': [{'index': 0, 'id': f'c{int(time.time()*1000)}', 'function': {'name': name, 'arguments': args}}]}}]} + else: + chunk = {'choices': [{'delta': {'content': 'E2E 完成:' + (last_tool if isinstance(last_tool, str) else '')[:300]}}]} + self.send_response(200); self.send_header('Content-Type', 'text/event-stream'); self.end_headers() + self.wfile.write(('data: ' + json.dumps(chunk, ensure_ascii=False) + '\n\ndata: [DONE]\n\n').encode()) + +# ---------- relay helpers ---------- +RP = free_port(); MP = free_port() +RURL = f'http://127.0.0.1:{RP}' + +def http(method, url, body=None, token=None, timeout=60, raw=False): + data = None if body is None else json.dumps(body).encode() + req = urllib.request.Request(url, method=method, data=data, headers={'Content-Type': 'application/json', **({'Authorization': 'Bearer ' + token} if token else {})}) + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + b = r.read(); return r.status, (b if raw else json.loads(b or b'null')) + except urllib.error.HTTPError as e: + b = e.read() + try: + return e.code, json.loads(b) + except Exception: + return e.code, b + +procs = {} +def start(name, args, env): + log = open(os.path.join(WORK, name + '.log'), 'a') + e = dict(os.environ); e.update(env) + procs[name] = subprocess.Popen(args, env=e, stdout=log, stderr=log, cwd=WORK) + +def stop(name): + p = procs.pop(name, None) + if p: + p.terminate() + try: + p.wait(10) + except Exception: + p.kill() + +def template(): + path = os.path.join(WORK, 'sample.json') + subprocess.run([BIN, '--sample-state', path], check=True) + t = json.load(open(path)) + for k in ('channels', 'mcp_servers', 'skills', 'pages', 'review_allow_rules', 'review_ask_rules', 'remote_computers', 'folders'): + if k in t: + t[k] = [] + t['providers'] = {'custom': {'api_key': '', 'base_url': f'http://127.0.0.1:{MP}/v1', 'default_model': 'e2e', 'known_models': ['e2e']}} + t['remote'] = {'lan': False, 'password': '', 'devices': [], 'mac_from_phone': False, 'device_link': False, 'relay_url': '', 'relay_user': '', 'host_id': '', 'host_token': '', 'host_name': '', 'backup_key': '', 'auto_backup': False, 'last_backup': 0} + b = t['bots'][0] + b.update({'name': 'Master', 'provider': 'Custom', 'model': 'e2e', 'messages': [], 'memories': [], 'allow_rules': [], 'last_turn': None, 'default_computer': 'Bot 的電腦', 'tools_enabled': True, 'instructions': ''}) + b.pop('threads', None) + t['bots'] = [b]; t['primary_bot'] = b['id']; t['selected'] = b['id'] + t['local_execution'] = False; t['direct_mode'] = False; t['auto_review'] = False + return t + +NOBOX = {'DOCKER_HOST': 'unix:///nonexistent/docker.sock', 'PATH': '/usr/bin:/bin:/usr/sbin:/sbin'} + +def run(): + srv = ThreadingHTTPServer(('127.0.0.1', MP), Model) + threading.Thread(target=srv.serve_forever, daemon=True).start() + os.makedirs(os.path.join(WORK, 'relay')) + start('relay', [RELAY, '--bind', '127.0.0.1', '--port', str(RP), '--data', os.path.join(WORK, 'relay')], {}) + for _ in range(50): + try: + if http('GET', RURL + '/api/hello')[0] == 200: + break + except Exception: + time.sleep(0.2) + # The brain: a seeded bot on the fake model, joins (and makes) the account. + bdir = os.path.join(WORK, 'brain'); os.makedirs(bdir) + st = template(); st['remote']['host_name'] = 'E2E大腦' + json.dump(st, open(os.path.join(bdir, 'state.json'), 'w'), ensure_ascii=False) + benv = {'ANYBOT_DATA_DIR': bdir, 'ANYBOT_ACCOUNT': f'{RURL}|{USER}', 'ANYBOT_ACCOUNT_PASSWORD': PW, 'ANYBOT_ACCOUNT_CREATE': '1', 'ANYBOT_HOST_NAME': 'E2E大腦', **NOBOX} + start('brain', [BIN, '--serve'], benv) + brain_id = None + for _ in range(60): + time.sleep(0.5) + try: + s = json.load(open(os.path.join(bdir, 'state.json'))) + if s['remote'].get('host_id'): + brain_id = s['remote']['host_id']; break + except Exception: + pass + check('brain joins the account', brain_id, 'no host id') + # The hands: a second AnyBot of the account, lending its hands to that brain only. + _, v = http('POST', RURL + '/relay/login', {'user': USER, 'password': PW, 'device': HANDS}) + _, h = http('POST', RURL + '/relay/hosts', {'name': HANDS}, token=v['token']) + hdir = os.path.join(WORK, 'hands'); os.makedirs(hdir) + hs = template(); hs['bots'] = [] + hs['remote'].update({'relay_url': RURL, 'relay_user': USER, 'host_id': h['id'], 'host_token': h['token'], 'host_name': HANDS, 'device_link': True, 'hands_for': brain_id, 'hands_for_name': 'E2E大腦'}) + json.dump(hs, open(os.path.join(hdir, 'state.json'), 'w'), ensure_ascii=False) + henv = {'ANYBOT_DATA_DIR': hdir, **NOBOX} + start('hands', [BIN, '--serve'], henv) + tok = h['token'] + sync = lambda method, rest='', body=None, q='': http(method, f'{RURL}/relay/hosts/{brain_id}/api/sync{rest}{q}', body, token=tok) + + def peers(): + return {x['name']: x for x in http('GET', RURL + '/relay/host/peers', token=tok)[1]['hosts']} + for _ in range(40): + p = peers() + if p.get('E2E大腦', {}).get('online') and p.get(HANDS, {}).get('online'): + break + time.sleep(0.5) + p = peers() + check('brain and hands are online', p.get('E2E大腦', {}).get('online') and p.get(HANDS, {}).get('online'), json.dumps(p, ensure_ascii=False)) + # Tell the account this computer shows that brain (as the Mac's screen does). + copy = {'version': 0, 'msgs': {}} + + def pull(full=False, client='screen'): + since = 0 if full else copy['version'] + code, v = sync('GET', q=f'?since={since}&client={client}') + if code != 200: + raise RuntimeError(f'pull {code} {v}') + if client == 'screen': + copy['version'] = v.get('version', 0) + for d in v.get('bots', []): + cur = copy['msgs'].setdefault(d['id'], []) + if len(cur) < d['from']: + copy['version'] = 0 # the screen asks for everything again + continue + del cur[d['from']:] + cur.extend(d['messages']) + return v + + # The brain looks the account's computers up every 15 s: a computer that just came on is + # there within that. + for _ in range(25): + v = pull() + comps = v.get('computers', {}).get('1', {}) + names = [x['name'] for x in comps.get('names', [])] + if HANDS in names: + break + time.sleep(1) + check('computers by their own names; no server computer', names == ['Master 的電腦', HANDS], names) + check('the bot works on its own computer by default', comps.get('current') == 'Master 的電腦', comps) + + def wait_turn(answer=None, timeout=90, during=None): + """Until the turn ends; answers requests with `answer` ("once"/"reject").""" + t0 = time.time(); seen = [] + while time.time() - t0 < timeout: + v = pull(client='poll') + for a in v.get('approvals', []): + key = (a.get('call_id'), a.get('kind')) + if key not in [s[:2] for s in seen]: + seen.append((a.get('call_id'), a.get('kind'), a)) + if during: + during(a) + elif answer: + sync('POST', '/act', {'bot': 1, 'action': 'answer', 'answer': answer}) + if not v.get('live') and not v.get('approvals') and time.time() - t0 > 2.5: + return seen, v + time.sleep(0.5) + return seen, None + + def last_tool(v): + msgs = [m for m in v['bots'][0]['messages'] if m['role'] == 'Tool'] if v and v.get('bots') else [] + return msgs[-1]['text'] if msgs else '' + + def full_messages(): + return pull(full=True, client='check')['bots'][0]['messages'] + + def tool_text_after(marker): + ms = full_messages() + i = max((k for k, m in enumerate(ms) if m['role'] == 'User' and marker in m['text']), default=-1) + return [m['text'] for m in ms[i + 1:] if m['role'] == 'Tool'] + + # 1. 查看電腦: the person's computer is the one they typed on. + sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '我現在用哪台?SC_LIST'}) + wait_turn('once') + t = ' '.join(tool_text_after('SC_LIST')) + check('list_computers names the hands as where the person is', HANDS in t and '使用者現在就是在這台' in t, t[:300]) + # 4. Not asked for: back to the chosen computer, with a word for the bot. + sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '跑一下 SC_REDIRECT'}) + wait_turn('once') + t = ' '.join(tool_text_after('SC_REDIRECT')) + check('a step the person did not ask for goes to the chosen computer, and the bot is told', 'moved-here' in t and '[Master 的電腦]' in t and '這一步你指定了' in t, t[:400]) + # 2. A step on the person's computer, named by them: asks once, runs there (agent-desktop). + sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '在我的電腦打開計算機 SC_OPEN'}) + seen, _ = wait_turn('once') + t = ' '.join(tool_text_after('SC_OPEN')) + check('open_app on the person computer asks first', any(s[1] == 'action' for s in seen), seen) + check('open_app ran on the hands via agent-desktop', f'[{HANDS}]' in t and ('已打開' in t), t[:400]) + # 3. A command there. + sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '在我的電腦跑指令 SC_SHELL'}) + wait_turn('once') + t = ' '.join(tool_text_after('SC_SHELL')) + check('shell runs on the hands', 'e2e-42' in t and f'[{HANDS}]' in t, t[:300]) + # 5. Its own computer. + sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '用你自己的 SC_OWN'}) + wait_turn('once') + t = ' '.join(tool_text_after('SC_OWN')) + check('own computer by default', 'on-own-5' in t and '[Master 的電腦]' in t, t[:300]) + # 6. 需要處理: 我完成了. + sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '在我的電腦 SC_HANDOFF'}) + got = {} + phone = {} + def on_handoff(a): + got.update(a) + # The phone (signed in as the person) sees it too, as 需要處理 with the computer. + _, ses = http('POST', RURL + '/relay/login', {'user': USER, 'password': PW, 'device': 'e2e-phone'}) + code, view = http('GET', f'{RURL}/h/{brain_id}/api/state?since=0', token=ses.get('token')) + phone.update(next((x for x in (view or {}).get('approvals', []) if x.get('bot') == 1), {}) if code == 200 else {'code': code, 'body': str(view)[:200]}) + sync('POST', '/act', {'bot': 1, 'action': 'answer', 'answer': 'once'}) + wait_turn(during=on_handoff) + t = ' '.join(tool_text_after('在我的電腦 SC_HANDOFF')) + check('hand-off shows as 需要處理 with the instruction and computer', got.get('kind') == 'handoff' and got.get('reason') == 'E2E:請按「我完成了」' and got.get('grant') == HANDS, got) + check('我完成了 lets the bot carry on', '我完成了' in t, t[:300]) + check('the phone shows the hand-off with its computer', phone.get('kind') == 'handoff' and phone.get('computer') == HANDS and not phone.get('can_always'), phone) + # 7. 需要處理: 跳過. + sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '再一次在我的電腦 SC_HANDOFF'}) + wait_turn('reject') + t = ' '.join(tool_text_after('再一次在我的電腦 SC_HANDOFF')) + check('跳過 tells the bot the step is skipped', '跳過' in t, t[:300]) + # 8. 需要處理: answered by a message. + sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '第三次在我的電腦 SC_HANDOFF'}) + def on_reply(a): + sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '我已經登入好了'}) + seen, v = wait_turn(during=on_reply) + t = ' '.join(tool_text_after('第三次在我的電腦 SC_HANDOFF')) + check('a message answers a hand-off', '傳了訊息' in t, t[:300]) + ms = full_messages() + check('the reply is in the chat once', sum(1 for m in ms if m['role'] == 'User' and m['text'] == '我已經登入好了') == 1, [m['text'] for m in ms if m['role'] == 'User']) + # 9. The screen's copy, kept from small updates, matches the brain's chat exactly. + for _ in range(3): + pull() + mine = copy['msgs'].get(1, []) + full = full_messages() + sig = lambda ms: [(m['role'], m['text'], m.get('at')) for m in ms] + check('screen copy equals the brain chat (no duplicates)', sig(mine) == sig(full), f'{len(mine)} vs {len(full)}') + users = [m['text'] for m in full if m['role'] == 'User'] + check('no user message twice', len(users) == len(set(users)), users) + # 10. Choosing a computer from the screen reaches the brain. + sync('POST', '/edit', {'client': 'screen', 'seq': 1, 'ops': [{'op': 'bot_set', 'id': 1, 'key': 'default_computer', 'value': HANDS}]}) + time.sleep(1.0) + c = pull(full=True, client='check2')['computers']['1'] + check('the computer chosen on the screen is the brain\'s choice', c['current'] == HANDS, c) + sync('POST', '/edit', {'client': 'screen', 'seq': 2, 'ops': [{'op': 'bot_set', 'id': 1, 'key': 'default_computer', 'value': 'Bot 的電腦'}]}) + time.sleep(1.0) + c = pull(full=True, client='check3')['computers']['1'] + check('and back to its own', c['current'] == 'Master 的電腦', c) + # 11. The hands go away: plain words, not codes. + stop('hands') + time.sleep(10) + sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '在我的電腦跑指令 SC_SHELL 二'}) + wait_turn('once', timeout=120) + t = ' '.join(tool_text_after('SC_SHELL 二')) + check('hands offline: the bot hears it in words', ('連不到' in t or '離線' in t) and 'e2e-42' not in t and '這一步你指定了' not in t and not any(code in t for code in ('503', '502', '404')), t[:300]) + start('hands', [BIN, '--serve'], henv) + for _ in range(40): + if peers().get(HANDS, {}).get('online'): + break + time.sleep(0.5) + sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '在我的電腦跑指令 SC_SHELL 三'}) + wait_turn('once') + t = ' '.join(tool_text_after('SC_SHELL 三')) + check('hands back: works again', 'e2e-42' in t and f'[{HANDS}]' in t, t[:300]) + # 12. The brain restarts: the screen picks everything up again, and work goes on. + before = len(full_messages()) + stop('brain') + time.sleep(3) + start('brain', [BIN, '--serve'], benv) + for _ in range(60): + try: + if peers().get('E2E大腦', {}).get('online'): + v = pull() + break + except Exception: + pass + time.sleep(0.5) + time.sleep(2) + pull() + check('after a brain restart the screen has the whole chat', len(copy['msgs'].get(1, [])) >= before, f'{len(copy["msgs"].get(1, []))} < {before}') + sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '在我的電腦跑指令 SC_SHELL 四'}) + wait_turn('once') + t = ' '.join(tool_text_after('SC_SHELL 四')) + check('after a brain restart the hands still work', 'e2e-42' in t and f'[{HANDS}]' in t, t[:300]) + pull(); pull() + check('screen copy still equals the brain chat', sig(copy['msgs'].get(1, [])) == sig(full_messages())) + # 13. 清除聊天記錄 from the screen clears the brain's chat. + sync('POST', '/act', {'bot': 1, 'action': 'clear'}) + time.sleep(1.5) + check('clear from the screen empties the brain chat', full_messages() == []) + pull(); pull() + check('and the screen copy follows', copy['msgs'].get(1, []) == [], len(copy['msgs'].get(1, []))) + +try: + run() +except Exception as e: + import traceback; traceback.print_exc() + check('harness ran to the end', False, repr(e)) +finally: + for n in list(procs): + stop(n) + failed = [r for r in results if not r[1]] + print(f'\n{len(results) - len(failed)}/{len(results)} passed; logs in {WORK}') + sys.exit(1 if failed else 0) diff --git a/src/agent.rs b/src/agent.rs index 0c31819..7ae3c57 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -112,9 +112,7 @@ fn drain_steer(job: &Job, history: &mut Vec, send: &dyn Fn(StreamEvent) fn target_for(job: &Job, args: &str) -> Result { let v = tools::args_of(args); let want = v.get("computer").and_then(|c| c.as_str()).filter(|c| !c.is_empty()).unwrap_or(&job.default_computer).to_string(); - job.computers.iter().find(|t| t.name == want).cloned().ok_or_else(|| { - format!("沒有「{want}」這台電腦。可以用:{}", job.computers.iter().map(|t| t.name.as_str()).collect::>().join("、")) - }) + job.computers.iter().find(|t| t.name == want).cloned().ok_or_else(|| not_here(job, &want)) } enum BrowserAt { @@ -225,6 +223,9 @@ fn keep_to_chosen(job: &Job, history: &[Message], args: &str) -> (String, Option let said = history.iter().rev().filter(|m| m.role == Role::User && !m.summary).take(3).map(|m| m.text.as_str()).collect::>().join("\n"); match job.computers.iter().find(|t| t.name == named) { Some(t) if mentions(&said, t, &job.roles) => (args.to_string(), None), + // A computer that isn't on the list now (switched off, its AnyBot closed): the step says + // so instead of quietly running somewhere else. + None => (args.to_string(), None), _ => { v["computer"] = serde_json::Value::String(job.default_computer.clone()); let note = format!("(這一步你指定了「{named}」,但使用者選的電腦是「{}」,而且他沒有說要換,所以這一步在「{}」上做。真的需要別台,先問使用者。)\n", job.default_computer, job.default_computer); @@ -241,6 +242,11 @@ fn noted(moved: &std::collections::HashMap, id: &str, text: &str } } +/// A computer the step names that isn't among this bot's computers right now, in plain words. +fn not_here(job: &Job, name: &str) -> String { + format!("「{name}」現在連不到:那台電腦可能沒開,或上面的 AnyBot 沒開、沒登入。請告訴使用者,等它連上再做(或問他要不要改用別台)。現在可以用:{}", job.computers.iter().map(|t| t.name.as_str()).collect::>().join("、")) +} + /// Does the call name a computer? fn names_a_computer(args: &str) -> bool { tools::args_of(args).get("computer").and_then(|c| c.as_str()).map(|c| !c.trim().is_empty()).unwrap_or(false) @@ -254,7 +260,7 @@ fn screen_target(job: &Job, args: &str) -> Result { let has_screen = |t: &tools::Target| matches!(t.place, tools::Place::Box { .. } | tools::Place::Local { .. } | tools::Place::Device { .. }); let named = v.get("computer").and_then(|c| c.as_str()).map(str::trim).filter(|c| !c.is_empty()); if let Some(n) = named { - let t = job.computers.iter().find(|t| t.name == n).ok_or_else(|| format!("沒有「{n}」這台電腦。可以用:{}", job.computers.iter().map(|t| t.name.as_str()).collect::>().join("、")))?; + let t = job.computers.iter().find(|t| t.name == n).ok_or_else(|| not_here(job, n))?; if !has_screen(t) { return Err(if n == job.roles.own { format!("{n}現在沒有螢幕:AnyBot 所在的電腦沒有 Docker({})。要用別台請使用者指名。", if cfg!(target_os = "macos") { "安裝 OrbStack 或 Docker Desktop 就會有" } else { "安裝 Docker Engine 就會有" }) diff --git a/src/app/remote_ui.rs b/src/app/remote_ui.rs index 7585c6b..c0668cc 100644 --- a/src/app/remote_ui.rs +++ b/src/app/remote_ui.rs @@ -131,8 +131,9 @@ impl App { 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 (once a minute). - if !self.state.remote.host_token.is_empty() && !self.rm.peers_at.map(|t| t.elapsed().as_secs() < 60).unwrap_or(false) { + // 裝置連結: 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 || { @@ -268,6 +269,15 @@ impl App { // 「我的電腦」 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; diff --git a/src/cloudbox.rs b/src/cloudbox.rs index 10540a9..1d20611 100644 --- a/src/cloudbox.rs +++ b/src/cloudbox.rs @@ -41,8 +41,9 @@ pub const HOST_PORT: u16 = 16080; pub const SCREEN: &str = "1280x800x24"; /// Health probe timeout (QQn). pub const HEALTH_TIMEOUT_MS: u64 = 3_000; -/// Snapshot ("flush the durable store") timeout (VQn). -pub const SNAPSHOT_TIMEOUT_MS: u64 = 30_000; +/// Saving a snapshot ("flush the durable store"). The original waits 30 s (VQn); a home folder of +/// a GB with an open browser takes minutes to pack, and a cut-off snapshot is no snapshot. +pub const SNAPSHOT_SAVE_TIMEOUT_MS: u64 = 600_000; /// Restore copy-in: longest wait and poll interval (zQn, HQn). pub const COPY_IN_WAIT_MS: u64 = 90_000; pub const COPY_IN_POLL_MS: u64 = 2_000; @@ -747,7 +748,11 @@ pub fn save_snapshot() -> Result { let tmp = data_dir().join("snapshot.tmp"); let file = std::fs::File::create(&tmp).map_err(|e| e.to_string())?; let mut child = docker() - .args(["exec", CONTAINER, "tar", "-C", HOME, "--exclude=./.cache", "-czf", "-", "."]) + // Caches are left out: the browser's grow to hundreds of MB and come back by themselves. + .args([ + "exec", CONTAINER, "tar", "-C", HOME, "--exclude=./.cache", "--exclude=Cache", "--exclude=Code Cache", "--exclude=GPUCache", "--exclude=CacheStorage", "--exclude=ScriptCache", "--exclude=Crashpad", + "--warning=no-file-changed", "-czf", "-", ".", + ]) .stdout(Stdio::from(file)) .stderr(Stdio::piped()) .spawn() @@ -763,10 +768,12 @@ pub fn save_snapshot() -> Result { }); let start = Instant::now(); let ok = loop { + // tar says 1 when a file changed while it was read (the open browser writes all the + // time): the archive is still whole. Only 2 and up are failures. if let Ok(Some(st)) = child.try_wait() { - break st.success(); + break st.code().map(|c| c <= 1).unwrap_or(false); } - if start.elapsed() > Duration::from_millis(SNAPSHOT_TIMEOUT_MS * 4) { + if start.elapsed() > Duration::from_millis(SNAPSHOT_SAVE_TIMEOUT_MS) { let _ = child.kill(); break false; } @@ -1527,15 +1534,29 @@ mod tests { println!("{txt}"); assert!(img.is_some()); run_on("shell", r#"{"command":"(xterm >/dev/null 2>&1 &) ; sleep 1.5"}"#, &t, &cancel, &ids); - computer_run("computer_use_click", &serde_json::json!({"x": 200, "y": 150})).unwrap(); + // Click the middle of that window, wherever it opened (other windows — a browser left + // open — may cover the corner), in the screenshot's pixels. (Its shell renames it, so it + // is found by its class: the newest xterm.) + let (geo, _) = run_on("shell", r#"{"command":"W=$(DISPLAY=:1 xdotool search --onlyvisible --class xterm | tail -1); DISPLAY=:1 xdotool getwindowgeometry --shell $W | tail -n +2 | head -4 | tr '\n' ' '"}"#, &t, &cancel, &ids); + let num = |k: &str| geo.split_whitespace().find_map(|w| w.strip_prefix(&format!("{k}=")).and_then(|v| v.parse::().ok())).unwrap_or(0.0); + let (cx, cy) = (num("X") + num("WIDTH") / 2.0, num("Y") + num("HEIGHT") / 2.0); + let k = 1024.0 / 1280.0; + computer_run("computer_use_click", &serde_json::json!({"x": cx * k, "y": cy * k})).unwrap(); computer_run("computer_use_typing", &serde_json::json!({"value": "echo 中文輸入OK > /home/bot/typed.txt"})).unwrap(); computer_run("computer_use_press_key", &serde_json::json!({"key": "return"})).unwrap(); std::thread::sleep(Duration::from_millis(800)); let (out, _) = run_on("shell", r#"{"command":"cat /home/bot/typed.txt"}"#, &t, &cancel, &ids); println!("typed: {out}"); assert!(out.contains("中文輸入OK"), "{out}"); - // snapshot + restore - save_snapshot().unwrap(); + // snapshot (with the browser and its caches in the home folder), then restore — which + // rebuilds the whole box, so only on a box meant for it (ANYBOT_TEST_RESTORE=1). + let size = save_snapshot().expect("snapshot saves even while the browser writes"); + println!("snapshot: {}", human(size)); + assert!(size > 0); + if std::env::var("ANYBOT_TEST_RESTORE").as_deref() != Ok("1") { + let _ = run_on("shell", r#"{"command":"rm -rf /home/bot/bot-999 /home/bot/typed.txt"}"#, &t, &cancel, &ids); + return; + } run_on("shell", r#"{"command":"rm -rf notes"}"#, &t, &cancel, &ids); rebuild(Rebuild::Restore).unwrap(); let start = Instant::now(); diff --git a/src/main.rs b/src/main.rs index e1b1e19..40b466e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -177,6 +177,17 @@ fn write_icon(out: &str, px: u32) -> Result<(), String> { fn main() -> eframe::Result<()> { let args: Vec = std::env::args().collect(); let screenshot = args.iter().position(|a| a == "--screenshot").and_then(|i| args.get(i + 1)).map(PathBuf::from); + // For tests that start AnyBots of their own: a whole settings file (the sample bots) to edit. + if args.get(1).map(String::as_str) == Some("--sample-state") { + let out = args.get(2).cloned().unwrap_or_else(|| "state.json".into()); + let mut st = model::State::samples(); + st.remote = Default::default(); + if let Err(e) = std::fs::write(&out, serde_json::to_vec_pretty(&st).unwrap_or_default()) { + eprintln!("{e}"); + std::process::exit(1); + } + return Ok(()); + } if args.get(1).map(String::as_str) == Some("--write-icon") { let out = args.get(2).cloned().unwrap_or_else(|| "anybot.png".into()); let px = args.get(3).and_then(|v| v.parse().ok()).unwrap_or(256); From 102746d9e8e8072b300b4dda3810d4085dc44d16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Thu, 5 Nov 2026 18:20:45 +0800 Subject: [PATCH 12/19] A brain's box can go by its own name (ANYBOT_BOX_NAME); the server's brain takes over its old box under anybot-brain-box A desktop AnyBot left on the server (older build) took "anybot-box" for its own and paused it every ~20 s as idle while the brain woke it: the bots' computer kept showing as asleep. The brain now renames its box once (files are in the volume, kept by name) and uses that name. Co-Authored-By: Claude Opus 5.5 --- deploy/server/docker-compose.yml | 3 ++ src/cloudbox.rs | 92 +++++++++++++++++++++----------- 2 files changed, 63 insertions(+), 32 deletions(-) diff --git a/deploy/server/docker-compose.yml b/deploy/server/docker-compose.yml index f825b76..6995ad5 100644 --- a/deploy/server/docker-compose.yml +++ b/deploy/server/docker-compose.yml @@ -54,6 +54,9 @@ services: OPENAI_API_KEY: "${OPENAI_API_KEY:-}" GEMINI_API_KEY: "${GEMINI_API_KEY:-}" ANYBOT_RESTORE_FROM_ACCOUNT: "${ANYBOT_RESTORE_FROM_ACCOUNT:-}" + # The bots' computer goes by its own name: a desktop AnyBot on this same machine keeps + # "anybot-box" for itself (and used to pause the brain's one as idle). + ANYBOT_BOX_NAME: "anybot-brain-box" volumes: - anybot-data:/data # The bot's own computer runs as a container next to this one. diff --git a/src/cloudbox.rs b/src/cloudbox.rs index 1d20611..e37a137 100644 --- a/src/cloudbox.rs +++ b/src/cloudbox.rs @@ -27,7 +27,34 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Mutex, OnceLock}; use std::time::{Duration, Instant, SystemTime}; +/// The box's usual name. Two AnyBots on one Docker host (a server's brain and a desktop AnyBot +/// left on the same machine) each took "anybot-box" for their own, and the desktop one kept +/// pausing the brain's box as "idle". ANYBOT_BOX_NAME gives an AnyBot a box of its own name. pub const CONTAINER: &str = "anybot-box"; + +/// This AnyBot's box (ANYBOT_BOX_NAME, else "anybot-box"). +pub fn container() -> &'static str { + static C: OnceLock = OnceLock::new(); + C.get_or_init(|| { + std::env::var("ANYBOT_BOX_NAME") + .ok() + .map(|n| n.trim().to_string()) + .filter(|n| !n.is_empty() && n.len() <= 60 && n.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')) + .unwrap_or_else(|| CONTAINER.to_string()) + }) +} + +/// A box of its own name takes over the usual one this AnyBot used before (same files: the +/// home folder is a volume, kept by name; a rename doesn't stop it). +fn adopt_usual_box() { + let me = container(); + if me == CONTAINER || dk(&["inspect", "-f", "{{.Id}}", me]).is_ok() { + return; + } + if dk(&["inspect", "-f", "{{.Id}}", CONTAINER]).is_ok() && dk(&["rename", CONTAINER, me]).is_ok() { + log(&format!("Bot 的電腦改用自己的名字 {me}(原本叫 {CONTAINER})。")); + } +} pub const IMAGE: &str = "anybot-box:1"; /// The bot's files live here and survive an update (the original's mounted sand-data tree). pub const VOLUME: &str = "anybot-box-home"; @@ -321,7 +348,7 @@ pub fn wanted_version() -> String { /// The running computer is older than the one this AnyBot carries. pub fn update_available() -> bool { - match dk(&["inspect", "-f", "{{index .Config.Labels \"anybot.box.version\"}}", CONTAINER]) { + match dk(&["inspect", "-f", "{{index .Config.Labels \"anybot.box.version\"}}", container()]) { Ok(v) => v.trim() != wanted_version(), Err(_) => false, } @@ -460,12 +487,12 @@ pub fn probe() -> Run { format!("Docker 沒有在執行:{}", e.lines().last().unwrap_or("")) }); } - match dk(&["inspect", "-f", "{{.State.Status}}", CONTAINER]) { + match dk(&["inspect", "-f", "{{.State.Status}}", container()]) { Err(_) => Run::Absent, Ok(s) => match s.as_str() { "running" => { // The health probe (3 s, like the original's GET /health). - if dk_timeout(&["exec", CONTAINER, "test", "-f", "/tmp/anybot-box.ready"], HEALTH_TIMEOUT_MS).is_ok() { + if dk_timeout(&["exec", container(), "test", "-f", "/tmp/anybot-box.ready"], HEALTH_TIMEOUT_MS).is_ok() { Run::Running } else { Run::Starting("連線中") @@ -550,14 +577,14 @@ fn create_container() -> Result<(), String> { let vnc = format!("VNC_PASSWORD={}", vnc_password()); log("正在開一台新的電腦…"); // host.docker.internal → this Mac (for 網路流量改走你的電腦; Docker Desktop and OrbStack already have it). - dk(&["run", "-d", "--name", CONTAINER, "--hostname", "bot-computer", "--shm-size", "1g", "--pids-limit", "4096", "--memory", "2g", "--cpus", "2", "--add-host", "host.docker.internal:host-gateway", "-p", &publish, "-v", &vol, "-e", &env, "-e", &vnc, "--label", "anybot.box=1", IMAGE]).map_err(|e| format!("開不了電腦:{}", tail(&e, 400)))?; + dk(&["run", "-d", "--name", container(), "--hostname", "bot-computer", "--shm-size", "1g", "--pids-limit", "4096", "--memory", "2g", "--cpus", "2", "--add-host", "host.docker.internal:host-gateway", "-p", &publish, "-v", &vol, "-e", &env, "-e", &vnc, "--label", "anybot.box=1", IMAGE]).map_err(|e| format!("開不了電腦:{}", tail(&e, 400)))?; Ok(()) } fn wait_ready(ms: u64) -> Result<(), String> { let start = Instant::now(); while start.elapsed() < Duration::from_millis(ms) { - if dk(&["exec", CONTAINER, "test", "-f", "/tmp/anybot-box.ready"]).is_ok() { + if dk(&["exec", container(), "test", "-f", "/tmp/anybot-box.ready"]).is_ok() { return Ok(()); } std::thread::sleep(Duration::from_millis(250)); @@ -589,7 +616,7 @@ fn box_cdp_forward() -> Result { for conn in l.incoming().flatten() { std::thread::spawn(move || { let script = format!("exec 3<>/dev/tcp/127.0.0.1/{BOX_CDP_PORT} || exit 1; cat <&3 & cat >&3; kill $! 2>/dev/null"); - let Ok(mut child) = docker().args(["exec", "-i", CONTAINER, "bash", "-c", &script]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn() else { return }; + let Ok(mut child) = docker().args(["exec", "-i", container(), "bash", "-c", &script]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn() else { return }; let (Some(mut cin), Some(mut cout)) = (child.stdin.take(), child.stdout.take()) else { return }; let (Ok(mut r), Ok(mut w)) = (conn.try_clone(), conn.try_clone()) else { return }; let up = std::thread::spawn(move || { @@ -621,7 +648,7 @@ pub fn browser_port() -> Result { return Ok(port); } let dbg = format!("--remote-debugging-port={BOX_CDP_PORT}"); - dk(&["exec", "-d", "-u", "bot", "-e", "DISPLAY=:1", "-e", "HOME=/home/bot", CONTAINER, "chromium", &dbg, "--remote-allow-origins=*", "--user-data-dir=/home/bot/.config/anybot-browser", "about:blank"]).map_err(|e| format!("Bot 的電腦打不開瀏覽器:{e}"))?; + dk(&["exec", "-d", "-u", "bot", "-e", "DISPLAY=:1", "-e", "HOME=/home/bot", container(), "chromium", &dbg, "--remote-allow-origins=*", "--user-data-dir=/home/bot/.config/anybot-browser", "about:blank"]).map_err(|e| format!("Bot 的電腦打不開瀏覽器:{e}"))?; let start = Instant::now(); while start.elapsed() < Duration::from_secs(20) { if box_cdp_alive(port) { @@ -652,8 +679,8 @@ pub fn ensure_running() -> Result<(), String> { Run::Asleep => { set_run(Run::Starting("喚醒中")); log("喚醒電腦…"); - let st = dk(&["inspect", "-f", "{{.State.Status}}", CONTAINER]).unwrap_or_default(); - let woke = if st == "paused" { dk(&["unpause", CONTAINER]) } else { dk(&["start", CONTAINER]) }; + let st = dk(&["inspect", "-f", "{{.State.Status}}", container()]).unwrap_or_default(); + let woke = if st == "paused" { dk(&["unpause", container()]) } else { dk(&["start", container()]) }; woke.map_err(|e| format!("叫不醒電腦:{e}")).and_then(|_| wait_ready(BOOT_WAIT_MS)) } Run::Starting(_) => { @@ -665,13 +692,13 @@ pub fn ensure_running() -> Result<(), String> { // A broken container: start it again. log(&format!("電腦狀態異常({e}),重新啟動…")); set_run(Run::Starting("連線中")); - dk(&["restart", CONTAINER]).map_err(|e| e.to_string()).and_then(|_| wait_ready(BOOT_WAIT_MS)) + dk(&["restart", container()]).map_err(|e| e.to_string()).and_then(|_| wait_ready(BOOT_WAIT_MS)) } }; match &result { Ok(()) => { set_run(Run::Running); - let _ = dk(&["exec", CONTAINER, "sh", "-c", &format!("mkdir -p {HOME} && chown bot:bot {HOME}")]); + let _ = dk(&["exec", container(), "sh", "-c", &format!("mkdir -p {HOME} && chown bot:bot {HOME}")]); } Err(e) => { log(e); @@ -695,6 +722,7 @@ pub fn start_background() { return; } std::thread::spawn(|| { + adopt_usual_box(); set_run(probe()); resume_interrupted(); loop { @@ -750,7 +778,7 @@ pub fn save_snapshot() -> Result { let mut child = docker() // Caches are left out: the browser's grow to hundreds of MB and come back by themselves. .args([ - "exec", CONTAINER, "tar", "-C", HOME, "--exclude=./.cache", "--exclude=Cache", "--exclude=Code Cache", "--exclude=GPUCache", "--exclude=CacheStorage", "--exclude=ScriptCache", "--exclude=Crashpad", + "exec", container(), "tar", "-C", HOME, "--exclude=./.cache", "--exclude=Cache", "--exclude=Code Cache", "--exclude=GPUCache", "--exclude=CacheStorage", "--exclude=ScriptCache", "--exclude=Crashpad", "--warning=no-file-changed", "-czf", "-", ".", ]) .stdout(Stdio::from(file)) @@ -794,7 +822,7 @@ fn copy_in_snapshot() -> Result<(), String> { use std::io::Write; let data = std::fs::read(snapshot_path()).map_err(|_| "還沒有快照可以還原".to_string())?; let mut child = docker() - .args(["exec", "-i", CONTAINER, "sh", "-c", &format!("tar -C {HOME} -xzf - && chown -R bot:bot {HOME} && echo '[box-copy-in] result outcome=hydrated' >> /tmp/anybot-copy-in.log")]) + .args(["exec", "-i", container(), "sh", "-c", &format!("tar -C {HOME} -xzf - && chown -R bot:bot {HOME} && echo '[box-copy-in] result outcome=hydrated' >> /tmp/anybot-copy-in.log")]) .stdin(Stdio::piped()) .stdout(Stdio::null()) .stderr(Stdio::piped()) @@ -808,7 +836,7 @@ fn copy_in_snapshot() -> Result<(), String> { // Like readCopyInOutcome: wait for the copy-in to report "hydrated". let start = Instant::now(); while start.elapsed() < Duration::from_millis(COPY_IN_WAIT_MS) { - if let Ok(s) = dk(&["exec", CONTAINER, "sh", "-c", "cat /tmp/anybot-copy-in.log 2>/dev/null || true"]) { + if let Ok(s) = dk(&["exec", container(), "sh", "-c", "cat /tmp/anybot-copy-in.log 2>/dev/null || true"]) { if s.contains("outcome=hydrated") { return Ok(()); } @@ -819,7 +847,7 @@ fn copy_in_snapshot() -> Result<(), String> { } fn home_has_files() -> bool { - dk(&["exec", CONTAINER, "sh", "-c", &format!("ls -A {HOME} | head -1")]).map(|s| !s.trim().is_empty()).unwrap_or(false) + dk(&["exec", container(), "sh", "-c", &format!("ls -A {HOME} | head -1")]).map(|s| !s.trim().is_empty()).unwrap_or(false) } pub fn human(b: u64) -> String { @@ -931,7 +959,7 @@ fn run_rebuild(kind: Rebuild, phase: &dyn Fn(&str, &str)) -> Result<(), String> phase("creating", "更新電腦的映像檔"); build_image(true)?; phase("creating", "換上一台新的電腦"); - let _ = dk(&["rm", "-f", CONTAINER]); + let _ = dk(&["rm", "-f", container()]); create_container()?; wait_ready(BOOT_WAIT_MS)?; phase("moving", "確認保留的檔案和新電腦的連線"); @@ -942,7 +970,7 @@ fn run_rebuild(kind: Rebuild, phase: &dyn Fn(&str, &str)) -> Result<(), String> } Rebuild::Reset => { phase("cleaning-up", "拆掉舊的電腦"); - let _ = dk(&["rm", "-f", CONTAINER]); + let _ = dk(&["rm", "-f", container()]); phase("wiping", "清除電腦裡的資料"); let _ = dk(&["volume", "rm", "-f", VOLUME]); phase("creating", "開一台全新的電腦"); @@ -954,7 +982,7 @@ fn run_rebuild(kind: Rebuild, phase: &dyn Fn(&str, &str)) -> Result<(), String> } Rebuild::Restore => { phase("cleaning-up", "拆掉舊的電腦和它的資料"); - let _ = dk(&["rm", "-f", CONTAINER]); + let _ = dk(&["rm", "-f", container()]); let _ = dk(&["volume", "rm", "-f", VOLUME]); phase("creating", "從快照開一台新的電腦"); if !image_exists() { @@ -990,7 +1018,7 @@ fn resume_interrupted() { /// Disk use in the box, for the settings page and the clean-up request. pub fn disk_report() -> Result { ensure_running()?; - dk(&["exec", "-u", "bot", CONTAINER, "sh", "-c", &format!("df -h {HOME} | tail -1; echo; du -sh {HOME}/* {HOME}/.cache 2>/dev/null | sort -rh | head -8")]) + dk(&["exec", "-u", "bot", container(), "sh", "-c", &format!("df -h {HOME} | tail -1; echo; du -sh {HOME}/* {HOME}/.cache 2>/dev/null | sort -rh | head -8")]) } // ---------- secrets (write-only) ---------- @@ -1080,7 +1108,7 @@ pub fn exec_wrapper(dir: &str, cmd: &str, interactive: bool, job: &str) -> Strin let inner = format!("mkdir -p {d} && cd {d} && {tagged}", d = sh_quote(dir)); let env = env_file_path(); let env_arg = if env.exists() { format!(" --env-file {}", sh_quote(&env.display().to_string())) } else { String::new() }; - format!("{} exec{} -u bot -e DISPLAY={DISPLAY} -e HOME={HOME}{}{env_arg} {CONTAINER} bash -lc {}", docker_prefix(), if interactive { " -i" } else { "" }, egress_env(), sh_quote(&inner)) + format!("{} exec{} -u bot -e DISPLAY={DISPLAY} -e HOME={HOME}{}{env_arg} {box} bash -lc {}", docker_prefix(), if interactive { " -i" } else { "" }, egress_env(), sh_quote(&inner), r#box = container()) } /// An argv that kills the box process carrying this job tag (and its children). @@ -1092,7 +1120,7 @@ pub fn exec_kill_argv(job: &str) -> Vec { } argv.extend([ "exec".to_string(), - CONTAINER.to_string(), + container().to_string(), "sh".to_string(), "-c".to_string(), format!("for p in $(pgrep -f 'export ANYBOT_JOB={job}'); do pkill -9 -P $p; pkill -9 -P $p; kill -9 $p 2>/dev/null; done; true"), @@ -1113,10 +1141,10 @@ pub fn copy_in(local: &str, bot_id: u64, name: &str) -> Result { .filter(|n| !n.is_empty() && n != "." && n != "..") .ok_or_else(|| "檔案名稱無效".to_string())?; let dir = format!("{}/附件", bot_dir(bot_id)); - dk(&["exec", "-u", "bot", CONTAINER, "mkdir", "-p", &dir])?; + dk(&["exec", "-u", "bot", container(), "mkdir", "-p", &dir])?; let dst = format!("{dir}/{safe}"); - dk(&["cp", local, &format!("{CONTAINER}:{dst}")])?; - let _ = dk(&["exec", CONTAINER, "chown", "bot:bot", &dst]); + dk(&["cp", local, &format!("{c}:{dst}", c = container())])?; + let _ = dk(&["exec", container(), "chown", "bot:bot", &dst]); Ok(dst) } @@ -1127,21 +1155,21 @@ pub fn copy_out(path_in_box: &str) -> Result { let dir = std::env::temp_dir().join(format!("anybot-from-box-{}", chrono::Utc::now().timestamp_millis())); std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; let local = dir.join(&name); - dk(&["cp", &format!("{CONTAINER}:{path_in_box}"), &local.display().to_string()]).map_err(|e| format!("拿不到檔案:{e}"))?; + dk(&["cp", &format!("{}:{path_in_box}", container()), &local.display().to_string()]).map_err(|e| format!("拿不到檔案:{e}"))?; Ok(local) } // ---------- the box's screen (xdotool) ---------- fn xdo(args: &[&str]) -> Result { - let mut a = vec!["exec", "-u", "bot", "-e", "DISPLAY=:1", CONTAINER, "xdotool"]; + let mut a = vec!["exec", "-u", "bot", "-e", "DISPLAY=:1", container(), "xdotool"]; a.extend_from_slice(args); dk(&a).map_err(|e| format!("xdotool:{e}")) } /// The desktop as an image (full size). pub fn grab() -> Result { - let png = dk_timeout(&["exec", CONTAINER, "import", "-window", "root", "-display", DISPLAY, "png:-"], 10_000)?; + let png = dk_timeout(&["exec", container(), "import", "-window", "root", "-display", DISPLAY, "png:-"], 10_000)?; image::load_from_memory(&png).map(|i| i.to_rgb8()).map_err(|e| format!("截圖讀不出來:{e}")) } @@ -1164,7 +1192,7 @@ pub fn frame() -> Option { if !run_state().is_running() { return None; } - let png = dk_timeout(&["exec", CONTAINER, "import", "-window", "root", "-display", DISPLAY, "-quality", "1", "png:-"], 5_000).ok()?; + let png = dk_timeout(&["exec", container(), "import", "-window", "root", "-display", DISPLAY, "-quality", "1", "png:-"], 5_000).ok()?; image::load_from_memory(&png).ok().map(|i| i.to_rgba8()) } @@ -1384,7 +1412,7 @@ pub fn type_text(text: &str) -> Result<(), String> { use std::io::Write; // Both selections hold the text; xclip keeps serving it after docker exec returns (setsid). let mut child = docker() - .args(["exec", "-i", "-u", "bot", "-e", "DISPLAY=:1", CONTAINER, "sh", "-c", "cat > /tmp/anybot-paste.txt && for s in clipboard primary; do setsid xclip -selection $s -i /tmp/anybot-paste.txt /dev/null 2>&1 & done; sleep 0.15"]) + .args(["exec", "-i", "-u", "bot", "-e", "DISPLAY=:1", container(), "sh", "-c", "cat > /tmp/anybot-paste.txt && for s in clipboard primary; do setsid xclip -selection $s -i /tmp/anybot-paste.txt /dev/null 2>&1 & done; sleep 0.15"]) .stdin(Stdio::piped()) .stdout(Stdio::null()) .stderr(Stdio::null()) @@ -1392,7 +1420,7 @@ pub fn type_text(text: &str) -> Result<(), String> { .map_err(|e| e.to_string())?; child.stdin.take().ok_or("寫不進剪貼簿")?.write_all(text.as_bytes()).map_err(|e| e.to_string())?; let _ = child.wait(); - let class = dk(&["exec", "-u", "bot", "-e", "DISPLAY=:1", CONTAINER, "sh", "-c", "xprop -id $(xdotool getactivewindow) WM_CLASS 2>/dev/null"]).unwrap_or_default().to_lowercase(); + let class = dk(&["exec", "-u", "bot", "-e", "DISPLAY=:1", container(), "sh", "-c", "xprop -id $(xdotool getactivewindow) WM_CLASS 2>/dev/null"]).unwrap_or_default().to_lowercase(); let paste = if class.contains("term") { "shift+Insert" } else { "ctrl+v" }; xdo(&["key", "--clearmodifiers", paste])?; Ok(()) @@ -1427,7 +1455,7 @@ pub fn viewer_text(text: &str) { // ---------- the browser viewer (noVNC) ---------- fn published_port() -> u16 { - dk(&["port", CONTAINER, &NOVNC_PORT.to_string()]) + dk(&["port", container(), &NOVNC_PORT.to_string()]) .ok() .and_then(|s| s.lines().next().and_then(|l| l.rsplit(':').next()).and_then(|p| p.trim().parse().ok())) .unwrap_or(HOST_PORT) @@ -1629,7 +1657,7 @@ mod tests { fn wrapper_runs_as_the_bot_in_its_folder() { let w = exec_wrapper("/home/bot/bot-3", "ls -la", false, ""); assert!(w.contains("exec -u bot")); - assert!(w.contains(CONTAINER)); + assert!(w.contains(container())); assert!(w.contains("/home/bot/bot-3")); } } From d52def0f46ff7e5aae4b5f8a1064470e61914353 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Thu, 5 Nov 2026 18:25:46 +0800 Subject: [PATCH 13/19] =?UTF-8?q?A=20click=20by=20number=20reads=20as=20th?= =?UTF-8?q?e=20element=20it=20is=20in=20the=20question=20(=E9=BB=9E=20e7?= =?UTF-8?q?=EF=BC=88=E6=8C=89=E9=88=95=E3=80=8C7=E3=80=8D=EF=BC=89),=20not?= =?UTF-8?q?=20(0,=200)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/agent.rs | 21 ++++++++++++++++++++- src/computer.rs | 10 ++++++++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/agent.rs b/src/agent.rs index 7ae3c57..7d30f83 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -234,6 +234,14 @@ fn keep_to_chosen(job: &Job, history: &[Message], args: &str) -> (String, Option } } +/// What element `e7` is, from the latest app list in the chat (「按鈕「7」」). +fn element_named(history: &[Message], r: &str) -> Option { + let tag = format!("{r} "); + history.iter().rev().filter(|m| m.role == Role::Tool).take(12).find_map(|m| { + m.text.lines().find_map(|l| l.trim_start().strip_prefix(&tag).map(|rest| rest.trim().to_string())).filter(|e| !e.is_empty()) + }) +} + /// A step's result, with a word first when the step was moved back to the chosen computer. fn noted(moved: &std::collections::HashMap, id: &str, text: &str) -> String { match moved.get(id) { @@ -508,7 +516,9 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< let what = if is_mac { format!("{} {}", crate::macapps::label(&call.name), crate::macapps::describe(&call.name, &call.args)) } else if is_screen { - format!("{} {}", computer::label(&call.name), computer::describe(&call.name, &call.args)) + // A control by its number: name it from the app's list the bot last read. + let named = tools::args_of(&call.args)["ref"].as_str().map(str::trim).filter(|r| !r.is_empty()).and_then(|r| element_named(&history, r)).map(|e| format!("({e})")).unwrap_or_default(); + format!("{} {}{named}", computer::label(&call.name), computer::describe(&call.name, &call.args)) } else if is_web { web::describe(&call.name, &call.args) } else { @@ -1132,6 +1142,15 @@ mod tests { assert!(!mentions("在 macOS 的登入機制下,幫 Bot 的電腦截圖", &mac, &roles)); } + #[test] + fn a_click_by_number_is_named_in_the_question() { + let list = Message::tool_result("c1", "computer_use_read_app", "[王小明的Mac mini] 「計算機」的畫面:\n 文字「0」\n e4 按鈕「全部清除」\n e7 按鈕「7」"); + assert_eq!(element_named(&[list.clone()], "e7").as_deref(), Some("按鈕「7」")); + assert_eq!(element_named(&[list], "e9"), None); + assert_eq!(computer::describe("computer_use_click", r#"{"ref":"e7"}"#), "點 e7"); + assert_eq!(computer::describe("computer_use_click", r#"{"x":10,"y":20}"#), "點 (10, 20)"); + } + #[test] fn every_computer_answers_to_its_own_name() { // One name per computer: the bot's own goes by the bot's name, the others by theirs, and diff --git a/src/computer.rs b/src/computer.rs index 6890f34..dddc149 100644 --- a/src/computer.rs +++ b/src/computer.rs @@ -161,7 +161,12 @@ fn args_of(args: &str) -> Value { pub fn describe(name: &str, args: &str) -> String { let v = args_of(args); - let xy = || format!("({}, {})", v["x"].as_f64().unwrap_or(0.0), v["y"].as_f64().unwrap_or(0.0)); + // By number from the app's list (e7), else where on the screen (it said "(0, 0)" for every + // click by number). + let xy = || match v["ref"].as_str().map(str::trim).filter(|r| !r.is_empty()) { + Some(r) => r.to_string(), + None => format!("({}, {})", v["x"].as_f64().unwrap_or(0.0), v["y"].as_f64().unwrap_or(0.0)), + }; match name { "computer_use_screenshot" => "螢幕截圖".into(), "computer_use_click" => { @@ -172,7 +177,8 @@ pub fn describe(name: &str, args: &str) -> String { "computer_use_typing" => { let t = v["value"].as_str().unwrap_or(""); let short: String = t.chars().take(60).collect(); - format!("「{short}{}」", if t.chars().count() > 60 { "…" } else { "" }) + let at = v["ref"].as_str().filter(|r| !r.trim().is_empty()).map(|r| format!("在 {r} ")).unwrap_or_default(); + format!("{at}「{short}{}」", if t.chars().count() > 60 { "…" } else { "" }) } "computer_use_press_key" => v["key"].as_str().unwrap_or("").to_string(), "computer_use_scroll" => format!("{} {} 格,在 {}", v["direction"].as_str().unwrap_or("down"), v["amount"].as_i64().unwrap_or(3), xy()), From 60f4fa7411c9cf667e97d04025a20c44b5ff1bec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Thu, 5 Nov 2026 18:50:08 +0800 Subject: [PATCH 14/19] =?UTF-8?q?Reading=20or=20using=20an=20app=20without?= =?UTF-8?q?=20=E8=BC=94=E5=8A=A9=E4=BD=BF=E7=94=A8=20asks=20for=20it=20and?= =?UTF-8?q?=20says=20so=20in=20words=20(it=20answered=20PERM=5FDENIED=20an?= =?UTF-8?q?d=20asked=20nobody)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/computer.rs | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/computer.rs b/src/computer.rs index dddc149..33ef2d3 100644 --- a/src/computer.rs +++ b/src/computer.rs @@ -430,6 +430,20 @@ pub fn ask_permission(pane: &str) { let _ = spawn_reaped(&mut std::process::Command::new("open").arg(format!("x-apple.systempreferences:com.apple.preference.security?{pane}"))); } +const NO_ACCESSIBILITY: &str = "macOS 還沒讓 AnyBot 使用「輔助使用」,所以讀不到、也按不了這台電腦上的 App。這台電腦上已經跳出詢問並打開「系統設定 → 隱私權與安全性 → 輔助使用」:請使用者把 AnyBot 打開(只要一次),再叫我試一次。"; + +/// agent-desktop's PERM_DENIED (no 輔助使用), as words the bot can pass on — and the person asked. +fn plain_permission_words(t: String) -> String { + if !t.contains("PERM_DENIED") { + return t; + } + ask_permission("Privacy_Accessibility"); + match t.split_once(",但讀不到它的畫面") { + Some((opened, _)) => format!("{opened},但讀不到它的畫面:{NO_ACCESSIBILITY}"), + None => NO_ACCESSIBILITY.to_string(), + } +} + /// The app a shown name stands for (「計算機」 → Calculator), from the apps' display names. #[cfg(target_os = "macos")] fn app_named(name: &str) -> Option { @@ -503,8 +517,16 @@ fn run_inner(name: &str, v: &Value) -> Result<(String, Option), String> return Err("使用者正在自己操作這台電腦(已取得控制權)。請等他交還控制權後再操作。".into()); } } + // Reading and using apps by their controls needs 輔助使用: without it macOS answers + // PERM_DENIED and nobody was asked. Ask (prompt + the Settings pane, now and then) and say it + // plainly; opening an app still opens it. + let by_controls = name == "computer_use_read_app" || run_text_mode_name(name, v).is_some(); + if cfg!(target_os = "macos") && by_controls && name != "computer_use_open_app" && !crate::axread::trusted(false) { + ask_permission("Privacy_Accessibility"); + return Err(NO_ACCESSIBILITY.into()); + } if let Some(r) = run_text_mode(name, v) { - return r; + return r.map(|(t, img)| (plain_permission_words(t), img)).map_err(plain_permission_words); } if !cfg!(any(target_os = "macos", target_os = "linux")) { return Err("操作畫面只支援 macOS 和 Linux".into()); From ad0234d0ceebddd1adf278371fcfbba3f48953a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Thu, 5 Nov 2026 19:07:43 +0800 Subject: [PATCH 15/19] =?UTF-8?q?Phone:=20the=20computer=20list=20waits=20?= =?UTF-8?q?for=20a=20brain=20that=20is=20restarting=20and=20goes=20in=20by?= =?UTF-8?q?=20itself=20(it=20sat=20on=20=E9=9B=A2=E7=B7=9A=20until=20tappe?= =?UTF-8?q?d)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- mobile/index.html | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/mobile/index.html b/mobile/index.html index 5bcf3fa..8d70bde 100644 --- a/mobile/index.html +++ b/mobile/index.html @@ -379,8 +379,13 @@ function signOut(expired) { } $("b-out1").onclick = () => signOut(); +// The list waits for a brain that is restarting (or a computer that woke up): it looks again +// every few seconds and goes in by itself; it used to sit on 「離線」 until tapped. +let hostRetry = null; async function pickHost() { - const me = await api("relay/me"); + clearTimeout(hostRetry); + let me; + try { me = await api("relay/me"); } catch (e) { toast(e.message); if (S.token) hostRetry = setTimeout(pickHost, 3000); return; } const all = me.hosts || []; // A computer that shows another one's bots (大腦) leads to that brain. const byId = Object.fromEntries(all.map((h) => [h.id, h])); @@ -399,10 +404,14 @@ async function pickHost() { } if (live.length === 1) { S.host = live[0].id; localStorage.setItem("anybot.host", S.host); return start(); } const note = (h) => { const n = sharers(h.id); return n.length ? " · " + esc(n.join("、")) + " 也用這個大腦" : ""; }; - $("hosts").innerHTML = hosts.length ? hosts.map((h) => `
${esc((h.name || "?")[0])}
${esc(h.name)}
${h.online ? "在線上" : "離線(" + esc(h.last_seen_text || "很久沒連線") + ")"}${note(h)}
`).join("") - : `
這個帳號還沒有連上任何 AnyBot。
在電腦上打開 AnyBot › 設定 › 手機與帳號,登入同一個帳號。
`; - document.querySelectorAll("#hosts .row").forEach((r) => r.onclick = () => { S.host = r.dataset.h; localStorage.setItem("anybot.host", S.host); start(); }); + const wanted = (S.host && byId[target(S.host)]) || (all.find((h) => sharers(h.id).length)) || hosts[0]; + const waiting = hosts.length && !live.length && wanted + ? `
「${esc(wanted.name)}」暫時沒有連線(可能正在重新啟動,或那台電腦睡著了)。
連上後會自動進入,不用重新整理。
` : ""; + $("hosts").innerHTML = waiting + (hosts.length ? hosts.map((h) => `
${esc((h.name || "?")[0])}
${esc(h.name)}
${h.online ? "在線上" : "離線(" + esc(h.last_seen_text || "很久沒連線") + ")"}${note(h)}
`).join("") + : `
這個帳號還沒有連上任何 AnyBot。
在電腦上打開 AnyBot › 設定 › 手機與帳號,登入同一個帳號。
`); + document.querySelectorAll("#hosts .row").forEach((r) => r.onclick = () => { clearTimeout(hostRetry); S.host = r.dataset.h; localStorage.setItem("anybot.host", S.host); start(); }); show("hosts"); + if (S.token && !live.length) hostRetry = setTimeout(pickHost, 3000); } // ---------- live state ---------- From 61e0531d322efe2d9080934e7dc5a885b0dbb349 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Thu, 5 Nov 2026 19:20:29 +0800 Subject: [PATCH 16/19] One AnyBot icon everywhere; the phone page fills the screen and shows the app's bot faces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New icon: the bot's cloud body and slanted eyes (as in the app's avatars) on a dark tile — the Mac/Linux app, browser tabs, iPhone home screen (PNG: it ignores SVG), Android and notifications. The web had its own ring-and-dot. - anybot --write-icon out.png size [file.svg] draws any SVG (for the web PNGs). - Phone: lists that reach the bottom keep their last row above the home bar and Safari's toolbar (it looked cut off); bots show their shape, colour and eyes instead of a letter. Co-Authored-By: Claude Opus 5.5 --- icons/anybot-full.svg | 22 +++++++++++++++++++++ icons/anybot.svg | 25 ++++++++++++++++-------- mobile/icon-180.png | Bin 0 -> 19096 bytes mobile/icon-192.png | Bin 0 -> 20455 bytes mobile/icon-512.png | Bin 0 -> 57605 bytes mobile/icon.svg | 22 +++++++++++++++++++++ mobile/index.html | 43 +++++++++++++++++++++++++++++++++++++----- relay/src/main.rs | 9 ++++++--- src/main.rs | 20 +++++++++++++------- src/remote.rs | 8 ++++++-- 10 files changed, 124 insertions(+), 25 deletions(-) create mode 100644 icons/anybot-full.svg create mode 100644 mobile/icon-180.png create mode 100644 mobile/icon-192.png create mode 100644 mobile/icon-512.png create mode 100644 mobile/icon.svg diff --git a/icons/anybot-full.svg b/icons/anybot-full.svg new file mode 100644 index 0000000..98c4e11 --- /dev/null +++ b/icons/anybot-full.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/icons/anybot.svg b/icons/anybot.svg index 56cfb72..5732fdf 100644 --- a/icons/anybot.svg +++ b/icons/anybot.svg @@ -1,13 +1,22 @@ - - + + - - + + + + + + + + + + + - - - - + + + + diff --git a/mobile/icon-180.png b/mobile/icon-180.png new file mode 100644 index 0000000000000000000000000000000000000000..2a951be4bc279d7dcd4c1f2f1ab3835b42b1e479 GIT binary patch literal 19096 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 cQF{Mj{L{rN&R*Wg;9v5BCC?t3C*Au00X-Yb%7 literal 0 HcmV?d00001 diff --git a/mobile/icon-512.png b/mobile/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..d90cac398e7becc0ee8080d86e28664e26ecadaa GIT binary patch literal 57605 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 literal 0 HcmV?d00001 diff --git a/mobile/icon.svg b/mobile/icon.svg new file mode 100644 index 0000000..1cf8b73 --- /dev/null +++ b/mobile/icon.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/index.html b/mobile/index.html index 8d70bde..361e51f 100644 --- a/mobile/index.html +++ b/mobile/index.html @@ -9,8 +9,9 @@ - - + + + AnyBot