AnyBot/src/model.rs

625 lines
24 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,
}
fn is_zero_i64(n: &i64) -> bool {
*n == 0
}
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
}
pub const BOT_COMPUTER_NAME: &str = "Bot 的電腦";
pub const THIS_COMPUTER_NAME: &str = "你的電腦";
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,
#[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,
#[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 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<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 a bot can be told to use: its own, this Mac, then the SSH ones.
pub fn computer_names(&self) -> Vec<String> {
let mut v = vec![BOT_COMPUTER_NAME.to_string(), THIS_COMPUTER_NAME.to_string()];
v.extend(self.remote_computers.iter().map(|c| c.name.clone()));
v
}
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 mut s = std::fs::read_to_string(Self::path())
.ok()
.and_then(|s| serde_json::from_str::<State>(&s).ok())
.filter(|s| !s.bots.is_empty())
.unwrap_or_else(State::first_run);
for b in s.bots.iter_mut() {
b.repair_interrupted();
}
s
}
pub fn save(&self) {
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");
if std::fs::write(&tmp, s).is_ok() {
let _ = std::fs::rename(&tmp, &p);
}
}
}
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)
}
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(),
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),
]
}
pub fn first_run() -> State {
State {
bots: Self::sample_bots(),
primary_bot: 1,
selected: 1,
next_id: 6,
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![],
}
}
/// A filled-in state for the --screenshot picture (never saved).
pub fn demo() -> State {
let mut s = State::first_run();
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}"))
}