From cbada6bdb12d3831b9f4ff587465c135efbbe404 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Fri, 6 Nov 2026 23:12:53 +0800 Subject: [PATCH 1/6] Bots learn from what they did; the box's browser works with the box's own disk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A job of five or more real steps is looked back on after the answer: the short way that worked becomes a skill (or improves the one it followed); the reply says so. The prompt has the bot check its skills before starting. - Upload, download and PDF on Bot 的電腦's browser look for and save files in the box, not on the brain (a picture in a Threads post took 24 steps). - The bot's browser keeps the six tabs used last (19 Threads tabs had filled the box's 2 GB). - Words name the control, not the box around it: a form's 「Customer name:」, 「Large」 and 「Submit order」 used to land on the

holding them and do nothing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4 --- FEATURES.md | 1 + src/agent.rs | 174 ++++++++++++++++++++++++++++++++++++ src/app.rs | 25 ++++++ src/app/channels_ui.rs | 2 +- src/appctl.rs | 6 +- src/botbrowser.rs | 28 ++++++ src/browser.rs | 60 ++++++++++--- src/cloudbox.rs | 47 ++++++++++ src/memory.rs | 196 ++++++++++++++++++++++++++++++++++++++++- src/providers.rs | 3 + 10 files changed, 521 insertions(+), 21 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index 00e371d..7d0814c 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -1255,6 +1255,7 @@ computer_use_screenshot、click、mouse_move、mouse_button(按住/放開) - **記憶**:跟 Bot 說「記住我喜歡靠窗的座位」,它會用 remember 記下來;「忘記…」用 forget。每個 Bot 最多 200 條、每條 500 字(估計值),每次聊天都帶著。Bot 設定裡的「記得的事」可以一條條刪。 - **技能**:教 Bot 一套做法後請它「存成技能」(save_skill),所有 Bot 共用;系統指示只列名稱和一句說明,Bot 要用時先用 use_skill 讀完整做法。設定 →「連接器」頁的「技能」卡可以刪除。 +- **自己進化**:一件事做了 5 步以上(操作電腦、網頁、指令,不算記憶和設定),回答完後在背景回頭看一次:這是在網站或程式上動手操作的事(填表、發文、回留言、上傳下載、改設定),就把這次真的成功的最短做法存成技能(用畫面上的名字,不寫編號和座標;繞路和失敗寫成「注意」;使用者說過的偏好寫進去);已經有同一件事的技能就合併成更好的一版,照著做而且很順就不動。回覆下面會多一行「(學會了新做法「…」)」或「(把做法「…」更新了)」。系統指示要 Bot 開始做事前先看技能清單、有符合的先 use_skill。實測(httpbin 訂 pizza,真的 Grok):第一次 11 步學到技能;第二次先讀技能、10 步 24 秒一次做完,回頭看後判斷不用改。 - **範本**:側邊欄「+」按右鍵 →「從範本建立」:研究助理、寫作夥伴、電腦助手、客服助理、開發助手,各有角色說明、指示、頭像和顏色。Bot 也能用 create_bot 的 template 參數建立。 ### 保險庫(照原版重做,2026-11-04) diff --git a/src/agent.rs b/src/agent.rs index dd3bef5..c0350d8 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -61,6 +61,8 @@ pub struct Job { pub review: Option, /// 示範教學 this bot has learned (for view_demo_step). pub demos: Vec, + /// The skills there are (a longer job is looked back on against them; see learn). + pub skills: Vec, } #[derive(Clone, Default)] @@ -103,6 +105,37 @@ fn review_action(job: &Job, review: &Review, action: &str, cancel: &AtomicBool) } } +/// 自己進化: after a longer job, the model looks back on its steps and keeps the way that worked +/// as a skill (or improves the one it followed), so the next time takes the short path. It runs +/// after Done: the person isn't kept waiting. +fn learn(job: &Job, history: &[Message], cancel: &AtomicBool, send: &dyn Fn(StreamEvent)) { + let Some(start) = history.iter().rposition(|m| m.role == Role::User && !m.steer) else { return }; + let turn = &history[start..]; + if !crate::memory::worth_learning(turn) { + return; + } + let req = ChatRequest { + system: crate::memory::LEARN_SYSTEM.into(), + history: vec![Message::user(&crate::memory::learn_brief(turn, &job.skills))], + tools: vec![], + ..job.req.clone() + }; + let out = match providers::run_step(&req, cancel, &|_| {}) { + Ok(o) => o, + Err(e) => { + eprintln!("AnyBot:整理做法時模型沒有回應:{e}"); + return; + } + }; + match (crate::memory::learned_skill(&out.text), turn.last()) { + (Some(k), Some(reply)) => { + eprintln!("AnyBot:學到做法「{}」", k.name); + send(StreamEvent::Learned(k, reply.at)); + } + _ => eprintln!("AnyBot:這次的做法沒有存:{}", out.text.split_whitespace().collect::>().join(" ").chars().take(200).collect::()), + } +} + /// Take what the person typed mid-turn and add it to the chat as steering. fn drain_steer(job: &Job, history: &mut Vec, send: &dyn Fn(StreamEvent)) -> bool { let queued: Vec = std::mem::take(&mut *job.steer.lock().unwrap()); @@ -593,6 +626,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< // Something typed while it answered keeps the same turn going. if job.steer.lock().unwrap().is_empty() { send(StreamEvent::Done); + learn(&job, &history, &cancel, &send); return; } continue; @@ -1284,6 +1318,7 @@ mod tests { steer: Arc::new(Mutex::new(vec![])), review: None, demos: vec![], + skills: vec![], } } @@ -1452,6 +1487,141 @@ mod tests { assert!(results[2].contains("盒子裡的測試頁 42"), "{}", results[2]); } + /// The box's browser works with the box's own disk (the brain runs elsewhere): an upload + /// finds the file there, a download and a PDF land there, and old tabs close. Needs Docker + /// and the box: `cargo test the_box_browser_uses_the_box_disk -- --ignored --nocapture`. + #[test] + #[ignore] + fn the_box_browser_uses_the_box_disk() { + let own = crate::model::BOT_COMPUTER_NAME; + let setup = r#"mkdir -p /tmp/anybot-files /tmp/anybot-up && cd /tmp/anybot-files && cat > index.html <<'EOF' +files

檔案測試

+ +

還沒選

下載報表 +EOF +printf 'a,b\n1,2\n' > report.csv && printf 'hello box' > '/tmp/anybot-up/上傳測試.txt' && (setsid python3 -m http.server 8768 --bind 127.0.0.1 >/dev/null 2>&1 &) ; sleep 1; echo served"#; + let check = "ls /home/bot/Downloads/report.csv /home/bot/Downloads/files-*.pdf && head -c 4 /home/bot/Downloads/files-*.pdf && echo"; + let pages = r#"curl -s 127.0.0.1:9222/json/list | python3 -c 'import json,sys; print("pages:", len([t for t in json.load(sys.stdin) if t["type"] == "page"]))'"#; + let clean = r#"pkill -f 'http[.]server 8768'; rm -rf /tmp/anybot-files /tmp/anybot-up /home/bot/Downloads/report.csv /home/bot/Downloads/files-*.pdf; curl -s 127.0.0.1:9222/json/list | python3 -c 'import json,sys,urllib.request; [urllib.request.urlopen("http://127.0.0.1:9222/json/close/" + t["id"]) for t in json.load(sys.stdin) if ":8768" in t["url"]]'; echo cleaned"#; + let mut replies: Vec<(&'static str, serde_json::Value)> = vec![ + ("shell", serde_json::json!({"command": setup})), + ("browser_open", serde_json::json!({"url": "http://127.0.0.1:8768/"})), + ("browser_upload", serde_json::json!({"path": "/tmp/anybot-up/上傳測試.txt"})), + ("browser_read", serde_json::json!({})), + ("browser_upload", serde_json::json!({"path": "/tmp/anybot-up/沒有這個.txt"})), + ("browser_download", serde_json::json!({"text": "下載報表"})), + ("browser_pdf", serde_json::json!({})), + ("shell", serde_json::json!({"command": check})), + ]; + for n in 1..=8 { + replies.push(("browser_open", serde_json::json!({"url": format!("http://127.0.0.1:8768/?n={n}")}))); + } + replies.push(("shell", serde_json::json!({"command": pages}))); + replies.push(("shell", serde_json::json!({"command": clean}))); + replies.push(("", serde_json::json!("好了"))); + let port = fake_model(replies); + let job = Job { + 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: own.into(), place: tools::Place::Box { dir: crate::cloudbox::bot_dir(1) } }], + default_computer: own.into(), + roles: tools::Roles { own: own.into(), person: None, server: true }, + local_execution: false, + direct: Arc::new(AtomicBool::new(true)), + ..test_job() + }; + let (tx, rx) = channel(); + spawn(job, 1, tx, Arc::new(AtomicBool::new(false)), eframe::egui::Context::default()); + let mut results = vec![]; + loop { + match rx.recv_timeout(Duration::from_secs(180)).expect("loop stalled") { + (_, StreamEvent::ToolResult(m)) => { + println!("{}: {}\n", m.tool_name, m.text.chars().take(300).collect::()); + results.push(m.text) + } + (_, StreamEvent::Done) => break, + (_, StreamEvent::Error(e)) => panic!("{e}"), + _ => {} + } + } + assert!(results[0].contains("served"), "{}", results[0]); + assert!(results[1].contains("檔案測試"), "{}", results[1]); + assert!(results[2].contains("已把 /tmp/anybot-up/上傳測試.txt 放進上傳欄位"), "{}", results[2]); + assert!(results[3].contains("已選:上傳測試.txt 9"), "{}", results[3]); + assert!(results[4].contains("這台電腦上沒有「/tmp/anybot-up/沒有這個.txt」"), "{}", results[4]); + assert!(results[5].contains("下載好了:/home/bot/Downloads/report.csv"), "{}", results[5]); + assert!(results[6].contains("已存成 PDF:/home/bot/Downloads/files-"), "{}", results[6]); + assert!(results[7].contains("%PDF") && !results[7].contains("No such file"), "{}", results[7]); + let count = results[16].split("pages:").nth(1).and_then(|n| n.trim().parse::().ok()).unwrap_or(99); + assert!(count <= 6, "{}", results[16]); + assert!(results[17].contains("cleaned"), "{}", results[17]); + } + + /// Words reach the control they name on a form laid out like httpbin's (each label and its + /// input inside a

): typing into 「Customer name:」, ticking 「Large」, pressing 「Submit + /// order」. They used to land on the

and do nothing. Needs the box: + /// `cargo test the_words_on_a_form_reach_its_controls -- --ignored --nocapture`. + #[test] + #[ignore] + fn the_words_on_a_form_reach_its_controls() { + let own = crate::model::BOT_COMPUTER_NAME; + let setup = r#"mkdir -p /tmp/anybot-form && cd /tmp/anybot-form && cat > index.html <<'EOF' +form +

+

+

+
Pizza Size +

+

+
Pizza Toppings +

+

+

