diff --git a/src/app.rs b/src/app.rs index 4919503..86b7144 100644 --- a/src/app.rs +++ b/src/app.rs @@ -832,7 +832,11 @@ impl App { } 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() }) } }); + // 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::()) } else { current.clone() }; - let chip = crate::icons::pill(ui, crate::icons::Icon::Monitor, &short, p.hover, p.text2, 28.0).on_hover_text(t("在「{name}」上做事 · 點一下換電腦").replace("{name}", ¤t)); - let mut pick: Option = None; - egui::Popup::menu(&chip).show(|ui| { - ui.set_min_width(menu_ui::MENU_W + 40.0); - ui.label(RichText::new(t("要 {name} 在哪台電腦做事").replace("{name}", &bot.name)).size(FONT_XS).color(p.text3)); - for (name, kind) in &choices { - let what = t(match kind.as_str() { - "own" => "它自己的電腦", - "person" => "你正在用的這台", - "this" => "這台電腦", - "server" => "大腦所在的伺服器", - "device" => "帳號裡的電腦", - _ => "SSH 電腦", - }); - let icon = if *name == current { crate::icons::Icon::Check } else { crate::icons::Icon::Monitor }; - if menu_ui::item_text(ui, p, Some(icon), &format!("{name} · {what}")).clicked() { - pick = Some(name.clone()); - ui.close(); - } - } - }); - if let Some(n) = pick { - self.set_computer(bot.id, &n); - } - } - fn model_picker(&mut self, ui: &mut egui::Ui, bot: &Bot) { let p = self.pal; let label = format!("{} · {}", short_provider(bot.provider), bot.model); diff --git a/src/app/computers_ui.rs b/src/app/computers_ui.rs index 2330287..9c97895 100644 --- a/src/app/computers_ui.rs +++ b/src/app/computers_ui.rs @@ -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)?; diff --git a/src/app/panel_ui.rs b/src/app/panel_ui.rs index 3290135..aac19fa 100644 --- a/src/app/panel_ui.rs +++ b/src/app/panel_ui.rs @@ -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 = 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(); diff --git a/src/app/remote_ui.rs b/src/app/remote_ui.rs index 5eaaf9a..d1c3a20 100644 --- a/src/app/remote_ui.rs +++ b/src/app/remote_ui.rs @@ -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) { diff --git a/src/appctl.rs b/src/appctl.rs index 298798e..9e4226a 100644 --- a/src/appctl.rs +++ b/src/appctl.rs @@ -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")) } diff --git a/src/attach.rs b/src/attach.rs index 5dac729..94d6d10 100644 --- a/src/attach.rs +++ b/src/attach.rs @@ -159,7 +159,7 @@ pub fn for_model(atts: &[Attachment]) -> (String, Vec) { let mut text = String::new(); let mut images = vec![]; for a in atts { - let where_ = if a.on_box.is_empty() { format!("檔案存在 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) => { diff --git a/src/cloudbox.rs b/src/cloudbox.rs index ca6f246..25d94f6 100644 --- a/src/cloudbox.rs +++ b/src/cloudbox.rs @@ -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 { - 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")); diff --git a/src/i18n_table.rs b/src/i18n_table.rs index ed98ab2..43e08a1 100644 --- a/src/i18n_table.rs +++ b/src/i18n_table.rs @@ -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 のファイルをこのコンピューターにバックアップします。毎日自動でも保存します"), ];