The bots' computer never sleeps; a server brain doesn't work on the server; the computer choice lives in the side panel; bots default to their own computer
- No idle pause: an asleep box is woken; its files are saved once a day instead of before sleep. - A headless brain offers no Local computer; attachments it receives go into the bot's own one. - 「你的電腦」 kept by older versions means the bot's own computer; only an explicit choice moves it. - The composer chip moved to the panel's 電腦 card (做事的電腦). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
5b2c974f3c
commit
ab564f706f
45
src/app.rs
45
src/app.rs
|
|
@ -832,7 +832,11 @@ impl App {
|
|||
}
|
||||
v.push(t);
|
||||
};
|
||||
// A brain on a server works on the bots' own computer and the person's computers, never on
|
||||
// the server itself (the person asked for it to be off the list).
|
||||
if !self.headless {
|
||||
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() } });
|
||||
}
|
||||
|
|
@ -873,9 +877,10 @@ impl App {
|
|||
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.
|
||||
// Unless the person chose another computer, a bot works on its own computer (in the
|
||||
// cloud). 「你的電腦」 from older versions named no computer in particular: the bot's own.
|
||||
if want == crate::model::THIS_COMPUTER_NAME {
|
||||
return (roles.person.clone().unwrap_or_else(|| roles.own.clone()), None);
|
||||
return (roles.own.clone(), None);
|
||||
}
|
||||
if targets.iter().any(|t| t.name == want) {
|
||||
return (want.to_string(), None);
|
||||
|
|
@ -2331,10 +2336,6 @@ impl App {
|
|||
ui.label(RichText::new(format!("{}{}", t("成員:"), names.join("、"))).size(FONT_XS).color(p.text2));
|
||||
}
|
||||
});
|
||||
// 在哪台電腦做事: always in sight, one click to change.
|
||||
if bot.members.is_empty() {
|
||||
self.computer_chip(ui, bot);
|
||||
}
|
||||
let right_w = 28.0 * 2.0 + 8.0 + 4.0;
|
||||
let text_w = (ui.available_width() - right_w).max(60.0);
|
||||
let hint = if self.mir.on && !self.mir.online { t("連不到大腦,現在只能看") } else { t("傳訊息給 {name}").replace("{name}", &bot.name) };
|
||||
|
|
@ -2603,38 +2604,6 @@ 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) {
|
||||
let p = self.pal;
|
||||
let label = format!("{} · {}", short_provider(bot.provider), bot.model);
|
||||
|
|
|
|||
|
|
@ -377,7 +377,6 @@ impl App {
|
|||
Download,
|
||||
Docker,
|
||||
Browser,
|
||||
Sleep,
|
||||
Wake,
|
||||
View,
|
||||
UpdateClick,
|
||||
|
|
@ -444,16 +443,12 @@ impl App {
|
|||
}
|
||||
_ => {
|
||||
// The screen: AnyBot has no per-bot overview, so it opens from here.
|
||||
sec_row(ui, p, "Bot 的電腦", "一台 Linux 電腦,你所有的 Bot 共用,每個 Bot 有自己的資料夾。閒置一陣子會自動休眠,需要時自動喚醒。", |ui| {
|
||||
sec_row(ui, p, "Bot 的電腦", "一台 Linux 電腦,你所有的 Bot 共用,每個 Bot 有自己的資料夾(例如「Master 的電腦」)。一直開著,不會休眠。", |ui| {
|
||||
if ui.add(primary_button("開啟電腦", p)).on_hover_text(t("看 Bot 的電腦的螢幕,也可以取得控制權自己操作")).clicked() {
|
||||
act = Act::View;
|
||||
}
|
||||
match run {
|
||||
Run::Running => {
|
||||
if ui.add(secondary_button("休眠")).clicked() {
|
||||
act = Act::Sleep;
|
||||
}
|
||||
}
|
||||
Run::Running => {}
|
||||
_ => {
|
||||
if ui.add(secondary_button("喚醒電腦")).clicked() {
|
||||
act = Act::Wake;
|
||||
|
|
@ -506,11 +501,11 @@ impl App {
|
|||
// 重設 Bot 的電腦: from the last snapshot (red, asks first).
|
||||
let snap = cloudbox::snapshot_time().map(|t| t.format("%m/%d %H:%M").to_string());
|
||||
let desc = match &snap { Some(t) => format!("上次快照:{t}"), None => t("還沒有快照") };
|
||||
sec_row_s(ui, p, "重設 Bot 的電腦", &desc, "電腦卡住時可重新開始。它會從你上次儲存的快照重建,最近的變更可能會遺失;沒有快照時會換一台全新的電腦。「儲存快照」把 Bot 的檔案備份到這台電腦,休眠前也會自動存一份。", |ui| {
|
||||
sec_row_s(ui, p, "重設 Bot 的電腦", &desc, "電腦卡住時可重新開始。它會從你上次儲存的快照重建,最近的變更可能會遺失;沒有快照時會換一台全新的電腦。「儲存快照」把 Bot 的檔案備份到這台電腦,每天也會自動存一份。", |ui| {
|
||||
if ui.add_enabled(!busy_rebuild, danger_button("重設", p)).clicked() {
|
||||
act = Act::Reset;
|
||||
}
|
||||
if ui.add_enabled(run.is_running(), secondary_button("儲存快照")).on_hover_text(t("把 Bot 的檔案備份到這台電腦;休眠前也會自動存一份")).clicked() {
|
||||
if ui.add_enabled(run.is_running(), secondary_button("儲存快照")).on_hover_text(t("把 Bot 的檔案備份到這台電腦;每天也會自動存一份")).clicked() {
|
||||
act = Act::Snapshot;
|
||||
}
|
||||
});
|
||||
|
|
@ -545,7 +540,6 @@ impl App {
|
|||
Act::Download => crate::preview::open_with_system("https://orbstack.dev/download"),
|
||||
Act::Docker => crate::preview::open_with_system("https://docs.docker.com/engine/install/"),
|
||||
Act::View => self.open_viewer(),
|
||||
Act::Sleep => self.box_task(|| cloudbox::sleep_now().map(|_| "已休眠。".to_string())),
|
||||
Act::Wake => self.box_task(|| cloudbox::ensure_running().map(|_| "Bot 的電腦已經開好了。".to_string())),
|
||||
Act::Browser => self.box_task(|| {
|
||||
let url = cloudbox::viewer_url(false)?;
|
||||
|
|
|
|||
|
|
@ -166,6 +166,9 @@ impl App {
|
|||
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();
|
||||
// 做事的電腦: which computer this bot works on (its own unless the person picks another).
|
||||
let (choices, current) = if bot.members.is_empty() { self.computer_choices(bot) } else { (vec![], String::new()) };
|
||||
let mut pick: Option<String> = None;
|
||||
panel_card(ui, p, |ui| {
|
||||
let run = crate::cloudbox::run_state();
|
||||
let running = if brain { self.mir.box_state["running"].as_bool().unwrap_or(false) } else { run.is_running() };
|
||||
|
|
@ -207,7 +210,27 @@ impl App {
|
|||
}
|
||||
let tip = if running { t("看「{name}」的螢幕,也可以取得控制權自己操作") } else { t("叫醒「{name}」並看它的螢幕") }.replace("{name}", &own_name);
|
||||
view = r.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(tip).clicked();
|
||||
if !choices.is_empty() {
|
||||
panel_select(ui, p, "做事的電腦", ("computer", bot.id), ¤t, |ui| {
|
||||
for (name, kind) in &choices {
|
||||
let what = t(match kind.as_str() {
|
||||
"own" => "它自己的電腦",
|
||||
"person" => "你正在用的這台",
|
||||
"this" => "這台電腦",
|
||||
"server" => "大腦所在的伺服器",
|
||||
"device" => "帳號裡的電腦",
|
||||
_ => "SSH 電腦",
|
||||
});
|
||||
if ui.selectable_label(*name == current, format!("{name} · {what}")).clicked() {
|
||||
pick = Some(name.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
if let Some(n) = pick {
|
||||
self.set_computer(bot.id, &n);
|
||||
}
|
||||
if view {
|
||||
if available {
|
||||
self.open_viewer();
|
||||
|
|
|
|||
|
|
@ -281,7 +281,10 @@ impl App {
|
|||
let meta = std::fs::metadata(&f).ok()?;
|
||||
let file = f.file_name()?.to_string_lossy().to_string();
|
||||
let name = file.split_once('-').map(|x| x.1.to_string()).unwrap_or(file);
|
||||
Some(crate::attach::Attachment { image: crate::attach::is_image_name(&name), name, path: f.display().to_string(), bytes: meta.len(), on_box: String::new() })
|
||||
// Also on the bot's own computer, where it works (a brain on a server
|
||||
// has no other computer of its own to read it from).
|
||||
let on_box = crate::cloudbox::copy_in(&f.display().to_string(), bot, &name).unwrap_or_default();
|
||||
Some(crate::attach::Attachment { image: crate::attach::is_image_name(&name), name, path: f.display().to_string(), bytes: meta.len(), on_box })
|
||||
})
|
||||
.collect();
|
||||
if let Some(l) = self.streams.get(&bot) {
|
||||
|
|
|
|||
|
|
@ -551,14 +551,11 @@ pub fn apply(state: &mut State, bot_id: u64, name: &str, args: &str) -> Outcome
|
|||
let serving = SERVING.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let own = state.bot(bot_id).map(|b| model::own_computer_name(&b.name)).unwrap_or_else(|| model::BOT_COMPUTER_NAME.into());
|
||||
let here = state.this_computer_name();
|
||||
let mut lines = vec![
|
||||
format!("「{own}」:你自己的電腦(AI 的電腦,不是使用者的電腦,獨立資料夾),一律可用"),
|
||||
if serving {
|
||||
format!("「{here}」:AnyBot 大腦所在的伺服器(不是使用者在用的電腦)")
|
||||
} else {
|
||||
format!("「{here}」:AnyBot 開著的這台{},{}", if cfg!(target_os = "macos") { " Mac" } else { "電腦" }, if state.local_execution { "已允許" } else { "使用者還沒在設定裡允許(設定 → 電腦 → 在這台電腦上執行)" })
|
||||
},
|
||||
];
|
||||
let mut lines = vec![format!("「{own}」:你自己的電腦(AI 的電腦,不是使用者的電腦,獨立資料夾),一律可用")];
|
||||
// A brain on a server doesn't work on the server itself.
|
||||
if !serving {
|
||||
lines.push(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)));
|
||||
ok(lines.join("\n"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ pub fn for_model(atts: &[Attachment]) -> (String, Vec<String>) {
|
|||
let mut text = String::new();
|
||||
let mut images = vec![];
|
||||
for a in atts {
|
||||
let where_ = if a.on_box.is_empty() { format!("檔案存在 AnyBot 所在的電腦:{}", a.path) } else { format!("檔案存在 AnyBot 所在的電腦:{};也已放進你自己的電腦:{}", a.path, a.on_box) };
|
||||
let where_ = if a.on_box.is_empty() { format!("檔案存在 AnyBot 所在的電腦:{}", a.path) } else { format!("檔案已放進你自己的電腦:{}", a.on_box) };
|
||||
if a.image {
|
||||
match image_b64(&a.path) {
|
||||
Some(b) => {
|
||||
|
|
|
|||
|
|
@ -50,8 +50,11 @@ pub const COPY_IN_POLL_MS: u64 = 2_000;
|
|||
pub const RESUME_FRESH_MS: u64 = 600_000;
|
||||
/// Boot: longest wait for the desktop to come up (estimated).
|
||||
pub const BOOT_WAIT_MS: u64 = 60_000;
|
||||
/// Idle time before the box goes to sleep (estimated; the original parks an idle box).
|
||||
pub const IDLE_SLEEP_SECS: u64 = 900;
|
||||
/// Quiet time before the daily snapshot is taken (nobody using the box for this long).
|
||||
pub const QUIET_SECS: u64 = 900;
|
||||
/// The box's files are saved to this computer about once a day (it no longer sleeps, which is
|
||||
/// when they used to be saved).
|
||||
pub const SNAPSHOT_EVERY_SECS: i64 = 24 * 3600;
|
||||
/// Secret names: an upper-case letter, then A–Z, 0–9, _ (original rule); size limits estimated.
|
||||
pub const SECRET_MAX_VALUE_BYTES: usize = 32_768;
|
||||
pub const SECRET_MAX_COUNT: usize = 100;
|
||||
|
|
@ -681,20 +684,10 @@ pub fn ensure_running() -> Result<(), String> {
|
|||
result
|
||||
}
|
||||
|
||||
/// Put the box to sleep now (saves a snapshot first, like the original's flush before parking).
|
||||
pub fn sleep_now() -> Result<(), String> {
|
||||
if !matches!(probe(), Run::Running) {
|
||||
return Ok(());
|
||||
}
|
||||
if is_busy() {
|
||||
return Err("有 Bot 正在用這台電腦,等它做完再休眠。".into());
|
||||
}
|
||||
let _ = save_snapshot();
|
||||
log("電腦進入休眠。");
|
||||
dk(&["pause", CONTAINER]).map(|_| set_run(Run::Asleep))
|
||||
}
|
||||
|
||||
/// Started once: puts an idle box to sleep, and replays a rebuild cut short by a restart.
|
||||
/// Started once: keeps the box awake (wakes it when it was asleep), saves its files once a day,
|
||||
/// and replays a rebuild cut short by a restart. The person asked for the bots' computer to be
|
||||
/// always there: an idle box used to be paused after 15 minutes, and the person — and a bot
|
||||
/// working from a brain far away — kept finding it "asleep".
|
||||
pub fn start_background() {
|
||||
static STARTED: AtomicBool = AtomicBool::new(false);
|
||||
if STARTED.swap(true, Ordering::SeqCst) {
|
||||
|
|
@ -704,24 +697,24 @@ pub fn start_background() {
|
|||
set_run(probe());
|
||||
resume_interrupted();
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_secs(20));
|
||||
let (idle, running, rebuilding) = {
|
||||
let (idle, rebuilding) = {
|
||||
let s = shared().lock().unwrap();
|
||||
(s.last_used.elapsed().as_secs(), s.run.is_running(), s.rebuilding)
|
||||
(s.last_used.elapsed().as_secs(), s.rebuilding)
|
||||
};
|
||||
let awake = running && !rebuilding && !is_busy() && !HELD_BY_PERSON.load(Ordering::SeqCst) && !viewer_open();
|
||||
// A browser on the desktop was left open on purpose (a login page the person walks
|
||||
// back to): it buys the box four times the idle time before parking. Freezing the
|
||||
// container froze Chromium and websockify together, so a bot that "opened the
|
||||
// browser" left the person a viewer that never showed anything.
|
||||
let limit = if awake && browser_running() { IDLE_SLEEP_SECS * 4 } else { IDLE_SLEEP_SECS };
|
||||
if awake && idle >= limit && vnc_clients() == 0 {
|
||||
let _ = sleep_now();
|
||||
}
|
||||
if !rebuilding {
|
||||
if matches!(probe(), Run::Asleep) {
|
||||
log("Bot 的電腦不休眠:叫醒它。");
|
||||
let _ = ensure_running();
|
||||
}
|
||||
let quiet = idle >= QUIET_SECS && !is_busy() && !HELD_BY_PERSON.load(Ordering::SeqCst) && !viewer_open();
|
||||
let due = snapshot_time().map(|t| (chrono::Local::now() - t).num_seconds() >= SNAPSHOT_EVERY_SECS).unwrap_or(true);
|
||||
if quiet && due && run_state().is_running() {
|
||||
let _ = save_snapshot();
|
||||
}
|
||||
refresh();
|
||||
maybe_scheduled_update();
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs(20));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -737,19 +730,6 @@ fn viewer_open() -> bool {
|
|||
VIEWER_OPEN.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Viewer tabs open on the box's desktop: every noVNC page keeps a websockify connection to
|
||||
/// x11vnc on :5900 (:170C hex), so a live connection means the person is looking at it now.
|
||||
fn vnc_clients() -> usize {
|
||||
dk(&["exec", CONTAINER, "sh", "-c",
|
||||
"awk '$4==\"01\" && ($2 ~ /:170C$/ || $3 ~ /:170C$/) {n++} END {print n+0}' /proc/net/tcp /proc/net/tcp6 2>/dev/null"])
|
||||
.ok()
|
||||
.and_then(|s| parse_count(&s))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn parse_count(s: &str) -> Option<usize> {
|
||||
s.trim().lines().last()?.trim().parse().ok()
|
||||
}
|
||||
|
||||
// ---------- snapshots (the durable store) ----------
|
||||
|
||||
|
|
@ -1481,14 +1461,6 @@ pub fn shutdown() {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn vnc_probe_parses_only_a_count() {
|
||||
assert_eq!(parse_count("3"), Some(3));
|
||||
assert_eq!(parse_count(" 2 \n"), Some(2));
|
||||
assert_eq!(parse_count(""), None);
|
||||
assert_eq!(parse_count("Error response from daemon: container paused"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_names_follow_the_original_rule() {
|
||||
assert!(valid_secret_name("CRM_API_TOKEN"));
|
||||
|
|
|
|||
|
|
@ -612,4 +612,6 @@ pub const ROWS: &[(&str, &str, &str)] = &[
|
|||
("聊天時指名別台就會換", "Name another in the chat to switch", "チャットで別の名前を言うと切り替わります"),
|
||||
("正在「{name}」上工作", "Working on “{name}”", "「{name}」で作業中"),
|
||||
("在「{name}」上做事 · 點一下換電腦", "Works on “{name}” · click to switch computers", "「{name}」で作業 · クリックで切り替え"),
|
||||
("一台 Linux 電腦,你所有的 Bot 共用,每個 Bot 有自己的資料夾(例如「Master 的電腦」)。一直開著,不會休眠。", "A Linux computer all your bots share; each bot has its own folder (e.g. “Master's computer”). Always on, it never sleeps.", "すべての Bot が共有する Linux コンピューター。Bot ごとにフォルダーがあります(例:「Master のコンピューター」)。常に起動していて、スリープしません。"),
|
||||
("把 Bot 的檔案備份到這台電腦;每天也會自動存一份", "Back up the bots' files to this computer; a copy is also saved every day", "Bot のファイルをこのコンピューターにバックアップします。毎日自動でも保存します"),
|
||||
];
|
||||
|
|
|
|||
Loading…
Reference in New Issue