+
+EOF +echo 'done送出了' > done.html; (setsid python3 -m http.server 8769 --bind 127.0.0.1 >/dev/null 2>&1 &) ; sleep 1; echo served"#; + let clean = r#"pkill -f 'http[.]server 8769'; rm -rf /tmp/anybot-form; curl -s 127.0.0.1:9222/json/list | python3 -c 'import json,sys,urllib.request; [urllib.request.urlopen("http://127.0.0.1:9222/json/close/" + t["id"]) for t in json.load(sys.stdin) if ":8769" in t["url"]]'; echo cleaned"#; + let port = fake_model(vec![ + ("shell", serde_json::json!({"command": setup})), + ("browser_open", serde_json::json!({"url": "http://127.0.0.1:8769/"})), + ("browser_type", serde_json::json!({"text": "Customer name:", "value": "李大華"})), + ("browser_type", serde_json::json!({"text": "Telephone", "value": "0922000111"})), + ("browser_click", serde_json::json!({"text": "Large"})), + ("browser_click", serde_json::json!({"text": "Extra Cheese"})), + ("browser_click", serde_json::json!({"text": "Onion"})), + ("browser_click", serde_json::json!({"text": "Submit order"})), + ("shell", serde_json::json!({"command": clean})), + ("", serde_json::json!("好了")), + ]); + let job = Job { + 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: own.into(), place: tools::Place::Box { dir: crate::cloudbox::bot_dir(1) } }], + default_computer: own.into(), + roles: tools::Roles { own: own.into(), person: None, server: true }, + local_execution: false, + direct: Arc::new(AtomicBool::new(true)), + ..test_job() + }; + let (tx, rx) = channel(); + spawn(job, 1, tx, Arc::new(AtomicBool::new(false)), eframe::egui::Context::default()); + let mut results = vec![]; + loop { + match rx.recv_timeout(Duration::from_secs(180)).expect("loop stalled") { + (_, StreamEvent::ToolResult(m)) => { + println!("{}: {}\n", m.tool_name, m.text.chars().take(240).collect::()); + results.push(m.text) + } + (_, StreamEvent::Done) => break, + (_, StreamEvent::Error(e)) => panic!("{e}"), + _ => {} + } + } + assert!(results[2].contains("欄位現在是「李大華」"), "{}", results[2]); + assert!(results[3].contains("欄位現在是「0922000111」"), "{}", results[3]); + let sent = &results[7]; + assert!(sent.contains("done.html?custname=%E6%9D%8E%E5%A4%A7%E8%8F%AF&custtel=0922000111&size=large&topping=cheese&topping=onion"), "{sent}"); + assert!(results[8].contains("cleaned"), "{}", results[8]); + } + #[test] fn the_same_miss_counts_as_the_same_step() { // Clicks a few pixels apart on the same strip are one step; another computer or target isn't. @@ -1596,6 +1766,7 @@ mod tests { steer: Arc::new(Mutex::new(vec![])), review: None, demos: vec![], + skills: vec![], }; let (tx, rx) = channel(); let cancel = Arc::new(AtomicBool::new(false)); @@ -1668,6 +1839,7 @@ mod tests { steer, review: None, demos: vec![], + skills: vec![], }; let (tx, rx) = channel(); spawn(job, 1, tx, Arc::new(AtomicBool::new(false)), eframe::egui::Context::default()); @@ -1746,6 +1918,7 @@ mod tests { steer: Arc::new(Mutex::new(vec![])), review: None, demos: vec![], + skills: vec![], }; let (tx, rx) = channel(); spawn(job, 1, tx, Arc::new(AtomicBool::new(false)), eframe::egui::Context::default()); @@ -1885,6 +2058,7 @@ mod tests { steer: Arc::new(Mutex::new(vec![])), review: None, demos: vec![], + skills: vec![], }; let (tx, rx) = channel(); spawn(job, 1, tx, Arc::new(AtomicBool::new(false)), eframe::egui::Context::default()); diff --git a/src/app.rs b/src/app.rs index 89515e6..5994735 100644 --- a/src/app.rs +++ b/src/app.rs @@ -832,6 +832,7 @@ impl App { steer, review, demos: b.demos.clone(), + skills: self.state.skills.clone(), }; agent::spawn(job, bot_id, self.tx.clone(), cancel, ctx.clone()); // (Its eyes start reading while it works; see face_pose.) @@ -1360,6 +1361,16 @@ impl App { fn drain_events(&mut self, ctx: &egui::Context) { while let Ok((id, ev)) = self.rx.try_recv() { + // 自己進化 arrives after the turn has ended: keep the skill, say so under the reply. + if let StreamEvent::Learned(k, reply_at) = ev { + let name = k.name.clone(); + let fresh = crate::memory::upsert_skill(&mut self.state.skills, k); + if let Some(m) = self.state.bot_mut(id).and_then(|b| b.messages.iter_mut().rev().find(|m| m.role == Role::Assistant && m.at == reply_at)) { + m.text.push_str(&crate::memory::learned_note(&name, fresh)); + } + self.dirty = true; + continue; + } // A stopped request may still deliver a few pieces; ignore them. if !self.streams.contains_key(&id) { continue; @@ -3560,6 +3571,20 @@ impl App { std::thread::sleep(std::time::Duration::from_millis(100)); } let secs = t0.elapsed().as_secs(); + // 自己進化 comes after the turn: wait for it when the turn was long enough to learn from. + let turn_msgs = app.state.bot(id).and_then(|b| b.messages.get(start..)).map(|m| m.to_vec()).unwrap_or_default(); + if crate::memory::worth_learning(&turn_msgs) { + let before = app.state.skills.clone(); + let t1 = Instant::now(); + while app.state.skills == before && t1.elapsed().as_secs() < 180 { + app.drain_events(ctx); + std::thread::sleep(std::time::Duration::from_millis(200)); + } + match app.state.skills.iter().find(|k| !before.contains(k)) { + Some(k) => println!("📚 學到({} 秒):{} — {}\n{}\n", t1.elapsed().as_secs(), k.name, k.description, k.instructions), + None => println!("📚 這次沒有存技能({} 秒)", t1.elapsed().as_secs()), + } + } // The chat can shrink (compaction) between the snapshot and now: slice safely. let msgs = app.state.bot(id).and_then(|b| b.messages.get(start..)).map(|m| m.to_vec()).unwrap_or_default(); let clip = |s: &str, n: usize| -> String { diff --git a/src/app/channels_ui.rs b/src/app/channels_ui.rs index 744fd39..81a3353 100644 --- a/src/app/channels_ui.rs +++ b/src/app/channels_ui.rs @@ -356,7 +356,7 @@ impl App { }); } setting_card(ui, p, |ui| { - setting_title(ui, "技能", "所有 Bot 共用的做法說明。在聊天裡教 Bot 一套做法並請它「存成技能」;Bot 需要時會先讀取再照做。", p); + setting_title(ui, "技能", "所有 Bot 共用的做法。Bot 做完較長的事,會自己把成功的做法整理成技能、下次照著做,做得更好時也會更新;你也可以在聊天裡教它一套做法。", p); if self.state.skills.is_empty() { hint(ui, "還沒有技能。", p); } diff --git a/src/appctl.rs b/src/appctl.rs index a1128d6..988d804 100644 --- a/src/appctl.rs +++ b/src/appctl.rs @@ -425,11 +425,7 @@ pub fn apply(state: &mut State, bot_id: u64, name: &str, args: &str) -> Outcome if name.is_empty() || instructions.trim().is_empty() { return err("技能要有名稱和做法".into()); } - let k = crate::memory::Skill { name: name.clone(), description: s("description").unwrap_or_default(), instructions }; - match state.skills.iter_mut().find(|x| x.name == name) { - Some(x) => *x = k, - None => state.skills.push(k), - } + crate::memory::upsert_skill(&mut state.skills, crate::memory::Skill { name: name.clone(), description: s("description").unwrap_or_default(), instructions }); ok(format!("已存成技能「{name}」,所有 Bot 都能用。")) } "use_skill" => { diff --git a/src/botbrowser.rs b/src/botbrowser.rs index 68c9861..df5cd3e 100644 --- a/src/botbrowser.rs +++ b/src/botbrowser.rs @@ -317,6 +317,7 @@ pub fn open(url: &str, profile: Option<&str>) -> Result { if r.status().as_u16() >= 400 { return Err(format!("{name} 沒辦法開新分頁({})", r.status())); } + close_old_tabs(port); } // Back when the page has loaded (not after a fixed 3 s: a slow page was still blank then), // with what it shows — the same as browser_read, one step fewer for every page. @@ -326,6 +327,23 @@ pub fn open(url: &str, profile: Option<&str>) -> Result { Ok(if page.is_empty() { head } else { format!("{head}\n\n{page}") }) } +/// Most tabs the bot's browser keeps. Every browser_open is a new tab and nothing closed them: +/// Bot 的電腦 once had 19 Threads pages open and its whole 2 GB in use (estimated). +const MAX_TABS: usize = 6; + +/// Close the tabs past MAX_TABS that were used longest ago (Chrome lists the latest first). +fn close_old_tabs(port: u16) { + let Ok(list) = local().get(&endpoint(port, "/json/list")).call().map_err(|e| e.to_string()).and_then(|r| r.into_body().read_json::().map_err(|e| e.to_string())) else { return }; + for id in old_tabs(&list) { + let _ = local().get(&endpoint(port, &format!("/json/close/{id}"))).call(); + } +} + +/// The page tabs past MAX_TABS in a /json/list answer (latest-used first). +fn old_tabs(list: &Value) -> Vec { + list.as_array().map(|a| a.iter().filter(|t| t["type"] == "page").skip(MAX_TABS).filter_map(|t| t["id"].as_str().map(String::from)).collect()).unwrap_or_default() +} + /// Longest wait for a page browser_open opened (estimated). const OPEN_WAIT_MS: u64 = 12_000; /// Text of the page handed back by browser_open (browser_read gives more when asked). @@ -431,6 +449,16 @@ mod tests { println!("{page}"); } + #[test] + fn only_the_tabs_used_longest_ago_close() { + let mut list: Vec = (0..9).map(|i| serde_json::json!({"type": "page", "id": format!("p{i}")})).collect(); + list.insert(2, serde_json::json!({"type": "browser_ui", "id": "ui"})); + list.insert(4, serde_json::json!({"type": "service_worker", "id": "sw"})); + assert_eq!(old_tabs(&Value::Array(list.clone())), vec!["p6", "p7", "p8"]); + list.truncate(5); + assert!(old_tabs(&Value::Array(list)).is_empty()); + } + #[test] fn urls_are_encoded_for_new_tabs() { assert_eq!(encode_component("https://www.threads.net/@me?x=1&y=2"), "https://www.threads.net/%40me%3Fx%3D1%26y%3D2"); diff --git a/src/browser.rs b/src/browser.rs index 027a0de..e56c00c 100644 --- a/src/browser.rs +++ b/src/browser.rs @@ -228,7 +228,20 @@ fn find_js(r#ref: &str, text: &str) -> String { const vis = e => {{ const r = e.getBoundingClientRect(); return r.width > 1 && r.height > 1; }}; const all = [...document.querySelectorAll('a,button,[role],input,select,textarea,label,summary,li,td,th,span,div,p,img')].filter(vis); const hit = e => [t(e.innerText), t(e.getAttribute('aria-label')), t(e.getAttribute('placeholder')), t(e.title), t(e.value), e.labels && e.labels[0] ? t(e.labels[0].innerText) : '']; - return all.find(e => hit(e).includes(tx)) || all.filter(e => hit(e).some(h => h.includes(tx))).sort((a, b) => t(a.innerText).length - t(b.innerText).length)[0] || null; + // The control the words name, not the box around it: 「Customer name:」 is a

holding a + // label and its input, 「Submit order」 a

