AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
// Data kept between runs: bots, their chats, provider settings and look.
|
|
|
|
|
|
|
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
|
use std::path::PathBuf;
|
|
|
|
|
|
|
|
|
|
|
|
use crate::providers::ProviderKind;
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Debug, Default)]
|
|
|
|
|
|
pub enum Role {
|
|
|
|
|
|
#[default]
|
|
|
|
|
|
User,
|
|
|
|
|
|
Assistant,
|
|
|
|
|
|
/// The result of one tool call, sent back to the model.
|
|
|
|
|
|
Tool,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Debug, Default)]
|
|
|
|
|
|
pub enum ToolStatus {
|
|
|
|
|
|
Waiting,
|
|
|
|
|
|
Running,
|
|
|
|
|
|
#[default]
|
|
|
|
|
|
Done,
|
|
|
|
|
|
Denied,
|
|
|
|
|
|
Failed,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Serialize, Deserialize, Debug)]
|
|
|
|
|
|
pub struct ToolCall {
|
|
|
|
|
|
pub id: String,
|
|
|
|
|
|
pub name: String,
|
|
|
|
|
|
/// The arguments as the model wrote them (JSON text).
|
|
|
|
|
|
pub args: String,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub status: ToolStatus,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Serialize, Deserialize, Debug, Default)]
|
|
|
|
|
|
pub struct Message {
|
|
|
|
|
|
pub role: Role,
|
|
|
|
|
|
pub text: String,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub thinking: String,
|
|
|
|
|
|
/// Which provider and model wrote this reply (shown under it).
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub via: String,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub error: bool,
|
|
|
|
|
|
/// Tools the model asked to run in this reply.
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
|
|
|
|
pub tool_calls: Vec<ToolCall>,
|
|
|
|
|
|
/// For Role::Tool: which call this answers.
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
|
|
|
|
|
pub tool_call_id: String,
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
|
|
|
|
|
pub tool_name: String,
|
|
|
|
|
|
/// Screenshots (base64 JPEG) shown to the model with this message. Not saved to disk.
|
|
|
|
|
|
#[serde(skip)]
|
|
|
|
|
|
pub images: Vec<String>,
|
|
|
|
|
|
/// A summary standing in for older messages (context compaction).
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub summary: bool,
|
|
|
|
|
|
/// Claude's own content blocks (thinking signatures, tool_use), replayed unchanged.
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
|
|
|
|
pub blocks: Vec<serde_json::Value>,
|
|
|
|
|
|
/// A routine (or another bot) woke the bot up: starts a turn like a person's message.
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub wake: bool,
|
|
|
|
|
|
/// Sent while the bot was working: steers the running turn instead of starting a new one.
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub steer: bool,
|
|
|
|
|
|
/// In a group chat: which bot wrote this answer (0 = the chat's own bot).
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "is_zero")]
|
|
|
|
|
|
pub author: u64,
|
|
|
|
|
|
/// 表情回應 the person put on this message.
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
|
|
|
|
pub reactions: Vec<String>,
|
|
|
|
|
|
/// Files and pictures the person attached, or files the bot sent (kept on disk).
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
|
|
|
|
pub attachments: Vec<crate::attach::Attachment>,
|
|
|
|
|
|
/// When it was written (ms since 1970; 0 for messages from before this was kept).
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "is_zero_i64")]
|
|
|
|
|
|
pub at: i64,
|
A turn cut off by a crash leaves a 「繼續」 card, and nothing it may have done runs twice
Each bot keeps a work log (oplog/bot-<id>.jsonl, after pi's durable-harness design, cut down):
before something happens a line says it is about to — the turn, each model call, each step once
its checks passed (with the arguments it runs with and whether it may run again), each message
typed mid-turn — synced to disk first. When the turn ends the file shrinks to the messages still
waiting, usually nothing.
On the next start after AnyBot was killed mid-turn, the log puts the chat right before anything
runs: the message the turn answered comes back if the chat lost it; a step that was running and
reaches outside (a text, a click, a file, a published site) shows as 「可能已經執行了」, both to the
person and in the result the bot reads — no longer as "not allowed"; a step that only looked shows
as cut off; messages typed mid-turn that never reached the bot go back into the chat. The chat is
saved, then the log closes the turn. A turn the person didn't stop ends with a 「繼續」 card (what
it was doing, what may have run, the messages put back); it goes on only when they press it — in
the window, on a screen of the brain, or on the phone — from where its steps stand, never running
a step that may have run again. Pressing stop is on disk at once, and a stopped turn stays
stopped.
Also in the turn loop:
- 自動審核 that doesn't answer (or answers something that isn't a verdict) asks the person; in
直接操作 mode it used to let the step through unchecked.
- "Too long for this model" folds older messages into a summary and asks the same step again,
once per thing the person said, instead of ending the turn.
- Folding keeps the person's chat whole: the summary goes in at the fold and the model reads from
the newest summary on (it used to delete what it folded).
- A step moved to the chosen computer runs there, but the chat keeps the call as the model wrote
it; the result says where it ran.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha5F5gJt93t3yEx3XcS
2026-11-08 16:49:12 +00:00
|
|
|
|
/// A card where AnyBot stopping cut a turn off (put there at the next start, see
|
|
|
|
|
|
/// oplog::recover), with 「繼續」 to go on from there. Never sent to the model.
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
|
|
|
|
|
pub interrupted: bool,
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn is_zero_i64(n: &i64) -> bool {
|
|
|
|
|
|
*n == 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
A turn cut off by a crash leaves a 「繼續」 card, and nothing it may have done runs twice
Each bot keeps a work log (oplog/bot-<id>.jsonl, after pi's durable-harness design, cut down):
before something happens a line says it is about to — the turn, each model call, each step once
its checks passed (with the arguments it runs with and whether it may run again), each message
typed mid-turn — synced to disk first. When the turn ends the file shrinks to the messages still
waiting, usually nothing.
On the next start after AnyBot was killed mid-turn, the log puts the chat right before anything
runs: the message the turn answered comes back if the chat lost it; a step that was running and
reaches outside (a text, a click, a file, a published site) shows as 「可能已經執行了」, both to the
person and in the result the bot reads — no longer as "not allowed"; a step that only looked shows
as cut off; messages typed mid-turn that never reached the bot go back into the chat. The chat is
saved, then the log closes the turn. A turn the person didn't stop ends with a 「繼續」 card (what
it was doing, what may have run, the messages put back); it goes on only when they press it — in
the window, on a screen of the brain, or on the phone — from where its steps stand, never running
a step that may have run again. Pressing stop is on disk at once, and a stopped turn stays
stopped.
Also in the turn loop:
- 自動審核 that doesn't answer (or answers something that isn't a verdict) asks the person; in
直接操作 mode it used to let the step through unchecked.
- "Too long for this model" folds older messages into a summary and asks the same step again,
once per thing the person said, instead of ending the turn.
- Folding keeps the person's chat whole: the summary goes in at the fold and the model reads from
the newest summary on (it used to delete what it folded).
- A step moved to the chosen computer runs there, but the chat keeps the call as the model wrote
it; the result says where it ran.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha5F5gJt93t3yEx3XcS
2026-11-08 16:49:12 +00:00
|
|
|
|
/// Where the model's view of a chat starts: the newest summary (folding keeps the person's chat
|
|
|
|
|
|
/// whole; the model reads from the fold on).
|
|
|
|
|
|
pub fn model_view_start(msgs: &[Message]) -> usize {
|
|
|
|
|
|
msgs.iter().rposition(|m| m.summary).unwrap_or(0)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// A summary the turn made (it folded the first `n` messages of its view; `boundary` is the time
|
|
|
|
|
|
/// of the first one it kept) goes into the chat at the fold. Nothing is removed: the person's
|
|
|
|
|
|
/// chat stays whole and the model reads from the newest summary on. The model's view can be
|
|
|
|
|
|
/// shorter than the chat (group chats filter), so the fold lands at the real first-kept message,
|
|
|
|
|
|
/// not the raw index.
|
|
|
|
|
|
pub fn fold_in(msgs: &mut Vec<Message>, n: usize, summary: Message, boundary: i64) {
|
|
|
|
|
|
let from = model_view_start(msgs);
|
|
|
|
|
|
let cut = msgs.iter().position(|m| m.at == boundary && m.role == Role::User).filter(|&i| i > from).unwrap_or((from + n).min(msgs.len()));
|
|
|
|
|
|
// Pictures in what was folded never go to the model again (and aren't saved anyway).
|
|
|
|
|
|
for m in &mut msgs[..cut] {
|
|
|
|
|
|
m.images.clear();
|
|
|
|
|
|
}
|
|
|
|
|
|
msgs.insert(cut, summary);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
pub fn now_ms() -> i64 {
|
|
|
|
|
|
chrono::Utc::now().timestamp_millis()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// How a turn ended: the original's outcomes `["success","cancelled","error"]`
|
|
|
|
|
|
/// (node-agent-coordinator `wl`, read back by `k6` as {turnId, outcome, settledAtMs}).
|
|
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Debug)]
|
|
|
|
|
|
#[serde(rename_all = "lowercase")]
|
|
|
|
|
|
pub enum TurnOutcome {
|
|
|
|
|
|
Success,
|
|
|
|
|
|
Cancelled,
|
|
|
|
|
|
Error,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Serialize, Deserialize, Debug)]
|
|
|
|
|
|
pub struct TurnSettlement {
|
|
|
|
|
|
pub turn_id: String,
|
|
|
|
|
|
pub outcome: TurnOutcome,
|
|
|
|
|
|
pub settled_at_ms: i64,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Another of the person's computers, reached over SSH. The other two computers are built in:
|
|
|
|
|
|
/// the bot's own (a sealed folder, like the original's cloud "box") and this Mac (only when
|
|
|
|
|
|
/// 在這台電腦上執行 is on).
|
|
|
|
|
|
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
|
|
|
|
|
|
pub struct RemoteComputer {
|
|
|
|
|
|
pub name: String,
|
|
|
|
|
|
/// e.g. `me@studio.local`
|
|
|
|
|
|
pub ssh_target: String,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub dir: String,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn is_zero(n: &u64) -> bool {
|
|
|
|
|
|
*n == 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-05 08:47:28 +00:00
|
|
|
|
/// A bot's own computer as kept in `Bot::default_computer` (it survives renaming the bot). It is
|
|
|
|
|
|
/// shown and offered under the bot's name: see [`own_computer_name`].
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
pub const BOT_COMPUTER_NAME: &str = "Bot 的電腦";
|
2026-11-05 08:47:28 +00:00
|
|
|
|
/// What older versions kept for "the person's computer". Read as the computer the person is using
|
|
|
|
|
|
/// now; every computer is shown under its own name.
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
pub const THIS_COMPUTER_NAME: &str = "你的電腦";
|
2026-11-05 08:47:28 +00:00
|
|
|
|
|
|
|
|
|
|
/// What a bot calls its own computer: 「Master 的電腦」 for the bot Master. One plain name per
|
|
|
|
|
|
/// computer — 「Bot 的電腦」 and 「你的電腦」 meant different machines to the person and the bot.
|
|
|
|
|
|
pub fn own_computer_name(bot_name: &str) -> String {
|
|
|
|
|
|
let n = bot_name.trim();
|
|
|
|
|
|
if n.is_empty() {
|
|
|
|
|
|
BOT_COMPUTER_NAME.to_string()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
format!("{n} 的電腦")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
|
|
|
|
|
|
impl Message {
|
|
|
|
|
|
pub fn user(text: &str) -> Self {
|
|
|
|
|
|
Message { role: Role::User, text: text.to_string(), at: now_ms(), ..Default::default() }
|
|
|
|
|
|
}
|
|
|
|
|
|
pub fn assistant(text: &str, via: &str) -> Self {
|
|
|
|
|
|
Message { role: Role::Assistant, text: text.to_string(), via: via.to_string(), at: now_ms(), ..Default::default() }
|
|
|
|
|
|
}
|
|
|
|
|
|
pub fn tool_result(id: &str, name: &str, output: &str) -> Self {
|
|
|
|
|
|
Message { role: Role::Tool, text: output.to_string(), tool_call_id: id.to_string(), tool_name: name.to_string(), at: now_ms(), ..Default::default() }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Serialize, Deserialize, Debug)]
|
|
|
|
|
|
pub struct Bot {
|
|
|
|
|
|
pub id: u64,
|
|
|
|
|
|
pub name: String,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub description: String,
|
|
|
|
|
|
/// What the bot should keep in mind (sent as the system prompt).
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub instructions: String,
|
|
|
|
|
|
pub shape: String,
|
|
|
|
|
|
pub color: String,
|
|
|
|
|
|
pub provider: ProviderKind,
|
|
|
|
|
|
pub model: String,
|
Bots work like someone who knows the computer: they know each machine, reach what they see, plan, and hand the planned steps to a quick model
Measured on the bot's own box (8 tasks x 2, grok-4.7): 14/16 done, 120 steps, 1114 s before;
16/16, 102 steps, 574 s after (model time 1015 -> 471 s). A replayed skill: 49 s -> 15 s.
Knowing the machine
- facts.rs: 電腦現況 per computer (system, desktop, systemd, sudo, network, package manager,
tools, apps, a fixed TZ), read once by a small read-only probe, kept 30 min, in the
instructions at the start of a turn.
- The box's sudo is the bot's: only what follows sudo is checked against the always-denied
list (it was refused outright, while the instructions said it could sudo), and commands with
sudo run as the box's root (setuid does nothing under gVisor).
- Box image v8 drops ENV TZ=UTC (it overrode /etc/localtime). ANYBOT_BOX_IMAGE /
ANYBOT_BOX_VOLUME give a throwaway box its own image and volume, never adopting the real one.
- Mac shell sandbox: App Store app CLIs (they carry their own app sandbox) and ps/top run
outside it; plain `osascript -e` too, asked the first time even in direct mode, refusing do
shell script, terminals, keystrokes, file reads, ObjC and the secret folders. A command the
sandbox broke says so, instead of looking like a broken program.
Reaching what it sees
- The Mac menu bar's status icons are numbered (each app's AXExtrasMenuBar, named from inside
for the system's own), computer_use_zoom looks closer at full sharpness.
- Numbers stay with their controls from look to look (they were reassigned every look, so a
step's second click landed elsewhere); the box's front window is listed first; a control can
be named instead of numbered (name "4").
- Web page numbering walks open shadow DOM (Chromium settings, web components).
- A box on a Docker host elsewhere retries the next port when the usual one is taken (a retry
without it came up with no network).
Working like a person
- Instructions: pick the route, know what done looks like, one route, check once, say what's
stuck instead of faking it with a substitute, ask before cutting the connection.
- StepPlan: the bot's model takes the first step, the step after trouble (failed, refused,
repeated, nothing changed on screen, the quick model switching routes), one in four, and the
final check; a quick model (Bot.step_model, auto: grok-4.20-0309-non-reasoning on xAI) does
the planned steps in between, falling back to the bot's model if it can't be used.
- Skills keep the steps that worked (with what each acted on, not numbers); replay_skill does
them again with no model in between, finding each control again, handing back on a mismatch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha5F5gJt93t3yEx3XcS
2026-11-09 01:01:03 +00:00
|
|
|
|
/// 執行步驟用的模型: "" picks the provider's quick model (providers::step_model), "off" uses
|
|
|
|
|
|
/// `model` for every step, anything else is that model. The bot's own model plans, checks
|
|
|
|
|
|
/// and gets the hard steps; the quick one carries out the planned steps in between.
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
|
|
|
|
|
pub step_model: String,
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub pinned: bool,
|
|
|
|
|
|
/// 資料夾 in the sidebar (empty = 未分區), like the original's groups.
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
|
|
|
|
|
pub folder: String,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub messages: Vec<Message>,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub unread: bool,
|
|
|
|
|
|
/// Let this bot search, read and edit files and run commands in `workdir`.
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub tools_enabled: bool,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub workdir: String,
|
|
|
|
|
|
/// "Always allow" grants, e.g. `edit_file` or `shell:git commit`.
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub allow_rules: Vec<String>,
|
|
|
|
|
|
/// 群組聊天: the bots that answer here, in order (empty = an ordinary bot).
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
|
|
|
|
pub members: Vec<u64>,
|
|
|
|
|
|
/// 討論串: earlier conversations put aside with 「開始新對話」 (title, messages).
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
|
|
|
|
pub threads: Vec<(String, Vec<Message>)>,
|
|
|
|
|
|
/// 記憶: facts this bot keeps across chats.
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub memories: Vec<String>,
|
|
|
|
|
|
/// 語音聊天: read this bot's answers aloud.
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub speak_replies: bool,
|
|
|
|
|
|
/// 語音聊天 › 語音 (a macOS voice; empty = chosen by language), 速度 (1.0 = normal) and
|
|
|
|
|
|
/// 語言 (spoken language for speech-to-text; empty = detect).
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
|
|
|
|
|
pub voice: String,
|
|
|
|
|
|
#[serde(default = "one_f32")]
|
|
|
|
|
|
pub voice_rate: f32,
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
|
|
|
|
|
pub voice_lang: String,
|
|
|
|
|
|
/// 示範教學: tasks the person showed this bot on the Mac.
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub demos: Vec<crate::teach::Demo>,
|
|
|
|
|
|
/// How much the model can read at once, for deciding when to summarize.
|
|
|
|
|
|
#[serde(default = "default_context_tokens")]
|
|
|
|
|
|
pub context_tokens: u64,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub routines: Vec<crate::routines::Routine>,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub last_turn: Option<TurnSettlement>,
|
|
|
|
|
|
/// Name of the computer tools use unless told otherwise.
|
|
|
|
|
|
#[serde(default = "default_computer")]
|
|
|
|
|
|
pub default_computer: String,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn one_f32() -> f32 {
|
|
|
|
|
|
1.0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn default_computer() -> String {
|
|
|
|
|
|
BOT_COMPUTER_NAME.into()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub const INTERRUPTED_NOTE: &str = "(這次的回應被中斷了,可能是 App 關閉或重新啟動。按「重新產生」再試一次。)";
|
|
|
|
|
|
|
|
|
|
|
|
impl Bot {
|
Bug sweep: gates, relay 2FA, Windows input, snapshots, encodings
Permissions and reach
- a file leaving a computer passes the same questions as reading it;
"looking" shell commands that do more than look ask first
- a symlink out of the work/sealed folder counts as outside it
(real paths, not just lexical ones); the search walk skips links
- web_fetch and skill_install read the public web only: localhost, the
LAN and cloud metadata are refused, at every redirect
- preview shows a file without running it; browser_upload may ask
- vault: logins no longer match across shared suffixes (pages.dev,
com.tw, ...); registrable_domain knows public suffixes
- axwrite: found elements are retained (use-after-free)
Relay
- replacing or turning off 2FA needs the current code (rate-limited);
new recovery codes only count once enrollment is verified
- a removed computer loses its socket and its `call` access
- a site name taken by another account answers 409
- RELAY_TRUST_PROXY: the login lock counts the caller's address, not
the proxy's (compose files and README)
- httpd: half a body is not a request
Desktop
- sign-in, join and restore work with 2FA on (code field; --serve reads
ANYBOT_ACCOUNT_CODE; the setup wizard points to a pairing code)
- Windows: text was typed twice; modifiers are held through click, drag
and scroll; horizontal scroll; screen control is no longer refused
- a recording that hits the limit is transcribed, not thrown away
- bot's computer: a new home volume restores the snapshot; a snapshot
is read through before the home is wiped; a failed volume removal
stops reset/restore
- events of a stopped turn no longer land in the next one; each channel
message gets a turn of its own
- saving a bot's settings keeps what it got meanwhile (three-way merge);
a save file that cannot be read is kept
- pages in Big5/GBK/Shift_JIS are read in their own encoding
(encoding_rs); osascript output past 64 KB no longer stalls
- pick_tab keeps a job's own non-http tab; pictures larger than the
texture limit are fitted; the auto-update time field takes typing
- skills live in <data>/skills; memories are unnumbered in the prompt;
the phone may skillify and skill_install
CI
- the test step fails when the tests fail (it piped into tail)
Tests: anybot 268 passed, 27 ignored; relay 16 passed; clippy unchanged.
Not run: Windows on a real machine (win.rs type-checked alone), the
Docker paths of the bot's computer, the live accessibility writes.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 03:46:17 +00:00
|
|
|
|
/// 「儲存」 in the bot's settings: what the person changed there (`edited`, against `from`
|
|
|
|
|
|
/// — the bot as the editor opened it) is put onto the bot as it is now. Everything else
|
|
|
|
|
|
/// stays as it is now: a turn running meanwhile gave it messages, things to remember,
|
|
|
|
|
|
/// 「永遠允許」 rules, a routine, a conversation put aside. (The editor's copy used to replace
|
|
|
|
|
|
/// the bot whole, messages aside, and all of that was gone with the save.)
|
|
|
|
|
|
/// In a list of plain values (記得的事, 永遠允許, 群組成員) what was taken out goes and what
|
|
|
|
|
|
/// was added comes; the rest of the list is as it is now.
|
|
|
|
|
|
pub fn take_edit(&mut self, mut from: Bot, mut edited: Bot) {
|
|
|
|
|
|
use serde_json::Value;
|
|
|
|
|
|
let messages = std::mem::take(&mut self.messages);
|
|
|
|
|
|
from.messages.clear();
|
|
|
|
|
|
edited.messages.clear();
|
|
|
|
|
|
let fields = |b: &Bot| serde_json::to_value(b).ok().and_then(|v| v.as_object().cloned());
|
|
|
|
|
|
let merged = (|| {
|
|
|
|
|
|
let (mut now, from, edited) = (fields(self)?, fields(&from)?, fields(&edited)?);
|
|
|
|
|
|
let plain = |v: &Value| v.as_array().is_some_and(|a| a.iter().all(|x| x.is_string() || x.is_number()));
|
|
|
|
|
|
let keys: std::collections::BTreeSet<&String> = from.keys().chain(edited.keys()).collect();
|
|
|
|
|
|
for k in keys {
|
|
|
|
|
|
let (was, is) = (from.get(k), edited.get(k));
|
|
|
|
|
|
if was == is {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
match (was, is, now.get(k)) {
|
|
|
|
|
|
(Some(was), Some(is), Some(cur)) if plain(was) && plain(is) && plain(cur) => {
|
|
|
|
|
|
let (was, is) = (was.as_array().cloned().unwrap_or_default(), is.as_array().cloned().unwrap_or_default());
|
|
|
|
|
|
let mut list: Vec<Value> = cur.as_array().cloned().unwrap_or_default();
|
|
|
|
|
|
list.retain(|x| !was.contains(x) || is.contains(x));
|
|
|
|
|
|
for x in is.iter().filter(|x| !was.contains(x)) {
|
|
|
|
|
|
if !list.contains(x) {
|
|
|
|
|
|
list.push(x.clone());
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
now.insert(k.clone(), Value::Array(list));
|
|
|
|
|
|
}
|
|
|
|
|
|
(_, Some(is), _) => {
|
|
|
|
|
|
now.insert(k.clone(), is.clone());
|
|
|
|
|
|
}
|
|
|
|
|
|
// (A field left out when it is empty: emptied in the editor.)
|
|
|
|
|
|
(_, None, _) => {
|
|
|
|
|
|
now.remove(k);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
serde_json::from_value::<Bot>(Value::Object(now)).ok()
|
|
|
|
|
|
})();
|
|
|
|
|
|
*self = merged.unwrap_or(edited);
|
|
|
|
|
|
self.messages = messages;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
/// A turn that was still running when the app closed can't continue: mark its empty
|
|
|
|
|
|
/// replies and unfinished tool steps as interrupted (an `error` settlement) so the chat
|
|
|
|
|
|
/// can be retried instead of looking stuck.
|
|
|
|
|
|
pub fn repair_interrupted(&mut self) -> bool {
|
|
|
|
|
|
let mut changed = false;
|
|
|
|
|
|
for m in self.messages.iter_mut().filter(|m| m.role == Role::Assistant && !m.summary) {
|
|
|
|
|
|
for c in m.tool_calls.iter_mut() {
|
|
|
|
|
|
if matches!(c.status, ToolStatus::Waiting | ToolStatus::Running) {
|
|
|
|
|
|
c.status = ToolStatus::Denied;
|
|
|
|
|
|
changed = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if m.text.trim().is_empty() && m.tool_calls.is_empty() {
|
|
|
|
|
|
m.text = INTERRUPTED_NOTE.into();
|
|
|
|
|
|
m.error = true;
|
|
|
|
|
|
changed = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if changed && self.last_turn.as_ref().map(|t| t.outcome != TurnOutcome::Error).unwrap_or(true) {
|
|
|
|
|
|
self.last_turn = Some(TurnSettlement { turn_id: "interrupted".into(), outcome: TurnOutcome::Error, settled_at_ms: chrono::Utc::now().timestamp_millis() });
|
|
|
|
|
|
}
|
|
|
|
|
|
changed
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn default_context_tokens() -> u64 {
|
|
|
|
|
|
crate::agent::DEFAULT_CONTEXT_TOKENS
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Debug)]
|
|
|
|
|
|
pub enum Theme {
|
|
|
|
|
|
Light,
|
|
|
|
|
|
Dark,
|
|
|
|
|
|
System,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Debug)]
|
|
|
|
|
|
pub enum Accent {
|
|
|
|
|
|
BotColor,
|
|
|
|
|
|
Black,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Serialize, Deserialize, Debug, Default)]
|
|
|
|
|
|
pub struct ProviderSettings {
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub api_key: String,
|
|
|
|
|
|
/// Empty means the provider's usual address.
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub base_url: String,
|
|
|
|
|
|
/// Empty means the provider's suggested model.
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub default_model: String,
|
|
|
|
|
|
/// Models fetched from the provider's model list.
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub known_models: Vec<String>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Serialize, Deserialize, Debug)]
|
|
|
|
|
|
pub struct State {
|
|
|
|
|
|
pub bots: Vec<Bot>,
|
|
|
|
|
|
pub primary_bot: u64,
|
|
|
|
|
|
pub selected: u64,
|
|
|
|
|
|
pub next_id: u64,
|
|
|
|
|
|
pub theme: Theme,
|
|
|
|
|
|
pub accent: Accent,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub providers: std::collections::BTreeMap<String, ProviderSettings>,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub sidebar_collapsed: bool,
|
Meaning beside the words: local embeddings + a document shelf (P6, step 2)
- embed.rs: vectors from the person's own Ollama (/api/embed), model
auto-found among installed ones (bge-m3, nomic-embed-text…), off by
absence — no Ollama is a normal state: every search still answers
with words, the toggle lives in 設定 › 一般
- memories grow a vec column; recall() is now hybrid: trigram FTS,
two-character windows for Chinese, cosine over the shelf, merged by
reciprocal rank — 「那篇講語法的筆記」 finds text that never says 筆記
- docindex.rs: the person's folders (their 文件 by default, add more in
設定 › 一般), text-shaped files chunked into SQLite + FTS, refreshed
by mtime, gone files leave; one background thread does it all — the
turn never waits on embeddings
- one tool for it: search_documents {query, limit} — reads, no approval
- 251 tests: a real temp folder swept, answered, and swept again after
deletion; offline Ollama tested as the normal state it is
2026-11-09 12:23:35 +00:00
|
|
|
|
/// 語意搜尋: "" = 找 Ollama 裡裝好的 embedding 模型, "off" = 關。
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub embed_model: String,
|
|
|
|
|
|
/// 文件索引的資料夾; 空 = 用這台電腦的「文件」資料夾。
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub doc_folders: Vec<String>,
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
#[serde(default = "default_sidebar_width")]
|
|
|
|
|
|
pub sidebar_width: f32,
|
|
|
|
|
|
/// 允許在這台電腦上執行任務,而不只是在它自己的電腦上 (setting `local-execution`, ids fuUvu2/Hd1KB+).
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub local_execution: bool,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub remote_computers: Vec<RemoteComputer>,
|
|
|
|
|
|
/// 自動審核 with the person's natural-language rules ("先詢問" wins when they conflict).
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub auto_review: bool,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub review_allow_rules: Vec<String>,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub review_ask_rules: Vec<String>,
|
|
|
|
|
|
/// Time zone for schedules and reminders (setting `timezone`, ids 40Gx0U/TO/JdP).
|
|
|
|
|
|
#[serde(default = "crate::routines::local_timezone")]
|
|
|
|
|
|
pub timezone: String,
|
|
|
|
|
|
#[serde(default = "first_routine_id")]
|
|
|
|
|
|
pub next_routine_id: u64,
|
|
|
|
|
|
/// 「Bot 完成任務或等你處理時通知你」(ttdOeI): banners when away.
|
|
|
|
|
|
#[serde(default = "yes")]
|
|
|
|
|
|
pub notify: bool,
|
2026-11-09 08:37:33 +00:00
|
|
|
|
/// 直接操作: one switch like the reference assistant's local-execution + auto-review; only safety checks and
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
/// "always ask" rules stop the bot.
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub direct_mode: bool,
|
|
|
|
|
|
/// 「在你離開時自動更新用戶端」(4vprnG); on by default like the original.
|
|
|
|
|
|
#[serde(default = "yes")]
|
|
|
|
|
|
pub auto_update_when_away: bool,
|
|
|
|
|
|
/// 訊息管道: Slack / Discord / Telegram channels a bot answers.
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub channels: Vec<crate::channels::Channel>,
|
|
|
|
|
|
#[serde(default = "first_routine_id")]
|
|
|
|
|
|
pub next_channel_id: u64,
|
|
|
|
|
|
/// MCP connectors the bots can use.
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub mcp_servers: Vec<crate::mcp::Server>,
|
|
|
|
|
|
/// 技能 every bot can load.
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub skills: Vec<crate::memory::Skill>,
|
頁面: pages a bot makes and keeps up to date (like the original's tracking pages)
- write_page / read_page / list_pages: Markdown pages with versions (last 10 kept), per bot;
the reply gets a card (title, fading preview, page icon, 頁面 · 更新時間, 最近更新), and
[標題](page:ID) links open the page
- The page opens large: summary, what changed, the version picker, 複製 / 存成檔案 / 刪除;
listed under 資料庫 in the right panel
- Page blocks for richer pages, in the chat's Markdown too: ```stats tiles, ```steps progress,
> [!NOTE]/[!TIP]/[!WARNING]/[!IMPORTANT] call-outs (with tables and Mermaid charts)
- Phone: the card in chats, page links, a page view with the same blocks; charts drawn by the
AnyBot as SVG (GET /api/pages/<id>[/chart/<n>]); members may read pages
- Bots are told to make a page for things to track and to update it from a routine only when
there is real news
- Screenshot views: ANYBOT_SHOT_VIEW=pagecard / page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 17:41:41 +00:00
|
|
|
|
/// 頁面 the bots made and keep up to date.
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub pages: Vec<crate::pages::Page>,
|
|
|
|
|
|
#[serde(default = "first_routine_id")]
|
|
|
|
|
|
pub next_page_id: u64,
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
/// 手機與帳號: phone access, the relay account and backups.
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub remote: crate::remote::Settings,
|
|
|
|
|
|
/// 分區 (sidebar sections) in their order, including empty ones.
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub folders: Vec<String>,
|
|
|
|
|
|
/// 你的電腦's label in 設定 → 電腦 (empty = the computer's own name).
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
|
|
|
|
|
pub machine_label: String,
|
|
|
|
|
|
/// 在這台電腦上執行 = 永不允許 (bots never ask; local tools are refused).
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub local_never: bool,
|
|
|
|
|
|
/// 訊息: bots may send iMessages / SMS from this Mac.
|
|
|
|
|
|
#[serde(default = "yes")]
|
|
|
|
|
|
pub messages_send: bool,
|
|
|
|
|
|
/// 訊息: send without the approval card each time.
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub messages_no_ask: bool,
|
|
|
|
|
|
/// 已釘選的 Bot, in the order of the grid at the top of the sidebar.
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
|
|
|
|
pub pin_order: Vec<u64>,
|
|
|
|
|
|
/// Where 未分區 sits among the 分區 (None = last).
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
|
|
|
|
pub loose_index: Option<usize>,
|
|
|
|
|
|
/// Groups folded shut in the sidebar ("" = 未分區).
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
|
|
|
|
pub folded: Vec<String>,
|
|
|
|
|
|
/// 語言: "auto" (follow the Mac) or one of i18n::LANGS.
|
|
|
|
|
|
#[serde(default = "auto_lang")]
|
|
|
|
|
|
pub language: String,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn auto_lang() -> String {
|
|
|
|
|
|
"auto".into()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// "Always ask" rules that direct mode starts with (own wording).
|
|
|
|
|
|
pub const DEFAULT_ASK_RULES: [&str; 3] = ["刪除檔案或資料夾", "付款、購買或轉帳", "用我的帳號發文、留言,或傳訊息給別人"];
|
|
|
|
|
|
|
|
|
|
|
|
fn yes() -> bool {
|
|
|
|
|
|
true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn first_routine_id() -> u64 {
|
|
|
|
|
|
1
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn default_sidebar_width() -> f32 {
|
|
|
|
|
|
crate::app::SIDEBAR_DEFAULT_WIDTH
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl State {
|
2026-11-05 08:47:28 +00:00
|
|
|
|
/// Every computer name a bot can be told to use here: its own (by its stored name), this
|
|
|
|
|
|
/// computer, then the SSH ones.
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
pub fn computer_names(&self) -> Vec<String> {
|
2026-11-05 08:47:28 +00:00
|
|
|
|
let mut v = vec![BOT_COMPUTER_NAME.to_string(), self.this_computer_name()];
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
v.extend(self.remote_computers.iter().map(|c| c.name.clone()));
|
|
|
|
|
|
v
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-05 08:47:28 +00:00
|
|
|
|
/// This computer's own name: the one given in 設定 › 電腦, else its name in the account, else
|
|
|
|
|
|
/// the name the system gives it.
|
|
|
|
|
|
pub fn this_computer_name(&self) -> String {
|
|
|
|
|
|
for n in [&self.machine_label, &self.remote.host_name] {
|
|
|
|
|
|
if !n.trim().is_empty() {
|
|
|
|
|
|
return n.trim().to_string();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
crate::remote::default_host_name()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
pub fn routine_owner(&self, routine_id: u64) -> Option<u64> {
|
|
|
|
|
|
self.bots.iter().find(|b| b.routines.iter().any(|r| r.id == routine_id)).map(|b| b.id)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Bug sweep: gates, relay 2FA, Windows input, snapshots, encodings
Permissions and reach
- a file leaving a computer passes the same questions as reading it;
"looking" shell commands that do more than look ask first
- a symlink out of the work/sealed folder counts as outside it
(real paths, not just lexical ones); the search walk skips links
- web_fetch and skill_install read the public web only: localhost, the
LAN and cloud metadata are refused, at every redirect
- preview shows a file without running it; browser_upload may ask
- vault: logins no longer match across shared suffixes (pages.dev,
com.tw, ...); registrable_domain knows public suffixes
- axwrite: found elements are retained (use-after-free)
Relay
- replacing or turning off 2FA needs the current code (rate-limited);
new recovery codes only count once enrollment is verified
- a removed computer loses its socket and its `call` access
- a site name taken by another account answers 409
- RELAY_TRUST_PROXY: the login lock counts the caller's address, not
the proxy's (compose files and README)
- httpd: half a body is not a request
Desktop
- sign-in, join and restore work with 2FA on (code field; --serve reads
ANYBOT_ACCOUNT_CODE; the setup wizard points to a pairing code)
- Windows: text was typed twice; modifiers are held through click, drag
and scroll; horizontal scroll; screen control is no longer refused
- a recording that hits the limit is transcribed, not thrown away
- bot's computer: a new home volume restores the snapshot; a snapshot
is read through before the home is wiped; a failed volume removal
stops reset/restore
- events of a stopped turn no longer land in the next one; each channel
message gets a turn of its own
- saving a bot's settings keeps what it got meanwhile (three-way merge);
a save file that cannot be read is kept
- pages in Big5/GBK/Shift_JIS are read in their own encoding
(encoding_rs); osascript output past 64 KB no longer stalls
- pick_tab keeps a job's own non-http tab; pictures larger than the
texture limit are fitted; the auto-update time field takes typing
- skills live in <data>/skills; memories are unnumbered in the prompt;
the phone may skillify and skill_install
CI
- the test step fails when the tests fail (it piped into tail)
Tests: anybot 268 passed, 27 ignored; relay 16 passed; clippy unchanged.
Not run: Windows on a real machine (win.rs type-checked alone), the
Docker paths of the bot's computer, the live accessibility writes.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 03:46:17 +00:00
|
|
|
|
/// The save on disk couldn't be read at start and couldn't be moved aside: never write over it.
|
|
|
|
|
|
static KEEP_UNREAD_SAVE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
|
|
|
|
|
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
impl State {
|
|
|
|
|
|
pub fn path() -> PathBuf {
|
|
|
|
|
|
// A server (or a second copy for testing) can keep its data elsewhere.
|
|
|
|
|
|
if let Ok(d) = std::env::var("ANYBOT_DATA_DIR") {
|
|
|
|
|
|
if !d.trim().is_empty() {
|
|
|
|
|
|
return PathBuf::from(d).join("state.json");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
let base = dirs::data_dir().unwrap_or_else(std::env::temp_dir);
|
|
|
|
|
|
base.join("AnyBot").join("state.json")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn load() -> State {
|
Bug sweep: gates, relay 2FA, Windows input, snapshots, encodings
Permissions and reach
- a file leaving a computer passes the same questions as reading it;
"looking" shell commands that do more than look ask first
- a symlink out of the work/sealed folder counts as outside it
(real paths, not just lexical ones); the search walk skips links
- web_fetch and skill_install read the public web only: localhost, the
LAN and cloud metadata are refused, at every redirect
- preview shows a file without running it; browser_upload may ask
- vault: logins no longer match across shared suffixes (pages.dev,
com.tw, ...); registrable_domain knows public suffixes
- axwrite: found elements are retained (use-after-free)
Relay
- replacing or turning off 2FA needs the current code (rate-limited);
new recovery codes only count once enrollment is verified
- a removed computer loses its socket and its `call` access
- a site name taken by another account answers 409
- RELAY_TRUST_PROXY: the login lock counts the caller's address, not
the proxy's (compose files and README)
- httpd: half a body is not a request
Desktop
- sign-in, join and restore work with 2FA on (code field; --serve reads
ANYBOT_ACCOUNT_CODE; the setup wizard points to a pairing code)
- Windows: text was typed twice; modifiers are held through click, drag
and scroll; horizontal scroll; screen control is no longer refused
- a recording that hits the limit is transcribed, not thrown away
- bot's computer: a new home volume restores the snapshot; a snapshot
is read through before the home is wiped; a failed volume removal
stops reset/restore
- events of a stopped turn no longer land in the next one; each channel
message gets a turn of its own
- saving a bot's settings keeps what it got meanwhile (three-way merge);
a save file that cannot be read is kept
- pages in Big5/GBK/Shift_JIS are read in their own encoding
(encoding_rs); osascript output past 64 KB no longer stalls
- pick_tab keeps a job's own non-http tab; pictures larger than the
texture limit are fitted; the auto-update time field takes typing
- skills live in <data>/skills; memories are unnumbered in the prompt;
the phone may skillify and skill_install
CI
- the test step fails when the tests fail (it piped into tail)
Tests: anybot 268 passed, 27 ignored; relay 16 passed; clippy unchanged.
Not run: Windows on a real machine (win.rs type-checked alone), the
Docker paths of the bot's computer, the live accessibility writes.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 03:46:17 +00:00
|
|
|
|
Self::load_from(&Self::path())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn load_from(p: &std::path::Path) -> State {
|
|
|
|
|
|
let raw = std::fs::read_to_string(p);
|
2026-11-05 07:48:20 +00:00
|
|
|
|
let parsed = raw.as_ref().ok().and_then(|s| serde_json::from_str::<State>(s).ok());
|
|
|
|
|
|
let mut s = match parsed {
|
|
|
|
|
|
Some(s) => s,
|
|
|
|
|
|
None => {
|
Bug sweep: gates, relay 2FA, Windows input, snapshots, encodings
Permissions and reach
- a file leaving a computer passes the same questions as reading it;
"looking" shell commands that do more than look ask first
- a symlink out of the work/sealed folder counts as outside it
(real paths, not just lexical ones); the search walk skips links
- web_fetch and skill_install read the public web only: localhost, the
LAN and cloud metadata are refused, at every redirect
- preview shows a file without running it; browser_upload may ask
- vault: logins no longer match across shared suffixes (pages.dev,
com.tw, ...); registrable_domain knows public suffixes
- axwrite: found elements are retained (use-after-free)
Relay
- replacing or turning off 2FA needs the current code (rate-limited);
new recovery codes only count once enrollment is verified
- a removed computer loses its socket and its `call` access
- a site name taken by another account answers 409
- RELAY_TRUST_PROXY: the login lock counts the caller's address, not
the proxy's (compose files and README)
- httpd: half a body is not a request
Desktop
- sign-in, join and restore work with 2FA on (code field; --serve reads
ANYBOT_ACCOUNT_CODE; the setup wizard points to a pairing code)
- Windows: text was typed twice; modifiers are held through click, drag
and scroll; horizontal scroll; screen control is no longer refused
- a recording that hits the limit is transcribed, not thrown away
- bot's computer: a new home volume restores the snapshot; a snapshot
is read through before the home is wiped; a failed volume removal
stops reset/restore
- events of a stopped turn no longer land in the next one; each channel
message gets a turn of its own
- saving a bot's settings keeps what it got meanwhile (three-way merge);
a save file that cannot be read is kept
- pages in Big5/GBK/Shift_JIS are read in their own encoding
(encoding_rs); osascript output past 64 KB no longer stalls
- pick_tab keeps a job's own non-http tab; pictures larger than the
texture limit are fitted; the auto-update time field takes typing
- skills live in <data>/skills; memories are unnumbered in the prompt;
the phone may skillify and skill_install
CI
- the test step fails when the tests fail (it piped into tail)
Tests: anybot 268 passed, 27 ignored; relay 16 passed; clippy unchanged.
Not run: Windows on a real machine (win.rs type-checked alone), the
Docker paths of the bot's computer, the live accessibility writes.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 03:46:17 +00:00
|
|
|
|
let stamp = chrono::Utc::now().timestamp();
|
|
|
|
|
|
match raw.as_ref() {
|
|
|
|
|
|
// A damaged save must not vanish quietly: keep the bytes (so the person can
|
|
|
|
|
|
// recover them), then start fresh. Silently wiping left no trace before.
|
|
|
|
|
|
Ok(raw) => {
|
|
|
|
|
|
let damaged = p.with_extension(format!("json.damaged-{stamp}"));
|
|
|
|
|
|
let _ = std::fs::write(&damaged, raw);
|
|
|
|
|
|
}
|
|
|
|
|
|
// No save yet: a first run.
|
|
|
|
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
|
|
|
|
|
// A save that is there but can't be read (its permissions, a disk error,
|
|
|
|
|
|
// bytes that aren't text) is no first run: the next save would have written
|
|
|
|
|
|
// an empty AnyBot over it. It is moved aside whole, and if even that fails
|
|
|
|
|
|
// nothing is saved over it this run.
|
|
|
|
|
|
Err(e) => {
|
|
|
|
|
|
let aside = p.with_extension(format!("json.unreadable-{stamp}"));
|
|
|
|
|
|
eprintln!("AnyBot:讀不到 {}({e})。原檔會留著,不會被新的設定蓋掉。", p.display());
|
|
|
|
|
|
if std::fs::rename(p, &aside).is_err() {
|
|
|
|
|
|
KEEP_UNREAD_SAVE.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-11-05 07:48:20 +00:00
|
|
|
|
}
|
|
|
|
|
|
State::first_run()
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-11-05 06:25:01 +00:00
|
|
|
|
s.drop_untouched_samples();
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
for b in s.bots.iter_mut() {
|
|
|
|
|
|
b.repair_interrupted();
|
|
|
|
|
|
}
|
2026-11-05 06:25:01 +00:00
|
|
|
|
s.fix_primary();
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
s
|
|
|
|
|
|
}
|
|
|
|
|
|
|
A turn cut off by a crash leaves a 「繼續」 card, and nothing it may have done runs twice
Each bot keeps a work log (oplog/bot-<id>.jsonl, after pi's durable-harness design, cut down):
before something happens a line says it is about to — the turn, each model call, each step once
its checks passed (with the arguments it runs with and whether it may run again), each message
typed mid-turn — synced to disk first. When the turn ends the file shrinks to the messages still
waiting, usually nothing.
On the next start after AnyBot was killed mid-turn, the log puts the chat right before anything
runs: the message the turn answered comes back if the chat lost it; a step that was running and
reaches outside (a text, a click, a file, a published site) shows as 「可能已經執行了」, both to the
person and in the result the bot reads — no longer as "not allowed"; a step that only looked shows
as cut off; messages typed mid-turn that never reached the bot go back into the chat. The chat is
saved, then the log closes the turn. A turn the person didn't stop ends with a 「繼續」 card (what
it was doing, what may have run, the messages put back); it goes on only when they press it — in
the window, on a screen of the brain, or on the phone — from where its steps stand, never running
a step that may have run again. Pressing stop is on disk at once, and a stopped turn stays
stopped.
Also in the turn loop:
- 自動審核 that doesn't answer (or answers something that isn't a verdict) asks the person; in
直接操作 mode it used to let the step through unchecked.
- "Too long for this model" folds older messages into a summary and asks the same step again,
once per thing the person said, instead of ending the turn.
- Folding keeps the person's chat whole: the summary goes in at the fold and the model reads from
the newest summary on (it used to delete what it folded).
- A step moved to the chosen computer runs there, but the chat keeps the call as the model wrote
it; the result says where it ran.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha5F5gJt93t3yEx3XcS
2026-11-08 16:49:12 +00:00
|
|
|
|
/// Write everything to disk; true once it is there.
|
|
|
|
|
|
pub fn save(&self) -> bool {
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
let p = Self::path();
|
Bug sweep: gates, relay 2FA, Windows input, snapshots, encodings
Permissions and reach
- a file leaving a computer passes the same questions as reading it;
"looking" shell commands that do more than look ask first
- a symlink out of the work/sealed folder counts as outside it
(real paths, not just lexical ones); the search walk skips links
- web_fetch and skill_install read the public web only: localhost, the
LAN and cloud metadata are refused, at every redirect
- preview shows a file without running it; browser_upload may ask
- vault: logins no longer match across shared suffixes (pages.dev,
com.tw, ...); registrable_domain knows public suffixes
- axwrite: found elements are retained (use-after-free)
Relay
- replacing or turning off 2FA needs the current code (rate-limited);
new recovery codes only count once enrollment is verified
- a removed computer loses its socket and its `call` access
- a site name taken by another account answers 409
- RELAY_TRUST_PROXY: the login lock counts the caller's address, not
the proxy's (compose files and README)
- httpd: half a body is not a request
Desktop
- sign-in, join and restore work with 2FA on (code field; --serve reads
ANYBOT_ACCOUNT_CODE; the setup wizard points to a pairing code)
- Windows: text was typed twice; modifiers are held through click, drag
and scroll; horizontal scroll; screen control is no longer refused
- a recording that hits the limit is transcribed, not thrown away
- bot's computer: a new home volume restores the snapshot; a snapshot
is read through before the home is wiped; a failed volume removal
stops reset/restore
- events of a stopped turn no longer land in the next one; each channel
message gets a turn of its own
- saving a bot's settings keeps what it got meanwhile (three-way merge);
a save file that cannot be read is kept
- pages in Big5/GBK/Shift_JIS are read in their own encoding
(encoding_rs); osascript output past 64 KB no longer stalls
- pick_tab keeps a job's own non-http tab; pictures larger than the
texture limit are fitted; the auto-update time field takes typing
- skills live in <data>/skills; memories are unnumbered in the prompt;
the phone may skillify and skill_install
CI
- the test step fails when the tests fail (it piped into tail)
Tests: anybot 268 passed, 27 ignored; relay 16 passed; clippy unchanged.
Not run: Windows on a real machine (win.rs type-checked alone), the
Docker paths of the bot's computer, the live accessibility writes.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 03:46:17 +00:00
|
|
|
|
// (The save that couldn't be read at start, nor moved aside, is still there.)
|
|
|
|
|
|
if KEEP_UNREAD_SAVE.load(std::sync::atomic::Ordering::SeqCst) && p.exists() {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
if let Some(dir) = p.parent() {
|
|
|
|
|
|
let _ = std::fs::create_dir_all(dir);
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Ok(s) = serde_json::to_string_pretty(self) {
|
|
|
|
|
|
let tmp = p.with_extension("json.tmp");
|
2026-11-05 07:48:20 +00:00
|
|
|
|
// Flush the bytes before renaming, so a power cut cannot leave a renamed-but-empty
|
|
|
|
|
|
// file where load() would find it (and treat it as a first run).
|
|
|
|
|
|
if let Ok(mut f) = std::fs::File::create(&tmp) {
|
|
|
|
|
|
use std::io::Write;
|
|
|
|
|
|
if f.write_all(s.as_bytes()).and_then(|_| f.sync_all()).is_ok() {
|
2026-11-09 12:33:56 +00:00
|
|
|
|
// state.json carries model keys: owner-only, like the vault's key file.
|
|
|
|
|
|
#[cfg(unix)]
|
|
|
|
|
|
{
|
|
|
|
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
|
|
let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600));
|
|
|
|
|
|
}
|
A turn cut off by a crash leaves a 「繼續」 card, and nothing it may have done runs twice
Each bot keeps a work log (oplog/bot-<id>.jsonl, after pi's durable-harness design, cut down):
before something happens a line says it is about to — the turn, each model call, each step once
its checks passed (with the arguments it runs with and whether it may run again), each message
typed mid-turn — synced to disk first. When the turn ends the file shrinks to the messages still
waiting, usually nothing.
On the next start after AnyBot was killed mid-turn, the log puts the chat right before anything
runs: the message the turn answered comes back if the chat lost it; a step that was running and
reaches outside (a text, a click, a file, a published site) shows as 「可能已經執行了」, both to the
person and in the result the bot reads — no longer as "not allowed"; a step that only looked shows
as cut off; messages typed mid-turn that never reached the bot go back into the chat. The chat is
saved, then the log closes the turn. A turn the person didn't stop ends with a 「繼續」 card (what
it was doing, what may have run, the messages put back); it goes on only when they press it — in
the window, on a screen of the brain, or on the phone — from where its steps stand, never running
a step that may have run again. Pressing stop is on disk at once, and a stopped turn stays
stopped.
Also in the turn loop:
- 自動審核 that doesn't answer (or answers something that isn't a verdict) asks the person; in
直接操作 mode it used to let the step through unchecked.
- "Too long for this model" folds older messages into a summary and asks the same step again,
once per thing the person said, instead of ending the turn.
- Folding keeps the person's chat whole: the summary goes in at the fold and the model reads from
the newest summary on (it used to delete what it folded).
- A step moved to the chosen computer runs there, but the chat keeps the call as the model wrote
it; the result says where it ran.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha5F5gJt93t3yEx3XcS
2026-11-08 16:49:12 +00:00
|
|
|
|
return std::fs::rename(&tmp, &p).is_ok();
|
2026-11-05 07:48:20 +00:00
|
|
|
|
}
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
A turn cut off by a crash leaves a 「繼續」 card, and nothing it may have done runs twice
Each bot keeps a work log (oplog/bot-<id>.jsonl, after pi's durable-harness design, cut down):
before something happens a line says it is about to — the turn, each model call, each step once
its checks passed (with the arguments it runs with and whether it may run again), each message
typed mid-turn — synced to disk first. When the turn ends the file shrinks to the messages still
waiting, usually nothing.
On the next start after AnyBot was killed mid-turn, the log puts the chat right before anything
runs: the message the turn answered comes back if the chat lost it; a step that was running and
reaches outside (a text, a click, a file, a published site) shows as 「可能已經執行了」, both to the
person and in the result the bot reads — no longer as "not allowed"; a step that only looked shows
as cut off; messages typed mid-turn that never reached the bot go back into the chat. The chat is
saved, then the log closes the turn. A turn the person didn't stop ends with a 「繼續」 card (what
it was doing, what may have run, the messages put back); it goes on only when they press it — in
the window, on a screen of the brain, or on the phone — from where its steps stand, never running
a step that may have run again. Pressing stop is on disk at once, and a stopped turn stays
stopped.
Also in the turn loop:
- 自動審核 that doesn't answer (or answers something that isn't a verdict) asks the person; in
直接操作 mode it used to let the step through unchecked.
- "Too long for this model" folds older messages into a summary and asks the same step again,
once per thing the person said, instead of ending the turn.
- Folding keeps the person's chat whole: the summary goes in at the fold and the model reads from
the newest summary on (it used to delete what it folded).
- A step moved to the chosen computer runs there, but the chat keeps the call as the model wrote
it; the result says where it ran.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha5F5gJt93t3yEx3XcS
2026-11-08 16:49:12 +00:00
|
|
|
|
false
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn provider(&self, kind: ProviderKind) -> ProviderSettings {
|
|
|
|
|
|
self.providers.get(kind.key()).cloned().unwrap_or_default()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn provider_mut(&mut self, kind: ProviderKind) -> &mut ProviderSettings {
|
|
|
|
|
|
self.providers.entry(kind.key().to_string()).or_default()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn bot(&self, id: u64) -> Option<&Bot> {
|
|
|
|
|
|
self.bots.iter().find(|b| b.id == id)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn bot_mut(&mut self, id: u64) -> Option<&mut Bot> {
|
|
|
|
|
|
self.bots.iter_mut().find(|b| b.id == id)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-05 06:25:01 +00:00
|
|
|
|
/// Older versions started with five sample bots. If they are all still there and were never
|
|
|
|
|
|
/// used (no chat, routine or memory, nothing renamed) and there is nothing else, it is a fresh
|
|
|
|
|
|
/// install: start empty, with the guide, like a new one.
|
|
|
|
|
|
pub fn drop_untouched_samples(&mut self) {
|
|
|
|
|
|
let samples = Self::sample_bots();
|
|
|
|
|
|
let untouched = |b: &Bot| {
|
|
|
|
|
|
samples.iter().any(|x| x.id == b.id && x.name == b.name && x.description == b.description && x.instructions == b.instructions)
|
|
|
|
|
|
&& b.messages.is_empty()
|
|
|
|
|
|
&& b.routines.is_empty()
|
|
|
|
|
|
&& b.memories.is_empty()
|
|
|
|
|
|
&& b.threads.is_empty()
|
|
|
|
|
|
};
|
|
|
|
|
|
if self.bots.len() == samples.len() && self.bots.iter().all(untouched) {
|
|
|
|
|
|
self.bots.clear();
|
|
|
|
|
|
self.primary_bot = 0;
|
|
|
|
|
|
self.selected = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The first bot made becomes the main one; with none at all, nothing is selected.
|
|
|
|
|
|
pub fn fix_primary(&mut self) {
|
|
|
|
|
|
if self.bot(self.primary_bot).is_none() {
|
|
|
|
|
|
self.primary_bot = self.bots.first().map(|b| b.id).unwrap_or(0);
|
|
|
|
|
|
}
|
|
|
|
|
|
if self.bot(self.selected).is_none() {
|
|
|
|
|
|
self.selected = self.primary_bot;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Bots that aren't here yet (another computer's, a backup's), as new bots with new ids.
|
|
|
|
|
|
/// A bot counts as already here when one has the same name and the same chat (or it was
|
|
|
|
|
|
/// never used). Returns how many were added.
|
|
|
|
|
|
pub fn take_in_bots(&mut self, other: &State) -> usize {
|
|
|
|
|
|
let last = |b: &Bot| b.messages.last().map(|m| (m.at, m.text.clone()));
|
|
|
|
|
|
let mut n = 0;
|
2026-11-05 07:48:20 +00:00
|
|
|
|
let mut reid: Vec<(u64, u64)> = vec![];
|
2026-11-05 06:25:01 +00:00
|
|
|
|
for b in &other.bots {
|
|
|
|
|
|
if self.bots.iter().any(|o| o.name == b.name && (b.messages.is_empty() || (o.messages.len() == b.messages.len() && last(o) == last(b)))) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
let mut b = b.clone();
|
2026-11-05 07:48:20 +00:00
|
|
|
|
let old_id = b.id;
|
2026-11-05 06:25:01 +00:00
|
|
|
|
b.id = self.next_id.max(1);
|
2026-11-05 07:48:20 +00:00
|
|
|
|
reid.push((old_id, b.id));
|
2026-11-05 06:25:01 +00:00
|
|
|
|
self.next_id = b.id + 1;
|
|
|
|
|
|
for r in b.routines.iter_mut() {
|
|
|
|
|
|
r.id = self.next_routine_id.max(1);
|
|
|
|
|
|
self.next_routine_id = r.id + 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
self.bots.push(b);
|
|
|
|
|
|
n += 1;
|
|
|
|
|
|
}
|
2026-11-05 07:48:20 +00:00
|
|
|
|
// Group members are bot ids from the other machine: follow them to the copies that
|
|
|
|
|
|
// just landed, or a group answers with bots that have nothing to do with it.
|
|
|
|
|
|
if !reid.is_empty() {
|
|
|
|
|
|
for g in self.bots.iter_mut() {
|
|
|
|
|
|
if g.members.is_empty() {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
for m in g.members.iter_mut() {
|
|
|
|
|
|
if let Some((_, new)) = reid.iter().find(|(o, _)| *o == *m) {
|
|
|
|
|
|
*m = *new;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-11-05 06:25:01 +00:00
|
|
|
|
// Keys, skills and connectors this computer doesn't have yet.
|
|
|
|
|
|
for (k, v) in &other.providers {
|
|
|
|
|
|
let mine = self.providers.entry(k.clone()).or_default();
|
|
|
|
|
|
if mine.api_key.is_empty() && !v.api_key.is_empty() {
|
|
|
|
|
|
*mine = v.clone();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
for sk in &other.skills {
|
|
|
|
|
|
if !self.skills.iter().any(|m| m.name == sk.name) {
|
|
|
|
|
|
self.skills.push(sk.clone());
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
for m in &other.mcp_servers {
|
|
|
|
|
|
if !self.mcp_servers.iter().any(|o| o.name == m.name) {
|
|
|
|
|
|
self.mcp_servers.push(m.clone());
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
self.fix_primary();
|
|
|
|
|
|
n
|
|
|
|
|
|
}
|
|
|
|
|
|
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
fn sample_bots() -> Vec<Bot> {
|
|
|
|
|
|
let mk = |id, name: &str, desc: &str, shape: &str, color: &str, provider: ProviderKind, pinned| Bot {
|
|
|
|
|
|
id,
|
|
|
|
|
|
name: name.into(),
|
|
|
|
|
|
description: desc.into(),
|
|
|
|
|
|
instructions: String::new(),
|
|
|
|
|
|
shape: shape.into(),
|
|
|
|
|
|
color: color.into(),
|
|
|
|
|
|
provider,
|
|
|
|
|
|
model: provider.suggested_model().into(),
|
Bots work like someone who knows the computer: they know each machine, reach what they see, plan, and hand the planned steps to a quick model
Measured on the bot's own box (8 tasks x 2, grok-4.7): 14/16 done, 120 steps, 1114 s before;
16/16, 102 steps, 574 s after (model time 1015 -> 471 s). A replayed skill: 49 s -> 15 s.
Knowing the machine
- facts.rs: 電腦現況 per computer (system, desktop, systemd, sudo, network, package manager,
tools, apps, a fixed TZ), read once by a small read-only probe, kept 30 min, in the
instructions at the start of a turn.
- The box's sudo is the bot's: only what follows sudo is checked against the always-denied
list (it was refused outright, while the instructions said it could sudo), and commands with
sudo run as the box's root (setuid does nothing under gVisor).
- Box image v8 drops ENV TZ=UTC (it overrode /etc/localtime). ANYBOT_BOX_IMAGE /
ANYBOT_BOX_VOLUME give a throwaway box its own image and volume, never adopting the real one.
- Mac shell sandbox: App Store app CLIs (they carry their own app sandbox) and ps/top run
outside it; plain `osascript -e` too, asked the first time even in direct mode, refusing do
shell script, terminals, keystrokes, file reads, ObjC and the secret folders. A command the
sandbox broke says so, instead of looking like a broken program.
Reaching what it sees
- The Mac menu bar's status icons are numbered (each app's AXExtrasMenuBar, named from inside
for the system's own), computer_use_zoom looks closer at full sharpness.
- Numbers stay with their controls from look to look (they were reassigned every look, so a
step's second click landed elsewhere); the box's front window is listed first; a control can
be named instead of numbered (name "4").
- Web page numbering walks open shadow DOM (Chromium settings, web components).
- A box on a Docker host elsewhere retries the next port when the usual one is taken (a retry
without it came up with no network).
Working like a person
- Instructions: pick the route, know what done looks like, one route, check once, say what's
stuck instead of faking it with a substitute, ask before cutting the connection.
- StepPlan: the bot's model takes the first step, the step after trouble (failed, refused,
repeated, nothing changed on screen, the quick model switching routes), one in four, and the
final check; a quick model (Bot.step_model, auto: grok-4.20-0309-non-reasoning on xAI) does
the planned steps in between, falling back to the bot's model if it can't be used.
- Skills keep the steps that worked (with what each acted on, not numbers); replay_skill does
them again with no model in between, finding each control again, handing back on a mismatch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha5F5gJt93t3yEx3XcS
2026-11-09 01:01:03 +00:00
|
|
|
|
step_model: String::new(),
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
pinned,
|
|
|
|
|
|
messages: vec![],
|
|
|
|
|
|
unread: false,
|
|
|
|
|
|
tools_enabled: false,
|
|
|
|
|
|
workdir: String::new(),
|
|
|
|
|
|
allow_rules: vec![],
|
|
|
|
|
|
demos: vec![],
|
|
|
|
|
|
speak_replies: false,
|
|
|
|
|
|
voice: String::new(),
|
|
|
|
|
|
voice_rate: 1.0,
|
|
|
|
|
|
voice_lang: String::new(),
|
|
|
|
|
|
folder: String::new(),
|
|
|
|
|
|
memories: vec![],
|
|
|
|
|
|
members: vec![],
|
|
|
|
|
|
threads: vec![],
|
|
|
|
|
|
context_tokens: default_context_tokens(),
|
|
|
|
|
|
routines: vec![],
|
|
|
|
|
|
last_turn: None,
|
|
|
|
|
|
default_computer: default_computer(),
|
|
|
|
|
|
};
|
|
|
|
|
|
let mut helper = mk(5, "電腦助手", "幫你整理檔案、跑指令。動手前會先問你。", "gem", "blue", ProviderKind::Anthropic, true);
|
|
|
|
|
|
helper.tools_enabled = true;
|
|
|
|
|
|
helper.workdir = default_workdir();
|
|
|
|
|
|
vec![
|
|
|
|
|
|
mk(1, "Bot", "你的主要聯絡窗口,什麼都可以問。", "squircle", "black", ProviderKind::Xai, false),
|
|
|
|
|
|
mk(2, "研究助理", "幫你查資料、整理重點。", "blob", "green", ProviderKind::Anthropic, true),
|
|
|
|
|
|
mk(3, "寫作夥伴", "改稿、翻譯、想標題。", "cloud", "purple", ProviderKind::OpenAi, true),
|
|
|
|
|
|
helper,
|
|
|
|
|
|
mk(4, "本機模型", "在你自己的電腦上跑,不用連網。", "hex", "brown", ProviderKind::Ollama, false),
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-05 06:25:01 +00:00
|
|
|
|
/// A new install: no bots yet — the main window walks the person through making the first
|
|
|
|
|
|
/// one (or signing in to bring their bots from another computer).
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
pub fn first_run() -> State {
|
|
|
|
|
|
State {
|
2026-11-05 06:25:01 +00:00
|
|
|
|
bots: vec![],
|
|
|
|
|
|
primary_bot: 0,
|
|
|
|
|
|
selected: 0,
|
|
|
|
|
|
next_id: 1,
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
theme: Theme::Light,
|
|
|
|
|
|
accent: Accent::Black,
|
|
|
|
|
|
providers: Default::default(),
|
|
|
|
|
|
sidebar_collapsed: false,
|
Meaning beside the words: local embeddings + a document shelf (P6, step 2)
- embed.rs: vectors from the person's own Ollama (/api/embed), model
auto-found among installed ones (bge-m3, nomic-embed-text…), off by
absence — no Ollama is a normal state: every search still answers
with words, the toggle lives in 設定 › 一般
- memories grow a vec column; recall() is now hybrid: trigram FTS,
two-character windows for Chinese, cosine over the shelf, merged by
reciprocal rank — 「那篇講語法的筆記」 finds text that never says 筆記
- docindex.rs: the person's folders (their 文件 by default, add more in
設定 › 一般), text-shaped files chunked into SQLite + FTS, refreshed
by mtime, gone files leave; one background thread does it all — the
turn never waits on embeddings
- one tool for it: search_documents {query, limit} — reads, no approval
- 251 tests: a real temp folder swept, answered, and swept again after
deletion; offline Ollama tested as the normal state it is
2026-11-09 12:23:35 +00:00
|
|
|
|
embed_model: String::new(),
|
|
|
|
|
|
doc_folders: vec![],
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
sidebar_width: default_sidebar_width(),
|
|
|
|
|
|
local_execution: false,
|
|
|
|
|
|
remote_computers: vec![],
|
|
|
|
|
|
auto_review: false,
|
|
|
|
|
|
review_allow_rules: vec![],
|
|
|
|
|
|
review_ask_rules: vec![],
|
|
|
|
|
|
timezone: crate::routines::local_timezone(),
|
|
|
|
|
|
next_routine_id: 1,
|
|
|
|
|
|
notify: true,
|
|
|
|
|
|
direct_mode: false,
|
|
|
|
|
|
auto_update_when_away: true,
|
|
|
|
|
|
channels: vec![],
|
|
|
|
|
|
next_channel_id: 1,
|
|
|
|
|
|
mcp_servers: vec![],
|
|
|
|
|
|
skills: vec![],
|
頁面: pages a bot makes and keeps up to date (like the original's tracking pages)
- write_page / read_page / list_pages: Markdown pages with versions (last 10 kept), per bot;
the reply gets a card (title, fading preview, page icon, 頁面 · 更新時間, 最近更新), and
[標題](page:ID) links open the page
- The page opens large: summary, what changed, the version picker, 複製 / 存成檔案 / 刪除;
listed under 資料庫 in the right panel
- Page blocks for richer pages, in the chat's Markdown too: ```stats tiles, ```steps progress,
> [!NOTE]/[!TIP]/[!WARNING]/[!IMPORTANT] call-outs (with tables and Mermaid charts)
- Phone: the card in chats, page links, a page view with the same blocks; charts drawn by the
AnyBot as SVG (GET /api/pages/<id>[/chart/<n>]); members may read pages
- Bots are told to make a page for things to track and to update it from a routine only when
there is real news
- Screenshot views: ANYBOT_SHOT_VIEW=pagecard / page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 17:41:41 +00:00
|
|
|
|
pages: vec![],
|
|
|
|
|
|
next_page_id: 1,
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
remote: Default::default(),
|
|
|
|
|
|
language: auto_lang(),
|
|
|
|
|
|
folders: vec![],
|
|
|
|
|
|
pin_order: vec![],
|
|
|
|
|
|
machine_label: String::new(),
|
|
|
|
|
|
local_never: false,
|
|
|
|
|
|
messages_send: true,
|
|
|
|
|
|
messages_no_ask: false,
|
|
|
|
|
|
loose_index: None,
|
|
|
|
|
|
folded: vec![],
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-05 06:25:01 +00:00
|
|
|
|
/// The five sample bots (for pictures and tests, never a real first start).
|
|
|
|
|
|
pub fn samples() -> State {
|
|
|
|
|
|
State { bots: Self::sample_bots(), primary_bot: 1, selected: 1, next_id: 6, ..State::first_run() }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
/// A filled-in state for the --screenshot picture (never saved).
|
|
|
|
|
|
pub fn demo() -> State {
|
2026-11-05 06:25:01 +00:00
|
|
|
|
let mut s = State::samples();
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
let via = format!("{} · {}", ProviderKind::Xai.label(), ProviderKind::Xai.suggested_model());
|
|
|
|
|
|
s.bots[0].messages = vec![
|
|
|
|
|
|
Message::user("幫我規劃這週末去台南兩天一夜的行程"),
|
|
|
|
|
|
Message {
|
|
|
|
|
|
role: Role::Assistant,
|
|
|
|
|
|
text: "好的!這是一個輕鬆的兩天一夜台南行程:\n\n# 第一天\n- 上午:赤崁樓、祀典武廟\n- 中午:國華街小吃(牛肉湯、碗粿)\n- 下午:神農街散步、林百貨\n- 晚上:花園夜市(週四、六、日)\n\n# 第二天\n- 上午:安平古堡、安平樹屋\n- 下午:四草綠色隧道搭船\n\n需要我幫你排交通和住宿嗎?".into(),
|
|
|
|
|
|
thinking: "使用者想要週末兩天一夜,台南景點集中在中西區與安平。".into(),
|
|
|
|
|
|
via: via.clone(),
|
|
|
|
|
|
..Default::default()
|
|
|
|
|
|
},
|
|
|
|
|
|
Message::user("換成用 Claude 回答看看,有什麼不同建議?"),
|
|
|
|
|
|
Message::assistant("如果想避開人潮,可以這樣調整:\n\n- 第一天早上先去安平,避開中午的觀光團\n- 晚餐改到保安路,選擇比夜市多\n- 第二天加一站奇美博物館,下雨也不怕", "Claude · claude-opus-5-5"),
|
|
|
|
|
|
];
|
|
|
|
|
|
s.bots[0].provider = ProviderKind::Anthropic;
|
|
|
|
|
|
s.bots[0].model = ProviderKind::Anthropic.suggested_model().into();
|
|
|
|
|
|
s.bots[1].unread = true;
|
|
|
|
|
|
// Talking to the bot sets up a routine.
|
|
|
|
|
|
let schedule = crate::routines::Schedule {
|
|
|
|
|
|
frequency: crate::routines::Frequency::Weekly,
|
|
|
|
|
|
interval: 1,
|
|
|
|
|
|
days_of_week: crate::routines::WORKWEEK.iter().map(|d| d.to_string()).collect(),
|
|
|
|
|
|
month_day: None,
|
|
|
|
|
|
weekday_of_month: None,
|
|
|
|
|
|
month: None,
|
|
|
|
|
|
time: "09:00".into(),
|
|
|
|
|
|
timezone: s.timezone.clone(),
|
|
|
|
|
|
once: false,
|
|
|
|
|
|
flexible: false,
|
|
|
|
|
|
};
|
|
|
|
|
|
let created = crate::routines::now();
|
|
|
|
|
|
s.bots[1].routines = vec![crate::routines::Routine {
|
|
|
|
|
|
id: 1,
|
|
|
|
|
|
title: "AI 新聞早報".into(),
|
|
|
|
|
|
prompt: "整理過去一天最重要的三則 AI 新聞,每則兩句話。".into(),
|
|
|
|
|
|
next_run: crate::routines::next_run(&schedule, created, created),
|
|
|
|
|
|
schedule,
|
|
|
|
|
|
paused: false,
|
|
|
|
|
|
created_at: created,
|
|
|
|
|
|
last_run: None,
|
|
|
|
|
|
computer: String::new(),
|
|
|
|
|
|
trigger: None,
|
|
|
|
|
|
}];
|
|
|
|
|
|
s.next_routine_id = 2;
|
|
|
|
|
|
s.bots[1].messages = vec![
|
|
|
|
|
|
Message::user("每個工作日早上 9 點幫我整理 AI 新聞"),
|
|
|
|
|
|
Message {
|
|
|
|
|
|
role: Role::Assistant,
|
|
|
|
|
|
via: "Claude · claude-opus-5-5".into(),
|
|
|
|
|
|
tool_calls: vec![ToolCall { id: "r1".into(), name: "create_routine".into(), args: r#"{"title":"AI 新聞早報"}"#.into(), status: ToolStatus::Done }],
|
|
|
|
|
|
..Default::default()
|
|
|
|
|
|
},
|
|
|
|
|
|
Message::tool_result("r1", "create_routine", "已建立例行任務 #1「AI 新聞早報」:每個工作日 09:00"),
|
|
|
|
|
|
Message::assistant("好,已經設定好了:每個工作日 09:00 我會整理三則重點給你。", "Claude · claude-opus-5-5"),
|
|
|
|
|
|
];
|
|
|
|
|
|
s.bots[2].messages = vec![Message::user("幫我想五個標題"), Message::assistant("1. 慢慢走,才看得見風景…", "OpenAI · gpt-5")];
|
|
|
|
|
|
// The computer helper mid-task: two tools done, one waiting for approval.
|
|
|
|
|
|
let via = "Claude · claude-opus-5-5";
|
|
|
|
|
|
let call = |id: &str, name: &str, args: &str, status| ToolCall { id: id.into(), name: name.into(), args: args.into(), status };
|
|
|
|
|
|
s.bots[3].messages = vec![
|
|
|
|
|
|
Message::user("幫我把下載資料夾裡的照片依照月份整理好"),
|
|
|
|
|
|
Message {
|
|
|
|
|
|
role: Role::Assistant,
|
|
|
|
|
|
text: "我先看看有哪些照片。".into(),
|
|
|
|
|
|
via: via.into(),
|
|
|
|
|
|
tool_calls: vec![call("c1", "list_dir", r#"{"path":"~/Downloads"}"#, ToolStatus::Done)],
|
|
|
|
|
|
..Default::default()
|
|
|
|
|
|
},
|
|
|
|
|
|
Message::tool_result("c1", "list_dir", "IMG_0412.jpg 2.1 MB\nIMG_0533.jpg 3.4 MB\nIMG_0601.heic 1.8 MB\n… 共 48 個檔案"),
|
|
|
|
|
|
Message {
|
|
|
|
|
|
role: Role::Assistant,
|
|
|
|
|
|
text: "找到 48 張照片,拍攝時間在 4 到 6 月。我會建立三個資料夾,再把照片搬進去。".into(),
|
|
|
|
|
|
via: via.into(),
|
|
|
|
|
|
tool_calls: vec![call("c2", "shell", r#"{"command":"mkdir -p 2026-04 2026-05 2026-06 && mv IMG_04*.jpg 2026-04/"}"#, ToolStatus::Waiting)],
|
|
|
|
|
|
..Default::default()
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
|
|
|
|
|
s.bots[3].default_computer = THIS_COMPUTER_NAME.into();
|
|
|
|
|
|
s.local_execution = true;
|
|
|
|
|
|
s.selected = 5;
|
|
|
|
|
|
s
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Where a bot starts on this Mac (你的電腦) unless told otherwise.
|
|
|
|
|
|
pub fn default_workdir() -> String {
|
|
|
|
|
|
dirs::home_dir().map(|h| h.display().to_string()).unwrap_or_default()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The sealed folder that is the bot's own computer.
|
|
|
|
|
|
pub fn bot_computer_dir(bot_id: u64) -> PathBuf {
|
|
|
|
|
|
dirs::home_dir().unwrap_or_else(std::env::temp_dir).join("AnyBot 工作區").join(format!("bot-{bot_id}"))
|
|
|
|
|
|
}
|
2026-11-05 06:25:01 +00:00
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod start_tests {
|
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
Bug sweep: gates, relay 2FA, Windows input, snapshots, encodings
Permissions and reach
- a file leaving a computer passes the same questions as reading it;
"looking" shell commands that do more than look ask first
- a symlink out of the work/sealed folder counts as outside it
(real paths, not just lexical ones); the search walk skips links
- web_fetch and skill_install read the public web only: localhost, the
LAN and cloud metadata are refused, at every redirect
- preview shows a file without running it; browser_upload may ask
- vault: logins no longer match across shared suffixes (pages.dev,
com.tw, ...); registrable_domain knows public suffixes
- axwrite: found elements are retained (use-after-free)
Relay
- replacing or turning off 2FA needs the current code (rate-limited);
new recovery codes only count once enrollment is verified
- a removed computer loses its socket and its `call` access
- a site name taken by another account answers 409
- RELAY_TRUST_PROXY: the login lock counts the caller's address, not
the proxy's (compose files and README)
- httpd: half a body is not a request
Desktop
- sign-in, join and restore work with 2FA on (code field; --serve reads
ANYBOT_ACCOUNT_CODE; the setup wizard points to a pairing code)
- Windows: text was typed twice; modifiers are held through click, drag
and scroll; horizontal scroll; screen control is no longer refused
- a recording that hits the limit is transcribed, not thrown away
- bot's computer: a new home volume restores the snapshot; a snapshot
is read through before the home is wiped; a failed volume removal
stops reset/restore
- events of a stopped turn no longer land in the next one; each channel
message gets a turn of its own
- saving a bot's settings keeps what it got meanwhile (three-way merge);
a save file that cannot be read is kept
- pages in Big5/GBK/Shift_JIS are read in their own encoding
(encoding_rs); osascript output past 64 KB no longer stalls
- pick_tab keeps a job's own non-http tab; pictures larger than the
texture limit are fitted; the auto-update time field takes typing
- skills live in <data>/skills; memories are unnumbered in the prompt;
the phone may skillify and skill_install
CI
- the test step fails when the tests fail (it piped into tail)
Tests: anybot 268 passed, 27 ignored; relay 16 passed; clippy unchanged.
Not run: Windows on a real machine (win.rs type-checked alone), the
Docker paths of the bot's computer, the live accessibility writes.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 03:46:17 +00:00
|
|
|
|
/// 「儲存」 in a bot's settings puts on the bot what the person changed there, and leaves
|
|
|
|
|
|
/// what the bot got while the settings were open.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn saving_a_bots_settings_keeps_what_it_got_meanwhile() {
|
|
|
|
|
|
let mut bot = State::samples().bots.remove(0);
|
|
|
|
|
|
bot.memories = vec!["喜歡靠窗".into(), "住台南".into()];
|
|
|
|
|
|
bot.allow_rules = vec!["edit_file".into()];
|
|
|
|
|
|
bot.messages = vec![Message::user("嗨")];
|
|
|
|
|
|
bot.voice.clear();
|
|
|
|
|
|
let from = bot.clone();
|
|
|
|
|
|
// In the editor: a new name, one memory forgotten, a voice picked.
|
|
|
|
|
|
let mut edited = from.clone();
|
|
|
|
|
|
edited.name = "新名字".into();
|
|
|
|
|
|
edited.memories.retain(|m| m != "喜歡靠窗");
|
|
|
|
|
|
edited.voice = "Meijia".into();
|
|
|
|
|
|
// Meanwhile, in the chat: an answer, something remembered, 「永遠允許」, a conversation
|
|
|
|
|
|
// put aside, a folder to work in.
|
|
|
|
|
|
bot.messages.push(Message::assistant("你好", "x"));
|
|
|
|
|
|
bot.memories.push("咖啡不加糖".into());
|
|
|
|
|
|
bot.allow_rules.push("shell:git".into());
|
|
|
|
|
|
bot.threads.push(("舊對話".into(), vec![Message::user("之前")]));
|
|
|
|
|
|
bot.workdir = "/tmp/work".into();
|
|
|
|
|
|
bot.tools_enabled = !from.tools_enabled;
|
|
|
|
|
|
bot.take_edit(from.clone(), edited);
|
|
|
|
|
|
assert_eq!((bot.name.as_str(), bot.voice.as_str()), ("新名字", "Meijia"));
|
|
|
|
|
|
assert_eq!(bot.memories, vec!["住台南".to_string(), "咖啡不加糖".to_string()]);
|
|
|
|
|
|
assert_eq!(bot.allow_rules, vec!["edit_file".to_string(), "shell:git".to_string()]);
|
|
|
|
|
|
assert_eq!((bot.messages.len(), bot.threads.len(), bot.workdir.as_str()), (2, 1, "/tmp/work"));
|
|
|
|
|
|
assert_eq!(bot.tools_enabled, !from.tools_enabled);
|
|
|
|
|
|
// A field emptied in the editor is emptied (it is left out of the file when empty).
|
|
|
|
|
|
let from = bot.clone();
|
|
|
|
|
|
let mut edited = bot.clone();
|
|
|
|
|
|
edited.voice.clear();
|
|
|
|
|
|
bot.take_edit(from, edited);
|
|
|
|
|
|
assert_eq!(bot.voice, "");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
A turn cut off by a crash leaves a 「繼續」 card, and nothing it may have done runs twice
Each bot keeps a work log (oplog/bot-<id>.jsonl, after pi's durable-harness design, cut down):
before something happens a line says it is about to — the turn, each model call, each step once
its checks passed (with the arguments it runs with and whether it may run again), each message
typed mid-turn — synced to disk first. When the turn ends the file shrinks to the messages still
waiting, usually nothing.
On the next start after AnyBot was killed mid-turn, the log puts the chat right before anything
runs: the message the turn answered comes back if the chat lost it; a step that was running and
reaches outside (a text, a click, a file, a published site) shows as 「可能已經執行了」, both to the
person and in the result the bot reads — no longer as "not allowed"; a step that only looked shows
as cut off; messages typed mid-turn that never reached the bot go back into the chat. The chat is
saved, then the log closes the turn. A turn the person didn't stop ends with a 「繼續」 card (what
it was doing, what may have run, the messages put back); it goes on only when they press it — in
the window, on a screen of the brain, or on the phone — from where its steps stand, never running
a step that may have run again. Pressing stop is on disk at once, and a stopped turn stays
stopped.
Also in the turn loop:
- 自動審核 that doesn't answer (or answers something that isn't a verdict) asks the person; in
直接操作 mode it used to let the step through unchecked.
- "Too long for this model" folds older messages into a summary and asks the same step again,
once per thing the person said, instead of ending the turn.
- Folding keeps the person's chat whole: the summary goes in at the fold and the model reads from
the newest summary on (it used to delete what it folded).
- A step moved to the chosen computer runs there, but the chat keeps the call as the model wrote
it; the result says where it ran.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha5F5gJt93t3yEx3XcS
2026-11-08 16:49:12 +00:00
|
|
|
|
/// Folding keeps the person's chat whole: each summary goes in at its fold, and the model's
|
|
|
|
|
|
/// view starts at the newest one.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn folding_keeps_every_message() {
|
|
|
|
|
|
let mut chat: Vec<Message> = (0..10).map(|i| Message { at: i + 1, ..if i % 2 == 0 { Message::user(&format!("問 {i}")) } else { Message::assistant(&format!("答 {i}"), "x") } }).collect();
|
|
|
|
|
|
let sum = |t: &str, at| Message { summary: true, at, ..Message::assistant(t, "x") };
|
|
|
|
|
|
// The turn folded the first 4 messages; the first it kept is 「問 4」 (at 5).
|
|
|
|
|
|
fold_in(&mut chat, 4, sum("摘要一", 100), 5);
|
|
|
|
|
|
assert_eq!(chat.len(), 11);
|
|
|
|
|
|
assert!(chat[4].summary && chat[5].text == "問 4");
|
|
|
|
|
|
assert_eq!(model_view_start(&chat), 4);
|
|
|
|
|
|
// Again later: the model's view started at 「摘要一」; it folded 3 of it (the summary,
|
|
|
|
|
|
// 問 4, 答 5) and kept from 「問 6」 (at 7).
|
|
|
|
|
|
fold_in(&mut chat, 3, sum("摘要二", 200), 7);
|
|
|
|
|
|
assert_eq!(chat.len(), 12);
|
|
|
|
|
|
assert_eq!(chat.iter().filter(|m| m.summary).count(), 2);
|
|
|
|
|
|
assert_eq!(model_view_start(&chat), 7);
|
|
|
|
|
|
assert_eq!(chat[8].text, "問 6");
|
|
|
|
|
|
assert_eq!(chat.iter().filter(|m| !m.summary).count(), 10, "nothing the person saw was removed");
|
|
|
|
|
|
// A boundary the chat can't find: the fold lands by count within the model's view.
|
|
|
|
|
|
fold_in(&mut chat, 2, sum("摘要三", 300), 9999);
|
|
|
|
|
|
assert!(chat[9].summary && chat[9].text == "摘要三");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-05 06:25:01 +00:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn a_new_install_has_no_bots() {
|
|
|
|
|
|
let mut s = State::first_run();
|
|
|
|
|
|
assert!(s.bots.is_empty());
|
|
|
|
|
|
s.fix_primary();
|
|
|
|
|
|
assert_eq!((s.primary_bot, s.selected), (0, 0));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn old_unused_sample_bots_go_away() {
|
|
|
|
|
|
let mut s = State::samples();
|
|
|
|
|
|
s.drop_untouched_samples();
|
|
|
|
|
|
assert!(s.bots.is_empty());
|
|
|
|
|
|
// Used once: they stay.
|
|
|
|
|
|
let mut s = State::samples();
|
|
|
|
|
|
s.bots[2].messages.push(Message::user("幫我改稿"));
|
|
|
|
|
|
s.drop_untouched_samples();
|
|
|
|
|
|
assert_eq!(s.bots.len(), 5);
|
|
|
|
|
|
// Renamed: it stays.
|
|
|
|
|
|
let mut s = State::samples();
|
|
|
|
|
|
s.bots[0].name = "小幫手".into();
|
|
|
|
|
|
s.drop_untouched_samples();
|
|
|
|
|
|
assert_eq!(s.bots.len(), 5);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn signing_in_takes_in_only_the_bots_not_here_yet() {
|
|
|
|
|
|
let other = State::samples();
|
|
|
|
|
|
let mut here = State::first_run();
|
|
|
|
|
|
assert_eq!(here.take_in_bots(&other), 5);
|
|
|
|
|
|
assert_eq!(here.primary_bot, here.bots[0].id);
|
|
|
|
|
|
// The same bots again: nothing doubles.
|
|
|
|
|
|
assert_eq!(here.take_in_bots(&other), 0);
|
|
|
|
|
|
// A bot of the same name with a different chat is a different bot.
|
|
|
|
|
|
let mut used = State::samples();
|
|
|
|
|
|
used.bots[1].messages.push(Message::user("嗨"));
|
|
|
|
|
|
assert_eq!(here.take_in_bots(&used), 1);
|
|
|
|
|
|
let ids: std::collections::HashSet<u64> = here.bots.iter().map(|b| b.id).collect();
|
|
|
|
|
|
assert_eq!(ids.len(), here.bots.len());
|
|
|
|
|
|
}
|
Bug sweep: gates, relay 2FA, Windows input, snapshots, encodings
Permissions and reach
- a file leaving a computer passes the same questions as reading it;
"looking" shell commands that do more than look ask first
- a symlink out of the work/sealed folder counts as outside it
(real paths, not just lexical ones); the search walk skips links
- web_fetch and skill_install read the public web only: localhost, the
LAN and cloud metadata are refused, at every redirect
- preview shows a file without running it; browser_upload may ask
- vault: logins no longer match across shared suffixes (pages.dev,
com.tw, ...); registrable_domain knows public suffixes
- axwrite: found elements are retained (use-after-free)
Relay
- replacing or turning off 2FA needs the current code (rate-limited);
new recovery codes only count once enrollment is verified
- a removed computer loses its socket and its `call` access
- a site name taken by another account answers 409
- RELAY_TRUST_PROXY: the login lock counts the caller's address, not
the proxy's (compose files and README)
- httpd: half a body is not a request
Desktop
- sign-in, join and restore work with 2FA on (code field; --serve reads
ANYBOT_ACCOUNT_CODE; the setup wizard points to a pairing code)
- Windows: text was typed twice; modifiers are held through click, drag
and scroll; horizontal scroll; screen control is no longer refused
- a recording that hits the limit is transcribed, not thrown away
- bot's computer: a new home volume restores the snapshot; a snapshot
is read through before the home is wiped; a failed volume removal
stops reset/restore
- events of a stopped turn no longer land in the next one; each channel
message gets a turn of its own
- saving a bot's settings keeps what it got meanwhile (three-way merge);
a save file that cannot be read is kept
- pages in Big5/GBK/Shift_JIS are read in their own encoding
(encoding_rs); osascript output past 64 KB no longer stalls
- pick_tab keeps a job's own non-http tab; pictures larger than the
texture limit are fitted; the auto-update time field takes typing
- skills live in <data>/skills; memories are unnumbered in the prompt;
the phone may skillify and skill_install
CI
- the test step fails when the tests fail (it piped into tail)
Tests: anybot 268 passed, 27 ignored; relay 16 passed; clippy unchanged.
Not run: Windows on a real machine (win.rs type-checked alone), the
Docker paths of the bot's computer, the live accessibility writes.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 03:46:17 +00:00
|
|
|
|
|
|
|
|
|
|
/// A save that is there but can't be read is kept, not taken for a first run (the next save
|
|
|
|
|
|
/// wrote a fresh AnyBot over it).
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn a_save_that_cannot_be_read_is_kept() {
|
|
|
|
|
|
let d = std::env::temp_dir().join(format!("anybot-load-{}", std::process::id()));
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&d);
|
|
|
|
|
|
std::fs::create_dir_all(&d).unwrap();
|
|
|
|
|
|
let p = d.join("state.json");
|
|
|
|
|
|
let kept = |mark: &str| std::fs::read_dir(&d).unwrap().flatten().map(|e| e.path()).find(|f| f.to_string_lossy().contains(mark));
|
|
|
|
|
|
// No save at all: a first run, and nothing is set aside.
|
|
|
|
|
|
State::load_from(&p);
|
|
|
|
|
|
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 0);
|
|
|
|
|
|
// Bytes that aren't text (a disk gone wrong): moved aside whole.
|
|
|
|
|
|
let bytes = [0xff, 0xfe, 0x00, 0x41, 0xff];
|
|
|
|
|
|
std::fs::write(&p, bytes).unwrap();
|
|
|
|
|
|
State::load_from(&p);
|
|
|
|
|
|
assert!(!p.exists());
|
|
|
|
|
|
assert_eq!(std::fs::read(kept("unreadable").unwrap()).unwrap(), bytes);
|
|
|
|
|
|
// Text that isn't a save: a copy is kept, as before.
|
|
|
|
|
|
std::fs::write(&p, "{ not json").unwrap();
|
|
|
|
|
|
State::load_from(&p);
|
|
|
|
|
|
assert_eq!(std::fs::read_to_string(kept("damaged").unwrap()).unwrap(), "{ not json");
|
|
|
|
|
|
// A good save loads.
|
|
|
|
|
|
let mut s = State::samples();
|
|
|
|
|
|
s.bots[0].name = "留著的".into();
|
|
|
|
|
|
std::fs::write(&p, serde_json::to_string(&s).unwrap()).unwrap();
|
|
|
|
|
|
assert_eq!(State::load_from(&p).bots[0].name, "留著的");
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&d);
|
|
|
|
|
|
}
|
2026-11-05 06:25:01 +00:00
|
|
|
|
}
|