From d78e75ebec4a29d6f181592efb462a701d3ca46f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Thu, 5 Nov 2026 14:25:01 +0800 Subject: [PATCH] First start: no bots, a guide instead; signing in syncs this computer and the account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A new install (desktop or anybot --serve) starts with no bots; the main window shows 「歡迎使用 AnyBot」: connect a model, make the first bot, or sign in. - Old data whose five sample bots were never used starts empty too. - Signing in syncs right away: an online brain of the account is used and this computer's bots are moved there; otherwise the backup's missing bots are taken in and backed up again. - The five sample bots remain only for screenshots and tests (State::samples). Co-Authored-By: Claude Opus 5.5 --- FEATURES.md | 22 ++++--- mobile/index.html | 2 +- src/app.rs | 27 ++++----- src/app/mirror_ui.rs | 11 +++- src/app/remote_ui.rs | 37 ++++++++++-- src/app/sidebar_ui.rs | 4 ++ src/app/welcome_ui.rs | 117 ++++++++++++++++++++++++++++++++++++ src/appctl.rs | 8 +-- src/model.rs | 136 ++++++++++++++++++++++++++++++++++++++++-- src/remote.rs | 61 +++++++++++++++++++ 10 files changed, 383 insertions(+), 42 deletions(-) create mode 100644 src/app/welcome_ui.rs diff --git a/FEATURES.md b/FEATURES.md index c358aea..f29d55b 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -1509,17 +1509,21 @@ computer_use_screenshot、click、mouse_move、mouse_button(按住/放開) --- -## 31. 第一次啟動的 Bot +## 31. 第一次啟動 -| Bot | 形狀、顏色 | 供應商 | 描述 | 備註 | -|---|---|---|---|---| -| 「Bot」 | 圓角方、黑 | xAI Grok | 「你的主要聯絡窗口,什麼都可以問。」 | 主要 Bot | -| 「研究助理」 | 圓球、綠 | Claude | 「幫你查資料、整理重點。」 | 已釘選 | -| 「寫作夥伴」 | 雲、紫 | OpenAI | 「改稿、翻譯、想標題。」 | 已釘選 | -| 「電腦助手」 | 寶石、藍 | Claude | 「幫你整理檔案、跑指令。動手前會先問你。」 | 已釘選,電腦工具預設開 | -| 「本機模型」 | 六角、棕 | Ollama | 「在你自己的電腦上跑,不用連網。」 | | +第一次打開時沒有任何 Bot,主畫面是「歡迎使用 AnyBot」引導: -每個 Bot 都用那家供應商的建議模型。 +1. **連上 AI 模型**:還沒有任何金鑰時,按「設定模型」打開模型設定;有了就打勾,寫出可以用哪一家。 +2. **建立你的第一個 Bot**:打開建立 Bot 視窗,預設用剛設好金鑰的那一家。第一個建立的 Bot 就是主要 Bot。 +3. **在別台電腦用過 AnyBot?**:「登入帳號」打開帳號設定。 + +刪掉最後一個 Bot 也會回到這個引導。伺服器(anybot --serve)裝好也是零個 Bot。舊版開過、但五個範例 Bot 從沒用過(沒聊天、沒改名、沒有例行任務和記憶)的資料,更新後會清掉範例,跟新裝一樣。手機頁沒有 Bot 時,請你到電腦上建立。 + +**登入後自動同步**(設定 → 帳號,按「登入」之後,不用再按別的): +- 帳號裡有另一台開著、自己思考的 AnyBot(不是手腳、不是在看別台的):改用它當大腦,那邊的 Bot 和聊天出現在這台;連上後自動把這台原本的 Bot(連同聊天)、模型金鑰、技能和連接器搬過去,大腦已經有的會略過。 +- 沒有:拿帳號裡最新的備份,把這台還沒有的 Bot 加進來(同名而且聊天相同的算已經有),再把合起來的整份備份回帳號。 + +截圖和測試用的五個範例 Bot(State::samples):Bot、研究助理、寫作夥伴、電腦助手、本機模型。 --- diff --git a/mobile/index.html b/mobile/index.html index bccc044..983d661 100644 --- a/mobile/index.html +++ b/mobile/index.html @@ -434,7 +434,7 @@ function apply(v) { function botName(id) { const b = S.bots.find((x) => x.id === id); return b ? b.name : ""; } function renderList() { const el = $("bots"); - if (!S.bots.length) { el.innerHTML = `
還沒有 Bot
`; return; } + if (!S.bots.length) { el.innerHTML = `
還沒有 Bot。打開電腦上的 AnyBot,照著畫面建立第一個 Bot,這裡就會出現。
`; return; } el.innerHTML = S.bots.map((b) => { const waiting = S.approvals.some((a) => a.bot === b.id); const state = waiting ? `等你決定` : b.busy ? `${esc(b.status || "工作中…")}` : ""; diff --git a/src/app.rs b/src/app.rs index ef18197..1da9e00 100644 --- a/src/app.rs +++ b/src/app.rs @@ -33,6 +33,7 @@ mod sidebar_ui; mod menu_ui; mod vault_ui; mod markdown_ui; +mod welcome_ui; mod mirror_ui; mod pages_ui; @@ -1411,7 +1412,9 @@ impl App { let shape = avatar::SHAPES[(id as usize) % avatar::SHAPES.len()]; let color = avatar::COLORS[(id as usize) % avatar::COLORS.len()].0; let base = self.state.bot(self.state.selected).map(|b| (b.provider, b.model.clone())); - let (provider, model) = base.unwrap_or((ProviderKind::Xai, ProviderKind::Xai.suggested_model().into())); + // The first bot: the service whose key was just set. + let first = self.ready_provider().unwrap_or(ProviderKind::Xai); + let (provider, model) = base.unwrap_or_else(|| (first, self.default_model(first))); let b = Bot { id, name: "新 Bot".into(), @@ -1689,6 +1692,11 @@ impl App { } egui::CentralPanel::default().frame(Frame::new().fill(p.pane)).show(root, |ui| { let id = self.state.selected; + if self.state.bots.is_empty() { + self.mirror_banner(ui); + self.welcome(ui); + return; + } let Some(bot) = self.state.bot(id).cloned() else { ui.centered_and_justified(|ui| ui.label(RichText::new(t("未選取 Bot")).color(p.text3))); return; @@ -3013,20 +3021,8 @@ impl App { if ui.add(danger_button("刪除", p)).clicked() { self.stop(id); self.state.bots.retain(|b| b.id != id); - if self.state.bots.is_empty() { - let mut fresh = State::first_run(); - fresh.bots.truncate(1); - fresh.bots[0].id = self.state.next_id; - self.state.next_id += 1; - self.state.bots = fresh.bots; - } - let first = self.state.bots[0].id; - if self.state.primary_bot == id { - self.state.primary_bot = first; - } - if self.state.selected == id { - self.state.selected = first; - } + // The last one gone: back to the getting-started guide. + self.state.fix_primary(); self.dirty = true; close = true; } @@ -3347,6 +3343,7 @@ impl eframe::App for App { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { let ctx = &ui.ctx().clone(); self.handle_screenshot(ctx); + self.state.fix_primary(); self.md_viewer(ctx); self.page_viewer(ctx); self.refresh_presence(ctx); diff --git a/src/app/mirror_ui.rs b/src/app/mirror_ui.rs index b0644a4..84fcf9c 100644 --- a/src/app/mirror_ui.rs +++ b/src/app/mirror_ui.rs @@ -376,6 +376,8 @@ pub struct Mirror { pub(super) want: Option<(String, String)>, pub(super) leave: bool, pub(super) moving: Arc, + /// Just signed in: copy this computer's bots to the brain once it answers. + pub(super) move_after_join: bool, pub(super) move_note: Arc>, /// Requests to the brain, sent in order by one worker. outbox: Option>, @@ -533,6 +535,7 @@ impl App { } self.state.next_id = self.state.next_id.max(b.id + 1); self.state.bots.push(b); + self.state.fix_primary(); true } Err(_) => false, @@ -540,11 +543,12 @@ impl App { } "bot_del" => { let id = op["id"].as_u64().unwrap_or(0); - if id == self.state.primary_bot || self.state.bots.len() <= 1 { + if self.state.bot(id).is_none() { false } else { self.stop(id); self.state.bots.retain(|b| b.id != id); + self.state.fix_primary(); true } } @@ -877,6 +881,9 @@ impl App { self.mir.reason.clear(); *self.mir.status.lock().unwrap() = format!("已連到大腦「{}」", v["brain"].as_str().unwrap_or(&self.state.remote.brain_name)); self.mirror_apply(v); + if std::mem::take(&mut self.mir.move_after_join) { + self.mirror_move_local(); + } } } } @@ -1097,7 +1104,7 @@ impl App { } Out::Move { ops, n } => { *note.lock().unwrap() = match remote::sync_post(&s, "edit", &json!({"ops": ops})) { - Ok(_) if n == 0 => "大腦上已經有這台的 Bot 了;模型金鑰、技能和連接器有缺的已補上".into(), + Ok(_) if n == 0 => "已同步:大腦上已經有這台的 Bot;模型金鑰、技能和連接器有缺的已補上".into(), Ok(_) => format!("已把 {n} 個 Bot(連同聊天)搬到大腦"), Err(e) => format!("搬移失敗:{e}"), }; diff --git a/src/app/remote_ui.rs b/src/app/remote_ui.rs index bd58e72..bdf71d7 100644 --- a/src/app/remote_ui.rs +++ b/src/app/remote_ui.rs @@ -20,8 +20,8 @@ pub struct RemoteUi { pass: String, invite: String, create: bool, - note: Arc>, - join_rx: Option>>, + pub(super) note: Arc>, + join_rx: Option>>, restore_rx: Option, String>>>, busy: Arc, peers: Arc>>, @@ -98,10 +98,10 @@ impl App { if let Ok(r) = rx.try_recv() { self.rm.join_rx = None; match r { - Ok(s) => { + Ok((s, found)) => { self.state.remote = s; self.rm.pass.clear(); - *self.rm.note.lock().unwrap() = "已登入,這台 AnyBot 已加入帳號".into(); + self.account_sync(found); self.mark(); } Err(e) => *self.rm.note.lock().unwrap() = e, @@ -352,6 +352,28 @@ impl App { } } + /// 登入後同步: the account's bots and this computer's become one set. + /// • Another AnyBot of the account is the brain → show it, and copy this computer's bots + /// there (once it answers; bots it already has are skipped). + /// • This one is the only one → take in the backup's bots it doesn't have, then back up + /// the whole set, so the account has this computer's bots too. + fn account_sync(&mut self, found: remote::AccountFind) { + if let Some((id, name)) = found.brain { + *self.rm.note.lock().unwrap() = format!("已登入,改用「{name}」當大腦:那邊的 Bot 會同步過來,這台的也會搬過去"); + self.mir.want = Some((id, name)); + self.mir.move_after_join = true; + // The brain's bots may use this computer (each action still asks). + self.state.remote.device_link = true; + return; + } + let got = found.backup.and_then(|b| serde_json::from_slice::(&b).ok()); + let n = got.map(|st| self.state.take_in_bots(&st)).unwrap_or(0); + *self.rm.note.lock().unwrap() = if n > 0 { format!("已登入,帳號裡的 {n} 個 Bot 已同步到這台") } else { "已登入,這台 AnyBot 已加入帳號".into() }; + if !self.state.bots.is_empty() && self.state.remote.auto_backup { + self.remote_backup(); + } + } + fn remote_backup(&mut self) { if self.rm.busy.swap(true, Ordering::SeqCst) { return; @@ -450,7 +472,12 @@ impl App { self.rm.join_rx = Some(rx); let (url, user, pass, create, invite, base) = (self.rm.relay_url.clone(), self.rm.user.trim().to_string(), self.rm.pass.clone(), self.rm.create, self.rm.invite.clone(), self.state.remote.clone()); std::thread::spawn(move || { - let _ = tx.send(remote::relay_join(&base, &url, &user, &pass, create, &invite)); + // Signed in: look right away for the bots the account already has. + let r = remote::relay_join(&base, &url, &user, &pass, create, &invite).map(|s| { + let found = remote::find_account_bots(&s, &pass); + (s, found) + }); + let _ = tx.send(r); }); } if ui.add_enabled(ready && self.rm.restore_rx.is_none(), secondary_button("從帳號備份還原")).on_hover_text(t("把這台電腦的 Bot 換成帳號裡最新的備份")).clicked() { diff --git a/src/app/sidebar_ui.rs b/src/app/sidebar_ui.rs index b62c66c..30d0472 100644 --- a/src/app/sidebar_ui.rs +++ b/src/app/sidebar_ui.rs @@ -872,6 +872,10 @@ impl App { if self.demo && std::env::var("ANYBOT_SHOT_SPLASH").is_err() { return false; } + // No bot yet to say hello. + if self.state.bot(self.state.primary_bot).is_none() { + return false; + } let now = ctx.input(|i| i.time); let start = *self.splash_at.get_or_insert(now); let t = now - start; diff --git a/src/app/welcome_ui.rs b/src/app/welcome_ui.rs new file mode 100644 index 0000000..3c995dd --- /dev/null +++ b/src/app/welcome_ui.rs @@ -0,0 +1,117 @@ +// 開始使用: what a new install shows instead of a chat. There are no bots until the person makes +// one; the guide takes them through it — connect a model, make the first bot — or signs in to +// bring the bots they already have on another computer. + +use super::*; + +impl App { + /// The first AI service that is ready to answer (has its key). + pub(super) fn ready_provider(&self) -> Option { + ALL_PROVIDERS.iter().copied().find(|k| k.needs_key() && !providers::resolve_key(*k, &self.state.provider(*k).api_key).is_empty()) + } + + pub(super) fn welcome(&mut self, ui: &mut egui::Ui) { + let p = self.pal; + let ready = self.ready_provider(); + let signed_in = !self.state.remote.host_token.is_empty(); + egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { + let w = (ui.available_width() - 48.0).clamp(240.0, 520.0); + ui.add_space((ui.available_height() * 0.12).clamp(24.0, 90.0)); + ui.vertical_centered(|ui| { + ui.set_max_width(w); + ui.label(RichText::new(t("歡迎使用 AnyBot")).size(26.0).strong().color(p.text)); + ui.add_space(4.0); + ui.label(RichText::new(t("跟著下面的步驟,一分鐘就能開始聊天。")).size(FONT_BASE).color(p.text2)); + ui.add_space(22.0); + }); + ui.horizontal(|ui| { + ui.add_space(((ui.available_width() - w) / 2.0).max(0.0)); + ui.vertical(|ui| { + ui.set_width(w); + // A screen of another computer: its bots are on the way. + if self.mir.on && !self.mir.online { + let status = self.mir.status.lock().unwrap().clone(); + setting_card(ui, p, |ui| { + ui.horizontal(|ui| { + ui.spinner(); + ui.label(RichText::new(t("正在同步帳號裡的 Bot…")).strong()); + }); + if !status.is_empty() { + hint(ui, status, p); + } + }); + return; + } + let mut go_keys = false; + let mut go_new = false; + let mut go_account = false; + step(ui, p, 1, "連上 AI 模型", ready.is_some(), |ui| { + match ready { + Some(k) => hint(ui, format!("已經可以用 {}。", k.label()), p), + None => { + hint(ui, "選一個 AI 服務(xAI、OpenAI、Claude、Gemini…),貼上它的 API 金鑰。也可以用自己電腦上的 Ollama,不用金鑰。", p); + ui.add_space(6.0); + if ui.add(primary_button("設定模型", p)).clicked() { + go_keys = true; + } + } + } + }); + step(ui, p, 2, "建立你的第一個 Bot", false, |ui| { + hint(ui, "幫它取名字、寫下它要做什麼。之後隨時可以再加更多 Bot。", p); + ui.add_space(6.0); + let b = if ready.is_some() { primary_button("建立 Bot", p) } else { secondary_button("建立 Bot") }; + if ui.add(b).clicked() { + go_new = true; + } + }); + ui.add_space(6.0); + setting_card(ui, p, |ui| { + ui.label(RichText::new(t("在別台電腦用過 AnyBot?")).strong()); + if signed_in { + let note = self.rm.note.lock().unwrap().clone(); + hint(ui, if note.is_empty() { "已登入帳號。帳號裡沒有別的 Bot,從上面建立第一個吧。".to_string() } else { note }, p); + } else { + hint(ui, "登入你的帳號,那邊的 Bot、聊天和設定會自動同步到這台;這台的也會同步上去。", p); + ui.add_space(6.0); + if ui.add(secondary_button("登入帳號")).clicked() { + go_account = true; + } + } + }); + if go_keys { + self.show_settings = true; + self.settings_tab = SettingsTab::Providers; + self.settings_provider = ProviderKind::Xai; + } + if go_new { + self.new_bot(); + } + if go_account { + self.show_settings = true; + self.settings_tab = SettingsTab::Remote; + } + }); + }); + }); + } +} + +/// One numbered step of the guide (a check mark once it is done). +fn step(ui: &mut egui::Ui, p: Palette, n: u32, title: &str, done: bool, body: impl FnOnce(&mut egui::Ui)) { + setting_card(ui, p, |ui| { + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(24.0, 24.0), egui::Sense::hover()); + let fill = if done { Color32::from_rgb(0x14, 0xc3, 0x70) } else { p.primary }; + ui.painter().circle_filled(r.center(), 12.0, fill); + let mark = if done { "✓".to_string() } else { n.to_string() }; + ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, mark, egui::FontId::proportional(13.0), if done { Color32::WHITE } else { p.on_primary }); + ui.add_space(4.0); + ui.label(RichText::new(t(title)).size(FONT_BASE).strong().color(p.text)); + }); + ui.horizontal(|ui| { + ui.add_space(32.0); + ui.vertical(|ui| body(ui)); + }); + }); +} diff --git a/src/appctl.rs b/src/appctl.rs index be81849..00056fe 100644 --- a/src/appctl.rs +++ b/src/appctl.rs @@ -589,7 +589,7 @@ mod tests { #[test] fn chat_sets_up_and_manages_routines() { - let mut st = State::first_run(); + let mut st = State::samples(); let r = apply(&mut st, 1, "create_routine", r#"{"title":"早報","prompt":"整理新聞","schedule":{"frequency":"weekly","daysOfWeek":["MO","TU","WE","TH","FR"],"time":"09:00"}}"#); assert!(r.ok, "{}", r.text); assert!(r.text.contains("每個工作日 09:00"), "{}", r.text); @@ -611,7 +611,7 @@ mod tests { /// replies that looked stuck. Loading repairs them so they can be retried. #[test] fn interrupted_replies_are_repaired() { - let mut st = State::first_run(); + let mut st = State::samples(); let b = st.bot_mut(1).unwrap(); b.messages = vec![ Message::user("你好"), @@ -631,7 +631,7 @@ mod tests { #[test] fn chat_manages_bots_and_settings() { - let mut st = State::first_run(); + let mut st = State::samples(); assert!(apply(&mut st, 1, "update_bot", r#"{"provider":"openai","model":"gpt-5-mini"}"#).text.contains("OpenAI · gpt-5-mini")); assert!(apply(&mut st, 1, "create_bot", r#"{"name":"翻譯官","provider":"gemini"}"#).ok); assert!(st.bots.iter().any(|b| b.name == "翻譯官" && b.provider == ProviderKind::Gemini)); @@ -645,7 +645,7 @@ mod tests { #[test] fn groups_from_the_chat() { - let mut st = State::first_run(); + let mut st = State::samples(); let names: Vec = st.bots.iter().take(2).map(|b| b.name.clone()).collect(); let o = apply(&mut st, 1, "create_group", &serde_json::json!({"name": "文宣組", "bots": names}).to_string()); assert!(o.ok && o.text.contains("文宣組"), "{}", o.text); diff --git a/src/model.rs b/src/model.rs index a5030f1..fce0850 100644 --- a/src/model.rs +++ b/src/model.rs @@ -408,11 +408,12 @@ impl State { let mut s = std::fs::read_to_string(Self::path()) .ok() .and_then(|s| serde_json::from_str::(&s).ok()) - .filter(|s| !s.bots.is_empty()) .unwrap_or_else(State::first_run); + s.drop_untouched_samples(); for b in s.bots.iter_mut() { b.repair_interrupted(); } + s.fix_primary(); s } @@ -445,6 +446,76 @@ impl State { self.bots.iter_mut().find(|b| b.id == id) } + /// Older versions started with five sample bots. If they are all still there and were never + /// used (no chat, routine or memory, nothing renamed) and there is nothing else, it is a fresh + /// install: start empty, with the guide, like a new one. + pub fn drop_untouched_samples(&mut self) { + let samples = Self::sample_bots(); + let untouched = |b: &Bot| { + samples.iter().any(|x| x.id == b.id && x.name == b.name && x.description == b.description && x.instructions == b.instructions) + && b.messages.is_empty() + && b.routines.is_empty() + && b.memories.is_empty() + && b.threads.is_empty() + }; + if self.bots.len() == samples.len() && self.bots.iter().all(untouched) { + self.bots.clear(); + self.primary_bot = 0; + self.selected = 0; + } + } + + /// The first bot made becomes the main one; with none at all, nothing is selected. + pub fn fix_primary(&mut self) { + if self.bot(self.primary_bot).is_none() { + self.primary_bot = self.bots.first().map(|b| b.id).unwrap_or(0); + } + if self.bot(self.selected).is_none() { + self.selected = self.primary_bot; + } + } + + /// Bots that aren't here yet (another computer's, a backup's), as new bots with new ids. + /// A bot counts as already here when one has the same name and the same chat (or it was + /// never used). Returns how many were added. + pub fn take_in_bots(&mut self, other: &State) -> usize { + let last = |b: &Bot| b.messages.last().map(|m| (m.at, m.text.clone())); + let mut n = 0; + for b in &other.bots { + if self.bots.iter().any(|o| o.name == b.name && (b.messages.is_empty() || (o.messages.len() == b.messages.len() && last(o) == last(b)))) { + continue; + } + let mut b = b.clone(); + b.id = self.next_id.max(1); + self.next_id = b.id + 1; + for r in b.routines.iter_mut() { + r.id = self.next_routine_id.max(1); + self.next_routine_id = r.id + 1; + } + self.bots.push(b); + n += 1; + } + // Keys, skills and connectors this computer doesn't have yet. + for (k, v) in &other.providers { + let mine = self.providers.entry(k.clone()).or_default(); + if mine.api_key.is_empty() && !v.api_key.is_empty() { + *mine = v.clone(); + } + } + for sk in &other.skills { + if !self.skills.iter().any(|m| m.name == sk.name) { + self.skills.push(sk.clone()); + } + } + for m in &other.mcp_servers { + if !self.mcp_servers.iter().any(|o| o.name == m.name) { + self.mcp_servers.push(m.clone()); + } + } + self.fix_primary(); + n + } + fn sample_bots() -> Vec { let mk = |id, name: &str, desc: &str, shape: &str, color: &str, provider: ProviderKind, pinned| Bot { id, @@ -487,12 +558,14 @@ impl State { ] } + /// A new install: no bots yet — the main window walks the person through making the first + /// one (or signing in to bring their bots from another computer). pub fn first_run() -> State { State { - bots: Self::sample_bots(), - primary_bot: 1, - selected: 1, - next_id: 6, + bots: vec![], + primary_bot: 0, + selected: 0, + next_id: 1, theme: Theme::Light, accent: Accent::Black, providers: Default::default(), @@ -527,9 +600,14 @@ impl State { } } + /// The five sample bots (for pictures and tests, never a real first start). + pub fn samples() -> State { + State { bots: Self::sample_bots(), primary_bot: 1, selected: 1, next_id: 6, ..State::first_run() } + } + /// A filled-in state for the --screenshot picture (never saved). pub fn demo() -> State { - let mut s = State::first_run(); + let mut s = State::samples(); let via = format!("{} · {}", ProviderKind::Xai.label(), ProviderKind::Xai.suggested_model()); s.bots[0].messages = vec![ Message::user("幫我規劃這週末去台南兩天一夜的行程"), @@ -622,3 +700,49 @@ pub fn default_workdir() -> String { pub fn bot_computer_dir(bot_id: u64) -> PathBuf { dirs::home_dir().unwrap_or_else(std::env::temp_dir).join("AnyBot 工作區").join(format!("bot-{bot_id}")) } + +#[cfg(test)] +mod start_tests { + use super::*; + + #[test] + fn a_new_install_has_no_bots() { + let mut s = State::first_run(); + assert!(s.bots.is_empty()); + s.fix_primary(); + assert_eq!((s.primary_bot, s.selected), (0, 0)); + } + + #[test] + fn old_unused_sample_bots_go_away() { + let mut s = State::samples(); + s.drop_untouched_samples(); + assert!(s.bots.is_empty()); + // Used once: they stay. + let mut s = State::samples(); + s.bots[2].messages.push(Message::user("幫我改稿")); + s.drop_untouched_samples(); + assert_eq!(s.bots.len(), 5); + // Renamed: it stays. + let mut s = State::samples(); + s.bots[0].name = "小幫手".into(); + s.drop_untouched_samples(); + assert_eq!(s.bots.len(), 5); + } + + #[test] + fn signing_in_takes_in_only_the_bots_not_here_yet() { + let other = State::samples(); + let mut here = State::first_run(); + assert_eq!(here.take_in_bots(&other), 5); + assert_eq!(here.primary_bot, here.bots[0].id); + // The same bots again: nothing doubles. + assert_eq!(here.take_in_bots(&other), 0); + // A bot of the same name with a different chat is a different bot. + let mut used = State::samples(); + used.bots[1].messages.push(Message::user("嗨")); + assert_eq!(here.take_in_bots(&used), 1); + let ids: std::collections::HashSet = here.bots.iter().map(|b| b.id).collect(); + assert_eq!(ids.len(), here.bots.len()); + } +} diff --git a/src/remote.rs b/src/remote.rs index 6c5517e..4be9713 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -1170,6 +1170,29 @@ pub fn peers(s: &Settings) -> Result, String> { Ok(v["hosts"].as_array().cloned().unwrap_or_default()) } +/// Right after signing in: where this account's bots already are. Another AnyBot of the account +/// that is on now and thinks for itself (not hands, not showing another) is the brain to sync +/// with; with none, the account's newest backup (if there is one) is what to take in. +pub struct AccountFind { + pub brain: Option<(String, String)>, + pub backup: Option>, +} + +pub fn find_account_bots(s: &Settings, password: &str) -> AccountFind { + let brain = peers(s).ok().and_then(|hosts| pick_brain(&hosts, &s.host_id)); + let backup = if brain.is_none() { restore(&s.relay_url, &s.relay_user, password).ok() } else { None }; + AccountFind { brain, backup } +} + +/// The brain among the account's AnyBots (relay `hosts` rows), never this one. +pub fn pick_brain(hosts: &[Value], me: &str) -> Option<(String, String)> { + hosts + .iter() + .filter(|h| h["online"].as_bool() == Some(true) && h["hands"].as_bool() != Some(true) && h["uses"].as_str().unwrap_or("").is_empty()) + .filter_map(|h| Some((h["id"].as_str()?.to_string(), h["name"].as_str().unwrap_or("AnyBot").to_string()))) + .find(|(id, _)| !id.is_empty() && id != me) +} + /// Is this an AnyBot account server? (Gives the address to use.) pub fn check_server(url: &str) -> Result { let mut u = url.trim().trim_end_matches('/').to_string(); @@ -1258,6 +1281,19 @@ mod tests { assert_eq!(handle(Req { method: "POST".into(), path: "/api/device/run".into(), body: br#"{"tool":"shell"}"#.to_vec(), ..Default::default() }, true).status, 403); } + #[test] + fn the_brain_is_an_online_one_that_thinks_for_itself() { + let hosts = vec![ + json!({"id": "me", "name": "這台", "online": true, "hands": false, "uses": ""}), + json!({"id": "h", "name": "伺服器手腳", "online": true, "hands": true, "uses": ""}), + json!({"id": "s", "name": "筆電", "online": true, "hands": false, "uses": "b"}), + json!({"id": "old", "name": "舊的", "online": false, "hands": false, "uses": ""}), + json!({"id": "b", "name": "家裡的 Mac", "online": true, "hands": false, "uses": ""}), + ]; + assert_eq!(pick_brain(&hosts, "me"), Some(("b".into(), "家裡的 Mac".into()))); + assert_eq!(pick_brain(&hosts[..4], "me"), None); + } + #[test] fn team_roles() { assert!(role_allows("member", "POST", &["api", "bots", "3", "send"])); @@ -1293,3 +1329,28 @@ pub const INSTALL_URL: &str = "https://code.30cm.net/daniel.w/AnyBot/raw/branch/ pub fn pair_line(relay_url: &str, code: &str) -> String { format!("curl -fsSL {INSTALL_URL} | sh -s -- hands {} {code}", relay_url.trim_end_matches('/')) } + +/// 登入後同步 against a real account server: ANYBOT_RELAY_URL=http://127.0.0.1:PORT +#[cfg(test)] +mod account_sync_live { + use super::*; + + #[test] + #[ignore] + fn a_new_computer_gets_the_accounts_bots_after_signing_in() { + let url = std::env::var("ANYBOT_RELAY_URL").expect("ANYBOT_RELAY_URL"); + let (user, pw) = (format!("t{}", crate::httpd::random_hex(4)), "sync-pass-123"); + // The first computer signs up and backs up its bots. + let a = relay_join(&Settings { host_name: "第一台".into(), ..Default::default() }, &url, &user, pw, true, "").unwrap(); + let mut st = crate::model::State::samples(); + st.remote = Default::default(); + backup_now(&a, &serde_json::to_vec(&st).unwrap()).unwrap(); + // A new computer signs in: no other brain is on, so the backup is what it takes in. + let b = relay_join(&Settings { host_name: "新電腦".into(), ..Default::default() }, &url, &user, pw, false, "").unwrap(); + let found = find_account_bots(&b, pw); + assert!(found.brain.is_none()); + let got: crate::model::State = serde_json::from_slice(&found.backup.expect("backup")).unwrap(); + let mut here = crate::model::State::first_run(); + assert_eq!(here.take_in_bots(&got), 5); + } +}