holding the button (its middle is empty page). + const ctl = 'a,button,input,select,textarea,summary,[role=button],[role=link],[role=checkbox],[role=radio],[role=switch],[role=tab],[role=menuitem],[role=option],[role=textbox],[role=combobox],[contenteditable=""],[contenteditable=true]'; + const isCtl = e => e.matches(ctl); + const inner = e => {{ + if (isCtl(e)) return e; + if (e.tagName === 'LABEL' && e.control && vis(e.control)) return e.control; + const c = [...e.querySelectorAll(ctl)].filter(vis); + return c.length === 1 ? c[0] : e; + }}; + const shortest = l => l.slice().sort((a, b) => t(a.innerText).length - t(b.innerText).length)[0]; + const pick = l => l.find(isCtl) || l.map(inner).find(isCtl) || shortest(l) || null; + const exact = all.filter(e => hit(e).includes(tx)); + return exact.length ? pick(exact) : pick(all.filter(e => hit(e).some(h => h.includes(tx)))); }})()"# ) } @@ -704,31 +717,43 @@ pub fn run(name: &str, args: &Value) -> Result<(String, Option), String> } "browser_upload" => { let path = s(args, "path"); - let full = crate::tools::expand_home(&path); - if !full.is_file() { + // The browser opens the file from its own disk: Bot 的電腦's when it runs there. + let in_box = botbrowser::using_box(); + let full = if in_box { crate::cloudbox::box_path(&path) } else { crate::tools::expand_home(&path).display().to_string() }; + let there = if in_box { crate::cloudbox::box_has_file(&full) } else { std::path::Path::new(&full).is_file() }; + if !there { return Err(format!("這台電腦上沒有「{path}」這個檔案")); } let js = format!("(() => {{ let el = {}; if (el && el.tagName !== 'INPUT') el = el.querySelector('input[type=file]') || el.closest('label')?.querySelector('input[type=file]'); return el || document.querySelector('input[type=file]'); }})()", find_js(&rf, &text)); let r = tab.call("Runtime.evaluate", json!({"expression": js, "returnByValue": false}))?; let obj = r["result"]["objectId"].as_str().ok_or("這一頁找不到上傳檔案的欄位")?.to_string(); - tab.call("DOM.setFileInputFiles", json!({"files": [full.display().to_string()], "objectId": obj}))?; + tab.call("DOM.setFileInputFiles", json!({"files": [full], "objectId": obj}))?; tab.eval(&format!("(() => {{ const el = {js}; if (el) el.dispatchEvent(new Event('change', {{bubbles: true}})); return 1; }})()"))?; settle(1000); - Ok((format!("已把 {} 放進上傳欄位。", full.display()), None)) + Ok((format!("已把 {full} 放進上傳欄位。"), None)) } "browser_download" => { - let dir = downloads_dir(); + // The file lands on the browser's own disk: Bot 的電腦's when it runs there. + let in_box = botbrowser::using_box(); + let dir = if in_box { crate::cloudbox::BOX_DOWNLOADS.to_string() } else { downloads_dir().display().to_string() }; + if in_box { + crate::cloudbox::box_mkdir(&dir)?; + } let clicked_at = std::time::SystemTime::now() - Duration::from_secs(1); - tab.call("Browser.setDownloadBehavior", json!({"behavior": "allow", "downloadPath": dir.display().to_string(), "eventsEnabled": false})).or_else(|_| tab.call("Page.setDownloadBehavior", json!({"behavior": "allow", "downloadPath": dir.display().to_string()})))?; + tab.call("Browser.setDownloadBehavior", json!({"behavior": "allow", "downloadPath": dir, "eventsEnabled": false})).or_else(|_| tab.call("Page.setDownloadBehavior", json!({"behavior": "allow", "downloadPath": dir})))?; let (x, y, what) = locate(&mut tab, &rf, &text)?; click_at(&mut tab, x, y, "left", 1)?; let start = Instant::now(); while start.elapsed().as_secs() < 60 { settle(700); // A new file, or one written again since the click (same name replaced). - let now: Vec = std::fs::read_dir(&dir).map(|r| r.flatten().filter(|e| e.metadata().and_then(|m| m.modified()).map(|t| t >= clicked_at).unwrap_or(false)).map(|e| e.path()).collect()).unwrap_or_default(); - if let Some(f) = now.iter().find(|p| !p.extension().map(|e| e == "crdownload" || e == "tmp").unwrap_or(false)) { - return Ok((format!("已點 {what},下載好了:{}(可以用 read_file 讀它)", f.display()), None)); + let now: Vec = if in_box { + crate::cloudbox::box_new_files(&dir, clicked_at.duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)) + } else { + std::fs::read_dir(&dir).map(|r| r.flatten().filter(|e| e.metadata().and_then(|m| m.modified()).map(|t| t >= clicked_at).unwrap_or(false)).map(|e| e.path().display().to_string()).collect()).unwrap_or_default() + }; + if let Some(f) = now.iter().find(|p| !(p.ends_with(".crdownload") || p.ends_with(".tmp"))) { + return Ok((format!("已點 {what},下載好了:{f}(可以用 read_file 讀它)"), None)); } } Err(format!("已點 {what},但 60 秒內沒有下載到檔案(有些網站要先選格式或日期,或在新分頁產生報表)。")) @@ -739,9 +764,18 @@ pub fn run(name: &str, args: &Value) -> Result<(String, Option), String> let bytes = base64::engine::general_purpose::STANDARD.decode(r["data"].as_str().unwrap_or("")).map_err(|e| e.to_string())?; let (title, _, _) = snapshot(&mut tab); let safe: String = title.chars().filter(|c| !"/\\:*?\"<>|".contains(*c)).take(60).collect(); - let path = downloads_dir().join(format!("{}-{}.pdf", if safe.trim().is_empty() { "網頁".into() } else { safe }, chrono::Local::now().format("%Y%m%d-%H%M%S"))); - std::fs::write(&path, bytes).map_err(|e| e.to_string())?; - Ok((format!("已存成 PDF:{}", path.display()), None)) + let name = format!("{}-{}.pdf", if safe.trim().is_empty() { "網頁".into() } else { safe }, chrono::Local::now().format("%Y%m%d-%H%M%S")); + // Saved where the bot can open it: on Bot 的電腦 when that's the browser's computer. + let path = if botbrowser::using_box() { + let p = format!("{}/{name}", crate::cloudbox::BOX_DOWNLOADS); + crate::cloudbox::box_write(&p, &bytes)?; + p + } else { + let p = downloads_dir().join(name); + std::fs::write(&p, bytes).map_err(|e| e.to_string())?; + p.display().to_string() + }; + Ok((format!("已存成 PDF:{path}"), None)) } "browser_dialog" => { let mut p = json!({"accept": args["accept"].as_bool().unwrap_or(true)}); diff --git a/src/cloudbox.rs b/src/cloudbox.rs index 99445f6..918e9fd 100644 --- a/src/cloudbox.rs +++ b/src/cloudbox.rs @@ -1598,6 +1598,46 @@ fn box_exec(script: &str) -> Result { } } +// ---------- files for the box's browser ---------- +// The brain drives the box's Chromium from outside, but that browser reads and writes the box's +// own disk: an upload, a download or a PDF has to be looked for there, not on the brain (a +// cloud brain once told the bot an attachment sitting in the box "isn't on this computer", and +// the bot spent 24 steps getting one picture into a post). + +/// Where the box's browser keeps downloads and PDFs (the bot reads them there). +pub const BOX_DOWNLOADS: &str = "/home/bot/Downloads"; + +/// A path on Bot 的電腦 as written by the bot: `~` is the bot user's home. +pub fn box_path(p: &str) -> String { + match p.trim() { + "~" => "/home/bot".into(), + t => t.strip_prefix("~/").map(|rest| format!("/home/bot/{rest}")).unwrap_or_else(|| t.to_string()), + } +} + +/// There is a file at `path` on Bot 的電腦. +pub fn box_has_file(path: &str) -> bool { + box_exec(&format!("[ -f {} ] && echo yes", crate::tools::sh_quote(path))).map(|o| o.trim() == "yes").unwrap_or(false) +} + +/// Files in `dir` on Bot 的電腦 written at or after `since` (seconds since 1970). +pub fn box_new_files(dir: &str, since: u64) -> Vec { + let script = format!("find {} -maxdepth 1 -type f -newermt @{since} 2>/dev/null; true", crate::tools::sh_quote(dir)); + box_exec(&script).map(|o| o.lines().map(str::trim).filter(|l| !l.is_empty()).map(String::from).collect()).unwrap_or_default() +} + +/// Make a folder on Bot 的電腦 (as the bot user). +pub fn box_mkdir(dir: &str) -> Result<(), String> { + box_exec(&format!("mkdir -p {}", crate::tools::sh_quote(dir))).map(|_| ()) +} + +/// Write a file on Bot 的電腦 (as the bot user), making its folder first. +pub fn box_write(path: &str, bytes: &[u8]) -> Result<(), String> { + let dir = std::path::Path::new(path).parent().map(|d| d.display().to_string()).unwrap_or_else(|| "/home/bot".into()); + let script = format!("mkdir -p {} && cat > {}", crate::tools::sh_quote(&dir), crate::tools::sh_quote(path)); + dk_with_input(&["exec", "-i", "-u", "bot", container(), "sh", "-c", &script], Some(bytes), 60_000).map(|_| ()).map_err(|e| format!("存不進 Bot 的電腦:{e}")) +} + /// The numbered controls of the box's latest look (centres in desktop pixels). static BOX_SPOTS: Mutex> = Mutex::new(None); /// The latest look's fingerprint (to say when a step changed nothing). @@ -2128,6 +2168,13 @@ mod tests { /// (xterm has no accessibility; clicking a number there used to type into the terminal). #[test] #[ignore] + fn box_paths_are_the_bot_users() { + assert_eq!(box_path("~/bot-6/附件/IMG_0587.jpeg"), "/home/bot/bot-6/附件/IMG_0587.jpeg"); + assert_eq!(box_path(" ~ "), "/home/bot"); + assert_eq!(box_path("/tmp/a.jpg"), "/tmp/a.jpg"); + } + + #[test] fn box_numbers_only_on_what_is_seen() { ensure_running().expect("box starts"); let cancel = AtomicBool::new(false); diff --git a/src/memory.rs b/src/memory.rs index 7b9db8b..30707f3 100644 --- a/src/memory.rs +++ b/src/memory.rs @@ -63,6 +63,121 @@ pub fn forget(list: &mut Vec, what: &str) -> Result { } } +/// Add a skill, or replace the one with the same name. True when it is new. +pub fn upsert_skill(list: &mut Vec, k: Skill) -> bool { + match list.iter_mut().find(|x| x.name == k.name) { + Some(x) => { + *x = k; + false + } + None => { + list.push(k); + true + } + } +} + +// ---------- 自己進化: a job's way that worked becomes a skill ---------- +// The bot forgot everything between chats: a post with a picture on Threads took it 39 steps, +// and the next one would have taken as many. After a longer job the model looks back at the +// steps and writes down the short path that worked (or improves the skill it followed); the +// next time it reads that first. + +/// Steps on computers and pages a job needs before it is worth looking back on (estimated). +pub const LEARN_MIN_STEPS: usize = 5; +/// Longest learned skill (estimated; a skill is read whole before each such job). +const MAX_SKILL_CHARS: usize = 4000; + +/// A turn that did enough real work, and didn't already save a skill itself. +pub fn worth_learning(turn: &[crate::model::Message]) -> bool { + let calls: Vec<&str> = turn.iter().flat_map(|m| m.tool_calls.iter().map(|c| c.name.as_str())).collect(); + !calls.contains(&"save_skill") && calls.iter().filter(|n| !crate::appctl::is_app_tool(n) && **n != "react").count() >= LEARN_MIN_STEPS +} + +pub const LEARN_SYSTEM: &str = "你在幫一個會操作電腦的 Bot 整理經驗,讓它下次做同樣的事更快、不再犯同樣的錯。下面是它剛做完的一件事:使用者的要求、每一步和結果、最後的回覆,還有它已經有的技能。 + +先判斷要不要存。使用者希望它從做過的事學會、下次不用重新摸索:只要這件事是在某個網站或程式上動手操作(填表、發文、回留言、按讚、上傳下載、改設定、跑一套固定的檢查),就要存,就算這次只做了一次。只有閒聊、單純回答問題、只是查資料讀內容,才不用存。這次沒做成功的,不要存成做法;但如果已經有這件事的技能,可以把「這樣做行不通」補進去。 + +要存的話,寫一份「下次照著做就會成功」的技能: +- 寫這次真的成功的那條最短路徑,一步一行,寫出用哪個工具、在哪台電腦、哪個網址或程式(每一步結果開頭 [ ] 裡的名字,就是那一步用的電腦)。 +- 繞路和失敗的嘗試不要寫成步驟;把「哪裡會卡、要怎麼避開」寫成「注意:」。 +- 用畫面上看得到的名字指東西(例如 browser_click 的 text 寫「Attach media」、browser_type 的 text 寫欄位名「Customer name」),不要寫元素編號或座標,那些每次都會變。browser_open 的結果就有這一頁的元素清單,不用另外截圖。 +- 寫通用的做法,不要寫這次的內容(這篇文的文字、這張圖的檔名);使用者表達過的偏好(語氣、格式、一定要附圖、用哪台電腦)要寫進去。 +- 已經有做同一件事的技能,就用同一個名稱,把舊的和這次學到的合併成更好的一版:舊的對的保留,錯的改掉。這次照著技能做而且很順,就不用再存。 + +只回一個 JSON,不要別的字: +不用存:{\"save\": false, \"why\": \"一句話說為什麼\"} +要存:{\"save\": true, \"name\": \"短名稱\", \"description\": \"一行:什麼時候用\", \"instructions\": \"做法\"}"; + +fn cut(s: &str, n: usize) -> String { + let flat = s.split_whitespace().collect::>().join(" "); + if flat.chars().count() <= n { flat } else { format!("{}…", flat.chars().take(n).collect::()) } +} + +/// What the model looks back on: the request, each step with its result, the answer and the +/// skills there are. +pub fn learn_brief(turn: &[crate::model::Message], skills: &[Skill]) -> String { + use crate::model::Role; + let mut steps: Vec = vec![]; + for (i, m) in turn.iter().enumerate() { + if m.role == Role::User && i > 0 { + steps.push(format!("(使用者中途說:{})", cut(&m.text, 300))); + } + for c in &m.tool_calls { + let result = turn.iter().find(|r| r.role == Role::Tool && r.tool_call_id == c.id).map(|r| r.text.as_str()).unwrap_or(""); + let failed = if c.status == crate::model::ToolStatus::Failed { "(失敗)" } else { "" }; + steps.push(format!("{} {} → {}{failed}", c.name, cut(&c.args, 160), cut(result, 220))); + } + } + const KEEP: usize = 40; + if steps.len() > KEEP * 2 { + let gone = steps.len() - KEEP * 2; + steps.splice(KEEP..steps.len() - KEEP, [format!("…(中間省略 {gone} 步)…")]); + } + let ask = turn.first().map(|m| cut(&m.text, 800)).unwrap_or_default(); + let answer = turn.iter().rev().find(|m| m.role == crate::model::Role::Assistant && !m.text.trim().is_empty()).map(|m| cut(&m.text, 600)).unwrap_or_default(); + let mut s = format!("使用者的要求:\n{ask}\n\n每一步(工具 參數 → 結果):\n"); + for (i, st) in steps.iter().enumerate() { + s.push_str(&format!("{}. {st}\n", i + 1)); + } + s.push_str(&format!("\n最後的回覆:\n{answer}\n\n已經有的技能:")); + if skills.is_empty() { + s.push_str("(沒有)"); + } + let mut room = 8000usize; + for k in skills { + let body = cut(&k.instructions, 2000.min(room)); + room = room.saturating_sub(body.chars().count()); + s.push_str(&format!("\n- {}:{}\n 做法:{body}", k.name, k.description)); + } + s +} + +/// The skill in the model's answer, when it chose to save one. +pub fn learned_skill(text: &str) -> Option { + let t = text.trim(); + let json = t.find('{').and_then(|a| t.rfind('}').filter(|b| *b >= a).map(|b| &t[a..=b]))?; + let v: serde_json::Value = serde_json::from_str(json).ok()?; + if v["save"].as_bool() != Some(true) { + return None; + } + let name: String = v["name"].as_str()?.trim().chars().take(40).collect(); + let instructions: String = v["instructions"].as_str()?.trim().chars().take(MAX_SKILL_CHARS).collect(); + if name.is_empty() || instructions.is_empty() { + return None; + } + Some(Skill { name, description: v["description"].as_str().unwrap_or("").trim().chars().take(120).collect(), instructions }) +} + +/// The line under the bot's reply that says it learned (or improved) a skill. +pub fn learned_note(name: &str, fresh: bool) -> String { + if fresh { + format!("\n\n(學會了新做法「{name}」,下次做這件事會照著做。)") + } else { + format!("\n\n(把做法「{name}」更新了,下次會照新的做。)") + } +} + pub fn prompt_section(memories: &[String], skills: &[Skill]) -> String { let mut s = String::new(); if !memories.is_empty() { @@ -74,7 +189,7 @@ pub fn prompt_section(memories: &[String], skills: &[Skill]) -> String { s.push_str("\n\n使用者說「記住…」「以後都…」時,用 remember 記下來;說「忘記…」時用 forget。"); } if !skills.is_empty() { - s.push_str("\n\n可用的技能(做法說明;需要時先用 use_skill 讀取完整內容再照做):\n"); + s.push_str("\n\n你會的技能(以前做成功的做法)。開始做一件事之前先看這裡:有符合的,先用 use_skill 讀出完整做法再照著做,不要重新摸索:\n"); for k in skills { s.push_str(&format!("- {}:{}\n", k.name, k.description)); } @@ -102,6 +217,83 @@ mod tests { assert_eq!(m.len(), MAX_MEMORIES); assert_eq!(m[0], "事 5"); let p = prompt_section(&m[..1], &[Skill { name: "週報".into(), description: "整理每週進度".into(), instructions: "…".into() }]); - assert!(p.contains("1. 事 5") && p.contains("- 週報:整理每週進度")); + assert!(p.contains("1. 事 5") && p.contains("- 週報:整理每週進度") && p.contains("use_skill")); + } + + fn call(id: &str, name: &str, args: &str, failed: bool) -> crate::model::ToolCall { + crate::model::ToolCall { id: id.into(), name: name.into(), args: args.into(), status: if failed { crate::model::ToolStatus::Failed } else { crate::model::ToolStatus::Done } } + } + + fn result(id: &str, text: &str) -> crate::model::Message { + crate::model::Message { role: crate::model::Role::Tool, tool_call_id: id.into(), text: text.into(), ..Default::default() } + } + + /// A post with a picture: five real steps, one of them failed, the person corrects it midway. + fn post_turn() -> Vec { + use crate::model::Message; + let step = |c: crate::model::ToolCall| Message { tool_calls: vec![c], ..Message::assistant("", "b") }; + vec![ + Message::user("幫我發一篇 Threads,記得附圖"), + step(call("1", "browser_open", r#"{"url":"https://www.threads.com/"}"#, false)), + result("1", "已打開 https://www.threads.com/\n[13] 按鈕「Create」"), + step(call("2", "browser_click", r#"{"ref":"13"}"#, false)), + result("2", "已點 [13]。新出現的文字:New thread"), + Message { steer: true, ..Message::user("語氣要輕鬆一點") }, + step(call("3", "browser_type", r#"{"ref":"12","value":"今天天氣好"}"#, false)), + result("3", "已在 [12] 輸入。"), + step(call("4", "browser_upload", r#"{"path":"/home/bot/x.jpg"}"#, true)), + result("4", "這台電腦上沒有這個檔案"), + step(call("5", "browser_upload", r#"{"path":"/home/bot/bot-6/附件/x.jpg"}"#, false)), + result("5", "已把 /home/bot/bot-6/附件/x.jpg 放進上傳欄位。"), + step(call("6", "remember", r#"{"fact":"x"}"#, false)), + result("6", "記住了"), + Message::assistant("發好了:https://www.threads.com/@me/post/1", "b"), + ] + } + + #[test] + fn a_long_enough_job_is_looked_back_on() { + let turn = post_turn(); + assert!(worth_learning(&turn)); + // App steps (remember) don't count; four real steps aren't enough. + assert!(!worth_learning(&turn[..10])); + // A job that saved a skill itself is left alone. + let mut saved = turn.clone(); + saved[12].tool_calls[0].name = "save_skill".into(); + assert!(!worth_learning(&saved)); + } + + #[test] + fn the_look_back_has_every_step_and_the_skills() { + let skills = [Skill { name: "Threads 發文".into(), description: "在 Threads 發文".into(), instructions: "1. browser_open https://www.threads.com/".into() }]; + let b = learn_brief(&post_turn(), &skills); + assert!(b.starts_with("使用者的要求:\n幫我發一篇 Threads,記得附圖"), "{b}"); + assert!(b.contains("1. browser_open {\"url\":\"https://www.threads.com/\"} → 已打開 https://www.threads.com/ [13] 按鈕「Create」"), "{b}"); + assert!(b.contains("(使用者中途說:語氣要輕鬆一點)"), "{b}"); + assert!(b.contains("browser_upload {\"path\":\"/home/bot/x.jpg\"} → 這台電腦上沒有這個檔案(失敗)"), "{b}"); + assert!(b.contains("最後的回覆:\n發好了"), "{b}"); + assert!(b.contains("- Threads 發文:在 Threads 發文\n 做法:1. browser_open"), "{b}"); + // A very long job keeps its start and its end. + let mut long = vec![crate::model::Message::user("做很多事")]; + for i in 0..200 { + long.push(crate::model::Message { tool_calls: vec![call(&i.to_string(), "browser_click", "{}", false)], ..crate::model::Message::assistant("", "b") }); + } + let b = learn_brief(&long, &[]); + assert!(b.contains("(中間省略 120 步)") && b.contains("81. browser_click") && !b.contains("82. "), "{b}"); + } + + #[test] + fn a_learned_skill_is_read_from_the_answer() { + let k = learned_skill("好的\n{\"save\": true, \"name\": \" Threads 發文附圖 \", \"description\": \"在 Threads 發一篇附圖的文\", \"instructions\": \"1. browser_open https://www.threads.com/\"}").unwrap(); + assert_eq!((k.name.as_str(), k.description.as_str()), ("Threads 發文附圖", "在 Threads 發一篇附圖的文")); + assert!(learned_skill(r#"{"save": false}"#).is_none()); + assert!(learned_skill(r#"{"save": true, "name": "", "instructions": "x"}"#).is_none()); + assert!(learned_skill("不用存").is_none()); + assert!(learned_skill("} {").is_none()); + let mut list = vec![]; + assert!(upsert_skill(&mut list, k.clone())); + assert!(!upsert_skill(&mut list, Skill { instructions: "新的".into(), ..k })); + assert_eq!((list.len(), list[0].instructions.as_str()), (1, "新的")); + assert!(learned_note("Threads 發文附圖", true).contains("學會了新做法「Threads 發文附圖」")); } } diff --git a/src/providers.rs b/src/providers.rs index 8c96983..f331df6 100644 --- a/src/providers.rs +++ b/src/providers.rs @@ -199,6 +199,9 @@ pub enum StreamEvent { React(String), /// The bot sent a file into the chat (a file card under its reply). Attach(crate::attach::Attachment), + /// 自己進化: after the turn, the way that worked was kept as a skill (with the time of the + /// reply it learned from, which gets a line saying so). + Learned(crate::memory::Skill, i64), Done, Error(String), } From b480ea16dd708453274c5a22630bd93124f119fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Fri, 6 Nov 2026 23:21:04 +0800 Subject: [PATCH 2/6] A finished job closes the tabs it left open, keeping the page it ended on Every browser_open is a new tab and nothing closed them. When a job ends (answered, stopped or failed) each bot browser it used keeps only its front page; a browser that isn't open isn't started for this, and one another job is still using is left alone. The six-tab limit stays for long jobs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4 --- src/agent.rs | 130 ++++++++++++++++++++++++++++++++++++++++++++++ src/botbrowser.rs | 8 +++ src/browser.rs | 12 +++++ 3 files changed, 150 insertions(+) diff --git a/src/agent.rs b/src/agent.rs index c0350d8..a4a3db3 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -154,6 +154,76 @@ fn target_for(job: &Job, args: &str) -> Result { job.computers.iter().find(|t| t.name == want).cloned().ok_or_else(|| not_here(job, &want)) } +/// One of the bot's own browsers (where, and which profile). +#[derive(Clone, PartialEq, Eq, Hash, Debug)] +enum BotBrowser { + Here(String), + Box(String), + Device(String, String), +} + +/// How many running jobs are using each bot browser (bots share the box's Chromium). +static BROWSER_JOBS: Mutex> = Mutex::new(Vec::new()); + +/// The bot's browsers a job used. When the job ends — answered, stopped or failed — each closes +/// the tabs the job left open and keeps the page it ended on (every browser_open is a new tab; +/// nothing used to close them, and the box ran out of memory with 19). A browser another job is +/// still using is left alone. +struct UsedBrowsers { + list: Vec, + via_name: String, +} + +/// A job starts using a bot browser. +fn claim(b: &BotBrowser) { + BROWSER_JOBS.lock().unwrap().push(b.clone()); +} + +/// A job is done with a bot browser: true when another job is still using it. +fn release(b: &BotBrowser) -> bool { + let mut jobs = BROWSER_JOBS.lock().unwrap(); + if let Some(i) = jobs.iter().position(|x| x == b) { + jobs.remove(i); + } + jobs.contains(b) +} + +impl UsedBrowsers { + fn add(&mut self, b: BotBrowser) { + if !self.list.contains(&b) { + claim(&b); + self.list.push(b); + } + } + + fn close_tabs(&mut self) { + for b in std::mem::take(&mut self.list) { + if release(&b) { + continue; + } + let args = |p: &str| if p.is_empty() { r#"{"action":"tidy"}"#.to_string() } else { serde_json::json!({"action": "tidy", "profile": p}).to_string() }; + let never = AtomicBool::new(false); + match &b { + BotBrowser::Here(p) => { + let _ = web::run_full("browser_tabs", &args(p), &never); + } + BotBrowser::Box(p) => { + let _ = crate::botbrowser::in_box(|| web::run_full("browser_tabs", &args(p), &never)); + } + BotBrowser::Device(host, p) => { + let _ = crate::remote::run_on_device(host, "browser_tabs", &args(p), &self.via_name); + } + } + } + } +} + +impl Drop for UsedBrowsers { + fn drop(&mut self) { + self.close_tabs(); + } +} + enum BrowserAt { Here, Box, @@ -575,6 +645,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< // The screen and browser steps of this turn (to stop the same step being tried over and over). let mut tried: Vec = Vec::new(); let mut local_on = job.local_execution; + let mut browsers = UsedBrowsers { list: vec![], via_name: job.via_name.clone() }; maybe_compact(&job, &mut history, COMPACT_THRESHOLD_PERCENT, &cancel, &send); for step in 0..MAX_STEPS { if step > 0 { @@ -626,6 +697,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< // Something typed while it answered keeps the same turn going. if job.steer.lock().unwrap().is_empty() { send(StreamEvent::Done); + browsers.close_tabs(); learn(&job, &history, &cancel, &send); return; } @@ -707,6 +779,14 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< let at = web::is_web_tool(&call.name).then(|| browser_place(&job, &call.name, &call.args)); if let Some((place, on)) = at.filter(|(place, _)| call.name == "web_fetch" || bots_browser(place, &call.name, &call.args)) { send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running)); + if call.name != "web_fetch" { + let profile = tools::args_of(&call.args).get("profile").and_then(|p| p.as_str()).unwrap_or("").trim().to_string(); + browsers.add(match &place { + BrowserAt::Here => BotBrowser::Here(profile), + BrowserAt::Box => BotBrowser::Box(profile), + BrowserAt::Device(host) => BotBrowser::Device(host.clone(), profile), + }); + } // 裝置連結: the AnyBot browser of another computer (its own logins). let (output, ok, img) = match place { BrowserAt::Device(host) => match crate::remote::run_on_device(&host, &call.name, &call.args, &job.via_name) { @@ -1556,6 +1636,46 @@ printf 'a,b\n1,2\n' > report.csv && printf 'hello box' > '/tmp/anybot-up/上傳 assert!(results[17].contains("cleaned"), "{}", results[17]); } + /// When a job ends, the tabs it left open in the box's browser close; the page it ended on + /// stays. Needs the box: `cargo test a_finished_job_closes_its_tabs -- --ignored --nocapture`. + #[test] + #[ignore] + fn a_finished_job_closes_its_tabs() { + let own = crate::model::BOT_COMPUTER_NAME; + let setup = "mkdir -p /tmp/anybot-tabs && cd /tmp/anybot-tabs && echo 'tab分頁' > index.html && (setsid python3 -m http.server 8770 --bind 127.0.0.1 >/dev/null 2>&1 &) ; sleep 1; echo served"; + let port = fake_model(vec![ + ("shell", serde_json::json!({"command": setup})), + ("browser_open", serde_json::json!({"url": "http://127.0.0.1:8770/?n=1"})), + ("browser_open", serde_json::json!({"url": "http://127.0.0.1:8770/?n=2"})), + ("browser_open", serde_json::json!({"url": "http://127.0.0.1:8770/?n=3"})), + ("", serde_json::json!("好了")), + ]); + let job = Job { + 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: own.into(), place: tools::Place::Box { dir: crate::cloudbox::bot_dir(1) } }], + default_computer: own.into(), + roles: tools::Roles { own: own.into(), person: None, server: true }, + local_execution: false, + direct: Arc::new(AtomicBool::new(true)), + ..test_job() + }; + let (tx, rx) = channel(); + spawn(job, 1, tx, Arc::new(AtomicBool::new(false)), eframe::egui::Context::default()); + loop { + match rx.recv_timeout(Duration::from_secs(120)).expect("loop stalled") { + (_, StreamEvent::Done) => break, + (_, StreamEvent::Error(e)) => panic!("{e}"), + _ => {} + } + } + std::thread::sleep(Duration::from_secs(2)); + let box_sh = |script: &str| String::from_utf8_lossy(&std::process::Command::new("docker").args(["exec", crate::cloudbox::container(), "sh", "-c", script]).output().unwrap().stdout).to_string(); + let left = box_sh(r#"curl -s 127.0.0.1:9222/json/list | python3 -c 'import json,sys; print("\n".join(t["url"] for t in json.load(sys.stdin) if t["type"] == "page"))'"#); + println!("left open:\n{left}"); + box_sh(r#"pkill -f 'http[.]server 8770'; rm -rf /tmp/anybot-tabs"#); + assert_eq!(left.trim(), "http://127.0.0.1:8770/?n=3", "{left}"); + } + /// Words reach the control they name on a form laid out like httpbin's (each label and its /// input inside a

): typing into 「Customer name:」, ticking 「Large」, pressing 「Submit /// order」. They used to land on the

