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:
王性驊 2026-11-05 17:07:04 +08:00
parent 5b2c974f3c
commit ab564f706f
8 changed files with 68 additions and 108 deletions

View File

@ -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}", &current));
let mut pick: Option<String> = None;
egui::Popup::menu(&chip).show(|ui| {
ui.set_min_width(menu_ui::MENU_W + 40.0);
ui.label(RichText::new(t("要 {name} 在哪台電腦做事").replace("{name}", &bot.name)).size(FONT_XS).color(p.text3));
for (name, kind) in &choices {
let what = t(match kind.as_str() {
"own" => "它自己的電腦",
"person" => "你正在用的這台",
"this" => "這台電腦",
"server" => "大腦所在的伺服器",
"device" => "帳號裡的電腦",
_ => "SSH 電腦",
});
let icon = if *name == current { crate::icons::Icon::Check } else { crate::icons::Icon::Monitor };
if menu_ui::item_text(ui, p, Some(icon), &format!("{name} · {what}")).clicked() {
pick = Some(name.clone());
ui.close();
}
}
});
if let Some(n) = pick {
self.set_computer(bot.id, &n);
}
}
fn model_picker(&mut self, ui: &mut egui::Ui, bot: &Bot) {
let p = self.pal;
let label = format!("{} · {}", short_provider(bot.provider), bot.model);

View File

@ -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)?;

View File

@ -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), &current, |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();

View File

@ -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) {

View File

@ -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"))
}

View File

@ -159,7 +159,7 @@ pub fn for_model(atts: &[Attachment]) -> (String, Vec<String>) {
let mut text = String::new();
let mut images = vec![];
for a in atts {
let where_ = if a.on_box.is_empty() { format!("檔案存在 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) => {

View File

@ -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"));

View File

@ -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 のファイルをこのコンピューターにバックアップします。毎日自動でも保存します"),
];