AnyBot/src/appctl.rs

691 lines
38 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

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

// 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<Value> {
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::<Vec<_>>()},
"name": {"type": "string"}, "description": {"type": "string"}, "instructions": {"type": "string"},
"provider": {"type": "string", "enum": ALL_PROVIDERS.iter().map(|p| p.key()).collect::<Vec<_>>()}, "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::<Vec<_>>()}, "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::<Schedule>(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::<String>()),
"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<String> {
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<u64>,
}
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<ProviderKind> {
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::<Vec<_>>().join("\n"))
}
"create_routine" => {
let trigger = match v.get("trigger").filter(|t| !t.is_null()) {
Some(t) => match serde_json::from_value::<crate::triggers::Trigger>(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::<Schedule>(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::<Schedule>(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::<crate::triggers::Trigger>(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<String> = 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::<Vec<_>>()
.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) => {
if let Some(f) = t.strip_prefix("記住了:").or_else(|| t.strip_prefix("已經記得:")) {
crate::store::memory_add(bot_id, f);
}
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) => {
if let Some(f) = t.strip_prefix("忘記了:") {
crate::store::memory_del(bot_id, f);
}
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::<Vec<_>>().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::<chrono_tz::Tz>().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<String> = 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);
}
}