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 ----------
$("b-screen").onclick = $("b-screen2").onclick = () => { S.back = S.view; show("screen"); $("seg").children[1].style.display = S.mac ? "" : "none"; startFrames(); };
// Each bot's own computer goes by the bot's name (「Master 的電腦」).
function ownComputer() { const n = botName(S.open); return n ? n + " 的電腦" : "Bot 的電腦"; }
$("b-screen").onclick = $("b-screen2").onclick = () => { S.back = S.view; show("screen"); $("seg").children[0].textContent = ownComputer(); $("seg").children[1].style.display = S.mac ? "" : "none"; startFrames(); };
$("b-back2").onclick = () => show(S.back === "chat" ? "chat" : "list");
document.querySelectorAll("#seg button").forEach((b) => b.onclick = () => { document.querySelectorAll("#seg button").forEach((x) => x.classList.toggle("on", x === b)); S.screen = b.dataset.w; $("k-wake").style.display = S.screen === "box" ? "" : "none"; startFrames(); });
let busyFrame = false, lastUrl = null;
@ -735,7 +737,7 @@ $("k-type").onclick = () => { $("typebar").classList.toggle("on"); if ($("typeba
$("b-type").onclick = () => { const t = $("typein").value; if (!t) return; $("typein").value = ""; input2({kind: "type", text: t}); };
$("typein").addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.isComposing) { e.preventDefault(); $("b-type").click(); } });
$("k-right").onclick = () => { S.right = !S.right; $("k-right").textContent = "右鍵:" + (S.right ? "開" : "關"); };
$("k-wake").onclick = () => { toast("正在叫醒 Bot 的電腦…"); input2({kind: "wake"}); };
$("k-wake").onclick = () => { toast("正在叫醒" + ownComputer() + "…"); input2({kind: "wake"}); };
// ---------- menu, notifications ----------
$("b-more").onclick = () => { $("m-host").style.display = S.relay ? "" : "none"; $("m-pair").style.display = S.relay ? "" : "none"; notifyLabel(); $("sheet").classList.add("on"); };
$("sheet").onclick = (e) => { if (e.target.id === "sheet") $("sheet").classList.remove("on"); };

View File

