2026-11-04 09:46:43 +00:00
|
|
|
|
// 大腦: one AnyBot of the account keeps the bots, chats, memory and schedules (the brain, usually
|
|
|
|
|
|
// `anybot --serve` next to the account server). Every other AnyBot signed in to the account shows
|
|
|
|
|
|
// the brain's bots in its own window and lends its hands (screen, files, browser) through 裝置連結.
|
|
|
|
|
|
//
|
|
|
|
|
|
// The brain side answers "what changed since version N?" per screen: the settings (bots without
|
|
|
|
|
|
// their chats) when they changed, and for each bot only the messages from the first one that
|
|
|
|
|
|
// changed. The screen side keeps a copy, sends the person's edits back as small operations, and
|
|
|
|
|
|
// turns chat actions (send, stop, approve…) into requests. When the brain can't be reached the
|
|
|
|
|
|
// window stays readable and waits.
|
|
|
|
|
|
|
|
|
|
|
|
use super::*;
|
|
|
|
|
|
use crate::remote::{self, SyncKind};
|
|
|
|
|
|
use serde_json::{json, Value};
|
|
|
|
|
|
use std::hash::{Hash, Hasher};
|
|
|
|
|
|
use std::sync::atomic::AtomicU64;
|
|
|
|
|
|
|
|
|
|
|
|
/// Settings that belong to each computer, never to the brain.
|
|
|
|
|
|
const LOCAL_KEYS: &[&str] = &["remote", "theme", "accent", "sidebar_collapsed", "sidebar_width", "selected", "machine_label", "local_execution", "local_never", "messages_send", "messages_no_ask"];
|
|
|
|
|
|
/// Bot fields the brain alone changes (its chat, and what a turn leaves behind).
|
|
|
|
|
|
const BOT_RUNTIME: &[&str] = &["id", "messages", "threads", "unread", "last_turn", "context_tokens"];
|
|
|
|
|
|
/// Stands in for a key or token the brain doesn't hand out.
|
|
|
|
|
|
pub const REDACTED: &str = "••••(已設定,在大腦上)";
|
|
|
|
|
|
|
|
|
|
|
|
// ---------- shared ----------
|
|
|
|
|
|
|
|
|
|
|
|
/// The settings as JSON: everything but the chats (messages and earlier conversations).
|
|
|
|
|
|
pub(super) fn config_json(st: &mut State) -> Value {
|
|
|
|
|
|
let mut bots = std::mem::take(&mut st.bots);
|
|
|
|
|
|
let mut top = serde_json::to_value(&*st).unwrap_or(Value::Null);
|
|
|
|
|
|
let mut arr = Vec::with_capacity(bots.len());
|
|
|
|
|
|
for b in bots.iter_mut() {
|
|
|
|
|
|
let m = std::mem::take(&mut b.messages);
|
|
|
|
|
|
let th = std::mem::take(&mut b.threads);
|
|
|
|
|
|
arr.push(serde_json::to_value(&*b).unwrap_or(Value::Null));
|
|
|
|
|
|
b.messages = m;
|
|
|
|
|
|
b.threads = th;
|
|
|
|
|
|
}
|
|
|
|
|
|
st.bots = bots;
|
|
|
|
|
|
top["bots"] = Value::Array(arr);
|
|
|
|
|
|
top
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Keys and tokens stay on the brain.
|
|
|
|
|
|
fn redact(top: &mut Value) {
|
|
|
|
|
|
if let Some(o) = top.as_object_mut() {
|
|
|
|
|
|
o.remove("remote");
|
|
|
|
|
|
}
|
|
|
|
|
|
let hide = |v: &mut Value| {
|
|
|
|
|
|
if v.as_str().map(|s| !s.is_empty()).unwrap_or(false) {
|
|
|
|
|
|
*v = Value::String(REDACTED.into());
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
if let Some(ps) = top["providers"].as_object_mut() {
|
|
|
|
|
|
for p in ps.values_mut() {
|
|
|
|
|
|
hide(&mut p["api_key"]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
for c in top["channels"].as_array_mut().into_iter().flatten() {
|
|
|
|
|
|
hide(&mut c["token"]);
|
|
|
|
|
|
}
|
|
|
|
|
|
for m in top["mcp_servers"].as_array_mut().into_iter().flatten() {
|
|
|
|
|
|
for k in ["env", "headers"] {
|
|
|
|
|
|
for v in m[k].as_object_mut().into_iter().flat_map(|o| o.values_mut()) {
|
|
|
|
|
|
hide(v);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Put the brain's own keys back wherever an edit still carries the stand-in.
|
|
|
|
|
|
fn unredact(new: &mut Value, old: &Value) {
|
|
|
|
|
|
match new {
|
|
|
|
|
|
Value::String(s) if s == REDACTED => *new = old.clone(),
|
|
|
|
|
|
Value::Object(o) => {
|
|
|
|
|
|
for (k, v) in o.iter_mut() {
|
|
|
|
|
|
unredact(v, old.get(k).unwrap_or(&Value::Null));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Value::Array(a) => {
|
|
|
|
|
|
for (i, v) in a.iter_mut().enumerate() {
|
|
|
|
|
|
let key = |x: &Value| x.get("id").cloned().filter(|x| !x.is_null()).or_else(|| x.get("name").cloned());
|
|
|
|
|
|
let twin = old.as_array().and_then(|olds| match key(v) {
|
|
|
|
|
|
Some(k) => olds.iter().find(|o| key(o).as_ref() == Some(&k)),
|
|
|
|
|
|
None => olds.get(i),
|
|
|
|
|
|
});
|
|
|
|
|
|
unredact(v, twin.unwrap_or(&Value::Null));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => {}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// A cheap fingerprint of one message: enough to see it changed (streaming, a tool finishing…).
|
|
|
|
|
|
fn msg_sig(m: &Message) -> u64 {
|
|
|
|
|
|
let mut h = std::collections::hash_map::DefaultHasher::new();
|
|
|
|
|
|
(m.role as u8, m.text.len(), m.thinking.len(), m.error, m.summary, m.images.len(), m.attachments.len(), m.at, &m.tool_call_id, &m.reactions).hash(&mut h);
|
|
|
|
|
|
m.text.chars().rev().take(48).collect::<String>().hash(&mut h);
|
|
|
|
|
|
for c in &m.tool_calls {
|
|
|
|
|
|
(&c.id, c.args.len(), c.status as u8).hash(&mut h);
|
|
|
|
|
|
}
|
|
|
|
|
|
h.finish()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn threads_sig(b: &Bot) -> u64 {
|
|
|
|
|
|
let mut h = std::collections::hash_map::DefaultHasher::new();
|
|
|
|
|
|
for (t, msgs) in &b.threads {
|
|
|
|
|
|
(t, msgs.len()).hash(&mut h);
|
|
|
|
|
|
}
|
|
|
|
|
|
h.finish()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn hash_value(v: &Value) -> u64 {
|
|
|
|
|
|
let mut h = std::collections::hash_map::DefaultHasher::new();
|
|
|
|
|
|
v.to_string().hash(&mut h);
|
|
|
|
|
|
h.finish()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-04 11:30:32 +00:00
|
|
|
|
/// What names an item in a list: its id, else its name, else (plain values) the value itself.
|
|
|
|
|
|
fn item_key(x: &Value) -> Option<Value> {
|
|
|
|
|
|
match x {
|
|
|
|
|
|
Value::Object(o) => o.get("id").filter(|v| !v.is_null()).or_else(|| o.get("name").filter(|v| v.is_string())).cloned(),
|
|
|
|
|
|
Value::Array(_) | Value::Null => None,
|
|
|
|
|
|
v => Some(v.clone()),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Every item has its own key, and no two share one.
|
|
|
|
|
|
fn keyed(a: &[Value]) -> bool {
|
|
|
|
|
|
let mut seen = std::collections::HashSet::new();
|
|
|
|
|
|
a.iter().all(|x| item_key(x).map(|k| seen.insert(k.to_string())).unwrap_or(false))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The smallest change from `old` to `new`: lists of named things and maps change item by item,
|
|
|
|
|
|
/// so two computers editing different items of one list don't undo each other.
|
|
|
|
|
|
pub(super) fn patch_of(old: &Value, new: &Value) -> Value {
|
|
|
|
|
|
match (old, new) {
|
|
|
|
|
|
(Value::Object(o), Value::Object(n)) => {
|
|
|
|
|
|
let put: serde_json::Map<String, Value> = n.iter().filter(|(k, v)| o.get(*k) != Some(*v)).map(|(k, v)| (k.clone(), v.clone())).collect();
|
|
|
|
|
|
let del: Vec<&String> = o.keys().filter(|k| !n.contains_key(*k)).collect();
|
|
|
|
|
|
json!({"map": {"put": put, "del": del}})
|
|
|
|
|
|
}
|
|
|
|
|
|
(Value::Array(o), Value::Array(n)) if keyed(o) && keyed(n) => {
|
|
|
|
|
|
let key = |x: &Value| item_key(x).unwrap_or(Value::Null);
|
|
|
|
|
|
let put: Vec<&Value> = n.iter().filter(|x| !o.iter().any(|y| key(y) == key(x) && y == *x)).collect();
|
|
|
|
|
|
let del: Vec<Value> = o.iter().map(key).filter(|k| !n.iter().any(|x| key(x) == *k)).collect();
|
|
|
|
|
|
let common_old: Vec<Value> = o.iter().map(key).filter(|k| n.iter().any(|x| key(x) == *k)).collect();
|
|
|
|
|
|
let common_new: Vec<Value> = n.iter().map(key).filter(|k| o.iter().any(|x| key(x) == *k)).collect();
|
|
|
|
|
|
let mut p = json!({"list": {"put": put, "del": del}});
|
|
|
|
|
|
if common_old != common_new {
|
|
|
|
|
|
p["list"]["order"] = Value::Array(n.iter().map(key).collect());
|
|
|
|
|
|
}
|
|
|
|
|
|
p
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => json!({"value": new}),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Apply a change made by `patch_of` to whatever the value is now.
|
|
|
|
|
|
pub(super) fn apply_patch(cur: &mut Value, p: &Value) {
|
|
|
|
|
|
if let Some(v) = p.get("value") {
|
|
|
|
|
|
*cur = v.clone();
|
|
|
|
|
|
} else if let Some(m) = p.get("map") {
|
|
|
|
|
|
if !cur.is_object() {
|
|
|
|
|
|
*cur = json!({});
|
|
|
|
|
|
}
|
|
|
|
|
|
let o = cur.as_object_mut().expect("object");
|
|
|
|
|
|
for (k, v) in m["put"].as_object().into_iter().flatten() {
|
|
|
|
|
|
o.insert(k.clone(), v.clone());
|
|
|
|
|
|
}
|
|
|
|
|
|
for k in m["del"].as_array().into_iter().flatten().filter_map(Value::as_str) {
|
|
|
|
|
|
o.remove(k);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if let Some(l) = p.get("list") {
|
|
|
|
|
|
if !cur.is_array() {
|
|
|
|
|
|
*cur = json!([]);
|
|
|
|
|
|
}
|
|
|
|
|
|
let a = cur.as_array_mut().expect("array");
|
|
|
|
|
|
let key = |x: &Value| item_key(x).unwrap_or(Value::Null);
|
|
|
|
|
|
for item in l["put"].as_array().into_iter().flatten() {
|
|
|
|
|
|
match a.iter_mut().find(|x| key(x) == key(item)) {
|
|
|
|
|
|
Some(x) => *x = item.clone(),
|
|
|
|
|
|
None => a.push(item.clone()),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
let del: Vec<Value> = l["del"].as_array().cloned().unwrap_or_default();
|
|
|
|
|
|
a.retain(|x| !del.contains(&key(x)));
|
|
|
|
|
|
if let Some(order) = l["order"].as_array() {
|
|
|
|
|
|
a.sort_by_key(|x| order.iter().position(|k| *k == key(x)).unwrap_or(usize::MAX));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-04 09:46:43 +00:00
|
|
|
|
/// The person's edits since `base`, as operations the brain applies one by one.
|
|
|
|
|
|
pub(super) fn diff_ops(base: &Value, cur: &Value) -> Vec<Value> {
|
|
|
|
|
|
let mut ops = vec![];
|
|
|
|
|
|
let (Some(b), Some(c)) = (base.as_object(), cur.as_object()) else { return ops };
|
|
|
|
|
|
for (k, v) in c {
|
|
|
|
|
|
if k == "bots" || LOCAL_KEYS.contains(&k.as_str()) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
if b.get(k) != Some(v) {
|
2026-11-04 11:30:32 +00:00
|
|
|
|
let p = patch_of(b.get(k).unwrap_or(&Value::Null), v);
|
|
|
|
|
|
ops.push(if p.get("value").is_some() { json!({"op": "set", "key": k, "value": v}) } else { json!({"op": "patch", "key": k, "patch": p}) });
|
2026-11-04 09:46:43 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
let id = |x: &Value| x["id"].as_u64().unwrap_or(0);
|
|
|
|
|
|
let empty = vec![];
|
|
|
|
|
|
let old = base["bots"].as_array().unwrap_or(&empty);
|
|
|
|
|
|
let new = cur["bots"].as_array().unwrap_or(&empty);
|
|
|
|
|
|
for nb in new {
|
|
|
|
|
|
match old.iter().find(|o| id(o) == id(nb)) {
|
|
|
|
|
|
None => ops.push(json!({"op": "bot_add", "bot": nb})),
|
|
|
|
|
|
Some(ob) => {
|
|
|
|
|
|
for (k, v) in nb.as_object().into_iter().flatten() {
|
|
|
|
|
|
if k == "unread" {
|
|
|
|
|
|
if ob[k] == true && *v == false {
|
|
|
|
|
|
ops.push(json!({"op": "bot_read", "id": id(nb)}));
|
|
|
|
|
|
}
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
if BOT_RUNTIME.contains(&k.as_str()) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
if ob.get(k) != Some(v) {
|
2026-11-04 11:30:32 +00:00
|
|
|
|
let p = patch_of(ob.get(k).unwrap_or(&Value::Null), v);
|
|
|
|
|
|
ops.push(if p.get("value").is_some() { json!({"op": "bot_set", "id": id(nb), "key": k, "value": v}) } else { json!({"op": "bot_patch", "id": id(nb), "key": k, "patch": p}) });
|
2026-11-04 09:46:43 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
for ob in old {
|
|
|
|
|
|
if !new.iter().any(|n| id(n) == id(ob)) {
|
|
|
|
|
|
ops.push(json!({"op": "bot_del", "id": id(ob)}));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
let order = |a: &Vec<Value>| a.iter().map(id).collect::<Vec<_>>();
|
|
|
|
|
|
let (oo, no) = (order(old), order(new));
|
|
|
|
|
|
let kept: Vec<u64> = oo.iter().copied().filter(|i| no.contains(i)).collect();
|
|
|
|
|
|
let kept_new: Vec<u64> = no.iter().copied().filter(|i| oo.contains(i)).collect();
|
|
|
|
|
|
if kept != kept_new {
|
|
|
|
|
|
ops.push(json!({"op": "bot_order", "ids": no}));
|
|
|
|
|
|
}
|
|
|
|
|
|
ops
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-04 11:07:55 +00:00
|
|
|
|
/// Replay edit operations on settings JSON: a screen keeps its own not-yet-confirmed edits on
|
|
|
|
|
|
/// top of what the brain last sent, so typing isn't undone by the brain's slightly older copy.
|
|
|
|
|
|
pub(super) fn apply_ops(cfg: &mut Value, ops: &[Value]) {
|
|
|
|
|
|
let id = |x: &Value| x["id"].as_u64().unwrap_or(0);
|
|
|
|
|
|
for op in ops {
|
|
|
|
|
|
let target = op["id"].as_u64().unwrap_or(0);
|
|
|
|
|
|
match op["op"].as_str().unwrap_or("") {
|
|
|
|
|
|
"set" => {
|
|
|
|
|
|
if let Some(k) = op["key"].as_str() {
|
|
|
|
|
|
cfg[k] = op["value"].clone();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-11-04 11:30:32 +00:00
|
|
|
|
"patch" => {
|
|
|
|
|
|
if let Some(k) = op["key"].as_str() {
|
|
|
|
|
|
apply_patch(&mut cfg[k], &op["patch"]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
"bot_patch" => {
|
|
|
|
|
|
if let (Some(b), Some(k)) = (cfg["bots"].as_array_mut().and_then(|a| a.iter_mut().find(|b| id(b) == target)), op["key"].as_str()) {
|
|
|
|
|
|
apply_patch(&mut b[k], &op["patch"]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-11-04 11:07:55 +00:00
|
|
|
|
"bot_add" => {
|
|
|
|
|
|
if let Some(a) = cfg["bots"].as_array_mut() {
|
|
|
|
|
|
if !a.iter().any(|b| id(b) == id(&op["bot"])) {
|
|
|
|
|
|
a.push(op["bot"].clone());
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
"bot_del" => {
|
|
|
|
|
|
if let Some(a) = cfg["bots"].as_array_mut() {
|
|
|
|
|
|
a.retain(|b| id(b) != target);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
"bot_set" | "bot_read" => {
|
|
|
|
|
|
if let Some(b) = cfg["bots"].as_array_mut().and_then(|a| a.iter_mut().find(|b| id(b) == target)) {
|
|
|
|
|
|
if op["op"] == "bot_read" {
|
|
|
|
|
|
b["unread"] = json!(false);
|
|
|
|
|
|
} else if let Some(k) = op["key"].as_str() {
|
|
|
|
|
|
b[k] = op["value"].clone();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
"bot_order" => {
|
|
|
|
|
|
let ids: Vec<u64> = op["ids"].as_array().into_iter().flatten().filter_map(Value::as_u64).collect();
|
|
|
|
|
|
if let Some(a) = cfg["bots"].as_array_mut() {
|
|
|
|
|
|
a.sort_by_key(|b| ids.iter().position(|i| *i == id(b)).unwrap_or(usize::MAX));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => {}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// A schedule edited on a screen keeps what the brain knows about runs (when it last ran, when it
|
|
|
|
|
|
/// runs next), so an older copy can't make a routine run twice.
|
|
|
|
|
|
fn keep_run_times(new: &mut Value, old: &Value) {
|
|
|
|
|
|
for r in new.as_array_mut().into_iter().flatten() {
|
|
|
|
|
|
let Some(o) = old.as_array().and_then(|a| a.iter().find(|o| o["id"] == r["id"])) else { continue };
|
|
|
|
|
|
let last = |x: &Value| x["last_run"].as_i64();
|
|
|
|
|
|
if last(o) > last(r) || r["last_run"].is_null() {
|
|
|
|
|
|
r["last_run"] = o["last_run"].clone();
|
|
|
|
|
|
}
|
|
|
|
|
|
let same = ["schedule", "trigger", "paused"].iter().all(|k| r[*k] == o[*k]);
|
|
|
|
|
|
if same {
|
|
|
|
|
|
r["next_run"] = o["next_run"].clone();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-04 09:46:43 +00:00
|
|
|
|
// ---------- the brain's side ----------
|
|
|
|
|
|
|
|
|
|
|
|
/// What the brain last sent each screen.
|
|
|
|
|
|
#[derive(Default)]
|
|
|
|
|
|
pub struct Screens {
|
|
|
|
|
|
seen: HashMap<String, Seen>,
|
|
|
|
|
|
/// The settings fingerprint, refreshed now and then while something changes.
|
|
|
|
|
|
pub(super) cfg_hash: u64,
|
|
|
|
|
|
cfg_at: Option<Instant>,
|
2026-11-04 11:07:55 +00:00
|
|
|
|
/// The last edit (by number) each screen sent, so it knows which of its edits are in.
|
|
|
|
|
|
acked: HashMap<String, u64>,
|
2026-11-04 09:46:43 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Default)]
|
|
|
|
|
|
struct Seen {
|
|
|
|
|
|
version: u64,
|
|
|
|
|
|
config: u64,
|
|
|
|
|
|
bots: HashMap<u64, Vec<u64>>,
|
|
|
|
|
|
threads: HashMap<u64, u64>,
|
|
|
|
|
|
at: Option<Instant>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------- the screen's side ----------
|
|
|
|
|
|
|
|
|
|
|
|
pub enum Incoming {
|
|
|
|
|
|
Pull(Value),
|
|
|
|
|
|
Offline(String),
|
2026-11-04 11:30:32 +00:00
|
|
|
|
/// A file from the brain arrived (its local copy's path).
|
|
|
|
|
|
File(String),
|
2026-11-04 09:46:43 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-04 11:07:55 +00:00
|
|
|
|
/// What a screen sends to the brain, one at a time and in order.
|
|
|
|
|
|
enum Out {
|
|
|
|
|
|
Edit { seq: u64, ops: Vec<Value> },
|
|
|
|
|
|
Act(Value),
|
|
|
|
|
|
Send { bot: u64, text: String, atts: Vec<crate::attach::Attachment> },
|
|
|
|
|
|
Move { ops: Vec<Value>, n: usize },
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-04 09:46:43 +00:00
|
|
|
|
#[derive(Default)]
|
|
|
|
|
|
pub struct Mirror {
|
|
|
|
|
|
/// This window shows a brain (instead of its own bots).
|
|
|
|
|
|
pub on: bool,
|
|
|
|
|
|
/// Heard from the brain lately.
|
|
|
|
|
|
pub online: bool,
|
|
|
|
|
|
/// The brain's settings as last received (edits are what differs from this).
|
|
|
|
|
|
pub(super) base: Value,
|
|
|
|
|
|
pub status: Arc<Mutex<String>>,
|
|
|
|
|
|
rx: Option<Receiver<Incoming>>,
|
|
|
|
|
|
tx: Option<Sender<Incoming>>,
|
|
|
|
|
|
gen: Arc<AtomicU64>,
|
|
|
|
|
|
version: Arc<AtomicU64>,
|
|
|
|
|
|
client: String,
|
|
|
|
|
|
/// This computer's own data, kept aside while it shows the brain.
|
|
|
|
|
|
local: Option<State>,
|
|
|
|
|
|
started: bool,
|
|
|
|
|
|
/// A request to the brain failed (shown above the message box).
|
|
|
|
|
|
pub(super) error: Arc<Mutex<String>>,
|
|
|
|
|
|
/// The person chose a brain in 設定 (applied on the next frame).
|
|
|
|
|
|
pub(super) want: Option<(String, String)>,
|
|
|
|
|
|
pub(super) leave: bool,
|
|
|
|
|
|
pub(super) moving: Arc<AtomicBool>,
|
|
|
|
|
|
pub(super) move_note: Arc<Mutex<String>>,
|
2026-11-04 11:07:55 +00:00
|
|
|
|
/// Requests to the brain, sent in order by one worker.
|
|
|
|
|
|
outbox: Option<Sender<Out>>,
|
|
|
|
|
|
/// Edits sent but not yet confirmed (by number).
|
|
|
|
|
|
pending: Arc<Mutex<Vec<(u64, Vec<Value>)>>>,
|
|
|
|
|
|
seq: u64,
|
|
|
|
|
|
/// Something didn't reach the brain: ask for everything again.
|
|
|
|
|
|
resync: Arc<AtomicBool>,
|
|
|
|
|
|
/// Why the brain can't be reached (shown above the chat), and since when.
|
|
|
|
|
|
pub(super) reason: String,
|
|
|
|
|
|
down_since: Option<Instant>,
|
2026-11-04 11:30:32 +00:00
|
|
|
|
/// Files from the brain being fetched (by local path).
|
|
|
|
|
|
fetching: std::collections::HashSet<String>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Where a screen keeps its copy of a file the brain has.
|
|
|
|
|
|
fn local_copy(brain_path: &str, name: &str) -> PathBuf {
|
|
|
|
|
|
let h = crate::httpd::sha256_hex(brain_path);
|
|
|
|
|
|
let name: String = name.chars().filter(|c| !matches!(c, '/' | '\\' | '\0' | ':')).take(80).collect();
|
|
|
|
|
|
State::path().with_file_name("brain-files").join(format!("{}-{name}", &h[..16]))
|
2026-11-04 09:46:43 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn cache_path() -> PathBuf {
|
|
|
|
|
|
State::path().with_file_name("brain-copy.json")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl App {
|
|
|
|
|
|
/// The brain: answer the screens' requests (on the app's thread, where the state is).
|
|
|
|
|
|
pub(super) fn brain_serve(&mut self) {
|
|
|
|
|
|
let reqs = remote::hub().take_sync();
|
|
|
|
|
|
if reqs.is_empty() {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
for r in reqs {
|
|
|
|
|
|
let v = match r.kind {
|
|
|
|
|
|
SyncKind::Pull { client, since } => self.brain_pull(&client, since),
|
2026-11-04 11:30:32 +00:00
|
|
|
|
SyncKind::Models(name) => {
|
|
|
|
|
|
let kind = serde_json::from_value::<crate::providers::ProviderKind>(json!(name)).ok();
|
|
|
|
|
|
match kind {
|
|
|
|
|
|
Some(kind) => {
|
|
|
|
|
|
let ps = self.state.provider(kind);
|
|
|
|
|
|
let (base, key) = (crate::providers::resolve_base(kind, &ps.base_url), crate::providers::resolve_key(kind, &ps.api_key));
|
|
|
|
|
|
let reply = r.reply;
|
|
|
|
|
|
// The list can take a while; the app doesn't wait for it.
|
|
|
|
|
|
std::thread::spawn(move || {
|
|
|
|
|
|
let _ = reply.send(match crate::providers::list_models(kind, &base, &key) {
|
|
|
|
|
|
Ok(models) => json!({"models": models}),
|
|
|
|
|
|
Err(e) => json!({"error": e}),
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
None => json!({"error": "大腦不認得這個模型服務(兩邊 AnyBot 版本不同?)"}),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-11-04 09:46:43 +00:00
|
|
|
|
SyncKind::Edit(body) => {
|
|
|
|
|
|
if self.mir.on {
|
|
|
|
|
|
json!({"error": "這台 AnyBot 本身連著別的大腦"})
|
|
|
|
|
|
} else {
|
|
|
|
|
|
self.brain_edit(&body)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
let _ = r.reply.send(v);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Keep the settings fingerprint fresh (at most once a second) so screens hear of edits.
|
|
|
|
|
|
pub(super) fn brain_watch(&mut self) {
|
|
|
|
|
|
if self.mir.on || !self.dirty || self.scr.cfg_at.map(|t| t.elapsed().as_millis() < 1000).unwrap_or(false) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
self.scr.cfg_at = Some(Instant::now());
|
|
|
|
|
|
let mut c = config_json(&mut self.state);
|
|
|
|
|
|
redact(&mut c);
|
|
|
|
|
|
self.scr.cfg_hash = hash_value(&c);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn brain_pull(&mut self, client: &str, since: u64) -> Value {
|
|
|
|
|
|
if self.mir.on {
|
|
|
|
|
|
return json!({"error": "這台 AnyBot 本身連著別的大腦"});
|
|
|
|
|
|
}
|
|
|
|
|
|
let version = remote::hub().version();
|
|
|
|
|
|
// Forget screens not heard from in a day.
|
|
|
|
|
|
self.scr.seen.retain(|_, s| s.at.map(|t| t.elapsed().as_secs() < 86_400).unwrap_or(false));
|
|
|
|
|
|
let seen = self.scr.seen.entry(client.to_string()).or_default();
|
|
|
|
|
|
// A screen that missed an answer (or is new) gets everything again.
|
|
|
|
|
|
if seen.version != since || since == 0 {
|
|
|
|
|
|
*seen = Seen::default();
|
|
|
|
|
|
}
|
|
|
|
|
|
seen.at = Some(Instant::now());
|
|
|
|
|
|
seen.version = version;
|
2026-11-04 11:07:55 +00:00
|
|
|
|
let acked = self.scr.acked.get(client).copied().unwrap_or(0);
|
|
|
|
|
|
let mut out = json!({"version": version, "brain": self.state.remote.host_name, "edit_seq": acked});
|
2026-11-04 09:46:43 +00:00
|
|
|
|
let mut c = config_json(&mut self.state);
|
|
|
|
|
|
redact(&mut c);
|
|
|
|
|
|
let ch = hash_value(&c);
|
|
|
|
|
|
if seen.config != ch {
|
|
|
|
|
|
seen.config = ch;
|
|
|
|
|
|
out["config"] = c;
|
|
|
|
|
|
}
|
|
|
|
|
|
let mut bots = vec![];
|
|
|
|
|
|
let mut threads = vec![];
|
|
|
|
|
|
for b in &self.state.bots {
|
|
|
|
|
|
let sigs: Vec<u64> = b.messages.iter().map(msg_sig).collect();
|
|
|
|
|
|
let old = seen.bots.get(&b.id);
|
|
|
|
|
|
let from = match old {
|
|
|
|
|
|
Some(o) => o.iter().zip(sigs.iter()).position(|(a, b)| a != b).unwrap_or(o.len().min(sigs.len())),
|
|
|
|
|
|
None => 0,
|
|
|
|
|
|
};
|
|
|
|
|
|
if old.map(|o| o.len() != sigs.len() || from < sigs.len()).unwrap_or(true) {
|
|
|
|
|
|
bots.push(json!({"id": b.id, "from": from, "messages": &b.messages[from..]}));
|
|
|
|
|
|
}
|
|
|
|
|
|
seen.bots.insert(b.id, sigs);
|
|
|
|
|
|
let ts = threads_sig(b);
|
|
|
|
|
|
if seen.threads.get(&b.id) != Some(&ts) {
|
|
|
|
|
|
seen.threads.insert(b.id, ts);
|
|
|
|
|
|
threads.push(json!({"id": b.id, "threads": b.threads}));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
seen.bots.retain(|id, _| self.state.bots.iter().any(|b| b.id == *id));
|
|
|
|
|
|
out["bots"] = Value::Array(bots);
|
|
|
|
|
|
out["threads"] = Value::Array(threads);
|
|
|
|
|
|
out["live"] = self.streams.iter().map(|(id, l)| (id.to_string(), json!({"status": l.status, "paused": l.paused}))).collect::<serde_json::Map<_, _>>().into();
|
|
|
|
|
|
out["approvals"] = self
|
|
|
|
|
|
.approvals
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|(id, a)| {
|
|
|
|
|
|
let kind = match a.kind {
|
|
|
|
|
|
ApprovalKind::Action => "action",
|
|
|
|
|
|
ApprovalKind::EnableTools => "tools",
|
|
|
|
|
|
ApprovalKind::EnableThisComputer => "local",
|
|
|
|
|
|
};
|
|
|
|
|
|
json!({"bot": id, "call_id": a.call_id, "tool": a.tool, "reason": a.reason, "grant": a.grant, "kind": kind})
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// A screen's edits.
|
|
|
|
|
|
pub(super) fn brain_edit(&mut self, body: &Value) -> Value {
|
|
|
|
|
|
let mut applied = 0;
|
2026-11-04 11:07:55 +00:00
|
|
|
|
let total = body["ops"].as_array().map(|a| a.len()).unwrap_or(0);
|
2026-11-04 09:46:43 +00:00
|
|
|
|
for op in body["ops"].as_array().into_iter().flatten() {
|
|
|
|
|
|
let ok = match op["op"].as_str().unwrap_or("") {
|
|
|
|
|
|
"set" => self.brain_set_top(op["key"].as_str().unwrap_or(""), op["value"].clone()),
|
|
|
|
|
|
"bot_add" => {
|
|
|
|
|
|
let mut v = op["bot"].clone();
|
|
|
|
|
|
match serde_json::from_value::<Bot>(v.take()) {
|
|
|
|
|
|
Ok(mut b) => {
|
|
|
|
|
|
if b.id == 0 || self.state.bot(b.id).is_some() {
|
|
|
|
|
|
b.id = self.state.next_id.max(1);
|
|
|
|
|
|
}
|
|
|
|
|
|
self.state.next_id = self.state.next_id.max(b.id + 1);
|
|
|
|
|
|
self.state.bots.push(b);
|
|
|
|
|
|
true
|
|
|
|
|
|
}
|
|
|
|
|
|
Err(_) => false,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
"bot_del" => {
|
|
|
|
|
|
let id = op["id"].as_u64().unwrap_or(0);
|
|
|
|
|
|
if id == self.state.primary_bot || self.state.bots.len() <= 1 {
|
|
|
|
|
|
false
|
|
|
|
|
|
} else {
|
|
|
|
|
|
self.stop(id);
|
|
|
|
|
|
self.state.bots.retain(|b| b.id != id);
|
|
|
|
|
|
true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
"merge" => self.brain_merge_top(op["key"].as_str().unwrap_or(""), &op["value"]),
|
2026-11-04 11:30:32 +00:00
|
|
|
|
"patch" => {
|
|
|
|
|
|
let key = op["key"].as_str().unwrap_or("");
|
|
|
|
|
|
let mut v = self.top_value(key);
|
|
|
|
|
|
apply_patch(&mut v, &op["patch"]);
|
|
|
|
|
|
self.brain_set_top(key, v)
|
|
|
|
|
|
}
|
|
|
|
|
|
"bot_patch" => {
|
|
|
|
|
|
let (id, key) = (op["id"].as_u64().unwrap_or(0), op["key"].as_str().unwrap_or(""));
|
|
|
|
|
|
match self.state.bot(id).map(|b| serde_json::to_value(b).ok().map(|v| v[key].clone()).unwrap_or(Value::Null)) {
|
|
|
|
|
|
Some(mut v) => {
|
|
|
|
|
|
let old = v.clone();
|
|
|
|
|
|
apply_patch(&mut v, &op["patch"]);
|
|
|
|
|
|
unredact(&mut v, &old);
|
|
|
|
|
|
self.brain_set_bot(id, key, v)
|
|
|
|
|
|
}
|
|
|
|
|
|
None => false,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-11-04 09:46:43 +00:00
|
|
|
|
"bot_set" => self.brain_set_bot(op["id"].as_u64().unwrap_or(0), op["key"].as_str().unwrap_or(""), op["value"].clone()),
|
|
|
|
|
|
"bot_read" => match self.state.bot_mut(op["id"].as_u64().unwrap_or(0)) {
|
|
|
|
|
|
Some(b) => {
|
|
|
|
|
|
b.unread = false;
|
|
|
|
|
|
true
|
|
|
|
|
|
}
|
|
|
|
|
|
None => false,
|
|
|
|
|
|
},
|
|
|
|
|
|
"bot_order" => {
|
|
|
|
|
|
let ids: Vec<u64> = op["ids"].as_array().into_iter().flatten().filter_map(Value::as_u64).collect();
|
|
|
|
|
|
self.state.bots.sort_by_key(|b| ids.iter().position(|i| *i == b.id).unwrap_or(usize::MAX));
|
|
|
|
|
|
true
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => false,
|
|
|
|
|
|
};
|
|
|
|
|
|
if ok {
|
|
|
|
|
|
applied += 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-11-04 11:07:55 +00:00
|
|
|
|
if let (Some(client), Some(seq)) = (body["client"].as_str(), body["seq"].as_u64()) {
|
|
|
|
|
|
self.scr.acked.insert(client.to_string(), seq);
|
|
|
|
|
|
// Something was refused: send that screen the settings again, so it drops it.
|
|
|
|
|
|
if applied < total {
|
|
|
|
|
|
if let Some(s) = self.scr.seen.get_mut(client) {
|
|
|
|
|
|
s.config = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-11-04 09:46:43 +00:00
|
|
|
|
if applied > 0 {
|
|
|
|
|
|
self.mark();
|
|
|
|
|
|
// Screens hear of it at once.
|
|
|
|
|
|
self.scr.cfg_at = None;
|
|
|
|
|
|
self.brain_watch();
|
|
|
|
|
|
}
|
|
|
|
|
|
json!({"ok": true, "applied": applied})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-04 11:30:32 +00:00
|
|
|
|
/// One top-level setting as JSON (with the brain's real keys).
|
|
|
|
|
|
fn top_value(&mut self, key: &str) -> Value {
|
|
|
|
|
|
let bots = std::mem::take(&mut self.state.bots);
|
|
|
|
|
|
let v = serde_json::to_value(&self.state).map(|t| t[key].clone()).unwrap_or(Value::Null);
|
|
|
|
|
|
self.state.bots = bots;
|
|
|
|
|
|
v
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-04 09:46:43 +00:00
|
|
|
|
fn brain_set_top(&mut self, key: &str, mut value: Value) -> bool {
|
|
|
|
|
|
if key.is_empty() || key == "bots" || LOCAL_KEYS.contains(&key) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
let bots = std::mem::take(&mut self.state.bots);
|
|
|
|
|
|
let mut top = serde_json::to_value(&self.state).unwrap_or(Value::Null);
|
|
|
|
|
|
unredact(&mut value, &top[key]);
|
|
|
|
|
|
// Counters never go back (a screen may be behind).
|
|
|
|
|
|
if key.starts_with("next_") {
|
|
|
|
|
|
value = json!(value.as_u64().unwrap_or(0).max(top[key].as_u64().unwrap_or(0)));
|
|
|
|
|
|
}
|
|
|
|
|
|
top[key] = value;
|
|
|
|
|
|
let ok = match serde_json::from_value::<State>(top) {
|
|
|
|
|
|
Ok(mut st) => {
|
|
|
|
|
|
st.remote = std::mem::take(&mut self.state.remote);
|
|
|
|
|
|
self.state = st;
|
|
|
|
|
|
true
|
|
|
|
|
|
}
|
|
|
|
|
|
Err(_) => false,
|
|
|
|
|
|
};
|
|
|
|
|
|
self.state.bots = bots;
|
|
|
|
|
|
ok
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 搬到大腦: add what the brain doesn't have yet (keys it lacks, skills and connectors by name).
|
|
|
|
|
|
fn brain_merge_top(&mut self, key: &str, value: &Value) -> bool {
|
|
|
|
|
|
let bots = std::mem::take(&mut self.state.bots);
|
|
|
|
|
|
let top = serde_json::to_value(&self.state).unwrap_or(Value::Null);
|
|
|
|
|
|
self.state.bots = bots;
|
|
|
|
|
|
let mut cur = top[key].clone();
|
|
|
|
|
|
match (key, &mut cur) {
|
|
|
|
|
|
("providers", Value::Object(o)) => {
|
|
|
|
|
|
for (k, v) in value.as_object().into_iter().flatten() {
|
|
|
|
|
|
let missing = o.get(k).map(|x| x["api_key"].as_str().unwrap_or("").is_empty()).unwrap_or(true);
|
|
|
|
|
|
if missing && v["api_key"].as_str().map(|s| !s.is_empty() && s != REDACTED).unwrap_or(false) {
|
|
|
|
|
|
o.insert(k.clone(), v.clone());
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
("skills" | "mcp_servers", Value::Array(a)) => {
|
|
|
|
|
|
for v in value.as_array().into_iter().flatten() {
|
|
|
|
|
|
if !a.iter().any(|x| x["name"] == v["name"]) {
|
|
|
|
|
|
a.push(v.clone());
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => return false,
|
|
|
|
|
|
}
|
|
|
|
|
|
self.brain_set_top(key, cur)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn brain_set_bot(&mut self, id: u64, key: &str, value: Value) -> bool {
|
|
|
|
|
|
if BOT_RUNTIME.contains(&key) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
let Some(i) = self.state.bots.iter().position(|b| b.id == id) else { return false };
|
|
|
|
|
|
let b = &mut self.state.bots[i];
|
|
|
|
|
|
let msgs = std::mem::take(&mut b.messages);
|
|
|
|
|
|
let threads = std::mem::take(&mut b.threads);
|
|
|
|
|
|
let mut v = serde_json::to_value(&*b).unwrap_or(Value::Null);
|
2026-11-04 11:07:55 +00:00
|
|
|
|
let mut value = value;
|
|
|
|
|
|
if key == "routines" {
|
|
|
|
|
|
keep_run_times(&mut value, &v["routines"]);
|
|
|
|
|
|
}
|
2026-11-04 09:46:43 +00:00
|
|
|
|
v[key] = value;
|
|
|
|
|
|
let ok = match serde_json::from_value::<Bot>(v) {
|
|
|
|
|
|
Ok(nb) => {
|
|
|
|
|
|
*b = nb;
|
|
|
|
|
|
true
|
|
|
|
|
|
}
|
|
|
|
|
|
Err(_) => false,
|
|
|
|
|
|
};
|
|
|
|
|
|
let b = &mut self.state.bots[i];
|
|
|
|
|
|
b.messages = msgs;
|
|
|
|
|
|
b.threads = threads;
|
|
|
|
|
|
ok
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------- the screen ----------
|
|
|
|
|
|
|
|
|
|
|
|
/// Once at start: a brain was chosen earlier, so show its copy.
|
|
|
|
|
|
pub(super) fn mirror_boot(&mut self) {
|
|
|
|
|
|
if self.mir.started || self.demo {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
self.mir.started = true;
|
|
|
|
|
|
self.mir.client = crate::httpd::random_hex(8);
|
|
|
|
|
|
if !self.state.remote.brain_id.is_empty() && !self.state.remote.host_token.is_empty() {
|
|
|
|
|
|
self.mirror_enter();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Start showing the brain: this computer's own bots are kept aside, untouched.
|
|
|
|
|
|
fn mirror_enter(&mut self) {
|
|
|
|
|
|
let ids: Vec<u64> = self.streams.keys().copied().collect();
|
|
|
|
|
|
for id in ids {
|
|
|
|
|
|
self.stop(id);
|
|
|
|
|
|
}
|
|
|
|
|
|
self.approvals.clear();
|
|
|
|
|
|
let local = self.state.clone();
|
|
|
|
|
|
if let Some(copy) = std::fs::read_to_string(cache_path()).ok().and_then(|s| serde_json::from_str::<State>(&s).ok()).filter(|s| !s.bots.is_empty()) {
|
|
|
|
|
|
self.state = copy;
|
|
|
|
|
|
self.keep_local_fields(&local);
|
|
|
|
|
|
// Edits wait for the brain's own answer; the copy may be old.
|
|
|
|
|
|
}
|
|
|
|
|
|
self.mir.base = Value::Null;
|
|
|
|
|
|
self.mir.local = Some(local);
|
|
|
|
|
|
self.mir.on = true;
|
|
|
|
|
|
self.mir.online = false;
|
|
|
|
|
|
self.mir.version.store(0, Ordering::SeqCst);
|
2026-11-04 11:07:55 +00:00
|
|
|
|
self.mir.pending.lock().unwrap().clear();
|
|
|
|
|
|
self.mir.reason.clear();
|
|
|
|
|
|
self.mir.down_since = None;
|
2026-11-04 09:46:43 +00:00
|
|
|
|
*self.mir.status.lock().unwrap() = "正在連到大腦…".into();
|
|
|
|
|
|
self.mirror_worker();
|
2026-11-04 11:07:55 +00:00
|
|
|
|
self.mirror_outbox();
|
2026-11-04 11:30:32 +00:00
|
|
|
|
crate::tunnel::hello(&self.state.remote.brain_id);
|
2026-11-04 09:46:43 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Back to this computer's own bots.
|
|
|
|
|
|
fn mirror_leave(&mut self) {
|
|
|
|
|
|
self.mir.gen.fetch_add(1, Ordering::SeqCst);
|
|
|
|
|
|
self.mir.on = false;
|
|
|
|
|
|
self.mir.online = false;
|
2026-11-04 11:07:55 +00:00
|
|
|
|
self.mir.outbox = None;
|
2026-11-04 09:46:43 +00:00
|
|
|
|
self.streams.clear();
|
|
|
|
|
|
self.approvals.clear();
|
|
|
|
|
|
if let Some(mut local) = self.mir.local.take() {
|
|
|
|
|
|
let mut remote = self.state.remote.clone();
|
|
|
|
|
|
remote.brain_id.clear();
|
|
|
|
|
|
remote.brain_name.clear();
|
2026-11-04 12:38:45 +00:00
|
|
|
|
remote.hands_for.clear();
|
|
|
|
|
|
remote.hands_for_name.clear();
|
2026-11-04 09:46:43 +00:00
|
|
|
|
local.remote = remote;
|
|
|
|
|
|
local.theme = self.state.theme;
|
|
|
|
|
|
local.accent = self.state.accent;
|
|
|
|
|
|
if local.bot(local.selected).is_none() {
|
|
|
|
|
|
local.selected = local.primary_bot;
|
|
|
|
|
|
}
|
|
|
|
|
|
self.state = local;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
self.state.remote.brain_id.clear();
|
|
|
|
|
|
self.state.remote.brain_name.clear();
|
2026-11-04 12:38:45 +00:00
|
|
|
|
self.state.remote.hands_for.clear();
|
|
|
|
|
|
self.state.remote.hands_for_name.clear();
|
2026-11-04 09:46:43 +00:00
|
|
|
|
}
|
|
|
|
|
|
self.state.save();
|
|
|
|
|
|
self.dirty = false;
|
2026-11-04 11:30:32 +00:00
|
|
|
|
crate::tunnel::hello("");
|
2026-11-04 09:46:43 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn keep_local_fields(&mut self, from: &State) {
|
|
|
|
|
|
let st = &mut self.state;
|
|
|
|
|
|
st.remote = from.remote.clone();
|
|
|
|
|
|
st.theme = from.theme;
|
|
|
|
|
|
st.accent = from.accent;
|
|
|
|
|
|
st.sidebar_collapsed = from.sidebar_collapsed;
|
|
|
|
|
|
st.sidebar_width = from.sidebar_width;
|
|
|
|
|
|
st.machine_label = from.machine_label.clone();
|
|
|
|
|
|
st.local_execution = from.local_execution;
|
|
|
|
|
|
st.local_never = from.local_never;
|
|
|
|
|
|
st.messages_send = from.messages_send;
|
|
|
|
|
|
st.messages_no_ask = from.messages_no_ask;
|
|
|
|
|
|
if st.bot(from.selected).is_some() {
|
|
|
|
|
|
st.selected = from.selected;
|
|
|
|
|
|
} else if st.bot(st.selected).is_none() {
|
|
|
|
|
|
st.selected = st.primary_bot;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn mirror_worker(&mut self) {
|
|
|
|
|
|
let gen = self.mir.gen.fetch_add(1, Ordering::SeqCst) + 1;
|
|
|
|
|
|
let (tx, rx) = channel();
|
|
|
|
|
|
self.mir.rx = Some(rx);
|
|
|
|
|
|
self.mir.tx = Some(tx.clone());
|
2026-11-04 11:07:55 +00:00
|
|
|
|
let (g, version, client, s, resync) = (self.mir.gen.clone(), self.mir.version.clone(), self.mir.client.clone(), self.state.remote.clone(), self.mir.resync.clone());
|
2026-11-04 09:46:43 +00:00
|
|
|
|
let repaint = self.repaint_fn();
|
|
|
|
|
|
std::thread::spawn(move || {
|
|
|
|
|
|
let mut backoff = 1u64;
|
|
|
|
|
|
while g.load(Ordering::SeqCst) == gen {
|
2026-11-04 11:07:55 +00:00
|
|
|
|
let since = if resync.swap(false, Ordering::SeqCst) { 0 } else { version.load(Ordering::SeqCst) };
|
|
|
|
|
|
match remote::sync_pull(&s, since, &client) {
|
2026-11-04 09:46:43 +00:00
|
|
|
|
Ok(v) => {
|
|
|
|
|
|
backoff = 1;
|
|
|
|
|
|
if g.load(Ordering::SeqCst) != gen {
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
version.store(v["version"].as_u64().unwrap_or(0), Ordering::SeqCst);
|
|
|
|
|
|
let _ = tx.send(Incoming::Pull(v));
|
|
|
|
|
|
}
|
|
|
|
|
|
Err(e) => {
|
|
|
|
|
|
let _ = tx.send(Incoming::Offline(e));
|
|
|
|
|
|
// Start over (with everything) once it is back.
|
|
|
|
|
|
version.store(0, Ordering::SeqCst);
|
|
|
|
|
|
std::thread::sleep(std::time::Duration::from_secs(backoff));
|
|
|
|
|
|
backoff = (backoff * 2).min(20);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
repaint();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn repaint_fn(&self) -> Arc<dyn Fn() + Send + Sync> {
|
|
|
|
|
|
match &self.ctx_handle {
|
|
|
|
|
|
Some(c) => {
|
|
|
|
|
|
let c = c.clone();
|
|
|
|
|
|
Arc::new(move || c.request_repaint())
|
|
|
|
|
|
}
|
|
|
|
|
|
None => Arc::new(|| {}),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Every frame on a screen: take in what the brain sent.
|
|
|
|
|
|
pub(super) fn mirror_tick(&mut self) {
|
|
|
|
|
|
if let Some((id, name)) = self.mir.want.take() {
|
|
|
|
|
|
if self.mir.on {
|
|
|
|
|
|
self.mirror_leave();
|
|
|
|
|
|
}
|
2026-11-04 12:38:45 +00:00
|
|
|
|
// This computer's hands are for that brain only.
|
|
|
|
|
|
self.state.remote.hands_for = id.clone();
|
|
|
|
|
|
self.state.remote.hands_for_name = name.clone();
|
2026-11-04 09:46:43 +00:00
|
|
|
|
self.state.remote.brain_id = id;
|
|
|
|
|
|
self.state.remote.brain_name = name;
|
|
|
|
|
|
self.state.save();
|
|
|
|
|
|
self.mirror_enter();
|
|
|
|
|
|
}
|
|
|
|
|
|
if std::mem::take(&mut self.mir.leave) && self.mir.on {
|
|
|
|
|
|
self.mirror_leave();
|
|
|
|
|
|
}
|
|
|
|
|
|
if !self.mir.on {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
let mut got = vec![];
|
|
|
|
|
|
if let Some(rx) = &self.mir.rx {
|
|
|
|
|
|
while let Ok(m) = rx.try_recv() {
|
|
|
|
|
|
got.push(m);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
for m in got {
|
|
|
|
|
|
match m {
|
|
|
|
|
|
Incoming::Offline(e) => {
|
|
|
|
|
|
self.mir.online = false;
|
|
|
|
|
|
self.mir.base = Value::Null;
|
2026-11-04 11:07:55 +00:00
|
|
|
|
self.mir.down_since.get_or_insert_with(Instant::now);
|
|
|
|
|
|
*self.mir.status.lock().unwrap() = format!("連不到大腦:{e}");
|
|
|
|
|
|
self.mir.reason = e;
|
2026-11-04 09:46:43 +00:00
|
|
|
|
}
|
2026-11-04 11:30:32 +00:00
|
|
|
|
Incoming::File(path) => {
|
|
|
|
|
|
self.mir.fetching.remove(&path);
|
|
|
|
|
|
self.att.thumbs.remove(&path);
|
|
|
|
|
|
}
|
2026-11-04 09:46:43 +00:00
|
|
|
|
Incoming::Pull(v) => {
|
|
|
|
|
|
if let Some(e) = v["error"].as_str() {
|
|
|
|
|
|
self.mir.online = false;
|
2026-11-04 11:07:55 +00:00
|
|
|
|
self.mir.down_since.get_or_insert_with(Instant::now);
|
2026-11-04 09:46:43 +00:00
|
|
|
|
*self.mir.status.lock().unwrap() = e.to_string();
|
2026-11-04 11:07:55 +00:00
|
|
|
|
self.mir.reason = e.to_string();
|
2026-11-04 09:46:43 +00:00
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
self.mir.online = true;
|
2026-11-04 11:07:55 +00:00
|
|
|
|
self.mir.down_since = None;
|
|
|
|
|
|
self.mir.reason.clear();
|
2026-11-04 09:46:43 +00:00
|
|
|
|
*self.mir.status.lock().unwrap() = format!("已連到大腦「{}」", v["brain"].as_str().unwrap_or(&self.state.remote.brain_name));
|
|
|
|
|
|
self.mirror_apply(v);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn mirror_apply(&mut self, v: Value) {
|
2026-11-04 11:07:55 +00:00
|
|
|
|
// Edits the brain has taken in are no longer "on the way".
|
|
|
|
|
|
let acked = v["edit_seq"].as_u64().unwrap_or(0);
|
|
|
|
|
|
self.mir.pending.lock().unwrap().retain(|(s, _)| *s > acked);
|
2026-11-04 09:46:43 +00:00
|
|
|
|
if v["config"].is_object() {
|
2026-11-04 11:07:55 +00:00
|
|
|
|
// What the brain will have once the edits on the way arrive, and on top of that what
|
|
|
|
|
|
// the person changed here and hasn't sent yet.
|
|
|
|
|
|
let inflight: Vec<Value> = self.mir.pending.lock().unwrap().iter().flat_map(|(_, o)| o.clone()).collect();
|
|
|
|
|
|
let unsaved = if self.mir.base.is_object() { diff_ops(&self.mir.base, &config_json(&mut self.state)) } else { vec![] };
|
|
|
|
|
|
let mut base = v["config"].clone();
|
|
|
|
|
|
apply_ops(&mut base, &inflight);
|
|
|
|
|
|
let mut cfg = base.clone();
|
|
|
|
|
|
apply_ops(&mut cfg, &unsaved);
|
|
|
|
|
|
if !unsaved.is_empty() {
|
|
|
|
|
|
self.dirty = true;
|
|
|
|
|
|
}
|
2026-11-04 09:46:43 +00:00
|
|
|
|
let local = std::mem::replace(&mut self.state, State::first_run());
|
|
|
|
|
|
match serde_json::from_value::<State>(cfg.clone()) {
|
|
|
|
|
|
Ok(mut st) => {
|
|
|
|
|
|
// Chats stay as they are (they come separately).
|
|
|
|
|
|
let mut old = local.bots.clone();
|
|
|
|
|
|
for b in st.bots.iter_mut() {
|
|
|
|
|
|
if let Some(o) = old.iter_mut().find(|o| o.id == b.id) {
|
|
|
|
|
|
b.messages = std::mem::take(&mut o.messages);
|
|
|
|
|
|
b.threads = std::mem::take(&mut o.threads);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
self.state = st;
|
|
|
|
|
|
self.keep_local_fields(&local);
|
2026-11-04 11:07:55 +00:00
|
|
|
|
self.mir.base = base;
|
2026-11-04 09:46:43 +00:00
|
|
|
|
}
|
|
|
|
|
|
Err(e) => {
|
|
|
|
|
|
self.state = local;
|
|
|
|
|
|
*self.mir.error.lock().unwrap() = format!("大腦的設定讀不出來(兩邊 AnyBot 版本不同?):{e}");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
for d in v["bots"].as_array().into_iter().flatten() {
|
|
|
|
|
|
let id = d["id"].as_u64().unwrap_or(0);
|
|
|
|
|
|
let from = d["from"].as_u64().unwrap_or(0) as usize;
|
2026-11-04 11:30:32 +00:00
|
|
|
|
let mut msgs: Vec<Message> = serde_json::from_value(d["messages"].clone()).unwrap_or_default();
|
|
|
|
|
|
self.mirror_files(&mut msgs);
|
2026-11-04 09:46:43 +00:00
|
|
|
|
if let Some(b) = self.state.bot_mut(id) {
|
|
|
|
|
|
b.messages.truncate(from);
|
|
|
|
|
|
b.messages.extend(msgs);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
for d in v["threads"].as_array().into_iter().flatten() {
|
|
|
|
|
|
if let Some(b) = self.state.bot_mut(d["id"].as_u64().unwrap_or(0)) {
|
|
|
|
|
|
b.threads = serde_json::from_value(d["threads"].clone()).unwrap_or_default();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// Who is working, and what waits for the person.
|
|
|
|
|
|
let live: HashMap<u64, (Option<String>, bool)> = v["live"].as_object().into_iter().flatten().filter_map(|(k, l)| Some((k.parse().ok()?, (l["status"].as_str().map(String::from), l["paused"].as_bool().unwrap_or(false))))).collect();
|
|
|
|
|
|
self.streams.retain(|id, _| live.contains_key(id));
|
|
|
|
|
|
for (id, (status, paused)) in live {
|
|
|
|
|
|
let l = self.streams.entry(id).or_insert_with(|| Live {
|
|
|
|
|
|
cancel: Arc::new(AtomicBool::new(false)),
|
|
|
|
|
|
started: Instant::now(),
|
|
|
|
|
|
last: Instant::now(),
|
|
|
|
|
|
produced: true,
|
|
|
|
|
|
status: None,
|
|
|
|
|
|
paused: false,
|
|
|
|
|
|
steer: Arc::default(),
|
|
|
|
|
|
turn_id: String::new(),
|
|
|
|
|
|
direct: Arc::default(),
|
|
|
|
|
|
});
|
|
|
|
|
|
l.status = status;
|
|
|
|
|
|
l.paused = paused;
|
|
|
|
|
|
l.last = Instant::now();
|
|
|
|
|
|
}
|
|
|
|
|
|
let mut waiting = HashMap::new();
|
|
|
|
|
|
for a in v["approvals"].as_array().into_iter().flatten() {
|
|
|
|
|
|
let id = a["bot"].as_u64().unwrap_or(0);
|
|
|
|
|
|
let kind = match a["kind"].as_str().unwrap_or("") {
|
|
|
|
|
|
"tools" => ApprovalKind::EnableTools,
|
|
|
|
|
|
"local" => ApprovalKind::EnableThisComputer,
|
|
|
|
|
|
_ => ApprovalKind::Action,
|
|
|
|
|
|
};
|
|
|
|
|
|
let (reply, _) = channel();
|
|
|
|
|
|
let s = |k: &str| a[k].as_str().unwrap_or("").to_string();
|
|
|
|
|
|
waiting.insert(id, Approval { call_id: s("call_id"), tool: s("tool"), reason: s("reason"), grant: s("grant"), reply, kind });
|
|
|
|
|
|
}
|
|
|
|
|
|
let ctx = self.ctx_handle.clone();
|
|
|
|
|
|
for (id, a) in waiting {
|
|
|
|
|
|
let new = self.approvals.get(&id).map(|o| o.call_id != a.call_id).unwrap_or(true);
|
|
|
|
|
|
self.approvals.insert(id, a);
|
|
|
|
|
|
if new {
|
|
|
|
|
|
if let Some(c) = &ctx {
|
|
|
|
|
|
self.on_new_approval(c, id);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
let keep: Vec<u64> = v["approvals"].as_array().into_iter().flatten().filter_map(|a| a["bot"].as_u64()).collect();
|
|
|
|
|
|
self.approvals.retain(|id, _| keep.contains(id));
|
|
|
|
|
|
if self.state.bot(self.state.selected).is_none() {
|
|
|
|
|
|
self.state.selected = self.state.primary_bot;
|
|
|
|
|
|
}
|
|
|
|
|
|
self.mirror_save_copy();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-04 11:30:32 +00:00
|
|
|
|
/// Files in the brain's chats live on the brain: show local copies, fetched once.
|
|
|
|
|
|
fn mirror_files(&mut self, msgs: &mut [Message]) {
|
|
|
|
|
|
for a in msgs.iter_mut().flat_map(|m| m.attachments.iter_mut()) {
|
|
|
|
|
|
if std::path::Path::new(&a.path).exists() {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
let local = local_copy(&a.path, &a.name);
|
|
|
|
|
|
let lp = local.display().to_string();
|
|
|
|
|
|
let brain_path = std::mem::replace(&mut a.path, lp.clone());
|
|
|
|
|
|
if local.exists() || !self.mir.fetching.insert(lp.clone()) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
let (s, tx, repaint) = (self.state.remote.clone(), self.mir.tx.clone(), self.repaint_fn());
|
|
|
|
|
|
std::thread::spawn(move || {
|
|
|
|
|
|
if let Ok(bytes) = remote::sync_file(&s, &brain_path) {
|
|
|
|
|
|
let _ = std::fs::create_dir_all(local.parent().unwrap_or(std::path::Path::new(".")));
|
|
|
|
|
|
let tmp = local.with_extension("part");
|
|
|
|
|
|
if std::fs::write(&tmp, bytes).is_ok() {
|
|
|
|
|
|
let _ = std::fs::rename(&tmp, &local);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(tx) = tx {
|
|
|
|
|
|
let _ = tx.send(Incoming::File(lp));
|
|
|
|
|
|
}
|
|
|
|
|
|
repaint();
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-04 09:46:43 +00:00
|
|
|
|
fn mirror_save_copy(&mut self) {
|
|
|
|
|
|
if let Ok(s) = serde_json::to_string(&self.state) {
|
|
|
|
|
|
let p = cache_path();
|
|
|
|
|
|
let tmp = p.with_extension("tmp");
|
|
|
|
|
|
if std::fs::write(&tmp, s).is_ok() {
|
|
|
|
|
|
let _ = std::fs::rename(&tmp, &p);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Saving on a screen: edits go to the brain; this computer's own file keeps its own bots.
|
|
|
|
|
|
pub(super) fn mirror_save(&mut self) {
|
|
|
|
|
|
if let Some(local) = self.mir.local.as_mut() {
|
|
|
|
|
|
local.remote = self.state.remote.clone();
|
|
|
|
|
|
local.theme = self.state.theme;
|
|
|
|
|
|
local.accent = self.state.accent;
|
|
|
|
|
|
local.sidebar_collapsed = self.state.sidebar_collapsed;
|
|
|
|
|
|
local.sidebar_width = self.state.sidebar_width;
|
|
|
|
|
|
local.machine_label = self.state.machine_label.clone();
|
|
|
|
|
|
local.local_execution = self.state.local_execution;
|
|
|
|
|
|
local.local_never = self.state.local_never;
|
|
|
|
|
|
local.messages_send = self.state.messages_send;
|
|
|
|
|
|
local.messages_no_ask = self.state.messages_no_ask;
|
|
|
|
|
|
local.save();
|
|
|
|
|
|
}
|
|
|
|
|
|
if !self.mir.online || !self.mir.base.is_object() {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
let cur = config_json(&mut self.state);
|
|
|
|
|
|
let ops = diff_ops(&self.mir.base, &cur);
|
|
|
|
|
|
if ops.is_empty() {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
self.mir.base = cur;
|
2026-11-04 11:07:55 +00:00
|
|
|
|
self.mir.seq += 1;
|
|
|
|
|
|
let seq = self.mir.seq;
|
|
|
|
|
|
self.mir.pending.lock().unwrap().push((seq, ops.clone()));
|
|
|
|
|
|
self.mirror_out(Out::Edit { seq, ops });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn mirror_out(&mut self, o: Out) {
|
|
|
|
|
|
if let Some(tx) = &self.mir.outbox {
|
|
|
|
|
|
let _ = tx.send(o);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// One worker sends everything to the brain in order (a rename before the message that uses
|
|
|
|
|
|
/// it, two quick edits in the order they were made).
|
|
|
|
|
|
fn mirror_outbox(&mut self) {
|
|
|
|
|
|
let (tx, rx) = channel::<Out>();
|
|
|
|
|
|
self.mir.outbox = Some(tx);
|
|
|
|
|
|
let (s, err, client, pending, resync) = (self.state.remote.clone(), self.mir.error.clone(), self.mir.client.clone(), self.mir.pending.clone(), self.mir.resync.clone());
|
|
|
|
|
|
let (note, busy) = (self.mir.move_note.clone(), self.mir.moving.clone());
|
|
|
|
|
|
let repaint = self.repaint_fn();
|
2026-11-04 09:46:43 +00:00
|
|
|
|
std::thread::spawn(move || {
|
2026-11-04 11:07:55 +00:00
|
|
|
|
for o in rx {
|
|
|
|
|
|
match o {
|
|
|
|
|
|
Out::Edit { seq, ops } => {
|
|
|
|
|
|
if let Err(e) = remote::sync_post(&s, "edit", &json!({"ops": ops, "client": client, "seq": seq})) {
|
|
|
|
|
|
*err.lock().unwrap() = format!("剛才的改動沒有存到大腦:{e}");
|
|
|
|
|
|
pending.lock().unwrap().retain(|(n, _)| *n != seq);
|
|
|
|
|
|
resync.store(true, Ordering::SeqCst);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Out::Act(body) => {
|
|
|
|
|
|
if let Err(e) = remote::sync_post(&s, "act", &body) {
|
|
|
|
|
|
*err.lock().unwrap() = format!("大腦沒有收到:{e}");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Out::Send { bot, text, atts } => {
|
|
|
|
|
|
let mut paths = vec![];
|
|
|
|
|
|
for a in &atts {
|
|
|
|
|
|
match std::fs::read(&a.path).map_err(|e| format!("讀不到這個檔案({e})")).and_then(|b| remote::sync_upload(&s, bot, &a.name, &b)) {
|
|
|
|
|
|
Ok(p) => paths.push(p),
|
|
|
|
|
|
Err(e) => *err.lock().unwrap() = format!("「{}」沒有傳到大腦:{e}", a.name),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Err(e) = remote::sync_post(&s, "act", &json!({"bot": bot, "action": "send", "text": text, "attachments": paths})) {
|
|
|
|
|
|
*err.lock().unwrap() = format!("訊息沒有送到大腦:{e}");
|
|
|
|
|
|
// Take back the message shown ahead of time.
|
|
|
|
|
|
resync.store(true, Ordering::SeqCst);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Out::Move { ops, n } => {
|
|
|
|
|
|
*note.lock().unwrap() = match remote::sync_post(&s, "edit", &json!({"ops": ops})) {
|
2026-11-04 11:30:32 +00:00
|
|
|
|
Ok(_) if n == 0 => "大腦上已經有這台的 Bot 了;模型金鑰、技能和連接器有缺的已補上".into(),
|
|
|
|
|
|
Ok(_) => format!("已把 {n} 個 Bot(連同聊天)搬到大腦"),
|
2026-11-04 11:07:55 +00:00
|
|
|
|
Err(e) => format!("搬移失敗:{e}"),
|
|
|
|
|
|
};
|
|
|
|
|
|
busy.store(false, Ordering::SeqCst);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
repaint();
|
2026-11-04 09:46:43 +00:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// A chat action on a screen goes to the brain.
|
|
|
|
|
|
pub(super) fn mirror_act(&mut self, body: Value) {
|
|
|
|
|
|
if !self.mir.online {
|
|
|
|
|
|
*self.mir.error.lock().unwrap() = "連不到大腦,現在只能看。".into();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-11-04 11:07:55 +00:00
|
|
|
|
self.mirror_out(Out::Act(body));
|
2026-11-04 09:46:43 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Send a message through the brain: files go up first, then the text.
|
|
|
|
|
|
pub(super) fn mirror_send(&mut self, bot: u64, text: String, atts: Vec<crate::attach::Attachment>) -> bool {
|
|
|
|
|
|
if !self.mir.online {
|
|
|
|
|
|
*self.mir.error.lock().unwrap() = "連不到大腦,訊息沒有送出。".into();
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Shown at once; the brain's copy replaces it.
|
|
|
|
|
|
if !self.streams.contains_key(&bot) {
|
|
|
|
|
|
if let Some(b) = self.state.bot_mut(bot) {
|
|
|
|
|
|
b.messages.push(Message { attachments: atts.clone(), ..Message::user(&text) });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-11-04 11:07:55 +00:00
|
|
|
|
// Settings changed just before go first.
|
|
|
|
|
|
if self.dirty {
|
|
|
|
|
|
self.mirror_save();
|
|
|
|
|
|
}
|
|
|
|
|
|
self.mirror_out(Out::Send { bot, text, atts });
|
2026-11-04 09:46:43 +00:00
|
|
|
|
true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 把這台的 Bot 搬到大腦: copy this computer's own bots (with their chats), keys, skills and
|
|
|
|
|
|
/// connectors to the brain, as new bots there.
|
|
|
|
|
|
pub(super) fn mirror_move_local(&mut self) {
|
|
|
|
|
|
let Some(local) = self.mir.local.clone() else { return };
|
|
|
|
|
|
if self.mir.moving.swap(true, Ordering::SeqCst) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
let mut ops: Vec<Value> = vec![];
|
2026-11-04 11:30:32 +00:00
|
|
|
|
// Skip what the brain already has: the same starter bot never used here, or a bot moved
|
|
|
|
|
|
// before (same name, same chat).
|
|
|
|
|
|
let last = |b: &Bot| b.messages.last().map(|m| (m.at, m.text.clone()));
|
|
|
|
|
|
let moving: Vec<&Bot> = local
|
|
|
|
|
|
.bots
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.filter(|b| !self.state.bots.iter().any(|o| o.name == b.name && (b.messages.is_empty() || (o.messages.len() == b.messages.len() && last(o) == last(b)))))
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
for b in moving.iter().copied() {
|
2026-11-04 09:46:43 +00:00
|
|
|
|
let mut v = serde_json::to_value(b).unwrap_or(Value::Null);
|
|
|
|
|
|
v["id"] = json!(0);
|
|
|
|
|
|
ops.push(json!({"op": "bot_add", "bot": v}));
|
|
|
|
|
|
}
|
|
|
|
|
|
let top = serde_json::to_value(&local).unwrap_or(Value::Null);
|
|
|
|
|
|
for k in ["providers", "skills", "mcp_servers"] {
|
|
|
|
|
|
ops.push(json!({"op": "merge", "key": k, "value": top[k]}));
|
|
|
|
|
|
}
|
2026-11-04 11:30:32 +00:00
|
|
|
|
let n = moving.len();
|
2026-11-04 11:07:55 +00:00
|
|
|
|
*self.mir.move_note.lock().unwrap() = "搬移中…".into();
|
|
|
|
|
|
self.mirror_out(Out::Move { ops, n });
|
2026-11-04 09:46:43 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The bar above the chat while showing a brain.
|
|
|
|
|
|
pub(super) fn mirror_banner(&mut self, ui: &mut egui::Ui) {
|
|
|
|
|
|
if !self.mir.on {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
let p = self.pal;
|
|
|
|
|
|
let err = std::mem::take(&mut *self.mir.error.lock().unwrap());
|
|
|
|
|
|
if !err.is_empty() {
|
|
|
|
|
|
self.notice = Some(err);
|
|
|
|
|
|
}
|
|
|
|
|
|
if self.mir.online {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-11-04 11:07:55 +00:00
|
|
|
|
// A blip (the account server restarting) isn't worth a warning.
|
|
|
|
|
|
let connecting = self.mir.down_since.is_none();
|
|
|
|
|
|
let (title, reason, tail) = match self.mir.down_since {
|
|
|
|
|
|
None => (format!("正在連到大腦「{}」…", self.state.remote.brain_name), String::new(), String::new()),
|
|
|
|
|
|
Some(t) if t.elapsed().as_secs() < 4 => return,
|
|
|
|
|
|
Some(_) => (format!("連不到大腦「{}」", self.state.remote.brain_name), self.mir.reason.clone(), t("之前的對話還能看,但現在不能傳訊息。連回來後會自動恢復。")),
|
|
|
|
|
|
};
|
|
|
|
|
|
ui.ctx().request_repaint_after(std::time::Duration::from_secs(1));
|
2026-11-04 09:46:43 +00:00
|
|
|
|
ui.vertical_centered(|ui| {
|
|
|
|
|
|
ui.set_max_width(TRANSCRIPT_MAX_WIDTH.min(ui.available_width() - 48.0));
|
2026-11-04 11:07:55 +00:00
|
|
|
|
Frame::new().fill(if connecting { p.hover } else { p.danger.gamma_multiply(0.08) }).corner_radius(8).inner_margin(Margin::symmetric(12, 8)).show(ui, |ui| {
|
|
|
|
|
|
ui.vertical(|ui| {
|
|
|
|
|
|
ui.label(RichText::new(title).size(FONT_SM).strong().color(p.text));
|
|
|
|
|
|
for line in [reason, tail] {
|
|
|
|
|
|
if !line.is_empty() {
|
|
|
|
|
|
ui.add(egui::Label::new(RichText::new(line).size(FONT_SM).color(p.text2)).wrap());
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-11-04 09:46:43 +00:00
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
ui.add_space(6.0);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod tests {
|
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn edits_become_small_operations() {
|
|
|
|
|
|
let base = json!({"theme": "light", "folders": ["a"], "bots": [{"id": 1, "name": "A", "unread": true}, {"id": 2, "name": "B"}]});
|
|
|
|
|
|
let cur = json!({"theme": "dark", "folders": ["a", "b"], "bots": [{"id": 1, "name": "A2", "unread": false}, {"id": 3, "name": "C"}]});
|
|
|
|
|
|
let ops = diff_ops(&base, &cur);
|
|
|
|
|
|
// theme is this computer's own; folders changed.
|
2026-11-04 11:30:32 +00:00
|
|
|
|
assert!(ops.iter().any(|o| o["op"] == "patch" && o["key"] == "folders" && o["patch"]["list"]["put"] == json!(["b"])));
|
2026-11-04 09:46:43 +00:00
|
|
|
|
assert!(!ops.iter().any(|o| o["key"] == "theme"));
|
|
|
|
|
|
assert!(ops.iter().any(|o| o["op"] == "bot_set" && o["key"] == "name" && o["value"] == "A2"));
|
|
|
|
|
|
assert!(ops.iter().any(|o| o["op"] == "bot_read" && o["id"] == 1));
|
|
|
|
|
|
assert!(ops.iter().any(|o| o["op"] == "bot_add" && o["bot"]["id"] == 3));
|
|
|
|
|
|
assert!(ops.iter().any(|o| o["op"] == "bot_del" && o["id"] == 2));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn keys_stay_on_the_brain() {
|
|
|
|
|
|
let mut v = json!({"remote": {"host_token": "x"}, "providers": {"xai": {"api_key": "sk-1"}, "openai": {"api_key": ""}}, "channels": [{"id": 4, "token": "t"}]});
|
|
|
|
|
|
let old = v.clone();
|
|
|
|
|
|
redact(&mut v);
|
|
|
|
|
|
assert!(v.get("remote").is_none());
|
|
|
|
|
|
assert_eq!(v["providers"]["xai"]["api_key"], REDACTED);
|
|
|
|
|
|
assert_eq!(v["providers"]["openai"]["api_key"], "");
|
|
|
|
|
|
assert_eq!(v["channels"][0]["token"], REDACTED);
|
|
|
|
|
|
// An edit that kept the stand-in keeps the real key.
|
|
|
|
|
|
let mut edit = v["providers"].clone();
|
|
|
|
|
|
edit["openai"]["api_key"] = json!("sk-new");
|
|
|
|
|
|
unredact(&mut edit, &old["providers"]);
|
|
|
|
|
|
assert_eq!(edit["xai"]["api_key"], "sk-1");
|
|
|
|
|
|
assert_eq!(edit["openai"]["api_key"], "sk-new");
|
|
|
|
|
|
let mut ch = v["channels"].clone();
|
|
|
|
|
|
unredact(&mut ch, &old["channels"]);
|
|
|
|
|
|
assert_eq!(ch[0]["token"], "t");
|
|
|
|
|
|
}
|
2026-11-04 11:07:55 +00:00
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn edits_replay_on_a_newer_copy() {
|
|
|
|
|
|
let base = json!({"folders": ["a"], "bots": [{"id": 1, "name": "A", "unread": true}, {"id": 2, "name": "B"}]});
|
|
|
|
|
|
let cur = json!({"folders": ["a", "b"], "bots": [{"id": 2, "name": "B"}, {"id": 1, "name": "A2", "unread": false}, {"id": 3, "name": "C"}]});
|
|
|
|
|
|
let ops = diff_ops(&base, &cur);
|
|
|
|
|
|
let mut again = base.clone();
|
|
|
|
|
|
apply_ops(&mut again, &ops);
|
|
|
|
|
|
assert_eq!(again, cur);
|
|
|
|
|
|
// The brain meanwhile renamed bot 2: the screen's own edit stays, the brain's shows too.
|
|
|
|
|
|
let mut newer = json!({"folders": ["a"], "bots": [{"id": 1, "name": "A", "unread": true}, {"id": 2, "name": "B from brain"}]});
|
|
|
|
|
|
apply_ops(&mut newer, &ops);
|
|
|
|
|
|
assert_eq!(newer["bots"][1]["name"], "A2");
|
|
|
|
|
|
assert_eq!(newer["bots"][0]["name"], "B from brain");
|
|
|
|
|
|
assert_eq!(newer["folders"], json!(["a", "b"]));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn an_old_copy_cant_make_a_routine_run_twice() {
|
|
|
|
|
|
let brain = json!([{"id": 7, "schedule": "daily", "trigger": null, "paused": false, "last_run": 200, "next_run": 300}]);
|
|
|
|
|
|
let mut edit = json!([{"id": 7, "title": "new", "schedule": "daily", "trigger": null, "paused": false, "last_run": 100, "next_run": 150}]);
|
|
|
|
|
|
keep_run_times(&mut edit, &brain);
|
|
|
|
|
|
assert_eq!(edit[0]["last_run"], 200);
|
|
|
|
|
|
assert_eq!(edit[0]["next_run"], 300);
|
|
|
|
|
|
assert_eq!(edit[0]["title"], "new");
|
|
|
|
|
|
// A new schedule gets worked out again.
|
|
|
|
|
|
let mut moved = json!([{"id": 7, "schedule": "weekly", "trigger": null, "paused": false, "last_run": 100, "next_run": null}]);
|
|
|
|
|
|
keep_run_times(&mut moved, &brain);
|
|
|
|
|
|
assert_eq!(moved[0]["last_run"], 200);
|
|
|
|
|
|
assert!(moved[0]["next_run"].is_null());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn failures_read_as_words_not_codes() {
|
|
|
|
|
|
let w = remote::why_refused(502, &Value::Null, "大腦");
|
|
|
|
|
|
assert!(!w.contains("502") && w.contains("帳號伺服器"));
|
|
|
|
|
|
let off = remote::why_refused(503, &json!({"error": "那台 AnyBot 目前離線(電腦關機、睡眠,或 AnyBot 沒開)"}), "大腦");
|
|
|
|
|
|
assert!(off.starts_with("大腦目前離線"));
|
|
|
|
|
|
assert!(remote::why_refused(401, &Value::Null, "大腦").contains("重新登入"));
|
|
|
|
|
|
assert!(remote::why_unreachable(&ureq::Error::HostNotFound).contains("網路"));
|
|
|
|
|
|
}
|
2026-11-04 11:30:32 +00:00
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn two_computers_edit_one_list_without_undoing_each_other() {
|
|
|
|
|
|
let base = json!({"skills": [{"name": "a", "on": true}, {"name": "b", "on": true}], "providers": {"xai": {"model": "1"}, "openai": {"model": "1"}}, "bots": [{"id": 1, "memories": ["x", "y"]}]});
|
|
|
|
|
|
// Laptop turns skill a off, changes xai, forgets memory x.
|
|
|
|
|
|
let mut laptop = base.clone();
|
|
|
|
|
|
laptop["skills"][0]["on"] = json!(false);
|
|
|
|
|
|
laptop["providers"]["xai"]["model"] = json!("2");
|
|
|
|
|
|
laptop["bots"][0]["memories"] = json!(["y"]);
|
|
|
|
|
|
// Meanwhile the desk computer turned skill b off, changed openai, and the bot learned z.
|
|
|
|
|
|
let mut brain = base.clone();
|
|
|
|
|
|
brain["skills"][1]["on"] = json!(false);
|
|
|
|
|
|
brain["skills"].as_array_mut().unwrap().push(json!({"name": "c", "on": true}));
|
|
|
|
|
|
brain["providers"]["openai"]["model"] = json!("3");
|
|
|
|
|
|
brain["bots"][0]["memories"] = json!(["x", "y", "z"]);
|
|
|
|
|
|
let ops = diff_ops(&base, &laptop);
|
|
|
|
|
|
assert!(ops.iter().all(|o| o["op"] != "set" || o["key"] != "skills"), "{ops:?}");
|
|
|
|
|
|
apply_ops(&mut brain, &ops);
|
|
|
|
|
|
assert_eq!(brain["skills"], json!([{"name": "a", "on": false}, {"name": "b", "on": false}, {"name": "c", "on": true}]));
|
|
|
|
|
|
assert_eq!(brain["providers"], json!({"xai": {"model": "2"}, "openai": {"model": "3"}}));
|
|
|
|
|
|
assert_eq!(brain["bots"][0]["memories"], json!(["y", "z"]));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn lists_without_names_are_sent_whole() {
|
|
|
|
|
|
let p = patch_of(&json!([[1], [2]]), &json!([[2]]));
|
|
|
|
|
|
assert_eq!(p, json!({"value": [[2]]}));
|
|
|
|
|
|
let mut cur = json!([{"id": 1}, {"id": 2}]);
|
|
|
|
|
|
apply_patch(&mut cur, &patch_of(&json!([{"id": 1}, {"id": 2}]), &json!([{"id": 2}, {"id": 1}])));
|
|
|
|
|
|
assert_eq!(cur, json!([{"id": 2}, {"id": 1}]));
|
|
|
|
|
|
}
|
2026-11-04 09:46:43 +00:00
|
|
|
|
}
|