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] 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