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