AnyBot/src/model.rs

880 lines
35 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<ToolCall>,
/// 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<String>,
/// 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<serde_json::Value>,
/// 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<String>,
/// 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<crate::attach::Attachment>,
/// 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<Message>, 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<Message>,
#[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<String>,
/// 群組聊天: the bots that answer here, in order (empty = an ordinary bot).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub members: Vec<u64>,
/// 討論串: earlier conversations put aside with 「開始新對話」 (title, messages).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub threads: Vec<(String, Vec<Message>)>,
/// 記憶: facts this bot keeps across chats.
#[serde(default)]
pub memories: Vec<String>,
/// 語音聊天: 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<crate::teach::Demo>,
/// 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<crate::routines::Routine>,
#[serde(default)]
pub last_turn: Option<TurnSettlement>,
/// 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<String>,
}
#[derive(Clone, Serialize, Deserialize, Debug)]
pub struct State {
pub bots: Vec<Bot>,
pub primary_bot: u64,
pub selected: u64,
pub next_id: u64,
pub theme: Theme,
pub accent: Accent,
#[serde(default)]
pub providers: std::collections::BTreeMap<String, ProviderSettings>,
#[serde(default)]
pub sidebar_collapsed: bool,
/// 語意搜尋: "" = 找 Ollama 裡裝好的 embedding 模型, "off" = 關。
#[serde(default)]
pub embed_model: String,
/// 文件索引的資料夾; 空 = 用這台電腦的「文件」資料夾。
#[serde(default)]
pub doc_folders: Vec<String>,
#[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<RemoteComputer>,
/// 自動審核 with the person's natural-language rules ("先詢問" wins when they conflict).
#[serde(default)]
pub auto_review: bool,
#[serde(default)]
pub review_allow_rules: Vec<String>,
#[serde(default)]
pub review_ask_rules: Vec<String>,
/// 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 the reference assistant'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<crate::channels::Channel>,
#[serde(default = "first_routine_id")]
pub next_channel_id: u64,
/// MCP connectors the bots can use.
#[serde(default)]
pub mcp_servers: Vec<crate::mcp::Server>,
/// 技能 every bot can load.
#[serde(default)]
pub skills: Vec<crate::memory::Skill>,
/// 頁面 the bots made and keep up to date.
#[serde(default)]
pub pages: Vec<crate::pages::Page>,
#[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<String>,
/// 你的電腦'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<u64>,
/// Where 未分區 sits among the 分區 (None = last).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub loose_index: Option<usize>,
/// Groups folded shut in the sidebar ("" = 未分區).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub folded: Vec<String>,
/// 語言: "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<String> {
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<u64> {
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::<State>(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() {
// state.json carries model keys: owner-only, like the vault's key file.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600));
}
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<Bot> {
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,
embed_model: String::new(),
doc_folders: vec![],
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<Message> = (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<u64> = here.bots.iter().map(|b| b.id).collect();
assert_eq!(ids.len(), here.bots.len());
}
}