and do nothing. Needs the box: @@ -1622,6 +1742,16 @@ echo 'done送出了' > done.html; (setsid pyt assert!(results[8].contains("cleaned"), "{}", results[8]); } + #[test] + fn a_browser_another_job_uses_keeps_its_tabs() { + let b = BotBrowser::Device("test-host-tabs".into(), String::new()); + claim(&b); + claim(&b); + assert!(release(&b), "the other job still uses it"); + assert!(!release(&b), "the last one out closes the tabs"); + assert!(!release(&b)); + } + #[test] fn the_same_miss_counts_as_the_same_step() { // Clicks a few pixels apart on the same strip are one step; another computer or target isn't. diff --git a/src/botbrowser.rs b/src/botbrowser.rs index df5cd3e..2bae05b 100644 --- a/src/botbrowser.rs +++ b/src/botbrowser.rs @@ -251,6 +251,14 @@ pub fn using_box() -> bool { } /// Start this profile's window if it isn't running. Returns (browser name, port, just started). +/// The debugging port of a bot browser that is already open (None when it isn't; never starts one). +pub fn running_port(profile: Option<&str>) -> Option { + if using_box() { + return crate::cloudbox::browser_running().then(crate::cloudbox::browser_port).and_then(Result::ok); + } + live_port(&resolve(profile).ok()?) +} + pub fn ensure(profile: &str, first_url: Option<&str>) -> Result<(&'static str, u16, bool), String> { if using_box() { let fresh = !crate::cloudbox::browser_running(); diff --git a/src/browser.rs b/src/browser.rs index e56c00c..686ae88 100644 --- a/src/browser.rs +++ b/src/browser.rs @@ -823,7 +823,19 @@ fn shrink(b64: &str) -> Option { Some(base64::engine::general_purpose::STANDARD.encode(out.into_inner())) } +/// A job is done with this browser: keep the page it ended on, close the rest (the list is +/// latest-used first). A browser that isn't open stays closed. Not offered to the model; the +/// loop calls it (browser_tabs action "tidy") when a job ends. +fn tidy(profile: Option<&str>) -> Result { + let Some(port) = botbrowser::running_port(profile) else { return Ok("瀏覽器沒有開著。".into()) }; + let closed = targets(port)?.iter().skip(1).filter(|t| botbrowser::local().get(&format!("http://127.0.0.1:{port}/json/close/{}", t["id"].as_str().unwrap_or(""))).call().is_ok()).count(); + Ok(format!("關了 {closed} 個用完的分頁。")) +} + fn tabs(args: &Value, profile: Option<&str>) -> Result { + if s(args, "action") == "tidy" { + return tidy(profile); + } let (_, port) = port_of(profile)?; let http = |path: &str, put: bool| -> Result { let url = format!("http://127.0.0.1:{port}{path}"); From 4605b317d85cbfea9fc632483eef5e3361f299ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Sat, 7 Nov 2026 00:03:25 +0800 Subject: [PATCH 3/6] =?UTF-8?q?Bot=20=E7=9A=84=E9=9B=BB=E8=85=A6=20can=20r?= =?UTF-8?q?un=20under=20gVisor:=20its=20own=20kernel,=20Chromium's=20sandb?= =?UTF-8?q?ox=20back=20on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 容器隔離 in 設定 › 電腦: standard (runc, the container is the only wall, Chromium runs --no-sandbox) or hardened (runsc: a virtualized kernel, all capabilities dropped but the few the desktop needs, ANYBOT_BOX_SANDBOX=1 turns Chromium's own sandbox back on). Box image v7. deploy/mac/gvisor-vm.sh makes a Lima VM with Docker + gVisor for a Mac (started at login), gvisor-migrate.sh moves the box's files and logins there. Checked and finished before committing: - the hardened choice is offered only on a Docker host that has runsc; a box set to it on a host without it opens the standard way and says so (it used to not start at all) - the settings row shows what the box really runs on, and when 更新 is needed - no swap only under gVisor (a standard box keeps Docker's default) - the migrate script sees the running Mac app ("AnyBot", not "anybot") - the VM script starts an existing VM by name, checks runsc and the kernel from the Mac - the version test says 7 Real run in the Lima VM: desktop, VNC, numbered controls, xdotool and screenshots work; no Chromium process has --no-sandbox, renderers run with seccomp and their own namespaces. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4 --- FEATURES.md | 1 + box/Dockerfile | 16 ++++-- box/browser | 7 ++- deploy/mac/gvisor-migrate.sh | 56 +++++++++++++++++++++ deploy/mac/gvisor-vm.sh | 83 ++++++++++++++++++++++++++++++++ deploy/server/docker-compose.yml | 7 +++ docs/architecture.md | 2 +- src/app/computers_ui.rs | 46 +++++++++++++++++- src/cloudbox.rs | 76 ++++++++++++++++++++++++++++- 9 files changed, 285 insertions(+), 9 deletions(-) create mode 100755 deploy/mac/gvisor-migrate.sh create mode 100755 deploy/mac/gvisor-vm.sh diff --git a/FEATURES.md b/FEATURES.md index 7d0814c..887ef92 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -729,6 +729,7 @@ AnyBot 是一個桌面聊天 App,介面和運作方式照 Grok Bot 重做。 - 預設是這台 Mac 的 Docker(OrbStack 或 Docker Desktop)。 - 可以填 Docker 主機,例如 `ssh://me@server`(Tailscale 的機器名稱也行),Bot 的電腦就會在那台主機上;看畫面時 AnyBot 會自己開 SSH 通道。 +- **容器隔離**(設定 › 電腦 › 在哪裡執行):標準(runc:容器和 Docker 主機共用核心,Chromium 關掉自己的沙盒,容器是唯一那道牆)或強隔離(gVisor/runsc:電腦有自己的虛擬核心,權限全拿掉只留開機要的幾個,Chromium 的沙盒打開,兩道牆)。Docker 主機沒有 gVisor 時強隔離不能選;設定是強隔離、主機卻沒有 gVisor 時(例如換了 Docker 主機)先用標準開機並寫進紀錄,不會整台開不起來。那一列顯示電腦實際用的是哪種,和設定不同時提示按「更新」。Mac 上用 `deploy/mac/gvisor-vm.sh` 建一台自帶 Docker+gVisor 的 Lima 虛擬機(登入時自動啟動),`gvisor-migrate.sh` 把原本的檔案和登入搬過去(AnyBot 要先完全退出)。實測(Mac 上的 Lima VM):開機、畫面、VNC、無障礙編號、xdotool、截圖都正常;13 個 Chromium 程序都沒有 --no-sandbox,網頁程序 seccomp=2、有自己的命名空間;每一步 exec 55–69 ms、截圖 95 ms。 - 換位置後會在新的地方重新建立;舊檔案可以先「儲存快照」,換過去再「從快照復原」。 - **「網路流量改走這台 Mac」**(原版:網路流量可以改走你的本機電腦,「僅對新連線生效」,管理員可以關掉):打開後,Bot 的電腦之後執行的指令會經過這台 Mac 上的代理伺服器出去(例如要連公司內網或 VPN 才看得到的服務)。代理只聽這台 Mac 自己(127.0.0.1),用原版的埠 8791,被占用就往後找。設定頁顯示經過的連線數。Bot 的電腦在別的主機上時不能用。管理員可以用 `defaults write com.anybot.AnyBot AllowBoxTrafficViaThisMac false` 鎖住,這時顯示「🔒 由你的管理員設定,無法變更」。 diff --git a/box/Dockerfile b/box/Dockerfile index 853b732..70c91a0 100644 --- a/box/Dockerfile +++ b/box/Dockerfile @@ -21,12 +21,20 @@ COPY overlay /etc/anybot/overlay COPY Xresources /etc/anybot/Xresources COPY menu /etc/anybot/menu RUN chmod +x /usr/local/bin/anybot-box-start /usr/local/bin/anybot-a11y /usr/local/bin/anybot-browser \ - # Chromium has no sandbox inside a container (no user namespaces); the box itself is the - # sandbox. Debian's chromium wrapper reads these flags for every launch (menu, bots, xdg-open). - && printf '%s\n' 'export CHROMIUM_FLAGS="$CHROMIUM_FLAGS --no-sandbox --disable-dev-shm-usage --no-first-run --no-default-browser-check --disable-gpu --password-store=basic --test-type"' > /etc/chromium.d/anybot + # Chromium has no sandbox inside a plain container (no user namespaces); the box itself is + # the sandbox. Under gVisor (容器隔離=runsc) the box has its own virtualized kernel and + # Chromium's sandbox works: AnyBot then starts the box with ANYBOT_BOX_SANDBOX=1 and the + # sandbox is switched back on (one more wall between a hostile web page and the machine). + # Debian's chromium wrapper reads these flags for every launch (menu, bots, xdg-open). + && printf '%s\n' \ + 'if [ "${ANYBOT_BOX_SANDBOX:-0}" = 1 ]; then' \ + ' export CHROMIUM_FLAGS="$CHROMIUM_FLAGS --disable-dev-shm-usage --no-first-run --no-default-browser-check --disable-gpu --password-store=basic"' \ + 'else' \ + ' export CHROMIUM_FLAGS="$CHROMIUM_FLAGS --no-sandbox --test-type --disable-dev-shm-usage --no-first-run --no-default-browser-check --disable-gpu --password-store=basic"' \ + 'fi' > /etc/chromium.d/anybot # Accessibility on for every app, including those started over `docker exec`. ENV DISPLAY=:1 SCREEN_SIZE=1280x800x24 GTK_MODULES=atk-bridge GTK_A11Y=atspi GNOME_ACCESSIBILITY=1 NO_AT_BRIDGE=0 EXPOSE 6080 -LABEL anybot.box.version="6" +LABEL anybot.box.version="7" ENTRYPOINT ["/usr/bin/tini", "--"] CMD ["/usr/local/bin/anybot-box-start"] diff --git a/box/browser b/box/browser index d78dee9..c4624d2 100644 --- a/box/browser +++ b/box/browser @@ -23,7 +23,12 @@ if [[ ! -e "$PROFILE" ]]; then ln -s /home/bot/.config/anybot-browser "$PROFILE" fi fi -FLAGS=(--no-sandbox --test-type --user-data-dir="$PROFILE" --remote-debugging-address=127.0.0.1 --remote-debugging-port=9222 +# The box (a container) is the sandbox by default, so Chromium runs --no-sandbox. Under gVisor +# (容器隔離=runsc) the box has a virtualized kernel of its own and AnyBot starts it with +# ANYBOT_BOX_SANDBOX=1 — Chromium's own sandbox works there, so keep it as a second wall. +SANDBOX=(--no-sandbox --test-type) +[ "${ANYBOT_BOX_SANDBOX:-0}" = 1 ] && SANDBOX=() +FLAGS=("${SANDBOX[@]}" --user-data-dir="$PROFILE" --remote-debugging-address=127.0.0.1 --remote-debugging-port=9222 --remote-allow-origins=* --force-renderer-accessibility --disable-blink-features=AutomationControlled --disable-dev-shm-usage --disable-gpu --use-angle=swiftshader --enable-unsafe-swiftshader --password-store=basic --no-first-run --no-default-browser-check --disable-session-crashed-bubble --hide-crash-restore-bubble diff --git a/deploy/mac/gvisor-migrate.sh b/deploy/mac/gvisor-migrate.sh new file mode 100755 index 0000000..694a7b8 --- /dev/null +++ b/deploy/mac/gvisor-migrate.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# 把 Bot 的電腦整個搬到 gVisor VM:box 的全部檔案與瀏覽器登入都在 home 資料卷 +# (anybot-box-home,Chromium 的 profile 也在裡面),整卷複製過去後, +# AnyBot 在 VM 上掛同名資料卷開機=檔案、登入、桌面設定全部跟著走。 +# 所有 Bot 共用這一台、這一份登入與檔案:不分帳號,資料夾照常共用。 +# +# 先完全退出 AnyBot 應用程式,然後: +# ./gvisor-migrate.sh 搬過去(本機這份保留,當備份) +# ./gvisor-migrate.sh --check 只比對兩邊,不動資料 +set -euo pipefail +VM=anybot-gvisor +VOL=anybot-box-home +VMHOST="unix://$HOME/.lima/$VM/sock/docker.sock" + +command -v docker >/dev/null 2>&1 || { echo "找不到 docker 指令"; exit 1; } +if [ -n "${DOCKER_HOST:-}" ]; then + echo "現在環境裡有 DOCKER_HOST=$DOCKER_HOST(要從本機 Docker 搬出,請用普通終端機跑)"; exit 1 +fi +docker volume inspect "$VOL" >/dev/null 2>&1 || { echo "本機找不到資料卷 $VOL(box 還沒建立過?)"; exit 1; } +limactl list "$VM" --format '{{.Status}}' 2>/dev/null | grep -q Running || { echo "VM $VM 沒在跑,先跑 gvisor-vm.sh"; exit 1; } +# The Mac app's process is "AnyBot" and `anybot --serve` is "anybot": match either case. +if pgrep -ix anybot >/dev/null 2>&1; then + echo "AnyBot 還在跑:請先從選單完全退出(它會把 box 叫醒、資料卷會一直在用)。"; exit 1 +fi + +echo "本機 box 的檔案:$(docker run --rm -v "$VOL":/h alpine:3.20 sh -c 'find /h | wc -l') 個項目、$(docker run --rm -v "$VOL":/h alpine:3.20 sh -c 'du -sh /h | cut -f1')" + +if [ "${1:-}" = "--check" ]; then + echo "VM 上的檔案:$(DOCKER_HOST=$VMHOST docker run --rm -v "$VOL":/h alpine:3.20 sh -c 'find /h 2>/dev/null | wc -l') 個項目" + exit 0 +fi + +echo "停止本機的 box(容器留在本機當備份,不刪)…" +docker stop anybot-box anybot-box-home-holder >/dev/null 2>&1 || true + +echo "VM 上準備同名資料卷…" +DOCKER_HOST=$VMHOST docker volume create "$VOL" >/dev/null + +echo "搬移中(本機 → VM,同一條管線直送,不落地)…" +# 兩邊都是 Linux,檔案擁有者(uid 1000 = box 裡的 bot 使用者)直接對得上。 +docker run --rm -v "$VOL":/h -w /h alpine:3.20 tar -cf - . \ + | DOCKER_HOST=$VMHOST docker run --rm -i -v "$VOL":/h -w /h alpine:3.20 tar -xf - + +echo "核對…" +DOCKER_HOST=$VMHOST docker run --rm -v "$VOL":/h alpine:3.20 sh -c 'echo "VM 上的檔案:$(find /h | wc -l) 個項目、$(du -sh /h | cut -f1)"' + +cat </dev/null || true + echo "已移除 VM $VM。" + exit 0 +fi + +command -v limactl >/dev/null 2>&1 || { echo "先裝 Lima:brew install lima"; exit 1; } + +STATUS=$(limactl list "$VM" --format '{{.Status}}' 2>/dev/null || true) +if [ "$STATUS" = "Running" ]; then + echo "VM $VM 已在執行。" +elif [ -n "$STATUS" ]; then + echo "VM $VM 是 $STATUS,啟動中…" + limactl start --tty=false "$VM" +else + echo "建立 VM ${VM}($CPUS CPU / ${MEM}GB / ${DISK}GB,第一次要下載映像檔,幾分鐘)…" + # template:docker-rootful = Lima's rootful Docker template (runsc wants a plain root daemon; + # the rootless docker template runs the daemon in a user namespace). + limactl start --name="$VM" --tty=false --cpus "$CPUS" --memory "$MEM" --disk "$DISK" template:docker-rootful +fi + +echo "VM 裡安裝 gVisor(runsc,systrap 平台:不需要 KVM,ARM VM 可用)…" +limactl shell "$VM" -- bash -ec ' +set -e +if ! command -v runsc >/dev/null 2>&1; then + sudo mkdir -p /etc/apt/keyrings + curl -fsSL https://gvisor.dev/archive.key | sudo gpg --dearmor -o /usr/share/keyrings/gvisor-archive-keyring.gpg + echo "deb [signed-by=/usr/share/keyrings/gvisor-archive-keyring.gpg] https://storage.googleapis.com/gvisor/releases release main" | sudo tee /etc/apt/sources.list.d/gvisor.list >/dev/null + sudo apt-get update -qq + sudo apt-get install -y -qq runsc +fi +# Register the runtime in the VM'\''s Docker daemon (merge, do not clobber other settings). +sudo python3 - </dev/null 2>&1 || limactl start-at-login "$VM" --enabled >/dev/null 2>&1 || echo "(沒能設定開機自動啟動:Mac 重開機後要再跑一次這個腳本)" + +# Lima forwards the VM's Docker socket to the Mac: AnyBot's Docker 主機用這個 unix:// 位址, +# 不用 ssh、不用碰 ~/.ssh/config。 +SOCK="$HOME/.lima/$VM/sock/docker.sock" +DOCKER_HOST="unix://$SOCK" docker info --format '{{range $k, $v := .Runtimes}}{{$k}} {{end}}' | grep -qw runsc || { echo "從 Mac 連到 VM 的 Docker 看不到 runsc"; exit 1; } +KERNEL=$(DOCKER_HOST="unix://$SOCK" docker run --rm --runtime=runsc --entrypoint cat alpine:3.20 /proc/version | cut -d' ' -f3) + +cat <, secret_error: String, docker_host: Option, + /// 容器隔離 row: "runc" (standard) or "runsc" (gVisor). + box_runtime: String, + /// Checked in the background: the Docker host has gVisor, and the runtime the box runs on. + runtime_facts: Arc>>, show_log: bool, teach_name: String, teach_for: Option, @@ -51,6 +55,9 @@ pub struct ComputerUi { thumb_at: Option, } +/// (the Docker host has gVisor, the runtime the box runs on now) +type RuntimeFacts = (bool, Option); + fn status_color(r: &Run, p: Palette) -> Color32 { match r { Run::Running => Color32::from_rgb(0x14, 0xc3, 0x70), @@ -97,7 +104,11 @@ impl App { if !self.demo && self.cu.update_checked.map_or(true, |t| t.elapsed().as_secs() > 30) { self.cu.update_checked = Some(Instant::now()); let flag = self.cu.update_flag.clone(); - std::thread::spawn(move || flag.store(cloudbox::update_available(), Ordering::SeqCst)); + let facts = self.cu.runtime_facts.clone(); + std::thread::spawn(move || { + flag.store(cloudbox::update_available(), Ordering::SeqCst); + *facts.lock().unwrap() = Some((cloudbox::host_has_gvisor(), cloudbox::running_runtime())); + }); } self.cu.update_ready = self.cu.update_flag.load(Ordering::SeqCst); ui.spacing_mut().item_spacing.y = 0.0; @@ -616,6 +627,39 @@ impl App { } ui.add(egui::TextEdit::singleline(&mut edit).hint_text(t("這台電腦(預設)")).desired_width(180.0).font(egui::TextStyle::Monospace)); }); + let rt_now = cloudbox::box_runtime(); + let facts = self.cu.runtime_facts.lock().unwrap().clone(); + // What the box really runs on, and what pressing 更新 would change. + let status = match &facts { + Some((false, _)) if rt_now == "runsc" => "這台 Docker 主機沒有裝 gVisor:會用標準隔離開機".to_string(), + Some((_, Some(running))) if *running != rt_now => format!("目前是{};按電腦的「更新」重建後改成{}", if running == "runsc" { "強隔離" } else { "標準" }, if rt_now == "runsc" { "強隔離" } else { "標準" }), + Some((_, Some(running))) => format!("目前是{}", if running == "runsc" { "強隔離(gVisor)" } else { "標準" }), + _ => String::new(), + }; + let gvisor_here = facts.as_ref().map(|f| f.0).unwrap_or(false); + sec_row_s(ui, p, "容器隔離", &status, "標準:容器和這台 Docker 主機共用 Linux 核心,Bot 開網頁時只有一層牆。Docker 主機裝了 gVisor 才能選強隔離:電腦得到自己的虛擬核心,Chromium 的沙盒也會打開,多一道牆。改完按電腦的「更新」重建後生效。", |ui| { + let rt = if self.cu.box_runtime.is_empty() { rt_now.clone() } else { self.cu.box_runtime.clone() }; + let std_on = rt == "runc"; + let gvis_on = rt == "runsc"; + if ui.selectable_label(std_on, t("標準")).clicked() { + self.cu.box_runtime = "runc".into(); + } + let g = ui.add_enabled_ui(gvisor_here || gvis_on, |ui| ui.selectable_label(gvis_on, t("強隔離(gVisor)"))).inner; + if g.clicked() { + self.cu.box_runtime = "runsc".into(); + } + g.on_disabled_hover_text(t("這台 Docker 主機沒有裝 gVisor(runsc)")); + if !self.cu.box_runtime.is_empty() && self.cu.box_runtime != rt_now { + if ui.add(primary_button("套用", p)).clicked() { + cloudbox::set_box_runtime(&self.cu.box_runtime); + self.cu.box_runtime.clear(); + self.cu.update_checked = None; + } + if ui.add(egui::Button::new(RichText::new(t("取消")).size(FONT_XS).color(p.text3)).frame(false)).clicked() { + self.cu.box_runtime.clear(); + } + } + }); }); if apply { cloudbox::set_docker_host(edit.trim()); diff --git a/src/cloudbox.rs b/src/cloudbox.rs index 918e9fd..5bbec2c 100644 --- a/src/cloudbox.rs +++ b/src/cloudbox.rs @@ -167,6 +167,13 @@ pub struct Config { /// loopback-only, but the box's own programs can reach it, and they should need a key). #[serde(default)] pub vnc_password: String, + /// 容器隔離: the Docker runtime the box runs on. Empty = Docker's default (runc: the box + /// shares the host's kernel, and Chromium runs with its own sandbox off). On a Linux Docker + /// host with gVisor installed, "runsc" gives the box a virtualized kernel and Chromium's + /// sandbox comes back — a second wall for the pages bots open. Takes effect on the next + /// 更新/重設/復原 (the container is recreated). + #[serde(default)] + pub runtime: String, } /// A scheduled update that was missed may still run for this long (the original: one hour). @@ -312,6 +319,39 @@ fn save_config(c: &Config) { let _ = std::fs::write(config_path(), serde_json::to_string_pretty(c).unwrap_or_default()); } +/// 容器隔離: the box's Docker runtime — "runc" (the default: a container sharing the host's +/// kernel) or "runsc" (gVisor: a virtualized kernel and user-space seccomp, for Docker hosts +/// that have it installed). Anything else falls back to the default. +pub fn box_runtime() -> String { + match config().lock().unwrap().runtime.as_str() { + "runsc" => "runsc".into(), + _ => "runc".into(), + } +} + +/// The box's Docker host has gVisor (a runtime registered as runsc). +pub fn host_has_gvisor() -> bool { + dk(&["info", "--format", "{{range $k, $v := .Runtimes}}{{$k}} {{end}}"]).map(|o| o.split_whitespace().any(|r| r == "runsc")).unwrap_or(false) +} + +/// The runtime the box is running on now ("runc", "runsc"), when there is a box. +pub fn running_runtime() -> Option { + dk(&["inspect", "-f", "{{.HostConfig.Runtime}}", container()]).ok().map(|r| r.trim().to_string()).filter(|r| !r.is_empty()) +} + +/// Changing it takes effect on the box's next rebuild (更新/重設/復原), where the container +/// is created again — nothing is recreated here behind the person's back. +pub fn set_box_runtime(r: &str) { + let r = if r.trim() == "runsc" { "runsc" } else { "" }; + let mut c = config().lock().unwrap(); + if c.runtime == r { + return; + } + c.runtime = r.to_string(); + save_config(&c); + log(&format!("容器隔離改為「{}」,下次重建電腦(更新、重設或復原)後生效。", if r == "runsc" { "gVisor(虛擬核心)" } else { "標準(共用主機核心)" })); +} + pub fn egress_via_mac() -> bool { config().lock().unwrap().egress_via_mac } @@ -688,8 +728,40 @@ fn create_container() -> Result<(), String> { let env = format!("SCREEN_SIZE={SCREEN}"); let vnc = format!("VNC_PASSWORD={}", vnc_password()); log("正在開一台新的電腦…"); + // 容器隔離: on a Docker host with gVisor (`runsc`), the box gets a virtualized kernel + // instead of the host's, its seccomp filters run in user space, and Chromium's own sandbox + // works inside the box (ANYBOT_BOX_SANDBOX=1 re-enables it; see box/Dockerfile). With the + // plain runc runtime the box itself is the sandbox, like the original. + // A Docker host without gVisor (the setting moved with a new Docker host, or gVisor was + // removed) would leave no computer at all ("unknown runtime"): open it the standard way and + // say so; the settings page shows which one it runs on. + let hardened = box_runtime() == "runsc" && host_has_gvisor(); + if box_runtime() == "runsc" && !hardened { + log("這台 Docker 主機沒有 gVisor(runsc),電腦先用標準隔離開機;裝好 gVisor 後按「更新」重建。"); + } + let runtime = "runsc"; + let sandbox_env = "ANYBOT_BOX_SANDBOX=1"; + let caps: Vec = ["CHOWN", "SETUID", "SETGID", "FOWNER", "DAC_OVERRIDE", "KILL", "NET_BIND_SERVICE"] + .iter() + .flat_map(|c| ["--cap-add".to_string(), (*c).to_string()]) + .collect(); + let mut args: Vec<&str> = vec!["run", "-d", "--name", container(), "--hostname", "bot-computer", "--shm-size", "1g", "--pids-limit", "4096", "--memory", "2g", "--cpus", "2", "--add-host", "host.docker.internal:host-gateway", "-p", &publish, "-v", &vol, "-e", &env, "-e", &vnc]; + if hardened { + // (No swap on top of the 2 GB under gVisor; a standard box keeps Docker's default.) + args.extend(["--memory-swap", "2g"]); + args.push("--runtime"); + args.push(runtime); + args.push("--cap-drop"); + args.push("ALL"); + for c in &caps { + args.push(c); + } + args.push("-e"); + args.push(sandbox_env); + } + args.extend(["--label", "anybot.box=1", IMAGE]); // host.docker.internal → this Mac (for 網路流量改走你的電腦; Docker Desktop and OrbStack already have it). - dk(&["run", "-d", "--name", container(), "--hostname", "bot-computer", "--shm-size", "1g", "--pids-limit", "4096", "--memory", "2g", "--cpus", "2", "--add-host", "host.docker.internal:host-gateway", "-p", &publish, "-v", &vol, "-e", &env, "-e", &vnc, "--label", "anybot.box=1", IMAGE]).map_err(|e| format!("開不了電腦:{}", tail(&e, 400)))?; + dk(&args).map_err(|e| format!("開不了電腦:{}", tail(&e, 400)))?; ensure_holder(); Ok(()) } @@ -2513,7 +2585,7 @@ mod tests { assert!(!in_schedule_window("03:00", t("02:59"))); assert!(in_schedule_window("23:30", t("00:10"))); assert!(!in_schedule_window("bad", t("00:10"))); - assert_eq!(wanted_version(), "6"); + assert_eq!(wanted_version(), "7"); } #[test] From 9a23411e518fe33e7d9df2d853c8a4b08be025bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Sat, 7 Nov 2026 00:19:56 +0800 Subject: [PATCH 4/6] Checked by using it: tests stay off the real box, gVisor scripts run clean, times are the person's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - box_numbers_only_on_what_is_seen is ignored again (a test inserted before it in cbada6b took its mark: every cargo test drove the real box), and the path test runs - gvisor-vm.sh restarts the VM's Docker only when runsc was just registered (a restart stopped Bot 的電腦), and `${var}` before full-width text (bash read 「)」's first byte as part of the name and stopped) - a scheduled box update and a PDF's name use the person's time zone (a server keeps UTC) - 容器隔離 lists 標準 before 強隔離 - screenshots of real data can open 設定 › 電腦 (ANYBOT_SHOT_VIEW=computers) and scroll (ANYBOT_SHOT_SCROLL) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4 --- deploy/mac/gvisor-migrate.sh | 4 ++-- deploy/mac/gvisor-vm.sh | 20 ++++++++++++-------- src/app.rs | 30 ++++++++++++++++++++++++++---- src/app/computers_ui.rs | 7 ++++--- src/browser.rs | 2 +- src/cloudbox.rs | 21 +++++++++++++-------- 6 files changed, 58 insertions(+), 26 deletions(-) diff --git a/deploy/mac/gvisor-migrate.sh b/deploy/mac/gvisor-migrate.sh index 694a7b8..23b09f7 100755 --- a/deploy/mac/gvisor-migrate.sh +++ b/deploy/mac/gvisor-migrate.sh @@ -14,9 +14,9 @@ VMHOST="unix://$HOME/.lima/$VM/sock/docker.sock" command -v docker >/dev/null 2>&1 || { echo "找不到 docker 指令"; exit 1; } if [ -n "${DOCKER_HOST:-}" ]; then - echo "現在環境裡有 DOCKER_HOST=$DOCKER_HOST(要從本機 Docker 搬出,請用普通終端機跑)"; exit 1 + echo "現在環境裡有 DOCKER_HOST=${DOCKER_HOST}(要從本機 Docker 搬出,請用普通終端機跑)"; exit 1 fi -docker volume inspect "$VOL" >/dev/null 2>&1 || { echo "本機找不到資料卷 $VOL(box 還沒建立過?)"; exit 1; } +docker volume inspect "$VOL" >/dev/null 2>&1 || { echo "本機找不到資料卷 ${VOL}(box 還沒建立過?)"; exit 1; } limactl list "$VM" --format '{{.Status}}' 2>/dev/null | grep -q Running || { echo "VM $VM 沒在跑,先跑 gvisor-vm.sh"; exit 1; } # The Mac app's process is "AnyBot" and `anybot --serve` is "anybot": match either case. if pgrep -ix anybot >/dev/null 2>&1; then diff --git a/deploy/mac/gvisor-vm.sh b/deploy/mac/gvisor-vm.sh index 1e1e25e..fde3c49 100755 --- a/deploy/mac/gvisor-vm.sh +++ b/deploy/mac/gvisor-vm.sh @@ -20,7 +20,7 @@ DISK=60 # GiB if [ "${1:-}" = "--rm" ]; then limactl delete --force "$VM" 2>/dev/null || true - echo "已移除 VM $VM。" + echo "已移除 VM ${VM}。" exit 0 fi @@ -30,7 +30,7 @@ STATUS=$(limactl list "$VM" --format '{{.Status}}' 2>/dev/null || true) if [ "$STATUS" = "Running" ]; then echo "VM $VM 已在執行。" elif [ -n "$STATUS" ]; then - echo "VM $VM 是 $STATUS,啟動中…" + echo "VM $VM 是 ${STATUS},啟動中…" limactl start --tty=false "$VM" else echo "建立 VM ${VM}($CPUS CPU / ${MEM}GB / ${DISK}GB,第一次要下載映像檔,幾分鐘)…" @@ -50,15 +50,19 @@ if ! command -v runsc >/dev/null 2>&1; then sudo apt-get install -y -qq runsc fi # Register the runtime in the VM'\''s Docker daemon (merge, do not clobber other settings). -sudo python3 - < { + app.show_settings = true; + app.settings_tab = SettingsTab::Remote; + } + Ok("computers") => { + app.show_settings = true; + app.settings_tab = SettingsTab::Computers; + } + _ => {} + } } // …or the side panel, or the computer viewer, as they look with that data. if app.screenshot.is_some() && !app.demo { @@ -3716,6 +3726,18 @@ impl eframe::App for App { } } } + // ANYBOT_SHOT_SCROLL="x,y,dy@frame": from that frame on, a wheel step of dy over x,y on + // each of 100 frames (egui eases a wheel in over real time; screenshot frames are quick). + if let Ok(sc) = std::env::var("ANYBOT_SHOT_SCROLL") { + if let Some((v, at)) = sc.split_once('@') { + let n: Vec = v.split(',').filter_map(|x| x.trim().parse().ok()).collect(); + let (at, f) = (at.trim().parse::().unwrap_or(30), self.frames + 1); + if n.len() == 3 && f >= at && f < at + 100 { + raw.events.push(egui::Event::PointerMoved(egui::pos2(n[0], n[1]))); + raw.events.push(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, n[2]), phase: egui::TouchPhase::Move, source: egui::MouseWheelSource::Unknown, modifiers: Default::default() }); + } + } + } let Ok(spec) = std::env::var("ANYBOT_SHOT_CLICK") else { return }; for one in spec.split(';') { let (pos, right) = match one.strip_suffix(":right") { Some(p) => (p, true), None => (one, false) }; diff --git a/src/app/computers_ui.rs b/src/app/computers_ui.rs index 542b3de..d1b77d3 100644 --- a/src/app/computers_ui.rs +++ b/src/app/computers_ui.rs @@ -641,14 +641,15 @@ impl App { let rt = if self.cu.box_runtime.is_empty() { rt_now.clone() } else { self.cu.box_runtime.clone() }; let std_on = rt == "runc"; let gvis_on = rt == "runsc"; - if ui.selectable_label(std_on, t("標準")).clicked() { - self.cu.box_runtime = "runc".into(); - } + // (The row lays out right to left: 強隔離 first puts 標準 on its left.) let g = ui.add_enabled_ui(gvisor_here || gvis_on, |ui| ui.selectable_label(gvis_on, t("強隔離(gVisor)"))).inner; if g.clicked() { self.cu.box_runtime = "runsc".into(); } g.on_disabled_hover_text(t("這台 Docker 主機沒有裝 gVisor(runsc)")); + if ui.selectable_label(std_on, t("標準")).clicked() { + self.cu.box_runtime = "runc".into(); + } if !self.cu.box_runtime.is_empty() && self.cu.box_runtime != rt_now { if ui.add(primary_button("套用", p)).clicked() { cloudbox::set_box_runtime(&self.cu.box_runtime); diff --git a/src/browser.rs b/src/browser.rs index 686ae88..c11221f 100644 --- a/src/browser.rs +++ b/src/browser.rs @@ -764,7 +764,7 @@ pub fn run(name: &str, args: &Value) -> Result<(String, Option), String> let bytes = base64::engine::general_purpose::STANDARD.decode(r["data"].as_str().unwrap_or("")).map_err(|e| e.to_string())?; let (title, _, _) = snapshot(&mut tab); let safe: String = title.chars().filter(|c| !"/\\:*?\"<>|".contains(*c)).take(60).collect(); - let name = format!("{}-{}.pdf", if safe.trim().is_empty() { "網頁".into() } else { safe }, chrono::Local::now().format("%Y%m%d-%H%M%S")); + let name = format!("{}-{}.pdf", if safe.trim().is_empty() { "網頁".into() } else { safe }, chrono::Utc::now().with_timezone(&crate::routines::person_tz()).format("%Y%m%d-%H%M%S")); // Saved where the bot can open it: on Bot 的電腦 when that's the browser's computer. let path = if botbrowser::using_box() { let p = format!("{}/{name}", crate::cloudbox::BOX_DOWNLOADS); diff --git a/src/cloudbox.rs b/src/cloudbox.rs index 5bbec2c..cb32e1e 100644 --- a/src/cloudbox.rs +++ b/src/cloudbox.rs @@ -420,7 +420,8 @@ fn maybe_scheduled_update() { (c.auto_update_at.clone(), c.auto_update_done.clone()) }; let Some(at) = at else { return }; - let now = chrono::Local::now(); + // The person's clock (a brain on a server keeps UTC: 03:00 used to be 11:00 in Taipei). + let now = chrono::Utc::now().with_timezone(&crate::routines::person_tz()); let today = now.format("%Y-%m-%d").to_string(); if done == today || !in_schedule_window(&at, now.time()) || is_busy() || !update_available() { return; @@ -2240,13 +2241,6 @@ mod tests { /// (xterm has no accessibility; clicking a number there used to type into the terminal). #[test] #[ignore] - fn box_paths_are_the_bot_users() { - assert_eq!(box_path("~/bot-6/附件/IMG_0587.jpeg"), "/home/bot/bot-6/附件/IMG_0587.jpeg"); - assert_eq!(box_path(" ~ "), "/home/bot"); - assert_eq!(box_path("/tmp/a.jpg"), "/tmp/a.jpg"); - } - - #[test] fn box_numbers_only_on_what_is_seen() { ensure_running().expect("box starts"); let cancel = AtomicBool::new(false); @@ -2576,6 +2570,13 @@ mod tests { let _ = run_on("shell", r#"{"command":"cd .. && rm -rf bot-998"}"#, &t, &cancel, &ids); } + #[test] + fn box_paths_are_the_bot_users() { + assert_eq!(box_path("~/bot-6/附件/IMG_0587.jpeg"), "/home/bot/bot-6/附件/IMG_0587.jpeg"); + assert_eq!(box_path(" ~ "), "/home/bot"); + assert_eq!(box_path("/tmp/a.jpg"), "/tmp/a.jpg"); + } + #[test] fn schedule_window_is_one_hour() { let t = |s: &str| chrono::NaiveTime::parse_from_str(s, "%H:%M").unwrap(); @@ -2585,6 +2586,10 @@ mod tests { assert!(!in_schedule_window("03:00", t("02:59"))); assert!(in_schedule_window("23:30", t("00:10"))); assert!(!in_schedule_window("bad", t("00:10"))); + // On a server (UTC), 03:00 is the person's 03:00: 19:00 UTC in Taipei. + use chrono::TimeZone; + let at = chrono::Utc.with_ymd_and_hms(2026, 11, 6, 19, 0, 0).unwrap().with_timezone(&chrono_tz::Asia::Taipei); + assert!(in_schedule_window("03:00", at.time())); assert_eq!(wanted_version(), "7"); } From 19f8257e488e53992027d516c8c766055960c341 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Sat, 7 Nov 2026 00:28:35 +0800 Subject: [PATCH 5/6] An upload says what the field holds now and what the page showed The tool only said it put the file in; on a page that shows nothing about it the bot spent five more steps checking (shell, read, screenshot, two scripts). Now the result names the file the field holds (and its size) and the page's new text, and an empty field is an error. Real run in the gVisor box with Grok: the sign-up form went from 10 steps / 35 s to 6 / 20 s; the second time it followed the skill it had learned, by names only, 6 steps / 21 s. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4 --- src/agent.rs | 2 +- src/browser.rs | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/agent.rs b/src/agent.rs index a4a3db3..1592bd1 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -1625,7 +1625,7 @@ printf 'a,b\n1,2\n' > report.csv && printf 'hello box' > '/tmp/anybot-up/上傳 } assert!(results[0].contains("served"), "{}", results[0]); assert!(results[1].contains("檔案測試"), "{}", results[1]); - assert!(results[2].contains("已把 /tmp/anybot-up/上傳測試.txt 放進上傳欄位"), "{}", results[2]); + assert!(results[2].contains("已把 /tmp/anybot-up/上傳測試.txt 放進上傳欄位,欄位裡現在是:上傳測試.txt(9 位元組)") && results[2].contains("已選:上傳測試.txt 9"), "{}", results[2]); assert!(results[3].contains("已選:上傳測試.txt 9"), "{}", results[3]); assert!(results[4].contains("這台電腦上沒有「/tmp/anybot-up/沒有這個.txt」"), "{}", results[4]); assert!(results[5].contains("下載好了:/home/bot/Downloads/report.csv"), "{}", results[5]); diff --git a/src/browser.rs b/src/browser.rs index c11221f..f6fcd98 100644 --- a/src/browser.rs +++ b/src/browser.rs @@ -727,10 +727,20 @@ pub fn run(name: &str, args: &Value) -> Result<(String, Option), String> let js = format!("(() => {{ let el = {}; if (el && el.tagName !== 'INPUT') el = el.querySelector('input[type=file]') || el.closest('label')?.querySelector('input[type=file]'); return el || document.querySelector('input[type=file]'); }})()", find_js(&rf, &text)); let r = tab.call("Runtime.evaluate", json!({"expression": js, "returnByValue": false}))?; let obj = r["result"]["objectId"].as_str().ok_or("這一頁找不到上傳檔案的欄位")?.to_string(); + let before = snapshot(&mut tab); tab.call("DOM.setFileInputFiles", json!({"files": [full], "objectId": obj}))?; tab.eval(&format!("(() => {{ const el = {js}; if (el) el.dispatchEvent(new Event('change', {{bubbles: true}})); return 1; }})()"))?; settle(1000); - Ok((format!("已把 {full} 放進上傳欄位。"), None)) + // What the field holds now (a page that shows nothing about it left the bot + // checking with five more steps), and what the page did with it. + let held = tab.eval(&format!("(() => {{ const el = {js}; return el && el.files ? Array.from(el.files).map(f => f.name + '(' + f.size + ' 位元組)').join('、') : ''; }})()")).ok().and_then(|v| v.as_str().map(String::from)).unwrap_or_default(); + if held.is_empty() { + return Err(format!("放了 {full},但上傳欄位還是空的(這一頁可能要先按「上傳」之類的按鈕,或欄位不是這一個)。")); + } + let after = snapshot(&mut tab); + let seen = changes(&before, &after); + let seen = if seen.contains("沒有變化") { String::new() } else { format!("\n{seen}") }; + Ok((format!("已把 {full} 放進上傳欄位,欄位裡現在是:{held}。{seen}"), None)) } "browser_download" => { // The file lands on the browser's own disk: Bot 的電腦's when it runs there. From e0a1545e0e028d5b06ff9c8cc87a95ce78b148ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Sat, 7 Nov 2026 00:36:24 +0800 Subject: [PATCH 6/6] A step on a skill or a memory says which one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The phone showed a bare 「刪除技能」 when 新 Bot deleted the test skill; save/use/delete skill, remember and forget now carry the name or the fact. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4 --- src/appctl.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/appctl.rs b/src/appctl.rs index 988d804..ba35341 100644 --- a/src/appctl.rs +++ b/src/appctl.rs @@ -128,6 +128,10 @@ pub fn describe(name: &str, args: &str) -> String { "delete_routine" | "run_routine_now" => format!("#{}", v.get("id").and_then(Value::as_u64).unwrap_or(0)), "message_bot" => format!("給 {}:{}", s("bot"), s("text").chars().take(40).collect::()), "create_bot" | "update_bot" => s("name").to_string() + &s("bot"), + // Which skill (the phone showed a bare 「刪除技能」), and which memory. + "save_skill" | "use_skill" | "delete_skill" => s("name"), + "remember" => s("fact").chars().take(40).collect(), + "forget" => s("what"), n if crate::pages::NAMES.contains(&n) => crate::pages::describe(n, &v), _ => String::new(), } @@ -594,6 +598,14 @@ pub fn apply(state: &mut State, bot_id: u64, name: &str, args: &str) -> Outcome mod tests { use super::*; + #[test] + fn a_step_on_skills_and_memories_names_which() { + assert_eq!(describe("delete_skill", r#"{"name":"本地活動報名"}"#), "本地活動報名"); + assert_eq!(describe("use_skill", r#"{"name":"Threads 發文"}"#), "Threads 發文"); + assert_eq!(describe("remember", r#"{"fact":"之後都用自己的電腦"}"#), "之後都用自己的電腦"); + assert_eq!(describe("forget", r#"{"what":"2"}"#), "2"); + } + #[test] fn chat_sets_up_and_manages_routines() { let mut st = State::samples();