// Data kept between runs: bots, their chats, provider settings and look. use serde::{Deserialize, Serialize}; use std::path::PathBuf; use crate::providers::ProviderKind; #[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Debug, Default)] pub enum Role { #[default] User, Assistant, /// The result of one tool call, sent back to the model. Tool, } #[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Debug, Default)] pub enum ToolStatus { Waiting, Running, #[default] Done, Denied, Failed, } #[derive(Clone, Serialize, Deserialize, Debug)] pub struct ToolCall { pub id: String, pub name: String, /// The arguments as the model wrote them (JSON text). pub args: String, #[serde(default)] pub status: ToolStatus, } #[derive(Clone, Serialize, Deserialize, Debug, Default)] pub struct Message { pub role: Role, pub text: String, #[serde(default)] pub thinking: String, /// Which provider and model wrote this reply (shown under it). #[serde(default)] pub via: String, #[serde(default)] pub error: bool, /// Tools the model asked to run in this reply. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub tool_calls: Vec, /// For Role::Tool: which call this answers. #[serde(default, skip_serializing_if = "String::is_empty")] pub tool_call_id: String, #[serde(default, skip_serializing_if = "String::is_empty")] pub tool_name: String, /// Screenshots (base64 JPEG) shown to the model with this message. Not saved to disk. #[serde(skip)] pub images: Vec, /// A summary standing in for older messages (context compaction). #[serde(default)] pub summary: bool, /// Claude's own content blocks (thinking signatures, tool_use), replayed unchanged. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub blocks: Vec, /// A routine (or another bot) woke the bot up: starts a turn like a person's message. #[serde(default)] pub wake: bool, /// Sent while the bot was working: steers the running turn instead of starting a new one. #[serde(default)] pub steer: bool, /// In a group chat: which bot wrote this answer (0 = the chat's own bot). #[serde(default, skip_serializing_if = "is_zero")] pub author: u64, /// 表情回應 the person put on this message. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub reactions: Vec, /// Files and pictures the person attached, or files the bot sent (kept on disk). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub attachments: Vec, /// When it was written (ms since 1970; 0 for messages from before this was kept). #[serde(default, skip_serializing_if = "is_zero_i64")] pub at: i64, /// A card where AnyBot stopping cut a turn off (put there at the next start, see /// oplog::recover), with 「繼續」 to go on from there. Never sent to the model. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub interrupted: bool, } fn is_zero_i64(n: &i64) -> bool { *n == 0 } /// Where the model's view of a chat starts: the newest summary (folding keeps the person's chat /// whole; the model reads from the fold on). pub fn model_view_start(msgs: &[Message]) -> usize { msgs.iter().rposition(|m| m.summary).unwrap_or(0) } /// A summary the turn made (it folded the first `n` messages of its view; `boundary` is the time /// of the first one it kept) goes into the chat at the fold. Nothing is removed: the person's /// chat stays whole and the model reads from the newest summary on. The model's view can be /// shorter than the chat (group chats filter), so the fold lands at the real first-kept message, /// not the raw index. pub fn fold_in(msgs: &mut Vec, n: usize, summary: Message, boundary: i64) { let from = model_view_start(msgs); let cut = msgs.iter().position(|m| m.at == boundary && m.role == Role::User).filter(|&i| i > from).unwrap_or((from + n).min(msgs.len())); // Pictures in what was folded never go to the model again (and aren't saved anyway). for m in &mut msgs[..cut] { m.images.clear(); } msgs.insert(cut, summary); } pub fn now_ms() -> i64 { chrono::Utc::now().timestamp_millis() } /// How a turn ended: the original's outcomes `["success","cancelled","error"]` /// (node-agent-coordinator `wl`, read back by `k6` as {turnId, outcome, settledAtMs}). #[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Debug)] #[serde(rename_all = "lowercase")] pub enum TurnOutcome { Success, Cancelled, Error, } #[derive(Clone, Serialize, Deserialize, Debug)] pub struct TurnSettlement { pub turn_id: String, pub outcome: TurnOutcome, pub settled_at_ms: i64, } /// Another of the person's computers, reached over SSH. The other two computers are built in: /// the bot's own (a sealed folder, like the original's cloud "box") and this Mac (only when /// 在這台電腦上執行 is on). #[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] pub struct RemoteComputer { pub name: String, /// e.g. `me@studio.local` pub ssh_target: String, #[serde(default)] pub dir: String, } fn is_zero(n: &u64) -> bool { *n == 0 } /// A bot's own computer as kept in `Bot::default_computer` (it survives renaming the bot). It is /// shown and offered under the bot's name: see [`own_computer_name`]. pub const BOT_COMPUTER_NAME: &str = "Bot 的電腦"; /// What older versions kept for "the person's computer". Read as the computer the person is using /// now; every computer is shown under its own name. pub const THIS_COMPUTER_NAME: &str = "你的電腦"; /// What a bot calls its own computer: 「Master 的電腦」 for the bot Master. One plain name per /// computer — 「Bot 的電腦」 and 「你的電腦」 meant different machines to the person and the bot. pub fn own_computer_name(bot_name: &str) -> String { let n = bot_name.trim(); if n.is_empty() { BOT_COMPUTER_NAME.to_string() } else { format!("{n} 的電腦") } } impl Message { pub fn user(text: &str) -> Self { Message { role: Role::User, text: text.to_string(), at: now_ms(), ..Default::default() } } pub fn assistant(text: &str, via: &str) -> Self { Message { role: Role::Assistant, text: text.to_string(), via: via.to_string(), at: now_ms(), ..Default::default() } } pub fn tool_result(id: &str, name: &str, output: &str) -> Self { Message { role: Role::Tool, text: output.to_string(), tool_call_id: id.to_string(), tool_name: name.to_string(), at: now_ms(), ..Default::default() } } } #[derive(Clone, Serialize, Deserialize, Debug)] pub struct Bot { pub id: u64, pub name: String, #[serde(default)] pub description: String, /// What the bot should keep in mind (sent as the system prompt). #[serde(default)] pub instructions: String, pub shape: String, pub color: String, pub provider: ProviderKind, pub model: String, /// 執行步驟用的模型: "" picks the provider's quick model (providers::step_model), "off" uses /// `model` for every step, anything else is that model. The bot's own model plans, checks /// and gets the hard steps; the quick one carries out the planned steps in between. #[serde(default, skip_serializing_if = "String::is_empty")] pub step_model: String, #[serde(default)] pub pinned: bool, /// 資料夾 in the sidebar (empty = 未分區), like the original's groups. #[serde(default, skip_serializing_if = "String::is_empty")] pub folder: String, #[serde(default)] pub messages: Vec, #[serde(default)] pub unread: bool, /// Let this bot search, read and edit files and run commands in `workdir`. #[serde(default)] pub tools_enabled: bool, #[serde(default)] pub workdir: String, /// "Always allow" grants, e.g. `edit_file` or `shell:git commit`. #[serde(default)] pub allow_rules: Vec, /// 群組聊天: the bots that answer here, in order (empty = an ordinary bot). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub members: Vec, /// 討論串: earlier conversations put aside with 「開始新對話」 (title, messages). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub threads: Vec<(String, Vec)>, /// 記憶: facts this bot keeps across chats. #[serde(default)] pub memories: Vec, /// 語音聊天: read this bot's answers aloud. #[serde(default)] pub speak_replies: bool, /// 語音聊天 › 語音 (a macOS voice; empty = chosen by language), 速度 (1.0 = normal) and /// 語言 (spoken language for speech-to-text; empty = detect). #[serde(default, skip_serializing_if = "String::is_empty")] pub voice: String, #[serde(default = "one_f32")] pub voice_rate: f32, #[serde(default, skip_serializing_if = "String::is_empty")] pub voice_lang: String, /// 示範教學: tasks the person showed this bot on the Mac. #[serde(default)] pub demos: Vec, /// How much the model can read at once, for deciding when to summarize. #[serde(default = "default_context_tokens")] pub context_tokens: u64, #[serde(default)] pub routines: Vec, #[serde(default)] pub last_turn: Option, /// Name of the computer tools use unless told otherwise. #[serde(default = "default_computer")] pub default_computer: String, } fn one_f32() -> f32 { 1.0 } pub fn default_computer() -> String { BOT_COMPUTER_NAME.into() } pub const INTERRUPTED_NOTE: &str = "(這次的回應被中斷了,可能是 App 關閉或重新啟動。按「重新產生」再試一次。)"; impl Bot { /// A turn that was still running when the app closed can't continue: mark its empty /// replies and unfinished tool steps as interrupted (an `error` settlement) so the chat /// can be retried instead of looking stuck. pub fn repair_interrupted(&mut self) -> bool { let mut changed = false; for m in self.messages.iter_mut().filter(|m| m.role == Role::Assistant && !m.summary) { for c in m.tool_calls.iter_mut() { if matches!(c.status, ToolStatus::Waiting | ToolStatus::Running) { c.status = ToolStatus::Denied; changed = true; } } if m.text.trim().is_empty() && m.tool_calls.is_empty() { m.text = INTERRUPTED_NOTE.into(); m.error = true; changed = true; } } if changed && self.last_turn.as_ref().map(|t| t.outcome != TurnOutcome::Error).unwrap_or(true) { self.last_turn = Some(TurnSettlement { turn_id: "interrupted".into(), outcome: TurnOutcome::Error, settled_at_ms: chrono::Utc::now().timestamp_millis() }); } changed } } pub fn default_context_tokens() -> u64 { crate::agent::DEFAULT_CONTEXT_TOKENS } #[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Debug)] pub enum Theme { Light, Dark, System, } #[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Debug)] pub enum Accent { BotColor, Black, } #[derive(Clone, Serialize, Deserialize, Debug, Default)] pub struct ProviderSettings { #[serde(default)] pub api_key: String, /// Empty means the provider's usual address. #[serde(default)] pub base_url: String, /// Empty means the provider's suggested model. #[serde(default)] pub default_model: String, /// Models fetched from the provider's model list. #[serde(default)] pub known_models: Vec, } #[derive(Clone, Serialize, Deserialize, Debug)] pub struct State { pub bots: Vec, pub primary_bot: u64, pub selected: u64, pub next_id: u64, pub theme: Theme, pub accent: Accent, #[serde(default)] pub providers: std::collections::BTreeMap, #[serde(default)] pub sidebar_collapsed: bool, #[serde(default = "default_sidebar_width")] pub sidebar_width: f32, /// 允許在這台電腦上執行任務,而不只是在它自己的電腦上 (setting `local-execution`, ids fuUvu2/Hd1KB+). #[serde(default)] pub local_execution: bool, #[serde(default)] pub remote_computers: Vec, /// 自動審核 with the person's natural-language rules ("先詢問" wins when they conflict). #[serde(default)] pub auto_review: bool, #[serde(default)] pub review_allow_rules: Vec, #[serde(default)] pub review_ask_rules: Vec, /// Time zone for schedules and reminders (setting `timezone`, ids 40Gx0U/TO/JdP). #[serde(default = "crate::routines::local_timezone")] pub timezone: String, #[serde(default = "first_routine_id")] pub next_routine_id: u64, /// 「Bot 完成任務或等你處理時通知你」(ttdOeI): banners when away. #[serde(default = "yes")] pub notify: bool, /// 直接操作: one switch like Grok Bot's local-execution + auto-review; only safety checks and /// "always ask" rules stop the bot. #[serde(default)] pub direct_mode: bool, /// 「在你離開時自動更新用戶端」(4vprnG); on by default like the original. #[serde(default = "yes")] pub auto_update_when_away: bool, /// 訊息管道: Slack / Discord / Telegram channels a bot answers. #[serde(default)] pub channels: Vec, #[serde(default = "first_routine_id")] pub next_channel_id: u64, /// MCP connectors the bots can use. #[serde(default)] pub mcp_servers: Vec, /// 技能 every bot can load. #[serde(default)] pub skills: Vec, /// 頁面 the bots made and keep up to date. #[serde(default)] pub pages: Vec, #[serde(default = "first_routine_id")] pub next_page_id: u64, /// 手機與帳號: phone access, the relay account and backups. #[serde(default)] pub remote: crate::remote::Settings, /// 分區 (sidebar sections) in their order, including empty ones. #[serde(default)] pub folders: Vec, /// 你的電腦's label in 設定 → 電腦 (empty = the computer's own name). #[serde(default, skip_serializing_if = "String::is_empty")] pub machine_label: String, /// 在這台電腦上執行 = 永不允許 (bots never ask; local tools are refused). #[serde(default)] pub local_never: bool, /// 訊息: bots may send iMessages / SMS from this Mac. #[serde(default = "yes")] pub messages_send: bool, /// 訊息: send without the approval card each time. #[serde(default)] pub messages_no_ask: bool, /// 已釘選的 Bot, in the order of the grid at the top of the sidebar. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub pin_order: Vec, /// Where 未分區 sits among the 分區 (None = last). #[serde(default, skip_serializing_if = "Option::is_none")] pub loose_index: Option, /// Groups folded shut in the sidebar ("" = 未分區). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub folded: Vec, /// 語言: "auto" (follow the Mac) or one of i18n::LANGS. #[serde(default = "auto_lang")] pub language: String, } fn auto_lang() -> String { "auto".into() } /// "Always ask" rules that direct mode starts with (own wording). pub const DEFAULT_ASK_RULES: [&str; 3] = ["刪除檔案或資料夾", "付款、購買或轉帳", "用我的帳號發文、留言,或傳訊息給別人"]; fn yes() -> bool { true } fn first_routine_id() -> u64 { 1 } fn default_sidebar_width() -> f32 { crate::app::SIDEBAR_DEFAULT_WIDTH } impl State { /// Every computer name a bot can be told to use here: its own (by its stored name), this /// computer, then the SSH ones. pub fn computer_names(&self) -> Vec { let mut v = vec![BOT_COMPUTER_NAME.to_string(), self.this_computer_name()]; v.extend(self.remote_computers.iter().map(|c| c.name.clone())); v } /// This computer's own name: the one given in 設定 › 電腦, else its name in the account, else /// the name the system gives it. pub fn this_computer_name(&self) -> String { for n in [&self.machine_label, &self.remote.host_name] { if !n.trim().is_empty() { return n.trim().to_string(); } } crate::remote::default_host_name() } pub fn routine_owner(&self, routine_id: u64) -> Option { self.bots.iter().find(|b| b.routines.iter().any(|r| r.id == routine_id)).map(|b| b.id) } } impl State { pub fn path() -> PathBuf { // A server (or a second copy for testing) can keep its data elsewhere. if let Ok(d) = std::env::var("ANYBOT_DATA_DIR") { if !d.trim().is_empty() { return PathBuf::from(d).join("state.json"); } } let base = dirs::data_dir().unwrap_or_else(std::env::temp_dir); base.join("AnyBot").join("state.json") } pub fn load() -> State { let p = Self::path(); let raw = std::fs::read_to_string(&p); let parsed = raw.as_ref().ok().and_then(|s| serde_json::from_str::(s).ok()); let mut s = match parsed { Some(s) => s, None => { // A damaged save must not vanish quietly: keep the bytes (so the person can // recover them), then start fresh. Silently wiping left no trace before. if let Ok(raw) = raw.as_ref() { let damaged = p.with_extension(format!("json.damaged-{}", chrono::Utc::now().timestamp())); let _ = std::fs::write(&damaged, raw); } State::first_run() } }; s.drop_untouched_samples(); for b in s.bots.iter_mut() { b.repair_interrupted(); } s.fix_primary(); s } /// Write everything to disk; true once it is there. pub fn save(&self) -> bool { let p = Self::path(); if let Some(dir) = p.parent() { let _ = std::fs::create_dir_all(dir); } if let Ok(s) = serde_json::to_string_pretty(self) { let tmp = p.with_extension("json.tmp"); // Flush the bytes before renaming, so a power cut cannot leave a renamed-but-empty // file where load() would find it (and treat it as a first run). if let Ok(mut f) = std::fs::File::create(&tmp) { use std::io::Write; if f.write_all(s.as_bytes()).and_then(|_| f.sync_all()).is_ok() { return std::fs::rename(&tmp, &p).is_ok(); } } } false } pub fn provider(&self, kind: ProviderKind) -> ProviderSettings { self.providers.get(kind.key()).cloned().unwrap_or_default() } pub fn provider_mut(&mut self, kind: ProviderKind) -> &mut ProviderSettings { self.providers.entry(kind.key().to_string()).or_default() } pub fn bot(&self, id: u64) -> Option<&Bot> { self.bots.iter().find(|b| b.id == id) } pub fn bot_mut(&mut self, id: u64) -> Option<&mut Bot> { 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; let mut reid: Vec<(u64, u64)> = vec![]; 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(); let old_id = b.id; b.id = self.next_id.max(1); reid.push((old_id, b.id)); 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; } // Group members are bot ids from the other machine: follow them to the copies that // just landed, or a group answers with bots that have nothing to do with it. if !reid.is_empty() { for g in self.bots.iter_mut() { if g.members.is_empty() { continue; } for m in g.members.iter_mut() { if let Some((_, new)) = reid.iter().find(|(o, _)| *o == *m) { *m = *new; } } } } // 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, name: name.into(), description: desc.into(), instructions: String::new(), shape: shape.into(), color: color.into(), provider, model: provider.suggested_model().into(), step_model: String::new(), pinned, messages: vec![], unread: false, tools_enabled: false, workdir: String::new(), allow_rules: vec![], demos: vec![], speak_replies: false, voice: String::new(), voice_rate: 1.0, voice_lang: String::new(), folder: String::new(), memories: vec![], members: vec![], threads: vec![], context_tokens: default_context_tokens(), routines: vec![], last_turn: None, default_computer: default_computer(), }; let mut helper = mk(5, "電腦助手", "幫你整理檔案、跑指令。動手前會先問你。", "gem", "blue", ProviderKind::Anthropic, true); helper.tools_enabled = true; helper.workdir = default_workdir(); vec![ mk(1, "Bot", "你的主要聯絡窗口,什麼都可以問。", "squircle", "black", ProviderKind::Xai, false), mk(2, "研究助理", "幫你查資料、整理重點。", "blob", "green", ProviderKind::Anthropic, true), mk(3, "寫作夥伴", "改稿、翻譯、想標題。", "cloud", "purple", ProviderKind::OpenAi, true), helper, mk(4, "本機模型", "在你自己的電腦上跑,不用連網。", "hex", "brown", ProviderKind::Ollama, false), ] } /// 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: vec![], primary_bot: 0, selected: 0, next_id: 1, theme: Theme::Light, accent: Accent::Black, providers: Default::default(), sidebar_collapsed: false, sidebar_width: default_sidebar_width(), local_execution: false, remote_computers: vec![], auto_review: false, review_allow_rules: vec![], review_ask_rules: vec![], timezone: crate::routines::local_timezone(), next_routine_id: 1, notify: true, direct_mode: false, auto_update_when_away: true, channels: vec![], next_channel_id: 1, mcp_servers: vec![], skills: vec![], pages: vec![], next_page_id: 1, remote: Default::default(), language: auto_lang(), folders: vec![], pin_order: vec![], machine_label: String::new(), local_never: false, messages_send: true, messages_no_ask: false, loose_index: None, folded: vec![], } } /// 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::samples(); let via = format!("{} · {}", ProviderKind::Xai.label(), ProviderKind::Xai.suggested_model()); s.bots[0].messages = vec![ Message::user("幫我規劃這週末去台南兩天一夜的行程"), Message { role: Role::Assistant, text: "好的!這是一個輕鬆的兩天一夜台南行程:\n\n# 第一天\n- 上午:赤崁樓、祀典武廟\n- 中午:國華街小吃(牛肉湯、碗粿)\n- 下午:神農街散步、林百貨\n- 晚上:花園夜市(週四、六、日)\n\n# 第二天\n- 上午:安平古堡、安平樹屋\n- 下午:四草綠色隧道搭船\n\n需要我幫你排交通和住宿嗎?".into(), thinking: "使用者想要週末兩天一夜,台南景點集中在中西區與安平。".into(), via: via.clone(), ..Default::default() }, Message::user("換成用 Claude 回答看看,有什麼不同建議?"), Message::assistant("如果想避開人潮,可以這樣調整:\n\n- 第一天早上先去安平,避開中午的觀光團\n- 晚餐改到保安路,選擇比夜市多\n- 第二天加一站奇美博物館,下雨也不怕", "Claude · claude-opus-5-5"), ]; s.bots[0].provider = ProviderKind::Anthropic; s.bots[0].model = ProviderKind::Anthropic.suggested_model().into(); s.bots[1].unread = true; // Talking to the bot sets up a routine. let schedule = crate::routines::Schedule { frequency: crate::routines::Frequency::Weekly, interval: 1, days_of_week: crate::routines::WORKWEEK.iter().map(|d| d.to_string()).collect(), month_day: None, weekday_of_month: None, month: None, time: "09:00".into(), timezone: s.timezone.clone(), once: false, flexible: false, }; let created = crate::routines::now(); s.bots[1].routines = vec![crate::routines::Routine { id: 1, title: "AI 新聞早報".into(), prompt: "整理過去一天最重要的三則 AI 新聞,每則兩句話。".into(), next_run: crate::routines::next_run(&schedule, created, created), schedule, paused: false, created_at: created, last_run: None, computer: String::new(), trigger: None, }]; s.next_routine_id = 2; s.bots[1].messages = vec![ Message::user("每個工作日早上 9 點幫我整理 AI 新聞"), Message { role: Role::Assistant, via: "Claude · claude-opus-5-5".into(), tool_calls: vec![ToolCall { id: "r1".into(), name: "create_routine".into(), args: r#"{"title":"AI 新聞早報"}"#.into(), status: ToolStatus::Done }], ..Default::default() }, Message::tool_result("r1", "create_routine", "已建立例行任務 #1「AI 新聞早報」:每個工作日 09:00"), Message::assistant("好,已經設定好了:每個工作日 09:00 我會整理三則重點給你。", "Claude · claude-opus-5-5"), ]; s.bots[2].messages = vec![Message::user("幫我想五個標題"), Message::assistant("1. 慢慢走,才看得見風景…", "OpenAI · gpt-5")]; // The computer helper mid-task: two tools done, one waiting for approval. let via = "Claude · claude-opus-5-5"; let call = |id: &str, name: &str, args: &str, status| ToolCall { id: id.into(), name: name.into(), args: args.into(), status }; s.bots[3].messages = vec![ Message::user("幫我把下載資料夾裡的照片依照月份整理好"), Message { role: Role::Assistant, text: "我先看看有哪些照片。".into(), via: via.into(), tool_calls: vec![call("c1", "list_dir", r#"{"path":"~/Downloads"}"#, ToolStatus::Done)], ..Default::default() }, Message::tool_result("c1", "list_dir", "IMG_0412.jpg 2.1 MB\nIMG_0533.jpg 3.4 MB\nIMG_0601.heic 1.8 MB\n… 共 48 個檔案"), Message { role: Role::Assistant, text: "找到 48 張照片,拍攝時間在 4 到 6 月。我會建立三個資料夾,再把照片搬進去。".into(), via: via.into(), tool_calls: vec![call("c2", "shell", r#"{"command":"mkdir -p 2026-04 2026-05 2026-06 && mv IMG_04*.jpg 2026-04/"}"#, ToolStatus::Waiting)], ..Default::default() }, ]; s.bots[3].default_computer = THIS_COMPUTER_NAME.into(); s.local_execution = true; s.selected = 5; s } } /// Where a bot starts on this Mac (你的電腦) unless told otherwise. pub fn default_workdir() -> String { dirs::home_dir().map(|h| h.display().to_string()).unwrap_or_default() } /// The sealed folder that is the bot's own computer. 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::*; /// Folding keeps the person's chat whole: each summary goes in at its fold, and the model's /// view starts at the newest one. #[test] fn folding_keeps_every_message() { let mut chat: Vec = (0..10).map(|i| Message { at: i + 1, ..if i % 2 == 0 { Message::user(&format!("問 {i}")) } else { Message::assistant(&format!("答 {i}"), "x") } }).collect(); let sum = |t: &str, at| Message { summary: true, at, ..Message::assistant(t, "x") }; // The turn folded the first 4 messages; the first it kept is 「問 4」 (at 5). fold_in(&mut chat, 4, sum("摘要一", 100), 5); assert_eq!(chat.len(), 11); assert!(chat[4].summary && chat[5].text == "問 4"); assert_eq!(model_view_start(&chat), 4); // Again later: the model's view started at 「摘要一」; it folded 3 of it (the summary, // 問 4, 答 5) and kept from 「問 6」 (at 7). fold_in(&mut chat, 3, sum("摘要二", 200), 7); assert_eq!(chat.len(), 12); assert_eq!(chat.iter().filter(|m| m.summary).count(), 2); assert_eq!(model_view_start(&chat), 7); assert_eq!(chat[8].text, "問 6"); assert_eq!(chat.iter().filter(|m| !m.summary).count(), 10, "nothing the person saw was removed"); // A boundary the chat can't find: the fold lands by count within the model's view. fold_in(&mut chat, 2, sum("摘要三", 300), 9999); assert!(chat[9].summary && chat[9].text == "摘要三"); } #[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()); } }