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);
+ }
+}