Every computer goes by its own name; a server brain works on the computer the person uses

- A bot's own computer is 「<bot> 的電腦」; 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 <noreply@anthropic.com>
This commit is contained in:
王性驊 2026-11-05 16:47:28 +08:00
parent 2a24606024
commit 3132f8ce91
20 changed files with 941 additions and 326 deletions

View File

@ -687,7 +687,9 @@ function renderChips() {
} }
// ---------- screens ---------- // ---------- 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"); $("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(); }); 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; 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}); }; $("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(); } }); $("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-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 ---------- // ---------- menu, notifications ----------
$("b-more").onclick = () => { $("m-host").style.display = S.relay ? "" : "none"; $("m-pair").style.display = S.relay ? "" : "none"; notifyLabel(); $("sheet").classList.add("on"); }; $("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"); }; $("sheet").onclick = (e) => { if (e.target.id === "sheet") $("sheet").classList.remove("on"); };

View File

@ -34,6 +34,8 @@ pub struct Job {
/// Computers this bot may work on (empty when computer tools are off). /// Computers this bot may work on (empty when computer tools are off).
pub computers: Vec<tools::Target>, pub computers: Vec<tools::Target>,
pub default_computer: String, 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. /// 在這台電腦上執行 is on.
pub local_execution: bool, pub local_execution: bool,
/// This bot's computer tools are on (when off, using them asks the person first). /// This bot's computer tools are on (when off, using them asks the person first).
@ -121,23 +123,29 @@ enum BrowserAt {
Device(String), Device(String),
} }
/// Whose browser a browser step uses: the computer it names, else the bot's chosen computer. /// Whose browser a browser step uses: the computer it names, else the bot's chosen computer; a
fn browser_place(job: &Job, name: &str, args: &str) -> BrowserAt { /// 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" { if name == "web_fetch" {
return BrowserAt::Here; return (BrowserAt::Here, String::new());
} }
let v = tools::args_of(args); 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 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 { let at = |t: &tools::Target| -> Option<BrowserAt> {
tools::Place::Device { host } => Some(host.clone()), match &t.place {
_ => None, tools::Place::Device { host } => Some(BrowserAt::Device(host.clone())),
}); tools::Place::Box { .. } if crate::cloudbox::available() => Some(BrowserAt::Box),
match job.computers.iter().find(|t| t.name == want).map(|t| &t.place) { tools::Place::Local { .. } if crate::botbrowser::available() => Some(BrowserAt::Here),
Some(tools::Place::Device { host }) => BrowserAt::Device(host.clone()), _ => None,
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), if let Some((b, n)) = job.computers.iter().find(|t| t.name == want).and_then(|t| at(t).map(|b| (b, t.name.clone()))) {
_ => BrowserAt::Here, 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 false
} }
/// Words that mean a computer in the person's message. /// Words that mean a computer in the person's message: its name (or the part after 「的」, so
fn mentions(text: &str, t: &tools::Target) -> bool { /// 「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(); let low = text.to_lowercase();
if finds(&low, &t.name.to_lowercase()) { let name = t.name.to_lowercase();
if finds(&low, &name) {
return true; return true;
} }
let words: &[&str] = match &t.place { if let Some((_, tail)) = name.rsplit_once('的') {
_ if t.name == crate::model::BOT_COMPUTER_NAME => &["bot 的電腦", "bot的電腦", "你自己的電腦", "你的虛擬", "虛擬電腦", "雲端電腦"], let tail = tail.trim();
_ if t.name == crate::model::THIS_COMPUTER_NAME => &["mac", "我的電腦", "我電腦", "我這台", "這台電腦", "本機", "我的筆電"], if tail.chars().count() >= 3 && tail != "電腦" && finds(&low, tail) {
tools::Place::Device { host } => return !crate::remote::peer_name(host).is_empty() && finds(&low, &crate::remote::peer_name(host).to_lowercase()), 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)) 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 /// A step naming a computer other than the chosen one, which the person didn't ask for, goes
/// to the chosen one. /// to the chosen one — and the bot is told so (quietly moving it made a bot "ask your Mac" six
fn keep_to_chosen(job: &Job, history: &[Message], args: &str) -> String { /// times while every step landed on its own computer).
fn keep_to_chosen(job: &Job, history: &[Message], args: &str) -> (String, Option<String>) {
let mut v = tools::args_of(args); 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) { 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::<Vec<_>>().join("\n"); let said = history.iter().rev().filter(|m| m.role == Role::User && !m.summary).take(3).map(|m| m.text.as_str()).collect::<Vec<_>>().join("\n");
match job.computers.iter().find(|t| t.name == named) { 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["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<String, String>, id: &str, text: &str) -> String {
match moved.get(id) {
Some(n) => format!("{n}{text}"),
None => text.to_string(),
}
}
/// Does the call name a computer? /// Does the call name a computer?
fn names_a_computer(args: &str) -> bool { 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) 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<String> {
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). /// 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 /// 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. /// 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<tools::Target, String> {
let has_screen = |t: &tools::Target| matches!(t.place, tools::Place::Box { .. } | tools::Place::Local { .. } | tools::Place::Device { .. }); 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()); let named = v.get("computer").and_then(|c| c.as_str()).map(str::trim).filter(|c| !c.is_empty());
if let Some(n) = named { 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::<Vec<_>>().join("、")))?;
if !has_screen(t) { if !has_screen(t) {
return Err(if n == crate::model::BOT_COMPUTER_NAME { return Err(if n == job.roles.own {
if cfg!(target_os = "macos") { "Bot 的電腦現在沒有螢幕:這台 Mac 沒有 Docker(安裝 OrbStack 或 Docker Desktop 就會有)。可以改用「你的電腦」。".into() } else { "Bot 的電腦現在沒有螢幕:這台電腦沒有 Docker(安裝 Docker Engine 就會有)。可以改用「你的電腦」。".into() } format!("{n}現在沒有螢幕:AnyBot 所在的電腦沒有 Docker({})。要用別台請使用者指名。", if cfg!(target_os = "macos") { "安裝 OrbStack 或 Docker Desktop 就會有" } else { "安裝 Docker Engine 就會有" })
} else { } else {
format!("「{n}」不能遠端操作螢幕,只能用檔案和指令工具。") format!("「{n}」不能遠端操作螢幕,只能用檔案和指令工具。")
}); });
@ -383,8 +409,14 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
return; return;
} }
let mut out = out; 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<String, String> = std::collections::HashMap::new();
for c in out.tool_calls.iter_mut() { 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 { let msg = Message {
role: Role::Assistant, 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 })); 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, &noted(&moved, &call.id, &output));
send(StreamEvent::ToolResult(result.clone())); send(StreamEvent::ToolResult(result.clone()));
history.push(result); history.push(result);
continue; 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)); send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running));
let (output, ok, img) = crate::teach::view_step(&job.demos, &call.args); 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 })); 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, &noted(&moved, &call.id, &output));
result.images.extend(img); result.images.extend(img);
send(StreamEvent::ToolResult(result.clone())); send(StreamEvent::ToolResult(result.clone()));
history.push(result); 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)) { 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)); send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running));
// 裝置連結: the AnyBot browser of another computer (its own logins). // 裝置連結: 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) { 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)), 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), 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::Box => crate::botbrowser::in_box(|| web::run_full(&call.name, &call.args, &cancel)),
BrowserAt::Here => 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 })); 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, &noted(&moved, &call.id, &output));
result.images.extend(img); result.images.extend(img);
send(StreamEvent::ToolResult(result.clone())); send(StreamEvent::ToolResult(result.clone()));
history.push(result); 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. // 生活卡片: drawn by the window from the call itself.
if call.name == "show_card" { if call.name == "show_card" {
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Done)); 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, &noted(&moved, &call.id, "卡片已顯示給使用者。"));
send(StreamEvent::ToolResult(result.clone())); send(StreamEvent::ToolResult(result.clone()));
history.push(result); history.push(result);
continue; 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(); let emoji: String = tools::args_of(&call.args)["emoji"].as_str().unwrap_or("👍").chars().take(4).collect();
send(StreamEvent::React(emoji.clone())); send(StreamEvent::React(emoji.clone()));
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Done)); 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, &noted(&moved, &call.id, &format!("已在使用者的訊息上按了 {emoji}。")));
send(StreamEvent::ToolResult(result.clone())); send(StreamEvent::ToolResult(result.clone()));
history.push(result); history.push(result);
continue; 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::Sealed { root }, .. }) => Ok(tools::resolve(&root, &path)),
Ok(tools::Target { place: tools::Place::Local { workdir }, .. }) => Ok(tools::resolve(&workdir, &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), Err(e) => Err(e),
}; };
// Secrets (SSH keys, the app's own data folder, .env…) never ride into the chat, // 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), Err(e) => (e, false),
}; };
send(StreamEvent::ToolStatus(call.id.clone(), if ok { ToolStatus::Done } else { ToolStatus::Failed })); 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, &noted(&moved, &call.id, &output));
send(StreamEvent::ToolResult(result.clone())); send(StreamEvent::ToolResult(result.clone()));
history.push(result); history.push(result);
continue; continue;
@ -542,12 +577,34 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
}, },
Err(_) => None, 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, &noted(&moved, &call.id, &output));
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
}
let local = match (&on_box, target_for(&job, &call.args)) { let local = match (&on_box, target_for(&job, &call.args)) {
(Some(p), _) => crate::cloudbox::copy_out(p), (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::Sealed { root }, .. })) => Ok(tools::resolve(&root, &path)),
(None, Ok(tools::Target { place: tools::Place::Local { workdir }, .. })) => Ok(tools::resolve(&workdir, &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)), (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. // 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) }); 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), Err(e) => (e, false),
}; };
send(StreamEvent::ToolStatus(call.id.clone(), if ok { ToolStatus::Done } else { ToolStatus::Failed })); 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, &noted(&moved, &call.id, &output));
send(StreamEvent::ToolResult(result.clone())); send(StreamEvent::ToolResult(result.clone()));
history.push(result); history.push(result);
continue; 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)); 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, &noted(&moved, &call.id, &output));
send(StreamEvent::ToolResult(result.clone())); send(StreamEvent::ToolResult(result.clone()));
history.push(result); history.push(result);
continue; 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::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::Sealed { root }, .. }) => Ok(tools::resolve(&root, &path)),
Ok(tools::Target { place: tools::Place::Local { workdir }, .. }) => Ok(tools::resolve(&workdir, &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), Err(e) => Err(e),
}; };
match local.and_then(|p| crate::remote::publish_site(&name, &p)) { 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)); 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, &noted(&moved, &call.id, &output));
send(StreamEvent::ToolResult(result.clone())); send(StreamEvent::ToolResult(result.clone()));
history.push(result); history.push(result);
continue; 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 { if call.name == crate::vault::LIST_TOOL {
let output = crate::vault::list_for_bot(&call.args); let output = crate::vault::list_for_bot(&call.args);
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Done)); 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, &noted(&moved, &call.id, &output));
send(StreamEvent::ToolResult(result.clone())); send(StreamEvent::ToolResult(result.clone()));
history.push(result); history.push(result);
continue; 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)); 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, &noted(&moved, &call.id, &output));
send(StreamEvent::ToolResult(result.clone())); send(StreamEvent::ToolResult(result.clone()));
history.push(result); history.push(result);
continue; continue;
@ -731,29 +788,30 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
("使用者拒絕了這個操作。請不要再試同樣的操作,改問使用者想怎麼做。".to_string(), ToolStatus::Denied, None) ("使用者拒絕了這個操作。請不要再試同樣的操作,改問使用者想怎麼做。".to_string(), ToolStatus::Denied, None)
}; };
send(StreamEvent::ToolStatus(call.id.clone(), status)); 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, &noted(&moved, &call.id, &output));
result.images.extend(img); result.images.extend(img);
send(StreamEvent::ToolResult(result.clone())); send(StreamEvent::ToolResult(result.clone()));
history.push(result); history.push(result);
continue; continue;
} }
// The browser tools use the person's own browser, so always this Mac. // The person's own apps and everyday browser are on the computer they are using:
// …and so does screen control: it's the person's own screen. // the one they name, else the one they typed on (else this one, when it is theirs).
let person_device = job.computers.iter().find(|t| t.name == crate::model::THIS_COMPUTER_NAME && matches!(t.place, tools::Place::Device { .. })).cloned(); let person_target = || -> Result<tools::Target, String> {
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::<Vec<_>>().join("、")))
};
let target = if is_screen { let target = if is_screen {
screen_target(&job, &call.args) screen_target(&job, &call.args)
} else if is_web && device_named(&job, &call.args).is_some() { } else if is_mac || is_web {
// The browser on another computer of the account, as named. // (Other browser steps went their own way above; this is the person's own Chrome.)
target_for(&job, &call.args) person_target()
} 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 { } else {
target_for(&job, &call.args) 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. // An admin lock (policy) refuses outright instead of asking.
if gate.is_ok() && matches!(t.place, tools::Place::Local { .. }) && job.local_locked { 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 { 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()) { match ask_person(ApprovalKind::EnableThisComputer, reason, "local:on".into()) {
ApprovalAnswer::Once => said_yes = true, ApprovalAnswer::Once => said_yes = true,
ApprovalAnswer::Always(_) => { ApprovalAnswer::Always(_) => {
local_on = true; local_on = true;
said_yes = 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)) => { (Ok(()), Ok(t)) => {
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running)); send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running));
let ids = tools::RunIds { conversation: bot_id.to_string(), request: call.id.clone() }; 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) 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) { match crate::remote::run_on_device(host, &call.name, &call.args, &job.via_name) {
Ok(v) => { Ok(v) => {
image = v["image"].as_str().map(String::from); 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, (Err(x), _) => x,
}; };
send(StreamEvent::ToolStatus(call.id.clone(), status)); 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, &noted(&moved, &call.id, &output));
result.images.extend(image); result.images.extend(image);
send(StreamEvent::ToolResult(result.clone())); send(StreamEvent::ToolResult(result.clone()));
history.push(result); history.push(result);
@ -988,6 +1046,28 @@ mod tests {
use crate::model::ToolCall; use crate::model::ToolCall;
use crate::providers::ProviderKind; 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] #[test]
fn computer_words_need_word_boundaries() { fn computer_words_need_word_boundaries() {
// "mac" as its own word names this computer; inside longer words (macOS, a device // "mac" as its own word names this computer; inside longer words (macOS, a device
@ -1000,9 +1080,51 @@ mod tests {
assert!(finds("我的電腦好慢", "我的電腦")); assert!(finds("我的電腦好慢", "我的電腦"));
assert!(!finds("你的虛擬電腦很吵", "bot 的電腦")); assert!(!finds("你的虛擬電腦很吵", "bot 的電腦"));
assert!(finds("在 bot 的電腦跑", "bot 的電腦")); assert!(finds("在 bot 的電腦跑", "bot 的電腦"));
let t = tools::Target { name: crate::model::THIS_COMPUTER_NAME.into(), place: tools::Place::Local { workdir: Default::default() } }; let roles = tools::Roles { own: "Master 的電腦".into(), person: Some("王小明的Mac mini".into()), server: true };
assert!(mentions("幫我在 Mac 截圖", &t)); let mac = tools::Target { name: "王小明的Mac mini".into(), place: tools::Place::Device { host: "m".into() } };
assert!(!mentions("在 macOS 的登入機制下,幫 Bot 的電腦截圖", &t)); 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] #[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() }, 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 } }], 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(), 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, local_execution: true,
grants: vec![], grants: vec![],
context_tokens: DEFAULT_CONTEXT_TOKENS, 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() }, 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![], computers: vec![],
default_computer: crate::model::BOT_COMPUTER_NAME.into(), 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, local_execution: false,
grants: vec![], grants: vec![],
context_tokens: DEFAULT_CONTEXT_TOKENS, 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() }, 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 } }], 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(), 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, local_execution: false,
tools_enabled: false, tools_enabled: false,
direct: Arc::new(AtomicBool::new(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() }, 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() } }], 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(), 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, local_execution: false,
tools_enabled: false, tools_enabled: false,
direct: Arc::new(AtomicBool::new(true)), direct: Arc::new(AtomicBool::new(true)),

