diff --git a/mobile/index.html b/mobile/index.html
index d799b4d..e8f1d71 100644
--- a/mobile/index.html
+++ b/mobile/index.html
@@ -687,7 +687,9 @@ function renderChips() {
}
// ---------- screens ----------
-$("b-screen").onclick = $("b-screen2").onclick = () => { S.back = S.view; show("screen"); $("seg").children[1].style.display = S.mac ? "" : "none"; startFrames(); };
+// Each bot's own computer goes by the bot's name (「Master 的電腦」).
+function ownComputer() { const n = botName(S.open); return n ? n + " 的電腦" : "Bot 的電腦"; }
+$("b-screen").onclick = $("b-screen2").onclick = () => { S.back = S.view; show("screen"); $("seg").children[0].textContent = ownComputer(); $("seg").children[1].style.display = S.mac ? "" : "none"; startFrames(); };
$("b-back2").onclick = () => show(S.back === "chat" ? "chat" : "list");
document.querySelectorAll("#seg button").forEach((b) => b.onclick = () => { document.querySelectorAll("#seg button").forEach((x) => x.classList.toggle("on", x === b)); S.screen = b.dataset.w; $("k-wake").style.display = S.screen === "box" ? "" : "none"; startFrames(); });
let busyFrame = false, lastUrl = null;
@@ -735,7 +737,7 @@ $("k-type").onclick = () => { $("typebar").classList.toggle("on"); if ($("typeba
$("b-type").onclick = () => { const t = $("typein").value; if (!t) return; $("typein").value = ""; input2({kind: "type", text: t}); };
$("typein").addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.isComposing) { e.preventDefault(); $("b-type").click(); } });
$("k-right").onclick = () => { S.right = !S.right; $("k-right").textContent = "右鍵:" + (S.right ? "開" : "關"); };
-$("k-wake").onclick = () => { toast("正在叫醒 Bot 的電腦…"); input2({kind: "wake"}); };
+$("k-wake").onclick = () => { toast("正在叫醒" + ownComputer() + "…"); input2({kind: "wake"}); };
// ---------- menu, notifications ----------
$("b-more").onclick = () => { $("m-host").style.display = S.relay ? "" : "none"; $("m-pair").style.display = S.relay ? "" : "none"; notifyLabel(); $("sheet").classList.add("on"); };
$("sheet").onclick = (e) => { if (e.target.id === "sheet") $("sheet").classList.remove("on"); };
diff --git a/src/agent.rs b/src/agent.rs
index 094707c..2733b65 100644
--- a/src/agent.rs
+++ b/src/agent.rs
@@ -34,6 +34,8 @@ pub struct Job {
/// Computers this bot may work on (empty when computer tools are off).
pub computers: Vec,
pub default_computer: String,
+ /// Which computer is the bot's own, which one the person is using, and whether this is a server.
+ pub roles: tools::Roles,
/// 在這台電腦上執行 is on.
pub local_execution: bool,
/// This bot's computer tools are on (when off, using them asks the person first).
@@ -121,23 +123,29 @@ enum BrowserAt {
Device(String),
}
-/// Whose browser a browser step uses: the computer it names, else the bot's chosen computer.
-fn browser_place(job: &Job, name: &str, args: &str) -> BrowserAt {
+/// Whose browser a browser step uses: the computer it names, else the bot's chosen computer; a
+/// computer without a browser (a server, an SSH one) hands it to the person's computer.
+fn browser_place(job: &Job, name: &str, args: &str) -> (BrowserAt, String) {
if name == "web_fetch" {
- return BrowserAt::Here;
+ return (BrowserAt::Here, String::new());
}
let v = tools::args_of(args);
let want = v.get("computer").and_then(|c| c.as_str()).map(str::trim).filter(|c| !c.is_empty()).unwrap_or(&job.default_computer).to_string();
- let any_device = || job.computers.iter().find_map(|t| match &t.place {
- tools::Place::Device { host } => Some(host.clone()),
- _ => None,
- });
- match job.computers.iter().find(|t| t.name == want).map(|t| &t.place) {
- Some(tools::Place::Device { host }) => BrowserAt::Device(host.clone()),
- Some(tools::Place::Box { .. }) if crate::cloudbox::available() => BrowserAt::Box,
- // No browser here (a brain on a server): the 手腳 computer's.
- _ if !crate::botbrowser::available() => any_device().map(BrowserAt::Device).unwrap_or(BrowserAt::Here),
- _ => BrowserAt::Here,
+ let at = |t: &tools::Target| -> Option {
+ match &t.place {
+ tools::Place::Device { host } => Some(BrowserAt::Device(host.clone())),
+ tools::Place::Box { .. } if crate::cloudbox::available() => Some(BrowserAt::Box),
+ tools::Place::Local { .. } if crate::botbrowser::available() => Some(BrowserAt::Here),
+ _ => None,
+ }
+ };
+ if let Some((b, n)) = job.computers.iter().find(|t| t.name == want).and_then(|t| at(t).map(|b| (b, t.name.clone()))) {
+ return (b, n);
+ }
+ let person = job.roles.person.as_ref().and_then(|p| job.computers.iter().find(|t| &t.name == p));
+ match person.and_then(|t| at(t).map(|b| (b, t.name.clone()))).or_else(|| job.computers.iter().find_map(|t| at(t).map(|b| (b, t.name.clone())))) {
+ Some(x) => x,
+ None => (BrowserAt::Here, String::new()),
}
}
@@ -172,54 +180,72 @@ fn finds(hay: &str, needle: &str) -> bool {
false
}
-/// Words that mean a computer in the person's message.
-fn mentions(text: &str, t: &tools::Target) -> bool {
+/// Words that mean a computer in the person's message: its name (or the part after 「的」, so
+/// 「Mac mini」 names 「王小明的Mac mini」), and for the bot's own computer and the one the person
+/// is using, the everyday words for them.
+fn mentions(text: &str, t: &tools::Target, roles: &tools::Roles) -> bool {
let low = text.to_lowercase();
- if finds(&low, &t.name.to_lowercase()) {
+ let name = t.name.to_lowercase();
+ if finds(&low, &name) {
return true;
}
- let words: &[&str] = match &t.place {
- _ if t.name == crate::model::BOT_COMPUTER_NAME => &["bot 的電腦", "bot的電腦", "你自己的電腦", "你的虛擬", "虛擬電腦", "雲端電腦"],
- _ if t.name == crate::model::THIS_COMPUTER_NAME => &["mac", "我的電腦", "我電腦", "我這台", "這台電腦", "本機", "我的筆電"],
- tools::Place::Device { host } => return !crate::remote::peer_name(host).is_empty() && finds(&low, &crate::remote::peer_name(host).to_lowercase()),
- _ => &[],
+ if let Some((_, tail)) = name.rsplit_once('的') {
+ let tail = tail.trim();
+ if tail.chars().count() >= 3 && tail != "電腦" && finds(&low, tail) {
+ return true;
+ }
+ }
+ if finds(&name, "mac") && finds(&low, "mac") {
+ return true;
+ }
+ let words: &[&str] = if t.name == roles.own {
+ &["bot 的電腦", "bot的電腦", "你的電腦", "你自己的電腦", "你的虛擬", "虛擬電腦", "雲端電腦"]
+ } else if roles.person.as_deref() == Some(t.name.as_str()) {
+ &["我的電腦", "我電腦", "我這台", "這台電腦", "本機", "我的筆電", "my computer"]
+ } else if roles.server && matches!(t.place, tools::Place::Local { .. }) {
+ &["伺服器", "server"]
+ } else {
+ &[]
};
+ if t.name == roles.own && finds(&low, &name.replace(' ', "")) {
+ return true;
+ }
words.iter().any(|w| finds(&low, w))
}
/// A step naming a computer other than the chosen one, which the person didn't ask for, goes
-/// to the chosen one.
-fn keep_to_chosen(job: &Job, history: &[Message], args: &str) -> String {
+/// to the chosen one — and the bot is told so (quietly moving it made a bot "ask your Mac" six
+/// times while every step landed on its own computer).
+fn keep_to_chosen(job: &Job, history: &[Message], args: &str) -> (String, Option) {
let mut v = tools::args_of(args);
- let Some(named) = v.get("computer").and_then(|c| c.as_str()).map(|c| c.trim().to_string()).filter(|c| !c.is_empty()) else { return args.to_string() };
+ let Some(named) = v.get("computer").and_then(|c| c.as_str()).map(|c| c.trim().to_string()).filter(|c| !c.is_empty()) else { return (args.to_string(), None) };
if named == job.default_computer || !job.computers.iter().any(|t| t.name == job.default_computer) {
- return args.to_string();
+ return (args.to_string(), None);
}
let said = history.iter().rev().filter(|m| m.role == Role::User && !m.summary).take(3).map(|m| m.text.as_str()).collect::>().join("\n");
match job.computers.iter().find(|t| t.name == named) {
- Some(t) if mentions(&said, t) => args.to_string(),
+ Some(t) if mentions(&said, t, &job.roles) => (args.to_string(), None),
_ => {
v["computer"] = serde_json::Value::String(job.default_computer.clone());
- v.to_string()
+ let note = format!("(這一步你指定了「{named}」,但使用者選的電腦是「{}」,而且他沒有說要換,所以這一步在「{}」上做。真的需要別台,先問使用者。)\n", job.default_computer, job.default_computer);
+ (v.to_string(), Some(note))
}
}
}
+/// A step's result, with a word first when the step was moved back to the chosen computer.
+fn noted(moved: &std::collections::HashMap, id: &str, text: &str) -> String {
+ match moved.get(id) {
+ Some(n) => format!("{n}{text}"),
+ None => text.to_string(),
+ }
+}
+
/// Does the call name a computer?
fn names_a_computer(args: &str) -> bool {
tools::args_of(args).get("computer").and_then(|c| c.as_str()).map(|c| !c.trim().is_empty()).unwrap_or(false)
}
-/// The other computer (裝置連結) a call names, if it names one.
-fn device_named(job: &Job, args: &str) -> Option {
- let v = tools::args_of(args);
- let n = v.get("computer").and_then(|c| c.as_str()).map(str::trim).filter(|c| !c.is_empty())?;
- job.computers.iter().find(|t| t.name == n).and_then(|t| match &t.place {
- tools::Place::Device { host } => Some(host.clone()),
- _ => None,
- })
-}
-
/// Rough token count: about 4 Latin characters or 1 Chinese character per token (estimated).
/// Which screen a computer_use call is for: the one named, else the bot's default when it has a
/// screen, else the bot's own desktop when there is one, else this Mac.
@@ -228,10 +254,10 @@ fn screen_target(job: &Job, args: &str) -> Result {
let has_screen = |t: &tools::Target| matches!(t.place, tools::Place::Box { .. } | tools::Place::Local { .. } | tools::Place::Device { .. });
let named = v.get("computer").and_then(|c| c.as_str()).map(str::trim).filter(|c| !c.is_empty());
if let Some(n) = named {
- let t = job.computers.iter().find(|t| t.name == n).ok_or_else(|| format!("沒有「{n}」這台電腦"))?;
+ let t = job.computers.iter().find(|t| t.name == n).ok_or_else(|| format!("沒有「{n}」這台電腦。可以用:{}", job.computers.iter().map(|t| t.name.as_str()).collect::>().join("、")))?;
if !has_screen(t) {
- return Err(if n == crate::model::BOT_COMPUTER_NAME {
- if cfg!(target_os = "macos") { "Bot 的電腦現在沒有螢幕:這台 Mac 沒有 Docker(安裝 OrbStack 或 Docker Desktop 就會有)。可以改用「你的電腦」。".into() } else { "Bot 的電腦現在沒有螢幕:這台電腦沒有 Docker(安裝 Docker Engine 就會有)。可以改用「你的電腦」。".into() }
+ return Err(if n == job.roles.own {
+ format!("{n}現在沒有螢幕:AnyBot 所在的電腦沒有 Docker({})。要用別台請使用者指名。", if cfg!(target_os = "macos") { "安裝 OrbStack 或 Docker Desktop 就會有" } else { "安裝 Docker Engine 就會有" })
} else {
format!("「{n}」不能遠端操作螢幕,只能用檔案和指令工具。")
});
@@ -383,8 +409,14 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
return;
}
let mut out = out;
+ // Steps moved back to the chosen computer: what the bot is told with each result.
+ let mut moved: std::collections::HashMap = std::collections::HashMap::new();
for c in out.tool_calls.iter_mut() {
- c.args = keep_to_chosen(&job, &history, &c.args);
+ let (args, note) = keep_to_chosen(&job, &history, &c.args);
+ c.args = args;
+ if let Some(n) = note {
+ moved.insert(c.id.clone(), n);
+ }
}
let msg = Message {
role: Role::Assistant,
@@ -425,7 +457,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
}
};
send(StreamEvent::ToolStatus(call.id.clone(), if ok { ToolStatus::Done } else { ToolStatus::Failed }));
- let result = Message::tool_result(&call.id, &call.name, &output);
+ let result = Message::tool_result(&call.id, &call.name, ¬ed(&moved, &call.id, &output));
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
@@ -435,7 +467,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running));
let (output, ok, img) = crate::teach::view_step(&job.demos, &call.args);
send(StreamEvent::ToolStatus(call.id.clone(), if ok { ToolStatus::Done } else { ToolStatus::Failed }));
- let mut result = Message::tool_result(&call.id, &call.name, &output);
+ let mut result = Message::tool_result(&call.id, &call.name, ¬ed(&moved, &call.id, &output));
result.images.extend(img);
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
@@ -446,7 +478,8 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
if call.name == "web_fetch" || (web::is_web_tool(&call.name) && !web::uses_this_computer(&call.name, &call.args)) {
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running));
// 裝置連結: the AnyBot browser of another computer (its own logins).
- let (output, ok, img) = match browser_place(&job, &call.name, &call.args) {
+ let (place, on) = browser_place(&job, &call.name, &call.args);
+ let (output, ok, img) = match place {
BrowserAt::Device(host) => match crate::remote::run_on_device(&host, &call.name, &call.args, &job.via_name) {
Ok(v) => (v["text"].as_str().unwrap_or("").to_string(), v["ok"].as_bool().unwrap_or(false), v["image"].as_str().map(String::from)),
Err(e) => (e, false, None),
@@ -454,8 +487,10 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
BrowserAt::Box => crate::botbrowser::in_box(|| web::run_full(&call.name, &call.args, &cancel)),
BrowserAt::Here => web::run_full(&call.name, &call.args, &cancel),
};
+ // Which computer's browser it was, like every other step.
+ let output = if on.is_empty() { output } else { format!("[{on}] {output}") };
send(StreamEvent::ToolStatus(call.id.clone(), if ok { ToolStatus::Done } else { ToolStatus::Failed }));
- let mut result = Message::tool_result(&call.id, &call.name, &output);
+ let mut result = Message::tool_result(&call.id, &call.name, ¬ed(&moved, &call.id, &output));
result.images.extend(img);
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
@@ -483,7 +518,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
// 生活卡片: drawn by the window from the call itself.
if call.name == "show_card" {
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Done));
- let result = Message::tool_result(&call.id, &call.name, "卡片已顯示給使用者。");
+ let result = Message::tool_result(&call.id, &call.name, ¬ed(&moved, &call.id, "卡片已顯示給使用者。"));
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
@@ -493,7 +528,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
let emoji: String = tools::args_of(&call.args)["emoji"].as_str().unwrap_or("👍").chars().take(4).collect();
send(StreamEvent::React(emoji.clone()));
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Done));
- let result = Message::tool_result(&call.id, &call.name, &format!("已在使用者的訊息上按了 {emoji}。"));
+ let result = Message::tool_result(&call.id, &call.name, ¬ed(&moved, &call.id, &format!("已在使用者的訊息上按了 {emoji}。")));
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
@@ -510,7 +545,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
},
Ok(tools::Target { place: tools::Place::Sealed { root }, .. }) => Ok(tools::resolve(&root, &path)),
Ok(tools::Target { place: tools::Place::Local { workdir }, .. }) => Ok(tools::resolve(&workdir, &path)),
- Ok(_) => Err("這台電腦上的檔案不能直接傳,請先複製到 Bot 的電腦。".to_string()),
+ Ok(_) => Err(format!("這台電腦上的檔案不能直接傳,請先複製到「{}」。", job.roles.own)),
Err(e) => Err(e),
};
// Secrets (SSH keys, the app's own data folder, .env…) never ride into the chat,
@@ -525,7 +560,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
Err(e) => (e, false),
};
send(StreamEvent::ToolStatus(call.id.clone(), if ok { ToolStatus::Done } else { ToolStatus::Failed }));
- let result = Message::tool_result(&call.id, &call.name, &output);
+ let result = Message::tool_result(&call.id, &call.name, ¬ed(&moved, &call.id, &output));
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
@@ -542,12 +577,34 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
},
Err(_) => None,
};
+ // On a server nobody sits at its screen: a link opens in the browser of the
+ // computer the person is using; a file is sent into the chat instead.
+ let person_host = job.roles.person.as_ref().and_then(|n| job.computers.iter().find(|t| &t.name == n)).and_then(|t| match &t.place {
+ tools::Place::Device { host } => Some(host.clone()),
+ _ => None,
+ });
+ if job.roles.server {
+ let web = path.starts_with("http://") || path.starts_with("https://");
+ let (output, ok) = match (&person_host, web) {
+ (Some(host), true) => match crate::remote::run_on_device(host, "browser_open", &serde_json::json!({"url": path}).to_string(), &job.via_name) {
+ Ok(v) => (format!("[{}] {}", job.roles.person.clone().unwrap_or_default(), v["text"].as_str().unwrap_or("")), v["ok"].as_bool().unwrap_or(false)),
+ Err(e) => (e, false),
+ },
+ (None, true) => ("不知道使用者現在在用哪台電腦,沒辦法打開給他看。把網址直接寫在回覆裡。".to_string(), false),
+ (_, false) => ("AnyBot 在伺服器上,檔案沒辦法直接在使用者的螢幕打開。改用 send_file 把檔案傳到聊天裡,使用者點一下就能看。".to_string(), false),
+ };
+ send(StreamEvent::ToolStatus(call.id.clone(), if ok { ToolStatus::Done } else { ToolStatus::Failed }));
+ let result = Message::tool_result(&call.id, &call.name, ¬ed(&moved, &call.id, &output));
+ send(StreamEvent::ToolResult(result.clone()));
+ history.push(result);
+ continue;
+ }
let local = match (&on_box, target_for(&job, &call.args)) {
(Some(p), _) => crate::cloudbox::copy_out(p),
(None, Ok(tools::Target { place: tools::Place::Sealed { root }, .. })) => Ok(tools::resolve(&root, &path)),
(None, Ok(tools::Target { place: tools::Place::Local { workdir }, .. })) => Ok(tools::resolve(&workdir, &path)),
(None, _) if path.starts_with("http://") || path.starts_with("https://") => Ok(std::path::PathBuf::from(&path)),
- _ => Err("這台電腦上的檔案不能直接打開給使用者看,請先複製到 Bot 的電腦或這台 Mac。".to_string()),
+ _ => Err(format!("這台電腦上的檔案不能直接打開給使用者看,請先複製到「{}」或這台電腦。", job.roles.own)),
};
// Secrets never open on the person's screen either.
let local = local.and_then(|p| if tools::is_ignored(&p) || tools::is_ignored(std::path::Path::new(&path)) { Err("這個檔案是機密的,不能打開。".to_string()) } else { Ok(p) });
@@ -560,7 +617,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
Err(e) => (e, false),
};
send(StreamEvent::ToolStatus(call.id.clone(), if ok { ToolStatus::Done } else { ToolStatus::Failed }));
- let result = Message::tool_result(&call.id, &call.name, &output);
+ let result = Message::tool_result(&call.id, &call.name, ¬ed(&moved, &call.id, &output));
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
@@ -583,7 +640,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
}
};
send(StreamEvent::ToolStatus(call.id.clone(), status));
- let result = Message::tool_result(&call.id, &call.name, &output);
+ let result = Message::tool_result(&call.id, &call.name, ¬ed(&moved, &call.id, &output));
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
@@ -618,7 +675,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
Ok(tools::Target { place: tools::Place::Box { dir }, .. }) => crate::cloudbox::copy_out(&if path.starts_with('/') { path.clone() } else { format!("{dir}/{path}") }),
Ok(tools::Target { place: tools::Place::Sealed { root }, .. }) => Ok(tools::resolve(&root, &path)),
Ok(tools::Target { place: tools::Place::Local { workdir }, .. }) => Ok(tools::resolve(&workdir, &path)),
- Ok(_) => Err("這台電腦上的檔案不能直接發布,請先複製到 Bot 的電腦。".to_string()),
+ Ok(_) => Err(format!("這台電腦上的檔案不能直接發布,請先複製到「{}」。", job.roles.own)),
Err(e) => Err(e),
};
match local.and_then(|p| crate::remote::publish_site(&name, &p)) {
@@ -627,7 +684,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
}
};
send(StreamEvent::ToolStatus(call.id.clone(), status));
- let result = Message::tool_result(&call.id, &call.name, &output);
+ let result = Message::tool_result(&call.id, &call.name, ¬ed(&moved, &call.id, &output));
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
@@ -638,7 +695,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
if call.name == crate::vault::LIST_TOOL {
let output = crate::vault::list_for_bot(&call.args);
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Done));
- let result = Message::tool_result(&call.id, &call.name, &output);
+ let result = Message::tool_result(&call.id, &call.name, ¬ed(&moved, &call.id, &output));
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
@@ -696,7 +753,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
}
};
send(StreamEvent::ToolStatus(call.id.clone(), status));
- let result = Message::tool_result(&call.id, &call.name, &output);
+ let result = Message::tool_result(&call.id, &call.name, ¬ed(&moved, &call.id, &output));
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
@@ -731,29 +788,30 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
("使用者拒絕了這個操作。請不要再試同樣的操作,改問使用者想怎麼做。".to_string(), ToolStatus::Denied, None)
};
send(StreamEvent::ToolStatus(call.id.clone(), status));
- let mut result = Message::tool_result(&call.id, &call.name, &output);
+ let mut result = Message::tool_result(&call.id, &call.name, ¬ed(&moved, &call.id, &output));
result.images.extend(img);
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
}
- // The browser tools use the person's own browser, so always this Mac.
- // …and so does screen control: it's the person's own screen.
- let person_device = job.computers.iter().find(|t| t.name == crate::model::THIS_COMPUTER_NAME && matches!(t.place, tools::Place::Device { .. })).cloned();
+ // The person's own apps and everyday browser are on the computer they are using:
+ // the one they name, else the one they typed on (else this one, when it is theirs).
+ let person_target = || -> Result {
+ if names_a_computer(&call.args) {
+ return target_for(&job, &call.args);
+ }
+ job.roles
+ .person
+ .as_ref()
+ .and_then(|n| job.computers.iter().find(|t| &t.name == n))
+ .cloned()
+ .ok_or_else(|| format!("不知道使用者現在在用哪台電腦。請問他要在哪台做(可以用:{})。", job.computers.iter().map(|t| t.name.as_str()).collect::>().join("、")))
+ };
let target = if is_screen {
screen_target(&job, &call.args)
- } else if is_web && device_named(&job, &call.args).is_some() {
- // The browser on another computer of the account, as named.
- target_for(&job, &call.args)
- } else if is_web && call.name.starts_with("browser_") && person_device.is_some() && !names_a_computer(&call.args) {
- // A brain on a server: the browser is the person's computer's (the one they
- // can see), unless the server's own is named.
- Ok(person_device.clone().unwrap())
- } else if is_web && call.name.starts_with("browser_") && !crate::botbrowser::available() && job.computers.iter().any(|t| matches!(t.place, tools::Place::Device { .. })) {
- // No browser here (a brain on a server): the 手腳 computer's.
- job.computers.iter().find(|t| matches!(t.place, tools::Place::Device { .. })).cloned().ok_or_else(|| "找不到這台電腦".to_string())
- } else if is_mac || web::uses_this_computer(&call.name, &call.args) {
- job.computers.iter().find(|t| t.name == crate::model::THIS_COMPUTER_NAME).cloned().ok_or_else(|| "找不到這台電腦".to_string())
+ } else if is_mac || is_web {
+ // (Other browser steps went their own way above; this is the person's own Chrome.)
+ person_target()
} else {
target_for(&job, &call.args)
};
@@ -790,17 +848,17 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
}
// An admin lock (policy) refuses outright instead of asking.
if gate.is_ok() && matches!(t.place, tools::Place::Local { .. }) && job.local_locked {
- gate = Err(format!("「在這台電腦上執行」設成了永不允許(或被管理員關閉),不能在「{}」上執行。請改用「{}」。", t.name, crate::model::BOT_COMPUTER_NAME));
+ gate = Err(format!("「在這台電腦上執行」設成了永不允許(或被管理員關閉),不能在「{}」上執行。請改用「{}」。", t.name, job.roles.own));
}
if gate.is_ok() && matches!(t.place, tools::Place::Local { .. }) && !local_on {
- let reason = format!("在你的電腦上執行任務,而不只是在它自己的電腦上:{}{what}\n(在 {} 上)", if is_web { format!("{} ", web::label(&call.name)) } else { String::new() }, t.name);
+ let reason = format!("在「{}」上執行任務,而不只是在它自己的電腦上:{}{what}", t.name, if is_web { format!("{} ", web::label(&call.name)) } else { String::new() });
match ask_person(ApprovalKind::EnableThisComputer, reason, "local:on".into()) {
ApprovalAnswer::Once => said_yes = true,
ApprovalAnswer::Always(_) => {
local_on = true;
said_yes = true;
}
- ApprovalAnswer::Reject => gate = Err(format!("使用者沒有允許在「{}」上執行。可以改用「{}」,或問使用者。", t.name, crate::model::BOT_COMPUTER_NAME)),
+ ApprovalAnswer::Reject => gate = Err(format!("使用者沒有允許在「{}」上執行。可以改用「{}」,或問使用者。", t.name, job.roles.own)),
}
}
}
@@ -934,9 +992,9 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
(Ok(()), Ok(t)) => {
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running));
let ids = tools::RunIds { conversation: bot_id.to_string(), request: call.id.clone() };
- let (out, ok) = if is_mac {
+ let (out, ok) = if is_mac && on_device.is_none() {
crate::macapps::run(&call.name, &call.args)
- } else if let (true, Some(host)) = (is_screen || is_web, on_device.as_ref()) {
+ } else if let (true, Some(host)) = (is_screen || is_web || is_mac, on_device.as_ref()) {
match crate::remote::run_on_device(host, &call.name, &call.args, &job.via_name) {
Ok(v) => {
image = v["image"].as_str().map(String::from);
@@ -967,7 +1025,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
(Err(x), _) => x,
};
send(StreamEvent::ToolStatus(call.id.clone(), status));
- let mut result = Message::tool_result(&call.id, &call.name, &output);
+ let mut result = Message::tool_result(&call.id, &call.name, ¬ed(&moved, &call.id, &output));
result.images.extend(image);
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
@@ -988,6 +1046,28 @@ mod tests {
use crate::model::ToolCall;
use crate::providers::ProviderKind;
+
+ /// A job with nothing in it, for tests that only need a few fields.
+ fn test_job() -> Job {
+ Job {
+ req: ChatRequest { kind: ProviderKind::Custom, base_url: String::new(), api_key: String::new(), model: String::new(), system: String::new(), history: vec![], tools: vec![], session: String::new() },
+ computers: vec![],
+ default_computer: String::new(),
+ roles: tools::Roles::default(),
+ local_execution: false,
+ grants: vec![],
+ context_tokens: DEFAULT_CONTEXT_TOKENS,
+ via: String::new(),
+ via_name: String::new(),
+ tools_enabled: true,
+ direct: Arc::new(AtomicBool::new(false)),
+ local_locked: false,
+ steer: Arc::new(Mutex::new(vec![])),
+ review: None,
+ demos: vec![],
+ }
+ }
+
#[test]
fn computer_words_need_word_boundaries() {
// "mac" as its own word names this computer; inside longer words (macOS, a device
@@ -1000,9 +1080,51 @@ mod tests {
assert!(finds("我的電腦好慢", "我的電腦"));
assert!(!finds("你的虛擬電腦很吵", "bot 的電腦"));
assert!(finds("在 bot 的電腦跑", "bot 的電腦"));
- let t = tools::Target { name: crate::model::THIS_COMPUTER_NAME.into(), place: tools::Place::Local { workdir: Default::default() } };
- assert!(mentions("幫我在 Mac 截圖", &t));
- assert!(!mentions("在 macOS 的登入機制下,幫 Bot 的電腦截圖", &t));
+ let roles = tools::Roles { own: "Master 的電腦".into(), person: Some("王小明的Mac mini".into()), server: true };
+ let mac = tools::Target { name: "王小明的Mac mini".into(), place: tools::Place::Device { host: "m".into() } };
+ assert!(mentions("幫我在 Mac 截圖", &mac, &roles));
+ assert!(!mentions("在 macOS 的登入機制下,幫 Bot 的電腦截圖", &mac, &roles));
+ }
+
+ #[test]
+ fn every_computer_answers_to_its_own_name() {
+ // One name per computer: the bot's own goes by the bot's name, the others by theirs, and
+ // the everyday words point at the right one (「我的電腦」 = where the person typed).
+ let roles = tools::Roles { own: "Master 的電腦".into(), person: Some("王小明的Mac mini".into()), server: true };
+ let own = tools::Target { name: "Master 的電腦".into(), place: tools::Place::Box { dir: String::new() } };
+ let mac = tools::Target { name: "王小明的Mac mini".into(), place: tools::Place::Device { host: "m".into() } };
+ let desk = tools::Target { name: "ai".into(), place: tools::Place::Device { host: "a".into() } };
+ let server = tools::Target { name: "雲端大腦".into(), place: tools::Place::Local { workdir: Default::default() } };
+ assert!(mentions("在 Master 的電腦打開 Threads", &own, &roles));
+ assert!(mentions("用master的電腦", &own, &roles));
+ assert!(mentions("你都用 Bot的電腦", &own, &roles));
+ assert!(mentions("在 Mac mini 上開", &mac, &roles));
+ assert!(mentions("截圖我的電腦", &mac, &roles));
+ assert!(!mentions("截圖我的電腦", &desk, &roles));
+ assert!(!mentions("截圖我的電腦", &own, &roles));
+ assert!(mentions("在 ai 上跑", &desk, &roles));
+ assert!(!mentions("用 AI 幫我想標題", &mac, &roles));
+ assert!(mentions("在伺服器上看看磁碟", &server, &roles));
+ }
+
+ #[test]
+ fn a_step_moved_back_to_the_chosen_computer_says_so() {
+ let roles = tools::Roles { own: "Master 的電腦".into(), person: Some("王小明的Mac mini".into()), server: true };
+ let job = Job {
+ computers: vec![
+ tools::Target { name: "Master 的電腦".into(), place: tools::Place::Box { dir: String::new() } },
+ tools::Target { name: "王小明的Mac mini".into(), place: tools::Place::Device { host: "m".into() } },
+ ],
+ default_computer: "Master 的電腦".into(),
+ roles,
+ ..test_job()
+ };
+ // Not asked for: back to the chosen one, with a word for the bot.
+ let (args, note) = keep_to_chosen(&job, &[Message::user("幫我截圖")], r#"{"computer":"王小明的Mac mini"}"#);
+ assert!(args.contains("Master 的電腦") && note.unwrap().contains("王小明的Mac mini"));
+ // Named by the person: it stays.
+ let (args, note) = keep_to_chosen(&job, &[Message::user("看一下我的電腦畫面")], r#"{"computer":"王小明的Mac mini"}"#);
+ assert!(args.contains("王小明的Mac mini") && note.is_none());
}
#[test]
@@ -1069,6 +1191,7 @@ mod tests {
req: ChatRequest { kind: ProviderKind::Custom, base_url: format!("http://127.0.0.1:{port}/v1"), api_key: String::new(), model: "m".into(), system: String::new(), history: vec![Message::user("有什麼檔案?")], tools: tools::specs(), session: String::new() },
computers: vec![tools::Target { name: crate::model::THIS_COMPUTER_NAME.into(), place: tools::Place::Local { workdir: w } }],
default_computer: crate::model::THIS_COMPUTER_NAME.into(),
+ roles: tools::Roles { own: crate::model::BOT_COMPUTER_NAME.into(), person: Some(crate::model::THIS_COMPUTER_NAME.into()), server: false },
local_execution: true,
grants: vec![],
context_tokens: DEFAULT_CONTEXT_TOKENS,
@@ -1140,6 +1263,7 @@ mod tests {
req: ChatRequest { kind: ProviderKind::Custom, base_url: format!("http://127.0.0.1:{port}/v1"), api_key: String::new(), model: "m".into(), system: String::new(), history: vec![Message::user("打招呼")], tools: vec![], session: String::new() },
computers: vec![],
default_computer: crate::model::BOT_COMPUTER_NAME.into(),
+ roles: tools::Roles { own: crate::model::BOT_COMPUTER_NAME.into(), person: Some(crate::model::THIS_COMPUTER_NAME.into()), server: false },
local_execution: false,
grants: vec![],
context_tokens: DEFAULT_CONTEXT_TOKENS,
@@ -1217,6 +1341,7 @@ mod tests {
req: ChatRequest { kind: ProviderKind::Custom, base_url: format!("http://127.0.0.1:{port}/v1"), api_key: String::new(), model: "m".into(), system: String::new(), history: vec![Message::user("看看我的電腦")], tools: tools::specs(), session: String::new() },
computers: vec![tools::Target { name: crate::model::THIS_COMPUTER_NAME.into(), place: tools::Place::Local { workdir: w } }],
default_computer: crate::model::THIS_COMPUTER_NAME.into(),
+ roles: tools::Roles { own: crate::model::BOT_COMPUTER_NAME.into(), person: Some(crate::model::THIS_COMPUTER_NAME.into()), server: false },
local_execution: false,
tools_enabled: false,
direct: Arc::new(AtomicBool::new(false)),
@@ -1290,6 +1415,7 @@ mod tests {
req: ChatRequest { kind: ProviderKind::Custom, base_url: format!("http://127.0.0.1:{port}/v1"), api_key: String::new(), model: "m".into(), system: String::new(), history: vec![Message::user("做一個檔案")], tools: tools::specs(), session: String::new() },
computers: vec![tools::Target { name: crate::model::THIS_COMPUTER_NAME.into(), place: tools::Place::Local { workdir: w.clone() } }],
default_computer: crate::model::THIS_COMPUTER_NAME.into(),
+ roles: tools::Roles { own: crate::model::BOT_COMPUTER_NAME.into(), person: Some(crate::model::THIS_COMPUTER_NAME.into()), server: false },
local_execution: false,
tools_enabled: false,
direct: Arc::new(AtomicBool::new(true)),
diff --git a/src/app.rs b/src/app.rs
index 0c8bc84..8f2b66b 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -180,6 +180,9 @@ pub struct App {
rm: remote_ui::RemoteUi,
/// Running as `anybot --serve` (no window; the phone is the screen).
headless: bool,
+ /// The computer the person last typed to a bot on: an account computer's id, "" for this
+ /// one, None when not known (the phone isn't a computer). It is "我的電腦" to the bots.
+ typed_on: Option,
/// 右側面板 and its tab (None = closed).
panel: Option,
/// When each emoji reaction appeared (for its pop) and when each message first showed (fade-in).
@@ -277,6 +280,7 @@ impl App {
msg_index: 0,
rm: Default::default(),
headless: false,
+ typed_on: None,
panel: None,
popped: HashMap::new(),
appeared: HashMap::new(),
@@ -638,6 +642,8 @@ impl App {
return;
}
self.drafts.insert(bot_id, String::new());
+ // Typed here: this computer is the person's.
+ self.typed_on = Some(String::new());
// While it works, a new message steers the running turn (the original's `turnSteer`:
// it doesn't count as a new turn) and reaches the model at its next step.
if let Some(l) = self.streams.get(&bot_id) {
@@ -685,24 +691,42 @@ impl App {
// Computer tools are always offered; if they're off for this bot (or this Mac isn't
// allowed yet), using one pops up a request instead of failing quietly.
let computers = self.targets_for(&b);
+ let roles = self.roles_for(&b, &computers);
let names: Vec = computers.iter().map(|t| t.name.clone()).collect();
- let default_computer = if names.contains(&b.default_computer) { b.default_computer.clone() } else { crate::model::BOT_COMPUTER_NAME.to_string() };
+ let (default_computer, missing_computer) = Self::chosen_computer(&b, &computers, &roles);
let mut tool_specs = appctl::specs();
tool_specs.extend(tools::specs_for(&names, &default_computer));
- let devices: Vec = computers.iter().filter(|t| matches!(t.place, tools::Place::Device { .. })).map(|t| t.name.clone()).collect();
- let here = computers.iter().find(|t| matches!(t.place, tools::Place::Local { .. })).map(|t| t.name.clone()).unwrap_or_else(|| crate::model::THIS_COMPUTER_NAME.into());
- tool_specs.extend(web::specs_for(&here, &devices));
- // Screen tools: the bot's own desktop (when it has one) and this Mac.
- let mut screens = vec![];
- if crate::cloudbox::available() {
- screens.push(crate::model::BOT_COMPUTER_NAME.to_string());
- }
- if crate::computer::screen_supported() {
- screens.extend(computers.iter().filter(|t| matches!(t.place, tools::Place::Local { .. })).map(|t| t.name.clone()));
- }
- // …and the account's other computers (手腳): their own AnyBot looks and clicks there.
- screens.extend(devices.iter().cloned());
- if cfg!(target_os = "macos") {
+ // Browsers: the bot's own computer's Chromium, this computer's AnyBot browser, and the
+ // account's other computers' ones (each with its own logins).
+ let browsers: Vec = computers
+ .iter()
+ .filter(|t| match t.place {
+ tools::Place::Box { .. } => crate::cloudbox::available(),
+ tools::Place::Local { .. } => crate::botbrowser::available(),
+ tools::Place::Device { .. } => true,
+ _ => false,
+ })
+ .map(|t| t.name.clone())
+ .collect();
+ let browser_default = if browsers.contains(&default_computer) { default_computer.clone() } else { roles.person.clone().filter(|p| browsers.contains(p)).or_else(|| browsers.first().cloned()).unwrap_or_default() };
+ tool_specs.extend(web::specs_for(&browsers, &browser_default));
+ // Screens: the bot's own desktop (when it has one), this computer's (when it has one), and
+ // the account's other computers (their own AnyBot looks and clicks there).
+ let screens: Vec = computers
+ .iter()
+ .filter(|t| match t.place {
+ tools::Place::Box { .. } => crate::cloudbox::available(),
+ tools::Place::Local { .. } => crate::computer::screen_supported(),
+ tools::Place::Device { .. } => true,
+ _ => false,
+ })
+ .map(|t| t.name.clone())
+ .collect();
+ // The person's Mac apps (mail, notes, calendar…): here on a Mac, or on the computer the
+ // person is using when this is a server.
+ let person_is_device = roles.person.as_ref().map(|n| computers.iter().any(|t| &t.name == n && matches!(t.place, tools::Place::Device { .. }))).unwrap_or(false);
+ let mac_apps = cfg!(target_os = "macos") || (roles.server && person_is_device);
+ if mac_apps {
tool_specs.extend(crate::macapps::specs().into_iter().filter(|t| self.state.messages_send || t["name"] != crate::macapps::SEND_MESSAGE));
}
tool_specs.extend(crate::mcp::specs());
@@ -741,7 +765,7 @@ impl App {
api_key: providers::resolve_key(b.provider, &ps.api_key),
model: b.model.clone(),
system: {
- let mut sys = system_prompt(&b, &self.state, &computers, &default_computer);
+ let mut sys = system_prompt(&b, &self.state, &computers, &default_computer, &roles, missing_computer.as_deref(), &self.computer_lines(&computers, &roles), mac_apps);
if speaker.is_some() {
let names: Vec = conv.members.iter().filter_map(|m| self.state.bot(*m)).map(|x| x.name.clone()).collect();
sys.push_str(&format!("\n\n你在群組聊天「{}」裡,成員依序回答:{}。其他成員的發言以【名字】標示。只回答你擅長的部分,補充不同的觀點,不要重複別人說過的;沒有要補充就簡短說一句。", conv.name, names.join("、")));
@@ -767,6 +791,7 @@ impl App {
req,
computers,
default_computer,
+ roles,
local_execution: self.local_on(),
local_locked: !self.managed.local.allows() || self.state.local_never,
tools_enabled: b.tools_enabled,
@@ -790,73 +815,202 @@ impl App {
self.mark();
}
- /// The computers a bot can be told to use (its own, this Mac, then SSH ones).
- fn targets_for(&self, b: &Bot) -> Vec {
- let mut v = vec![
- tools::Target {
- name: crate::model::BOT_COMPUTER_NAME.into(),
- place: if crate::cloudbox::available() { tools::Place::Box { dir: crate::cloudbox::bot_dir(b.id) } } else { tools::Place::Sealed { root: crate::model::bot_computer_dir(b.id) } },
- },
- tools::Target {
- name: crate::model::THIS_COMPUTER_NAME.into(),
- place: tools::Place::Local { workdir: tools::expand_home(if b.workdir.trim().is_empty() { "~" } else { b.workdir.trim() }) },
- },
- ];
- v.extend(self.state.remote_computers.iter().map(|c| tools::Target { name: c.name.clone(), place: tools::Place::Ssh { target: c.ssh_target.clone(), dir: c.dir.clone() } }));
- // 裝置連結: other AnyBots of this account that allow it.
- v.extend(self.linked_devices().into_iter().filter(|d| !v.iter().any(|t| t.name == d.name)).collect::>());
- // A brain on a server whose hands are the person's computer: "你的電腦" is that computer
- // (what the person means by "my Mac"), and the server itself goes by its own name.
- if !cfg!(target_os = "macos") {
- if let Some(hand) = crate::remote::my_hands(&self.state.remote.host_id).into_iter().next() {
- if let Some(i) = v.iter().position(|t| matches!(&t.place, tools::Place::Device { host } if *host == hand)) {
- for t in v.iter_mut() {
- if matches!(t.place, tools::Place::Local { .. }) {
- t.name = crate::model::BRAIN_HOST_NAME.into();
- }
- }
- v[i].name = crate::model::THIS_COMPUTER_NAME.into();
- }
- } else if self.headless {
- // A server brain with no hands connected: the machine it runs on is a server,
- // not anyone's computer. Wearing the name 你的電腦 here made bots "work on your
- // computer" on the server while the person watched their own untouched desktop.
- for t in v.iter_mut() {
- if matches!(t.place, tools::Place::Local { .. }) {
- t.name = crate::model::BRAIN_HOST_NAME.into();
- }
- }
+ /// The computers a bot can be told to use, each under its own name: the bot's own
+ /// (「Master 的電腦」), this computer, the SSH ones, then the account's other computers.
+ pub(super) fn targets_for(&self, b: &Bot) -> Vec {
+ let mut v = vec![tools::Target {
+ name: crate::model::own_computer_name(&b.name),
+ place: if crate::cloudbox::available() { tools::Place::Box { dir: crate::cloudbox::bot_dir(b.id) } } else { tools::Place::Sealed { root: crate::model::bot_computer_dir(b.id) } },
+ }];
+ // Two computers never share a name (the bot and the person name them to choose).
+ let add = |v: &mut Vec, mut t: tools::Target| {
+ let base = t.name.clone();
+ let mut n = 2;
+ while v.iter().any(|x| x.name == t.name) {
+ t.name = format!("{base} ({n})");
+ n += 1;
}
+ v.push(t);
+ };
+ add(&mut v, tools::Target { name: self.state.this_computer_name(), place: tools::Place::Local { workdir: tools::expand_home(if b.workdir.trim().is_empty() { "~" } else { b.workdir.trim() }) } });
+ for c in &self.state.remote_computers {
+ add(&mut v, tools::Target { name: c.name.clone(), place: tools::Place::Ssh { target: c.ssh_target.clone(), dir: c.dir.clone() } });
+ }
+ // 裝置連結: other AnyBots of this account that allow it.
+ for d in self.linked_devices() {
+ add(&mut v, d);
}
v
}
- /// 查看電腦: the computers this bot really has, in plain words.
- fn computers_text(&self, b: &Bot) -> String {
- self.targets_for(b)
- .iter()
- .map(|t| match &t.place {
- tools::Place::Sealed { .. } | tools::Place::Box { .. } => format!("{}:這個 Bot 自己的 Linux 桌面(AI 的電腦,不是使用者的電腦),一律可用", t.name),
- tools::Place::Local { .. } if t.name == crate::model::BRAIN_HOST_NAME => format!("{}:AnyBot 大腦所在的伺服器「{}」(不是使用者的電腦)", t.name, self.state.remote.host_name),
- tools::Place::Local { .. } => format!(
- "{}:{}「{}」,{}",
- t.name,
- if cfg!(target_os = "macos") { "使用者的這台 Mac" } else { "使用者的這台電腦" },
- self.state.machine_label,
- if self.state.local_execution { "已允許" } else { "還沒預先允許(直接用工具,App 會問使用者)" }
+ /// Which of these computers is the bot's own, which one the person is using, and whether
+ /// this AnyBot is a server. The person's computer is the one they last typed on; a server
+ /// that hasn't heard from any yet takes the account's only other computer, if there is one.
+ pub(super) fn roles_for(&self, b: &Bot, targets: &[tools::Target]) -> tools::Roles {
+ let server = self.headless;
+ let local = targets.iter().find(|t| matches!(t.place, tools::Place::Local { .. })).map(|t| t.name.clone());
+ let device = |h: &str| targets.iter().find(|t| matches!(&t.place, tools::Place::Device { host } if host == h)).map(|t| t.name.clone());
+ let person = match self.typed_on.as_deref() {
+ Some(h) if !h.is_empty() => device(h).or_else(|| if server { None } else { local.clone() }),
+ _ if !server => local,
+ // Not heard yet (or only the phone): when the account has one other computer, that
+ // is it; with more, nobody guesses (the bot asks which one).
+ _ => {
+ let mut devices = targets.iter().filter(|t| matches!(t.place, tools::Place::Device { .. }));
+ match (devices.next(), devices.next()) {
+ (Some(only), None) => Some(only.name.clone()),
+ _ => None,
+ }
+ }
+ };
+ tools::Roles { own: crate::model::own_computer_name(&b.name), person, server }
+ }
+
+ /// The computer the person chose for this bot, by its name now (the bot's own is kept under a
+ /// fixed key so renaming the bot keeps it), and the chosen one when it isn't connected.
+ pub(super) fn chosen_computer(b: &Bot, targets: &[tools::Target], roles: &tools::Roles) -> (String, Option) {
+ let want = b.default_computer.trim();
+ if want.is_empty() || want == crate::model::BOT_COMPUTER_NAME || want == roles.own {
+ return (roles.own.clone(), None);
+ }
+ // Kept by older versions: the person's own computer.
+ if want == crate::model::THIS_COMPUTER_NAME {
+ return (roles.person.clone().unwrap_or_else(|| roles.own.clone()), None);
+ }
+ if targets.iter().any(|t| t.name == want) {
+ return (want.to_string(), None);
+ }
+ (roles.own.clone(), Some(want.to_string()))
+ }
+
+ /// What to keep in `default_computer` for a computer chosen by name.
+ pub(super) fn computer_to_keep(b: &Bot, name: &str) -> String {
+ if name == crate::model::own_computer_name(&b.name) {
+ crate::model::BOT_COMPUTER_NAME.to_string()
+ } else {
+ name.to_string()
+ }
+ }
+
+ /// The computers to choose from for a bot (name, what it is) and the chosen one. A screen of a
+ /// brain asks the brain (only it knows the account's computers and which one is chosen).
+ pub(super) fn computer_choices(&self, b: &Bot) -> (Vec<(String, String)>, String) {
+ if self.mir.on {
+ let own = crate::model::own_computer_name(&b.name);
+ return match self.mir.computers.get(&b.id) {
+ Some(c) => (
+ c["names"].as_array().map(|a| a.iter().filter_map(|x| Some((x["name"].as_str()?.to_string(), x["kind"].as_str().unwrap_or("").to_string()))).collect()).unwrap_or_default(),
+ c["current"].as_str().unwrap_or(&own).to_string(),
),
- tools::Place::Ssh { target, .. } => format!("{}:透過 SSH 連到 {target}", t.name),
- tools::Place::Device { host } => {
- let real = crate::remote::peer_name(host);
- if t.name == crate::model::THIS_COMPUTER_NAME {
- format!("{}:使用者的電腦「{real}」(大腦透過它的 AnyBot 看畫面、點擊、開瀏覽器和跑指令)", t.name)
- } else {
- format!("{}:帳號裡的另一台電腦(裝置連結)", t.name)
+ None => (vec![(own.clone(), "own".into())], own),
+ };
+ }
+ let t = self.targets_for(b);
+ let r = self.roles_for(b, &t);
+ let (current, _) = Self::chosen_computer(b, &t, &r);
+ (t.iter().map(|x| (x.name.clone(), mirror_ui::computer_kind(x, &r).to_string())).collect(), current)
+ }
+
+ /// Choose the computer a bot works on (a screen sends it to the brain with its other edits).
+ pub(super) fn set_computer(&mut self, bot_id: u64, name: &str) {
+ if let Some(b) = self.state.bot_mut(bot_id) {
+ let keep = Self::computer_to_keep(b, name);
+ if b.default_computer != keep {
+ b.default_computer = keep;
+ self.mark();
+ }
+ }
+ // Shown at once on a screen (the brain's answer follows).
+ if let Some(c) = self.mir.computers.get_mut(&bot_id) {
+ c["current"] = serde_json::json!(name);
+ }
+ }
+
+ /// 查看電腦 and the instructions: each computer by its name, what it is, in plain words.
+ pub(super) fn computer_lines(&self, targets: &[tools::Target], roles: &tools::Roles) -> Vec {
+ targets
+ .iter()
+ .map(|t| {
+ let yours = roles.person.as_deref() == Some(t.name.as_str());
+ match &t.place {
+ tools::Place::Box { dir } => {
+ let secrets = crate::cloudbox::secret_names();
+ format!(
+ "「{}」:你自己的 Linux 電腦(Debian,有桌面和 Chromium;你的資料夾是 {dir},可以 sudo 安裝套件),不是使用者的電腦,一律可用,需要時會自動喚醒。{}",
+ t.name,
+ if secrets.is_empty() { String::new() } else { format!("使用者設好的環境密鑰(已是環境變數,值不要印出來):{}。", secrets.join("、")) }
+ )
}
+ tools::Place::Sealed { root } => format!("「{}」:你自己的電腦(獨立資料夾 {},看不到使用者的檔案;沒有螢幕),一律可用。", t.name, root.display()),
+ tools::Place::Local { workdir } if roles.server => format!("「{}」:AnyBot 大腦所在的伺服器(起始資料夾 {}),不是使用者在用的電腦;只有使用者指名它才用。", t.name, workdir.display()),
+ tools::Place::Local { workdir } => format!(
+ "「{}」:AnyBot 開著的這台{}(起始資料夾 {}){},{}。",
+ t.name,
+ if cfg!(target_os = "macos") { " Mac" } else { "電腦(快捷鍵用 Ctrl,不是 ⌘)" },
+ workdir.display(),
+ if yours { ",使用者現在就在用這台" } else { "" },
+ if self.state.local_execution { "已允許" } else { "還沒預先允許(直接用工具,App 會問使用者)" }
+ ),
+ tools::Place::Ssh { target, .. } => format!("「{}」:使用者的另一台電腦,透過 SSH 連到 {target}。", t.name),
+ tools::Place::Device { .. } => format!(
+ "「{}」:使用者帳號裡的電腦,透過它上面開著的 AnyBot 操作(檔案、指令、瀏覽器、看畫面和點擊){}。",
+ t.name,
+ if yours { ",使用者現在就是在這台跟你說話" } else { "" }
+ ),
}
})
- .collect::>()
- .join("\n")
+ .collect()
+ }
+
+ /// 查看電腦: the computers this bot really has, in plain words.
+ fn computers_text(&self, b: &Bot) -> String {
+ let targets = self.targets_for(b);
+ let roles = self.roles_for(b, &targets);
+ let (chosen, missing) = Self::chosen_computer(b, &targets, &roles);
+ let mut lines = self.computer_lines(&targets, &roles);
+ lines.push(format!("使用者選的電腦:「{chosen}」"));
+ if let Some(m) = missing {
+ lines.push(format!("(使用者原本選的「{m}」現在沒有連線:那台的 AnyBot 沒開或沒登入。)"));
+ }
+ lines.join("\n")
+ }
+
+ /// update_bot with a computer: the names are this bot's computers (the account's ones too),
+ /// which only the app knows; the rest of the change goes through appctl as usual.
+ fn update_bot_computer(&mut self, bot_id: u64, args: &str) -> appctl::Outcome {
+ let mut v: serde_json::Value = serde_json::from_str(args).unwrap_or_default();
+ let want = v["default_computer"].as_str().unwrap_or("").trim().to_string();
+ let fail = |text: String| appctl::Outcome { text, ok: false, wake: vec![], theme_changed: false, page: None };
+ let target = match v["bot"].as_str().map(str::trim).filter(|n| !n.is_empty()) {
+ Some(n) => match self.state.bots.iter().find(|b| b.name == n) {
+ Some(b) => b.id,
+ None => return fail(format!("找不到叫「{n}」的 Bot")),
+ },
+ None => bot_id,
+ };
+ let Some(b) = self.state.bot(target).cloned() else { return fail("找不到這個 Bot".into()) };
+ let targets = self.targets_for(&b);
+ let roles = self.roles_for(&b, &targets);
+ let name = if want == crate::model::BOT_COMPUTER_NAME || want == crate::model::THIS_COMPUTER_NAME { Self::chosen_computer(&Bot { default_computer: want.clone(), ..b.clone() }, &targets, &roles).0 } else { want.clone() };
+ if !targets.iter().any(|t| t.name == name) {
+ return fail(format!("沒有「{want}」這台電腦。可以用:{}", targets.iter().map(|t| t.name.as_str()).collect::>().join("、")));
+ }
+ if let Some(bm) = self.state.bot_mut(target) {
+ bm.default_computer = Self::computer_to_keep(&b, &name);
+ }
+ self.mark();
+ if let Some(o) = v.as_object_mut() {
+ o.remove("default_computer");
+ }
+ let more = v.as_object().map(|o| o.keys().any(|k| k != "bot")).unwrap_or(false);
+ let note = format!("已把「{}」的電腦改成「{name}」。", b.name);
+ if more {
+ let mut out = appctl::apply(&mut self.state, bot_id, "update_bot", &v.to_string());
+ out.text = format!("{note}{}", out.text);
+ out
+ } else {
+ appctl::Outcome { text: note, ok: true, wake: vec![], theme_changed: false, page: None }
+ }
}
/// Record how a turn ended (success / cancelled / error) and clear its live state.
@@ -977,6 +1131,8 @@ impl App {
let out = if call.name == "list_computers" {
let text = self.state.bot(bot_id).cloned().map(|b| self.computers_text(&b)).unwrap_or_default();
appctl::Outcome { text, ok: true, wake: vec![], theme_changed: false, page: None }
+ } else if call.name == "update_bot" && serde_json::from_str::(&call.args).map(|v| v["default_computer"].is_string()).unwrap_or(false) {
+ self.update_bot_computer(bot_id, &call.args)
} else {
appctl::apply(&mut self.state, bot_id, &call.name, &call.args)
};
@@ -1737,11 +1893,17 @@ impl App {
ui.close();
}
if menu_ui::item(ui, p, Icon::Eraser, "清除聊天記錄").clicked() {
- self.stop(id);
- if let Some(b) = self.state.bot_mut(id) {
- b.messages.clear();
+ if self.mir.on {
+ // The chat lives on the brain: clearing only this copy left the brain sure this
+ // screen still had it, and its next changes landed in the wrong place.
+ self.mirror_act(serde_json::json!({"bot": id, "action": "clear"}));
+ } else {
+ self.stop(id);
+ if let Some(b) = self.state.bot_mut(id) {
+ b.messages.clear();
+ }
+ self.mark();
}
- self.mark();
ui.close();
}
if menu_ui::danger(ui, p, Icon::Trash, "刪除").clicked() {
@@ -2169,6 +2331,10 @@ impl App {
ui.label(RichText::new(format!("{}{}", t("成員:"), names.join("、"))).size(FONT_XS).color(p.text2));
}
});
+ // 在哪台電腦做事: always in sight, one click to change.
+ if bot.members.is_empty() {
+ self.computer_chip(ui, bot);
+ }
let right_w = 28.0 * 2.0 + 8.0 + 4.0;
let text_w = (ui.available_width() - right_w).max(60.0);
let hint = if self.mir.on && !self.mir.online { t("連不到大腦,現在只能看") } else { t("傳訊息給 {name}").replace("{name}", &bot.name) };
@@ -2437,6 +2603,38 @@ impl App {
}
}
+ /// The computer the bot works on, as a small button next to the message box; it opens the
+ /// list of computers (each by its name) to choose another.
+ fn computer_chip(&mut self, ui: &mut egui::Ui, bot: &Bot) {
+ let p = self.pal;
+ let (choices, current) = self.computer_choices(bot);
+ let short: String = if current.chars().count() > 14 { format!("{}…", current.chars().take(13).collect::()) } else { current.clone() };
+ let chip = crate::icons::pill(ui, crate::icons::Icon::Monitor, &short, p.hover, p.text2, 28.0).on_hover_text(t("在「{name}」上做事 · 點一下換電腦").replace("{name}", ¤t));
+ let mut pick: Option = None;
+ egui::Popup::menu(&chip).show(|ui| {
+ ui.set_min_width(menu_ui::MENU_W + 40.0);
+ ui.label(RichText::new(t("要 {name} 在哪台電腦做事").replace("{name}", &bot.name)).size(FONT_XS).color(p.text3));
+ for (name, kind) in &choices {
+ let what = t(match kind.as_str() {
+ "own" => "它自己的電腦",
+ "person" => "你正在用的這台",
+ "this" => "這台電腦",
+ "server" => "大腦所在的伺服器",
+ "device" => "帳號裡的電腦",
+ _ => "SSH 電腦",
+ });
+ let icon = if *name == current { crate::icons::Icon::Check } else { crate::icons::Icon::Monitor };
+ if menu_ui::item_text(ui, p, Some(icon), &format!("{name} · {what}")).clicked() {
+ pick = Some(name.clone());
+ ui.close();
+ }
+ }
+ });
+ if let Some(n) = pick {
+ self.set_computer(bot.id, &n);
+ }
+ }
+
fn model_picker(&mut self, ui: &mut egui::Ui, bot: &Bot) {
let p = self.pal;
let label = format!("{} · {}", short_provider(bot.provider), bot.model);
@@ -2583,15 +2781,21 @@ impl App {
ui.checkbox(&mut b.tools_enabled, t("讓這個 Bot 查看、修改檔案和執行指令"));
if b.tools_enabled {
ui.add_space(6.0);
- form_row(ui, "預設電腦", p, |ui| {
- let names = self.state.computer_names();
- egui::ComboBox::from_id_salt("default_computer").selected_text(b.default_computer.clone()).width(150.0).show_ui(ui, |ui| {
- for n in names {
- let label = if n == crate::model::THIS_COMPUTER_NAME && !self.state.local_execution { format!("{n}(要先在設定裡允許)") } else { n.clone() };
- ui.selectable_value(&mut b.default_computer, n, label);
+ form_row(ui, "做事的電腦", p, |ui| {
+ let (choices, current) = self.computer_choices(&b);
+ let mut pick = None;
+ egui::ComboBox::from_id_salt("default_computer").selected_text(current.clone()).width(190.0).show_ui(ui, |ui| {
+ for (n, kind) in choices {
+ let label = if kind == "this" && !self.state.local_execution { format!("{n}(要先在設定裡允許)") } else { n.clone() };
+ if ui.selectable_label(n == current, label).clicked() {
+ pick = Some(n);
+ }
}
});
- ui.label(RichText::new(t("聊天時說「在我的 Mac 上」就會換")).size(11.5).color(p.text3));
+ if let Some(n) = pick {
+ b.default_computer = Self::computer_to_keep(&b, &n);
+ }
+ ui.label(RichText::new(t("聊天時指名另一台(例如「在 Mac mini 上」)就會換")).size(11.5).color(p.text3));
});
form_row(ui, "起始資料夾", p, |ui| {
ui.add(egui::TextEdit::singleline(&mut b.workdir).hint_text(t("在你的電腦上,例如 ~/Documents/專案")).desired_width(ui.available_width()));
@@ -3680,7 +3884,8 @@ fn transcript_markdown(b: &Bot) -> String {
}
/// What the bot is told about itself, the app it lives in and the computers it can use.
-fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_computer: &str) -> String {
+#[allow(clippy::too_many_arguments)]
+fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_computer: &str, roles: &tools::Roles, missing: Option<&str>, lines: &[String], mac_apps: bool) -> String {
let mut s = format!("你是「{}」。", b.name);
if !b.description.trim().is_empty() {
s.push_str(&format!("你的角色:{}。", b.description.trim()));
@@ -3689,24 +3894,17 @@ fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_compu
s.push_str("你是使用者的主要 Bot:主要聯絡窗口,可以主動幫忙,也能用 message_bot 把工作交給其他 Bot。");
}
let now = crate::routines::now_in(&st.timezone);
- // Which screens can really be operated, named as the model must name them: the fixed
- // wording promised "你的電腦(使用者的 Mac)" on server brains that have no such computer,
- // and bots then "worked on your computer" on the server itself.
+ // Which screens can really be operated, each by its computer's name.
let mut screens: Vec = Vec::new();
for t in computers {
match &t.place {
- tools::Place::Box { .. } => screens.push(format!("{}(AI 自己的 Linux 桌面,不是使用者的電腦)", t.name)),
- tools::Place::Local { .. } => {
- if crate::computer::screen_supported() {
- screens.push(format!("{}(這台電腦自己的畫面)", t.name));
- }
- }
- tools::Place::Device { host } if t.name == crate::model::THIS_COMPUTER_NAME => screens.push(format!("{}(使用者自己的電腦「{}」的畫面)", t.name, crate::remote::peer_name(host))),
- tools::Place::Device { .. } => screens.push(format!("{}(帳號裡另一台電腦的畫面)", t.name)),
- tools::Place::Ssh { .. } | tools::Place::Sealed { .. } => {}
+ tools::Place::Box { .. } if crate::cloudbox::available() => screens.push(format!("「{}」", t.name)),
+ tools::Place::Local { .. } if crate::computer::screen_supported() => screens.push(format!("「{}」", t.name)),
+ tools::Place::Device { .. } => screens.push(format!("「{}」", t.name)),
+ _ => {}
}
}
- let screens = if screens.is_empty() { "目前沒有任何可操作的螢幕".to_string() } else { screens.join(";") };
+ let screens = if screens.is_empty() { "目前沒有任何可操作的螢幕".to_string() } else { format!("可操作的螢幕:{}", screens.join("、")) };
s.push_str(&format!(
"\n\n現在是 {}({},時區 {})。\n\
你在 AnyBot 這個 App 裡,可以直接操作 App:\n\
@@ -3723,11 +3921,12 @@ fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_compu
- 如果讀到的是登入頁,請使用者在跳出來的 AnyBot 瀏覽器視窗登入一次(之後會記住),登入好再讀。絕對不要叫使用者去改瀏覽器的設定或選單(例如「允許來自 Apple 事件的 JavaScript」),那不需要。也不要說要從 Chrome 複製登入或 Cookie,沒有這個功能。\n\
- AnyBot 瀏覽器有設定檔(profile),每個有自己的登入。使用者說要換 profile/設定檔/另一個帳號時,用 browser_profiles:list 看有哪些,use 切換,add 新增(新的要登入一次)。也可以在 browser_open / browser_read 帶 profile 參數。使用者想用自己平常的 Chrome(自己的 Chrome 設定檔、已經登入的帳號)時,用 profile「你的瀏覽器」:請使用者在 Chrome 切到想要的設定檔,再 browser_read。\n\
- 你也能像人一樣操作螢幕({screens})。computer_use_screenshot 看畫面,再用 computer_use_click / computer_use_typing / computer_use_press_key / computer_use_scroll / computer_use_drag 操作;座標用最新截圖上的像素。每做一步就再截圖確認。用在檔案和網頁工具做不到的事(例如操作其他 App、在 Chrome 切換設定檔)。\n\
- - 使用者 Mac 上的「提醒事項、行事曆、聯絡人、備忘錄、郵件、訊息」有專門的工具(ios_read_reminders、mobile_read_calendar、mobile_read_contacts、mac_read_notes、mac_read_mail 等),比操作畫面快又準;寄信或傳訊息每次都會先請使用者確認,起草郵件(mac_draft_mail)則讓使用者自己按寄出。\n\
+{}\
- 不要說「我沒有瀏覽器或工具」:先試這些工具。需要權限時 App 會自己跳出視窗問使用者;工具回報要開什麼設定時,照實轉告。",
now.format("%Y-%m-%d %H:%M"),
["週一", "週二", "週三", "週四", "週五", "週六", "週日"][now.weekday().num_days_from_monday() as usize],
- st.timezone
+ st.timezone,
+ if mac_apps { " - 使用者 Mac 上的「提醒事項、行事曆、聯絡人、備忘錄、郵件、訊息」有專門的工具(ios_read_reminders、mobile_read_calendar、mobile_read_contacts、mac_read_notes、mac_read_mail 等),比操作畫面快又準,會在使用者正在用的那台 Mac 上做;寄信或傳訊息每次都會先請使用者確認,起草郵件(mac_draft_mail)則讓使用者自己按寄出。\n" } else { "" }
));
if !b.routines.is_empty() {
s.push_str("\n\n你目前的例行任務:\n");
@@ -3740,47 +3939,29 @@ fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_compu
s.push_str(&mine.join("\n"));
}
if !computers.is_empty() {
- let list: Vec = computers
- .iter()
- .map(|t| match &t.place {
- tools::Place::Sealed { root } => format!("- {}:你自己的電腦(獨立資料夾 {},看不到使用者的檔案;沒有螢幕)", t.name, root.display()),
- tools::Place::Box { dir } => {
- let secrets = crate::cloudbox::secret_names();
- format!(
- "- {}:你自己的 Linux 電腦(Debian,有桌面和 Chromium,所有 Bot 共用;你的資料夾是 {dir},可以 sudo 安裝套件)。需要時會自動喚醒。可以用 computer_use_* 工具(computer=「{}」)看它的螢幕和操作滑鼠鍵盤,例如開 chromium 上網、測網頁。{}",
- t.name,
- t.name,
- if secrets.is_empty() { String::new() } else { format!("使用者設好的環境密鑰(已是環境變數,值不要印出來):{}。", secrets.join("、")) }
- )
- }
- tools::Place::Local { workdir } => format!(
- "- {}:{},起始資料夾 {}{}",
- t.name,
- if t.name == crate::model::BRAIN_HOST_NAME { "你(大腦)所在的伺服器,不是使用者的電腦;只有使用者明確要在伺服器上做事才用" } else if cfg!(target_os = "macos") { "使用者的這台 Mac" } else if crate::platform::has_desktop() { "使用者的這台 Linux 電腦(快捷鍵用 Ctrl,不是 ⌘)" } else { "AnyBot 所在的伺服器(不是使用者的 Mac;要用使用者的 Mac 請選帳號裡那台電腦的名字)" },
- workdir.display(),
- if st.local_execution { "" } else { "(還沒預先允許:直接使用工具即可,App 會跳出視窗問使用者)" }
- ),
- tools::Place::Ssh { target, .. } => format!("- {}:使用者的另一台電腦(SSH {target})", t.name),
- tools::Place::Device { host } if t.name == crate::model::THIS_COMPUTER_NAME => format!(
- "- {}:使用者自己的電腦「{}」(你在伺服器上,透過它的 AnyBot 操作它:檔案、指令、瀏覽器和 computer_use_* 工具,computer=「{}」)。使用者說「我的電腦」「我的筆電」「這台」或提到 Mac,都是指它。",
- t.name,
- crate::remote::peer_name(host),
- t.name
- ),
- tools::Place::Device { .. } => format!("- {}:使用者帳號裡的另一台電腦(裝置連結;可以用檔案、指令和 computer_use_* 工具,computer=「{}」)", t.name, t.name),
- })
- .collect();
+ let list: Vec = lines.iter().map(|l| format!("- {l}")).collect();
+ let mine = match &roles.person {
+ Some(p) => format!("使用者說「我的電腦」「這台」「我的 Mac」時,指的是「{p}」(他正在用的那台)。"),
+ None => "現在不知道使用者在用哪台電腦:他說「我的電腦」「這台」時,先問他是哪一台。".to_string(),
+ };
+ let gone = match missing {
+ Some(m) => format!("\n- 使用者選的「{m}」現在沒有連線(那台的 AnyBot 沒開或沒登入)。要在那台做事時,告訴使用者先打開它;要改用別台,先問使用者。"),
+ None => String::new(),
+ };
s.push_str(&format!(
- "\n\n你可以用工具在電腦上做事。每個工具都有 computer 參數,預設是「{default_computer}」。\n{}\n\
- - 使用者替你選的電腦是「{default_computer}」:檔案、指令、看畫面、點擊和瀏覽器都在這台做,不要自己換到別台。只有使用者在訊息裡指名另一台(「在我的 Mac 上」「在 Bot 的電腦上」),才把 computer 設成那台。\n\
- - 不確定使用者指的是哪台電腦時(例如他說「我的電腦」而那台不在清單上、有幾台都說得通、或他的說法和你手上的預設不一致),先用一句話問他要哪台,問清楚再動手;不要擅作主張選一台就開始做,也不要把一台說成另一台。\n\
+ "\n\n你可以用工具在電腦上做事。每台電腦都用它自己的名字稱呼,工具的 computer 參數就填那個名字:\n{}\n\
+ - 使用者替你選的電腦是「{default_computer}」:檔案、指令、看畫面、點擊和瀏覽器都在這台做,不要自己換到別台。只有使用者在訊息裡指名另一台,才把 computer 設成那台。{gone}\n\
+ - 「{own}」是你自己的電腦;使用者說「你的電腦」「Bot 的電腦」指的就是它。{mine}\n\
+ - 回覆使用者時,說到電腦一律用上面的名字(例如「我在「{own}」打開了 Threads」),不要說「你的電腦」「Bot 的電腦」這種會混淆的說法。\n\
+ - 不確定使用者指的是哪台電腦時(他說的那台不在清單上、有幾台都說得通、或他的說法和你手上的預設不一致),先用一句話問他要哪台,問清楚再動手;不要擅作主張選一台就開始做,也不要把一台說成另一台。\n\
- 先用 list_dir、search、read_file 看清楚,再動手。\n\
- 需要權限時(電腦工具還沒打開、要用使用者的電腦、會改變東西的操作),直接呼叫工具,App 會自己跳出視窗請使用者允許;不要叫使用者自己去改設定。被拒絕就換個方式或問使用者。\n\
- 跑很久的指令會移到背景,用 read_terminal 追蹤,不要重複執行;它在等輸入時(例如問 y/n),用 write_terminal 回答。\n\
- 只有工具結果證明做完了,才說做完;失敗就照實說。\n\
- 連續執行多步驟工具時不要每步都發訊息(先說「好,我先…」,做完一個工具播一段,最後再總結——使用者打一句就看到三四則回覆)。執行時就安靜地連續呼叫工具,全部做完後用一段話講結果;只有卡住、需要使用者動手(例如要他輸入密碼)、或要先問清哪台電腦時,才中途說話。\n\
- 回覆使用者時,用簡單的話說明你做了什麼、在哪台電腦、結果如何。",
- list.join("\n")
+ list.join("\n"),
+ own = roles.own,
));
}
let chans: Vec = st.channels.iter().filter(|c| c.bot_id == b.id && c.enabled).map(|c| format!("{}「{}」", c.kind.label(), c.name)).collect();
diff --git a/src/app/chat_ui.rs b/src/app/chat_ui.rs
index 0e412df..80ac997 100644
--- a/src/app/chat_ui.rs
+++ b/src/app/chat_ui.rs
@@ -459,10 +459,19 @@ impl App {
let waiting = self.approvals.contains_key(&bot.id);
let last = bot.messages.iter().rev().find(|m| m.role == Role::Assistant);
let running = last.and_then(|m| m.tool_calls.iter().rev().find(|c| matches!(c.status, ToolStatus::Running | ToolStatus::Waiting)));
- let on_box = running.map(|c| tools::args_of(&c.args)["computer"].as_str().map(|n| n == crate::model::BOT_COMPUTER_NAME).unwrap_or(true)).unwrap_or(false);
+ // Where it works, by the computer's name (its own computer is 「 的電腦」).
+ let named = running.and_then(|c| tools::args_of(&c.args)["computer"].as_str().map(String::from));
+ let own = crate::model::own_computer_name(&bot.name);
+ let on_box = running.is_some() && named.as_deref().map(|n| n == own || n == crate::model::BOT_COMPUTER_NAME).unwrap_or(true);
let has_text = last.map(|m| !m.text.trim().is_empty()).unwrap_or(false);
let seed = live.turn_id.bytes().fold(0u64, |a, b| a.wrapping_mul(31).wrapping_add(b as u64));
- let phrase = live.status.clone().unwrap_or_else(|| t(&status_phrase(running.map(|c| c.name.as_str()), on_box, waiting, has_text, seed)));
+ let phrase = live.status.clone().unwrap_or_else(|| {
+ let ph = status_phrase(running.map(|c| c.name.as_str()), on_box, waiting, has_text, seed);
+ match &named {
+ Some(n) if ph == "正在你的電腦上工作" => t("正在「{name}」上工作").replace("{name}", n),
+ _ => t(&ph),
+ }
+ });
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.add_space(6.0);
diff --git a/src/app/computers_ui.rs b/src/app/computers_ui.rs
index 6a5be14..2330287 100644
--- a/src/app/computers_ui.rs
+++ b/src/app/computers_ui.rs
@@ -9,6 +9,8 @@ use crate::cloudbox::{self, Rebuild, Run};
/// How often the viewer asks the box for a new picture (estimated).
const VIEWER_FRAME_MS: u64 = 450;
+/// The brain's computer comes over the account server: a picture about every 0.7 s.
+const REMOTE_FRAME_MS: u64 = 700;
/// The floating bar stays this long after the bot's last mouse/keyboard action on this Mac.
const LOCAL_BAR_SECS: u64 = 8;
@@ -61,6 +63,8 @@ pub struct ComputerUi {
thumb_img: Arc>>,
thumb_busy: Arc,
thumb_at: Option,
+ /// The viewer shows the brain's computer (this window is a screen of a brain): how to reach it.
+ remote: Option,
}
enum Input {
@@ -356,7 +360,13 @@ impl App {
sec_title(ui, "Bot 的電腦", p, |_| {});
sec_card(ui, p, |ui| {
let name = self.state.remote.brain_name.clone();
- sec_row(ui, p, "在大腦上", &format!("Bot 用的電腦在大腦「{name}」上,要在那台管理。這台電腦是大腦的手腳:上面的權限決定 Bot 能不能看這台的畫面、操作這台。"), |_| {});
+ let mut view = false;
+ sec_row(ui, p, "在大腦上", &format!("每個 Bot 自己的電腦(例如「Master 的電腦」)在大腦「{name}」上,要在那台管理;這裡可以看它的畫面。這台電腦是大腦的手腳:上面的權限決定 Bot 能不能看這台的畫面、操作這台。"), |ui| {
+ view = ui.add(primary_button("開啟電腦", p)).clicked();
+ });
+ if view {
+ self.open_viewer();
+ }
});
} else {
sec_title(ui, "Bot 的電腦", p, |ui| status_pill(ui, &run, p));
@@ -919,12 +929,19 @@ impl App {
None => self.cu.thumb = Some(ctx.load_texture("box-thumb", img, egui::TextureOptions::LINEAR)),
}
}
- let every = if cloudbox::run_state().is_running() { 3 } else { 30 };
+ // On a screen of a brain the bots' computer is the brain's: its picture comes from there.
+ let brain = self.mir.on.then(|| self.state.remote.clone());
+ let running = if brain.is_some() { self.mir.box_state["running"].as_bool().unwrap_or(false) } else { cloudbox::run_state().is_running() };
+ let every = if running { 3 } else { 30 };
if !self.cu.thumb_at.map(|t| t.elapsed().as_secs() < every).unwrap_or(false) && !self.cu.thumb_busy.swap(true, Ordering::SeqCst) {
self.cu.thumb_at = Some(Instant::now());
let (slot, busy, c) = (self.cu.thumb_img.clone(), self.cu.thumb_busy.clone(), ctx.clone());
std::thread::spawn(move || {
- if let Some(img) = cloudbox::thumbnail(320) {
+ let img = match &brain {
+ Some(s) => crate::remote::sync_screen(s, true).ok().and_then(|b| image::load_from_memory(&b).ok()).map(|i| i.to_rgba8()),
+ None => cloudbox::thumbnail(320),
+ };
+ if let Some(img) = img {
let size = [img.width() as usize, img.height() as usize];
*slot.lock().unwrap() = Some(egui::ColorImage::from_rgba_unmultiplied(size, img.as_raw()));
c.request_repaint();
@@ -939,8 +956,28 @@ impl App {
pub(super) fn open_viewer(&mut self) {
self.cu.viewer_open = true;
self.cu.fit = true;
- cloudbox::set_viewer_open(true);
self.show_settings = false;
+ // A screen of a brain watches the brain's computer (where its bots really work).
+ if self.mir.on {
+ let s = self.state.remote.clone();
+ self.cu.remote = Some(s.clone());
+ if let Some(stop) = self.cu.worker.take() {
+ stop.store(true, Ordering::SeqCst);
+ }
+ self.cu.texture = None;
+ std::thread::spawn(move || {
+ let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "viewer", "on": true}));
+ let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "wake"}));
+ });
+ return;
+ }
+ if self.cu.remote.take().is_some() {
+ if let Some(stop) = self.cu.worker.take() {
+ stop.store(true, Ordering::SeqCst);
+ }
+ self.cu.texture = None;
+ }
+ cloudbox::set_viewer_open(true);
if !cloudbox::run_state().is_running() && !self.demo {
self.box_task(|| cloudbox::ensure_running().map(|_| String::new()));
}
@@ -949,8 +986,15 @@ impl App {
fn close_viewer(&mut self) {
self.cu.viewer_open = false;
self.cu.control = false;
- cloudbox::HELD_BY_PERSON.store(false, Ordering::SeqCst);
- cloudbox::set_viewer_open(false);
+ if let Some(s) = self.cu.remote.clone() {
+ std::thread::spawn(move || {
+ let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "hold", "on": false}));
+ let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "viewer", "on": false}));
+ });
+ } else {
+ cloudbox::HELD_BY_PERSON.store(false, Ordering::SeqCst);
+ cloudbox::set_viewer_open(false);
+ }
if let Some(stop) = self.cu.worker.take() {
stop.store(true, Ordering::SeqCst);
}
@@ -966,6 +1010,54 @@ impl App {
let slot = self.cu.frame.clone();
let c = ctx.clone();
let s2 = stop.clone();
+ let brain = self.cu.remote.clone();
+ if let Some(s) = brain.clone() {
+ // The brain's computer: pictures and the person's input go through the account server.
+ let s_frames = s.clone();
+ std::thread::spawn(move || {
+ let s = s_frames;
+ let mut seq = 0u64;
+ while !s2.load(Ordering::SeqCst) {
+ match crate::remote::sync_screen(&s, false).ok().and_then(|b| image::load_from_memory(&b).ok()) {
+ Some(img) => {
+ let img = img.to_rgba8();
+ seq += 1;
+ let size = [img.width() as usize, img.height() as usize];
+ *slot.lock().unwrap() = Some((seq, egui::ColorImage::from_rgba_unmultiplied(size, img.as_raw())));
+ c.request_repaint();
+ std::thread::sleep(std::time::Duration::from_millis(REMOTE_FRAME_MS));
+ }
+ None => {
+ std::thread::sleep(std::time::Duration::from_millis(1500));
+ c.request_repaint();
+ }
+ }
+ }
+ });
+ let (tx, rx) = channel::();
+ self.cu.input_tx = Some(tx);
+ std::thread::spawn(move || {
+ let mut last_move = Instant::now();
+ while let Ok(i) = rx.recv() {
+ let v = match i {
+ Input::Click(x, y, b, n) => serde_json::json!({"kind": "click", "x": x, "y": y, "button": b, "count": n}),
+ // Hovering sends many moves; a few a second are enough over the network.
+ Input::Move(x, y) => {
+ if last_move.elapsed().as_millis() < 250 {
+ continue;
+ }
+ last_move = Instant::now();
+ serde_json::json!({"kind": "move", "x": x, "y": y})
+ }
+ Input::Scroll(x, y, down, n) => serde_json::json!({"kind": "scroll", "x": x, "y": y, "down": down, "steps": n}),
+ Input::Key(k) => serde_json::json!({"kind": "key", "key": k}),
+ Input::Text(t) => serde_json::json!({"kind": "text", "text": t}),
+ };
+ let _ = crate::remote::sync_post(&s, "screen", &v);
+ }
+ });
+ return;
+ }
std::thread::spawn(move || {
let mut seq = 0u64;
while !s2.load(Ordering::SeqCst) {
@@ -1014,13 +1106,25 @@ impl App {
}
}
}
- let run = cloudbox::run_state();
+ let brain = self.cu.remote.is_some();
+ // The brain's computer reports its state with the brain's answers.
+ let run = if brain {
+ match (self.mir.box_state["running"].as_bool().unwrap_or(false), self.mir.box_state["available"].as_bool().unwrap_or(true)) {
+ (true, _) => Run::Running,
+ (false, false) => Run::NoDocker("大腦那台沒有 Docker".into()),
+ _ => Run::Asleep,
+ }
+ } else {
+ cloudbox::run_state()
+ };
let screen = ctx.content_rect();
let open = true;
let default = vec2((screen.width() * 0.62).min(900.0), (screen.width() * 0.62).min(900.0) * 0.625 + 52.0);
- let busy = cloudbox::is_busy();
+ let busy = if brain { self.mir.box_state["busy"].as_bool().unwrap_or(false) } else { cloudbox::is_busy() };
+ let title = self.state.bot(self.state.selected).map(|b| crate::model::own_computer_name(&b.name)).unwrap_or_else(|| crate::model::BOT_COMPUTER_NAME.to_string());
+ let title = if brain { format!("{title}(在大腦「{}」上)", self.state.remote.brain_name) } else { title };
let mut close_clicked = false;
- egui::Window::new("Bot 的電腦")
+ egui::Window::new("box-viewer")
.id(egui::Id::unique("box-viewer"))
.title_bar(false)
.default_size(default)
@@ -1039,7 +1143,7 @@ impl App {
.show(ctx, |ui| {
ui.horizontal(|ui| {
monitor_icon(ui, 20.0, p.text);
- ui.label(RichText::new(crate::model::BOT_COMPUTER_NAME).strong().size(FONT_LG));
+ ui.label(RichText::new(&title).strong().size(FONT_LG));
status_pill(ui, &run, p);
if busy && !self.cu.control {
crate::icons::label(ui, crate::icons::Icon::Bot, &t("Bot 正在操作"), FONT_XS, p.accent);
@@ -1060,7 +1164,7 @@ impl App {
close_clicked = true;
}
ui.add_space(4.0);
- if ui.add(secondary_button("全螢幕")).on_hover_text(t("在瀏覽器用 noVNC 全螢幕開啟")).clicked() {
+ if !brain && ui.add(secondary_button("全螢幕")).on_hover_text(t("在瀏覽器用 noVNC 全螢幕開啟")).clicked() {
let control = self.cu.control;
self.box_task(move || {
let url = cloudbox::viewer_url(control)?;
@@ -1076,7 +1180,15 @@ impl App {
let btn = egui::Button::new(RichText::new(label).strong().color(col)).fill(fill).corner_radius(8).min_size(vec2(96.0, 30.0));
if ui.add_enabled(run.is_running(), btn).on_hover_text(if self.cu.control { "Bot 可以繼續操作" } else { "你自己點擊和輸入;Bot 的操作會先暫停" }).clicked() {
self.cu.control = !self.cu.control;
- cloudbox::HELD_BY_PERSON.store(self.cu.control, Ordering::SeqCst);
+ match self.cu.remote.clone() {
+ Some(s) => {
+ let on = self.cu.control;
+ std::thread::spawn(move || {
+ let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "hold", "on": on}));
+ });
+ }
+ None => cloudbox::HELD_BY_PERSON.store(self.cu.control, Ordering::SeqCst),
+ }
}
});
});
@@ -1114,7 +1226,14 @@ impl App {
if matches!(run, Run::Asleep | Run::Absent) {
let b = egui::Rect::from_center_size(rect.center() + vec2(0.0, 34.0), vec2(90.0, 30.0));
if ui.put(b, primary_button("喚醒", p)).clicked() {
- self.box_task(|| cloudbox::ensure_running().map(|_| String::new()));
+ match self.cu.remote.clone() {
+ Some(s) => {
+ std::thread::spawn(move || {
+ let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "wake"}));
+ });
+ }
+ None => self.box_task(|| cloudbox::ensure_running().map(|_| String::new())),
+ }
}
}
}
diff --git a/src/app/mirror_ui.rs b/src/app/mirror_ui.rs
index 84fcf9c..724e615 100644
--- a/src/app/mirror_ui.rs
+++ b/src/app/mirror_ui.rs
@@ -393,6 +393,27 @@ pub struct Mirror {
fetching: std::collections::HashSet,
/// 加一台電腦: the line to run on the new computer (or why there is none), and when it was made.
pub(super) pair: Arc)>>,
+ /// The bots' own computer on the brain: {label, running, available, busy}.
+ pub(super) box_state: Value,
+ /// Each bot's computers on the brain, by bot id: {names: [{name, kind}], current, missing}.
+ pub(super) computers: HashMap,
+}
+
+/// What a computer is, in a word a screen can show next to its name.
+pub(super) fn computer_kind(t: &crate::tools::Target, r: &crate::tools::Roles) -> &'static str {
+ use crate::tools::Place;
+ if t.name == r.own {
+ "own"
+ } else if r.person.as_deref() == Some(t.name.as_str()) {
+ "person"
+ } else {
+ match t.place {
+ Place::Local { .. } if r.server => "server",
+ Place::Local { .. } => "this",
+ Place::Device { .. } => "device",
+ _ => "ssh",
+ }
+ }
}
/// Where a screen keeps its copy of a file the brain has.
@@ -516,6 +537,23 @@ impl App {
json!({"bot": id, "call_id": a.call_id, "tool": a.tool, "reason": a.reason, "grant": a.grant, "kind": kind})
})
.collect();
+ // The bots' computer lives here; a screen shows its state, not its own computer's.
+ let run = crate::cloudbox::run_state();
+ out["box"] = json!({"label": run.label(), "running": run.is_running(), "available": crate::cloudbox::available(), "busy": crate::cloudbox::is_busy()});
+ // Which computers each bot has (only the brain knows the account's other computers).
+ out["computers"] = self
+ .state
+ .bots
+ .iter()
+ .map(|b| {
+ let t = self.targets_for(b);
+ let r = self.roles_for(b, &t);
+ let (current, missing) = Self::chosen_computer(b, &t, &r);
+ let names: Vec = t.iter().map(|x| json!({"name": x.name, "kind": computer_kind(x, &r)})).collect();
+ (b.id.to_string(), json!({"names": names, "current": current, "missing": missing}))
+ })
+ .collect::>()
+ .into();
out
}
@@ -932,6 +970,14 @@ impl App {
let mut msgs: Vec = serde_json::from_value(d["messages"].clone()).unwrap_or_default();
self.mirror_files(&mut msgs);
if let Some(b) = self.state.bot_mut(id) {
+ // The brain sends what changed from message `from` on, sure this copy has the
+ // ones before. A copy that is shorter (cleared here, or an answer lost on the
+ // way) would get them appended in the wrong place — the same message twice and
+ // half-written replies piling up. Ask for the whole chat again instead.
+ if b.messages.len() < from {
+ self.mir.resync.store(true, Ordering::SeqCst);
+ continue;
+ }
b.messages.truncate(from);
b.messages.extend(msgs);
}
@@ -982,6 +1028,12 @@ impl App {
}
}
}
+ if v["box"].is_object() {
+ self.mir.box_state = v["box"].clone();
+ }
+ if let Some(c) = v["computers"].as_object() {
+ self.mir.computers = c.iter().filter_map(|(k, x)| Some((k.parse().ok()?, x.clone()))).collect();
+ }
let keep: Vec = v["approvals"].as_array().into_iter().flatten().filter_map(|a| a["bot"].as_u64()).collect();
self.approvals.retain(|id, _| keep.contains(id));
if self.state.bot(self.state.selected).is_none() {
diff --git a/src/app/panel_ui.rs b/src/app/panel_ui.rs
index 4e81b11..3290135 100644
--- a/src/app/panel_ui.rs
+++ b/src/app/panel_ui.rs
@@ -159,16 +159,29 @@ impl App {
// 電腦: the bot's own computer with a live picture of its screen (like the original).
panel_title(ui, "電腦", p);
let mut view = false;
- let thumb = if crate::cloudbox::available() { self.box_thumbnail(ctx) } else { None };
+ // On a screen of a brain, the bot's computer is on the brain: its state and picture come
+ // from there (this computer's own box is a different machine).
+ let brain = self.mir.on;
+ let available = if brain { self.mir.box_state["available"].as_bool().unwrap_or(true) } else { crate::cloudbox::available() };
+ let thumb = if available { self.box_thumbnail(ctx) } else { None };
+ let own_name = crate::model::own_computer_name(&bot.name);
+ let brain_name = self.state.remote.brain_name.clone();
panel_card(ui, p, |ui| {
let run = crate::cloudbox::run_state();
- let running = run.is_running();
- let state = if crate::cloudbox::available() { t(&run.label()) } else { t("沒有 Docker") };
+ let running = if brain { self.mir.box_state["running"].as_bool().unwrap_or(false) } else { run.is_running() };
+ let state = if brain {
+ let label = self.mir.box_state["label"].as_str().map(t).unwrap_or_else(|| t("連線中…"));
+ format!("{label} · {}", t("在大腦「{name}」上").replace("{name}", &brain_name))
+ } else if available {
+ t(&run.label())
+ } else {
+ t("沒有 Docker")
+ };
let (rect, r) = ui.allocate_exact_size(vec2(ui.available_width(), 64.0), egui::Sense::click());
if r.hovered() {
ui.painter().rect_filled(rect, 9, p.hover);
}
- ui.painter().text(rect.left_top() + vec2(10.0, 15.0), egui::Align2::LEFT_TOP, t("Bot 的電腦"), egui::FontId::proportional(13.0), p.text);
+ ui.painter().text(rect.left_top() + vec2(10.0, 15.0), egui::Align2::LEFT_TOP, &own_name, egui::FontId::proportional(13.0), p.text);
ui.painter().text(rect.left_top() + vec2(10.0, 33.0), egui::Align2::LEFT_TOP, state, egui::FontId::proportional(12.0), p.text3);
// The screen: 16:10, rounded, the real desktop when there is a picture.
let box_r = egui::Rect::from_min_size(rect.right_top() + vec2(-86.0, 8.0), vec2(78.0, 48.0));
@@ -192,11 +205,11 @@ impl App {
if running {
ui.painter().circle_filled(box_r.right_top() + vec2(-6.0, 6.0), 3.5, Color32::from_rgb(0x14, 0xc3, 0x70));
}
- let tip = if running { "看 Bot 的電腦的螢幕,也可以取得控制權自己操作" } else { "叫醒 Bot 的電腦並看它的螢幕" };
- view = r.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(t(tip)).clicked();
+ let tip = if running { t("看「{name}」的螢幕,也可以取得控制權自己操作") } else { t("叫醒「{name}」並看它的螢幕") }.replace("{name}", &own_name);
+ view = r.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(tip).clicked();
});
if view {
- if crate::cloudbox::available() {
+ if available {
self.open_viewer();
} else {
self.show_settings = true;
diff --git a/src/app/remote_ui.rs b/src/app/remote_ui.rs
index 4c2c7a0..5eaaf9a 100644
--- a/src/app/remote_ui.rs
+++ b/src/app/remote_ui.rs
@@ -258,10 +258,16 @@ impl App {
fn remote_cmd(&mut self, ctx: &egui::Context, c: Cmd) {
match c {
- Cmd::Send { bot, text, attachments } => {
+ Cmd::Send { bot, text, attachments, from } => {
if self.state.bot(bot).is_none() {
return;
}
+ // Typed on one of the account's computers: that one is the person's now (for
+ // 「我的電腦」 and the model on their network). The phone isn't a computer.
+ if !from.is_empty() {
+ remote::set_person_host(&from);
+ self.typed_on = Some(from);
+ }
// Only files the app itself stored in its attachments folder may ride along;
// resolve first, so "../../" paths cannot escape it (the phone can name any path).
let root = crate::model::State::path().parent().map(|p| p.join("attachments")).and_then(|p| p.canonicalize().ok());
@@ -327,6 +333,13 @@ impl App {
}
}
}
+ Cmd::Clear { bot } => {
+ self.stop(bot);
+ if let Some(b) = self.state.bot_mut(bot) {
+ b.messages.clear();
+ }
+ self.mark();
+ }
Cmd::NewThread { bot } => {
self.stop(bot);
if let Some(b) = self.state.bot_mut(bot) {
@@ -743,6 +756,11 @@ impl App {
.filter(|(id, _, online)| !id.is_empty() && *online && *id != self.state.remote.host_id)
// A brain with hands of its own works on those, not on the account's other brains.
.filter(|(id, _, _)| !has_hands || remote::is_hands(id))
+ // A computer showing another brain lends its hands to that one, not to this.
+ .filter(|(id, _, _)| {
+ let uses = remote::peer_uses(id);
+ uses.is_empty() || uses == self.state.remote.host_id
+ })
.map(|(id, name, _)| tools::Target { name: name.clone(), place: tools::Place::Device { host: id.clone() } })
.collect()
}
diff --git a/src/appctl.rs b/src/appctl.rs
index aa0fa46..298798e 100644
--- a/src/appctl.rs
+++ b/src/appctl.rs
@@ -547,18 +547,19 @@ pub fn apply(state: &mut State, bot_id: u64, name: &str, args: &str) -> Outcome
Outcome { text: format!("已交給「{name}」,它的回覆會出現在它自己的聊天裡。"), ok: true, wake: vec![(target, msg)], theme_changed: false, page: None }
}
"list_computers" => {
- // On a server brain (serve), Local is the server, not the person's computer —
- // matching what the app's own list_computers says after its rename.
+ // (The window answers this with the account's computers too; this is the plain list.)
let serving = SERVING.load(std::sync::atomic::Ordering::Relaxed);
+ let own = state.bot(bot_id).map(|b| model::own_computer_name(&b.name)).unwrap_or_else(|| model::BOT_COMPUTER_NAME.into());
+ let here = state.this_computer_name();
let mut lines = vec![
- format!("{}:{}", model::BOT_COMPUTER_NAME, if serving { "這個 Bot 自己的 Linux 電腦(AI 的電腦,不是使用者的電腦,獨立資料夾),一律可用" } else { "你自己的電腦(獨立的資料夾),一律可用" }),
+ format!("「{own}」:你自己的電腦(AI 的電腦,不是使用者的電腦,獨立資料夾),一律可用"),
if serving {
- format!("{}:AnyBot 大腦所在的伺服器(不是使用者的電腦)。使用者的電腦要在它上面裝 AnyBot 並登入同一帳號,才會以「{}」出現", model::BRAIN_HOST_NAME, model::THIS_COMPUTER_NAME)
+ format!("「{here}」:AnyBot 大腦所在的伺服器(不是使用者在用的電腦)")
} else {
- format!("{}:{},{}", model::THIS_COMPUTER_NAME, if cfg!(target_os = "macos") { "這台 Mac" } else { "這台電腦" }, if state.local_execution { "已允許" } else { "使用者還沒在設定裡允許(設定 → 電腦 → 在這台電腦上執行)" })
+ format!("「{here}」:AnyBot 開著的這台{},{}", if cfg!(target_os = "macos") { " Mac" } else { "電腦" }, if state.local_execution { "已允許" } else { "使用者還沒在設定裡允許(設定 → 電腦 → 在這台電腦上執行)" })
},
];
- lines.extend(state.remote_computers.iter().map(|c| format!("{}:透過 SSH 連到 {}", c.name, c.ssh_target)));
+ lines.extend(state.remote_computers.iter().map(|c| format!("「{}」:透過 SSH 連到 {}", c.name, c.ssh_target)));
ok(lines.join("\n"))
}
"update_settings" => {
diff --git a/src/attach.rs b/src/attach.rs
index 3969e4d..5dac729 100644
--- a/src/attach.rs
+++ b/src/attach.rs
@@ -159,7 +159,7 @@ pub fn for_model(atts: &[Attachment]) -> (String, Vec) {
let mut text = String::new();
let mut images = vec![];
for a in atts {
- let where_ = if a.on_box.is_empty() { format!("檔案在使用者的 Mac:{}", a.path) } else { format!("檔案在使用者的 Mac:{};也已放進 Bot 的電腦:{}", a.path, a.on_box) };
+ let where_ = if a.on_box.is_empty() { format!("檔案存在 AnyBot 所在的電腦:{}", a.path) } else { format!("檔案存在 AnyBot 所在的電腦:{};也已放進你自己的電腦:{}", a.path, a.on_box) };
if a.image {
match image_b64(&a.path) {
Some(b) => {
diff --git a/src/botbrowser.rs b/src/botbrowser.rs
index d5a7115..881f262 100644
--- a/src/botbrowser.rs
+++ b/src/botbrowser.rs
@@ -264,7 +264,7 @@ pub fn ensure(profile: &str, first_url: Option<&str>) -> Result<(&'static str, u
let _ = local().put(&endpoint(port, &format!("/json/new?{}", encode_component(u)))).send_empty();
}
}
- return Ok(("Bot 的電腦的 Chromium", port, fresh && first_url.is_some()));
+ return Ok(("它桌面上的 Chromium", port, fresh && first_url.is_some()));
}
let Some((name, bin)) = engine() else { return Err("這台電腦沒有 Chrome、Brave、Edge 或 Chromium,沒辦法開 AnyBot 自己的瀏覽器。".into()) };
if profile == PERSON_PROFILE {
diff --git a/src/cloudbox.rs b/src/cloudbox.rs
index 191e7ab..ca6f246 100644
--- a/src/cloudbox.rs
+++ b/src/cloudbox.rs
@@ -625,7 +625,7 @@ pub fn browser_port() -> Result {
}
std::thread::sleep(Duration::from_millis(300));
}
- Err("Bot 的電腦的瀏覽器沒有及時打開,請再試一次。".into())
+ Err("這台電腦的瀏覽器沒有及時打開,請再試一次。".into())
}
static START_LOCK: Mutex<()> = Mutex::new(());
@@ -1248,7 +1248,7 @@ pub fn computer_run(name: &str, v: &Value) -> Result<(String, Option), S
ensure_running()?;
let _busy = Busy::new(true);
if HELD_BY_PERSON.load(Ordering::SeqCst) && name != "computer_use_screenshot" && name != "computer_use_check_permissions" {
- return Err("使用者正在自己操作 Bot 的電腦(已取得控制權)。請等他交還控制權後再操作,或先截圖看看畫面。".into());
+ return Err("使用者正在自己操作這台電腦(已取得控制權)。請等他交還控制權後再操作,或先截圖看看畫面。".into());
}
let scale = || shared().lock().unwrap().scale.ok_or_else(|| "請先用 computer_use_screenshot 看一下畫面,座標要用截圖上的位置。".to_string());
let pt = |v: &Value, s: (f64, f64)| -> Result<(String, String), String> {
@@ -1275,7 +1275,7 @@ pub fn computer_run(name: &str, v: &Value) -> Result<(String, Option), S
r
};
match name {
- "computer_use_check_permissions" => Ok(("Bot 的電腦不需要另外的權限:可以截圖,也可以操作滑鼠和鍵盤。".into(), None)),
+ "computer_use_check_permissions" => Ok(("這台電腦不需要另外的權限:可以截圖,也可以操作滑鼠和鍵盤。".into(), None)),
"computer_use_screenshot" => {
let img = grab()?;
let (w, h) = img.dimensions();
@@ -1283,7 +1283,7 @@ pub fn computer_run(name: &str, v: &Value) -> Result<(String, Option), S
let img = if resize { image::imageops::resize(&img, fw, fh, image::imageops::FilterType::Triangle) } else { img };
let jpeg = crate::computer::encode_jpeg(&img).ok_or("截圖太大了,沒辦法送出")?;
shared().lock().unwrap().scale = Some((w as f64 / fw.max(1) as f64, h as f64 / fh.max(1) as f64));
- Ok((format!("Bot 的電腦的螢幕截圖({fw}×{fh},Linux 桌面)。之後的座標請用這張圖上的像素位置。"), Some(base64::engine::general_purpose::STANDARD.encode(jpeg))))
+ Ok((format!("螢幕截圖({fw}×{fh},Linux 桌面)。之後的座標請用這張圖上的像素位置。"), Some(base64::engine::general_purpose::STANDARD.encode(jpeg))))
}
"computer_use_click" => {
let (x, y) = pt(v, scale()?)?;
diff --git a/src/computer.rs b/src/computer.rs
index 918a1df..d75d535 100644
--- a/src/computer.rs
+++ b/src/computer.rs
@@ -188,7 +188,7 @@ pub fn describe(name: &str, args: &str) -> String {
#[allow(dead_code)]
pub fn specs() -> Vec {
- specs_for(&[crate::model::THIS_COMPUTER_NAME.to_string()], crate::model::THIS_COMPUTER_NAME)
+ specs_for(&[], "")
}
/// The screen tools, with a `computer` choice when the bot has its own desktop too.
@@ -220,26 +220,10 @@ pub fn specs_for(computers: &[String], default: &str) -> Vec {
json!({"name": "computer_use_check_permissions", "description": "Check whether the screen can be seen and the mouse and keyboard used on that computer.",
"parameters": {"type": "object", "properties": {}}}),
];
- // Named whenever there is a choice, or the one screen isn't this computer or the bot's own
- // (a 手腳 computer): the bot should say whose screen it means.
- let own = [crate::model::BOT_COMPUTER_NAME, crate::model::THIS_COMPUTER_NAME];
- if computers.len() > 1 || computers.iter().any(|c| !own.contains(&c.as_str())) {
- // Explain each name that is actually offered (the old text promised "the person's Mac"
- // on machines where it does not exist, and the bot grabbed the next best thing).
- let mut parts = Vec::new();
- if computers.iter().any(|c| c == crate::model::BOT_COMPUTER_NAME) {
- parts.push(format!("{} is a private Linux desktop for the bot (the AI's own computer, not the person's).", crate::model::BOT_COMPUTER_NAME));
- }
- if computers.iter().any(|c| c == crate::model::THIS_COMPUTER_NAME) {
- parts.push(format!("{} is the person's own computer (use it for the person's own apps).", crate::model::THIS_COMPUTER_NAME));
- }
- if computers.iter().any(|c| c == crate::model::BRAIN_HOST_NAME) {
- parts.push(format!("{} is the server the brain runs on — not a person's screen; a person asking about \"my screen\" never means it.", crate::model::BRAIN_HOST_NAME));
- }
- if computers.iter().any(|c| c != crate::model::BOT_COMPUTER_NAME && c != crate::model::THIS_COMPUTER_NAME && c != crate::model::BRAIN_HOST_NAME) {
- parts.push("Any other name is another computer of the account.".to_string());
- }
- let desc = format!("Whose screen. Default: {default}. {}", parts.join(" "));
+ // Always named: each computer goes by its own name (the instructions say which is which),
+ // so the bot says whose screen it means and the person can tell where it worked.
+ if !computers.is_empty() {
+ let desc = format!("Whose screen, by the computer's name. Default: {default} (the one the person chose). Use another only when the person names it.");
for t in v.iter_mut() {
t["parameters"]["properties"]["computer"] = json!({"type": "string", "enum": computers, "description": desc});
}
diff --git a/src/i18n_table.rs b/src/i18n_table.rs
index a554317..2fa684b 100644
--- a/src/i18n_table.rs
+++ b/src/i18n_table.rs
@@ -598,4 +598,18 @@ pub const ROWS: &[(&str, &str, &str)] = &[
("發起語音聊天:你講完它就回答並唸出來,再繼續聽", "Start voice chat: when you finish speaking it answers out loud, then keeps listening", "ボイスチャットを開始:話し終えると声で答え、また聞き取ります"),
("幫我建立一個例行任務:", "Set up a routine for me: ", "ルーティンを作成して:"),
("你好,我是你的 Bot。", "Hi, I'm your bot.", "こんにちは、あなたの Bot です。"),
+ ("在大腦「{name}」上", "On the brain “{name}”", "ブレイン「{name}」上"),
+ ("連線中…", "Connecting…", "接続中…"),
+ ("看「{name}」的螢幕,也可以取得控制權自己操作", "Watch the screen of “{name}”; you can also take control", "「{name}」の画面を見ます。操作を代わることもできます"),
+ ("叫醒「{name}」並看它的螢幕", "Wake “{name}” and watch its screen", "「{name}」を起こして画面を見ます"),
+ ("要 {name} 在哪台電腦做事", "Which computer {name} works on", "{name} が作業するコンピューター"),
+ ("它自己的電腦", "its own computer", "自分のコンピューター"),
+ ("你正在用的這台", "the one you're using", "今使っているコンピューター"),
+ ("這台電腦", "this computer", "このコンピューター"),
+ ("大腦所在的伺服器", "the brain's server", "ブレインのサーバー"),
+ ("SSH 電腦", "SSH computer", "SSH コンピューター"),
+ ("做事的電腦", "Works on", "作業するコンピューター"),
+ ("聊天時指名另一台(例如「在 Mac mini 上」)就會換", "Name another one in the chat (e.g. “on the Mac mini”) to switch", "チャットで別のコンピューターを指名すると切り替わります(例:「Mac mini で」)"),
+ ("正在「{name}」上工作", "Working on “{name}”", "「{name}」で作業中"),
+ ("在「{name}」上做事 · 點一下換電腦", "Works on “{name}” · click to switch computers", "「{name}」で作業 · クリックで切り替え"),
];
diff --git a/src/memory.rs b/src/memory.rs
index 9600cec..7b9db8b 100644
--- a/src/memory.rs
+++ b/src/memory.rs
@@ -28,7 +28,7 @@ pub struct Template {
pub const TEMPLATES: [Template; 5] = [
Template { name: "研究助理", description: "上網找資料、整理重點、附上來源", instructions: "先釐清問題,再用 web_fetch 或瀏覽器找至少兩個可靠來源。回答先給結論,再列重點,每個重點附來源網址。不確定的地方直接說不確定。", shape: "circle", color: "green" },
Template { name: "寫作夥伴", description: "幫你構思、改寫和潤稿", instructions: "先問清楚讀者和語氣。改稿時保留作者的意思,列出改了什麼和為什麼。用繁體中文、台灣用語。", shape: "cloud", color: "purple" },
- Template { name: "電腦助手", description: "在 Bot 的電腦上跑指令、整理檔案、操作網頁", instructions: "預設在「Bot 的電腦」上做事。動手前先看清楚(list_dir、read_file、截圖),每一步確認結果。要動到使用者的 Mac 時先說明要做什麼。", shape: "gem", color: "blue" },
+ Template { name: "電腦助手", description: "在自己的電腦上跑指令、整理檔案、操作網頁", instructions: "預設在你自己的電腦上做事。動手前先看清楚(list_dir、read_file、截圖),每一步確認結果。要動到使用者的 Mac 時先說明要做什麼。", shape: "gem", color: "blue" },
Template { name: "客服助理", description: "在 Slack、Discord、Telegram 或 Teams 回覆客人", instructions: "回答簡短、有禮貌。不確定的事不要編,說會請同事確認。不要透露內部資料或使用者的私人資訊。", shape: "pill", color: "orange" },
Template { name: "開發助手", description: "看程式、跑測試、追 GitHub 上的事", instructions: "改程式前先讀相關檔案和測試。每次修改後跑測試,回報結果。PR 或 CI 的事件來時,先看紀錄再下結論。", shape: "hexagon", color: "black" },
];
diff --git a/src/model.rs b/src/model.rs
index b6e4633..536b5cb 100644
--- a/src/model.rs
+++ b/src/model.rs
@@ -123,10 +123,23 @@ fn is_zero(n: &u64) -> bool {
*n == 0
}
+/// A bot's own computer as kept in `Bot::default_computer` (it survives renaming the bot). It is
+/// shown and offered under the bot's name: see [`own_computer_name`].
pub const BOT_COMPUTER_NAME: &str = "Bot 的電腦";
+/// What older versions kept for "the person's computer". Read as the computer the person is using
+/// now; every computer is shown under its own name.
pub const THIS_COMPUTER_NAME: &str = "你的電腦";
-/// A brain on a server, when the person's own computer lends it hands (then that one is 你的電腦).
-pub const BRAIN_HOST_NAME: &str = "大腦伺服器";
+
+/// What a bot calls its own computer: 「Master 的電腦」 for the bot Master. One plain name per
+/// computer — 「Bot 的電腦」 and 「你的電腦」 meant different machines to the person and the bot.
+pub fn own_computer_name(bot_name: &str) -> String {
+ let n = bot_name.trim();
+ if n.is_empty() {
+ BOT_COMPUTER_NAME.to_string()
+ } else {
+ format!("{n} 的電腦")
+ }
+}
impl Message {
pub fn user(text: &str) -> Self {
@@ -382,13 +395,25 @@ fn default_sidebar_width() -> f32 {
}
impl State {
- /// Every computer a bot can be told to use: its own, this Mac, then the SSH ones.
+ /// Every computer name a bot can be told to use here: its own (by its stored name), this
+ /// computer, then the SSH ones.
pub fn computer_names(&self) -> Vec {
- let mut v = vec![BOT_COMPUTER_NAME.to_string(), THIS_COMPUTER_NAME.to_string()];
+ let mut v = vec![BOT_COMPUTER_NAME.to_string(), self.this_computer_name()];
v.extend(self.remote_computers.iter().map(|c| c.name.clone()));
v
}
+ /// This computer's own name: the one given in 設定 › 電腦, else its name in the account, else
+ /// the name the system gives it.
+ pub fn this_computer_name(&self) -> String {
+ for n in [&self.machine_label, &self.remote.host_name] {
+ if !n.trim().is_empty() {
+ return n.trim().to_string();
+ }
+ }
+ crate::remote::default_host_name()
+ }
+
pub fn routine_owner(&self, routine_id: u64) -> Option {
self.bots.iter().find(|b| b.routines.iter().any(|r| r.id == routine_id)).map(|b| b.id)
}
diff --git a/src/platform.rs b/src/platform.rs
index 48ffa2f..2a5c862 100644
--- a/src/platform.rs
+++ b/src/platform.rs
@@ -3,15 +3,6 @@
// `localize` is applied to every translated label (i18n::t) and to what the bot is told (its
// instructions and tool descriptions), so the wording follows the system in one place.
-/// A screen the person sits at (not a server with no display).
-pub fn has_desktop() -> bool {
- if cfg!(target_os = "linux") {
- std::env::var("DISPLAY").map(|v| !v.is_empty()).unwrap_or(false) || std::env::var("WAYLAND_DISPLAY").map(|v| !v.is_empty()).unwrap_or(false)
- } else {
- true
- }
-}
-
const PAIRS: &[(&str, &str)] = &[
("使用者的這台 Mac", "使用者的這台電腦"),
("這台 Mac", "這台電腦"),
diff --git a/src/remote.rs b/src/remote.rs
index c4213dc..1a4d62e 100644
--- a/src/remote.rs
+++ b/src/remote.rs
@@ -92,6 +92,12 @@ impl Default for Settings {
}
pub fn default_host_name() -> String {
+ // Asked of the system once (it runs a program).
+ static N: OnceLock = OnceLock::new();
+ N.get_or_init(system_host_name).clone()
+}
+
+fn system_host_name() -> String {
if !cfg!(target_os = "macos") {
let pretty = std::process::Command::new("hostnamectl").arg("--pretty").output().ok().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default();
let name = if pretty.is_empty() { std::fs::read_to_string("/etc/hostname").unwrap_or_default().trim().to_string() } else { pretty };
@@ -109,7 +115,8 @@ pub fn default_host_name() -> String {
/// What the phone asks AnyBot to do; the app carries these out on its own thread.
#[derive(Clone, Debug, PartialEq)]
pub enum Cmd {
- Send { bot: u64, text: String, attachments: Vec },
+ /// `from`: the account computer the person typed it on (empty: the phone, or this one).
+ Send { bot: u64, text: String, attachments: Vec, from: String },
Stop { bot: u64 },
Answer { bot: u64, answer: String },
Read { bot: u64 },
@@ -119,6 +126,8 @@ pub enum Cmd {
Reply { bot: u64 },
/// Bring back an earlier conversation (過去的對話).
RestoreThread { bot: u64, index: usize },
+ /// 清除聊天記錄 from a screen of this brain (the chat lives here).
+ Clear { bot: u64 },
/// A new phone signed in (to remember), or one was signed out.
DeviceAdded(Device),
DeviceSeen(String),
@@ -393,7 +402,7 @@ pub fn handle(req: Req, via_relay: bool) -> Resp {
// 團隊: the bot knows who is talking when it isn't the owner.
let who = req.header("x-anybot-user");
let text = if role != "owner" && !who.is_empty() { format!("【{who}】{text}") } else { text };
- Cmd::Send { bot, text, attachments }
+ Cmd::Send { bot, text, attachments, from: String::new() }
}
"stop" => Cmd::Stop { bot },
"answer" => Cmd::Answer { bot, answer: b["answer"].as_str().unwrap_or("reject").to_string() },
@@ -425,6 +434,52 @@ pub fn handle(req: Req, via_relay: bool) -> Resp {
Err(e) => Resp::error(503, &e),
},
("POST", ["api", "sync", "upload", id]) => upload(id.parse().unwrap_or(0), &req),
+ // A screen of this brain watches (and may take over) the bots' own computer, which lives
+ // here: its picture on that screen used to be its own computer's, not this one's.
+ ("GET", ["api", "sync", "screen"]) => {
+ if req.query.get("thumb").map(|t| t == "1").unwrap_or(false) {
+ match crate::cloudbox::thumbnail(320) {
+ Some(img) => {
+ let rgb = image::DynamicImage::ImageRgba8(img).to_rgb8();
+ match crate::computer::encode_jpeg(&rgb) {
+ Some(j) => Resp::bytes("image/jpeg", j),
+ None => Resp::error(500, "畫面編碼失敗"),
+ }
+ }
+ None => Resp::error(404, "還沒有畫面"),
+ }
+ } else {
+ screen("box", &s)
+ }
+ }
+ ("POST", ["api", "sync", "screen"]) => {
+ let v = req.json();
+ match v["kind"].as_str().unwrap_or("") {
+ "hold" => {
+ crate::cloudbox::HELD_BY_PERSON.store(v["on"].as_bool().unwrap_or(false), Ordering::SeqCst);
+ Resp::json(&json!({"ok": true}))
+ }
+ "viewer" => {
+ crate::cloudbox::set_viewer_open(v["on"].as_bool().unwrap_or(false));
+ Resp::json(&json!({"ok": true}))
+ }
+ // The person's own hands on the picture (frame pixels, like this computer's own
+ // viewer; not the bot's tools, which stop while the person holds it).
+ "click" | "move" | "scroll" | "key" | "text" => {
+ crate::cloudbox::touch();
+ let (x, y) = (v["x"].as_i64().unwrap_or(0), v["y"].as_i64().unwrap_or(0));
+ match v["kind"].as_str().unwrap_or("") {
+ "click" => crate::cloudbox::viewer_click(x, y, if v["button"].as_str() == Some("right") { "right" } else { "left" }, v["count"].as_u64().unwrap_or(1).clamp(1, 3) as u32),
+ "move" => crate::cloudbox::viewer_move(x, y),
+ "scroll" => crate::cloudbox::viewer_scroll(x, y, v["down"].as_bool().unwrap_or(true), v["steps"].as_u64().unwrap_or(1).clamp(1, 20) as u32),
+ "key" => crate::cloudbox::viewer_key(v["key"].as_str().unwrap_or("")),
+ _ => crate::cloudbox::viewer_text(v["text"].as_str().unwrap_or("")),
+ }
+ Resp::json(&json!({"ok": true}))
+ }
+ _ => screen_input("box", &v, &s),
+ }
+ }
("POST", ["api", "sync", "models"]) => match hub().ask_sync_for(SyncKind::Models(req.json()["provider"].as_str().unwrap_or("").to_string()), 45) {
Ok(v) => Resp::json(&v),
Err(e) => Resp::error(503, &e),
@@ -449,6 +504,8 @@ pub fn handle(req: Req, via_relay: bool) -> Resp {
bot,
text: b["text"].as_str().unwrap_or("").trim().to_string(),
attachments: b["attachments"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default(),
+ // The relay names the computer that asked: the one the person is using.
+ from: req.header("x-anybot-from").to_string(),
},
"stop" => Cmd::Stop { bot },
"answer" => Cmd::Answer { bot, answer: b["answer"].as_str().unwrap_or("reject").to_string() },
@@ -457,6 +514,7 @@ pub fn handle(req: Req, via_relay: bool) -> Resp {
"regenerate" => Cmd::Regenerate { bot },
"reply" => Cmd::Reply { bot },
"restore" => Cmd::RestoreThread { bot, index: b["index"].as_u64().unwrap_or(0) as usize },
+ "clear" => Cmd::Clear { bot },
_ => return Resp::error(404, "沒有這個動作"),
};
hub().push(cmd);
@@ -590,7 +648,7 @@ fn screen_input(which: &str, v: &Value, s: &Settings) -> Resp {
}
let (name, args) = match kind {
"click" => ("computer_use_click", json!({"x": v["x"], "y": v["y"], "button": v["button"].as_str().unwrap_or("left"), "count": v["count"].as_u64().unwrap_or(1)})),
- "type" => ("computer_use_typing", json!({"text": v["text"]})),
+ "type" => ("computer_use_typing", json!({"value": v["text"]})),
"key" => ("computer_use_press_key", json!({"key": v["key"]})),
"scroll" => ("computer_use_scroll", json!({"x": v["x"], "y": v["y"], "direction": v["direction"].as_str().unwrap_or("down"), "amount": v["amount"].as_i64().unwrap_or(3)})),
_ => return Resp::error(400, "不認得的操作"),
@@ -693,7 +751,11 @@ fn host_port(url: &str) -> Option<(String, u16)> {
/// can't open a connection to, and the person's computer lends this brain its hands.
pub fn model_hands_for(url: &str) -> Option {
let s = hub().settings();
- let hand = my_hands(&s.host_id).into_iter().next()?;
+ // The person's own computer (the one they type on) is the one on their network; the first
+ // hands by id was a desktop in the server room that can't reach a model at their office.
+ let hands = my_hands(&s.host_id);
+ let person = person_host();
+ let hand = hands.iter().find(|h| **h == person).or_else(|| hands.first()).cloned()?;
let (h, p) = host_port(url)?;
if !private_host(&h) || h == "localhost" || h.starts_with("127.") {
return None;
@@ -716,7 +778,7 @@ pub fn model_via_hands(hand: &str, url: &str, headers: &[(String, String)], body
let args = json!({"url": url, "headers": headers, "body": body}).to_string();
let req = json!({"tool": MODEL_FETCH, "args": args, "who": "大腦"}).to_string();
let name = peer_name(hand);
- let whom = if name.is_empty() { "你的電腦".to_string() } else { format!("「{name}」") };
+ let whom = if name.is_empty() { "使用者的電腦".to_string() } else { format!("「{name}」") };
let r = crate::tunnel::call(hand, "POST", "/api/device/run", &[], req.as_bytes(), 600).ok_or_else(|| format!("模型在只有{whom}連得到的網路上,但大腦現在和帳號伺服器沒有連線"))?;
let a = r.map_err(|e| format!("透過{whom}連模型失敗:{e}"))?;
let v = answer_value(a.status, &a.body, &whom)?;
@@ -1155,6 +1217,15 @@ pub fn sync_upload(s: &Settings, bot: u64, name: &str, bytes: &[u8]) -> Result Result, String> {
+ let (st, bytes) = to_brain_raw(s, "GET", "/screen", &[("thumb", if thumb { "1" } else { "0" }.to_string())], &[], 30)?;
+ if !(200..300).contains(&st) {
+ return Err(why_refused(st, &serde_json::from_slice(&bytes).unwrap_or(Value::Null), "大腦"));
+ }
+ Ok(bytes)
+}
+
/// A file the brain keeps (an attachment shown in a chat), as bytes.
pub fn sync_file(s: &Settings, path: &str) -> Result, String> {
let (st, bytes) = to_brain_raw(s, "GET", "/file", &[("path", path.to_string())], &[], 120)?;
@@ -1350,6 +1421,25 @@ pub fn my_hands(me: &str) -> Vec {
v
}
+fn person_cell() -> &'static Mutex {
+ static P: OnceLock> = OnceLock::new();
+ P.get_or_init(Mutex::default)
+}
+
+/// The account computer the person last typed on (they are sitting at it).
+pub fn set_person_host(id: &str) {
+ *person_cell().lock().unwrap() = id.to_string();
+}
+
+pub fn person_host() -> String {
+ person_cell().lock().unwrap().clone()
+}
+
+/// The brain an account computer shows (empty: it thinks for itself), as last seen.
+pub fn peer_uses(id: &str) -> String {
+ peer_rows().lock().unwrap().get(id).map(|r| r.1.clone()).unwrap_or_default()
+}
+
/// A computer's own name as the account server knows it.
pub fn peer_name(id: &str) -> String {
peer_rows().lock().unwrap().get(id).map(|r| r.0.clone()).unwrap_or_default()
@@ -1466,7 +1556,7 @@ mod tests {
assert_eq!(handle(send, false).status, 200);
let cmds = hub().take_cmds();
assert!(cmds.iter().any(|c| matches!(c, Cmd::DeviceAdded(d) if d.name == "iPhone")));
- assert!(cmds.contains(&Cmd::Send { bot: 1, text: "你好".into(), attachments: vec![] }));
+ assert!(cmds.contains(&Cmd::Send { bot: 1, text: "你好".into(), attachments: vec![], from: String::new() }));
// The Mac's own screen stays closed unless allowed.
let mut mac = Req { method: "GET".into(), path: "/api/screen/mac".into(), ..Default::default() };
mac.headers = req.headers.clone();
diff --git a/src/tools.rs b/src/tools.rs
index 24c3feb..f3c3a87 100644
--- a/src/tools.rs
+++ b/src/tools.rs
@@ -622,6 +622,17 @@ pub struct Target {
pub place: Place,
}
+/// Which of a bot's computers is which, by name (every computer goes by one plain name).
+#[derive(Clone, Debug, Default)]
+pub struct Roles {
+ /// The bot's own computer: 「Master 的電腦」.
+ pub own: String,
+ /// The computer the person is using now (where they typed to the bot), when known.
+ pub person: Option,
+ /// This AnyBot runs on a server: the computer it is on is nobody's desk.
+ pub server: bool,
+}
+
/// The tools with a `computer` choice added to each, listing the computers this bot can use.
pub fn specs_for(computers: &[String], default: &str) -> Vec {
let mut v = specs();
@@ -629,14 +640,9 @@ pub fn specs_for(computers: &[String], default: &str) -> Vec {
if t["name"] == "read_terminal" || t["name"] == "write_terminal" {
continue;
}
- // None of these being the person's own computer (a server brain with no hands):
- // a bot that "helped on your computer" then worked on the server, while the person
- // watched their own desktop stay untouched. Say what is true instead.
- let desc = if computers.iter().any(|c| c == crate::model::THIS_COMPUTER_NAME) {
- format!("Which computer to work on. Default: {default}. Use the one the person names (\"在我的 Mac 上\" = {}).", crate::model::THIS_COMPUTER_NAME)
- } else {
- format!("Which computer to work on. Default: {default}. None of these is the person's own computer (it is not connected): when the person means their own computer, ask which computer they want and tell them theirs is not connected (App › 設定 › 電腦) — never work on another machine and call it theirs.")
- };
+ // Each computer goes by its own name; the instructions say which is which. Working on
+ // one and calling it another (the server as "your computer") is what confused people.
+ let desc = format!("Which computer to work on, by its name. Default: {default} (the one the person chose). Use another only when the person names it; when unsure which one they mean, ask.");
t["parameters"]["properties"]["computer"] = json!({
"type": "string",
"enum": computers,
@@ -686,7 +692,7 @@ pub fn decide_on(name: &str, args: &str, target: &Target, grants: &[String]) ->
}
let p = resolve(root, &arg_str(&v, "path"));
if !inside(root, &p) {
- return Decision::Deny(format!("{} 看不到這台電腦上的其他檔案({})。要處理你的檔案,請用「{}」。", target.name, p.display(), crate::model::THIS_COMPUTER_NAME));
+ return Decision::Deny(format!("{} 看不到這台電腦上的其他檔案({})。要處理使用者的檔案,請在使用者的電腦上做(不確定是哪台就先問)。", target.name, p.display()));
}
Decision::Allow
}
diff --git a/src/web.rs b/src/web.rs
index e3ecdbc..b860586 100644
--- a/src/web.rs
+++ b/src/web.rs
@@ -59,30 +59,14 @@ pub fn read_needs_permission(args: &str) -> bool {
uses_person_browser(args)
}
-/// The browser tools, also when the browser is on another computer of the account (裝置連結):
-/// then each takes a `computer`, since each computer's AnyBot browser has its own logins.
-pub fn specs_for(here: &str, devices: &[String]) -> Vec {
- let local = crate::botbrowser::available();
- let mut v = specs_inner(local || !devices.is_empty());
- if !devices.is_empty() {
- let mut names: Vec = devices.to_vec();
- // A brain on a server with the person's computer as its hands: that one's browser first.
- let person = devices.iter().any(|d| d == crate::model::THIS_COMPUTER_NAME);
- if local {
- names.insert(if person { names.len() } else { 0 }, here.to_string());
- }
- let desc = format!(
- "Which computer's AnyBot browser to use. Each computer's browser has its own logins (a site logged in on one isn't on another). {}",
- if person {
- format!("Leave out for {} (the person's own computer, where they can see the window).", crate::model::THIS_COMPUTER_NAME)
- } else if local {
- format!("Leave out for {here}.")
- } else {
- "This computer has no browser: always name one.".to_string()
- }
- );
+/// The browser tools. `browsers`: the computers with an AnyBot browser this bot can use (its own,
+/// this one, the account's other computers), by name; each has its own logins.
+pub fn specs_for(browsers: &[String], default: &str) -> Vec {
+ let mut v = specs_inner(!browsers.is_empty());
+ if !browsers.is_empty() {
+ let desc = format!("Which computer's AnyBot browser to use, by the computer's name. Each computer's browser has its own logins (a site logged in on one isn't on another). Default: {default}. Use another only when the person names it.");
for t in v.iter_mut().filter(|t| t["name"].as_str().map(|n| n.starts_with("browser_")).unwrap_or(false)) {
- t["parameters"]["properties"]["computer"] = json!({"type": "string", "enum": names, "description": desc});
+ t["parameters"]["properties"]["computer"] = json!({"type": "string", "enum": browsers, "description": desc});
}
}
v