AnyBot/src/model.rs

874 lines
35 KiB
Rust
Raw Normal View History

// 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 turn cut off by a crash leaves a 「繼續」 card, and nothing it may have done runs twice Each bot keeps a work log (oplog/bot-<id>.jsonl, after pi's durable-harness design, cut down): before something happens a line says it is about to — the turn, each model call, each step once its checks passed (with the arguments it runs with and whether it may run again), each message typed mid-turn — synced to disk first. When the turn ends the file shrinks to the messages still waiting, usually nothing. On the next start after AnyBot was killed mid-turn, the log puts the chat right before anything runs: the message the turn answered comes back if the chat lost it; a step that was running and reaches outside (a text, a click, a file, a published site) shows as 「可能已經執行了」, both to the person and in the result the bot reads — no longer as "not allowed"; a step that only looked shows as cut off; messages typed mid-turn that never reached the bot go back into the chat. The chat is saved, then the log closes the turn. A turn the person didn't stop ends with a 「繼續」 card (what it was doing, what may have run, the messages put back); it goes on only when they press it — in the window, on a screen of the brain, or on the phone — from where its steps stand, never running a step that may have run again. Pressing stop is on disk at once, and a stopped turn stays stopped. Also in the turn loop: - 自動審核 that doesn't answer (or answers something that isn't a verdict) asks the person; in 直接操作 mode it used to let the step through unchecked. - "Too long for this model" folds older messages into a summary and asks the same step again, once per thing the person said, instead of ending the turn. - Folding keeps the person's chat whole: the summary goes in at the fold and the model reads from the newest summary on (it used to delete what it folded). - A step moved to the chosen computer runs there, but the chat keeps the call as the model wrote it; the result says where it ran. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uirha5F5gJt93t3yEx3XcS
2026-11-08 16:49:12 +00:00
/// 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
}
A turn cut off by a crash leaves a 「繼續」 card, and nothing it may have done runs twice Each bot keeps a work log (oplog/bot-<id>.jsonl, after pi's durable-harness design, cut down): before something happens a line says it is about to — the turn, each model call, each step once its checks passed (with the arguments it runs with and whether it may run again), each message typed mid-turn — synced to disk first. When the turn ends the file shrinks to the messages still waiting, usually nothing. On the next start after AnyBot was killed mid-turn, the log puts the chat right before anything runs: the message the turn answered comes back if the chat lost it; a step that was running and reaches outside (a text, a click, a file, a published site) shows as 「可能已經執行了」, both to the person and in the result the bot reads — no longer as "not allowed"; a step that only looked shows as cut off; messages typed mid-turn that never reached the bot go back into the chat. The chat is saved, then the log closes the turn. A turn the person didn't stop ends with a 「繼續」 card (what it was doing, what may have run, the messages put back); it goes on only when they press it — in the window, on a screen of the brain, or on the phone — from where its steps stand, never running a step that may have run again. Pressing stop is on disk at once, and a stopped turn stays stopped. Also in the turn loop: - 自動審核 that doesn't answer (or answers something that isn't a verdict) asks the person; in 直接操作 mode it used to let the step through unchecked. - "Too long for this model" folds older messages into a summary and asks the same step again, once per thing the person said, instead of ending the turn. - Folding keeps the person's chat whole: the summary goes in at the fold and the model reads from the newest summary on (it used to delete what it folded). - A step moved to the chosen computer runs there, but the chat keeps the call as the model wrote it; the result says where it ran. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uirha5F5gJt93t3yEx3XcS
2026-11-08 16:49:12 +00:00
/// 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,
Bots work like someone who knows the computer: they know each machine, reach what they see, plan, and hand the planned steps to a quick model Measured on the bot's own box (8 tasks x 2, grok-4.7): 14/16 done, 120 steps, 1114 s before; 16/16, 102 steps, 574 s after (model time 1015 -> 471 s). A replayed skill: 49 s -> 15 s. Knowing the machine - facts.rs: 電腦現況 per computer (system, desktop, systemd, sudo, network, package manager, tools, apps, a fixed TZ), read once by a small read-only probe, kept 30 min, in the instructions at the start of a turn. - The box's sudo is the bot's: only what follows sudo is checked against the always-denied list (it was refused outright, while the instructions said it could sudo), and commands with sudo run as the box's root (setuid does nothing under gVisor). - Box image v8 drops ENV TZ=UTC (it overrode /etc/localtime). ANYBOT_BOX_IMAGE / ANYBOT_BOX_VOLUME give a throwaway box its own image and volume, never adopting the real one. - Mac shell sandbox: App Store app CLIs (they carry their own app sandbox) and ps/top run outside it; plain `osascript -e` too, asked the first time even in direct mode, refusing do shell script, terminals, keystrokes, file reads, ObjC and the secret folders. A command the sandbox broke says so, instead of looking like a broken program. Reaching what it sees - The Mac menu bar's status icons are numbered (each app's AXExtrasMenuBar, named from inside for the system's own), computer_use_zoom looks closer at full sharpness. - Numbers stay with their controls from look to look (they were reassigned every look, so a step's second click landed elsewhere); the box's front window is listed first; a control can be named instead of numbered (name "4"). - Web page numbering walks open shadow DOM (Chromium settings, web components). - A box on a Docker host elsewhere retries the next port when the usual one is taken (a retry without it came up with no network). Working like a person - Instructions: pick the route, know what done looks like, one route, check once, say what's stuck instead of faking it with a substitute, ask before cutting the connection. - StepPlan: the bot's model takes the first step, the step after trouble (failed, refused, repeated, nothing changed on screen, the quick model switching routes), one in four, and the final check; a quick model (Bot.step_model, auto: grok-4.20-0309-non-reasoning on xAI) does the planned steps in between, falling back to the bot's model if it can't be used. - Skills keep the steps that worked (with what each acted on, not numbers); replay_skill does them again with no model in between, finding each control again, handing back on a mismatch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uirha5F5gJt93t3yEx3XcS
2026-11-09 01:01:03 +00:00
/// 執行步驟用的模型: "" 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
}
A turn cut off by a crash leaves a 「繼續」 card, and nothing it may have done runs twice Each bot keeps a work log (oplog/bot-<id>.jsonl, after pi's durable-harness design, cut down): before something happens a line says it is about to — the turn, each model call, each step once its checks passed (with the arguments it runs with and whether it may run again), each message typed mid-turn — synced to disk first. When the turn ends the file shrinks to the messages still waiting, usually nothing. On the next start after AnyBot was killed mid-turn, the log puts the chat right before anything runs: the message the turn answered comes back if the chat lost it; a step that was running and reaches outside (a text, a click, a file, a published site) shows as 「可能已經執行了」, both to the person and in the result the bot reads — no longer as "not allowed"; a step that only looked shows as cut off; messages typed mid-turn that never reached the bot go back into the chat. The chat is saved, then the log closes the turn. A turn the person didn't stop ends with a 「繼續」 card (what it was doing, what may have run, the messages put back); it goes on only when they press it — in the window, on a screen of the brain, or on the phone — from where its steps stand, never running a step that may have run again. Pressing stop is on disk at once, and a stopped turn stays stopped. Also in the turn loop: - 自動審核 that doesn't answer (or answers something that isn't a verdict) asks the person; in 直接操作 mode it used to let the step through unchecked. - "Too long for this model" folds older messages into a summary and asks the same step again, once per thing the person said, instead of ending the turn. - Folding keeps the person's chat whole: the summary goes in at the fold and the model reads from the newest summary on (it used to delete what it folded). - A step moved to the chosen computer runs there, but the chat keeps the call as the model wrote it; the result says where it ran. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uirha5F5gJt93t3yEx3XcS
2026-11-08 16:49:12 +00:00
/// 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() {
A turn cut off by a crash leaves a 「繼續」 card, and nothing it may have done runs twice Each bot keeps a work log (oplog/bot-<id>.jsonl, after pi's durable-harness design, cut down): before something happens a line says it is about to — the turn, each model call, each step once its checks passed (with the arguments it runs with and whether it may run again), each message typed mid-turn — synced to disk first. When the turn ends the file shrinks to the messages still waiting, usually nothing. On the next start after AnyBot was killed mid-turn, the log puts the chat right before anything runs: the message the turn answered comes back if the chat lost it; a step that was running and reaches outside (a text, a click, a file, a published site) shows as 「可能已經執行了」, both to the person and in the result the bot reads — no longer as "not allowed"; a step that only looked shows as cut off; messages typed mid-turn that never reached the bot go back into the chat. The chat is saved, then the log closes the turn. A turn the person didn't stop ends with a 「繼續」 card (what it was doing, what may have run, the messages put back); it goes on only when they press it — in the window, on a screen of the brain, or on the phone — from where its steps stand, never running a step that may have run again. Pressing stop is on disk at once, and a stopped turn stays stopped. Also in the turn loop: - 自動審核 that doesn't answer (or answers something that isn't a verdict) asks the person; in 直接操作 mode it used to let the step through unchecked. - "Too long for this model" folds older messages into a summary and asks the same step again, once per thing the person said, instead of ending the turn. - Folding keeps the person's chat whole: the summary goes in at the fold and the model reads from the newest summary on (it used to delete what it folded). - A step moved to the chosen computer runs there, but the chat keeps the call as the model wrote it; the result says where it ran. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uirha5F5gJt93t3yEx3XcS
2026-11-08 16:49:12 +00:00
return std::fs::rename(&tmp, &p).is_ok();
}
}
}
A turn cut off by a crash leaves a 「繼續」 card, and nothing it may have done runs twice Each bot keeps a work log (oplog/bot-<id>.jsonl, after pi's durable-harness design, cut down): before something happens a line says it is about to — the turn, each model call, each step once its checks passed (with the arguments it runs with and whether it may run again), each message typed mid-turn — synced to disk first. When the turn ends the file shrinks to the messages still waiting, usually nothing. On the next start after AnyBot was killed mid-turn, the log puts the chat right before anything runs: the message the turn answered comes back if the chat lost it; a step that was running and reaches outside (a text, a click, a file, a published site) shows as 「可能已經執行了」, both to the person and in the result the bot reads — no longer as "not allowed"; a step that only looked shows as cut off; messages typed mid-turn that never reached the bot go back into the chat. The chat is saved, then the log closes the turn. A turn the person didn't stop ends with a 「繼續」 card (what it was doing, what may have run, the messages put back); it goes on only when they press it — in the window, on a screen of the brain, or on the phone — from where its steps stand, never running a step that may have run again. Pressing stop is on disk at once, and a stopped turn stays stopped. Also in the turn loop: - 自動審核 that doesn't answer (or answers something that isn't a verdict) asks the person; in 直接操作 mode it used to let the step through unchecked. - "Too long for this model" folds older messages into a summary and asks the same step again, once per thing the person said, instead of ending the turn. - Folding keeps the person's chat whole: the summary goes in at the fold and the model reads from the newest summary on (it used to delete what it folded). - A step moved to the chosen computer runs there, but the chat keeps the call as the model wrote it; the result says where it ran. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uirha5F5gJt93t3yEx3XcS
2026-11-08 16:49:12 +00:00
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(),
Bots work like someone who knows the computer: they know each machine, reach what they see, plan, and hand the planned steps to a quick model Measured on the bot's own box (8 tasks x 2, grok-4.7): 14/16 done, 120 steps, 1114 s before; 16/16, 102 steps, 574 s after (model time 1015 -> 471 s). A replayed skill: 49 s -> 15 s. Knowing the machine - facts.rs: 電腦現況 per computer (system, desktop, systemd, sudo, network, package manager, tools, apps, a fixed TZ), read once by a small read-only probe, kept 30 min, in the instructions at the start of a turn. - The box's sudo is the bot's: only what follows sudo is checked against the always-denied list (it was refused outright, while the instructions said it could sudo), and commands with sudo run as the box's root (setuid does nothing under gVisor). - Box image v8 drops ENV TZ=UTC (it overrode /etc/localtime). ANYBOT_BOX_IMAGE / ANYBOT_BOX_VOLUME give a throwaway box its own image and volume, never adopting the real one. - Mac shell sandbox: App Store app CLIs (they carry their own app sandbox) and ps/top run outside it; plain `osascript -e` too, asked the first time even in direct mode, refusing do shell script, terminals, keystrokes, file reads, ObjC and the secret folders. A command the sandbox broke says so, instead of looking like a broken program. Reaching what it sees - The Mac menu bar's status icons are numbered (each app's AXExtrasMenuBar, named from inside for the system's own), computer_use_zoom looks closer at full sharpness. - Numbers stay with their controls from look to look (they were reassigned every look, so a step's second click landed elsewhere); the box's front window is listed first; a control can be named instead of numbered (name "4"). - Web page numbering walks open shadow DOM (Chromium settings, web components). - A box on a Docker host elsewhere retries the next port when the usual one is taken (a retry without it came up with no network). Working like a person - Instructions: pick the route, know what done looks like, one route, check once, say what's stuck instead of faking it with a substitute, ask before cutting the connection. - StepPlan: the bot's model takes the first step, the step after trouble (failed, refused, repeated, nothing changed on screen, the quick model switching routes), one in four, and the final check; a quick model (Bot.step_model, auto: grok-4.20-0309-non-reasoning on xAI) does the planned steps in between, falling back to the bot's model if it can't be used. - Skills keep the steps that worked (with what each acted on, not numbers); replay_skill does them again with no model in between, finding each control again, handing back on a mismatch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uirha5F5gJt93t3yEx3XcS
2026-11-09 01:01:03 +00:00
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::*;
A turn cut off by a crash leaves a 「繼續」 card, and nothing it may have done runs twice Each bot keeps a work log (oplog/bot-<id>.jsonl, after pi's durable-harness design, cut down): before something happens a line says it is about to — the turn, each model call, each step once its checks passed (with the arguments it runs with and whether it may run again), each message typed mid-turn — synced to disk first. When the turn ends the file shrinks to the messages still waiting, usually nothing. On the next start after AnyBot was killed mid-turn, the log puts the chat right before anything runs: the message the turn answered comes back if the chat lost it; a step that was running and reaches outside (a text, a click, a file, a published site) shows as 「可能已經執行了」, both to the person and in the result the bot reads — no longer as "not allowed"; a step that only looked shows as cut off; messages typed mid-turn that never reached the bot go back into the chat. The chat is saved, then the log closes the turn. A turn the person didn't stop ends with a 「繼續」 card (what it was doing, what may have run, the messages put back); it goes on only when they press it — in the window, on a screen of the brain, or on the phone — from where its steps stand, never running a step that may have run again. Pressing stop is on disk at once, and a stopped turn stays stopped. Also in the turn loop: - 自動審核 that doesn't answer (or answers something that isn't a verdict) asks the person; in 直接操作 mode it used to let the step through unchecked. - "Too long for this model" folds older messages into a summary and asks the same step again, once per thing the person said, instead of ending the turn. - Folding keeps the person's chat whole: the summary goes in at the fold and the model reads from the newest summary on (it used to delete what it folded). - A step moved to the chosen computer runs there, but the chat keeps the call as the model wrote it; the result says where it ran. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uirha5F5gJt93t3yEx3XcS
2026-11-08 16:49:12 +00:00
/// 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());
}
}