691 lines
38 KiB
Rust
691 lines
38 KiB
Rust
// 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);
|
||
}
|
||
}
|