View File

@ -180,6 +180,9 @@ pub struct App {
rm: remote_ui::RemoteUi, rm: remote_ui::RemoteUi,
/// Running as `anybot --serve` (no window; the phone is the screen). /// Running as `anybot --serve` (no window; the phone is the screen).
headless: bool, 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<String>,
/// 右側面板 and its tab (None = closed). /// 右側面板 and its tab (None = closed).
panel: Option<panel_ui::PanelTab>, panel: Option<panel_ui::PanelTab>,
/// When each emoji reaction appeared (for its pop) and when each message first showed (fade-in). /// 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, msg_index: 0,
rm: Default::default(), rm: Default::default(),
headless: false, headless: false,
typed_on: None,
panel: None, panel: None,
popped: HashMap::new(), popped: HashMap::new(),
appeared: HashMap::new(), appeared: HashMap::new(),
@ -638,6 +642,8 @@ impl App {
return; return;
} }
self.drafts.insert(bot_id, String::new()); 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`: // 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. // 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) { 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 // 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. // allowed yet), using one pops up a request instead of failing quietly.
let computers = self.targets_for(&b); let computers = self.targets_for(&b);
let roles = self.roles_for(&b, &computers);
let names: Vec<String> = computers.iter().map(|t| t.name.clone()).collect(); let names: Vec<String> = 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(); let mut tool_specs = appctl::specs();
tool_specs.extend(tools::specs_for(&names, &default_computer)); tool_specs.extend(tools::specs_for(&names, &default_computer));
let devices: Vec<String> = computers.iter().filter(|t| matches!(t.place, tools::Place::Device { .. })).map(|t| t.name.clone()).collect(); // Browsers: the bot's own computer's Chromium, this computer's AnyBot browser, and the
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()); // account's other computers' ones (each with its own logins).
tool_specs.extend(web::specs_for(&here, &devices)); let browsers: Vec<String> = computers
// Screen tools: the bot's own desktop (when it has one) and this Mac. .iter()
let mut screens = vec![]; .filter(|t| match t.place {
if crate::cloudbox::available() { tools::Place::Box { .. } => crate::cloudbox::available(),
screens.push(crate::model::BOT_COMPUTER_NAME.to_string()); tools::Place::Local { .. } => crate::botbrowser::available(),
} tools::Place::Device { .. } => true,
if crate::computer::screen_supported() { _ => false,
screens.extend(computers.iter().filter(|t| matches!(t.place, tools::Place::Local { .. })).map(|t| t.name.clone())); })
} .map(|t| t.name.clone())
// …and the account's other computers (手腳): their own AnyBot looks and clicks there. .collect();
screens.extend(devices.iter().cloned()); 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() };
if cfg!(target_os = "macos") { 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<String> = 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::macapps::specs().into_iter().filter(|t| self.state.messages_send || t["name"] != crate::macapps::SEND_MESSAGE));
} }
tool_specs.extend(crate::mcp::specs()); tool_specs.extend(crate::mcp::specs());
@ -741,7 +765,7 @@ impl App {
api_key: providers::resolve_key(b.provider, &ps.api_key), api_key: providers::resolve_key(b.provider, &ps.api_key),
model: b.model.clone(), model: b.model.clone(),
system: { 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() { if speaker.is_some() {
let names: Vec<String> = conv.members.iter().filter_map(|m| self.state.bot(*m)).map(|x| x.name.clone()).collect(); let names: Vec<String> = 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("、"))); sys.push_str(&format!("\n\n你在群組聊天「{}」裡,成員依序回答:{}。其他成員的發言以【名字】標示。只回答你擅長的部分,補充不同的觀點,不要重複別人說過的;沒有要補充就簡短說一句。", conv.name, names.join("、")));
@ -767,6 +791,7 @@ impl App {
req, req,
computers, computers,
default_computer, default_computer,
roles,
local_execution: self.local_on(), local_execution: self.local_on(),
local_locked: !self.managed.local.allows() || self.state.local_never, local_locked: !self.managed.local.allows() || self.state.local_never,
tools_enabled: b.tools_enabled, tools_enabled: b.tools_enabled,
@ -790,73 +815,202 @@ impl App {
self.mark(); self.mark();
} }
/// The computers a bot can be told to use (its own, this Mac, then SSH ones). /// The computers a bot can be told to use, each under its own name: the bot's own
fn targets_for(&self, b: &Bot) -> Vec<tools::Target> { /// (「Master 的電腦」), this computer, the SSH ones, then the account's other computers.
let mut v = vec![ pub(super) fn targets_for(&self, b: &Bot) -> Vec<tools::Target> {
tools::Target { let mut v = vec![tools::Target {
name: crate::model::BOT_COMPUTER_NAME.into(), 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) } }, 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 { // Two computers never share a name (the bot and the person name them to choose).
name: crate::model::THIS_COMPUTER_NAME.into(), let add = |v: &mut Vec<tools::Target>, mut t: tools::Target| {
place: tools::Place::Local { workdir: tools::expand_home(if b.workdir.trim().is_empty() { "~" } else { b.workdir.trim() }) }, let base = t.name.clone();
}, let mut n = 2;
]; while v.iter().any(|x| x.name == t.name) {
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() } })); t.name = format!("{base} ({n})");
// 裝置連結: other AnyBots of this account that allow it. n += 1;
v.extend(self.linked_devices().into_iter().filter(|d| !v.iter().any(|t| t.name == d.name)).collect::<Vec<_>>());
// A brain on a server whose hands are the person's computer: "你的電腦" is that computer
// (what the person means by "my Mac"), and the server itself goes by its own name.
if !cfg!(target_os = "macos") {
if let Some(hand) = crate::remote::my_hands(&self.state.remote.host_id).into_iter().next() {
if let Some(i) = v.iter().position(|t| matches!(&t.place, tools::Place::Device { host } if *host == hand)) {
for t in v.iter_mut() {
if matches!(t.place, tools::Place::Local { .. }) {
t.name = crate::model::BRAIN_HOST_NAME.into();
}
}
v[i].name = crate::model::THIS_COMPUTER_NAME.into();
}
} else if self.headless {
// A server brain with no hands connected: the machine it runs on is a server,
// not anyone's computer. Wearing the name 你的電腦 here made bots "work on your
// computer" on the server while the person watched their own untouched desktop.
for t in v.iter_mut() {
if matches!(t.place, tools::Place::Local { .. }) {
t.name = crate::model::BRAIN_HOST_NAME.into();
}
}
} }
v.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 v
} }
/// 查看電腦: the computers this bot really has, in plain words. /// Which of these computers is the bot's own, which one the person is using, and whether
fn computers_text(&self, b: &Bot) -> String { /// this AnyBot is a server. The person's computer is the one they last typed on; a server
self.targets_for(b) /// that hasn't heard from any yet takes the account's only other computer, if there is one.
.iter() pub(super) fn roles_for(&self, b: &Bot, targets: &[tools::Target]) -> tools::Roles {
.map(|t| match &t.place { let server = self.headless;
tools::Place::Sealed { .. } | tools::Place::Box { .. } => format!("{}:這個 Bot 自己的 Linux 桌面(AI 的電腦,不是使用者的電腦),一律可用", t.name), let local = targets.iter().find(|t| matches!(t.place, tools::Place::Local { .. })).map(|t| t.name.clone());
tools::Place::Local { .. } if t.name == crate::model::BRAIN_HOST_NAME => format!("{}:AnyBot 大腦所在的伺服器「{}」(不是使用者的電腦)", t.name, self.state.remote.host_name), let device = |h: &str| targets.iter().find(|t| matches!(&t.place, tools::Place::Device { host } if host == h)).map(|t| t.name.clone());
tools::Place::Local { .. } => format!( let person = match self.typed_on.as_deref() {
"{}:{}「{}」,{}", Some(h) if !h.is_empty() => device(h).or_else(|| if server { None } else { local.clone() }),
t.name, _ if !server => local,
if cfg!(target_os = "macos") { "使用者的這台 Mac" } else { "使用者的這台電腦" }, // Not heard yet (or only the phone): when the account has one other computer, that
self.state.machine_label, // is it; with more, nobody guesses (the bot asks which one).
if self.state.local_execution { "已允許" } else { "還沒預先允許(直接用工具,App 會問使用者)" } _ => {
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<String>) {
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), None => (vec![(own.clone(), "own".into())], own),
tools::Place::Device { host } => { };
let real = crate::remote::peer_name(host); }
if t.name == crate::model::THIS_COMPUTER_NAME { let t = self.targets_for(b);
format!("{}:使用者的電腦「{real}」(大腦透過它的 AnyBot 看畫面、點擊、開瀏覽器和跑指令)", t.name) let r = self.roles_for(b, &t);
} else { let (current, _) = Self::chosen_computer(b, &t, &r);
format!("{}:帳號裡的另一台電腦(裝置連結)", t.name) (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<String> {
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::<Vec<_>>() .collect()
.join("\n") }
/// 查看電腦: 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::<Vec<_>>().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. /// 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 out = if call.name == "list_computers" {
let text = self.state.bot(bot_id).cloned().map(|b| self.computers_text(&b)).unwrap_or_default(); 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 } appctl::Outcome { text, ok: true, wake: vec![], theme_changed: false, page: None }
} else if call.name == "update_bot" && serde_json::from_str::<serde_json::Value>(&call.args).map(|v| v["default_computer"].is_string()).unwrap_or(false) {
self.update_bot_computer(bot_id, &call.args)
} else { } else {
appctl::apply(&mut self.state, bot_id, &call.name, &call.args) appctl::apply(&mut self.state, bot_id, &call.name, &call.args)
}; };
@ -1737,11 +1893,17 @@ impl App {
ui.close(); ui.close();
} }
if menu_ui::item(ui, p, Icon::Eraser, "清除聊天記錄").clicked() { if menu_ui::item(ui, p, Icon::Eraser, "清除聊天記錄").clicked() {
self.stop(id); if self.mir.on {
if let Some(b) = self.state.bot_mut(id) { // The chat lives on the brain: clearing only this copy left the brain sure this
b.messages.clear(); // 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(); ui.close();
} }
if menu_ui::danger(ui, p, Icon::Trash, "刪除").clicked() { 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)); 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 right_w = 28.0 * 2.0 + 8.0 + 4.0;
let text_w = (ui.available_width() - right_w).max(60.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) }; 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::<String>()) } 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}", &current));
let mut pick: Option<String> = 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) { fn model_picker(&mut self, ui: &mut egui::Ui, bot: &Bot) {
let p = self.pal; let p = self.pal;
let label = format!("{} · {}", short_provider(bot.provider), bot.model); let label = format!("{} · {}", short_provider(bot.provider), bot.model);
@ -2583,15 +2781,21 @@ impl App {
ui.checkbox(&mut b.tools_enabled, t("讓這個 Bot 查看、修改檔案和執行指令")); ui.checkbox(&mut b.tools_enabled, t("讓這個 Bot 查看、修改檔案和執行指令"));
if b.tools_enabled { if b.tools_enabled {
ui.add_space(6.0); ui.add_space(6.0);
form_row(ui, "預設電腦", p, |ui| { form_row(ui, "做事的電腦", p, |ui| {
let names = self.state.computer_names(); let (choices, current) = self.computer_choices(&b);
egui::ComboBox::from_id_salt("default_computer").selected_text(b.default_computer.clone()).width(150.0).show_ui(ui, |ui| { let mut pick = None;
for n in names { egui::ComboBox::from_id_salt("default_computer").selected_text(current.clone()).width(190.0).show_ui(ui, |ui| {
let label = if n == crate::model::THIS_COMPUTER_NAME && !self.state.local_execution { format!("{n}(要先在設定裡允許)") } else { n.clone() }; for (n, kind) in choices {
ui.selectable_value(&mut b.default_computer, n, label); 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| { form_row(ui, "起始資料夾", p, |ui| {
ui.add(egui::TextEdit::singleline(&mut b.workdir).hint_text(t("在你的電腦上,例如 ~/Documents/專案")).desired_width(ui.available_width())); 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. /// 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); let mut s = format!("你是「{}」。", b.name);
if !b.description.trim().is_empty() { if !b.description.trim().is_empty() {
s.push_str(&format!("你的角色:{}。", b.description.trim())); 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。"); s.push_str("你是使用者的主要 Bot:主要聯絡窗口,可以主動幫忙,也能用 message_bot 把工作交給其他 Bot。");
} }
let now = crate::routines::now_in(&st.timezone); let now = crate::routines::now_in(&st.timezone);
// Which screens can really be operated, named as the model must name them: the fixed // Which screens can really be operated, each by its computer's name.
// wording promised "你的電腦(使用者的 Mac)" on server brains that have no such computer,
// and bots then "worked on your computer" on the server itself.
let mut screens: Vec<String> = Vec::new(); let mut screens: Vec<String> = Vec::new();
for t in computers { for t in computers {
match &t.place { match &t.place {
tools::Place::Box { .. } => screens.push(format!("{}(AI 自己的 Linux 桌面,不是使用者的電腦)", t.name)), tools::Place::Box { .. } if crate::cloudbox::available() => screens.push(format!("「{}」", t.name)),
tools::Place::Local { .. } => { tools::Place::Local { .. } if crate::computer::screen_supported() => screens.push(format!("「{}」", t.name)),
if crate::computer::screen_supported() { tools::Place::Device { .. } => screens.push(format!("「{}」", t.name)),
screens.push(format!("{}(這台電腦自己的畫面)", t.name)); _ => {}
}
}
tools::Place::Device { host } if t.name == crate::model::THIS_COMPUTER_NAME => screens.push(format!("{}(使用者自己的電腦「{}」的畫面)", t.name, crate::remote::peer_name(host))),
tools::Place::Device { .. } => screens.push(format!("{}(帳號裡另一台電腦的畫面)", t.name)),
tools::Place::Ssh { .. } | tools::Place::Sealed { .. } => {}
} }
} }
let screens = if screens.is_empty() { "目前沒有任何可操作的螢幕".to_string() } else { screens.join(";") }; let screens = if screens.is_empty() { "目前沒有任何可操作的螢幕".to_string() } else { format!("可操作的螢幕:{}", screens.join("、")) };
s.push_str(&format!( s.push_str(&format!(
"\n\n現在是 {}({},時區 {})。\n\ "\n\n現在是 {}({},時區 {})。\n\
你在 AnyBot 這個 App 裡,可以直接操作 App:\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 瀏覽器視窗登入一次(之後會記住),登入好再讀。絕對不要叫使用者去改瀏覽器的設定或選單(例如「允許來自 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\ - 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\ - 你也能像人一樣操作螢幕({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 會自己跳出視窗問使用者;工具回報要開什麼設定時,照實轉告。", - 不要說「我沒有瀏覽器或工具」:先試這些工具。需要權限時 App 會自己跳出視窗問使用者;工具回報要開什麼設定時,照實轉告。",
now.format("%Y-%m-%d %H:%M"), now.format("%Y-%m-%d %H:%M"),
["週一", "週二", "週三", "週四", "週五", "週六", "週日"][now.weekday().num_days_from_monday() as usize], ["週一", "週二", "週三", "週四", "週五", "週六", "週日"][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() { if !b.routines.is_empty() {
s.push_str("\n\n你目前的例行任務:\n"); 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")); s.push_str(&mine.join("\n"));
} }
if !computers.is_empty() { if !computers.is_empty() {
let list: Vec<String> = computers let list: Vec<String> = lines.iter().map(|l| format!("- {l}")).collect();
.iter() let mine = match &roles.person {
.map(|t| match &t.place { Some(p) => format!("使用者說「我的電腦」「這台」「我的 Mac」時,指的是「{p}」(他正在用的那台)。"),
tools::Place::Sealed { root } => format!("- {}:你自己的電腦(獨立資料夾 {},看不到使用者的檔案;沒有螢幕)", t.name, root.display()), None => "現在不知道使用者在用哪台電腦:他說「我的電腦」「這台」時,先問他是哪一台。".to_string(),
tools::Place::Box { dir } => { };
let secrets = crate::cloudbox::secret_names(); let gone = match missing {
format!( Some(m) => format!("\n- 使用者選的「{m}」現在沒有連線(那台的 AnyBot 沒開或沒登入)。要在那台做事時,告訴使用者先打開它;要改用別台,先問使用者。"),
"- {}:你自己的 Linux 電腦(Debian,有桌面和 Chromium,所有 Bot 共用;你的資料夾是 {dir},可以 sudo 安裝套件)。需要時會自動喚醒。可以用 computer_use_* 工具(computer=「{}」)看它的螢幕和操作滑鼠鍵盤,例如開 chromium 上網、測網頁。{}", None => String::new(),
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();
s.push_str(&format!( s.push_str(&format!(
"\n\n你可以用工具在電腦上做事。每個工具都有 computer 參數,預設是「{default_computer}」。\n{}\n\ "\n\n你可以用工具在電腦上做事。每台電腦都用它自己的名字稱呼,工具的 computer 參數就填那個名字:\n{}\n\
- 使用者替你選的電腦是「{default_computer}」:檔案、指令、看畫面、點擊和瀏覽器都在這台做,不要自己換到別台。只有使用者在訊息裡指名另一台(「在我的 Mac 上」「在 Bot 的電腦上」),才把 computer 設成那台。\n\ - 使用者替你選的電腦是「{default_computer}」:檔案、指令、看畫面、點擊和瀏覽器都在這台做,不要自己換到別台。只有使用者在訊息裡指名另一台,才把 computer 設成那台。{gone}\n\
- 不確定使用者指的是哪台電腦時(例如他說「我的電腦」而那台不在清單上、有幾台都說得通、或他的說法和你手上的預設不一致),先用一句話問他要哪台,問清楚再動手;不要擅作主張選一台就開始做,也不要把一台說成另一台。\n\ - 「{own}」是你自己的電腦;使用者說「你的電腦」「Bot 的電腦」指的就是它。{mine}\n\
- 回覆使用者時,說到電腦一律用上面的名字(例如「我在「{own}」打開了 Threads」),不要說「你的電腦」「Bot 的電腦」這種會混淆的說法。\n\
- 不確定使用者指的是哪台電腦時(他說的那台不在清單上、有幾台都說得通、或他的說法和你手上的預設不一致),先用一句話問他要哪台,問清楚再動手;不要擅作主張選一台就開始做,也不要把一台說成另一台。\n\
- 先用 list_dir、search、read_file 看清楚,再動手。\n\ - 先用 list_dir、search、read_file 看清楚,再動手。\n\
- 需要權限時(電腦工具還沒打開、要用使用者的電腦、會改變東西的操作),直接呼叫工具,App 會自己跳出視窗請使用者允許;不要叫使用者自己去改設定。被拒絕就換個方式或問使用者。\n\ - 需要權限時(電腦工具還沒打開、要用使用者的電腦、會改變東西的操作),直接呼叫工具,App 會自己跳出視窗請使用者允許;不要叫使用者自己去改設定。被拒絕就換個方式或問使用者。\n\
- 跑很久的指令會移到背景,用 read_terminal 追蹤,不要重複執行;它在等輸入時(例如問 y/n),用 write_terminal 回答。\n\ - 跑很久的指令會移到背景,用 read_terminal 追蹤,不要重複執行;它在等輸入時(例如問 y/n),用 write_terminal 回答。\n\
- 只有工具結果證明做完了,才說做完;失敗就照實說。\n\ - 只有工具結果證明做完了,才說做完;失敗就照實說。\n\
- 連續執行多步驟工具時不要每步都發訊息(先說「好,我先…」,做完一個工具播一段,最後再總結——使用者打一句就看到三四則回覆)。執行時就安靜地連續呼叫工具,全部做完後用一段話講結果;只有卡住、需要使用者動手(例如要他輸入密碼)、或要先問清哪台電腦時,才中途說話。\n\ - 連續執行多步驟工具時不要每步都發訊息(先說「好,我先…」,做完一個工具播一段,最後再總結——使用者打一句就看到三四則回覆)。執行時就安靜地連續呼叫工具,全部做完後用一段話講結果;只有卡住、需要使用者動手(例如要他輸入密碼)、或要先問清哪台電腦時,才中途說話。\n\
- 回覆使用者時,用簡單的話說明你做了什麼、在哪台電腦、結果如何。", - 回覆使用者時,用簡單的話說明你做了什麼、在哪台電腦、結果如何。",
list.join("\n") list.join("\n"),
own = roles.own,
)); ));
} }
let chans: Vec<String> = st.channels.iter().filter(|c| c.bot_id == b.id && c.enabled).map(|c| format!("{}「{}」", c.kind.label(), c.name)).collect(); let chans: Vec<String> = st.channels.iter().filter(|c| c.bot_id == b.id && c.enabled).map(|c| format!("{}「{}」", c.kind.label(), c.name)).collect();

View File

@ -459,10 +459,19 @@ impl App {
let waiting = self.approvals.contains_key(&bot.id); let waiting = self.approvals.contains_key(&bot.id);
let last = bot.messages.iter().rev().find(|m| m.role == Role::Assistant); 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 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 「<name> 的電腦」).
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 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 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.add_space(4.0);
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.add_space(6.0); ui.add_space(6.0);

View File

@ -9,6 +9,8 @@ use crate::cloudbox::{self, Rebuild, Run};
/// How often the viewer asks the box for a new picture (estimated). /// How often the viewer asks the box for a new picture (estimated).
const VIEWER_FRAME_MS: u64 = 450; 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. /// The floating bar stays this long after the bot's last mouse/keyboard action on this Mac.
const LOCAL_BAR_SECS: u64 = 8; const LOCAL_BAR_SECS: u64 = 8;
@ -61,6 +63,8 @@ pub struct ComputerUi {
thumb_img: Arc<Mutex<Option<egui::ColorImage>>>, thumb_img: Arc<Mutex<Option<egui::ColorImage>>>,
thumb_busy: Arc<AtomicBool>, thumb_busy: Arc<AtomicBool>,
thumb_at: Option<Instant>, thumb_at: Option<Instant>,
/// The viewer shows the brain's computer (this window is a screen of a brain): how to reach it.
remote: Option<crate::remote::Settings>,
} }
enum Input { enum Input {
@ -356,7 +360,13 @@ impl App {
sec_title(ui, "Bot 的電腦", p, |_| {}); sec_title(ui, "Bot 的電腦", p, |_| {});
sec_card(ui, p, |ui| { sec_card(ui, p, |ui| {
let name = self.state.remote.brain_name.clone(); 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 { } else {
sec_title(ui, "Bot 的電腦", p, |ui| status_pill(ui, &run, p)); 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)), 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) { 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()); self.cu.thumb_at = Some(Instant::now());
let (slot, busy, c) = (self.cu.thumb_img.clone(), self.cu.thumb_busy.clone(), ctx.clone()); let (slot, busy, c) = (self.cu.thumb_img.clone(), self.cu.thumb_busy.clone(), ctx.clone());
std::thread::spawn(move || { 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]; let size = [img.width() as usize, img.height() as usize];
*slot.lock().unwrap() = Some(egui::ColorImage::from_rgba_unmultiplied(size, img.as_raw())); *slot.lock().unwrap() = Some(egui::ColorImage::from_rgba_unmultiplied(size, img.as_raw()));
c.request_repaint(); c.request_repaint();
@ -939,8 +956,28 @@ impl App {
pub(super) fn open_viewer(&mut self) { pub(super) fn open_viewer(&mut self) {
self.cu.viewer_open = true; self.cu.viewer_open = true;
self.cu.fit = true; self.cu.fit = true;
cloudbox::set_viewer_open(true);
self.show_settings = false; 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 { if !cloudbox::run_state().is_running() && !self.demo {
self.box_task(|| cloudbox::ensure_running().map(|_| String::new())); self.box_task(|| cloudbox::ensure_running().map(|_| String::new()));
} }
@ -949,8 +986,15 @@ impl App {
fn close_viewer(&mut self) { fn close_viewer(&mut self) {
self.cu.viewer_open = false; self.cu.viewer_open = false;
self.cu.control = false; self.cu.control = false;
cloudbox::HELD_BY_PERSON.store(false, Ordering::SeqCst); if let Some(s) = self.cu.remote.clone() {
cloudbox::set_viewer_open(false); 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() { if let Some(stop) = self.cu.worker.take() {
stop.store(true, Ordering::SeqCst); stop.store(true, Ordering::SeqCst);
} }
@ -966,6 +1010,54 @@ impl App {
let slot = self.cu.frame.clone(); let slot = self.cu.frame.clone();
let c = ctx.clone(); let c = ctx.clone();
let s2 = stop.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::<Input>();
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 || { std::thread::spawn(move || {
let mut seq = 0u64; let mut seq = 0u64;
while !s2.load(Ordering::SeqCst) { 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 screen = ctx.content_rect();
let open = true; 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 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; let mut close_clicked = false;
egui::Window::new("Bot 的電腦") egui::Window::new("box-viewer")
.id(egui::Id::unique("box-viewer")) .id(egui::Id::unique("box-viewer"))
.title_bar(false) .title_bar(false)
.default_size(default) .default_size(default)
@ -1039,7 +1143,7 @@ impl App {
.show(ctx, |ui| { .show(ctx, |ui| {
ui.horizontal(|ui| { ui.horizontal(|ui| {
monitor_icon(ui, 20.0, p.text); 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); status_pill(ui, &run, p);
if busy && !self.cu.control { if busy && !self.cu.control {
crate::icons::label(ui, crate::icons::Icon::Bot, &t("Bot 正在操作"), FONT_XS, p.accent); crate::icons::label(ui, crate::icons::Icon::Bot, &t("Bot 正在操作"), FONT_XS, p.accent);
@ -1060,7 +1164,7 @@ impl App {
close_clicked = true; close_clicked = true;
} }
ui.add_space(4.0); 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; let control = self.cu.control;
self.box_task(move || { self.box_task(move || {
let url = cloudbox::viewer_url(control)?; 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)); 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() { 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; 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) { 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)); 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() { 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())),
}
} }
} }
} }

View File

@ -393,6 +393,27 @@ pub struct Mirror {
fetching: std::collections::HashSet<String>, fetching: std::collections::HashSet<String>,
/// 加一台電腦: the line to run on the new computer (or why there is none), and when it was made. /// 加一台電腦: the line to run on the new computer (or why there is none), and when it was made.
pub(super) pair: Arc<Mutex<(String, bool, Option<Instant>)>>, pub(super) pair: Arc<Mutex<(String, bool, Option<Instant>)>>,
/// 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<u64, Value>,
}
/// 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. /// 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}) json!({"bot": id, "call_id": a.call_id, "tool": a.tool, "reason": a.reason, "grant": a.grant, "kind": kind})
}) })
.collect(); .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<Value> = 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::<serde_json::Map<_, _>>()
.into();
out out
} }
@ -932,6 +970,14 @@ impl App {
let mut msgs: Vec<Message> = serde_json::from_value(d["messages"].clone()).unwrap_or_default(); let mut msgs: Vec<Message> = serde_json::from_value(d["messages"].clone()).unwrap_or_default();
self.mirror_files(&mut msgs); self.mirror_files(&mut msgs);
if let Some(b) = self.state.bot_mut(id) { 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.truncate(from);
b.messages.extend(msgs); 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<u64> = v["approvals"].as_array().into_iter().flatten().filter_map(|a| a["bot"].as_u64()).collect(); let keep: Vec<u64> = v["approvals"].as_array().into_iter().flatten().filter_map(|a| a["bot"].as_u64()).collect();
self.approvals.retain(|id, _| keep.contains(id)); self.approvals.retain(|id, _| keep.contains(id));
if self.state.bot(self.state.selected).is_none() { if self.state.bot(self.state.selected).is_none() {

View File

@ -159,16 +159,29 @@ impl App {
// 電腦: the bot's own computer with a live picture of its screen (like the original). // 電腦: the bot's own computer with a live picture of its screen (like the original).
panel_title(ui, "電腦", p); panel_title(ui, "電腦", p);
let mut view = false; 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| { panel_card(ui, p, |ui| {
let run = crate::cloudbox::run_state(); let run = crate::cloudbox::run_state();
let running = run.is_running(); let running = if brain { self.mir.box_state["running"].as_bool().unwrap_or(false) } else { run.is_running() };
let state = if crate::cloudbox::available() { t(&run.label()) } else { t("沒有 Docker") }; 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()); let (rect, r) = ui.allocate_exact_size(vec2(ui.available_width(), 64.0), egui::Sense::click());
if r.hovered() { if r.hovered() {
ui.painter().rect_filled(rect, 9, p.hover); 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); 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. // The screen: 16:10, rounded, the real desktop when there is a picture.
let box_r = egui::Rect::from_min_size(rect.right_top() + vec2(-86.0, 8.0), vec2(78.0, 48.0)); let box_r = egui::Rect::from_min_size(rect.right_top() + vec2(-86.0, 8.0), vec2(78.0, 48.0));
@ -192,11 +205,11 @@ impl App {
if running { if running {
ui.painter().circle_filled(box_r.right_top() + vec2(-6.0, 6.0), 3.5, Color32::from_rgb(0x14, 0xc3, 0x70)); 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 的電腦並看它的螢幕" }; let tip = if running { t("看「{name}」的螢幕,也可以取得控制權自己操作") } else { t("叫醒「{name}」並看它的螢幕") }.replace("{name}", &own_name);
view = r.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(t(tip)).clicked(); view = r.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(tip).clicked();
}); });
if view { if view {
if crate::cloudbox::available() { if available {
self.open_viewer(); self.open_viewer();
} else { } else {
self.show_settings = true; self.show_settings = true;

View File

@ -258,10 +258,16 @@ impl App {
fn remote_cmd(&mut self, ctx: &egui::Context, c: Cmd) { fn remote_cmd(&mut self, ctx: &egui::Context, c: Cmd) {
match c { match c {
Cmd::Send { bot, text, attachments } => { Cmd::Send { bot, text, attachments, from } => {
if self.state.bot(bot).is_none() { if self.state.bot(bot).is_none() {
return; 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; // 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). // resolve first, so "../../" paths cannot escape it (the phone can name any path).
let root = crate::model::State::path().parent().map(|p| p.join("attachments")).and_then(|p| p.canonicalize().ok()); let 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 } => { Cmd::NewThread { bot } => {
self.stop(bot); self.stop(bot);
if let Some(b) = self.state.bot_mut(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) .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. // 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)) .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() } }) .map(|(id, name, _)| tools::Target { name: name.clone(), place: tools::Place::Device { host: id.clone() } })
.collect() .collect()
} }

View File

@ -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 } Outcome { text: format!("已交給「{name}」,它的回覆會出現在它自己的聊天裡。"), ok: true, wake: vec![(target, msg)], theme_changed: false, page: None }
} }
"list_computers" => { "list_computers" => {
// On a server brain (serve), Local is the server, not the person's computer — // (The window answers this with the account's computers too; this is the plain list.)
// matching what the app's own list_computers says after its rename.
let serving = SERVING.load(std::sync::atomic::Ordering::Relaxed); 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![ let mut lines = vec![
format!("{}:{}", model::BOT_COMPUTER_NAME, if serving { "這個 Bot 自己的 Linux 電腦(AI 的電腦,不是使用者的電腦,獨立資料夾),一律可用" } else { "你自己的電腦(獨立的資料夾),一律可用" }), format!("「{own}」:你自己的電腦(AI 的電腦,不是使用者的電腦,獨立資料夾),一律可用"),
if serving { if serving {
format!("{}:AnyBot 大腦所在的伺服器(不是使用者的電腦)。使用者的電腦要在它上面裝 AnyBot 並登入同一帳號,才會以「{}」出現", model::BRAIN_HOST_NAME, model::THIS_COMPUTER_NAME) format!("「{here}」:AnyBot 大腦所在的伺服器(不是使用者在用的電腦)")
} else { } 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")) ok(lines.join("\n"))
} }
"update_settings" => { "update_settings" => {

View File

@ -159,7 +159,7 @@ pub fn for_model(atts: &[Attachment]) -> (String, Vec<String>) {
let mut text = String::new(); let mut text = String::new();
let mut images = vec![]; let mut images = vec![];
for a in atts { 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 { if a.image {
match image_b64(&a.path) { match image_b64(&a.path) {
Some(b) => { Some(b) => {

View File

@ -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(); 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()) }; let Some((name, bin)) = engine() else { return Err("這台電腦沒有 Chrome、Brave、Edge 或 Chromium,沒辦法開 AnyBot 自己的瀏覽器。".into()) };
if profile == PERSON_PROFILE { if profile == PERSON_PROFILE {

View File

@ -625,7 +625,7 @@ pub fn browser_port() -> Result<u16, String> {
} }
std::thread::sleep(Duration::from_millis(300)); std::thread::sleep(Duration::from_millis(300));
} }
Err("Bot 的電腦的瀏覽器沒有及時打開,請再試一次。".into()) Err("這台電腦的瀏覽器沒有及時打開,請再試一次。".into())
} }
static START_LOCK: Mutex<()> = Mutex::new(()); static START_LOCK: Mutex<()> = Mutex::new(());
@ -1248,7 +1248,7 @@ pub fn computer_run(name: &str, v: &Value) -> Result<(String, Option<String>), S
ensure_running()?; ensure_running()?;
let _busy = Busy::new(true); let _busy = Busy::new(true);
if HELD_BY_PERSON.load(Ordering::SeqCst) && name != "computer_use_screenshot" && name != "computer_use_check_permissions" { 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 scale = || shared().lock().unwrap().scale.ok_or_else(|| "請先用 computer_use_screenshot 看一下畫面,座標要用截圖上的位置。".to_string());
let pt = |v: &Value, s: (f64, f64)| -> Result<(String, String), 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<String>), S
r r
}; };
match name { match name {
"computer_use_check_permissions" => Ok(("Bot 的電腦不需要另外的權限:可以截圖,也可以操作滑鼠和鍵盤。".into(), None)), "computer_use_check_permissions" => Ok(("這台電腦不需要另外的權限:可以截圖,也可以操作滑鼠和鍵盤。".into(), None)),
"computer_use_screenshot" => { "computer_use_screenshot" => {
let img = grab()?; let img = grab()?;
let (w, h) = img.dimensions(); let (w, h) = img.dimensions();
@ -1283,7 +1283,7 @@ pub fn computer_run(name: &str, v: &Value) -> Result<(String, Option<String>), S
let img = if resize { image::imageops::resize(&img, fw, fh, image::imageops::FilterType::Triangle) } else { img }; 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("截圖太大了,沒辦法送出")?; 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)); 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" => { "computer_use_click" => {
let (x, y) = pt(v, scale()?)?; let (x, y) = pt(v, scale()?)?;

View File

@ -188,7 +188,7 @@ pub fn describe(name: &str, args: &str) -> String {
#[allow(dead_code)] #[allow(dead_code)]
pub fn specs() -> Vec<Value> { pub fn specs() -> Vec<Value> {
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. /// 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<Value> {
json!({"name": "computer_use_check_permissions", "description": "Check whether the screen can be seen and the mouse and keyboard used on that computer.", 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": {}}}), "parameters": {"type": "object", "properties": {}}}),
]; ];
// Named whenever there is a choice, or the one screen isn't this computer or the bot's own // Always named: each computer goes by its own name (the instructions say which is which),
// (a 手腳 computer): the bot should say whose screen it means. // so the bot says whose screen it means and the person can tell where it worked.
let own = [crate::model::BOT_COMPUTER_NAME, crate::model::THIS_COMPUTER_NAME]; if !computers.is_empty() {
if computers.len() > 1 || computers.iter().any(|c| !own.contains(&c.as_str())) { 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.");
// Explain each name that is actually offered (the old text promised "the person's Mac"
// on machines where it does not exist, and the bot grabbed the next best thing).
let mut parts = Vec::new();
if computers.iter().any(|c| c == crate::model::BOT_COMPUTER_NAME) {
parts.push(format!("{} is a private Linux desktop for the bot (the AI's own computer, not the person's).", crate::model::BOT_COMPUTER_NAME));
}
if computers.iter().any(|c| c == crate::model::THIS_COMPUTER_NAME) {
parts.push(format!("{} is the person's own computer (use it for the person's own apps).", crate::model::THIS_COMPUTER_NAME));
}
if computers.iter().any(|c| c == crate::model::BRAIN_HOST_NAME) {
parts.push(format!("{} is the server the brain runs on — not a person's screen; a person asking about \"my screen\" never means it.", crate::model::BRAIN_HOST_NAME));
}
if computers.iter().any(|c| c != crate::model::BOT_COMPUTER_NAME && c != crate::model::THIS_COMPUTER_NAME && c != crate::model::BRAIN_HOST_NAME) {
parts.push("Any other name is another computer of the account.".to_string());
}
let desc = format!("Whose screen. Default: {default}. {}", parts.join(" "));
for t in v.iter_mut() { for t in v.iter_mut() {
t["parameters"]["properties"]["computer"] = json!({"type": "string", "enum": computers, "description": desc}); t["parameters"]["properties"]["computer"] = json!({"type": "string", "enum": computers, "description": desc});
} }

View File

@ -598,4 +598,18 @@ pub const ROWS: &[(&str, &str, &str)] = &[
("發起語音聊天:你講完它就回答並唸出來,再繼續聽", "Start voice chat: when you finish speaking it answers out loud, then keeps listening", "ボイスチャットを開始:話し終えると声で答え、また聞き取ります"), ("發起語音聊天:你講完它就回答並唸出來,再繼續聽", "Start voice chat: when you finish speaking it answers out loud, then keeps listening", "ボイスチャットを開始:話し終えると声で答え、また聞き取ります"),
("幫我建立一個例行任務:", "Set up a routine for me: ", "ルーティンを作成して:"), ("幫我建立一個例行任務:", "Set up a routine for me: ", "ルーティンを作成して:"),
("你好,我是你的 Bot。", "Hi, I'm your bot.", "こんにちは、あなたの Bot です。"), ("你好,我是你的 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}」で作業 · クリックで切り替え"),
]; ];

View File

@ -28,7 +28,7 @@ pub struct Template {
pub const TEMPLATES: [Template; 5] = [ pub const TEMPLATES: [Template; 5] = [
Template { name: "研究助理", description: "上網找資料、整理重點、附上來源", instructions: "先釐清問題,再用 web_fetch 或瀏覽器找至少兩個可靠來源。回答先給結論,再列重點,每個重點附來源網址。不確定的地方直接說不確定。", shape: "circle", color: "green" }, Template { name: "研究助理", description: "上網找資料、整理重點、附上來源", instructions: "先釐清問題,再用 web_fetch 或瀏覽器找至少兩個可靠來源。回答先給結論,再列重點,每個重點附來源網址。不確定的地方直接說不確定。", shape: "circle", color: "green" },
Template { name: "寫作夥伴", description: "幫你構思、改寫和潤稿", instructions: "先問清楚讀者和語氣。改稿時保留作者的意思,列出改了什麼和為什麼。用繁體中文、台灣用語。", shape: "cloud", color: "purple" }, 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: "在 Slack、Discord、Telegram 或 Teams 回覆客人", instructions: "回答簡短、有禮貌。不確定的事不要編,說會請同事確認。不要透露內部資料或使用者的私人資訊。", shape: "pill", color: "orange" },
Template { name: "開發助手", description: "看程式、跑測試、追 GitHub 上的事", instructions: "改程式前先讀相關檔案和測試。每次修改後跑測試,回報結果。PR 或 CI 的事件來時,先看紀錄再下結論。", shape: "hexagon", color: "black" }, Template { name: "開發助手", description: "看程式、跑測試、追 GitHub 上的事", instructions: "改程式前先讀相關檔案和測試。每次修改後跑測試,回報結果。PR 或 CI 的事件來時,先看紀錄再下結論。", shape: "hexagon", color: "black" },
]; ];

View File

@ -123,10 +123,23 @@ fn is_zero(n: &u64) -> bool {
*n == 0 *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 的電腦"; 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 = "你的電腦"; 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 { impl Message {
pub fn user(text: &str) -> Self { pub fn user(text: &str) -> Self {
@ -382,13 +395,25 @@ fn default_sidebar_width() -> f32 {
} }
impl State { 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<String> { pub fn computer_names(&self) -> Vec<String> {
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.extend(self.remote_computers.iter().map(|c| c.name.clone()));
v 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<u64> { pub fn routine_owner(&self, routine_id: u64) -> Option<u64> {
self.bots.iter().find(|b| b.routines.iter().any(|r| r.id == routine_id)).map(|b| b.id) self.bots.iter().find(|b| b.routines.iter().any(|r| r.id == routine_id)).map(|b| b.id)
} }

View File

@ -3,15 +3,6 @@
// `localize` is applied to every translated label (i18n::t) and to what the bot is told (its // `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. // 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)] = &[ const PAIRS: &[(&str, &str)] = &[
("使用者的這台 Mac", "使用者的這台電腦"), ("使用者的這台 Mac", "使用者的這台電腦"),
("這台 Mac", "這台電腦"), ("這台 Mac", "這台電腦"),

