Merge branch 'first-run-guide': no starter bots, a guide, sync after signing in
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
8f79c33b35
22
FEATURES.md
22
FEATURES.md
|
|
@ -1509,17 +1509,21 @@ computer_use_screenshot、click、mouse_move、mouse_button(按住/放開)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 31. 第一次啟動的 Bot
|
## 31. 第一次啟動
|
||||||
|
|
||||||
| Bot | 形狀、顏色 | 供應商 | 描述 | 備註 |
|
第一次打開時沒有任何 Bot,主畫面是「歡迎使用 AnyBot」引導:
|
||||||
|---|---|---|---|---|
|
|
||||||
| 「Bot」 | 圓角方、黑 | xAI Grok | 「你的主要聯絡窗口,什麼都可以問。」 | 主要 Bot |
|
|
||||||
| 「研究助理」 | 圓球、綠 | Claude | 「幫你查資料、整理重點。」 | 已釘選 |
|
|
||||||
| 「寫作夥伴」 | 雲、紫 | OpenAI | 「改稿、翻譯、想標題。」 | 已釘選 |
|
|
||||||
| 「電腦助手」 | 寶石、藍 | Claude | 「幫你整理檔案、跑指令。動手前會先問你。」 | 已釘選,電腦工具預設開 |
|
|
||||||
| 「本機模型」 | 六角、棕 | Ollama | 「在你自己的電腦上跑,不用連網。」 | |
|
|
||||||
|
|
||||||
每個 Bot 都用那家供應商的建議模型。
|
1. **連上 AI 模型**:還沒有任何金鑰時,按「設定模型」打開模型設定;有了就打勾,寫出可以用哪一家。
|
||||||
|
2. **建立你的第一個 Bot**:打開建立 Bot 視窗,預設用剛設好金鑰的那一家。第一個建立的 Bot 就是主要 Bot。
|
||||||
|
3. **在別台電腦用過 AnyBot?**:「登入帳號」打開帳號設定。
|
||||||
|
|
||||||
|
刪掉最後一個 Bot 也會回到這個引導。伺服器(anybot --serve)裝好也是零個 Bot。舊版開過、但五個範例 Bot 從沒用過(沒聊天、沒改名、沒有例行任務和記憶)的資料,更新後會清掉範例,跟新裝一樣。手機頁沒有 Bot 時,請你到電腦上建立。
|
||||||
|
|
||||||
|
**登入後自動同步**(設定 → 帳號,按「登入」之後,不用再按別的):
|
||||||
|
- 帳號裡有另一台開著、自己思考的 AnyBot(不是手腳、不是在看別台的):改用它當大腦,那邊的 Bot 和聊天出現在這台;連上後自動把這台原本的 Bot(連同聊天)、模型金鑰、技能和連接器搬過去,大腦已經有的會略過。
|
||||||
|
- 沒有:拿帳號裡最新的備份,把這台還沒有的 Bot 加進來(同名而且聊天相同的算已經有),再把合起來的整份備份回帳號。
|
||||||
|
|
||||||
|
截圖和測試用的五個範例 Bot(State::samples):Bot、研究助理、寫作夥伴、電腦助手、本機模型。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -434,7 +434,7 @@ function apply(v) {
|
||||||
function botName(id) { const b = S.bots.find((x) => x.id === id); return b ? b.name : ""; }
|
function botName(id) { const b = S.bots.find((x) => x.id === id); return b ? b.name : ""; }
|
||||||
function renderList() {
|
function renderList() {
|
||||||
const el = $("bots");
|
const el = $("bots");
|
||||||
if (!S.bots.length) { el.innerHTML = `<div class="empty">還沒有 Bot</div>`; return; }
|
if (!S.bots.length) { el.innerHTML = `<div class="empty">還沒有 Bot。打開電腦上的 AnyBot,照著畫面建立第一個 Bot,這裡就會出現。</div>`; return; }
|
||||||
el.innerHTML = S.bots.map((b) => {
|
el.innerHTML = S.bots.map((b) => {
|
||||||
const waiting = S.approvals.some((a) => a.bot === b.id);
|
const waiting = S.approvals.some((a) => a.bot === b.id);
|
||||||
const state = waiting ? `<span class="wait">等你決定</span>` : b.busy ? `<span class="busy">${esc(b.status || "工作中…")}</span>` : "";
|
const state = waiting ? `<span class="wait">等你決定</span>` : b.busy ? `<span class="busy">${esc(b.status || "工作中…")}</span>` : "";
|
||||||
|
|
|
||||||
27
src/app.rs
27
src/app.rs
|
|
@ -33,6 +33,7 @@ mod sidebar_ui;
|
||||||
mod menu_ui;
|
mod menu_ui;
|
||||||
mod vault_ui;
|
mod vault_ui;
|
||||||
mod markdown_ui;
|
mod markdown_ui;
|
||||||
|
mod welcome_ui;
|
||||||
mod mirror_ui;
|
mod mirror_ui;
|
||||||
mod pages_ui;
|
mod pages_ui;
|
||||||
|
|
||||||
|
|
@ -1411,7 +1412,9 @@ impl App {
|
||||||
let shape = avatar::SHAPES[(id as usize) % avatar::SHAPES.len()];
|
let shape = avatar::SHAPES[(id as usize) % avatar::SHAPES.len()];
|
||||||
let color = avatar::COLORS[(id as usize) % avatar::COLORS.len()].0;
|
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 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 {
|
let b = Bot {
|
||||||
id,
|
id,
|
||||||
name: "新 Bot".into(),
|
name: "新 Bot".into(),
|
||||||
|
|
@ -1689,6 +1692,11 @@ impl App {
|
||||||
}
|
}
|
||||||
egui::CentralPanel::default().frame(Frame::new().fill(p.pane)).show(root, |ui| {
|
egui::CentralPanel::default().frame(Frame::new().fill(p.pane)).show(root, |ui| {
|
||||||
let id = self.state.selected;
|
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 {
|
let Some(bot) = self.state.bot(id).cloned() else {
|
||||||
ui.centered_and_justified(|ui| ui.label(RichText::new(t("未選取 Bot")).color(p.text3)));
|
ui.centered_and_justified(|ui| ui.label(RichText::new(t("未選取 Bot")).color(p.text3)));
|
||||||
return;
|
return;
|
||||||
|
|
@ -3013,20 +3021,8 @@ impl App {
|
||||||
if ui.add(danger_button("刪除", p)).clicked() {
|
if ui.add(danger_button("刪除", p)).clicked() {
|
||||||
self.stop(id);
|
self.stop(id);
|
||||||
self.state.bots.retain(|b| b.id != id);
|
self.state.bots.retain(|b| b.id != id);
|
||||||
if self.state.bots.is_empty() {
|
// The last one gone: back to the getting-started guide.
|
||||||
let mut fresh = State::first_run();
|
self.state.fix_primary();
|
||||||
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;
|
|
||||||
}
|
|
||||||
self.dirty = true;
|
self.dirty = true;
|
||||||
close = true;
|
close = true;
|
||||||
}
|
}
|
||||||
|
|
@ -3347,6 +3343,7 @@ impl eframe::App for App {
|
||||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||||
let ctx = &ui.ctx().clone();
|
let ctx = &ui.ctx().clone();
|
||||||
self.handle_screenshot(ctx);
|
self.handle_screenshot(ctx);
|
||||||
|
self.state.fix_primary();
|
||||||
self.md_viewer(ctx);
|
self.md_viewer(ctx);
|
||||||
self.page_viewer(ctx);
|
self.page_viewer(ctx);
|
||||||
self.refresh_presence(ctx);
|
self.refresh_presence(ctx);
|
||||||
|
|
|
||||||
|
|
@ -376,6 +376,8 @@ pub struct Mirror {
|
||||||
pub(super) want: Option<(String, String)>,
|
pub(super) want: Option<(String, String)>,
|
||||||
pub(super) leave: bool,
|
pub(super) leave: bool,
|
||||||
pub(super) moving: Arc<AtomicBool>,
|
pub(super) moving: Arc<AtomicBool>,
|
||||||
|
/// 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<Mutex<String>>,
|
pub(super) move_note: Arc<Mutex<String>>,
|
||||||
/// Requests to the brain, sent in order by one worker.
|
/// Requests to the brain, sent in order by one worker.
|
||||||
outbox: Option<Sender<Out>>,
|
outbox: Option<Sender<Out>>,
|
||||||
|
|
@ -533,6 +535,7 @@ impl App {
|
||||||
}
|
}
|
||||||
self.state.next_id = self.state.next_id.max(b.id + 1);
|
self.state.next_id = self.state.next_id.max(b.id + 1);
|
||||||
self.state.bots.push(b);
|
self.state.bots.push(b);
|
||||||
|
self.state.fix_primary();
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
Err(_) => false,
|
Err(_) => false,
|
||||||
|
|
@ -540,11 +543,12 @@ impl App {
|
||||||
}
|
}
|
||||||
"bot_del" => {
|
"bot_del" => {
|
||||||
let id = op["id"].as_u64().unwrap_or(0);
|
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
|
false
|
||||||
} else {
|
} else {
|
||||||
self.stop(id);
|
self.stop(id);
|
||||||
self.state.bots.retain(|b| b.id != id);
|
self.state.bots.retain(|b| b.id != id);
|
||||||
|
self.state.fix_primary();
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -877,6 +881,9 @@ impl App {
|
||||||
self.mir.reason.clear();
|
self.mir.reason.clear();
|
||||||
*self.mir.status.lock().unwrap() = format!("已連到大腦「{}」", v["brain"].as_str().unwrap_or(&self.state.remote.brain_name));
|
*self.mir.status.lock().unwrap() = format!("已連到大腦「{}」", v["brain"].as_str().unwrap_or(&self.state.remote.brain_name));
|
||||||
self.mirror_apply(v);
|
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 } => {
|
Out::Move { ops, n } => {
|
||||||
*note.lock().unwrap() = match remote::sync_post(&s, "edit", &json!({"ops": ops})) {
|
*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(連同聊天)搬到大腦"),
|
Ok(_) => format!("已把 {n} 個 Bot(連同聊天)搬到大腦"),
|
||||||
Err(e) => format!("搬移失敗:{e}"),
|
Err(e) => format!("搬移失敗:{e}"),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,8 @@ pub struct RemoteUi {
|
||||||
pass: String,
|
pass: String,
|
||||||
invite: String,
|
invite: String,
|
||||||
create: bool,
|
create: bool,
|
||||||
note: Arc<Mutex<String>>,
|
pub(super) note: Arc<Mutex<String>>,
|
||||||
join_rx: Option<Receiver<Result<Settings, String>>>,
|
join_rx: Option<Receiver<Result<(Settings, remote::AccountFind), String>>>,
|
||||||
restore_rx: Option<Receiver<Result<Vec<u8>, String>>>,
|
restore_rx: Option<Receiver<Result<Vec<u8>, String>>>,
|
||||||
busy: Arc<AtomicBool>,
|
busy: Arc<AtomicBool>,
|
||||||
peers: Arc<Mutex<Vec<(String, String, bool)>>>,
|
peers: Arc<Mutex<Vec<(String, String, bool)>>>,
|
||||||
|
|
@ -98,10 +98,10 @@ impl App {
|
||||||
if let Ok(r) = rx.try_recv() {
|
if let Ok(r) = rx.try_recv() {
|
||||||
self.rm.join_rx = None;
|
self.rm.join_rx = None;
|
||||||
match r {
|
match r {
|
||||||
Ok(s) => {
|
Ok((s, found)) => {
|
||||||
self.state.remote = s;
|
self.state.remote = s;
|
||||||
self.rm.pass.clear();
|
self.rm.pass.clear();
|
||||||
*self.rm.note.lock().unwrap() = "已登入,這台 AnyBot 已加入帳號".into();
|
self.account_sync(found);
|
||||||
self.mark();
|
self.mark();
|
||||||
}
|
}
|
||||||
Err(e) => *self.rm.note.lock().unwrap() = e,
|
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::<State>(&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) {
|
fn remote_backup(&mut self) {
|
||||||
if self.rm.busy.swap(true, Ordering::SeqCst) {
|
if self.rm.busy.swap(true, Ordering::SeqCst) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -450,7 +472,12 @@ impl App {
|
||||||
self.rm.join_rx = Some(rx);
|
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());
|
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 || {
|
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() {
|
if ui.add_enabled(ready && self.rm.restore_rx.is_none(), secondary_button("從帳號備份還原")).on_hover_text(t("把這台電腦的 Bot 換成帳號裡最新的備份")).clicked() {
|
||||||
|
|
|
||||||
|
|
@ -872,6 +872,10 @@ impl App {
|
||||||
if self.demo && std::env::var("ANYBOT_SHOT_SPLASH").is_err() {
|
if self.demo && std::env::var("ANYBOT_SHOT_SPLASH").is_err() {
|
||||||
return false;
|
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 now = ctx.input(|i| i.time);
|
||||||
let start = *self.splash_at.get_or_insert(now);
|
let start = *self.splash_at.get_or_insert(now);
|
||||||
let t = now - start;
|
let t = now - start;
|
||||||
|
|
|
||||||
|
|
@ -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<ProviderKind> {
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -589,7 +589,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn chat_sets_up_and_manages_routines() {
|
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"}}"#);
|
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.ok, "{}", r.text);
|
||||||
assert!(r.text.contains("每個工作日 09:00"), "{}", 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.
|
/// replies that looked stuck. Loading repairs them so they can be retried.
|
||||||
#[test]
|
#[test]
|
||||||
fn interrupted_replies_are_repaired() {
|
fn interrupted_replies_are_repaired() {
|
||||||
let mut st = State::first_run();
|
let mut st = State::samples();
|
||||||
let b = st.bot_mut(1).unwrap();
|
let b = st.bot_mut(1).unwrap();
|
||||||
b.messages = vec![
|
b.messages = vec![
|
||||||
Message::user("你好"),
|
Message::user("你好"),
|
||||||
|
|
@ -631,7 +631,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn chat_manages_bots_and_settings() {
|
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, "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!(apply(&mut st, 1, "create_bot", r#"{"name":"翻譯官","provider":"gemini"}"#).ok);
|
||||||
assert!(st.bots.iter().any(|b| b.name == "翻譯官" && b.provider == ProviderKind::Gemini));
|
assert!(st.bots.iter().any(|b| b.name == "翻譯官" && b.provider == ProviderKind::Gemini));
|
||||||
|
|
@ -645,7 +645,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn groups_from_the_chat() {
|
fn groups_from_the_chat() {
|
||||||
let mut st = State::first_run();
|
let mut st = State::samples();
|
||||||
let names: Vec<String> = st.bots.iter().take(2).map(|b| b.name.clone()).collect();
|
let names: Vec<String> = 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());
|
let o = apply(&mut st, 1, "create_group", &serde_json::json!({"name": "文宣組", "bots": names}).to_string());
|
||||||
assert!(o.ok && o.text.contains("文宣組"), "{}", o.text);
|
assert!(o.ok && o.text.contains("文宣組"), "{}", o.text);
|
||||||
|
|
|
||||||
136
src/model.rs
136
src/model.rs
|
|
@ -408,11 +408,12 @@ impl State {
|
||||||
let mut s = std::fs::read_to_string(Self::path())
|
let mut s = std::fs::read_to_string(Self::path())
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|s| serde_json::from_str::<State>(&s).ok())
|
.and_then(|s| serde_json::from_str::<State>(&s).ok())
|
||||||
.filter(|s| !s.bots.is_empty())
|
|
||||||
.unwrap_or_else(State::first_run);
|
.unwrap_or_else(State::first_run);
|
||||||
|
s.drop_untouched_samples();
|
||||||
for b in s.bots.iter_mut() {
|
for b in s.bots.iter_mut() {
|
||||||
b.repair_interrupted();
|
b.repair_interrupted();
|
||||||
}
|
}
|
||||||
|
s.fix_primary();
|
||||||
s
|
s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -445,6 +446,76 @@ impl State {
|
||||||
self.bots.iter_mut().find(|b| b.id == id)
|
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<Bot> {
|
fn sample_bots() -> Vec<Bot> {
|
||||||
let mk = |id, name: &str, desc: &str, shape: &str, color: &str, provider: ProviderKind, pinned| Bot {
|
let mk = |id, name: &str, desc: &str, shape: &str, color: &str, provider: ProviderKind, pinned| Bot {
|
||||||
id,
|
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 {
|
pub fn first_run() -> State {
|
||||||
State {
|
State {
|
||||||
bots: Self::sample_bots(),
|
bots: vec![],
|
||||||
primary_bot: 1,
|
primary_bot: 0,
|
||||||
selected: 1,
|
selected: 0,
|
||||||
next_id: 6,
|
next_id: 1,
|
||||||
theme: Theme::Light,
|
theme: Theme::Light,
|
||||||
accent: Accent::Black,
|
accent: Accent::Black,
|
||||||
providers: Default::default(),
|
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).
|
/// A filled-in state for the --screenshot picture (never saved).
|
||||||
pub fn demo() -> State {
|
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());
|
let via = format!("{} · {}", ProviderKind::Xai.label(), ProviderKind::Xai.suggested_model());
|
||||||
s.bots[0].messages = vec![
|
s.bots[0].messages = vec![
|
||||||
Message::user("幫我規劃這週末去台南兩天一夜的行程"),
|
Message::user("幫我規劃這週末去台南兩天一夜的行程"),
|
||||||
|
|
@ -622,3 +700,49 @@ pub fn default_workdir() -> String {
|
||||||
pub fn bot_computer_dir(bot_id: u64) -> PathBuf {
|
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}"))
|
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<u64> = here.bots.iter().map(|b| b.id).collect();
|
||||||
|
assert_eq!(ids.len(), here.bots.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1170,6 +1170,29 @@ pub fn peers(s: &Settings) -> Result<Vec<Value>, String> {
|
||||||
Ok(v["hosts"].as_array().cloned().unwrap_or_default())
|
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<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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.)
|
/// Is this an AnyBot account server? (Gives the address to use.)
|
||||||
pub fn check_server(url: &str) -> Result<String, String> {
|
pub fn check_server(url: &str) -> Result<String, String> {
|
||||||
let mut u = url.trim().trim_end_matches('/').to_string();
|
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);
|
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]
|
#[test]
|
||||||
fn team_roles() {
|
fn team_roles() {
|
||||||
assert!(role_allows("member", "POST", &["api", "bots", "3", "send"]));
|
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 {
|
pub fn pair_line(relay_url: &str, code: &str) -> String {
|
||||||
format!("curl -fsSL {INSTALL_URL} | sh -s -- hands {} {code}", relay_url.trim_end_matches('/'))
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue