809 lines
31 KiB
Rust
809 lines
31 KiB
Rust
// 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
|
||
}
|
||
|
||
/// 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,
|
||
#[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 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
|
||
}
|
||
|
||
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");
|
||
// 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() {
|
||
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)
|
||
}
|
||
|
||
/// 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(),
|
||
pinned,
|
||
messages: vec![],
|
||
unread: false,
|
||
tools_enabled: false,
|
||
workdir: String::new(),
|
||
allow_rules: vec![],
|
||
demos: vec![],
|
||
speak_replies: false,
|
||
voice: String::new(),
|
||
voice_rate: 1.0,
|
||
voice_lang: String::new(),
|
||
folder: String::new(),
|
||
memories: vec![],
|
||
members: vec![],
|
||
threads: vec![],
|
||
context_tokens: default_context_tokens(),
|
||
routines: vec![],
|
||
last_turn: None,
|
||
default_computer: default_computer(),
|
||
};
|
||
let mut helper = mk(5, "電腦助手", "幫你整理檔案、跑指令。動手前會先問你。", "gem", "blue", ProviderKind::Anthropic, true);
|
||
helper.tools_enabled = true;
|
||
helper.workdir = default_workdir();
|
||
vec![
|
||
mk(1, "Bot", "你的主要聯絡窗口,什麼都可以問。", "squircle", "black", ProviderKind::Xai, false),
|
||
mk(2, "研究助理", "幫你查資料、整理重點。", "blob", "green", ProviderKind::Anthropic, true),
|
||
mk(3, "寫作夥伴", "改稿、翻譯、想標題。", "cloud", "purple", ProviderKind::OpenAi, true),
|
||
helper,
|
||
mk(4, "本機模型", "在你自己的電腦上跑,不用連網。", "hex", "brown", ProviderKind::Ollama, false),
|
||
]
|
||
}
|
||
|
||
/// A new install: no bots yet — the main window walks the person through making the first
|
||
/// one (or signing in to bring their bots from another computer).
|
||
pub fn first_run() -> State {
|
||
State {
|
||
bots: vec![],
|
||
primary_bot: 0,
|
||
selected: 0,
|
||
next_id: 1,
|
||
theme: Theme::Light,
|
||
accent: Accent::Black,
|
||
providers: Default::default(),
|
||
sidebar_collapsed: false,
|
||
sidebar_width: default_sidebar_width(),
|
||
local_execution: false,
|
||
remote_computers: vec![],
|
||
auto_review: false,
|
||
review_allow_rules: vec![],
|
||
review_ask_rules: vec![],
|
||
timezone: crate::routines::local_timezone(),
|
||
next_routine_id: 1,
|
||
notify: true,
|
||
direct_mode: false,
|
||
auto_update_when_away: true,
|
||
channels: vec![],
|
||
next_channel_id: 1,
|
||
mcp_servers: vec![],
|
||
skills: vec![],
|
||
pages: vec![],
|
||
next_page_id: 1,
|
||
remote: Default::default(),
|
||
language: auto_lang(),
|
||
folders: vec![],
|
||
pin_order: vec![],
|
||
machine_label: String::new(),
|
||
local_never: false,
|
||
messages_send: true,
|
||
messages_no_ask: false,
|
||
loose_index: None,
|
||
folded: vec![],
|
||
}
|
||
}
|
||
|
||
/// The five sample bots (for pictures and tests, never a real first start).
|
||
pub fn samples() -> State {
|
||
State { bots: Self::sample_bots(), primary_bot: 1, selected: 1, next_id: 6, ..State::first_run() }
|
||
}
|
||
|
||
/// A filled-in state for the --screenshot picture (never saved).
|
||
pub fn demo() -> State {
|
||
let mut s = State::samples();
|
||
let via = format!("{} · {}", ProviderKind::Xai.label(), ProviderKind::Xai.suggested_model());
|
||
s.bots[0].messages = vec![
|
||
Message::user("幫我規劃這週末去台南兩天一夜的行程"),
|
||
Message {
|
||
role: Role::Assistant,
|
||
text: "好的!這是一個輕鬆的兩天一夜台南行程:\n\n# 第一天\n- 上午:赤崁樓、祀典武廟\n- 中午:國華街小吃(牛肉湯、碗粿)\n- 下午:神農街散步、林百貨\n- 晚上:花園夜市(週四、六、日)\n\n# 第二天\n- 上午:安平古堡、安平樹屋\n- 下午:四草綠色隧道搭船\n\n需要我幫你排交通和住宿嗎?".into(),
|
||
thinking: "使用者想要週末兩天一夜,台南景點集中在中西區與安平。".into(),
|
||
via: via.clone(),
|
||
..Default::default()
|
||
},
|
||
Message::user("換成用 Claude 回答看看,有什麼不同建議?"),
|
||
Message::assistant("如果想避開人潮,可以這樣調整:\n\n- 第一天早上先去安平,避開中午的觀光團\n- 晚餐改到保安路,選擇比夜市多\n- 第二天加一站奇美博物館,下雨也不怕", "Claude · claude-opus-5-5"),
|
||
];
|
||
s.bots[0].provider = ProviderKind::Anthropic;
|
||
s.bots[0].model = ProviderKind::Anthropic.suggested_model().into();
|
||
s.bots[1].unread = true;
|
||
// Talking to the bot sets up a routine.
|
||
let schedule = crate::routines::Schedule {
|
||
frequency: crate::routines::Frequency::Weekly,
|
||
interval: 1,
|
||
days_of_week: crate::routines::WORKWEEK.iter().map(|d| d.to_string()).collect(),
|
||
month_day: None,
|
||
weekday_of_month: None,
|
||
month: None,
|
||
time: "09:00".into(),
|
||
timezone: s.timezone.clone(),
|
||
once: false,
|
||
flexible: false,
|
||
};
|
||
let created = crate::routines::now();
|
||
s.bots[1].routines = vec![crate::routines::Routine {
|
||
id: 1,
|
||
title: "AI 新聞早報".into(),
|
||
prompt: "整理過去一天最重要的三則 AI 新聞,每則兩句話。".into(),
|
||
next_run: crate::routines::next_run(&schedule, created, created),
|
||
schedule,
|
||
paused: false,
|
||
created_at: created,
|
||
last_run: None,
|
||
computer: String::new(),
|
||
trigger: None,
|
||
}];
|
||
s.next_routine_id = 2;
|
||
s.bots[1].messages = vec![
|
||
Message::user("每個工作日早上 9 點幫我整理 AI 新聞"),
|
||
Message {
|
||
role: Role::Assistant,
|
||
via: "Claude · claude-opus-5-5".into(),
|
||
tool_calls: vec![ToolCall { id: "r1".into(), name: "create_routine".into(), args: r#"{"title":"AI 新聞早報"}"#.into(), status: ToolStatus::Done }],
|
||
..Default::default()
|
||
},
|
||
Message::tool_result("r1", "create_routine", "已建立例行任務 #1「AI 新聞早報」:每個工作日 09:00"),
|
||
Message::assistant("好,已經設定好了:每個工作日 09:00 我會整理三則重點給你。", "Claude · claude-opus-5-5"),
|
||
];
|
||
s.bots[2].messages = vec![Message::user("幫我想五個標題"), Message::assistant("1. 慢慢走,才看得見風景…", "OpenAI · gpt-5")];
|
||
// The computer helper mid-task: two tools done, one waiting for approval.
|
||
let via = "Claude · claude-opus-5-5";
|
||
let call = |id: &str, name: &str, args: &str, status| ToolCall { id: id.into(), name: name.into(), args: args.into(), status };
|
||
s.bots[3].messages = vec![
|
||
Message::user("幫我把下載資料夾裡的照片依照月份整理好"),
|
||
Message {
|
||
role: Role::Assistant,
|
||
text: "我先看看有哪些照片。".into(),
|
||
via: via.into(),
|
||
tool_calls: vec![call("c1", "list_dir", r#"{"path":"~/Downloads"}"#, ToolStatus::Done)],
|
||
..Default::default()
|
||
},
|
||
Message::tool_result("c1", "list_dir", "IMG_0412.jpg 2.1 MB\nIMG_0533.jpg 3.4 MB\nIMG_0601.heic 1.8 MB\n… 共 48 個檔案"),
|
||
Message {
|
||
role: Role::Assistant,
|
||
text: "找到 48 張照片,拍攝時間在 4 到 6 月。我會建立三個資料夾,再把照片搬進去。".into(),
|
||
via: via.into(),
|
||
tool_calls: vec![call("c2", "shell", r#"{"command":"mkdir -p 2026-04 2026-05 2026-06 && mv IMG_04*.jpg 2026-04/"}"#, ToolStatus::Waiting)],
|
||
..Default::default()
|
||
},
|
||
];
|
||
s.bots[3].default_computer = THIS_COMPUTER_NAME.into();
|
||
s.local_execution = true;
|
||
s.selected = 5;
|
||
s
|
||
}
|
||
}
|
||
|
||
/// Where a bot starts on this Mac (你的電腦) unless told otherwise.
|
||
pub fn default_workdir() -> String {
|
||
dirs::home_dir().map(|h| h.display().to_string()).unwrap_or_default()
|
||
}
|
||
|
||
/// The sealed folder that is the bot's own computer.
|
||
pub fn bot_computer_dir(bot_id: u64) -> PathBuf {
|
||
dirs::home_dir().unwrap_or_else(std::env::temp_dir).join("AnyBot 工作區").join(format!("bot-{bot_id}"))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod start_tests {
|
||
use super::*;
|
||
|
||
#[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());
|
||
}
|
||
}
|