View File

@ -92,6 +92,12 @@ impl Default for Settings {
} }
pub fn default_host_name() -> String { pub fn default_host_name() -> String {
// Asked of the system once (it runs a program).
static N: OnceLock<String> = OnceLock::new();
N.get_or_init(system_host_name).clone()
}
fn system_host_name() -> String {
if !cfg!(target_os = "macos") { 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 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 }; 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. /// What the phone asks AnyBot to do; the app carries these out on its own thread.
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub enum Cmd { pub enum Cmd {
Send { bot: u64, text: String, attachments: Vec<String> }, /// `from`: the account computer the person typed it on (empty: the phone, or this one).
Send { bot: u64, text: String, attachments: Vec<String>, from: String },
Stop { bot: u64 }, Stop { bot: u64 },
Answer { bot: u64, answer: String }, Answer { bot: u64, answer: String },
Read { bot: u64 }, Read { bot: u64 },
@ -119,6 +126,8 @@ pub enum Cmd {
Reply { bot: u64 }, Reply { bot: u64 },
/// Bring back an earlier conversation (過去的對話). /// Bring back an earlier conversation (過去的對話).
RestoreThread { bot: u64, index: usize }, 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. /// A new phone signed in (to remember), or one was signed out.
DeviceAdded(Device), DeviceAdded(Device),
DeviceSeen(String), 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. // 團隊: the bot knows who is talking when it isn't the owner.
let who = req.header("x-anybot-user"); let who = req.header("x-anybot-user");
let text = if role != "owner" && !who.is_empty() { format!("【{who}】{text}") } else { text }; 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 }, "stop" => Cmd::Stop { bot },
"answer" => Cmd::Answer { bot, answer: b["answer"].as_str().unwrap_or("reject").to_string() }, "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), Err(e) => Resp::error(503, &e),
}, },
("POST", ["api", "sync", "upload", id]) => upload(id.parse().unwrap_or(0), &req), ("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) { ("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), Ok(v) => Resp::json(&v),
Err(e) => Resp::error(503, &e), Err(e) => Resp::error(503, &e),
@ -449,6 +504,8 @@ pub fn handle(req: Req, via_relay: bool) -> Resp {
bot, bot,
text: b["text"].as_str().unwrap_or("").trim().to_string(), 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(), 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 }, "stop" => Cmd::Stop { bot },
"answer" => Cmd::Answer { bot, answer: b["answer"].as_str().unwrap_or("reject").to_string() }, "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 }, "regenerate" => Cmd::Regenerate { bot },
"reply" => Cmd::Reply { bot }, "reply" => Cmd::Reply { bot },
"restore" => Cmd::RestoreThread { bot, index: b["index"].as_u64().unwrap_or(0) as usize }, "restore" => Cmd::RestoreThread { bot, index: b["index"].as_u64().unwrap_or(0) as usize },
"clear" => Cmd::Clear { bot },
_ => return Resp::error(404, "沒有這個動作"), _ => return Resp::error(404, "沒有這個動作"),
}; };
hub().push(cmd); hub().push(cmd);
@ -590,7 +648,7 @@ fn screen_input(which: &str, v: &Value, s: &Settings) -> Resp {
} }
let (name, args) = match kind { 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)})), "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"]})), "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)})), "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, "不認得的操作"), _ => 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. /// can't open a connection to, and the person's computer lends this brain its hands.
pub fn model_hands_for(url: &str) -> Option<String> { pub fn model_hands_for(url: &str) -> Option<String> {
let s = hub().settings(); 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)?; let (h, p) = host_port(url)?;
if !private_host(&h) || h == "localhost" || h.starts_with("127.") { if !private_host(&h) || h == "localhost" || h.starts_with("127.") {
return None; 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 args = json!({"url": url, "headers": headers, "body": body}).to_string();
let req = json!({"tool": MODEL_FETCH, "args": args, "who": "大腦"}).to_string(); let req = json!({"tool": MODEL_FETCH, "args": args, "who": "大腦"}).to_string();
let name = peer_name(hand); 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 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 a = r.map_err(|e| format!("透過{whom}連模型失敗:{e}"))?;
let v = answer_value(a.status, &a.body, &whom)?; 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<S
v["path"].as_str().map(String::from).ok_or("大腦沒有收下檔案".into()) v["path"].as_str().map(String::from).ok_or("大腦沒有收下檔案".into())
} }
/// The brain's bots' computer as a JPEG (`thumb`: the small picture that never wakes it).
pub fn sync_screen(s: &Settings, thumb: bool) -> Result<Vec<u8>, 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. /// A file the brain keeps (an attachment shown in a chat), as bytes.
pub fn sync_file(s: &Settings, path: &str) -> Result<Vec<u8>, String> { pub fn sync_file(s: &Settings, path: &str) -> Result<Vec<u8>, String> {
let (st, bytes) = to_brain_raw(s, "GET", "/file", &[("path", path.to_string())], &[], 120)?; 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<String> {
v v
} }
fn person_cell() -> &'static Mutex<String> {
static P: OnceLock<Mutex<String>> = 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. /// A computer's own name as the account server knows it.
pub fn peer_name(id: &str) -> String { pub fn peer_name(id: &str) -> String {
peer_rows().lock().unwrap().get(id).map(|r| r.0.clone()).unwrap_or_default() 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); assert_eq!(handle(send, false).status, 200);
let cmds = hub().take_cmds(); let cmds = hub().take_cmds();
assert!(cmds.iter().any(|c| matches!(c, Cmd::DeviceAdded(d) if d.name == "iPhone"))); 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. // The Mac's own screen stays closed unless allowed.
let mut mac = Req { method: "GET".into(), path: "/api/screen/mac".into(), ..Default::default() }; let mut mac = Req { method: "GET".into(), path: "/api/screen/mac".into(), ..Default::default() };
mac.headers = req.headers.clone(); mac.headers = req.headers.clone();

View File

@ -622,6 +622,17 @@ pub struct Target {
pub place: Place, 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<String>,
/// 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. /// 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<Value> { pub fn specs_for(computers: &[String], default: &str) -> Vec<Value> {
let mut v = specs(); let mut v = specs();
@ -629,14 +640,9 @@ pub fn specs_for(computers: &[String], default: &str) -> Vec<Value> {
if t["name"] == "read_terminal" || t["name"] == "write_terminal" { if t["name"] == "read_terminal" || t["name"] == "write_terminal" {
continue; continue;
} }
// None of these being the person's own computer (a server brain with no hands): // Each computer goes by its own name; the instructions say which is which. Working on
// a bot that "helped on your computer" then worked on the server, while the person // one and calling it another (the server as "your computer") is what confused people.
// watched their own desktop stay untouched. Say what is true instead. 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.");
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.")
};
t["parameters"]["properties"]["computer"] = json!({ t["parameters"]["properties"]["computer"] = json!({
"type": "string", "type": "string",
"enum": computers, "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")); let p = resolve(root, &arg_str(&v, "path"));
if !inside(root, &p) { 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 Decision::Allow
} }

View File

@ -59,30 +59,14 @@ pub fn read_needs_permission(args: &str) -> bool {
uses_person_browser(args) uses_person_browser(args)
} }
/// The browser tools, also when the browser is on another computer of the account (裝置連結): /// The browser tools. `browsers`: the computers with an AnyBot browser this bot can use (its own,
/// then each takes a `computer`, since each computer's AnyBot browser has its own logins. /// this one, the account's other computers), by name; each has its own logins.
pub fn specs_for(here: &str, devices: &[String]) -> Vec<Value> { pub fn specs_for(browsers: &[String], default: &str) -> Vec<Value> {
let local = crate::botbrowser::available(); let mut v = specs_inner(!browsers.is_empty());
let mut v = specs_inner(local || !devices.is_empty()); if !browsers.is_empty() {
if !devices.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.");
let mut names: Vec<String> = 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()
}
);
for t in v.iter_mut().filter(|t| t["name"].as_str().map(|n| n.starts_with("browser_")).unwrap_or(false)) { for t in v.iter_mut().filter(|t| t["name"].as_str().map(|n| n.starts_with("browser_")).unwrap_or(false)) {
t["parameters"]["properties"]["computer"] = json!({"type": "string", "enum": names, "description": desc}); t["parameters"]["properties"]["computer"] = json!({"type": "string", "enum": browsers, "description": desc});
} }
} }
v v