// Tools that let a bot run the app itself: set up routines, manage bots, talk to other bots, // change settings and list computers. In the original, routines are made by asking in chat // ("例行任務是這個 Bot 依排程重複執行的任務。請在聊天中請它設定一個。", ZJmAzX) and the primary bot // "也能和其他 Bot 協調工作" (mQQ/Uq). These run on the window's thread because they change the app. use serde_json::{json, Value}; use crate::model::{self, Bot, Message, State, Theme, Accent}; use crate::providers::{ProviderKind, ALL_PROVIDERS}; use crate::routines::{self, Routine, Schedule}; pub const NAMES: [&str; 20] = [ "list_routines", "create_routine", "update_routine", "delete_routine", "run_routine_now", "list_bots", "create_bot", "update_bot", "message_bot", "list_computers", "update_settings", "remember", "forget", "save_skill", "use_skill", "delete_skill", "create_group", "move_to_group", "rename_group", "delete_group", ]; pub fn is_app_tool(name: &str) -> bool { NAMES.contains(&name) || crate::pages::NAMES.contains(&name) } pub fn label(name: &str) -> &'static str { match name { "list_routines" => "查看例行任務", "create_routine" => "建立例行任務", "update_routine" => "修改例行任務", "delete_routine" => "刪除例行任務", "run_routine_now" => "立即執行例行任務", "list_bots" => "查看 Bot", "create_bot" => "建立 Bot", "update_bot" => "修改 Bot", "message_bot" => "傳訊息給其他 Bot", "list_computers" => "查看電腦", "update_settings" => "修改設定", "remember" => "記住", "forget" => "忘記", "save_skill" => "存成技能", "use_skill" => "讀取技能", "delete_skill" => "刪除技能", "create_group" => "建立群組", "move_to_group" => "移到群組", "rename_group" => "重新命名群組", "delete_group" => "刪除群組", n if crate::pages::NAMES.contains(&n) => crate::pages::label(n), _ => "App 操作", } } fn schedule_schema() -> Value { json!({ "type": "object", "description": "When it repeats, in the person's words turned into fields.", "properties": { "frequency": {"type": "string", "enum": ["minutely", "hourly", "daily", "weekly", "monthly", "yearly"]}, "interval": {"type": "integer", "description": "Every N minutes (at least 5)/hours/days/weeks/months/years (default 1)."}, "once": {"type": "boolean", "description": "僅一次: run once at the next matching time, then stop."}, "flexible": {"type": "boolean", "description": "「左右」: the exact minute doesn't matter (runs within ±15 minutes)."}, "daysOfWeek": {"type": "array", "items": {"type": "string", "enum": routines::WEEKDAYS}, "description": "For weekly. Workdays = MO..FR."}, "monthDay": {"type": "integer", "description": "Day of the month (monthly/yearly)."}, "weekdayOfMonth": {"type": "object", "properties": {"week": {"type": "integer", "description": "1-4, or -1 for the last"}, "day": {"type": "string", "enum": routines::WEEKDAYS}}}, "month": {"type": "integer", "description": "1-12 (yearly)."}, "time": {"type": "string", "description": "24-hour HH:MM (hourly: only the minutes count; minutely: ignored, use 00:00)."}, "timezone": {"type": "string", "description": "IANA name; leave empty for the person's time zone."} }, "required": ["frequency", "time"] }) } pub fn specs() -> Vec { vec![ json!({"name": "list_routines", "description": "List this bot's routines (scheduled, repeating tasks).", "parameters": {"type": "object", "properties": {}}}), json!({"name": "remember", "description": "Keep a fact or preference the person wants you to remember across chats.", "parameters": {"type": "object", "properties": {"fact": {"type": "string"}}, "required": ["fact"]}}), json!({"name": "forget", "description": "Forget a remembered fact (its number or words in it).", "parameters": {"type": "object", "properties": {"what": {"type": "string"}}, "required": ["what"]}}), json!({"name": "save_skill", "description": "Save a reusable procedure as a skill every bot can use (replaces one with the same name).", "parameters": {"type": "object", "properties": {"name": {"type": "string"}, "description": {"type": "string", "description": "One line: when to use it"}, "instructions": {"type": "string", "description": "The full steps"}}, "required": ["name", "description", "instructions"]}}), json!({"name": "use_skill", "description": "Load a skill's full instructions before doing that kind of task.", "parameters": {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}}), json!({"name": "delete_skill", "description": "Delete a skill.", "parameters": {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}}), json!({"name": "create_routine", "description": "Set up a routine when the person wants something done on a schedule (every morning, every Monday, on the 1st…) or when something happens (a PR is merged, CI fails, a Slack message mentions a word, a Sentry/PagerDuty/Linear change, a new email, a webhook call): give schedule or trigger. The prompt is what you will be asked each time it runs.", "parameters": {"type": "object", "properties": { "title": {"type": "string"}, "prompt": {"type": "string"}, "schedule": schedule_schema(), "trigger": crate::triggers::schema(), "computer": {"type": "string", "description": "Computer to work on when it runs (optional)."}}, "required": ["title", "prompt"]}}), json!({"name": "update_routine", "description": "Change, pause or resume a routine.", "parameters": {"type": "object", "properties": { "id": {"type": "integer"}, "title": {"type": "string"}, "prompt": {"type": "string"}, "schedule": schedule_schema(), "trigger": crate::triggers::schema(), "paused": {"type": "boolean"}}, "required": ["id"]}}), json!({"name": "delete_routine", "description": "Delete a routine. The person is asked first.", "parameters": {"type": "object", "properties": {"id": {"type": "integer"}}, "required": ["id"]}}), json!({"name": "run_routine_now", "description": "Run a routine once right away.", "parameters": {"type": "object", "properties": {"id": {"type": "integer"}}, "required": ["id"]}}), json!({"name": "list_bots", "description": "List the person's bots with their provider, model and what they do.", "parameters": {"type": "object", "properties": {}}}), json!({"name": "create_bot", "description": "Create a new bot, optionally from a template (a ready role and instructions).", "parameters": {"type": "object", "properties": { "template": {"type": "string", "enum": crate::memory::TEMPLATES.iter().map(|t| t.name).collect::>()}, "name": {"type": "string"}, "description": {"type": "string"}, "instructions": {"type": "string"}, "provider": {"type": "string", "enum": ALL_PROVIDERS.iter().map(|p| p.key()).collect::>()}, "model": {"type": "string"}}, "required": []}}), json!({"name": "update_bot", "description": "Change a bot (yourself when bot is empty): name, description, instructions, provider, model, pin, default computer.", "parameters": {"type": "object", "properties": { "bot": {"type": "string", "description": "Bot name; empty = yourself."}, "name": {"type": "string"}, "description": {"type": "string"}, "instructions": {"type": "string"}, "provider": {"type": "string", "enum": ALL_PROVIDERS.iter().map(|p| p.key()).collect::>()}, "model": {"type": "string"}, "pinned": {"type": "boolean"}, "default_computer": {"type": "string"}}}}), json!({"name": "create_group", "description": "Make a group (a folder in the sidebar that holds bots, e.g. 文宣組). Optionally put bots in it right away.", "parameters": {"type": "object", "properties": {"name": {"type": "string"}, "bots": {"type": "array", "items": {"type": "string"}, "description": "Bot names to move into it"}}, "required": ["name"]}}), json!({"name": "move_to_group", "description": "Move bots into a sidebar group (made if it doesn't exist). An empty group name moves them back to 未分區.", "parameters": {"type": "object", "properties": {"group": {"type": "string"}, "bots": {"type": "array", "items": {"type": "string"}}}, "required": ["group", "bots"]}}), json!({"name": "rename_group", "description": "Rename a sidebar group.", "parameters": {"type": "object", "properties": {"group": {"type": "string"}, "new_name": {"type": "string"}}, "required": ["group", "new_name"]}}), json!({"name": "delete_group", "description": "Delete a sidebar group; its bots move to 未分區 (nothing is deleted).", "parameters": {"type": "object", "properties": {"group": {"type": "string"}}, "required": ["group"]}}), json!({"name": "message_bot", "description": "Hand work to another bot: sends it a message and wakes it up. Its answer appears in its own chat.", "parameters": {"type": "object", "properties": {"bot": {"type": "string"}, "text": {"type": "string"}}, "required": ["bot", "text"]}}), json!({"name": "list_computers", "description": "List the computers you can work on and whether each is available.", "parameters": {"type": "object", "properties": {}}}), json!({"name": "update_settings", "description": "Change app settings: theme (light/dark/system), accent (bot_color/black), timezone (IANA). Running tasks on the person's own computer can only be turned on by the person.", "parameters": {"type": "object", "properties": { "theme": {"type": "string", "enum": ["light", "dark", "system"]}, "accent": {"type": "string", "enum": ["bot_color", "black"]}, "timezone": {"type": "string"}}}}), ] .into_iter() .chain(crate::pages::specs()) .collect() } /// One line for the chat row. pub fn describe(name: &str, args: &str) -> String { let v: Value = serde_json::from_str(args).unwrap_or(Value::Null); let s = |k: &str| v.get(k).and_then(Value::as_str).unwrap_or("").to_string(); match name { "create_routine" | "update_routine" => { let t = s("title"); let when = v.get("schedule").and_then(|x| serde_json::from_value::(x.clone()).ok()).map(|x| routines::describe(&x)).unwrap_or_default(); format!("{t} {when}").trim().to_string() } "delete_routine" | "run_routine_now" => format!("#{}", v.get("id").and_then(Value::as_u64).unwrap_or(0)), "message_bot" => format!("給 {}:{}", s("bot"), s("text").chars().take(40).collect::()), "create_bot" | "update_bot" => s("name").to_string() + &s("bot"), // Which skill (the phone showed a bare 「刪除技能」), and which memory. "save_skill" | "use_skill" | "delete_skill" => s("name"), "remember" => s("fact").chars().take(40).collect(), "forget" => s("what"), n if crate::pages::NAMES.contains(&n) => crate::pages::describe(n, &v), _ => String::new(), } } /// App actions that need a yes from the person first (the original confirms deletions: /// "這會刪除例行任務並停止其後續執行。此操作無法復原。", VAgxC4). pub fn needs_approval(name: &str, args: &str, state: &State) -> Option { let v: Value = serde_json::from_str(args).unwrap_or(Value::Null); if name == "delete_routine" { let id = v.get("id").and_then(Value::as_u64).unwrap_or(0); let title = state.bots.iter().flat_map(|b| b.routines.iter()).find(|r| r.id == id).map(|r| r.title.clone()).unwrap_or_else(|| format!("#{id}")); return Some(format!("刪除例行任務:「{title}」。這會停止它之後的執行,無法復原。")); } None } pub struct Outcome { pub text: String, pub ok: bool, /// Bots to wake with a new turn (message_bot, run_routine_now). pub wake: Vec<(u64, Message)>, pub theme_changed: bool, /// write_page: the page made or changed (its card goes under the reply). pub page: Option, } fn ok(text: String) -> Outcome { Outcome { text, ok: true, wake: vec![], theme_changed: false, page: None } } fn err(text: String) -> Outcome { Outcome { text, ok: false, wake: vec![], theme_changed: false, page: None } } fn find_bot<'a>(state: &'a State, name: &str) -> Option<&'a Bot> { let n = name.trim().to_lowercase(); state.bots.iter().find(|b| b.name.to_lowercase() == n).or_else(|| state.bots.iter().find(|b| b.name.to_lowercase().contains(&n))) } fn provider_of(key: &str) -> Option { ALL_PROVIDERS.iter().copied().find(|p| p.key() == key) } /// When a routine runs next, in words (for the phone's list). pub fn routine_when(r: &Routine) -> String { if let Some(t) = &r.trigger { return format!("事件觸發:{}", t.describe()); } match (r.paused, r.next_run) { (true, _) => "已暫停".to_string(), (false, Some(t)) => format!("下次 {}", routines::describe_when(t, &r.schedule.timezone)), _ => "沒有下次".to_string(), } } pub fn routine_line(r: &Routine) -> String { let next = match (r.paused, r.next_run) { (true, _) => "已暫停".to_string(), (false, Some(t)) => format!("下次 {}", routines::describe_when(t, &r.schedule.timezone)), _ => "沒有下次".to_string(), }; if let Some(t) = &r.trigger { return format!("#{} 「{}」:事件觸發({}){}", r.id, r.title, t.describe(), if r.paused { " · 已暫停" } else { "" }); } format!("#{} 「{}」:{} · {}", r.id, r.title, routines::describe(&r.schedule), next) } /// A routine firing, as the message that wakes the bot. pub fn wake_message(r: &Routine) -> Message { Message { wake: true, ..Message::user(&format!("[例行任務]「{}」時間到了:{}", r.title, r.prompt)) } } pub fn apply(state: &mut State, bot_id: u64, name: &str, args: &str) -> Outcome { let v: Value = match serde_json::from_str(if args.trim().is_empty() { "{}" } else { args }) { Ok(v) => v, Err(_) => return err("參數不是有效的 JSON".into()), }; let s = |k: &str| v.get(k).and_then(Value::as_str).map(str::to_string); let now = routines::now(); match name { "write_page" => match crate::pages::write(&mut state.pages, &mut state.next_page_id, bot_id, &v, crate::model::now_ms()) { Ok(w) => Outcome { text: w.text, ok: true, wake: vec![], theme_changed: false, page: Some(w.id) }, Err(e) => err(e), }, "read_page" => match crate::pages::read(&state.pages, bot_id, &v) { Ok(t) => ok(t), Err(e) => err(e), }, "list_pages" => ok(crate::pages::list(&state.pages, bot_id)), "list_routines" => { let Some(b) = state.bot(bot_id) else { return err("找不到這個 Bot".into()) }; if b.routines.is_empty() { return ok("還沒有例行任務。".into()); } ok(b.routines.iter().map(routine_line).collect::>().join("\n")) } "create_routine" => { let trigger = match v.get("trigger").filter(|t| !t.is_null()) { Some(t) => match serde_json::from_value::(t.clone()).map_err(|e| e.to_string()).and_then(|t| t.validate().map(|_| t)) { Ok(t) => Some(t), Err(e) => return err(format!("觸發條件有問題:{e}")), }, None => None, }; let default_sched = json!({"frequency": "daily", "time": "09:00"}); let raw = match (v.get("schedule"), &trigger) { (Some(r), _) => r, (None, Some(_)) => &default_sched, (None, None) => return err("缺少 schedule(或用 trigger 設定事件觸發)".into()), }; let mut raw = raw.clone(); if raw.get("timezone").and_then(Value::as_str).unwrap_or("").is_empty() { raw["timezone"] = json!(state.timezone); } let sched = match serde_json::from_value::(raw).map_err(|e| e.to_string()).and_then(routines::validate) { Ok(x) => x, Err(e) => return err(format!("排程有問題:{e}")), }; let id = state.next_routine_id; state.next_routine_id += 1; let r = Routine { id, title: s("title").unwrap_or_else(|| "例行任務".into()), prompt: s("prompt").unwrap_or_default(), next_run: if trigger.is_some() { None } else { routines::next_run(&sched, now, now) }, schedule: sched, paused: false, created_at: now, last_run: None, computer: s("computer").unwrap_or_default(), trigger, }; let line = routine_line(&r); match state.bot_mut(bot_id) { Some(b) => b.routines.push(r), None => return err("找不到這個 Bot".into()), } ok(format!("已建立例行任務 {line}")) } "update_routine" | "delete_routine" | "run_routine_now" => { let id = v.get("id").and_then(Value::as_u64).unwrap_or(0); let Some(owner) = state.routine_owner(id) else { return err(format!("找不到例行任務 #{id}")) }; let tz = state.timezone.clone(); let b = state.bot_mut(owner).unwrap(); let idx = b.routines.iter().position(|r| r.id == id).unwrap(); match name { "delete_routine" => { let r = b.routines.remove(idx); ok(format!("已刪除例行任務「{}」", r.title)) } "run_routine_now" => { let r = &mut b.routines[idx]; r.last_run = Some(now); let msg = wake_message(r); Outcome { text: format!("已開始執行「{}」", r.title), ok: true, wake: vec![(owner, msg)], theme_changed: false, page: None } } _ => { let r = &mut b.routines[idx]; if let Some(t) = s("title") { r.title = t; } if let Some(p) = s("prompt") { r.prompt = p; } if let Some(raw) = v.get("schedule") { let mut raw = raw.clone(); if raw.get("timezone").and_then(Value::as_str).unwrap_or("").is_empty() { raw["timezone"] = json!(tz); } match serde_json::from_value::(raw).map_err(|e| e.to_string()).and_then(routines::validate) { Ok(x) => r.schedule = x, Err(e) => return err(format!("排程有問題:{e}")), } } if let Some(p) = v.get("paused").and_then(Value::as_bool) { r.paused = p; } if let Some(t) = v.get("trigger") { if t.is_null() { r.trigger = None; } else { match serde_json::from_value::(t.clone()).map_err(|e| e.to_string()).and_then(|t| t.validate().map(|_| t)) { Ok(t) => r.trigger = Some(t), Err(e) => return err(format!("觸發條件有問題:{e}")), } } } r.next_run = if r.trigger.is_some() { None } else { routines::next_run(&r.schedule, r.created_at, now) }; ok(format!("已更新 {}", routine_line(r))) } } } "create_group" | "move_to_group" => { let group = s(if name == "create_group" { "name" } else { "group" }).unwrap_or_default().trim().to_string(); if name == "create_group" && group.is_empty() { return err("請給群組名稱".into()); } if !group.is_empty() && !state.folders.contains(&group) { state.folders.push(group.clone()); } let names: Vec = v.get("bots").and_then(Value::as_array).map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default(); let mut moved = vec![]; let mut missing = vec![]; for n in names { match find_bot(state, &n).map(|b| b.id) { Some(id) => { if let Some(b) = state.bot_mut(id) { b.folder = group.clone(); moved.push(b.name.clone()); } } None => missing.push(n), } } let mut text = if name == "create_group" { format!("已建立群組「{group}」。") } else { String::new() }; if !moved.is_empty() { text.push_str(&format!("已把 {} 移到{}。", moved.join("、"), if group.is_empty() { "未分區".to_string() } else { format!("群組「{group}」") })); } if !missing.is_empty() { text.push_str(&format!("找不到:{}。", missing.join("、"))); } Outcome { text, ok: missing.is_empty() || !moved.is_empty(), wake: vec![], theme_changed: false, page: None } } "rename_group" => { let (old, new) = (s("group").unwrap_or_default(), s("new_name").unwrap_or_default().trim().to_string()); if new.is_empty() { return err("請給新名稱".into()); } let exists = state.folders.contains(&old) || state.bots.iter().any(|b| b.folder == old); if !exists { return err(format!("沒有叫「{old}」的群組")); } for f in state.folders.iter_mut() { if *f == old { *f = new.clone(); } } if !state.folders.contains(&new) { state.folders.push(new.clone()); } for b in state.bots.iter_mut() { if b.folder == old { b.folder = new.clone(); } } ok(format!("已把群組「{old}」改名為「{new}」。")) } "delete_group" => { let g = s("group").unwrap_or_default(); let n = state.bots.iter().filter(|b| b.folder == g).count(); if !state.folders.contains(&g) && n == 0 { return err(format!("沒有叫「{g}」的群組")); } state.folders.retain(|f| *f != g); for b in state.bots.iter_mut() { if b.folder == g { b.folder.clear(); } } ok(format!("已刪除群組「{g}」,裡面的 {n} 個 Bot 移到未分區。")) } "list_bots" => ok(state .bots .iter() .map(|b| { let mut line = format!("「{}」{}{} · {} · {}", b.name, if b.id == state.primary_bot { "(主要 Bot)" } else { "" }, if b.folder.is_empty() { String::new() } else { format!("[群組:{}]", b.folder) }, b.provider.label(), b.model); if !b.description.is_empty() { line.push_str(&format!(":{}", b.description)); } if b.id == bot_id { line.push_str("(就是你)"); } line }) .collect::>() .join("\n")), "remember" => { let Some(b) = state.bot_mut(bot_id) else { return err("找不到這個 Bot".into()) }; match crate::memory::remember(&mut b.memories, &s("fact").unwrap_or_default()) { Ok(t) => ok(t), Err(e) => err(e), } } "forget" => { let Some(b) = state.bot_mut(bot_id) else { return err("找不到這個 Bot".into()) }; match crate::memory::forget(&mut b.memories, &s("what").unwrap_or_default()) { Ok(t) => ok(t), Err(e) => err(e), } } "save_skill" => { let name = s("name").unwrap_or_default().trim().to_string(); let instructions = s("instructions").unwrap_or_default(); if name.is_empty() || instructions.trim().is_empty() { return err("技能要有名稱和做法".into()); } crate::memory::upsert_skill(&mut state.skills, crate::memory::Skill { name: name.clone(), description: s("description").unwrap_or_default(), instructions, steps: vec![] }); ok(format!("已存成技能「{name}」,所有 Bot 都能用。")) } "use_skill" => { let name = s("name").unwrap_or_default(); match state.skills.iter().find(|k| k.name == name.trim()).or_else(|| state.skills.iter().find(|k| k.name.contains(name.trim()) && !name.trim().is_empty())) { Some(k) => ok(format!("技能「{}」的做法:\n{}", k.name, k.instructions)), None => err(format!("沒有叫「{name}」的技能。有:{}", state.skills.iter().map(|k| k.name.as_str()).collect::>().join("、"))), } } "delete_skill" => { let name = s("name").unwrap_or_default(); let before = state.skills.len(); state.skills.retain(|k| k.name != name.trim()); if state.skills.len() < before { ok(format!("已刪除技能「{name}」")) } else { err(format!("沒有叫「{name}」的技能")) } } "create_bot" => { // 範本: a ready role and instructions. let template = s("template").and_then(|t| crate::memory::TEMPLATES.iter().find(|x| x.name == t.trim())); let name = s("name").or_else(|| template.map(|t| t.name.to_string())).unwrap_or_else(|| "新 Bot".into()); let base = state.bot(bot_id).cloned(); let provider = s("provider").and_then(|p| provider_of(&p)).or(base.as_ref().map(|b| b.provider)).unwrap_or(ProviderKind::Xai); let model = s("model").unwrap_or_else(|| if Some(provider) == base.as_ref().map(|b| b.provider) { base.as_ref().unwrap().model.clone() } else { provider.suggested_model().into() }); let id = state.next_id; state.next_id += 1; let shape = crate::avatar::SHAPES[(id as usize) % crate::avatar::SHAPES.len()]; let color = crate::avatar::COLORS[(id as usize) % crate::avatar::COLORS.len()].0; state.bots.push(Bot { id, name: name.clone(), description: s("description").or_else(|| template.map(|t| t.description.to_string())).unwrap_or_default(), instructions: s("instructions").or_else(|| template.map(|t| t.instructions.to_string())).unwrap_or_default(), shape: template.map(|t| t.shape).unwrap_or(shape).into(), color: template.map(|t| t.color).unwrap_or(color).into(), provider, model: model.clone(), step_model: String::new(), pinned: false, messages: vec![], unread: false, tools_enabled: false, workdir: model::default_workdir(), 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: model::default_context_tokens(), routines: vec![], last_turn: None, default_computer: model::default_computer(), }); ok(format!("已建立 Bot「{name}」({} · {model})", provider.label())) } "update_bot" => { let target = match s("bot").filter(|n| !n.trim().is_empty()) { Some(n) => match find_bot(state, &n) { Some(b) => b.id, None => return err(format!("找不到叫「{n}」的 Bot")), }, None => bot_id, }; let computers = state.computer_names(); let b = state.bot_mut(target).unwrap(); let mut changed = vec![]; if let Some(x) = s("name") { b.name = x; changed.push("名稱"); } if let Some(x) = s("description") { b.description = x; changed.push("描述"); } if let Some(x) = s("instructions") { b.instructions = x; changed.push("指示"); } if let Some(p) = s("provider") { match provider_of(&p) { Some(k) => { b.provider = k; if s("model").is_none() { b.model = k.suggested_model().into(); } changed.push("AI 供應商"); } None => return err(format!("沒有「{p}」這個供應商")), } } if let Some(m) = s("model") { b.model = m; changed.push("模型"); } if let Some(p) = v.get("pinned").and_then(Value::as_bool) { b.pinned = p; changed.push("釘選"); } if let Some(c) = s("default_computer") { if !computers.contains(&c) { return err(format!("沒有「{c}」這台電腦。可以用:{}", computers.join("、"))); } b.default_computer = c; changed.push("預設電腦"); } if changed.is_empty() { return err("沒有要改的項目".into()); } ok(format!("已更新「{}」的{}(現在:{} · {},預設電腦「{}」)", b.name, changed.join("、"), b.provider.label(), b.model, b.default_computer)) } "message_bot" => { let Some(n) = s("bot") else { return err("要指定 Bot".into()) }; let Some(target) = find_bot(state, &n).map(|b| b.id) else { return err(format!("找不到叫「{n}」的 Bot")) }; if target == bot_id { return err("不能傳訊息給自己".into()); } let from = state.bot(bot_id).map(|b| b.name.clone()).unwrap_or_default(); let text = s("text").unwrap_or_default(); let msg = Message { wake: true, ..Message::user(&format!("[來自 Bot]「{from}」:{text}")) }; let name = state.bot(target).map(|b| b.name.clone()).unwrap_or_default(); Outcome { text: format!("已交給「{name}」,它的回覆會出現在它自己的聊天裡。"), ok: true, wake: vec![(target, msg)], theme_changed: false, page: None } } "update_settings" => { let mut out = Outcome { text: String::new(), ok: true, wake: vec![], theme_changed: false, page: None }; let mut changed = vec![]; if let Some(t) = s("theme") { // An unknown name used to mean Light with no word about it. let (th, known) = match t.as_str() { "dark" => (Theme::Dark, true), "system" => (Theme::System, true), "light" => (Theme::Light, true), _ => (state.theme, false), }; if known { state.theme = th; out.theme_changed = true; changed.push(format!("主題 → {t}")); } else { out.text.push_str(&format!("主題「{t}」不存在(可用 dark、light、system)。\n")); } } if let Some(a) = s("accent") { state.accent = if a == "bot_color" { Accent::BotColor } else { Accent::Black }; changed.push(format!("強調色 → {a}")); } if let Some(z) = s("timezone") { if z.parse::().is_err() { return err(format!("「{z}」不是有效的時區名稱(例如 Asia/Taipei)")); } state.timezone = z.clone(); changed.push(format!("時區 → {z}")); } if changed.is_empty() { return err("沒有要改的設定".into()); } out.text = format!("已更新:{}", changed.join("、")); out } other => err(format!("沒有「{other}」這個操作")), } } #[cfg(test)] mod tests { use super::*; #[test] fn a_step_on_skills_and_memories_names_which() { assert_eq!(describe("delete_skill", r#"{"name":"本地活動報名"}"#), "本地活動報名"); assert_eq!(describe("use_skill", r#"{"name":"Threads 發文"}"#), "Threads 發文"); assert_eq!(describe("remember", r#"{"fact":"之後都用自己的電腦"}"#), "之後都用自己的電腦"); assert_eq!(describe("forget", r#"{"what":"2"}"#), "2"); } #[test] fn chat_sets_up_and_manages_routines() { let mut st = State::samples(); let r = apply(&mut st, 1, "create_routine", r#"{"title":"早報","prompt":"整理新聞","schedule":{"frequency":"weekly","daysOfWeek":["MO","TU","WE","TH","FR"],"time":"09:00"}}"#); assert!(r.ok, "{}", r.text); assert!(r.text.contains("每個工作日 09:00"), "{}", r.text); let b = st.bot(1).unwrap(); assert_eq!(b.routines.len(), 1); assert!(b.routines[0].next_run.unwrap() > routines::now()); let id = b.routines[0].id; assert!(apply(&mut st, 1, "update_routine", &format!(r#"{{"id":{id},"paused":true}}"#)).text.contains("已暫停")); assert!(needs_approval("delete_routine", &format!(r#"{{"id":{id}}}"#), &st).unwrap().contains("早報")); let w = apply(&mut st, 1, "run_routine_now", &format!(r#"{{"id":{id}}}"#)); assert_eq!(w.wake[0].0, 1); assert!(w.wake[0].1.wake); assert!(apply(&mut st, 1, "delete_routine", &format!(r#"{{"id":{id}}}"#)).ok); assert!(st.bot(1).unwrap().routines.is_empty()); assert!(!apply(&mut st, 1, "create_routine", r#"{"title":"x","prompt":"y","schedule":{"frequency":"weekly","time":"09:00"}}"#).ok); } /// The person's case: the app closed while two replies were coming in, leaving empty /// replies that looked stuck. Loading repairs them so they can be retried. #[test] fn interrupted_replies_are_repaired() { let mut st = State::samples(); let b = st.bot_mut(1).unwrap(); b.messages = vec![ Message::user("你好"), Message::assistant("你好!", "x"), Message::user("找宮古島攻略"), Message { thinking: "想一下".into(), ..Message::assistant("", "x") }, Message::user("嗨"), Message::assistant("", "x"), ]; assert!(b.repair_interrupted()); assert_eq!(b.messages[1].text, "你好!"); assert!(b.messages[3].error && b.messages[5].error); assert_eq!(b.messages[5].text, model::INTERRUPTED_NOTE); assert_eq!(b.last_turn.as_ref().unwrap().outcome, model::TurnOutcome::Error); assert!(!b.repair_interrupted(), "a second pass changes nothing"); } #[test] fn chat_manages_bots_and_settings() { let mut st = State::samples(); assert!(apply(&mut st, 1, "update_bot", r#"{"provider":"openai","model":"gpt-5-mini"}"#).text.contains("OpenAI · gpt-5-mini")); assert!(apply(&mut st, 1, "create_bot", r#"{"name":"翻譯官","provider":"gemini"}"#).ok); assert!(st.bots.iter().any(|b| b.name == "翻譯官" && b.provider == ProviderKind::Gemini)); let m = apply(&mut st, 1, "message_bot", r#"{"bot":"寫作","text":"幫我想標題"}"#); assert!(m.ok && m.wake[0].0 == 3, "{}", m.text); assert!(apply(&mut st, 1, "update_bot", r#"{"default_computer":"火星"}"#).text.contains("沒有")); assert!(apply(&mut st, 1, "update_settings", r#"{"theme":"dark","timezone":"Asia/Tokyo"}"#).theme_changed); assert_eq!(st.timezone, "Asia/Tokyo"); } #[test] fn groups_from_the_chat() { let mut st = State::samples(); let names: Vec = st.bots.iter().take(2).map(|b| b.name.clone()).collect(); let o = apply(&mut st, 1, "create_group", &serde_json::json!({"name": "文宣組", "bots": names}).to_string()); assert!(o.ok && o.text.contains("文宣組"), "{}", o.text); assert_eq!(st.bots.iter().filter(|b| b.folder == "文宣組").count(), 2); assert!(st.folders.contains(&"文宣組".to_string())); assert!(apply(&mut st, 1, "rename_group", r#"{"group":"文宣組","new_name":"行銷組"}"#).ok); assert_eq!(st.bots.iter().filter(|b| b.folder == "行銷組").count(), 2); assert!(apply(&mut st, 1, "list_bots", "{}").text.contains("[群組:行銷組]")); assert!(apply(&mut st, 1, "delete_group", r#"{"group":"行銷組"}"#).ok); assert!(st.bots.iter().all(|b| b.folder.is_empty()) && st.folders.is_empty()); assert!(!apply(&mut st, 1, "rename_group", r#"{"group":"沒有","new_name":"x"}"#).ok); } }