@ -34,6 +34,8 @@ pub struct Job {
/// Computers this bot may work on (empty when computer tools are off).
pub computers: Vec<tools::Target>,
pub default_computer: String,
/// Which computer is the bot's own, which one the person is using, and whether this is a server.
pub roles: tools::Roles,
/// 在這台電腦上執行 is on.
pub local_execution: bool,
/// This bot's computer tools are on (when off, using them asks the person first).
@ -121,23 +123,29 @@ enum BrowserAt {
Device(String),
}
/// Whose browser a browser step uses: the computer it names, else the bot's chosen computer.
fn browser_place(job: &Job, name: &str, args: &str) -> BrowserAt {
/// Whose browser a browser step uses: the computer it names, else the bot's chosen computer; a
/// computer without a browser (a server, an SSH one) hands it to the person's computer.
fn browser_place(job: &Job, name: &str, args: &str) -> (BrowserAt, String) {
if name == "web_fetch" {
return BrowserAt::Here;
return (BrowserAt::Here, String::new());
}
let v = tools::args_of(args);
let want = v.get("computer").and_then(|c| c.as_str()).map(str::trim).filter(|c| !c.is_empty()).unwrap_or(&job.default_computer).to_string();
let any_device = || job.computers.iter().find_map(|t| match &t.place {
tools::Place::Device { host } => Some(host.clone()),
_ => None,
});
match job.computers.iter().find(|t| t.name == want).map(|t| &t.place) {
Some(tools::Place::Device { host }) => BrowserAt::Device(host.clone()),
Some(tools::Place::Box { .. }) if crate::cloudbox::available() => BrowserAt::Box,
// No browser here (a brain on a server): the 手腳 computer's.
_ if !crate::botbrowser::available() => any_device().map(BrowserAt::Device).unwrap_or(BrowserAt::Here),
_ => BrowserAt::Here,
let at = |t: &tools::Target| -> Option<BrowserAt> {
match &t.place {
tools::Place::Device { host } => Some(BrowserAt::Device(host.clone())),
tools::Place::Box { .. } if crate::cloudbox::available() => Some(BrowserAt::Box),
tools::Place::Local { .. } if crate::botbrowser::available() => Some(BrowserAt::Here),
_ => None,
}
};
if let Some((b, n)) = job.computers.iter().find(|t| t.name == want).and_then(|t| at(t).map(|b| (b, t.name.clone()))) {
return (b, n);
}
let person = job.roles.person.as_ref().and_then(|p| job.computers.iter().find(|t| &t.name == p));
match person.and_then(|t| at(t).map(|b| (b, t.name.clone()))).or_else(|| job.computers.iter().find_map(|t| at(t).map(|b| (b, t.name.clone())))) {
Some(x) => x,
None => (BrowserAt::Here, String::new()),
}
}
@ -172,54 +180,72 @@ fn finds(hay: &str, needle: &str) -> bool {
false
}
/// Words that mean a computer in the person's message.
fn mentions(text: &str, t: &tools::Target) -> bool {
/// Words that mean a computer in the person's message: its name (or the part after 「的」, so
/// 「Mac mini」 names 「王小明的Mac mini」), and for the bot's own computer and the one the person
/// is using, the everyday words for them.
fn mentions(text: &str, t: &tools::Target, roles: &tools::Roles) -> bool {
let low = text.to_lowercase();
if finds(&low, &t.name.to_lowercase()) {
let name = t.name.to_lowercase();
if finds(&low, &name) {
return true;
}
let words: &[&str] = match &t.place {
_ if t.name == crate::model::BOT_COMPUTER_NAME => &["bot 的電腦", "bot的電腦", "你自己的電腦", "你的虛擬", "虛擬電腦", "雲端電腦"],
_ if t.name == crate::model::THIS_COMPUTER_NAME => &["mac", "我的電腦", "我電腦", "我這台", "這台電腦", "本機", "我的筆電"],
tools::Place::Device { host } => return !crate::remote::peer_name(host).is_empty() && finds(&low, &crate::remote::peer_name(host).to_lowercase()),
_ => &[],
if let Some((_, tail)) = name.rsplit_once('的') {
let tail = tail.trim();
if tail.chars().count() >= 3 && tail != "電腦" && finds(&low, tail) {
return true;
}
}
if finds(&name, "mac") && finds(&low, "mac") {
return true;
}
let words: &[&str] = if t.name == roles.own {
&["bot 的電腦", "bot的電腦", "你的電腦", "你自己的電腦", "你的虛擬", "虛擬電腦", "雲端電腦"]
} else if roles.person.as_deref() == Some(t.name.as_str()) {
&["我的電腦", "我電腦", "我這台", "這台電腦", "本機", "我的筆電", "my computer"]
} else if roles.server && matches!(t.place, tools::Place::Local { .. }) {
&["伺服器", "server"]
} else {
&[]
};
if t.name == roles.own && finds(&low, &name.replace(' ', "")) {
return true;
}
words.iter().any(|w| finds(&low, w))
}
/// A step naming a computer other than the chosen one, which the person didn't ask for, goes
/// to the chosen one.
fn keep_to_chosen(job: &Job, history: &[Message], args: &str) -> String {
/// to the chosen one — and the bot is told so (quietly moving it made a bot "ask your Mac" six
/// times while every step landed on its own computer).
fn keep_to_chosen(job: &Job, history: &[Message], args: &str) -> (String, Option<String>) {
let mut v = tools::args_of(args);
let Some(named) = v.get("computer").and_then(|c| c.as_str()).map(|c| c.trim().to_string()).filter(|c| !c.is_empty()) else { return args.to_string() };
let Some(named) = v.get("computer").and_then(|c| c.as_str()).map(|c| c.trim().to_string()).filter(|c| !c.is_empty()) else { return (args.to_string(), None) };
if named == job.default_computer || !job.computers.iter().any(|t| t.name == job.default_computer) {
return args.to_string();
return (args.to_string(), None);
}
let said = history.iter().rev().filter(|m| m.role == Role::User && !m.summary).take(3).map(|m| m.text.as_str()).collect::<Vec<_>>().join("\n");
match job.computers.iter().find(|t| t.name == named) {
Some(t) if mentions(&said, t) => args.to_string(),
Some(t) if mentions(&said, t, &job.roles) => (args.to_string(), None),
_ => {
v["computer"] = serde_json::Value::String(job.default_computer.clone());
v.to_string()
let note = format!("(這一步你指定了「{named}」,但使用者選的電腦是「{}」,而且他沒有說要換,所以這一步在「{}」上做。真的需要別台,先問使用者。)\n", job.default_computer, job.default_computer);
(v.to_string(), Some(note))
}
}
}
/// A step's result, with a word first when the step was moved back to the chosen computer.
fn noted(moved: &std::collections::HashMap<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?
fn names_a_computer(args: &str) -> bool {
tools::args_of(args).get("computer").and_then(|c| c.as_str()).map(|c| !c.trim().is_empty()).unwrap_or(false)
}
/// The other computer (裝置連結) a call names, if it names one.
fn device_named(job: &Job, args: &str) -> Option<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).
/// Which screen a computer_use call is for: the one named, else the bot's default when it has a
/// screen, else the bot's own desktop when there is one, else this Mac.
@ -228,10 +254,10 @@ fn screen_target(job: &Job, args: &str) -> Result<tools::Target, String> {
let has_screen = |t: &tools::Target| matches!(t.place, tools::Place::Box { .. } | tools::Place::Local { .. } | tools::Place::Device { .. });
let named = v.get("computer").and_then(|c| c.as_str()).map(str::trim).filter(|c| !c.is_empty());
if let Some(n) = named {
let t = job.computers.iter().find(|t| t.name == n).ok_or_else(|| format!("沒有「{n}」這台電腦"))?;
let t = job.computers.iter().find(|t| t.name == n).ok_or_else(|| format!("沒有「{n}」這台電腦。可以用:{}", job.computers.iter().map(|t| t.name.as_str()).collect::<Vec<_>>().join("、")))?;
if !has_screen(t) {
return Err(if n == crate::model::BOT_COMPUTER_NAME {
if cfg!(target_os = "macos") { "Bot 的電腦現在沒有螢幕:這台 Mac 沒有 Docker(安裝 OrbStack 或 Docker Desktop 就會有)。可以改用「你的電腦」。".into() } else { "Bot 的電腦現在沒有螢幕:這台電腦沒有 Docker(安裝 Docker Engine 就會有)。可以改用「你的電腦」。".into() }
return Err(if n == job.roles.own {
format!("{n}現在沒有螢幕:AnyBot 所在的電腦沒有 Docker({})。要用別台請使用者指名。", if cfg!(target_os = "macos") { "安裝 OrbStack 或 Docker Desktop 就會有" } else { "安裝 Docker Engine 就會有" })
} else {
format!("「{n}」不能遠端操作螢幕,只能用檔案和指令工具。")
});
@ -383,8 +409,14 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
return;
}
let mut out = out;
// Steps moved back to the chosen computer: what the bot is told with each result.
let mut moved: std::collections::HashMap<String, String> = std::collections::HashMap::new();
for c in out.tool_calls.iter_mut() {
c.args = keep_to_chosen(&job, &history, &c.args);
let (args, note) = keep_to_chosen(&job, &history, &c.args);
c.args = args;
if let Some(n) = note {
moved.insert(c.id.clone(), n);
}
}
let msg = Message {
role: Role::Assistant,
@ -425,7 +457,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
}
};
send(StreamEvent::ToolStatus(call.id.clone(), if ok { ToolStatus::Done } else { ToolStatus::Failed }));
let result = Message::tool_result(&call.id, &call.name, &output);
let result = Message::tool_result(&call.id, &call.name, &noted(&moved, &call.id, &output));
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
@ -435,7 +467,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running));
let (output, ok, img) = crate::teach::view_step(&job.demos, &call.args);
send(StreamEvent::ToolStatus(call.id.clone(), if ok { ToolStatus::Done } else { ToolStatus::Failed }));
let mut result = Message::tool_result(&call.id, &call.name, &output);
let mut result = Message::tool_result(&call.id, &call.name, &noted(&moved, &call.id, &output));
result.images.extend(img);
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
@ -446,7 +478,8 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
if call.name == "web_fetch" || (web::is_web_tool(&call.name) && !web::uses_this_computer(&call.name, &call.args)) {
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running));
// 裝置連結: the AnyBot browser of another computer (its own logins).
let (output, ok, img) = match browser_place(&job, &call.name, &call.args) {
let (place, on) = browser_place(&job, &call.name, &call.args);
let (output, ok, img) = match place {
BrowserAt::Device(host) => match crate::remote::run_on_device(&host, &call.name, &call.args, &job.via_name) {
Ok(v) => (v["text"].as_str().unwrap_or("").to_string(), v["ok"].as_bool().unwrap_or(false), v["image"].as_str().map(String::from)),
Err(e) => (e, false, None),
@ -454,8 +487,10 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
BrowserAt::Box => crate::botbrowser::in_box(|| web::run_full(&call.name, &call.args, &cancel)),
BrowserAt::Here => web::run_full(&call.name, &call.args, &cancel),
};
// Which computer's browser it was, like every other step.
let output = if on.is_empty() { output } else { format!("[{on}] {output}") };
send(StreamEvent::ToolStatus(call.id.clone(), if ok { ToolStatus::Done } else { ToolStatus::Failed }));
let mut result = Message::tool_result(&call.id, &call.name, &output);
let mut result = Message::tool_result(&call.id, &call.name, &noted(&moved, &call.id, &output));
result.images.extend(img);
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
@ -483,7 +518,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
// 生活卡片: drawn by the window from the call itself.
if call.name == "show_card" {
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Done));
let result = Message::tool_result(&call.id, &call.name, "卡片已顯示給使用者。");
let result = Message::tool_result(&call.id, &call.name, &noted(&moved, &call.id, "卡片已顯示給使用者。"));
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
@ -493,7 +528,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
let emoji: String = tools::args_of(&call.args)["emoji"].as_str().unwrap_or("👍").chars().take(4).collect();
send(StreamEvent::React(emoji.clone()));
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Done));
let result = Message::tool_result(&call.id, &call.name, &format!("已在使用者的訊息上按了 {emoji}。"));
let result = Message::tool_result(&call.id, &call.name, &noted(&moved, &call.id, &format!("已在使用者的訊息上按了 {emoji}。")));
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
@ -510,7 +545,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
},
Ok(tools::Target { place: tools::Place::Sealed { root }, .. }) => Ok(tools::resolve(&root, &path)),
Ok(tools::Target { place: tools::Place::Local { workdir }, .. }) => Ok(tools::resolve(&workdir, &path)),
Ok(_) => Err("這台電腦上的檔案不能直接傳,請先複製到 Bot 的電腦。".to_string()),
Ok(_) => Err(format!("這台電腦上的檔案不能直接傳,請先複製到「{}」。", job.roles.own)),
Err(e) => Err(e),
};
// Secrets (SSH keys, the app's own data folder, .env…) never ride into the chat,
@ -525,7 +560,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
Err(e) => (e, false),
};
send(StreamEvent::ToolStatus(call.id.clone(), if ok { ToolStatus::Done } else { ToolStatus::Failed }));
let result = Message::tool_result(&call.id, &call.name, &output);
let result = Message::tool_result(&call.id, &call.name, &noted(&moved, &call.id, &output));
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
@ -542,12 +577,34 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
},
Err(_) => None,
};
// On a server nobody sits at its screen: a link opens in the browser of the
// computer the person is using; a file is sent into the chat instead.
let person_host = job.roles.person.as_ref().and_then(|n| job.computers.iter().find(|t| &t.name == n)).and_then(|t| match &t.place {
tools::Place::Device { host } => Some(host.clone()),
_ => None,
});
if job.roles.server {
let web = path.starts_with("http://") || path.starts_with("https://");
let (output, ok) = match (&person_host, web) {
(Some(host), true) => match crate::remote::run_on_device(host, "browser_open", &serde_json::json!({"url": path}).to_string(), &job.via_name) {
Ok(v) => (format!("[{}] {}", job.roles.person.clone().unwrap_or_default(), v["text"].as_str().unwrap_or("")), v["ok"].as_bool().unwrap_or(false)),
Err(e) => (e, false),
},
(None, true) => ("不知道使用者現在在用哪台電腦,沒辦法打開給他看。把網址直接寫在回覆裡。".to_string(), false),
(_, false) => ("AnyBot 在伺服器上,檔案沒辦法直接在使用者的螢幕打開。改用 send_file 把檔案傳到聊天裡,使用者點一下就能看。".to_string(), false),
};
send(StreamEvent::ToolStatus(call.id.clone(), if ok { ToolStatus::Done } else { ToolStatus::Failed }));
let result = Message::tool_result(&call.id, &call.name, &noted(&moved, &call.id, &output));
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
}
let local = match (&on_box, target_for(&job, &call.args)) {
(Some(p), _) => crate::cloudbox::copy_out(p),
(None, Ok(tools::Target { place: tools::Place::Sealed { root }, .. })) => Ok(tools::resolve(&root, &path)),
(None, Ok(tools::Target { place: tools::Place::Local { workdir }, .. })) => Ok(tools::resolve(&workdir, &path)),
(None, _) if path.starts_with("http://") || path.starts_with("https://") => Ok(std::path::PathBuf::from(&path)),
_ => Err("這台電腦上的檔案不能直接打開給使用者看,請先複製到 Bot 的電腦或這台 Mac。".to_string()),
_ => Err(format!("這台電腦上的檔案不能直接打開給使用者看,請先複製到「{}」或這台電腦。", job.roles.own)),
};
// Secrets never open on the person's screen either.
let local = local.and_then(|p| if tools::is_ignored(&p) || tools::is_ignored(std::path::Path::new(&path)) { Err("這個檔案是機密的,不能打開。".to_string()) } else { Ok(p) });
@ -560,7 +617,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
Err(e) => (e, false),
};
send(StreamEvent::ToolStatus(call.id.clone(), if ok { ToolStatus::Done } else { ToolStatus::Failed }));
let result = Message::tool_result(&call.id, &call.name, &output);
let result = Message::tool_result(&call.id, &call.name, &noted(&moved, &call.id, &output));
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
@ -583,7 +640,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
}
};
send(StreamEvent::ToolStatus(call.id.clone(), status));
let result = Message::tool_result(&call.id, &call.name, &output);
let result = Message::tool_result(&call.id, &call.name, &noted(&moved, &call.id, &output));
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
@ -618,7 +675,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
Ok(tools::Target { place: tools::Place::Box { dir }, .. }) => crate::cloudbox::copy_out(&if path.starts_with('/') { path.clone() } else { format!("{dir}/{path}") }),
Ok(tools::Target { place: tools::Place::Sealed { root }, .. }) => Ok(tools::resolve(&root, &path)),
Ok(tools::Target { place: tools::Place::Local { workdir }, .. }) => Ok(tools::resolve(&workdir, &path)),
Ok(_) => Err("這台電腦上的檔案不能直接發布,請先複製到 Bot 的電腦。".to_string()),
Ok(_) => Err(format!("這台電腦上的檔案不能直接發布,請先複製到「{}」。", job.roles.own)),
Err(e) => Err(e),
};
match local.and_then(|p| crate::remote::publish_site(&name, &p)) {
@ -627,7 +684,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
}
};
send(StreamEvent::ToolStatus(call.id.clone(), status));
let result = Message::tool_result(&call.id, &call.name, &output);
let result = Message::tool_result(&call.id, &call.name, &noted(&moved, &call.id, &output));
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
@ -638,7 +695,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
if call.name == crate::vault::LIST_TOOL {
let output = crate::vault::list_for_bot(&call.args);
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Done));
let result = Message::tool_result(&call.id, &call.name, &output);
let result = Message::tool_result(&call.id, &call.name, &noted(&moved, &call.id, &output));
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
@ -696,7 +753,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
}
};
send(StreamEvent::ToolStatus(call.id.clone(), status));
let result = Message::tool_result(&call.id, &call.name, &output);
let result = Message::tool_result(&call.id, &call.name, &noted(&moved, &call.id, &output));
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
@ -731,29 +788,30 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
("使用者拒絕了這個操作。請不要再試同樣的操作,改問使用者想怎麼做。".to_string(), ToolStatus::Denied, None)
};
send(StreamEvent::ToolStatus(call.id.clone(), status));
let mut result = Message::tool_result(&call.id, &call.name, &output);
let mut result = Message::tool_result(&call.id, &call.name, &noted(&moved, &call.id, &output));
result.images.extend(img);
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
}
// The browser tools use the person's own browser, so always this Mac.
// …and so does screen control: it's the person's own screen.
let person_device = job.computers.iter().find(|t| t.name == crate::model::THIS_COMPUTER_NAME && matches!(t.place, tools::Place::Device { .. })).cloned();
// The person's own apps and everyday browser are on the computer they are using:
// the one they name, else the one they typed on (else this one, when it is theirs).
let person_target = || -> Result<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 {
screen_target(&job, &call.args)
} else if is_web && device_named(&job, &call.args).is_some() {
// The browser on another computer of the account, as named.
target_for(&job, &call.args)
} else if is_web && call.name.starts_with("browser_") && person_device.is_some() && !names_a_computer(&call.args) {
// A brain on a server: the browser is the person's computer's (the one they
// can see), unless the server's own is named.
Ok(person_device.clone().unwrap())
} else if is_web && call.name.starts_with("browser_") && !crate::botbrowser::available() && job.computers.iter().any(|t| matches!(t.place, tools::Place::Device { .. })) {
// No browser here (a brain on a server): the 手腳 computer's.
job.computers.iter().find(|t| matches!(t.place, tools::Place::Device { .. })).cloned().ok_or_else(|| "找不到這台電腦".to_string())
} else if is_mac || web::uses_this_computer(&call.name, &call.args) {
job.computers.iter().find(|t| t.name == crate::model::THIS_COMPUTER_NAME).cloned().ok_or_else(|| "找不到這台電腦".to_string())
} else if is_mac || is_web {
// (Other browser steps went their own way above; this is the person's own Chrome.)
person_target()
} else {
target_for(&job, &call.args)
};
@ -790,17 +848,17 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
}
// An admin lock (policy) refuses outright instead of asking.
if gate.is_ok() && matches!(t.place, tools::Place::Local { .. }) && job.local_locked {
gate = Err(format!("「在這台電腦上執行」設成了永不允許(或被管理員關閉),不能在「{}」上執行。請改用「{}」。", t.name, crate::model::BOT_COMPUTER_NAME));
gate = Err(format!("「在這台電腦上執行」設成了永不允許(或被管理員關閉),不能在「{}」上執行。請改用「{}」。", t.name, job.roles.own));
}
if gate.is_ok() && matches!(t.place, tools::Place::Local { .. }) && !local_on {
let reason = format!("在你的電腦上執行任務,而不只是在它自己的電腦上:{}{what}\n(在 {} 上)", if is_web { format!("{} ", web::label(&call.name)) } else { String::new() }, t.name);
let reason = format!("在「{}」上執行任務,而不只是在它自己的電腦上:{}{what}", t.name, if is_web { format!("{} ", web::label(&call.name)) } else { String::new() });
match ask_person(ApprovalKind::EnableThisComputer, reason, "local:on".into()) {
ApprovalAnswer::Once => said_yes = true,
ApprovalAnswer::Always(_) => {
local_on = true;
said_yes = true;
}
ApprovalAnswer::Reject => gate = Err(format!("使用者沒有允許在「{}」上執行。可以改用「{}」,或問使用者。", t.name, crate::model::BOT_COMPUTER_NAME)),
ApprovalAnswer::Reject => gate = Err(format!("使用者沒有允許在「{}」上執行。可以改用「{}」,或問使用者。", t.name, job.roles.own)),
}
}
}
@ -934,9 +992,9 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
(Ok(()), Ok(t)) => {
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running));
let ids = tools::RunIds { conversation: bot_id.to_string(), request: call.id.clone() };
let (out, ok) = if is_mac {
let (out, ok) = if is_mac && on_device.is_none() {
crate::macapps::run(&call.name, &call.args)
} else if let (true, Some(host)) = (is_screen || is_web, on_device.as_ref()) {
} else if let (true, Some(host)) = (is_screen || is_web || is_mac, on_device.as_ref()) {
match crate::remote::run_on_device(host, &call.name, &call.args, &job.via_name) {
Ok(v) => {
image = v["image"].as_str().map(String::from);
@ -967,7 +1025,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
(Err(x), _) => x,
};
send(StreamEvent::ToolStatus(call.id.clone(), status));
let mut result = Message::tool_result(&call.id, &call.name, &output);
let mut result = Message::tool_result(&call.id, &call.name, &noted(&moved, &call.id, &output));
result.images.extend(image);
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
@ -988,6 +1046,28 @@ mod tests {
use crate::model::ToolCall;
use crate::providers::ProviderKind;
/// A job with nothing in it, for tests that only need a few fields.
fn test_job() -> Job {
Job {
req: ChatRequest { kind: ProviderKind::Custom, base_url: String::new(), api_key: String::new(), model: String::new(), system: String::new(), history: vec![], tools: vec![], session: String::new() },
computers: vec![],
default_computer: String::new(),
roles: tools::Roles::default(),
local_execution: false,
grants: vec![],
context_tokens: DEFAULT_CONTEXT_TOKENS,
via: String::new(),
via_name: String::new(),
tools_enabled: true,
direct: Arc::new(AtomicBool::new(false)),
local_locked: false,
steer: Arc::new(Mutex::new(vec![])),
review: None,
demos: vec![],
}
}
#[test]
fn computer_words_need_word_boundaries() {
// "mac" as its own word names this computer; inside longer words (macOS, a device
@ -1000,9 +1080,51 @@ mod tests {
assert!(finds("我的電腦好慢", "我的電腦"));
assert!(!finds("你的虛擬電腦很吵", "bot 的電腦"));
assert!(finds("在 bot 的電腦跑", "bot 的電腦"));
let t = tools::Target { name: crate::model::THIS_COMPUTER_NAME.into(), place: tools::Place::Local { workdir: Default::default() } };
assert!(mentions("幫我在 Mac 截圖", &t));
assert!(!mentions("在 macOS 的登入機制下,幫 Bot 的電腦截圖", &t));
let roles = tools::Roles { own: "Master 的電腦".into(), person: Some("王小明的Mac mini".into()), server: true };
let mac = tools::Target { name: "王小明的Mac mini".into(), place: tools::Place::Device { host: "m".into() } };
assert!(mentions("幫我在 Mac 截圖", &mac, &roles));
assert!(!mentions("在 macOS 的登入機制下,幫 Bot 的電腦截圖", &mac, &roles));
}
#[test]
fn every_computer_answers_to_its_own_name() {
// One name per computer: the bot's own goes by the bot's name, the others by theirs, and
// the everyday words point at the right one (「我的電腦」 = where the person typed).
let roles = tools::Roles { own: "Master 的電腦".into(), person: Some("王小明的Mac mini".into()), server: true };
let own = tools::Target { name: "Master 的電腦".into(), place: tools::Place::Box { dir: String::new() } };
let mac = tools::Target { name: "王小明的Mac mini".into(), place: tools::Place::Device { host: "m".into() } };
let desk = tools::Target { name: "ai".into(), place: tools::Place::Device { host: "a".into() } };
let server = tools::Target { name: "雲端大腦".into(), place: tools::Place::Local { workdir: Default::default() } };
assert!(mentions("在 Master 的電腦打開 Threads", &own, &roles));
assert!(mentions("用master的電腦", &own, &roles));
assert!(mentions("你都用 Bot的電腦", &own, &roles));
assert!(mentions("在 Mac mini 上開", &mac, &roles));
assert!(mentions("截圖我的電腦", &mac, &roles));
assert!(!mentions("截圖我的電腦", &desk, &roles));
assert!(!mentions("截圖我的電腦", &own, &roles));
assert!(mentions("在 ai 上跑", &desk, &roles));
assert!(!mentions("用 AI 幫我想標題", &mac, &roles));
assert!(mentions("在伺服器上看看磁碟", &server, &roles));
}
#[test]
fn a_step_moved_back_to_the_chosen_computer_says_so() {
let roles = tools::Roles { own: "Master 的電腦".into(), person: Some("王小明的Mac mini".into()), server: true };
let job = Job {
computers: vec![
tools::Target { name: "Master 的電腦".into(), place: tools::Place::Box { dir: String::new() } },
tools::Target { name: "王小明的Mac mini".into(), place: tools::Place::Device { host: "m".into() } },
],
default_computer: "Master 的電腦".into(),
roles,
..test_job()
};
// Not asked for: back to the chosen one, with a word for the bot.
let (args, note) = keep_to_chosen(&job, &[Message::user("幫我截圖")], r#"{"computer":"王小明的Mac mini"}"#);
assert!(args.contains("Master 的電腦") && note.unwrap().contains("王小明的Mac mini"));
// Named by the person: it stays.
let (args, note) = keep_to_chosen(&job, &[Message::user("看一下我的電腦畫面")], r#"{"computer":"王小明的Mac mini"}"#);
assert!(args.contains("王小明的Mac mini") && note.is_none());
}
#[test]
@ -1069,6 +1191,7 @@ mod tests {
req: ChatRequest { kind: ProviderKind::Custom, base_url: format!("http://127.0.0.1:{port}/v1"), api_key: String::new(), model: "m".into(), system: String::new(), history: vec![Message::user("有什麼檔案?")], tools: tools::specs(), session: String::new() },
computers: vec![tools::Target { name: crate::model::THIS_COMPUTER_NAME.into(), place: tools::Place::Local { workdir: w } }],
default_computer: crate::model::THIS_COMPUTER_NAME.into(),
roles: tools::Roles { own: crate::model::BOT_COMPUTER_NAME.into(), person: Some(crate::model::THIS_COMPUTER_NAME.into()), server: false },
local_execution: true,
grants: vec![],
context_tokens: DEFAULT_CONTEXT_TOKENS,
@ -1140,6 +1263,7 @@ mod tests {
req: ChatRequest { kind: ProviderKind::Custom, base_url: format!("http://127.0.0.1:{port}/v1"), api_key: String::new(), model: "m".into(), system: String::new(), history: vec![Message::user("打招呼")], tools: vec![], session: String::new() },
computers: vec![],
default_computer: crate::model::BOT_COMPUTER_NAME.into(),
roles: tools::Roles { own: crate::model::BOT_COMPUTER_NAME.into(), person: Some(crate::model::THIS_COMPUTER_NAME.into()), server: false },
local_execution: false,
grants: vec![],
context_tokens: DEFAULT_CONTEXT_TOKENS,
@ -1217,6 +1341,7 @@ mod tests {
req: ChatRequest { kind: ProviderKind::Custom, base_url: format!("http://127.0.0.1:{port}/v1"), api_key: String::new(), model: "m".into(), system: String::new(), history: vec![Message::user("看看我的電腦")], tools: tools::specs(), session: String::new() },
computers: vec![tools::Target { name: crate::model::THIS_COMPUTER_NAME.into(), place: tools::Place::Local { workdir: w } }],
default_computer: crate::model::THIS_COMPUTER_NAME.into(),
roles: tools::Roles { own: crate::model::BOT_COMPUTER_NAME.into(), person: Some(crate::model::THIS_COMPUTER_NAME.into()), server: false },
local_execution: false,
tools_enabled: false,
direct: Arc::new(AtomicBool::new(false)),
@ -1290,6 +1415,7 @@ mod tests {
req: ChatRequest { kind: ProviderKind::Custom, base_url: format!("http://127.0.0.1:{port}/v1"), api_key: String::new(), model: "m".into(), system: String::new(), history: vec![Message::user("做一個檔案")], tools: tools::specs(), session: String::new() },
computers: vec![tools::Target { name: crate::model::THIS_COMPUTER_NAME.into(), place: tools::Place::Local { workdir: w.clone() } }],
default_computer: crate::model::THIS_COMPUTER_NAME.into(),
roles: tools::Roles { own: crate::model::BOT_COMPUTER_NAME.into(), person: Some(crate::model::THIS_COMPUTER_NAME.into()), server: false },
local_execution: false,
tools_enabled: false,
direct: Arc::new(AtomicBool::new(true)),

View File

@ -180,6 +180,9 @@ pub struct App {
rm: remote_ui::RemoteUi,
/// Running as `anybot --serve` (no window; the phone is the screen).
headless: bool,
/// The computer the person last typed to a bot on: an account computer's id, "" for this
/// one, None when not known (the phone isn't a computer). It is "我的電腦" to the bots.
typed_on: Option<String>,
/// 右側面板 and its tab (None = closed).
panel: Option<panel_ui::PanelTab>,
/// When each emoji reaction appeared (for its pop) and when each message first showed (fade-in).
@ -277,6 +280,7 @@ impl App {
msg_index: 0,
rm: Default::default(),
headless: false,
typed_on: None,
panel: None,
popped: HashMap::new(),
appeared: HashMap::new(),
@ -638,6 +642,8 @@ impl App {
return;
}
self.drafts.insert(bot_id, String::new());
// Typed here: this computer is the person's.
self.typed_on = Some(String::new());
// While it works, a new message steers the running turn (the original's `turnSteer`:
// it doesn't count as a new turn) and reaches the model at its next step.
if let Some(l) = self.streams.get(&bot_id) {
@ -685,24 +691,42 @@ impl App {
// Computer tools are always offered; if they're off for this bot (or this Mac isn't
// allowed yet), using one pops up a request instead of failing quietly.
let computers = self.targets_for(&b);
let roles = self.roles_for(&b, &computers);
let names: Vec<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();
tool_specs.extend(tools::specs_for(&names, &default_computer));
let devices: Vec<String> = computers.iter().filter(|t| matches!(t.place, tools::Place::Device { .. })).map(|t| t.name.clone()).collect();
let here = computers.iter().find(|t| matches!(t.place, tools::Place::Local { .. })).map(|t| t.name.clone()).unwrap_or_else(|| crate::model::THIS_COMPUTER_NAME.into());
tool_specs.extend(web::specs_for(&here, &devices));
// Screen tools: the bot's own desktop (when it has one) and this Mac.
let mut screens = vec![];
if crate::cloudbox::available() {
screens.push(crate::model::BOT_COMPUTER_NAME.to_string());
}
if crate::computer::screen_supported() {
screens.extend(computers.iter().filter(|t| matches!(t.place, tools::Place::Local { .. })).map(|t| t.name.clone()));
}
// …and the account's other computers (手腳): their own AnyBot looks and clicks there.
screens.extend(devices.iter().cloned());
if cfg!(target_os = "macos") {
// Browsers: the bot's own computer's Chromium, this computer's AnyBot browser, and the
// account's other computers' ones (each with its own logins).
let browsers: Vec<String> = computers
.iter()
.filter(|t| match t.place {
tools::Place::Box { .. } => crate::cloudbox::available(),
tools::Place::Local { .. } => crate::botbrowser::available(),
tools::Place::Device { .. } => true,
_ => false,
})
.map(|t| t.name.clone())
.collect();
let browser_default = if browsers.contains(&default_computer) { default_computer.clone() } else { roles.person.clone().filter(|p| browsers.contains(p)).or_else(|| browsers.first().cloned()).unwrap_or_default() };
tool_specs.extend(web::specs_for(&browsers, &browser_default));
// Screens: the bot's own desktop (when it has one), this computer's (when it has one), and
// the account's other computers (their own AnyBot looks and clicks there).
let screens: Vec<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::mcp::specs());
@ -741,7 +765,7 @@ impl App {
api_key: providers::resolve_key(b.provider, &ps.api_key),
model: b.model.clone(),
system: {
let mut sys = system_prompt(&b, &self.state, &computers, &default_computer);
let mut sys = system_prompt(&b, &self.state, &computers, &default_computer, &roles, missing_computer.as_deref(), &self.computer_lines(&computers, &roles), mac_apps);
if speaker.is_some() {
let names: Vec<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("、")));
@ -767,6 +791,7 @@ impl App {
req,
computers,
default_computer,
roles,
local_execution: self.local_on(),
local_locked: !self.managed.local.allows() || self.state.local_never,
tools_enabled: b.tools_enabled,
@ -790,73 +815,202 @@ impl App {
self.mark();
}
/// The computers a bot can be told to use (its own, this Mac, then SSH ones).
fn targets_for(&self, b: &Bot) -> Vec<tools::Target> {
let mut v = vec![
tools::Target {
name: crate::model::BOT_COMPUTER_NAME.into(),
place: if crate::cloudbox::available() { tools::Place::Box { dir: crate::cloudbox::bot_dir(b.id) } } else { tools::Place::Sealed { root: crate::model::bot_computer_dir(b.id) } },
},
tools::Target {
name: crate::model::THIS_COMPUTER_NAME.into(),
place: tools::Place::Local { workdir: tools::expand_home(if b.workdir.trim().is_empty() { "~" } else { b.workdir.trim() }) },
},
];
v.extend(self.state.remote_computers.iter().map(|c| tools::Target { name: c.name.clone(), place: tools::Place::Ssh { target: c.ssh_target.clone(), dir: c.dir.clone() } }));
// 裝置連結: other AnyBots of this account that allow it.
v.extend(self.linked_devices().into_iter().filter(|d| !v.iter().any(|t| t.name == d.name)).collect::<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();
}
}
/// The computers a bot can be told to use, each under its own name: the bot's own
/// (「Master 的電腦」), this computer, the SSH ones, then the account's other computers.
pub(super) fn targets_for(&self, b: &Bot) -> Vec<tools::Target> {
let mut v = vec![tools::Target {
name: crate::model::own_computer_name(&b.name),
place: if crate::cloudbox::available() { tools::Place::Box { dir: crate::cloudbox::bot_dir(b.id) } } else { tools::Place::Sealed { root: crate::model::bot_computer_dir(b.id) } },
}];
// Two computers never share a name (the bot and the person name them to choose).
let add = |v: &mut Vec<tools::Target>, mut t: tools::Target| {
let base = t.name.clone();
let mut n = 2;
while v.iter().any(|x| x.name == t.name) {
t.name = format!("{base} ({n})");
n += 1;
}
v.push(t);
};
add(&mut v, tools::Target { name: self.state.this_computer_name(), place: tools::Place::Local { workdir: tools::expand_home(if b.workdir.trim().is_empty() { "~" } else { b.workdir.trim() }) } });
for c in &self.state.remote_computers {
add(&mut v, tools::Target { name: c.name.clone(), place: tools::Place::Ssh { target: c.ssh_target.clone(), dir: c.dir.clone() } });
}
// 裝置連結: other AnyBots of this account that allow it.
for d in self.linked_devices() {
add(&mut v, d);
}
v
}
/// 查看電腦: the computers this bot really has, in plain words.
fn computers_text(&self, b: &Bot) -> String {
self.targets_for(b)
.iter()
.map(|t| match &t.place {
tools::Place::Sealed { .. } | tools::Place::Box { .. } => format!("{}:這個 Bot 自己的 Linux 桌面(AI 的電腦,不是使用者的電腦),一律可用", t.name),
tools::Place::Local { .. } if t.name == crate::model::BRAIN_HOST_NAME => format!("{}:AnyBot 大腦所在的伺服器「{}」(不是使用者的電腦)", t.name, self.state.remote.host_name),
tools::Place::Local { .. } => format!(
"{}:{}「{}」,{}",
t.name,
if cfg!(target_os = "macos") { "使用者的這台 Mac" } else { "使用者的這台電腦" },
self.state.machine_label,
if self.state.local_execution { "已允許" } else { "還沒預先允許(直接用工具,App 會問使用者)" }
/// Which of these computers is the bot's own, which one the person is using, and whether
/// this AnyBot is a server. The person's computer is the one they last typed on; a server
/// that hasn't heard from any yet takes the account's only other computer, if there is one.
pub(super) fn roles_for(&self, b: &Bot, targets: &[tools::Target]) -> tools::Roles {
let server = self.headless;
let local = targets.iter().find(|t| matches!(t.place, tools::Place::Local { .. })).map(|t| t.name.clone());
let device = |h: &str| targets.iter().find(|t| matches!(&t.place, tools::Place::Device { host } if host == h)).map(|t| t.name.clone());
let person = match self.typed_on.as_deref() {
Some(h) if !h.is_empty() => device(h).or_else(|| if server { None } else { local.clone() }),
_ if !server => local,
// Not heard yet (or only the phone): when the account has one other computer, that
// is it; with more, nobody guesses (the bot asks which one).
_ => {
let mut devices = targets.iter().filter(|t| matches!(t.place, tools::Place::Device { .. }));
match (devices.next(), devices.next()) {
(Some(only), None) => Some(only.name.clone()),
_ => None,
}
}
};
tools::Roles { own: crate::model::own_computer_name(&b.name), person, server }
}
/// The computer the person chose for this bot, by its name now (the bot's own is kept under a
/// fixed key so renaming the bot keeps it), and the chosen one when it isn't connected.
pub(super) fn chosen_computer(b: &Bot, targets: &[tools::Target], roles: &tools::Roles) -> (String, Option<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),
tools::Place::Device { host } => {
let real = crate::remote::peer_name(host);
if t.name == crate::model::THIS_COMPUTER_NAME {
format!("{}:使用者的電腦「{real}」(大腦透過它的 AnyBot 看畫面、點擊、開瀏覽器和跑指令)", t.name)
} else {
format!("{}:帳號裡的另一台電腦(裝置連結)", t.name)
None => (vec![(own.clone(), "own".into())], own),
};
}
let t = self.targets_for(b);
let r = self.roles_for(b, &t);
let (current, _) = Self::chosen_computer(b, &t, &r);
(t.iter().map(|x| (x.name.clone(), mirror_ui::computer_kind(x, &r).to_string())).collect(), current)
}
/// Choose the computer a bot works on (a screen sends it to the brain with its other edits).
pub(super) fn set_computer(&mut self, bot_id: u64, name: &str) {
if let Some(b) = self.state.bot_mut(bot_id) {
let keep = Self::computer_to_keep(b, name);
if b.default_computer != keep {
b.default_computer = keep;
self.mark();
}
}
// Shown at once on a screen (the brain's answer follows).
if let Some(c) = self.mir.computers.get_mut(&bot_id) {
c["current"] = serde_json::json!(name);
}
}
/// 查看電腦 and the instructions: each computer by its name, what it is, in plain words.
pub(super) fn computer_lines(&self, targets: &[tools::Target], roles: &tools::Roles) -> Vec<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<_>>()
.join("\n")
.collect()
}
/// 查看電腦: the computers this bot really has, in plain words.
fn computers_text(&self, b: &Bot) -> String {
let targets = self.targets_for(b);
let roles = self.roles_for(b, &targets);
let (chosen, missing) = Self::chosen_computer(b, &targets, &roles);
let mut lines = self.computer_lines(&targets, &roles);
lines.push(format!("使用者選的電腦:「{chosen}」"));
if let Some(m) = missing {
lines.push(format!("(使用者原本選的「{m}」現在沒有連線:那台的 AnyBot 沒開或沒登入。)"));
}
lines.join("\n")
}
/// update_bot with a computer: the names are this bot's computers (the account's ones too),
/// which only the app knows; the rest of the change goes through appctl as usual.
fn update_bot_computer(&mut self, bot_id: u64, args: &str) -> appctl::Outcome {
let mut v: serde_json::Value = serde_json::from_str(args).unwrap_or_default();
let want = v["default_computer"].as_str().unwrap_or("").trim().to_string();
let fail = |text: String| appctl::Outcome { text, ok: false, wake: vec![], theme_changed: false, page: None };
let target = match v["bot"].as_str().map(str::trim).filter(|n| !n.is_empty()) {
Some(n) => match self.state.bots.iter().find(|b| b.name == n) {
Some(b) => b.id,
None => return fail(format!("找不到叫「{n}」的 Bot")),
},
None => bot_id,
};
let Some(b) = self.state.bot(target).cloned() else { return fail("找不到這個 Bot".into()) };
let targets = self.targets_for(&b);
let roles = self.roles_for(&b, &targets);
let name = if want == crate::model::BOT_COMPUTER_NAME || want == crate::model::THIS_COMPUTER_NAME { Self::chosen_computer(&Bot { default_computer: want.clone(), ..b.clone() }, &targets, &roles).0 } else { want.clone() };
if !targets.iter().any(|t| t.name == name) {
return fail(format!("沒有「{want}」這台電腦。可以用:{}", targets.iter().map(|t| t.name.as_str()).collect::<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.
@ -977,6 +1131,8 @@ impl App {
let out = if call.name == "list_computers" {
let text = self.state.bot(bot_id).cloned().map(|b| self.computers_text(&b)).unwrap_or_default();
appctl::Outcome { text, ok: true, wake: vec![], theme_changed: false, page: None }
} else if call.name == "update_bot" && serde_json::from_str::<serde_json::Value>(&call.args).map(|v| v["default_computer"].is_string()).unwrap_or(false) {
self.update_bot_computer(bot_id, &call.args)
} else {
appctl::apply(&mut self.state, bot_id, &call.name, &call.args)
};
@ -1737,11 +1893,17 @@ impl App {
ui.close();
}
if menu_ui::item(ui, p, Icon::Eraser, "清除聊天記錄").clicked() {
self.stop(id);
if let Some(b) = self.state.bot_mut(id) {
b.messages.clear();
if self.mir.on {
// The chat lives on the brain: clearing only this copy left the brain sure this
// screen still had it, and its next changes landed in the wrong place.
self.mirror_act(serde_json::json!({"bot": id, "action": "clear"}));
} else {
self.stop(id);
if let Some(b) = self.state.bot_mut(id) {
b.messages.clear();
}
self.mark();
}
self.mark();
ui.close();
}
if menu_ui::danger(ui, p, Icon::Trash, "刪除").clicked() {
@ -2169,6 +2331,10 @@ impl App {
ui.label(RichText::new(format!("{}{}", t("成員:"), names.join("、"))).size(FONT_XS).color(p.text2));
}
});
// 在哪台電腦做事: always in sight, one click to change.
if bot.members.is_empty() {
self.computer_chip(ui, bot);
}
let right_w = 28.0 * 2.0 + 8.0 + 4.0;
let text_w = (ui.available_width() - right_w).max(60.0);
let hint = if self.mir.on && !self.mir.online { t("連不到大腦,現在只能看") } else { t("傳訊息給 {name}").replace("{name}", &bot.name) };
@ -2437,6 +2603,38 @@ impl App {
}
}
/// The computer the bot works on, as a small button next to the message box; it opens the
/// list of computers (each by its name) to choose another.
fn computer_chip(&mut self, ui: &mut egui::Ui, bot: &Bot) {
let p = self.pal;
let (choices, current) = self.computer_choices(bot);
let short: String = if current.chars().count() > 14 { format!("{}…", current.chars().take(13).collect::<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) {
let p = self.pal;
let label = format!("{} · {}", short_provider(bot.provider), bot.model);
@ -2583,15 +2781,21 @@ impl App {
ui.checkbox(&mut b.tools_enabled, t("讓這個 Bot 查看、修改檔案和執行指令"));
if b.tools_enabled {
ui.add_space(6.0);
form_row(ui, "預設電腦", p, |ui| {
let names = self.state.computer_names();
egui::ComboBox::from_id_salt("default_computer").selected_text(b.default_computer.clone()).width(150.0).show_ui(ui, |ui| {
for n in names {
let label = if n == crate::model::THIS_COMPUTER_NAME && !self.state.local_execution { format!("{n}(要先在設定裡允許)") } else { n.clone() };
ui.selectable_value(&mut b.default_computer, n, label);
form_row(ui, "做事的電腦", p, |ui| {
let (choices, current) = self.computer_choices(&b);
let mut pick = None;
egui::ComboBox::from_id_salt("default_computer").selected_text(current.clone()).width(190.0).show_ui(ui, |ui| {
for (n, kind) in choices {
let label = if kind == "this" && !self.state.local_execution { format!("{n}(要先在設定裡允許)") } else { n.clone() };
if ui.selectable_label(n == current, label).clicked() {
pick = Some(n);
}
}
});
ui.label(RichText::new(t("聊天時說「在我的 Mac 上」就會換")).size(11.5).color(p.text3));
if let Some(n) = pick {
b.default_computer = Self::computer_to_keep(&b, &n);
}
ui.label(RichText::new(t("聊天時指名另一台(例如「在 Mac mini 上」)就會換")).size(11.5).color(p.text3));
});
form_row(ui, "起始資料夾", p, |ui| {
ui.add(egui::TextEdit::singleline(&mut b.workdir).hint_text(t("在你的電腦上,例如 ~/Documents/專案")).desired_width(ui.available_width()));
@ -3680,7 +3884,8 @@ fn transcript_markdown(b: &Bot) -> String {
}
/// What the bot is told about itself, the app it lives in and the computers it can use.
fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_computer: &str) -> String {
#[allow(clippy::too_many_arguments)]
fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_computer: &str, roles: &tools::Roles, missing: Option<&str>, lines: &[String], mac_apps: bool) -> String {
let mut s = format!("你是「{}」。", b.name);
if !b.description.trim().is_empty() {
s.push_str(&format!("你的角色:{}。", b.description.trim()));
@ -3689,24 +3894,17 @@ fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_compu
s.push_str("你是使用者的主要 Bot:主要聯絡窗口,可以主動幫忙,也能用 message_bot 把工作交給其他 Bot。");
}
let now = crate::routines::now_in(&st.timezone);
// Which screens can really be operated, named as the model must name them: the fixed
// wording promised "你的電腦(使用者的 Mac)" on server brains that have no such computer,
// and bots then "worked on your computer" on the server itself.
// Which screens can really be operated, each by its computer's name.
let mut screens: Vec<String> = Vec::new();
for t in computers {
match &t.place {
tools::Place::Box { .. } => screens.push(format!("{}(AI 自己的 Linux 桌面,不是使用者的電腦)", t.name)),
tools::Place::Local { .. } => {
if crate::computer::screen_supported() {
screens.push(format!("{}(這台電腦自己的畫面)", t.name));
}
}
tools::Place::Device { host } if t.name == crate::model::THIS_COMPUTER_NAME => screens.push(format!("{}(使用者自己的電腦「{}」的畫面)", t.name, crate::remote::peer_name(host))),
tools::Place::Device { .. } => screens.push(format!("{}(帳號裡另一台電腦的畫面)", t.name)),
tools::Place::Ssh { .. } | tools::Place::Sealed { .. } => {}
tools::Place::Box { .. } if crate::cloudbox::available() => screens.push(format!("「{}」", t.name)),
tools::Place::Local { .. } if crate::computer::screen_supported() => screens.push(format!("「{}」", t.name)),
tools::Place::Device { .. } => screens.push(format!("「{}」", t.name)),
_ => {}
}
}
let screens = if screens.is_empty() { "目前沒有任何可操作的螢幕".to_string() } else { screens.join(";") };
let screens = if screens.is_empty() { "目前沒有任何可操作的螢幕".to_string() } else { format!("可操作的螢幕:{}", screens.join("、")) };
s.push_str(&format!(
"\n\n現在是 {}({},時區 {})。\n\
你在 AnyBot 這個 App 裡,可以直接操作 App:\n\
@ -3723,11 +3921,12 @@ fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_compu
- 如果讀到的是登入頁,請使用者在跳出來的 AnyBot 瀏覽器視窗登入一次(之後會記住),登入好再讀。絕對不要叫使用者去改瀏覽器的設定或選單(例如「允許來自 Apple 事件的 JavaScript」),那不需要。也不要說要從 Chrome 複製登入或 Cookie,沒有這個功能。\n\
- AnyBot 瀏覽器有設定檔(profile),每個有自己的登入。使用者說要換 profile/設定檔/另一個帳號時,用 browser_profiles:list 看有哪些,use 切換,add 新增(新的要登入一次)。也可以在 browser_open / browser_read 帶 profile 參數。使用者想用自己平常的 Chrome(自己的 Chrome 設定檔、已經登入的帳號)時,用 profile「你的瀏覽器」:請使用者在 Chrome 切到想要的設定檔,再 browser_read。\n\
- 你也能像人一樣操作螢幕({screens})。computer_use_screenshot 看畫面,再用 computer_use_click / computer_use_typing / computer_use_press_key / computer_use_scroll / computer_use_drag 操作;座標用最新截圖上的像素。每做一步就再截圖確認。用在檔案和網頁工具做不到的事(例如操作其他 App、在 Chrome 切換設定檔)。\n\
- 使用者 Mac 上的「提醒事項、行事曆、聯絡人、備忘錄、郵件、訊息」有專門的工具(ios_read_reminders、mobile_read_calendar、mobile_read_contacts、mac_read_notes、mac_read_mail 等),比操作畫面快又準;寄信或傳訊息每次都會先請使用者確認,起草郵件(mac_draft_mail)則讓使用者自己按寄出。\n\
{}\
- 不要說「我沒有瀏覽器或工具」:先試這些工具。需要權限時 App 會自己跳出視窗問使用者;工具回報要開什麼設定時,照實轉告。",
now.format("%Y-%m-%d %H:%M"),
["週一", "週二", "週三", "週四", "週五", "週六", "週日"][now.weekday().num_days_from_monday() as usize],
st.timezone
st.timezone,
if mac_apps { " - 使用者 Mac 上的「提醒事項、行事曆、聯絡人、備忘錄、郵件、訊息」有專門的工具(ios_read_reminders、mobile_read_calendar、mobile_read_contacts、mac_read_notes、mac_read_mail 等),比操作畫面快又準,會在使用者正在用的那台 Mac 上做;寄信或傳訊息每次都會先請使用者確認,起草郵件(mac_draft_mail)則讓使用者自己按寄出。\n" } else { "" }
));
if !b.routines.is_empty() {
s.push_str("\n\n你目前的例行任務:\n");
@ -3740,47 +3939,29 @@ fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_compu
s.push_str(&mine.join("\n"));
}
if !computers.is_empty() {
let list: Vec<String> = computers
.iter()
.map(|t| match &t.place {
tools::Place::Sealed { root } => format!("- {}:你自己的電腦(獨立資料夾 {},看不到使用者的檔案;沒有螢幕)", t.name, root.display()),
tools::Place::Box { dir } => {
let secrets = crate::cloudbox::secret_names();
format!(
"- {}:你自己的 Linux 電腦(Debian,有桌面和 Chromium,所有 Bot 共用;你的資料夾是 {dir},可以 sudo 安裝套件)。需要時會自動喚醒。可以用 computer_use_* 工具(computer=「{}」)看它的螢幕和操作滑鼠鍵盤,例如開 chromium 上網、測網頁。{}",
t.name,
t.name,
if secrets.is_empty() { String::new() } else { format!("使用者設好的環境密鑰(已是環境變數,值不要印出來):{}。", secrets.join("、")) }
)
}
tools::Place::Local { workdir } => format!(
"- {}:{},起始資料夾 {}{}",
t.name,
if t.name == crate::model::BRAIN_HOST_NAME { "你(大腦)所在的伺服器,不是使用者的電腦;只有使用者明確要在伺服器上做事才用" } else if cfg!(target_os = "macos") { "使用者的這台 Mac" } else if crate::platform::has_desktop() { "使用者的這台 Linux 電腦(快捷鍵用 Ctrl,不是 ⌘)" } else { "AnyBot 所在的伺服器(不是使用者的 Mac;要用使用者的 Mac 請選帳號裡那台電腦的名字)" },
workdir.display(),
if st.local_execution { "" } else { "(還沒預先允許:直接使用工具即可,App 會跳出視窗問使用者)" }
),
tools::Place::Ssh { target, .. } => format!("- {}:使用者的另一台電腦(SSH {target})", t.name),
tools::Place::Device { host } if t.name == crate::model::THIS_COMPUTER_NAME => format!(
"- {}:使用者自己的電腦「{}」(你在伺服器上,透過它的 AnyBot 操作它:檔案、指令、瀏覽器和 computer_use_* 工具,computer=「{}」)。使用者說「我的電腦」「我的筆電」「這台」或提到 Mac,都是指它。",
t.name,
crate::remote::peer_name(host),
t.name
),
tools::Place::Device { .. } => format!("- {}:使用者帳號裡的另一台電腦(裝置連結;可以用檔案、指令和 computer_use_* 工具,computer=「{}」)", t.name, t.name),
})
.collect();
let list: Vec<String> = lines.iter().map(|l| format!("- {l}")).collect();
let mine = match &roles.person {
Some(p) => format!("使用者說「我的電腦」「這台」「我的 Mac」時,指的是「{p}」(他正在用的那台)。"),
None => "現在不知道使用者在用哪台電腦:他說「我的電腦」「這台」時,先問他是哪一台。".to_string(),
};
let gone = match missing {
Some(m) => format!("\n- 使用者選的「{m}」現在沒有連線(那台的 AnyBot 沒開或沒登入)。要在那台做事時,告訴使用者先打開它;要改用別台,先問使用者。"),
None => String::new(),
};
s.push_str(&format!(
"\n\n你可以用工具在電腦上做事。每個工具都有 computer 參數,預設是「{default_computer}」。\n{}\n\
- 使用者替你選的電腦是「{default_computer}」:檔案、指令、看畫面、點擊和瀏覽器都在這台做,不要自己換到別台。只有使用者在訊息裡指名另一台(「在我的 Mac 上」「在 Bot 的電腦上」),才把 computer 設成那台。\n\
- 不確定使用者指的是哪台電腦時(例如他說「我的電腦」而那台不在清單上、有幾台都說得通、或他的說法和你手上的預設不一致),先用一句話問他要哪台,問清楚再動手;不要擅作主張選一台就開始做,也不要把一台說成另一台。\n\
"\n\n你可以用工具在電腦上做事。每台電腦都用它自己的名字稱呼,工具的 computer 參數就填那個名字:\n{}\n\
- 使用者替你選的電腦是「{default_computer}」:檔案、指令、看畫面、點擊和瀏覽器都在這台做,不要自己換到別台。只有使用者在訊息裡指名另一台,才把 computer 設成那台。{gone}\n\
- 「{own}」是你自己的電腦;使用者說「你的電腦」「Bot 的電腦」指的就是它。{mine}\n\
- 回覆使用者時,說到電腦一律用上面的名字(例如「我在「{own}」打開了 Threads」),不要說「你的電腦」「Bot 的電腦」這種會混淆的說法。\n\
- 不確定使用者指的是哪台電腦時(他說的那台不在清單上、有幾台都說得通、或他的說法和你手上的預設不一致),先用一句話問他要哪台,問清楚再動手;不要擅作主張選一台就開始做,也不要把一台說成另一台。\n\
- 先用 list_dir、search、read_file 看清楚,再動手。\n\
- 需要權限時(電腦工具還沒打開、要用使用者的電腦、會改變東西的操作),直接呼叫工具,App 會自己跳出視窗請使用者允許;不要叫使用者自己去改設定。被拒絕就換個方式或問使用者。\n\
- 跑很久的指令會移到背景,用 read_terminal 追蹤,不要重複執行;它在等輸入時(例如問 y/n),用 write_terminal 回答。\n\
- 只有工具結果證明做完了,才說做完;失敗就照實說。\n\
- 連續執行多步驟工具時不要每步都發訊息(先說「好,我先…」,做完一個工具播一段,最後再總結——使用者打一句就看到三四則回覆)。執行時就安靜地連續呼叫工具,全部做完後用一段話講結果;只有卡住、需要使用者動手(例如要他輸入密碼)、或要先問清哪台電腦時,才中途說話。\n\
- 回覆使用者時,用簡單的話說明你做了什麼、在哪台電腦、結果如何。",
list.join("\n")
list.join("\n"),
own = roles.own,
));
}
let chans: Vec<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 last = bot.messages.iter().rev().find(|m| m.role == Role::Assistant);
let running = last.and_then(|m| m.tool_calls.iter().rev().find(|c| matches!(c.status, ToolStatus::Running | ToolStatus::Waiting)));
let on_box = running.map(|c| tools::args_of(&c.args)["computer"].as_str().map(|n| n == crate::model::BOT_COMPUTER_NAME).unwrap_or(true)).unwrap_or(false);
// Where it works, by the computer's name (its own computer is 「<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 seed = live.turn_id.bytes().fold(0u64, |a, b| a.wrapping_mul(31).wrapping_add(b as u64));
let phrase = live.status.clone().unwrap_or_else(|| t(&status_phrase(running.map(|c| c.name.as_str()), on_box, waiting, has_text, seed)));
let phrase = live.status.clone().unwrap_or_else(|| {
let ph = status_phrase(running.map(|c| c.name.as_str()), on_box, waiting, has_text, seed);
match &named {
Some(n) if ph == "正在你的電腦上工作" => t("正在「{name}」上工作").replace("{name}", n),
_ => t(&ph),
}
});
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.add_space(6.0);

View File

@ -9,6 +9,8 @@ use crate::cloudbox::{self, Rebuild, Run};
/// How often the viewer asks the box for a new picture (estimated).
const VIEWER_FRAME_MS: u64 = 450;
/// The brain's computer comes over the account server: a picture about every 0.7 s.
const REMOTE_FRAME_MS: u64 = 700;
/// The floating bar stays this long after the bot's last mouse/keyboard action on this Mac.
const LOCAL_BAR_SECS: u64 = 8;
@ -61,6 +63,8 @@ pub struct ComputerUi {
thumb_img: Arc<Mutex<Option<egui::ColorImage>>>,
thumb_busy: Arc<AtomicBool>,
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 {
@ -356,7 +360,13 @@ impl App {
sec_title(ui, "Bot 的電腦", p, |_| {});
sec_card(ui, p, |ui| {
let name = self.state.remote.brain_name.clone();
sec_row(ui, p, "在大腦上", &format!("Bot 用的電腦在大腦「{name}」上,要在那台管理。這台電腦是大腦的手腳:上面的權限決定 Bot 能不能看這台的畫面、操作這台。"), |_| {});
let mut view = false;
sec_row(ui, p, "在大腦上", &format!("每個 Bot 自己的電腦(例如「Master 的電腦」)在大腦「{name}」上,要在那台管理;這裡可以看它的畫面。這台電腦是大腦的手腳:上面的權限決定 Bot 能不能看這台的畫面、操作這台。"), |ui| {
view = ui.add(primary_button("開啟電腦", p)).clicked();
});
if view {
self.open_viewer();
}
});
} else {
sec_title(ui, "Bot 的電腦", p, |ui| status_pill(ui, &run, p));
@ -919,12 +929,19 @@ impl App {
None => self.cu.thumb = Some(ctx.load_texture("box-thumb", img, egui::TextureOptions::LINEAR)),
}
}
let every = if cloudbox::run_state().is_running() { 3 } else { 30 };
// On a screen of a brain the bots' computer is the brain's: its picture comes from there.
let brain = self.mir.on.then(|| self.state.remote.clone());
let running = if brain.is_some() { self.mir.box_state["running"].as_bool().unwrap_or(false) } else { cloudbox::run_state().is_running() };
let every = if running { 3 } else { 30 };
if !self.cu.thumb_at.map(|t| t.elapsed().as_secs() < every).unwrap_or(false) && !self.cu.thumb_busy.swap(true, Ordering::SeqCst) {
self.cu.thumb_at = Some(Instant::now());
let (slot, busy, c) = (self.cu.thumb_img.clone(), self.cu.thumb_busy.clone(), ctx.clone());
std::thread::spawn(move || {
if let Some(img) = cloudbox::thumbnail(320) {
let img = match &brain {
Some(s) => crate::remote::sync_screen(s, true).ok().and_then(|b| image::load_from_memory(&b).ok()).map(|i| i.to_rgba8()),
None => cloudbox::thumbnail(320),
};
if let Some(img) = img {
let size = [img.width() as usize, img.height() as usize];
*slot.lock().unwrap() = Some(egui::ColorImage::from_rgba_unmultiplied(size, img.as_raw()));
c.request_repaint();
@ -939,8 +956,28 @@ impl App {
pub(super) fn open_viewer(&mut self) {
self.cu.viewer_open = true;
self.cu.fit = true;
cloudbox::set_viewer_open(true);
self.show_settings = false;
// A screen of a brain watches the brain's computer (where its bots really work).
if self.mir.on {
let s = self.state.remote.clone();
self.cu.remote = Some(s.clone());
if let Some(stop) = self.cu.worker.take() {
stop.store(true, Ordering::SeqCst);
}
self.cu.texture = None;
std::thread::spawn(move || {
let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "viewer", "on": true}));
let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "wake"}));
});
return;
}
if self.cu.remote.take().is_some() {
if let Some(stop) = self.cu.worker.take() {
stop.store(true, Ordering::SeqCst);
}
self.cu.texture = None;
}
cloudbox::set_viewer_open(true);
if !cloudbox::run_state().is_running() && !self.demo {
self.box_task(|| cloudbox::ensure_running().map(|_| String::new()));
}
@ -949,8 +986,15 @@ impl App {
fn close_viewer(&mut self) {
self.cu.viewer_open = false;
self.cu.control = false;
cloudbox::HELD_BY_PERSON.store(false, Ordering::SeqCst);
cloudbox::set_viewer_open(false);
if let Some(s) = self.cu.remote.clone() {
std::thread::spawn(move || {
let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "hold", "on": false}));
let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "viewer", "on": false}));
});
} else {
cloudbox::HELD_BY_PERSON.store(false, Ordering::SeqCst);
cloudbox::set_viewer_open(false);
}
if let Some(stop) = self.cu.worker.take() {
stop.store(true, Ordering::SeqCst);
}
@ -966,6 +1010,54 @@ impl App {
let slot = self.cu.frame.clone();
let c = ctx.clone();
let s2 = stop.clone();
let brain = self.cu.remote.clone();
if let Some(s) = brain.clone() {
// The brain's computer: pictures and the person's input go through the account server.
let s_frames = s.clone();
std::thread::spawn(move || {
let s = s_frames;
let mut seq = 0u64;
while !s2.load(Ordering::SeqCst) {
match crate::remote::sync_screen(&s, false).ok().and_then(|b| image::load_from_memory(&b).ok()) {
Some(img) => {
let img = img.to_rgba8();
seq += 1;
let size = [img.width() as usize, img.height() as usize];
*slot.lock().unwrap() = Some((seq, egui::ColorImage::from_rgba_unmultiplied(size, img.as_raw())));
c.request_repaint();
std::thread::sleep(std::time::Duration::from_millis(REMOTE_FRAME_MS));
}
None => {
std::thread::sleep(std::time::Duration::from_millis(1500));
c.request_repaint();
}
}
}
});
let (tx, rx) = channel::<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 || {
let mut seq = 0u64;
while !s2.load(Ordering::SeqCst) {
@ -1014,13 +1106,25 @@ impl App {
}
}
}
let run = cloudbox::run_state();
let brain = self.cu.remote.is_some();
// The brain's computer reports its state with the brain's answers.
let run = if brain {
match (self.mir.box_state["running"].as_bool().unwrap_or(false), self.mir.box_state["available"].as_bool().unwrap_or(true)) {
(true, _) => Run::Running,
(false, false) => Run::NoDocker("大腦那台沒有 Docker".into()),
_ => Run::Asleep,
}
} else {
cloudbox::run_state()
};
let screen = ctx.content_rect();
let open = true;
let default = vec2((screen.width() * 0.62).min(900.0), (screen.width() * 0.62).min(900.0) * 0.625 + 52.0);
let busy = cloudbox::is_busy();
let busy = if brain { self.mir.box_state["busy"].as_bool().unwrap_or(false) } else { cloudbox::is_busy() };
let title = self.state.bot(self.state.selected).map(|b| crate::model::own_computer_name(&b.name)).unwrap_or_else(|| crate::model::BOT_COMPUTER_NAME.to_string());
let title = if brain { format!("{title}(在大腦「{}」上)", self.state.remote.brain_name) } else { title };
let mut close_clicked = false;
egui::Window::new("Bot 的電腦")
egui::Window::new("box-viewer")
.id(egui::Id::unique("box-viewer"))
.title_bar(false)
.default_size(default)
@ -1039,7 +1143,7 @@ impl App {
.show(ctx, |ui| {
ui.horizontal(|ui| {
monitor_icon(ui, 20.0, p.text);
ui.label(RichText::new(crate::model::BOT_COMPUTER_NAME).strong().size(FONT_LG));
ui.label(RichText::new(&title).strong().size(FONT_LG));
status_pill(ui, &run, p);
if busy && !self.cu.control {
crate::icons::label(ui, crate::icons::Icon::Bot, &t("Bot 正在操作"), FONT_XS, p.accent);
@ -1060,7 +1164,7 @@ impl App {
close_clicked = true;
}
ui.add_space(4.0);
if ui.add(secondary_button("全螢幕")).on_hover_text(t("在瀏覽器用 noVNC 全螢幕開啟")).clicked() {
if !brain && ui.add(secondary_button("全螢幕")).on_hover_text(t("在瀏覽器用 noVNC 全螢幕開啟")).clicked() {
let control = self.cu.control;
self.box_task(move || {
let url = cloudbox::viewer_url(control)?;
@ -1076,7 +1180,15 @@ impl App {
let btn = egui::Button::new(RichText::new(label).strong().color(col)).fill(fill).corner_radius(8).min_size(vec2(96.0, 30.0));
if ui.add_enabled(run.is_running(), btn).on_hover_text(if self.cu.control { "Bot 可以繼續操作" } else { "你自己點擊和輸入;Bot 的操作會先暫停" }).clicked() {
self.cu.control = !self.cu.control;
cloudbox::HELD_BY_PERSON.store(self.cu.control, Ordering::SeqCst);
match self.cu.remote.clone() {
Some(s) => {
let on = self.cu.control;
std::thread::spawn(move || {
let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "hold", "on": on}));
});
}
None => cloudbox::HELD_BY_PERSON.store(self.cu.control, Ordering::SeqCst),
}
}
});
});
@ -1114,7 +1226,14 @@ impl App {
if matches!(run, Run::Asleep | Run::Absent) {
let b = egui::Rect::from_center_size(rect.center() + vec2(0.0, 34.0), vec2(90.0, 30.0));
if ui.put(b, primary_button("喚醒", p)).clicked() {
self.box_task(|| cloudbox::ensure_running().map(|_| String::new()));
match self.cu.remote.clone() {
Some(s) => {
std::thread::spawn(move || {
let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "wake"}));
});
}
None => self.box_task(|| cloudbox::ensure_running().map(|_| String::new())),
}
}
}
}

View File

@ -393,6 +393,27 @@ pub struct Mirror {
fetching: std::collections::HashSet<String>,
/// 加一台電腦: 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>)>>,
/// 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.
@ -516,6 +537,23 @@ impl App {
json!({"bot": id, "call_id": a.call_id, "tool": a.tool, "reason": a.reason, "grant": a.grant, "kind": kind})
})
.collect();
// The bots' computer lives here; a screen shows its state, not its own computer's.
let run = crate::cloudbox::run_state();
out["box"] = json!({"label": run.label(), "running": run.is_running(), "available": crate::cloudbox::available(), "busy": crate::cloudbox::is_busy()});
// Which computers each bot has (only the brain knows the account's other computers).
out["computers"] = self
.state
.bots
.iter()
.map(|b| {
let t = self.targets_for(b);
let r = self.roles_for(b, &t);
let (current, missing) = Self::chosen_computer(b, &t, &r);
let names: Vec<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
}
@ -932,6 +970,14 @@ impl App {
let mut msgs: Vec<Message> = serde_json::from_value(d["messages"].clone()).unwrap_or_default();
self.mirror_files(&mut msgs);
if let Some(b) = self.state.bot_mut(id) {
// The brain sends what changed from message `from` on, sure this copy has the
// ones before. A copy that is shorter (cleared here, or an answer lost on the
// way) would get them appended in the wrong place — the same message twice and
// half-written replies piling up. Ask for the whole chat again instead.
if b.messages.len() < from {
self.mir.resync.store(true, Ordering::SeqCst);
continue;
}
b.messages.truncate(from);
b.messages.extend(msgs);
}
@ -982,6 +1028,12 @@ impl App {
}
}
}
if v["box"].is_object() {
self.mir.box_state = v["box"].clone();
}
if let Some(c) = v["computers"].as_object() {
self.mir.computers = c.iter().filter_map(|(k, x)| Some((k.parse().ok()?, x.clone()))).collect();
}
let keep: Vec<u64> = v["approvals"].as_array().into_iter().flatten().filter_map(|a| a["bot"].as_u64()).collect();
self.approvals.retain(|id, _| keep.contains(id));
if self.state.bot(self.state.selected).is_none() {

View File

@ -159,16 +159,29 @@ impl App {
// 電腦: the bot's own computer with a live picture of its screen (like the original).
panel_title(ui, "電腦", p);
let mut view = false;
let thumb = if crate::cloudbox::available() { self.box_thumbnail(ctx) } else { None };
// On a screen of a brain, the bot's computer is on the brain: its state and picture come
// from there (this computer's own box is a different machine).
let brain = self.mir.on;
let available = if brain { self.mir.box_state["available"].as_bool().unwrap_or(true) } else { crate::cloudbox::available() };
let thumb = if available { self.box_thumbnail(ctx) } else { None };
let own_name = crate::model::own_computer_name(&bot.name);
let brain_name = self.state.remote.brain_name.clone();
panel_card(ui, p, |ui| {
let run = crate::cloudbox::run_state();
let running = run.is_running();
let state = if crate::cloudbox::available() { t(&run.label()) } else { t("沒有 Docker") };
let running = if brain { self.mir.box_state["running"].as_bool().unwrap_or(false) } else { run.is_running() };
let state = if brain {
let label = self.mir.box_state["label"].as_str().map(t).unwrap_or_else(|| t("連線中…"));
format!("{label} · {}", t("在大腦「{name}」上").replace("{name}", &brain_name))
} else if available {
t(&run.label())
} else {
t("沒有 Docker")
};
let (rect, r) = ui.allocate_exact_size(vec2(ui.available_width(), 64.0), egui::Sense::click());
if r.hovered() {
ui.painter().rect_filled(rect, 9, p.hover);
}
ui.painter().text(rect.left_top() + vec2(10.0, 15.0), egui::Align2::LEFT_TOP, t("Bot 的電腦"), egui::FontId::proportional(13.0), p.text);
ui.painter().text(rect.left_top() + vec2(10.0, 15.0), egui::Align2::LEFT_TOP, &own_name, egui::FontId::proportional(13.0), p.text);
ui.painter().text(rect.left_top() + vec2(10.0, 33.0), egui::Align2::LEFT_TOP, state, egui::FontId::proportional(12.0), p.text3);
// The screen: 16:10, rounded, the real desktop when there is a picture.
let box_r = egui::Rect::from_min_size(rect.right_top() + vec2(-86.0, 8.0), vec2(78.0, 48.0));
@ -192,11 +205,11 @@ impl App {
if running {
ui.painter().circle_filled(box_r.right_top() + vec2(-6.0, 6.0), 3.5, Color32::from_rgb(0x14, 0xc3, 0x70));
}
let tip = if running { "看 Bot 的電腦的螢幕,也可以取得控制權自己操作" } else { "叫醒 Bot 的電腦並看它的螢幕" };
view = r.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(t(tip)).clicked();
let tip = if running { t("看「{name}」的螢幕,也可以取得控制權自己操作") } else { t("叫醒「{name}」並看它的螢幕") }.replace("{name}", &own_name);
view = r.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(tip).clicked();
});
if view {
if crate::cloudbox::available() {
if available {
self.open_viewer();
} else {
self.show_settings = true;

View File

@ -258,10 +258,16 @@ impl App {
fn remote_cmd(&mut self, ctx: &egui::Context, c: Cmd) {
match c {
Cmd::Send { bot, text, attachments } => {
Cmd::Send { bot, text, attachments, from } => {
if self.state.bot(bot).is_none() {
return;
}
// Typed on one of the account's computers: that one is the person's now (for
// 「我的電腦」 and the model on their network). The phone isn't a computer.
if !from.is_empty() {
remote::set_person_host(&from);
self.typed_on = Some(from);
}
// Only files the app itself stored in its attachments folder may ride along;
// resolve first, so "../../" paths cannot escape it (the phone can name any path).
let root = crate::model::State::path().parent().map(|p| p.join("attachments")).and_then(|p| p.canonicalize().ok());
@ -327,6 +333,13 @@ impl App {
}
}
}
Cmd::Clear { bot } => {
self.stop(bot);
if let Some(b) = self.state.bot_mut(bot) {
b.messages.clear();
}
self.mark();
}
Cmd::NewThread { bot } => {
self.stop(bot);
if let Some(b) = self.state.bot_mut(bot) {
@ -743,6 +756,11 @@ impl App {
.filter(|(id, _, online)| !id.is_empty() && *online && *id != self.state.remote.host_id)
// A brain with hands of its own works on those, not on the account's other brains.
.filter(|(id, _, _)| !has_hands || remote::is_hands(id))
// A computer showing another brain lends its hands to that one, not to this.
.filter(|(id, _, _)| {
let uses = remote::peer_uses(id);
uses.is_empty() || uses == self.state.remote.host_id
})
.map(|(id, name, _)| tools::Target { name: name.clone(), place: tools::Place::Device { host: id.clone() } })
.collect()
}

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 }
}
"list_computers" => {
// On a server brain (serve), Local is the server, not the person's computer —
// matching what the app's own list_computers says after its rename.
// (The window answers this with the account's computers too; this is the plain list.)
let serving = SERVING.load(std::sync::atomic::Ordering::Relaxed);
let own = state.bot(bot_id).map(|b| model::own_computer_name(&b.name)).unwrap_or_else(|| model::BOT_COMPUTER_NAME.into());
let here = state.this_computer_name();
let mut lines = vec![
format!("{}:{}", model::BOT_COMPUTER_NAME, if serving { "這個 Bot 自己的 Linux 電腦(AI 的電腦,不是使用者的電腦,獨立資料夾),一律可用" } else { "你自己的電腦(獨立的資料夾),一律可用" }),
format!("「{own}」:你自己的電腦(AI 的電腦,不是使用者的電腦,獨立資料夾),一律可用"),
if serving {
format!("{}:AnyBot 大腦所在的伺服器(不是使用者的電腦)。使用者的電腦要在它上面裝 AnyBot 並登入同一帳號,才會以「{}」出現", model::BRAIN_HOST_NAME, model::THIS_COMPUTER_NAME)
format!("「{here}」:AnyBot 大腦所在的伺服器(不是使用者在用的電腦)")
} else {
format!("{}:{},{}", model::THIS_COMPUTER_NAME, if cfg!(target_os = "macos") { "這台 Mac" } else { "這台電腦" }, if state.local_execution { "已允許" } else { "使用者還沒在設定裡允許(設定 → 電腦 → 在這台電腦上執行)" })
format!("「{here}」:AnyBot 開著的這台{},{}", if cfg!(target_os = "macos") { " Mac" } else { "電腦" }, if state.local_execution { "已允許" } else { "使用者還沒在設定裡允許(設定 → 電腦 → 在這台電腦上執行)" })
},
];
lines.extend(state.remote_computers.iter().map(|c| format!("{}:透過 SSH 連到 {}", c.name, c.ssh_target)));
lines.extend(state.remote_computers.iter().map(|c| format!("「{}」:透過 SSH 連到 {}", c.name, c.ssh_target)));
ok(lines.join("\n"))
}
"update_settings" => {

View File

@ -159,7 +159,7 @@ pub fn for_model(atts: &[Attachment]) -> (String, Vec<String>) {
let mut text = String::new();
let mut images = vec![];
for a in atts {
let where_ = if a.on_box.is_empty() { format!("檔案在使用者的 Mac:{}", a.path) } else { format!("檔案在使用者的 Mac:{};也已放進 Bot 的電腦:{}", a.path, a.on_box) };
let where_ = if a.on_box.is_empty() { format!("檔案存在 AnyBot 所在的電腦:{}", a.path) } else { format!("檔案存在 AnyBot 所在的電腦:{};也已放進你自己的電腦:{}", a.path, a.on_box) };
if a.image {
match image_b64(&a.path) {
Some(b) => {

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

View File

@ -625,7 +625,7 @@ pub fn browser_port() -> Result<u16, String> {
}
std::thread::sleep(Duration::from_millis(300));
}
Err("Bot 的電腦的瀏覽器沒有及時打開,請再試一次。".into())
Err("這台電腦的瀏覽器沒有及時打開,請再試一次。".into())
}
static START_LOCK: Mutex<()> = Mutex::new(());
@ -1248,7 +1248,7 @@ pub fn computer_run(name: &str, v: &Value) -> Result<(String, Option<String>), S
ensure_running()?;
let _busy = Busy::new(true);
if HELD_BY_PERSON.load(Ordering::SeqCst) && name != "computer_use_screenshot" && name != "computer_use_check_permissions" {
return Err("使用者正在自己操作 Bot 的電腦(已取得控制權)。請等他交還控制權後再操作,或先截圖看看畫面。".into());
return Err("使用者正在自己操作這台電腦(已取得控制權)。請等他交還控制權後再操作,或先截圖看看畫面。".into());
}
let scale = || shared().lock().unwrap().scale.ok_or_else(|| "請先用 computer_use_screenshot 看一下畫面,座標要用截圖上的位置。".to_string());
let pt = |v: &Value, s: (f64, f64)| -> Result<(String, String), String> {
@ -1275,7 +1275,7 @@ pub fn computer_run(name: &str, v: &Value) -> Result<(String, Option<String>), S
r
};
match name {
"computer_use_check_permissions" => Ok(("Bot 的電腦不需要另外的權限:可以截圖,也可以操作滑鼠和鍵盤。".into(), None)),
"computer_use_check_permissions" => Ok(("這台電腦不需要另外的權限:可以截圖,也可以操作滑鼠和鍵盤。".into(), None)),
"computer_use_screenshot" => {
let img = grab()?;
let (w, h) = img.dimensions();
@ -1283,7 +1283,7 @@ pub fn computer_run(name: &str, v: &Value) -> Result<(String, Option<String>), S
let img = if resize { image::imageops::resize(&img, fw, fh, image::imageops::FilterType::Triangle) } else { img };
let jpeg = crate::computer::encode_jpeg(&img).ok_or("截圖太大了,沒辦法送出")?;
shared().lock().unwrap().scale = Some((w as f64 / fw.max(1) as f64, h as f64 / fh.max(1) as f64));
Ok((format!("Bot 的電腦的螢幕截圖({fw}×{fh},Linux 桌面)。之後的座標請用這張圖上的像素位置。"), Some(base64::engine::general_purpose::STANDARD.encode(jpeg))))
Ok((format!("螢幕截圖({fw}×{fh},Linux 桌面)。之後的座標請用這張圖上的像素位置。"), Some(base64::engine::general_purpose::STANDARD.encode(jpeg))))
}
"computer_use_click" => {
let (x, y) = pt(v, scale()?)?;

View File

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

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", "ボイスチャットを開始:話し終えると声で答え、また聞き取ります"),
("幫我建立一個例行任務:", "Set up a routine for me: ", "ルーティンを作成して:"),
("你好,我是你的 Bot。", "Hi, I'm your bot.", "こんにちは、あなたの Bot です。"),
("在大腦「{name}」上", "On the brain “{name}”", "ブレイン「{name}」上"),
("連線中…", "Connecting…", "接続中…"),
("看「{name}」的螢幕,也可以取得控制權自己操作", "Watch the screen of “{name}”; you can also take control", "「{name}」の画面を見ます。操作を代わることもできます"),
("叫醒「{name}」並看它的螢幕", "Wake “{name}” and watch its screen", "「{name}」を起こして画面を見ます"),
("要 {name} 在哪台電腦做事", "Which computer {name} works on", "{name} が作業するコンピューター"),
("它自己的電腦", "its own computer", "自分のコンピューター"),
("你正在用的這台", "the one you're using", "今使っているコンピューター"),
("這台電腦", "this computer", "このコンピューター"),
("大腦所在的伺服器", "the brain's server", "ブレインのサーバー"),
("SSH 電腦", "SSH computer", "SSH コンピューター"),
("做事的電腦", "Works on", "作業するコンピューター"),
("聊天時指名另一台(例如「在 Mac mini 上」)就會換", "Name another one in the chat (e.g. “on the Mac mini”) to switch", "チャットで別のコンピューターを指名すると切り替わります(例:「Mac mini で」)"),
("正在「{name}」上工作", "Working on “{name}”", "「{name}」で作業中"),
("在「{name}」上做事 · 點一下換電腦", "Works on “{name}” · click to switch computers", "「{name}」で作業 · クリックで切り替え"),
];

View File

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

View File

@ -123,10 +123,23 @@ fn is_zero(n: &u64) -> bool {
*n == 0
}
/// A bot's own computer as kept in `Bot::default_computer` (it survives renaming the bot). It is
/// shown and offered under the bot's name: see [`own_computer_name`].
pub const BOT_COMPUTER_NAME: &str = "Bot 的電腦";
/// What older versions kept for "the person's computer". Read as the computer the person is using
/// now; every computer is shown under its own name.
pub const THIS_COMPUTER_NAME: &str = "你的電腦";
/// A brain on a server, when the person's own computer lends it hands (then that one is 你的電腦).
pub const BRAIN_HOST_NAME: &str = "大腦伺服器";
/// What a bot calls its own computer: 「Master 的電腦」 for the bot Master. One plain name per
/// computer — 「Bot 的電腦」 and 「你的電腦」 meant different machines to the person and the bot.
pub fn own_computer_name(bot_name: &str) -> String {
let n = bot_name.trim();
if n.is_empty() {
BOT_COMPUTER_NAME.to_string()
} else {
format!("{n} 的電腦")
}
}
impl Message {
pub fn user(text: &str) -> Self {
@ -382,13 +395,25 @@ fn default_sidebar_width() -> f32 {
}
impl State {
/// Every computer a bot can be told to use: its own, this Mac, then the SSH ones.
/// Every computer name a bot can be told to use here: its own (by its stored name), this
/// computer, then the SSH ones.
pub fn computer_names(&self) -> Vec<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
}
/// 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> {
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
// instructions and tool descriptions), so the wording follows the system in one place.
/// A screen the person sits at (not a server with no display).
pub fn has_desktop() -> bool {
if cfg!(target_os = "linux") {
std::env::var("DISPLAY").map(|v| !v.is_empty()).unwrap_or(false) || std::env::var("WAYLAND_DISPLAY").map(|v| !v.is_empty()).unwrap_or(false)
} else {
true
}
}
const PAIRS: &[(&str, &str)] = &[
("使用者的這台 Mac", "使用者的這台電腦"),
("這台 Mac", "這台電腦"),

View File

@ -92,6 +92,12 @@ impl Default for Settings {
}
pub fn default_host_name() -> String {
// Asked of the system once (it runs a program).
static N: OnceLock<String> = OnceLock::new();
N.get_or_init(system_host_name).clone()
}
fn system_host_name() -> String {
if !cfg!(target_os = "macos") {
let pretty = std::process::Command::new("hostnamectl").arg("--pretty").output().ok().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default();
let name = if pretty.is_empty() { std::fs::read_to_string("/etc/hostname").unwrap_or_default().trim().to_string() } else { pretty };
@ -109,7 +115,8 @@ pub fn default_host_name() -> String {
/// What the phone asks AnyBot to do; the app carries these out on its own thread.
#[derive(Clone, Debug, PartialEq)]
pub enum Cmd {
Send { bot: u64, text: String, attachments: Vec<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 },
Answer { bot: u64, answer: String },
Read { bot: u64 },
@ -119,6 +126,8 @@ pub enum Cmd {
Reply { bot: u64 },
/// Bring back an earlier conversation (過去的對話).
RestoreThread { bot: u64, index: usize },
/// 清除聊天記錄 from a screen of this brain (the chat lives here).
Clear { bot: u64 },
/// A new phone signed in (to remember), or one was signed out.
DeviceAdded(Device),
DeviceSeen(String),
@ -393,7 +402,7 @@ pub fn handle(req: Req, via_relay: bool) -> Resp {
// 團隊: the bot knows who is talking when it isn't the owner.
let who = req.header("x-anybot-user");
let text = if role != "owner" && !who.is_empty() { format!("【{who}】{text}") } else { text };
Cmd::Send { bot, text, attachments }
Cmd::Send { bot, text, attachments, from: String::new() }
}
"stop" => Cmd::Stop { bot },
"answer" => Cmd::Answer { bot, answer: b["answer"].as_str().unwrap_or("reject").to_string() },
@ -425,6 +434,52 @@ pub fn handle(req: Req, via_relay: bool) -> Resp {
Err(e) => Resp::error(503, &e),
},
("POST", ["api", "sync", "upload", id]) => upload(id.parse().unwrap_or(0), &req),
// A screen of this brain watches (and may take over) the bots' own computer, which lives
// here: its picture on that screen used to be its own computer's, not this one's.
("GET", ["api", "sync", "screen"]) => {
if req.query.get("thumb").map(|t| t == "1").unwrap_or(false) {
match crate::cloudbox::thumbnail(320) {
Some(img) => {
let rgb = image::DynamicImage::ImageRgba8(img).to_rgb8();
match crate::computer::encode_jpeg(&rgb) {
Some(j) => Resp::bytes("image/jpeg", j),
None => Resp::error(500, "畫面編碼失敗"),
}
}
None => Resp::error(404, "還沒有畫面"),
}
} else {
screen("box", &s)
}
}
("POST", ["api", "sync", "screen"]) => {
let v = req.json();
match v["kind"].as_str().unwrap_or("") {
"hold" => {
crate::cloudbox::HELD_BY_PERSON.store(v["on"].as_bool().unwrap_or(false), Ordering::SeqCst);
Resp::json(&json!({"ok": true}))
}
"viewer" => {
crate::cloudbox::set_viewer_open(v["on"].as_bool().unwrap_or(false));
Resp::json(&json!({"ok": true}))
}
// The person's own hands on the picture (frame pixels, like this computer's own
// viewer; not the bot's tools, which stop while the person holds it).
"click" | "move" | "scroll" | "key" | "text" => {
crate::cloudbox::touch();
let (x, y) = (v["x"].as_i64().unwrap_or(0), v["y"].as_i64().unwrap_or(0));
match v["kind"].as_str().unwrap_or("") {
"click" => crate::cloudbox::viewer_click(x, y, if v["button"].as_str() == Some("right") { "right" } else { "left" }, v["count"].as_u64().unwrap_or(1).clamp(1, 3) as u32),
"move" => crate::cloudbox::viewer_move(x, y),
"scroll" => crate::cloudbox::viewer_scroll(x, y, v["down"].as_bool().unwrap_or(true), v["steps"].as_u64().unwrap_or(1).clamp(1, 20) as u32),
"key" => crate::cloudbox::viewer_key(v["key"].as_str().unwrap_or("")),
_ => crate::cloudbox::viewer_text(v["text"].as_str().unwrap_or("")),
}
Resp::json(&json!({"ok": true}))
}
_ => screen_input("box", &v, &s),
}
}
("POST", ["api", "sync", "models"]) => match hub().ask_sync_for(SyncKind::Models(req.json()["provider"].as_str().unwrap_or("").to_string()), 45) {
Ok(v) => Resp::json(&v),
Err(e) => Resp::error(503, &e),
@ -449,6 +504,8 @@ pub fn handle(req: Req, via_relay: bool) -> Resp {
bot,
text: b["text"].as_str().unwrap_or("").trim().to_string(),
attachments: b["attachments"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default(),
// The relay names the computer that asked: the one the person is using.
from: req.header("x-anybot-from").to_string(),
},
"stop" => Cmd::Stop { bot },
"answer" => Cmd::Answer { bot, answer: b["answer"].as_str().unwrap_or("reject").to_string() },
@ -457,6 +514,7 @@ pub fn handle(req: Req, via_relay: bool) -> Resp {
"regenerate" => Cmd::Regenerate { bot },
"reply" => Cmd::Reply { bot },
"restore" => Cmd::RestoreThread { bot, index: b["index"].as_u64().unwrap_or(0) as usize },
"clear" => Cmd::Clear { bot },
_ => return Resp::error(404, "沒有這個動作"),
};
hub().push(cmd);
@ -590,7 +648,7 @@ fn screen_input(which: &str, v: &Value, s: &Settings) -> Resp {
}
let (name, args) = match kind {
"click" => ("computer_use_click", json!({"x": v["x"], "y": v["y"], "button": v["button"].as_str().unwrap_or("left"), "count": v["count"].as_u64().unwrap_or(1)})),
"type" => ("computer_use_typing", json!({"text": v["text"]})),
"type" => ("computer_use_typing", json!({"value": v["text"]})),
"key" => ("computer_use_press_key", json!({"key": v["key"]})),
"scroll" => ("computer_use_scroll", json!({"x": v["x"], "y": v["y"], "direction": v["direction"].as_str().unwrap_or("down"), "amount": v["amount"].as_i64().unwrap_or(3)})),
_ => return Resp::error(400, "不認得的操作"),
@ -693,7 +751,11 @@ fn host_port(url: &str) -> Option<(String, u16)> {
/// can't open a connection to, and the person's computer lends this brain its hands.
pub fn model_hands_for(url: &str) -> Option<String> {
let s = hub().settings();
let hand = my_hands(&s.host_id).into_iter().next()?;
// The person's own computer (the one they type on) is the one on their network; the first
// hands by id was a desktop in the server room that can't reach a model at their office.
let hands = my_hands(&s.host_id);
let person = person_host();
let hand = hands.iter().find(|h| **h == person).or_else(|| hands.first()).cloned()?;
let (h, p) = host_port(url)?;
if !private_host(&h) || h == "localhost" || h.starts_with("127.") {
return None;
@ -716,7 +778,7 @@ pub fn model_via_hands(hand: &str, url: &str, headers: &[(String, String)], body
let args = json!({"url": url, "headers": headers, "body": body}).to_string();
let req = json!({"tool": MODEL_FETCH, "args": args, "who": "大腦"}).to_string();
let name = peer_name(hand);
let whom = if name.is_empty() { "你的電腦".to_string() } else { format!("「{name}」") };
let whom = if name.is_empty() { "使用者的電腦".to_string() } else { format!("「{name}」") };
let r = crate::tunnel::call(hand, "POST", "/api/device/run", &[], req.as_bytes(), 600).ok_or_else(|| format!("模型在只有{whom}連得到的網路上,但大腦現在和帳號伺服器沒有連線"))?;
let a = r.map_err(|e| format!("透過{whom}連模型失敗:{e}"))?;
let v = answer_value(a.status, &a.body, &whom)?;
@ -1155,6 +1217,15 @@ pub fn sync_upload(s: &Settings, bot: u64, name: &str, bytes: &[u8]) -> Result<S
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.
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)?;
@ -1350,6 +1421,25 @@ pub fn my_hands(me: &str) -> Vec<String> {
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.
pub fn peer_name(id: &str) -> String {
peer_rows().lock().unwrap().get(id).map(|r| r.0.clone()).unwrap_or_default()
@ -1466,7 +1556,7 @@ mod tests {
assert_eq!(handle(send, false).status, 200);
let cmds = hub().take_cmds();
assert!(cmds.iter().any(|c| matches!(c, Cmd::DeviceAdded(d) if d.name == "iPhone")));
assert!(cmds.contains(&Cmd::Send { bot: 1, text: "你好".into(), attachments: vec![] }));
assert!(cmds.contains(&Cmd::Send { bot: 1, text: "你好".into(), attachments: vec![], from: String::new() }));
// The Mac's own screen stays closed unless allowed.
let mut mac = Req { method: "GET".into(), path: "/api/screen/mac".into(), ..Default::default() };
mac.headers = req.headers.clone();

View File

@ -622,6 +622,17 @@ pub struct Target {
pub place: Place,
}
/// Which of a bot's computers is which, by name (every computer goes by one plain name).
#[derive(Clone, Debug, Default)]
pub struct Roles {
/// The bot's own computer: 「Master 的電腦」.
pub own: String,
/// The computer the person is using now (where they typed to the bot), when known.
pub person: Option<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.
pub fn specs_for(computers: &[String], default: &str) -> Vec<Value> {
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" {
continue;
}
// None of these being the person's own computer (a server brain with no hands):
// a bot that "helped on your computer" then worked on the server, while the person
// watched their own desktop stay untouched. Say what is true instead.
let desc = if computers.iter().any(|c| c == crate::model::THIS_COMPUTER_NAME) {
format!("Which computer to work on. Default: {default}. Use the one the person names (\"在我的 Mac 上\" = {}).", crate::model::THIS_COMPUTER_NAME)
} else {
format!("Which computer to work on. Default: {default}. None of these is the person's own computer (it is not connected): when the person means their own computer, ask which computer they want and tell them theirs is not connected (App › 設定 › 電腦) — never work on another machine and call it theirs.")
};
// Each computer goes by its own name; the instructions say which is which. Working on
// one and calling it another (the server as "your computer") is what confused people.
let desc = format!("Which computer to work on, by its name. Default: {default} (the one the person chose). Use another only when the person names it; when unsure which one they mean, ask.");
t["parameters"]["properties"]["computer"] = json!({
"type": "string",
"enum": computers,
@ -686,7 +692,7 @@ pub fn decide_on(name: &str, args: &str, target: &Target, grants: &[String]) ->
}
let p = resolve(root, &arg_str(&v, "path"));
if !inside(root, &p) {
return Decision::Deny(format!("{} 看不到這台電腦上的其他檔案({})。要處理你的檔案,請用「{}」。", target.name, p.display(), crate::model::THIS_COMPUTER_NAME));
return Decision::Deny(format!("{} 看不到這台電腦上的其他檔案({})。要處理使用者的檔案,請在使用者的電腦上做(不確定是哪台就先問)。", target.name, p.display()));
}
Decision::Allow
}

View File

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