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
|
|
|
|
// 手機遙控: use AnyBot from a phone (or any browser).
|
|
|
|
|
|
//
|
|
|
|
|
|
// Two ways in, one API:
|
|
|
|
|
|
// • 區域網路/Tailscale: this AnyBot answers on port 8793 itself (password login).
|
|
|
|
|
|
// • 帳號(薄後端): this AnyBot keeps an outgoing connection to the relay (relay/ in this repo,
|
|
|
|
|
|
// run on a small server). The phone signs in to the relay with the account, and the relay hands
|
|
|
|
|
|
// each request to this AnyBot. No open port, works from anywhere, and the relay also keeps the
|
|
|
|
|
|
// account, the device list and an encrypted backup of AnyBot's data.
|
|
|
|
|
|
//
|
|
|
|
|
|
// The original keeps its brain in its cloud and reaches devices from there (its device tools wait
|
|
|
|
|
|
// 30 s for the device). Here the brain stays in AnyBot — on this Mac, or on an always-on server
|
|
|
|
|
|
// started with `anybot --serve` — and the relay only passes requests along.
|
|
|
|
|
|
|
|
|
|
|
|
use crate::httpd::{self, Req, Resp};
|
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
|
use serde_json::{json, Value};
|
|
|
|
|
|
use std::collections::HashMap;
|
|
|
|
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
|
|
|
|
use std::sync::{Arc, Condvar, Mutex, OnceLock};
|
|
|
|
|
|
use std::time::{Duration, Instant};
|
|
|
|
|
|
|
|
|
|
|
|
pub const LAN_PORT: u16 = 8793;
|
|
|
|
|
|
/// How long the phone's "anything new?" request waits.
|
|
|
|
|
|
pub const LONG_POLL_SECS: u64 = 20;
|
|
|
|
|
|
/// Wrong passwords allowed before logins slow down.
|
|
|
|
|
|
const MAX_FAILS: u32 = 5;
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Serialize, Deserialize, Debug, Default, PartialEq)]
|
|
|
|
|
|
pub struct Device {
|
|
|
|
|
|
pub name: String,
|
|
|
|
|
|
pub token_hash: String,
|
|
|
|
|
|
pub created: i64,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub last_seen: i64,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
|
|
|
|
|
|
pub struct Settings {
|
|
|
|
|
|
/// Answer phones on the local network (and Tailscale) on LAN_PORT.
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub lan: bool,
|
|
|
|
|
|
/// The phone password, stored as salt$hash.
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub password: String,
|
|
|
|
|
|
/// Phones and browsers signed in directly.
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub devices: Vec<Device>,
|
|
|
|
|
|
/// The phone may see and operate this Mac's own screen (not just the bot's computer).
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub mac_from_phone: bool,
|
|
|
|
|
|
/// Other AnyBots in the same account may operate this Mac (裝置連結). Each action still follows
|
|
|
|
|
|
/// this Mac's rules and asks the person on the brain's side.
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub device_link: bool,
|
|
|
|
|
|
/// The relay (thin backend), e.g. https://anybot.example.com
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub relay_url: String,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub relay_user: String,
|
|
|
|
|
|
/// This AnyBot's own key at the relay (a "host").
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub host_id: String,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub host_token: String,
|
|
|
|
|
|
/// What this AnyBot is called on the phone.
|
|
|
|
|
|
#[serde(default = "default_host_name")]
|
|
|
|
|
|
pub host_name: String,
|
|
|
|
|
|
/// Key for the encrypted backup (made from the account password; never sent).
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub backup_key: String,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub auto_backup: bool,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub last_backup: i64,
|
2026-11-04 09:46:43 +00:00
|
|
|
|
/// 大腦: the AnyBot of this account whose bots, chats and settings this one shows (its relay
|
|
|
|
|
|
/// host id). Empty = this AnyBot is its own brain. When set, this one is a screen and hands.
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
|
|
|
|
|
pub brain_id: String,
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
|
|
|
|
|
pub brain_name: String,
|
2026-11-04 12:38:45 +00:00
|
|
|
|
/// 手腳: only this brain (host id) may use this computer (empty = any AnyBot of the account).
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
|
|
|
|
|
pub hands_for: String,
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
|
|
|
|
|
pub hands_for_name: 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
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl Default for Settings {
|
|
|
|
|
|
fn default() -> Self {
|
2026-11-04 12:38:45 +00:00
|
|
|
|
Settings { lan: false, password: String::new(), devices: vec![], mac_from_phone: false, device_link: false, relay_url: String::new(), relay_user: String::new(), host_id: String::new(), host_token: String::new(), host_name: default_host_name(), backup_key: String::new(), auto_backup: true, last_backup: 0, brain_id: String::new(), brain_name: String::new(), hands_for: String::new(), hands_for_name: 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
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn default_host_name() -> String {
|
2026-11-05 08:47:28 +00:00
|
|
|
|
// Asked of the system once (it runs a program).
|
|
|
|
|
|
static N: OnceLock<String> = OnceLock::new();
|
|
|
|
|
|
N.get_or_init(system_host_name).clone()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn system_host_name() -> 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
|
|
|
|
if !cfg!(target_os = "macos") {
|
|
|
|
|
|
let pretty = std::process::Command::new("hostnamectl").arg("--pretty").output().ok().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default();
|
|
|
|
|
|
let name = if pretty.is_empty() { std::fs::read_to_string("/etc/hostname").unwrap_or_default().trim().to_string() } else { pretty };
|
|
|
|
|
|
if !name.is_empty() {
|
|
|
|
|
|
return name;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
let out = std::process::Command::new("scutil").args(["--get", "ComputerName"]).output().ok().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default();
|
|
|
|
|
|
if !out.is_empty() {
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
std::env::var("HOSTNAME").ok().filter(|h| !h.is_empty()).unwrap_or_else(|| "AnyBot".into())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// What the phone asks AnyBot to do; the app carries these out on its own thread.
|
|
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
|
|
|
|
pub enum Cmd {
|
2026-11-05 08:47:28 +00:00
|
|
|
|
/// `from`: the account computer the person typed it on (empty: the phone, or this one).
|
|
|
|
|
|
Send { bot: u64, text: String, attachments: Vec<String>, from: 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
|
|
|
|
Stop { bot: u64 },
|
|
|
|
|
|
Answer { bot: u64, answer: String },
|
|
|
|
|
|
Read { bot: u64 },
|
|
|
|
|
|
NewThread { bot: u64 },
|
2026-11-06 12:33:06 +00:00
|
|
|
|
/// The time zone of the phone the person uses (a brain on a server only knows UTC).
|
|
|
|
|
|
PersonTimezone(String),
|
Phone: a home with the computers, pages and routines; each bot's settings a tap away; long jobs fold up
The phone page was bare: one row per bot, a chat listing every step, a menu with four lines.
- Home: the main bot large at the top with its model and last words, the other bots with times,
then its computers (own box and the account's, online or not, 看畫面), the newest pages and the
routines with their next run.
- Chat: the bot's name opens its settings — model (chat models only), computer, past conversations
(restore), regenerate, new chat. A turn's steps fold into 「已完成 N 個步驟」 when done (the newest
three stay while it works); copy and regenerate under replies; four ready starts in a new chat;
a jump back to the newest message.
- Menu: 外觀 (follow the phone, light, dark).
- API: api/state carries pages and models; each bot its model, computers, routines, past
conversations; POST api/bots/{id}/set | regenerate | restore. The home's last line skips errors.
Checked on a local copy of the brain's data (cut off from the account and Docker) at iPhone size.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
2026-11-06 12:28:08 +00:00
|
|
|
|
/// The phone changes a bot's model or its computer (empty = unchanged).
|
|
|
|
|
|
SetBot { bot: u64, provider: String, model: String, computer: String },
|
2026-11-04 09:46:43 +00:00
|
|
|
|
/// Answer the last message again (重新產生) / reply to what is there (重新傳送).
|
|
|
|
|
|
Regenerate { bot: u64 },
|
|
|
|
|
|
Reply { bot: u64 },
|
|
|
|
|
|
/// Bring back an earlier conversation (過去的對話).
|
|
|
|
|
|
RestoreThread { bot: u64, index: usize },
|
2026-11-05 08:47:28 +00:00
|
|
|
|
/// 清除聊天記錄 from a screen of this brain (the chat lives here).
|
|
|
|
|
|
Clear { bot: 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
|
|
|
|
/// A new phone signed in (to remember), or one was signed out.
|
|
|
|
|
|
DeviceAdded(Device),
|
|
|
|
|
|
DeviceSeen(String),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// What the phone can see, refreshed by the app when something changes.
|
|
|
|
|
|
#[derive(Default, Clone)]
|
|
|
|
|
|
pub struct View {
|
|
|
|
|
|
pub host: String,
|
|
|
|
|
|
pub bots: Vec<Value>,
|
|
|
|
|
|
pub approvals: Vec<Value>,
|
|
|
|
|
|
pub messages: HashMap<u64, Vec<Value>>,
|
頁面: 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
|
|
|
|
/// 頁面, for the phone's page view.
|
|
|
|
|
|
pub pages: Vec<crate::pages::Page>,
|
Phone: a home with the computers, pages and routines; each bot's settings a tap away; long jobs fold up
The phone page was bare: one row per bot, a chat listing every step, a menu with four lines.
- Home: the main bot large at the top with its model and last words, the other bots with times,
then its computers (own box and the account's, online or not, 看畫面), the newest pages and the
routines with their next run.
- Chat: the bot's name opens its settings — model (chat models only), computer, past conversations
(restore), regenerate, new chat. A turn's steps fold into 「已完成 N 個步驟」 when done (the newest
three stay while it works); copy and regenerate under replies; four ready starts in a new chat;
a jump back to the newest message.
- Menu: 外觀 (follow the phone, light, dark).
- API: api/state carries pages and models; each bot its model, computers, routines, past
conversations; POST api/bots/{id}/set | regenerate | restore. The home's last line skips errors.
Checked on a local copy of the brain's data (cut off from the account and Docker) at iPhone size.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
2026-11-06 12:28:08 +00:00
|
|
|
|
/// The models the phone can switch a bot to: each ready provider with its known models.
|
|
|
|
|
|
pub models: Vec<Value>,
|
The phone page does what the desktop does, laid out like it
A new mobile/index.html in the desktop's (and Grok Bot's) shape: the sidebar
with its sections and folders, the chat with steps, thinking, reactions, quotes
and the approval card, 對話詳細資訊, Bot 設定, routines, pages, computers and
設定. Each action goes to the desktop's own code on the app's thread
(api/act/<what> -> App::phone_act): appctl, set_pinned, restart_bot,
toggle_reaction, box_task, channels_restart and the rest. No new rules.
Keys, channel tokens and the vault never reach the phone; a model key can only
be written. Admins keep what the phone always let them do; making, changing or
deleting bots, routines, memories, pages, folders and settings stays the
owner's. The state the phone polls now carries the sidebar's section order.
Checked by using it on a preview brain: routines, memories, templates, folders,
keys, signing another phone out, approvals and a chat with a fake model.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
2026-11-06 19:05:12 +00:00
|
|
|
|
/// The sidebar's 分區 in order (None = 未分區), empty ones too, as the desktop shows them.
|
|
|
|
|
|
pub sections: Vec<Option<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
|
|
|
|
pub version: u64,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub struct Hub {
|
|
|
|
|
|
view: Mutex<View>,
|
|
|
|
|
|
changed: Condvar,
|
|
|
|
|
|
cmds: Mutex<Vec<Cmd>>,
|
|
|
|
|
|
repaint: Mutex<Option<Arc<dyn Fn() + Send + Sync>>>,
|
|
|
|
|
|
settings: Mutex<Settings>,
|
2026-11-05 07:48:20 +00:00
|
|
|
|
/// Wrong logins, counted per source (one noisy LAN device must not lock out the owner).
|
|
|
|
|
|
fails: Mutex<HashMap<String, (u32, Instant)>>,
|
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
|
|
|
|
relay_status: Mutex<String>,
|
2026-11-04 09:46:43 +00:00
|
|
|
|
sync: Mutex<Vec<SyncReq>>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 大腦: another AnyBot of this account (a screen) asks for what changed, or sends its edits.
|
|
|
|
|
|
pub enum SyncKind {
|
The phone page does what the desktop does, laid out like it
A new mobile/index.html in the desktop's (and Grok Bot's) shape: the sidebar
with its sections and folders, the chat with steps, thinking, reactions, quotes
and the approval card, 對話詳細資訊, Bot 設定, routines, pages, computers and
設定. Each action goes to the desktop's own code on the app's thread
(api/act/<what> -> App::phone_act): appctl, set_pinned, restart_bot,
toggle_reaction, box_task, channels_restart and the rest. No new rules.
Keys, channel tokens and the vault never reach the phone; a model key can only
be written. Admins keep what the phone always let them do; making, changing or
deleting bots, routines, memories, pages, folders and settings stays the
owner's. The state the phone polls now carries the sidebar's section order.
Checked by using it on a preview brain: routines, memories, templates, folders,
keys, signing another phone out, approvals and a chat with a fake model.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
2026-11-06 19:05:12 +00:00
|
|
|
|
/// 手機: something the desktop does with a bot, a page, a routine or a setting, or one of the
|
|
|
|
|
|
/// views it shows (a bot's details, 設定), done on the app's thread; `role` is who asks.
|
|
|
|
|
|
Phone { action: String, body: Value, role: String },
|
2026-11-04 09:46:43 +00:00
|
|
|
|
Pull { client: String, since: u64 },
|
|
|
|
|
|
Edit(Value),
|
2026-11-04 11:30:32 +00:00
|
|
|
|
/// A provider's model list, fetched with the brain's own key (screens don't have it).
|
|
|
|
|
|
Models(String),
|
2026-11-04 09:46:43 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub struct SyncReq {
|
|
|
|
|
|
pub kind: SyncKind,
|
|
|
|
|
|
pub reply: std::sync::mpsc::Sender<Value>,
|
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 hub() -> &'static Hub {
|
|
|
|
|
|
static H: OnceLock<Hub> = OnceLock::new();
|
2026-11-05 07:48:20 +00:00
|
|
|
|
H.get_or_init(|| Hub { view: Mutex::new(View::default()), changed: Condvar::new(), cmds: Mutex::new(vec![]), repaint: Mutex::new(None), settings: Mutex::new(Settings::default()), fails: Mutex::new(HashMap::new()), relay_status: Mutex::new(String::new()), sync: Mutex::new(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
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
static VERSION: AtomicU64 = AtomicU64::new(1);
|
|
|
|
|
|
|
|
|
|
|
|
impl Hub {
|
|
|
|
|
|
pub fn publish(&self, mut v: View) {
|
|
|
|
|
|
v.version = VERSION.fetch_add(1, Ordering::SeqCst) + 1;
|
|
|
|
|
|
*self.view.lock().unwrap() = v;
|
|
|
|
|
|
self.changed.notify_all();
|
|
|
|
|
|
}
|
2026-11-04 09:46:43 +00:00
|
|
|
|
pub fn take_sync(&self) -> Vec<SyncReq> {
|
|
|
|
|
|
std::mem::take(&mut *self.sync.lock().unwrap())
|
|
|
|
|
|
}
|
|
|
|
|
|
/// Hand a sync request to the app's thread and wait for its answer.
|
|
|
|
|
|
fn ask_sync(&self, kind: SyncKind) -> Result<Value, String> {
|
2026-11-04 11:30:32 +00:00
|
|
|
|
self.ask_sync_for(kind, 10)
|
|
|
|
|
|
}
|
|
|
|
|
|
fn ask_sync_for(&self, kind: SyncKind, secs: u64) -> Result<Value, String> {
|
2026-11-04 09:46:43 +00:00
|
|
|
|
let (tx, rx) = std::sync::mpsc::channel();
|
|
|
|
|
|
self.sync.lock().unwrap().push(SyncReq { kind, reply: tx });
|
|
|
|
|
|
if let Some(r) = self.repaint.lock().unwrap().as_ref() {
|
|
|
|
|
|
r();
|
|
|
|
|
|
}
|
2026-11-04 11:30:32 +00:00
|
|
|
|
rx.recv_timeout(Duration::from_secs(secs)).map_err(|_| "大腦太忙,沒有及時回應".to_string())
|
2026-11-04 09:46:43 +00:00
|
|
|
|
}
|
|
|
|
|
|
pub fn version(&self) -> u64 {
|
|
|
|
|
|
self.view.lock().unwrap().version
|
|
|
|
|
|
}
|
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 take_cmds(&self) -> Vec<Cmd> {
|
|
|
|
|
|
std::mem::take(&mut *self.cmds.lock().unwrap())
|
|
|
|
|
|
}
|
|
|
|
|
|
fn push(&self, c: Cmd) {
|
|
|
|
|
|
self.cmds.lock().unwrap().push(c);
|
|
|
|
|
|
if let Some(r) = self.repaint.lock().unwrap().as_ref() {
|
|
|
|
|
|
r();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
pub fn set_repaint(&self, f: Arc<dyn Fn() + Send + Sync>) {
|
|
|
|
|
|
*self.repaint.lock().unwrap() = Some(f);
|
|
|
|
|
|
}
|
|
|
|
|
|
pub fn set_settings(&self, s: &Settings) {
|
|
|
|
|
|
*self.settings.lock().unwrap() = s.clone();
|
|
|
|
|
|
}
|
|
|
|
|
|
pub fn settings(&self) -> Settings {
|
|
|
|
|
|
self.settings.lock().unwrap().clone()
|
|
|
|
|
|
}
|
|
|
|
|
|
pub fn relay_status(&self) -> String {
|
|
|
|
|
|
self.relay_status.lock().unwrap().clone()
|
|
|
|
|
|
}
|
|
|
|
|
|
fn set_relay_status(&self, s: &str) {
|
|
|
|
|
|
*self.relay_status.lock().unwrap() = s.to_string();
|
|
|
|
|
|
}
|
|
|
|
|
|
/// The view once it is newer than `since` (or after the wait).
|
|
|
|
|
|
fn wait_newer(&self, since: u64, secs: u64) -> View {
|
|
|
|
|
|
let g = self.view.lock().unwrap();
|
|
|
|
|
|
let (g, _) = self.changed.wait_timeout_while(g, Duration::from_secs(secs), |v| v.version <= since).unwrap();
|
|
|
|
|
|
g.clone()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------- shaping the chat for the phone ----------
|
|
|
|
|
|
|
|
|
|
|
|
pub fn tool_label(name: &str) -> &'static str {
|
|
|
|
|
|
if crate::appctl::is_app_tool(name) {
|
|
|
|
|
|
crate::appctl::label(name)
|
|
|
|
|
|
} else if crate::macapps::is_mac_tool(name) {
|
|
|
|
|
|
crate::macapps::label(name)
|
|
|
|
|
|
} else if crate::computer::is_computer_tool(name) {
|
|
|
|
|
|
crate::computer::label(name)
|
|
|
|
|
|
} else if crate::web::is_web_tool(name) {
|
|
|
|
|
|
crate::web::label(name)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
crate::tools::label(name)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn tool_detail(name: &str, args: &str) -> String {
|
|
|
|
|
|
let d = if crate::appctl::is_app_tool(name) {
|
|
|
|
|
|
crate::appctl::describe(name, args)
|
|
|
|
|
|
} else if crate::computer::is_computer_tool(name) {
|
|
|
|
|
|
crate::computer::describe(name, args)
|
|
|
|
|
|
} else if crate::web::is_web_tool(name) {
|
|
|
|
|
|
crate::web::describe(name, args)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
crate::tools::describe(name, args)
|
|
|
|
|
|
};
|
|
|
|
|
|
d.chars().take(120).collect()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// One message as the phone shows it (tool results are folded away like in the window).
|
頁面: 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
|
|
|
|
pub fn message_json(m: &crate::model::Message, names: &HashMap<u64, String>, pages: &[crate::pages::Page]) -> Option<Value> {
|
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
|
|
|
|
use crate::model::{Role, ToolStatus};
|
|
|
|
|
|
if m.role == Role::Tool {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
let calls: Vec<Value> = m
|
|
|
|
|
|
.tool_calls
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|c| {
|
|
|
|
|
|
let st = match c.status {
|
|
|
|
|
|
ToolStatus::Waiting => "waiting",
|
|
|
|
|
|
ToolStatus::Running => "running",
|
|
|
|
|
|
ToolStatus::Done => "done",
|
|
|
|
|
|
ToolStatus::Denied => "denied",
|
|
|
|
|
|
ToolStatus::Failed => "failed",
|
|
|
|
|
|
};
|
頁面: 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
|
|
|
|
let page = (c.name == "write_page").then(|| serde_json::from_str::<Value>(&c.args).ok().and_then(|a| a["id"].as_u64())).flatten().and_then(|id| pages.iter().find(|p| p.id == id));
|
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 c.name == "show_card" {
|
|
|
|
|
|
json!({"card": serde_json::from_str::<Value>(&c.args).unwrap_or(Value::Null), "status": st})
|
頁面: 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
|
|
|
|
} else if let Some(p) = page {
|
|
|
|
|
|
json!({"page": crate::pages::card_json(p, crate::model::now_ms()), "status": st})
|
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
|
|
|
|
} else {
|
|
|
|
|
|
json!({"label": tool_label(&c.name), "detail": tool_detail(&c.name, &c.args), "status": st})
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
Some(json!({
|
|
|
|
|
|
"role": match m.role { Role::User => "user", _ => "assistant" },
|
|
|
|
|
|
"text": m.text,
|
|
|
|
|
|
"via": m.via,
|
|
|
|
|
|
"error": m.error,
|
|
|
|
|
|
"summary": m.summary,
|
|
|
|
|
|
"wake": m.wake,
|
|
|
|
|
|
"author": if m.author == 0 { String::new() } else { names.get(&m.author).cloned().unwrap_or_default() },
|
|
|
|
|
|
"reactions": m.reactions,
|
Real-use fixes: the person sees the screens the bot shows, files reach the phone, no blank-tab loops
From real runs on the cloud brain with the person's Mac (Threads patrol, a post draft, "show me my
screen"):
- computer_use_show: a plain picture of any computer's screen goes into the chat (the bot sees it
too). The bot used to describe a screenshot in words and fail to send it.
- The phone shows the bot's attachments: pictures in place, other files download on a tap (api/file,
only from the chats' file folder). It showed nothing for the bot and names only for the person.
- send_file works from the person's other computers (fetch_file over the relay, 20 MB, secrets stay).
- The same call with the same answer again and again is warned the 3rd time and stopped from the
4th (a blank tab was read 11 times, then the bot said Threads was logged out without opening it);
a blank tab says so; checking a login means opening the site.
- Screenshot lists: a nameless row around a named button isn't numbered, a nameless row takes the
words inside it, empty spacer rows aren't numbered (System Settings: 63 numbers -> 33, all named).
- Writing "in my voice" reads the person's own recent posts first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
2026-11-06 11:37:45 +00:00
|
|
|
|
"attachments": m.attachments.iter().map(|a| json!({"name": a.name, "image": a.image, "path": a.path})).collect::<Vec<_>>(),
|
Phone: a home with the computers, pages and routines; each bot's settings a tap away; long jobs fold up
The phone page was bare: one row per bot, a chat listing every step, a menu with four lines.
- Home: the main bot large at the top with its model and last words, the other bots with times,
then its computers (own box and the account's, online or not, 看畫面), the newest pages and the
routines with their next run.
- Chat: the bot's name opens its settings — model (chat models only), computer, past conversations
(restore), regenerate, new chat. A turn's steps fold into 「已完成 N 個步驟」 when done (the newest
three stay while it works); copy and regenerate under replies; four ready starts in a new chat;
a jump back to the newest message.
- Menu: 外觀 (follow the phone, light, dark).
- API: api/state carries pages and models; each bot its model, computers, routines, past
conversations; POST api/bots/{id}/set | regenerate | restore. The home's last line skips errors.
Checked on a local copy of the brain's data (cut off from the account and Docker) at iPhone size.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
2026-11-06 12:28:08 +00:00
|
|
|
|
"at": m.at,
|
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
|
|
|
|
"tools": calls,
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Newest messages kept in the phone's view of each chat.
|
|
|
|
|
|
pub const PHONE_TAIL: usize = 120;
|
|
|
|
|
|
|
|
|
|
|
|
// ---------- the API (both the local server and the relay call this) ----------
|
|
|
|
|
|
|
|
|
|
|
|
fn devices_ok(token: &str) -> Option<String> {
|
|
|
|
|
|
if token.is_empty() {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
let h = httpd::sha256_hex(token);
|
|
|
|
|
|
hub().settings.lock().unwrap().devices.iter().find(|d| d.token_hash == h).map(|d| d.token_hash.clone())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// `via_relay`: the relay already checked the account, so no phone password is needed.
|
|
|
|
|
|
pub fn handle(req: Req, via_relay: bool) -> Resp {
|
|
|
|
|
|
let parts = req.parts();
|
Clippy is clean on macOS and Linux, for the app and the relay
Every warning `cargo clippy --all-targets` gave (50 on macOS, 8 more only on
Linux, 1 in the relay), fixed without changing what anything does:
- long types get a name (Peer, Members, Imported, Joining, Mention, Stream…);
- the two big enum variants are boxed (the vault's login plan, joining an
account);
- a test now waits for the copy it relaunched (it left a zombie behind);
- a hint's format had a dead `if` whose two branches were both "";
- the rest is what clippy suggests: sort_by_key, as_chunks, is_none_or,
is_multiple_of, while let, from_ref, next_back, struct init, no casts to
the same type.
Checked: cargo test (177 macOS, 181 Linux), relay tests (11), remote_e2e
28/28, and the live Linux test (X11; Wayland clicks, keys, Chinese typing,
scroll and screenshots).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
2026-11-07 02:10:28 +00:00
|
|
|
|
let path: Vec<&str> = parts.to_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
|
|
|
|
match (req.method.as_str(), path.as_slice()) {
|
|
|
|
|
|
("GET", []) | ("GET", ["index.html"]) => return page(),
|
People watch and help on the bots' computer through VNC (noVNC), carried by the account server
The core: the bot works the computer; a person watches it like a colleague, and takes over
when it needs them. VNC is made for that (only what changed, every mouse move and key, touch on
phones), so the pictures sent one by one go, and with them their own input path.
- vnc.rs: a VNC proxy to the box's x11vnc through one docker exec (a Python bridge that ends
with either side). It signs in to the box with the box's own password (its VNC_PASSWORD) and
offers the viewer no password, so the password never leaves the brain. The person's clicks
and keys make the bot's steps wait until 8 s after they stop; text typed in the viewer's box
(any language) is pasted in the box.
- Account server: /screen (the viewer page) and /novnc.js (noVNC 1.6.0, MPL-2.0, bundled);
/relay/vnc/<host> takes the browser's WebSocket (account session, or a 10-minute ticket the
app asks for with POST /relay/screen-ticket; owners and admins) and carries it over that
AnyBot's lasting connection (vopen / vdata / vtext / vclose). Both ends of that connection
wait 2 ms instead of 40 ms to read while things move: a request and its answer took up to
160 ms more than needed.
- Phone page: 看 Bot 的電腦 and 接管 open the viewer page (also on the local network: /api/vnc on
the AnyBot's own phone server). This Mac's own screen moved to the ⋯ menu.
- Mac app: 看畫面 opens the viewer in the browser (a screen of a brain: the brain's computer by
ticket; else this computer's box, noVNC straight from it). The in-app picture viewer, the
live JPEG stream, the person's click/move/scroll/key endpoints and the input shell are gone.
- Opening a Mac app uses LaunchServices (`open -a`): agent-desktop's launch waited on the window
through accessibility and failed without 輔助使用 now and then.
Measured on this Mac (made-up account, local relay + headless brain + the real box): viewer
signed in in 0.4–0.7 s, a request and its answer through relay + brain + box 25–58 ms median;
headless Chrome shows the desktop live in /screen. remote_e2e 28/28 ×2, 149 unit tests, relay 11.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:05:44 +00:00
|
|
|
|
("GET", ["screen"]) => return Resp::bytes("text/html; charset=utf-8", SCREEN_PAGE.as_bytes().to_vec()).with_header("Content-Security-Policy", "default-src 'self'; img-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss:"),
|
|
|
|
|
|
("GET", ["novnc.js"]) => return Resp::bytes("text/javascript; charset=utf-8", NOVNC.as_bytes().to_vec()).with_header("Cache-Control", "public, max-age=86400"),
|
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
|
|
|
|
("GET", ["manifest.webmanifest"]) => return Resp::bytes("application/manifest+json", MANIFEST.as_bytes().to_vec()),
|
|
|
|
|
|
("GET", ["icon.svg"]) => return Resp::bytes("image/svg+xml", ICON.as_bytes().to_vec()),
|
2026-11-05 11:20:29 +00:00
|
|
|
|
("GET", [name]) if ICON_PNGS.iter().any(|(n, _)| n == name) => return Resp::bytes("image/png", ICON_PNGS.iter().find(|(n, _)| n == name).map(|(_, b)| b.to_vec()).unwrap_or_default()),
|
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
|
|
|
|
("GET", ["api", "hello"]) => {
|
|
|
|
|
|
let s = hub().settings();
|
|
|
|
|
|
return Resp::json(&json!({"app": "anybot", "host": s.host_name, "relay": via_relay, "needs_password": !via_relay}));
|
|
|
|
|
|
}
|
|
|
|
|
|
("POST", ["api", "login"]) if !via_relay => return login(&req),
|
|
|
|
|
|
_ => {}
|
|
|
|
|
|
}
|
|
|
|
|
|
if !via_relay {
|
|
|
|
|
|
match devices_ok(&req.bearer()) {
|
|
|
|
|
|
Some(h) => hub().push(Cmd::DeviceSeen(h)),
|
|
|
|
|
|
None => return Resp::error(401, "請先登入"),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
let s = hub().settings();
|
|
|
|
|
|
// Direct phones are the owner's. Through the relay, the relay says who is asking.
|
|
|
|
|
|
let role = if via_relay { Some(req.header("x-anybot-role")).filter(|r| !r.is_empty()).unwrap_or("owner").to_string() } else { "owner".to_string() };
|
|
|
|
|
|
if !role_allows(&role, &req.method, &path) {
|
|
|
|
|
|
return Resp::error(403, "你在這台 AnyBot 的角色不能做這件事(請擁有者處理)");
|
|
|
|
|
|
}
|
|
|
|
|
|
match (req.method.as_str(), path.as_slice()) {
|
|
|
|
|
|
("GET", ["api", "state"]) => {
|
|
|
|
|
|
let since: u64 = req.query.get("since").and_then(|v| v.parse().ok()).unwrap_or(0);
|
2026-11-06 12:33:06 +00:00
|
|
|
|
// The phone says where the person is (once per change, not every poll).
|
|
|
|
|
|
if let Some(tz) = req.query.get("tz").filter(|t| t.parse::<chrono_tz::Tz>().is_ok()) {
|
|
|
|
|
|
static TOLD: Mutex<String> = Mutex::new(String::new());
|
|
|
|
|
|
let mut told = TOLD.lock().unwrap_or_else(|e| e.into_inner());
|
|
|
|
|
|
if *told != *tz {
|
|
|
|
|
|
*told = tz.clone();
|
|
|
|
|
|
hub().push(Cmd::PersonTimezone(tz.clone()));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
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 v = hub().wait_newer(since, if since == 0 { 0 } else { LONG_POLL_SECS });
|
Phone: a home with the computers, pages and routines; each bot's settings a tap away; long jobs fold up
The phone page was bare: one row per bot, a chat listing every step, a menu with four lines.
- Home: the main bot large at the top with its model and last words, the other bots with times,
then its computers (own box and the account's, online or not, 看畫面), the newest pages and the
routines with their next run.
- Chat: the bot's name opens its settings — model (chat models only), computer, past conversations
(restore), regenerate, new chat. A turn's steps fold into 「已完成 N 個步驟」 when done (the newest
three stay while it works); copy and regenerate under replies; four ready starts in a new chat;
a jump back to the newest message.
- Menu: 外觀 (follow the phone, light, dark).
- API: api/state carries pages and models; each bot its model, computers, routines, past
conversations; POST api/bots/{id}/set | regenerate | restore. The home's last line skips errors.
Checked on a local copy of the brain's data (cut off from the account and Docker) at iPhone size.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
2026-11-06 12:28:08 +00:00
|
|
|
|
// 頁面 for the phone's home: the newest first, each with its bot.
|
|
|
|
|
|
let now = crate::model::now_ms();
|
|
|
|
|
|
let mut pages: Vec<&crate::pages::Page> = v.pages.iter().collect();
|
|
|
|
|
|
pages.sort_by_key(|p| std::cmp::Reverse(p.updated));
|
|
|
|
|
|
let bot_name = |id: u64| v.bots.iter().find(|b| b["id"].as_u64() == Some(id)).and_then(|b| b["name"].as_str()).unwrap_or("").to_string();
|
|
|
|
|
|
let pages: Vec<Value> = pages.into_iter().take(12).map(|p| {
|
|
|
|
|
|
let mut c = crate::pages::card_json(p, now);
|
|
|
|
|
|
c["bot"] = json!(bot_name(p.bot));
|
|
|
|
|
|
c
|
|
|
|
|
|
}).collect();
|
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
|
|
|
|
Resp::json(&json!({
|
|
|
|
|
|
"version": v.version, "host": v.host, "bots": v.bots, "approvals": v.approvals,
|
|
|
|
|
|
"box": crate::cloudbox::run_state().label(),
|
|
|
|
|
|
"box_available": crate::cloudbox::available(),
|
|
|
|
|
|
"mac": s.mac_from_phone && cfg!(target_os = "macos") && role != "member",
|
|
|
|
|
|
"role": role,
|
Phone: a home with the computers, pages and routines; each bot's settings a tap away; long jobs fold up
The phone page was bare: one row per bot, a chat listing every step, a menu with four lines.
- Home: the main bot large at the top with its model and last words, the other bots with times,
then its computers (own box and the account's, online or not, 看畫面), the newest pages and the
routines with their next run.
- Chat: the bot's name opens its settings — model (chat models only), computer, past conversations
(restore), regenerate, new chat. A turn's steps fold into 「已完成 N 個步驟」 when done (the newest
three stay while it works); copy and regenerate under replies; four ready starts in a new chat;
a jump back to the newest message.
- Menu: 外觀 (follow the phone, light, dark).
- API: api/state carries pages and models; each bot its model, computers, routines, past
conversations; POST api/bots/{id}/set | regenerate | restore. The home's last line skips errors.
Checked on a local copy of the brain's data (cut off from the account and Docker) at iPhone size.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
2026-11-06 12:28:08 +00:00
|
|
|
|
"pages": pages,
|
|
|
|
|
|
"models": if role == "member" { vec![] } else { v.models.clone() },
|
The phone page does what the desktop does, laid out like it
A new mobile/index.html in the desktop's (and Grok Bot's) shape: the sidebar
with its sections and folders, the chat with steps, thinking, reactions, quotes
and the approval card, 對話詳細資訊, Bot 設定, routines, pages, computers and
設定. Each action goes to the desktop's own code on the app's thread
(api/act/<what> -> App::phone_act): appctl, set_pinned, restart_bot,
toggle_reaction, box_task, channels_restart and the rest. No new rules.
Keys, channel tokens and the vault never reach the phone; a model key can only
be written. Admins keep what the phone always let them do; making, changing or
deleting bots, routines, memories, pages, folders and settings stays the
owner's. The state the phone polls now carries the sidebar's section order.
Checked by using it on a preview brain: routines, memories, templates, folders,
keys, signing another phone out, approvals and a chat with a fake model.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
2026-11-06 19:05:12 +00:00
|
|
|
|
"sections": v.sections,
|
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
|
|
|
|
}))
|
|
|
|
|
|
}
|
頁面: 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 whole page (or an older version with ?rev=N), and its charts drawn as SVG.
|
|
|
|
|
|
("GET", ["api", "pages", id]) => {
|
|
|
|
|
|
let id: u64 = id.parse().unwrap_or(0);
|
|
|
|
|
|
let v = hub().view.lock().unwrap();
|
|
|
|
|
|
let Some(cur) = v.pages.iter().find(|p| p.id == id) else { return Resp::error(404, "這個頁面已經刪除了") };
|
|
|
|
|
|
let shown = req.query.get("rev").and_then(|r| r.parse().ok()).and_then(|r| crate::pages::version_of(cur, r)).unwrap_or_else(|| cur.clone());
|
|
|
|
|
|
let bot = v.bots.iter().find(|b| b["id"].as_u64() == Some(cur.bot)).and_then(|b| b["name"].as_str()).unwrap_or("").to_string();
|
|
|
|
|
|
let mut out = crate::pages::page_json(&shown, &bot, crate::model::now_ms());
|
|
|
|
|
|
out["versions"] = crate::pages::page_json(cur, &bot, 0)["versions"].clone();
|
|
|
|
|
|
out["latest"] = json!(cur.revision);
|
|
|
|
|
|
Resp::json(&out)
|
|
|
|
|
|
}
|
|
|
|
|
|
("GET", ["api", "pages", id, "chart", n]) => {
|
|
|
|
|
|
let id: u64 = id.parse().unwrap_or(0);
|
|
|
|
|
|
let n: usize = n.parse().unwrap_or(0);
|
|
|
|
|
|
let body = {
|
|
|
|
|
|
let v = hub().view.lock().unwrap();
|
|
|
|
|
|
let Some(cur) = v.pages.iter().find(|p| p.id == id) else { return Resp::error(404, "這個頁面已經刪除了") };
|
|
|
|
|
|
req.query.get("rev").and_then(|r| r.parse().ok()).and_then(|r| crate::pages::version_of(cur, r)).unwrap_or_else(|| cur.clone()).body
|
|
|
|
|
|
};
|
|
|
|
|
|
let Some(src) = crate::pages::mermaid_blocks(&body).into_iter().nth(n) else { return Resp::error(404, "找不到這張圖") };
|
|
|
|
|
|
match crate::diagram::svg(&src, req.query.get("dark").map(|d| d == "1").unwrap_or(false)) {
|
|
|
|
|
|
Ok(svg) => Resp::bytes("image/svg+xml", svg.into_bytes()),
|
|
|
|
|
|
Err(e) => Resp::error(422, &e),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
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
|
|
|
|
("GET", ["api", "bots", id]) => {
|
|
|
|
|
|
let id: u64 = id.parse().unwrap_or(0);
|
|
|
|
|
|
let v = hub().view.lock().unwrap();
|
|
|
|
|
|
match v.messages.get(&id) {
|
|
|
|
|
|
Some(m) => Resp::json(&json!({"version": v.version, "messages": m})),
|
|
|
|
|
|
None => Resp::error(404, "找不到這個 Bot"),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
The phone page does what the desktop does, laid out like it
A new mobile/index.html in the desktop's (and Grok Bot's) shape: the sidebar
with its sections and folders, the chat with steps, thinking, reactions, quotes
and the approval card, 對話詳細資訊, Bot 設定, routines, pages, computers and
設定. Each action goes to the desktop's own code on the app's thread
(api/act/<what> -> App::phone_act): appctl, set_pinned, restart_bot,
toggle_reaction, box_task, channels_restart and the rest. No new rules.
Keys, channel tokens and the vault never reach the phone; a model key can only
be written. Admins keep what the phone always let them do; making, changing or
deleting bots, routines, memories, pages, folders and settings stays the
owner's. The state the phone polls now carries the sidebar's section order.
Checked by using it on a preview brain: routines, memories, templates, folders,
keys, signing another phone out, approvals and a chat with a fake model.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
2026-11-06 19:05:12 +00:00
|
|
|
|
// What the desktop shows beside the chat and in 設定, made when asked (never keys, tokens
|
|
|
|
|
|
// or the vault's secrets), and everything else it can do (api/act/<what>).
|
|
|
|
|
|
("GET", ["api", "bots", id, "detail"]) => phone_act("detail", json!({"bot": id.parse::<u64>().unwrap_or(0)}), &role),
|
|
|
|
|
|
("GET", ["api", "settings"]) => phone_act("settings", json!({}), &role),
|
|
|
|
|
|
("GET", ["api", "box", "thumb"]) => box_thumb(),
|
|
|
|
|
|
("POST", ["api", "act", action]) => phone_act(action, req.json(), &role),
|
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
|
|
|
|
("POST", ["api", "bots", id, action]) => {
|
|
|
|
|
|
let bot: u64 = id.parse().unwrap_or(0);
|
|
|
|
|
|
let b = req.json();
|
|
|
|
|
|
let cmd = match *action {
|
|
|
|
|
|
"send" => {
|
|
|
|
|
|
let text = b["text"].as_str().unwrap_or("").trim().to_string();
|
|
|
|
|
|
let attachments: Vec<String> = b["attachments"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default();
|
|
|
|
|
|
if text.is_empty() && attachments.is_empty() {
|
|
|
|
|
|
return Resp::error(400, "訊息是空的");
|
|
|
|
|
|
}
|
|
|
|
|
|
// 團隊: the bot knows who is talking when it isn't the owner.
|
|
|
|
|
|
let who = req.header("x-anybot-user");
|
|
|
|
|
|
let text = if role != "owner" && !who.is_empty() { format!("【{who}】{text}") } else { text };
|
2026-11-05 08:47:28 +00:00
|
|
|
|
Cmd::Send { bot, text, attachments, from: 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
|
|
|
|
}
|
|
|
|
|
|
"stop" => Cmd::Stop { bot },
|
|
|
|
|
|
"answer" => Cmd::Answer { bot, answer: b["answer"].as_str().unwrap_or("reject").to_string() },
|
|
|
|
|
|
"read" => Cmd::Read { bot },
|
|
|
|
|
|
"new" => Cmd::NewThread { bot },
|
Phone: a home with the computers, pages and routines; each bot's settings a tap away; long jobs fold up
The phone page was bare: one row per bot, a chat listing every step, a menu with four lines.
- Home: the main bot large at the top with its model and last words, the other bots with times,
then its computers (own box and the account's, online or not, 看畫面), the newest pages and the
routines with their next run.
- Chat: the bot's name opens its settings — model (chat models only), computer, past conversations
(restore), regenerate, new chat. A turn's steps fold into 「已完成 N 個步驟」 when done (the newest
three stay while it works); copy and regenerate under replies; four ready starts in a new chat;
a jump back to the newest message.
- Menu: 外觀 (follow the phone, light, dark).
- API: api/state carries pages and models; each bot its model, computers, routines, past
conversations; POST api/bots/{id}/set | regenerate | restore. The home's last line skips errors.
Checked on a local copy of the brain's data (cut off from the account and Docker) at iPhone size.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
2026-11-06 12:28:08 +00:00
|
|
|
|
"regenerate" => Cmd::Regenerate { bot },
|
|
|
|
|
|
"restore" => Cmd::RestoreThread { bot, index: b["index"].as_u64().unwrap_or(0) as usize },
|
|
|
|
|
|
"set" => Cmd::SetBot {
|
|
|
|
|
|
bot,
|
|
|
|
|
|
provider: b["provider"].as_str().unwrap_or("").to_string(),
|
|
|
|
|
|
model: b["model"].as_str().unwrap_or("").trim().to_string(),
|
|
|
|
|
|
computer: b["computer"].as_str().unwrap_or("").trim().to_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
|
|
|
|
"upload" => return upload(bot, &req),
|
|
|
|
|
|
_ => return Resp::error(404, "沒有這個動作"),
|
|
|
|
|
|
};
|
|
|
|
|
|
hub().push(cmd);
|
|
|
|
|
|
Resp::json(&json!({"ok": true}))
|
|
|
|
|
|
}
|
People watch and help on the bots' computer through VNC (noVNC), carried by the account server
The core: the bot works the computer; a person watches it like a colleague, and takes over
when it needs them. VNC is made for that (only what changed, every mouse move and key, touch on
phones), so the pictures sent one by one go, and with them their own input path.
- vnc.rs: a VNC proxy to the box's x11vnc through one docker exec (a Python bridge that ends
with either side). It signs in to the box with the box's own password (its VNC_PASSWORD) and
offers the viewer no password, so the password never leaves the brain. The person's clicks
and keys make the bot's steps wait until 8 s after they stop; text typed in the viewer's box
(any language) is pasted in the box.
- Account server: /screen (the viewer page) and /novnc.js (noVNC 1.6.0, MPL-2.0, bundled);
/relay/vnc/<host> takes the browser's WebSocket (account session, or a 10-minute ticket the
app asks for with POST /relay/screen-ticket; owners and admins) and carries it over that
AnyBot's lasting connection (vopen / vdata / vtext / vclose). Both ends of that connection
wait 2 ms instead of 40 ms to read while things move: a request and its answer took up to
160 ms more than needed.
- Phone page: 看 Bot 的電腦 and 接管 open the viewer page (also on the local network: /api/vnc on
the AnyBot's own phone server). This Mac's own screen moved to the ⋯ menu.
- Mac app: 看畫面 opens the viewer in the browser (a screen of a brain: the brain's computer by
ticket; else this computer's box, noVNC straight from it). The in-app picture viewer, the
live JPEG stream, the person's click/move/scroll/key endpoints and the input shell are gone.
- Opening a Mac app uses LaunchServices (`open -a`): agent-desktop's launch waited on the window
through accessibility and failed without 輔助使用 now and then.
Measured on this Mac (made-up account, local relay + headless brain + the real box): viewer
signed in in 0.4–0.7 s, a request and its answer through relay + brain + box 25–58 ms median;
headless Chrome shows the desktop live in /screen. remote_e2e 28/28 ×2, 149 unit tests, relay 11.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:05:44 +00:00
|
|
|
|
("GET", ["api", "screen", which]) => screen(which, &s),
|
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
|
|
|
|
("POST", ["api", "screen", which]) => screen_input(which, &req.json(), &s),
|
2026-11-04 12:38:45 +00:00
|
|
|
|
("POST", ["api", "device", "run"]) => device_run(&req.json(), &s, via_relay, req.header("x-anybot-from")),
|
2026-11-04 09:46:43 +00:00
|
|
|
|
("GET", ["api", "sync"]) => {
|
|
|
|
|
|
let since: u64 = req.query.get("since").and_then(|v| v.parse().ok()).unwrap_or(0);
|
|
|
|
|
|
let client = req.query.get("client").cloned().unwrap_or_default();
|
|
|
|
|
|
if since > 0 {
|
|
|
|
|
|
let _ = hub().wait_newer(since, LONG_POLL_SECS);
|
|
|
|
|
|
}
|
|
|
|
|
|
match hub().ask_sync(SyncKind::Pull { client, since }) {
|
|
|
|
|
|
Ok(v) => Resp::json(&v),
|
|
|
|
|
|
Err(e) => Resp::error(503, &e),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
("POST", ["api", "sync", "edit"]) => match hub().ask_sync(SyncKind::Edit(req.json())) {
|
|
|
|
|
|
Ok(v) if v["error"].is_string() => Resp::error(400, v["error"].as_str().unwrap_or("")),
|
|
|
|
|
|
Ok(v) => Resp::json(&v),
|
|
|
|
|
|
Err(e) => Resp::error(503, &e),
|
|
|
|
|
|
},
|
|
|
|
|
|
("POST", ["api", "sync", "upload", id]) => upload(id.parse().unwrap_or(0), &req),
|
People watch and help on the bots' computer through VNC (noVNC), carried by the account server
The core: the bot works the computer; a person watches it like a colleague, and takes over
when it needs them. VNC is made for that (only what changed, every mouse move and key, touch on
phones), so the pictures sent one by one go, and with them their own input path.
- vnc.rs: a VNC proxy to the box's x11vnc through one docker exec (a Python bridge that ends
with either side). It signs in to the box with the box's own password (its VNC_PASSWORD) and
offers the viewer no password, so the password never leaves the brain. The person's clicks
and keys make the bot's steps wait until 8 s after they stop; text typed in the viewer's box
(any language) is pasted in the box.
- Account server: /screen (the viewer page) and /novnc.js (noVNC 1.6.0, MPL-2.0, bundled);
/relay/vnc/<host> takes the browser's WebSocket (account session, or a 10-minute ticket the
app asks for with POST /relay/screen-ticket; owners and admins) and carries it over that
AnyBot's lasting connection (vopen / vdata / vtext / vclose). Both ends of that connection
wait 2 ms instead of 40 ms to read while things move: a request and its answer took up to
160 ms more than needed.
- Phone page: 看 Bot 的電腦 and 接管 open the viewer page (also on the local network: /api/vnc on
the AnyBot's own phone server). This Mac's own screen moved to the ⋯ menu.
- Mac app: 看畫面 opens the viewer in the browser (a screen of a brain: the brain's computer by
ticket; else this computer's box, noVNC straight from it). The in-app picture viewer, the
live JPEG stream, the person's click/move/scroll/key endpoints and the input shell are gone.
- Opening a Mac app uses LaunchServices (`open -a`): agent-desktop's launch waited on the window
through accessibility and failed without 輔助使用 now and then.
Measured on this Mac (made-up account, local relay + headless brain + the real box): viewer
signed in in 0.4–0.7 s, a request and its answer through relay + brain + box 25–58 ms median;
headless Chrome shows the desktop live in /screen. remote_e2e 28/28 ×2, 149 unit tests, relay 11.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:05:44 +00:00
|
|
|
|
// A screen of this brain shows the bots' own computer, which lives here: a small picture
|
|
|
|
|
|
// (people watch it live through VNC, see vnc.rs).
|
|
|
|
|
|
("GET", ["api", "sync", "screen"]) => match crate::cloudbox::thumbnail(320) {
|
|
|
|
|
|
Some(img) => match crate::computer::encode_jpeg(&image::DynamicImage::ImageRgba8(img).to_rgb8()) {
|
|
|
|
|
|
Some(j) => Resp::bytes("image/jpeg", j),
|
|
|
|
|
|
None => Resp::error(500, "畫面編碼失敗"),
|
|
|
|
|
|
},
|
|
|
|
|
|
None => Resp::error(404, "還沒有畫面"),
|
|
|
|
|
|
},
|
|
|
|
|
|
// 接管 / 我完成了 from a screen: the bot's steps wait (or not) for the person.
|
2026-11-05 08:47:28 +00:00
|
|
|
|
("POST", ["api", "sync", "screen"]) => {
|
|
|
|
|
|
let v = req.json();
|
|
|
|
|
|
match v["kind"].as_str().unwrap_or("") {
|
|
|
|
|
|
"hold" => {
|
|
|
|
|
|
crate::cloudbox::HELD_BY_PERSON.store(v["on"].as_bool().unwrap_or(false), Ordering::SeqCst);
|
|
|
|
|
|
Resp::json(&json!({"ok": true}))
|
|
|
|
|
|
}
|
People watch and help on the bots' computer through VNC (noVNC), carried by the account server
The core: the bot works the computer; a person watches it like a colleague, and takes over
when it needs them. VNC is made for that (only what changed, every mouse move and key, touch on
phones), so the pictures sent one by one go, and with them their own input path.
- vnc.rs: a VNC proxy to the box's x11vnc through one docker exec (a Python bridge that ends
with either side). It signs in to the box with the box's own password (its VNC_PASSWORD) and
offers the viewer no password, so the password never leaves the brain. The person's clicks
and keys make the bot's steps wait until 8 s after they stop; text typed in the viewer's box
(any language) is pasted in the box.
- Account server: /screen (the viewer page) and /novnc.js (noVNC 1.6.0, MPL-2.0, bundled);
/relay/vnc/<host> takes the browser's WebSocket (account session, or a 10-minute ticket the
app asks for with POST /relay/screen-ticket; owners and admins) and carries it over that
AnyBot's lasting connection (vopen / vdata / vtext / vclose). Both ends of that connection
wait 2 ms instead of 40 ms to read while things move: a request and its answer took up to
160 ms more than needed.
- Phone page: 看 Bot 的電腦 and 接管 open the viewer page (also on the local network: /api/vnc on
the AnyBot's own phone server). This Mac's own screen moved to the ⋯ menu.
- Mac app: 看畫面 opens the viewer in the browser (a screen of a brain: the brain's computer by
ticket; else this computer's box, noVNC straight from it). The in-app picture viewer, the
live JPEG stream, the person's click/move/scroll/key endpoints and the input shell are gone.
- Opening a Mac app uses LaunchServices (`open -a`): agent-desktop's launch waited on the window
through accessibility and failed without 輔助使用 now and then.
Measured on this Mac (made-up account, local relay + headless brain + the real box): viewer
signed in in 0.4–0.7 s, a request and its answer through relay + brain + box 25–58 ms median;
headless Chrome shows the desktop live in /screen. remote_e2e 28/28 ×2, 149 unit tests, relay 11.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:05:44 +00:00
|
|
|
|
"wake" => match crate::cloudbox::ensure_running() {
|
|
|
|
|
|
Ok(()) => Resp::json(&json!({"ok": true})),
|
|
|
|
|
|
Err(e) => Resp::error(500, &e),
|
|
|
|
|
|
},
|
|
|
|
|
|
_ => Resp::error(400, "不認得的操作"),
|
2026-11-05 08:47:28 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-11-04 11:30:32 +00:00
|
|
|
|
("POST", ["api", "sync", "models"]) => match hub().ask_sync_for(SyncKind::Models(req.json()["provider"].as_str().unwrap_or("").to_string()), 45) {
|
|
|
|
|
|
Ok(v) => Resp::json(&v),
|
|
|
|
|
|
Err(e) => Resp::error(503, &e),
|
|
|
|
|
|
},
|
|
|
|
|
|
// A file shown in a chat (only what AnyBot keeps as attachments).
|
Real-use fixes: the person sees the screens the bot shows, files reach the phone, no blank-tab loops
From real runs on the cloud brain with the person's Mac (Threads patrol, a post draft, "show me my
screen"):
- computer_use_show: a plain picture of any computer's screen goes into the chat (the bot sees it
too). The bot used to describe a screenshot in words and fail to send it.
- The phone shows the bot's attachments: pictures in place, other files download on a tap (api/file,
only from the chats' file folder). It showed nothing for the bot and names only for the person.
- send_file works from the person's other computers (fetch_file over the relay, 20 MB, secrets stay).
- The same call with the same answer again and again is warned the 3rd time and stopped from the
4th (a blank tab was read 11 times, then the bot said Threads was logged out without opening it);
a blank tab says so; checking a login means opening the site.
- Screenshot lists: a nameless row around a named button isn't numbered, a nameless row takes the
words inside it, empty spacer rows aren't numbered (System Settings: 63 numbers -> 33, all named).
- Writing "in my voice" reads the person's own recent posts first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
2026-11-06 11:37:45 +00:00
|
|
|
|
// A file in a chat (a picture the bot showed, a file it sent, one the person attached),
|
|
|
|
|
|
// for the phone to show or download. Only from the chats' own file folder.
|
|
|
|
|
|
("GET", ["api", "file"]) => match chat_file(&req.query.get("path").cloned().unwrap_or_default()) {
|
|
|
|
|
|
Some((f, b)) => Resp::bytes(content_type(&f), b),
|
|
|
|
|
|
None => Resp::error(404, "找不到這個檔案"),
|
|
|
|
|
|
},
|
2026-11-04 11:30:32 +00:00
|
|
|
|
("GET", ["api", "sync", "file"]) => {
|
|
|
|
|
|
let want = std::path::PathBuf::from(req.query.get("path").cloned().unwrap_or_default());
|
|
|
|
|
|
let root = crate::model::State::path().parent().map(|p| p.join("attachments")).and_then(|p| p.canonicalize().ok());
|
|
|
|
|
|
match (want.canonicalize().ok(), root) {
|
|
|
|
|
|
(Some(f), Some(root)) if f.starts_with(&root) && f.is_file() => match std::fs::read(&f) {
|
|
|
|
|
|
Ok(b) => Resp::bytes("application/octet-stream", b),
|
|
|
|
|
|
Err(_) => Resp::error(404, "大腦上找不到這個檔案"),
|
|
|
|
|
|
},
|
|
|
|
|
|
_ => Resp::error(404, "大腦上找不到這個檔案"),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-11-04 09:46:43 +00:00
|
|
|
|
("POST", ["api", "sync", "act"]) => {
|
|
|
|
|
|
let b = req.json();
|
|
|
|
|
|
let bot = b["bot"].as_u64().unwrap_or(0);
|
|
|
|
|
|
let cmd = match b["action"].as_str().unwrap_or("") {
|
|
|
|
|
|
"send" => Cmd::Send {
|
|
|
|
|
|
bot,
|
|
|
|
|
|
text: b["text"].as_str().unwrap_or("").trim().to_string(),
|
|
|
|
|
|
attachments: b["attachments"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default(),
|
2026-11-05 08:47:28 +00:00
|
|
|
|
// The relay names the computer that asked: the one the person is using.
|
|
|
|
|
|
from: req.header("x-anybot-from").to_string(),
|
2026-11-04 09:46:43 +00:00
|
|
|
|
},
|
|
|
|
|
|
"stop" => Cmd::Stop { bot },
|
|
|
|
|
|
"answer" => Cmd::Answer { bot, answer: b["answer"].as_str().unwrap_or("reject").to_string() },
|
|
|
|
|
|
"read" => Cmd::Read { bot },
|
|
|
|
|
|
"new" => Cmd::NewThread { bot },
|
|
|
|
|
|
"regenerate" => Cmd::Regenerate { bot },
|
|
|
|
|
|
"reply" => Cmd::Reply { bot },
|
|
|
|
|
|
"restore" => Cmd::RestoreThread { bot, index: b["index"].as_u64().unwrap_or(0) as usize },
|
2026-11-05 08:47:28 +00:00
|
|
|
|
"clear" => Cmd::Clear { bot },
|
2026-11-04 09:46:43 +00:00
|
|
|
|
_ => return Resp::error(404, "沒有這個動作"),
|
|
|
|
|
|
};
|
|
|
|
|
|
hub().push(cmd);
|
|
|
|
|
|
Resp::json(&json!({"ok": true}))
|
|
|
|
|
|
}
|
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
|
|
|
|
("POST", ["api", "logout"]) if !via_relay => {
|
|
|
|
|
|
let h = httpd::sha256_hex(&req.bearer());
|
|
|
|
|
|
hub().push(Cmd::DeviceSeen(format!("-{h}")));
|
|
|
|
|
|
Resp::json(&json!({"ok": true}))
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => Resp::error(404, "沒有這個網址"),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 團隊角色: what each role may do. "member" chats and attaches files; "admin" also approves,
|
|
|
|
|
|
/// stops, starts new chats and uses the screens; only "device" (another AnyBot of the owner's
|
|
|
|
|
|
/// account) may run tools here.
|
The phone page does what the desktop does, laid out like it
A new mobile/index.html in the desktop's (and Grok Bot's) shape: the sidebar
with its sections and folders, the chat with steps, thinking, reactions, quotes
and the approval card, 對話詳細資訊, Bot 設定, routines, pages, computers and
設定. Each action goes to the desktop's own code on the app's thread
(api/act/<what> -> App::phone_act): appctl, set_pinned, restart_bot,
toggle_reaction, box_task, channels_restart and the rest. No new rules.
Keys, channel tokens and the vault never reach the phone; a model key can only
be written. Admins keep what the phone always let them do; making, changing or
deleting bots, routines, memories, pages, folders and settings stays the
owner's. The state the phone polls now carries the sidebar's section order.
Checked by using it on a preview brain: routines, memories, templates, folders,
keys, signing another phone out, approvals and a chat with a fake model.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
2026-11-06 19:05:12 +00:00
|
|
|
|
/// A phone action carried out on the app's thread (where the state is), with its answer.
|
|
|
|
|
|
fn phone_act(action: &str, body: Value, role: &str) -> Resp {
|
|
|
|
|
|
match hub().ask_sync_for(SyncKind::Phone { action: action.to_string(), body, role: role.to_string() }, 20) {
|
|
|
|
|
|
Ok(v) if v.get("error").is_some() => Resp::error(400, v["error"].as_str().unwrap_or("做不到")),
|
|
|
|
|
|
Ok(v) => Resp::json(&v),
|
|
|
|
|
|
Err(e) => Resp::error(503, &e),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The bots' computer's screen, small (the phone's 電腦 card and a hand-off), as JPEG.
|
|
|
|
|
|
fn box_thumb() -> Resp {
|
|
|
|
|
|
let Some(img) = crate::cloudbox::thumbnail(480) else { return Resp::error(404, "Bot 的電腦沒有開著") };
|
|
|
|
|
|
let mut out = std::io::Cursor::new(vec![]);
|
|
|
|
|
|
match image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 72).encode_image(&image::DynamicImage::ImageRgba8(img).to_rgb8()) {
|
|
|
|
|
|
Ok(()) => Resp::bytes("image/jpeg", out.into_inner()),
|
|
|
|
|
|
Err(e) => Resp::error(500, &e.to_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
|
|
|
|
pub fn role_allows(role: &str, method: &str, path: &[&str]) -> bool {
|
|
|
|
|
|
let device = matches!(path, ["api", "device", "run"]);
|
2026-11-04 09:46:43 +00:00
|
|
|
|
let sync = matches!(path, ["api", "sync", ..]);
|
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
|
|
|
|
match role {
|
|
|
|
|
|
"device" => device,
|
2026-11-04 09:46:43 +00:00
|
|
|
|
// 大腦: the owner's other AnyBots showing this one's bots.
|
|
|
|
|
|
"mirror" => sync,
|
|
|
|
|
|
"owner" | "admin" => !device && !sync,
|
Real-use fixes: the person sees the screens the bot shows, files reach the phone, no blank-tab loops
From real runs on the cloud brain with the person's Mac (Threads patrol, a post draft, "show me my
screen"):
- computer_use_show: a plain picture of any computer's screen goes into the chat (the bot sees it
too). The bot used to describe a screenshot in words and fail to send it.
- The phone shows the bot's attachments: pictures in place, other files download on a tap (api/file,
only from the chats' file folder). It showed nothing for the bot and names only for the person.
- send_file works from the person's other computers (fetch_file over the relay, 20 MB, secrets stay).
- The same call with the same answer again and again is warned the 3rd time and stopped from the
4th (a blank tab was read 11 times, then the bot said Threads was logged out without opening it);
a blank tab says so; checking a login means opening the site.
- Screenshot lists: a nameless row around a named button isn't numbered, a nameless row takes the
words inside it, empty spacer rows aren't numbered (System Settings: 63 numbers -> 33, all named).
- Writing "in my voice" reads the person's own recent posts first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
2026-11-06 11:37:45 +00:00
|
|
|
|
"member" => matches!((method, path), ("GET", ["api", "state"]) | ("GET", ["api", "bots", _]) | ("GET", ["api", "pages", ..]) | ("GET", ["api", "file"]) | ("POST", ["api", "bots", _, "send" | "read" | "upload"])),
|
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
|
|
|
|
_ => false,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Real-use fixes: the person sees the screens the bot shows, files reach the phone, no blank-tab loops
From real runs on the cloud brain with the person's Mac (Threads patrol, a post draft, "show me my
screen"):
- computer_use_show: a plain picture of any computer's screen goes into the chat (the bot sees it
too). The bot used to describe a screenshot in words and fail to send it.
- The phone shows the bot's attachments: pictures in place, other files download on a tap (api/file,
only from the chats' file folder). It showed nothing for the bot and names only for the person.
- send_file works from the person's other computers (fetch_file over the relay, 20 MB, secrets stay).
- The same call with the same answer again and again is warned the 3rd time and stopped from the
4th (a blank tab was read 11 times, then the bot said Threads was logged out without opening it);
a blank tab says so; checking a login means opening the site.
- Screenshot lists: a nameless row around a named button isn't numbered, a nameless row takes the
words inside it, empty spacer rows aren't numbered (System Settings: 63 numbers -> 33, all named).
- Writing "in my voice" reads the person's own recent posts first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
2026-11-06 11:37:45 +00:00
|
|
|
|
/// A chat file by its path, only when it really is inside the chats' file folder.
|
|
|
|
|
|
fn chat_file(want: &str) -> Option<(std::path::PathBuf, Vec<u8>)> {
|
|
|
|
|
|
let root = crate::model::State::path().parent()?.join("attachments").canonicalize().ok()?;
|
|
|
|
|
|
let f = std::path::PathBuf::from(want).canonicalize().ok()?;
|
|
|
|
|
|
if !f.starts_with(&root) || !f.is_file() {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
let b = std::fs::read(&f).ok()?;
|
|
|
|
|
|
Some((f, b))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn content_type(f: &std::path::Path) -> &'static str {
|
|
|
|
|
|
match f.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref() {
|
|
|
|
|
|
Some("jpg" | "jpeg") => "image/jpeg",
|
|
|
|
|
|
Some("png") => "image/png",
|
|
|
|
|
|
Some("gif") => "image/gif",
|
|
|
|
|
|
Some("webp") => "image/webp",
|
|
|
|
|
|
Some("pdf") => "application/pdf",
|
|
|
|
|
|
Some("txt" | "md" | "csv") => "text/plain; charset=utf-8",
|
|
|
|
|
|
_ => "application/octet-stream",
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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 login(req: &Req) -> Resp {
|
2026-11-05 07:48:20 +00:00
|
|
|
|
// One counter per source address, cleaned as it is touched.
|
|
|
|
|
|
let ip = req.peer.split(':').next().unwrap_or(&req.peer).to_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
|
|
|
|
{
|
|
|
|
|
|
let mut f = hub().fails.lock().unwrap();
|
2026-11-05 07:48:20 +00:00
|
|
|
|
f.retain(|_, (_, t)| t.elapsed() < Duration::from_secs(60));
|
|
|
|
|
|
if let Some((n, t)) = f.get(&ip) {
|
|
|
|
|
|
if *n >= MAX_FAILS && t.elapsed() < Duration::from_secs(60) {
|
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
|
|
|
|
return Resp::error(429, "密碼錯太多次,請一分鐘後再試");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
let b = req.json();
|
|
|
|
|
|
let pw = b["password"].as_str().unwrap_or("");
|
|
|
|
|
|
let stored = hub().settings().password;
|
|
|
|
|
|
if stored.is_empty() || !httpd::check_password(pw, &stored) {
|
2026-11-05 07:48:20 +00:00
|
|
|
|
{
|
|
|
|
|
|
let mut f = hub().fails.lock().unwrap();
|
|
|
|
|
|
let e = f.entry(ip.clone()).or_insert((0, Instant::now()));
|
|
|
|
|
|
if e.1.elapsed() >= Duration::from_secs(60) {
|
|
|
|
|
|
*e = (0, Instant::now());
|
|
|
|
|
|
}
|
|
|
|
|
|
e.0 += 1;
|
|
|
|
|
|
e.1 = Instant::now();
|
|
|
|
|
|
}
|
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
|
|
|
|
// Slow guessing down a little more.
|
|
|
|
|
|
std::thread::sleep(Duration::from_millis(600));
|
|
|
|
|
|
return Resp::error(401, if stored.is_empty() { "這台 AnyBot 還沒設定手機密碼" } else { "密碼不對" });
|
|
|
|
|
|
}
|
2026-11-05 07:48:20 +00:00
|
|
|
|
hub().fails.lock().unwrap().remove(&ip);
|
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 token = httpd::random_hex(32);
|
|
|
|
|
|
let name = b["device"].as_str().filter(|d| !d.trim().is_empty()).unwrap_or("手機").chars().take(40).collect::<String>();
|
|
|
|
|
|
let d = Device { name, token_hash: httpd::sha256_hex(&token), created: chrono::Utc::now().timestamp(), last_seen: chrono::Utc::now().timestamp() };
|
|
|
|
|
|
// Known at once (the app saves it a moment later).
|
|
|
|
|
|
hub().settings.lock().unwrap().devices.push(d.clone());
|
|
|
|
|
|
hub().push(Cmd::DeviceAdded(d));
|
|
|
|
|
|
Resp::json(&json!({"token": token}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn upload(bot: u64, req: &Req) -> Resp {
|
|
|
|
|
|
let name = req.query.get("name").cloned().unwrap_or_else(|| "手機上傳".into());
|
|
|
|
|
|
let name: String = name.chars().filter(|c| !matches!(c, '/' | '\\' | '\0')).take(80).collect();
|
|
|
|
|
|
if req.body.is_empty() {
|
|
|
|
|
|
return Resp::error(400, "檔案是空的");
|
|
|
|
|
|
}
|
|
|
|
|
|
let dir = std::env::temp_dir().join(format!("anybot-phone-{}", httpd::random_hex(6)));
|
|
|
|
|
|
let _ = std::fs::create_dir_all(&dir);
|
|
|
|
|
|
let tmp = dir.join(if name.is_empty() { "upload".to_string() } else { name });
|
|
|
|
|
|
if let Err(e) = std::fs::write(&tmp, &req.body) {
|
|
|
|
|
|
return Resp::error(500, &e.to_string());
|
|
|
|
|
|
}
|
|
|
|
|
|
let r = crate::attach::store(bot, &tmp);
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
|
|
|
|
match r {
|
|
|
|
|
|
Ok(a) => Resp::json(&json!({"path": a.path, "name": a.name, "image": a.image})),
|
|
|
|
|
|
Err(e) => Resp::error(400, &e),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// A frame of the bot's computer ("box") or this Mac ("mac") as JPEG.
|
People watch and help on the bots' computer through VNC (noVNC), carried by the account server
The core: the bot works the computer; a person watches it like a colleague, and takes over
when it needs them. VNC is made for that (only what changed, every mouse move and key, touch on
phones), so the pictures sent one by one go, and with them their own input path.
- vnc.rs: a VNC proxy to the box's x11vnc through one docker exec (a Python bridge that ends
with either side). It signs in to the box with the box's own password (its VNC_PASSWORD) and
offers the viewer no password, so the password never leaves the brain. The person's clicks
and keys make the bot's steps wait until 8 s after they stop; text typed in the viewer's box
(any language) is pasted in the box.
- Account server: /screen (the viewer page) and /novnc.js (noVNC 1.6.0, MPL-2.0, bundled);
/relay/vnc/<host> takes the browser's WebSocket (account session, or a 10-minute ticket the
app asks for with POST /relay/screen-ticket; owners and admins) and carries it over that
AnyBot's lasting connection (vopen / vdata / vtext / vclose). Both ends of that connection
wait 2 ms instead of 40 ms to read while things move: a request and its answer took up to
160 ms more than needed.
- Phone page: 看 Bot 的電腦 and 接管 open the viewer page (also on the local network: /api/vnc on
the AnyBot's own phone server). This Mac's own screen moved to the ⋯ menu.
- Mac app: 看畫面 opens the viewer in the browser (a screen of a brain: the brain's computer by
ticket; else this computer's box, noVNC straight from it). The in-app picture viewer, the
live JPEG stream, the person's click/move/scroll/key endpoints and the input shell are gone.
- Opening a Mac app uses LaunchServices (`open -a`): agent-desktop's launch waited on the window
through accessibility and failed without 輔助使用 now and then.
Measured on this Mac (made-up account, local relay + headless brain + the real box): viewer
signed in in 0.4–0.7 s, a request and its answer through relay + brain + box 25–58 ms median;
headless Chrome shows the desktop live in /screen. remote_e2e 28/28 ×2, 149 unit tests, relay 11.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:05:44 +00:00
|
|
|
|
/// This Mac's screen for the phone (when allowed). The bots' computer is watched through VNC
|
|
|
|
|
|
/// (the page /screen, vnc.rs).
|
|
|
|
|
|
fn screen(which: &str, s: &Settings) -> Resp {
|
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
|
|
|
|
match which {
|
|
|
|
|
|
"mac" if s.mac_from_phone => {
|
Bots click by number: every control on the screen is boxed and numbered, on every computer
A bot asked to open a Threads profile clicked an empty strip ten times: it guessed coordinates
from a picture, and the web tools' description told it they didn't work on its own computer.
- marks.rs: the picture a bot gets has every button, link, field and menu item boxed and
numbered (a tiny built-in digit font, pure Rust), with the same numbers in a list. click /
typing / scroll / mouse_move take the number (ref) and land on the control's centre.
- Where the controls come from: macOS AX and Linux AT-SPI through agent-desktop on a person's
computer (desktop::elements); AT-SPI in the bots' own computer (box/a11y, after XBot's
box-a11y), web pages included. On a Mac a control whose spot another app's window covers gets
no number (topmost window at its centre must be the app's; the Dock's invisible full-screen
window doesn't count). open_app waits until the app is really in front.
- No going round in circles: a step that changed nothing says so; the same step (clicks within
16 px) a third time in a turn comes with a warning, from the fourth time it isn't run.
- The box (version 4): one TigerVNC server instead of Xvfb + x11vnc, the AT-SPI bus and registry
at boot, one browser launcher (box/browser, after XBot's box-chrome: same profile and CDP,
renderer accessibility, a launch lock, stale profile locks cleared). One docker exec per look
(list + picture, ~0.4 s). A brain updates an older box by itself when it has been idle a
minute (files and logins stay); a new box is never made from an older image.
- Gone: computer_use_read_app (the list is in every picture), computer_use_mouse_button, the
per-call picture for the phone (it gets a plain one), the stale browser_read note; the prompt
says web work goes through browser_* by number, and never to read browser cookie or history
files.
Tests: marks (numbering, drawing, fingerprints), box_numbers_are_exact on a box from the new
image (a Threads-like page: the icon links get numbers, Profile clicked by number, Chinese typed
into the numbered field, a click on nothing called out), box_screen_is_exact, VNC e2e 11/11 on
TigerVNC, Calculator 7×8=56 by numbers through the signed app on this Mac, remote_e2e 28/28,
153 unit tests; Linux build + 157 tests + x11_live.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 16:47:01 +00:00
|
|
|
|
match crate::computer::plain_picture() {
|
|
|
|
|
|
Ok((w, h, jpeg)) => Resp::bytes("image/jpeg", jpeg).with_header("X-Size", &format!("{w}x{h}")),
|
|
|
|
|
|
Err(e) => Resp::error(503, &e),
|
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
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
"mac" => Resp::error(403, "這台 Mac 沒有開放從手機操作(AnyBot 設定 › 手機與帳號)"),
|
|
|
|
|
|
_ => Resp::error(404, "沒有這個畫面"),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
People watch and help on the bots' computer through VNC (noVNC), carried by the account server
The core: the bot works the computer; a person watches it like a colleague, and takes over
when it needs them. VNC is made for that (only what changed, every mouse move and key, touch on
phones), so the pictures sent one by one go, and with them their own input path.
- vnc.rs: a VNC proxy to the box's x11vnc through one docker exec (a Python bridge that ends
with either side). It signs in to the box with the box's own password (its VNC_PASSWORD) and
offers the viewer no password, so the password never leaves the brain. The person's clicks
and keys make the bot's steps wait until 8 s after they stop; text typed in the viewer's box
(any language) is pasted in the box.
- Account server: /screen (the viewer page) and /novnc.js (noVNC 1.6.0, MPL-2.0, bundled);
/relay/vnc/<host> takes the browser's WebSocket (account session, or a 10-minute ticket the
app asks for with POST /relay/screen-ticket; owners and admins) and carries it over that
AnyBot's lasting connection (vopen / vdata / vtext / vclose). Both ends of that connection
wait 2 ms instead of 40 ms to read while things move: a request and its answer took up to
160 ms more than needed.
- Phone page: 看 Bot 的電腦 and 接管 open the viewer page (also on the local network: /api/vnc on
the AnyBot's own phone server). This Mac's own screen moved to the ⋯ menu.
- Mac app: 看畫面 opens the viewer in the browser (a screen of a brain: the brain's computer by
ticket; else this computer's box, noVNC straight from it). The in-app picture viewer, the
live JPEG stream, the person's click/move/scroll/key endpoints and the input shell are gone.
- Opening a Mac app uses LaunchServices (`open -a`): agent-desktop's launch waited on the window
through accessibility and failed without 輔助使用 now and then.
Measured on this Mac (made-up account, local relay + headless brain + the real box): viewer
signed in in 0.4–0.7 s, a request and its answer through relay + brain + box 25–58 ms median;
headless Chrome shows the desktop live in /screen. remote_e2e 28/28 ×2, 149 unit tests, relay 11.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:05:44 +00:00
|
|
|
|
/// The phone taps, types or scrolls on this Mac's screen, as the person, in the pixels of the
|
|
|
|
|
|
/// picture it was sent (shot_size). The bots' computer takes the person's input through VNC.
|
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 screen_input(which: &str, v: &Value, s: &Settings) -> Resp {
|
One way to act on a screen: a real click or keystroke, wherever the bot looked
- Acting by an app's number (read_app's e7) is now a real click at that control's spot, the
same path as screenshot coordinates. agent-desktop only reads; its own actions (a second set
with its own key names and failures: no typing in terminals, "/" and "`" failing off the main
thread) are gone, with computer_use_select, background clicks and press_key's app.
- Every action comes back with the screen after it (or, with no picture allowed, the front
app's numbered controls), so the bot no longer spends a step on a screenshot to check.
- Coordinates map without remembering a screenshot: the picture's size depends only on the
screen. The phone's taps go straight to the screen as the person's, in the pixels of the
picture it was sent; they used to borrow the bot's last screenshot scale and landed 1.25x off
on the bots' computer, or failed with "screenshot first".
- Mac typing pastes (⌘V, clipboard put back): with 注音 on, text sent as key events vanished
while return arrived, so commands typed into a terminal ran empty. Bare printing keys
("slash", "r") are typed the same way; keys like "slash" and "grave" have names.
- The bots' Docker computer: one docker exec per step (action, settle, picture), never
`mousemove --sync` (it waits forever when the pointer is already there: a second click on
the same spot hung two minutes), open_app (chromium = the browser browser_* drive, xterm).
Tests: box_screen_is_exact (real Docker: bot and phone coordinates exact, same spot twice in
under 2 s, ASCII + Chinese + named key typed), Calculator by numbers and Terminal typing under
注音 through the signed app, remote_e2e 28/28, Linux build + 151 tests + x11_live.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 13:51:19 +00:00
|
|
|
|
let (x, y) = (v["x"].as_f64().unwrap_or(0.0), v["y"].as_f64().unwrap_or(0.0));
|
|
|
|
|
|
let down = v["direction"].as_str().unwrap_or("down") != "up";
|
|
|
|
|
|
let amount = v["amount"].as_i64().unwrap_or(3).clamp(1, 20);
|
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
|
|
|
|
match which {
|
|
|
|
|
|
"mac" if s.mac_from_phone => {
|
People watch and help on the bots' computer through VNC (noVNC), carried by the account server
The core: the bot works the computer; a person watches it like a colleague, and takes over
when it needs them. VNC is made for that (only what changed, every mouse move and key, touch on
phones), so the pictures sent one by one go, and with them their own input path.
- vnc.rs: a VNC proxy to the box's x11vnc through one docker exec (a Python bridge that ends
with either side). It signs in to the box with the box's own password (its VNC_PASSWORD) and
offers the viewer no password, so the password never leaves the brain. The person's clicks
and keys make the bot's steps wait until 8 s after they stop; text typed in the viewer's box
(any language) is pasted in the box.
- Account server: /screen (the viewer page) and /novnc.js (noVNC 1.6.0, MPL-2.0, bundled);
/relay/vnc/<host> takes the browser's WebSocket (account session, or a 10-minute ticket the
app asks for with POST /relay/screen-ticket; owners and admins) and carries it over that
AnyBot's lasting connection (vopen / vdata / vtext / vclose). Both ends of that connection
wait 2 ms instead of 40 ms to read while things move: a request and its answer took up to
160 ms more than needed.
- Phone page: 看 Bot 的電腦 and 接管 open the viewer page (also on the local network: /api/vnc on
the AnyBot's own phone server). This Mac's own screen moved to the ⋯ menu.
- Mac app: 看畫面 opens the viewer in the browser (a screen of a brain: the brain's computer by
ticket; else this computer's box, noVNC straight from it). The in-app picture viewer, the
live JPEG stream, the person's click/move/scroll/key endpoints and the input shell are gone.
- Opening a Mac app uses LaunchServices (`open -a`): agent-desktop's launch waited on the window
through accessibility and failed without 輔助使用 now and then.
Measured on this Mac (made-up account, local relay + headless brain + the real box): viewer
signed in in 0.4–0.7 s, a request and its answer through relay + brain + box 25–58 ms median;
headless Chrome shows the desktop live in /screen. remote_e2e 28/28 ×2, 149 unit tests, relay 11.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:05:44 +00:00
|
|
|
|
let (name, args) = match v["kind"].as_str().unwrap_or("") {
|
One way to act on a screen: a real click or keystroke, wherever the bot looked
- Acting by an app's number (read_app's e7) is now a real click at that control's spot, the
same path as screenshot coordinates. agent-desktop only reads; its own actions (a second set
with its own key names and failures: no typing in terminals, "/" and "`" failing off the main
thread) are gone, with computer_use_select, background clicks and press_key's app.
- Every action comes back with the screen after it (or, with no picture allowed, the front
app's numbered controls), so the bot no longer spends a step on a screenshot to check.
- Coordinates map without remembering a screenshot: the picture's size depends only on the
screen. The phone's taps go straight to the screen as the person's, in the pixels of the
picture it was sent; they used to borrow the bot's last screenshot scale and landed 1.25x off
on the bots' computer, or failed with "screenshot first".
- Mac typing pastes (⌘V, clipboard put back): with 注音 on, text sent as key events vanished
while return arrived, so commands typed into a terminal ran empty. Bare printing keys
("slash", "r") are typed the same way; keys like "slash" and "grave" have names.
- The bots' Docker computer: one docker exec per step (action, settle, picture), never
`mousemove --sync` (it waits forever when the pointer is already there: a second click on
the same spot hung two minutes), open_app (chromium = the browser browser_* drive, xterm).
Tests: box_screen_is_exact (real Docker: bot and phone coordinates exact, same spot twice in
under 2 s, ASCII + Chinese + named key typed), Calculator by numbers and Terminal typing under
注音 through the signed app, remote_e2e 28/28, Linux build + 151 tests + x11_live.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 13:51:19 +00:00
|
|
|
|
"click" => ("computer_use_click", json!({"x": x, "y": y, "button": v["button"].as_str().unwrap_or("left"), "count": v["count"].as_u64().unwrap_or(1)})),
|
|
|
|
|
|
"type" => ("computer_use_typing", json!({"value": v["text"]})),
|
|
|
|
|
|
"key" => ("computer_use_press_key", json!({"key": v["key"]})),
|
|
|
|
|
|
"scroll" => ("computer_use_scroll", json!({"x": x, "y": y, "direction": if down { "down" } else { "up" }, "amount": amount})),
|
|
|
|
|
|
_ => return Resp::error(400, "不認得的操作"),
|
|
|
|
|
|
};
|
|
|
|
|
|
match crate::computer::person_act(name, &args) {
|
|
|
|
|
|
Ok(()) => Resp::json(&json!({"ok": true})),
|
|
|
|
|
|
Err(e) => Resp::error(500, &e),
|
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
|
|
|
|
}
|
|
|
|
|
|
}
|
People watch and help on the bots' computer through VNC (noVNC), carried by the account server
The core: the bot works the computer; a person watches it like a colleague, and takes over
when it needs them. VNC is made for that (only what changed, every mouse move and key, touch on
phones), so the pictures sent one by one go, and with them their own input path.
- vnc.rs: a VNC proxy to the box's x11vnc through one docker exec (a Python bridge that ends
with either side). It signs in to the box with the box's own password (its VNC_PASSWORD) and
offers the viewer no password, so the password never leaves the brain. The person's clicks
and keys make the bot's steps wait until 8 s after they stop; text typed in the viewer's box
(any language) is pasted in the box.
- Account server: /screen (the viewer page) and /novnc.js (noVNC 1.6.0, MPL-2.0, bundled);
/relay/vnc/<host> takes the browser's WebSocket (account session, or a 10-minute ticket the
app asks for with POST /relay/screen-ticket; owners and admins) and carries it over that
AnyBot's lasting connection (vopen / vdata / vtext / vclose). Both ends of that connection
wait 2 ms instead of 40 ms to read while things move: a request and its answer took up to
160 ms more than needed.
- Phone page: 看 Bot 的電腦 and 接管 open the viewer page (also on the local network: /api/vnc on
the AnyBot's own phone server). This Mac's own screen moved to the ⋯ menu.
- Mac app: 看畫面 opens the viewer in the browser (a screen of a brain: the brain's computer by
ticket; else this computer's box, noVNC straight from it). The in-app picture viewer, the
live JPEG stream, the person's click/move/scroll/key endpoints and the input shell are gone.
- Opening a Mac app uses LaunchServices (`open -a`): agent-desktop's launch waited on the window
through accessibility and failed without 輔助使用 now and then.
Measured on this Mac (made-up account, local relay + headless brain + the real box): viewer
signed in in 0.4–0.7 s, a request and its answer through relay + brain + box 25–58 ms median;
headless Chrome shows the desktop live in /screen. remote_e2e 28/28 ×2, 149 unit tests, relay 11.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:05:44 +00:00
|
|
|
|
"mac" => Resp::error(403, "這台 Mac 沒有開放從手機操作"),
|
|
|
|
|
|
_ => Resp::error(404, "沒有這個畫面"),
|
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
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 裝置連結: another AnyBot in this account (e.g. the one on the server) runs a screen tool on
|
|
|
|
|
|
/// this Mac. Only through the relay, and only when this Mac allows it.
|
2026-11-04 12:38:45 +00:00
|
|
|
|
fn device_run(v: &Value, s: &Settings, via_relay: bool, from: &str) -> Resp {
|
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 !via_relay || !s.device_link {
|
|
|
|
|
|
return Resp::error(403, "這台電腦沒有開放給帳號裡的其他 AnyBot 操作");
|
|
|
|
|
|
}
|
2026-11-04 12:38:45 +00:00
|
|
|
|
if !s.hands_for.is_empty() && from != s.hands_for {
|
|
|
|
|
|
return Resp::error(403, &format!("這台電腦只讓大腦「{}」使用", s.hands_for_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
|
|
|
|
let tool = v["tool"].as_str().unwrap_or("");
|
|
|
|
|
|
let args = v["args"].as_str().unwrap_or("{}");
|
|
|
|
|
|
let who = v["who"].as_str().unwrap_or("遠端 AnyBot");
|
2026-11-05 07:00:31 +00:00
|
|
|
|
// The brain's model request to a model only this computer's network reaches.
|
|
|
|
|
|
if tool == MODEL_FETCH {
|
|
|
|
|
|
return model_fetch_here(args);
|
|
|
|
|
|
}
|
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 crate::computer::is_computer_tool(tool) {
|
|
|
|
|
|
let (text, ok, img) = crate::computer::run_by(tool, args, who);
|
|
|
|
|
|
return Resp::json(&json!({"text": text, "ok": ok, "image": img}));
|
|
|
|
|
|
}
|
2026-11-04 09:46:43 +00:00
|
|
|
|
// The AnyBot browser here (its logins are this computer's).
|
|
|
|
|
|
if crate::web::is_web_tool(tool) && tool != "web_fetch" {
|
|
|
|
|
|
let mut v: Value = serde_json::from_str(args).unwrap_or(json!({}));
|
|
|
|
|
|
if let Some(o) = v.as_object_mut() {
|
|
|
|
|
|
o.remove("computer");
|
|
|
|
|
|
}
|
|
|
|
|
|
let (text, ok, img) = crate::web::run_full(tool, &v.to_string(), &std::sync::atomic::AtomicBool::new(false));
|
|
|
|
|
|
return Resp::json(&json!({"text": text, "ok": ok, "image": img}));
|
|
|
|
|
|
}
|
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 crate::macapps::is_mac_tool(tool) && cfg!(target_os = "macos") {
|
|
|
|
|
|
let (text, ok) = crate::macapps::run(tool, args);
|
|
|
|
|
|
return Resp::json(&json!({"text": text, "ok": ok}));
|
|
|
|
|
|
}
|
Real-use fixes: the person sees the screens the bot shows, files reach the phone, no blank-tab loops
From real runs on the cloud brain with the person's Mac (Threads patrol, a post draft, "show me my
screen"):
- computer_use_show: a plain picture of any computer's screen goes into the chat (the bot sees it
too). The bot used to describe a screenshot in words and fail to send it.
- The phone shows the bot's attachments: pictures in place, other files download on a tap (api/file,
only from the chats' file folder). It showed nothing for the bot and names only for the person.
- send_file works from the person's other computers (fetch_file over the relay, 20 MB, secrets stay).
- The same call with the same answer again and again is warned the 3rd time and stopped from the
4th (a blank tab was read 11 times, then the bot said Threads was logged out without opening it);
a blank tab says so; checking a login means opening the site.
- Screenshot lists: a nameless row around a named button isn't numbered, a nameless row takes the
words inside it, empty spacer rows aren't numbered (System Settings: 63 numbers -> 33, all named).
- Writing "in my voice" reads the person's own recent posts first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
2026-11-06 11:37:45 +00:00
|
|
|
|
// send_file from this computer: the file itself goes to the brain, which puts it in the chat.
|
|
|
|
|
|
if tool == FETCH_FILE {
|
|
|
|
|
|
return fetch_file_here(args);
|
|
|
|
|
|
}
|
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 matches!(tool, "shell" | "read_file" | "edit_file" | "list_dir" | "search" | "read_terminal" | "write_terminal") {
|
2026-11-05 13:28:32 +00:00
|
|
|
|
// The brain passes the bot's work folder inside the tool's args (empty = this home).
|
|
|
|
|
|
let dir = crate::tools::args_of(args).get("workdir").and_then(Value::as_str).unwrap_or("").trim().to_string();
|
|
|
|
|
|
let mut workdir = crate::tools::expand_home(if dir.is_empty() { "~" } else { &dir });
|
|
|
|
|
|
if !workdir.is_absolute() {
|
|
|
|
|
|
workdir = dirs::home_dir().unwrap_or(workdir);
|
|
|
|
|
|
}
|
|
|
|
|
|
let t = crate::tools::Target { name: crate::model::THIS_COMPUTER_NAME.into(), place: crate::tools::Place::Local { workdir } };
|
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 ids = crate::tools::RunIds { conversation: "device".into(), request: httpd::random_hex(6) };
|
|
|
|
|
|
let (text, ok) = crate::tools::run_on(tool, args, &t, &std::sync::atomic::AtomicBool::new(false), &ids);
|
|
|
|
|
|
return Resp::json(&json!({"text": text, "ok": ok}));
|
|
|
|
|
|
}
|
|
|
|
|
|
Resp::error(400, "這個工具不能遠端使用")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Real-use fixes: the person sees the screens the bot shows, files reach the phone, no blank-tab loops
From real runs on the cloud brain with the person's Mac (Threads patrol, a post draft, "show me my
screen"):
- computer_use_show: a plain picture of any computer's screen goes into the chat (the bot sees it
too). The bot used to describe a screenshot in words and fail to send it.
- The phone shows the bot's attachments: pictures in place, other files download on a tap (api/file,
only from the chats' file folder). It showed nothing for the bot and names only for the person.
- send_file works from the person's other computers (fetch_file over the relay, 20 MB, secrets stay).
- The same call with the same answer again and again is warned the 3rd time and stopped from the
4th (a blank tab was read 11 times, then the bot said Threads was logged out without opening it);
a blank tab says so; checking a login means opening the site.
- Screenshot lists: a nameless row around a named button isn't numbered, a nameless row takes the
words inside it, empty spacer rows aren't numbered (System Settings: 63 numbers -> 33, all named).
- Writing "in my voice" reads the person's own recent posts first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
2026-11-06 11:37:45 +00:00
|
|
|
|
/// A file on this computer, for the brain's send_file (the brain has no other way to it).
|
|
|
|
|
|
pub const FETCH_FILE: &str = "fetch_file";
|
|
|
|
|
|
/// Largest file sent that way: the relay carries 30 MB, and base64 adds a third.
|
|
|
|
|
|
const FETCH_FILE_MAX: u64 = 20 * 1024 * 1024;
|
|
|
|
|
|
|
|
|
|
|
|
fn fetch_file_here(args: &str) -> Resp {
|
|
|
|
|
|
let v = crate::tools::args_of(args);
|
|
|
|
|
|
let path = v["path"].as_str().unwrap_or("").trim().to_string();
|
|
|
|
|
|
let dir = v["workdir"].as_str().unwrap_or("").trim().to_string();
|
|
|
|
|
|
let base = crate::tools::expand_home(if dir.is_empty() { "~" } else { &dir });
|
|
|
|
|
|
let full = crate::tools::resolve(&base, &path);
|
|
|
|
|
|
// Same rule as reading a file: keys, the app's own data and other secrets never leave.
|
|
|
|
|
|
if crate::tools::is_ignored(&full) || crate::tools::is_ignored(std::path::Path::new(&path)) {
|
|
|
|
|
|
return Resp::json(&json!({"text": "這個檔案是機密的,不能傳到聊天。", "ok": false}));
|
|
|
|
|
|
}
|
|
|
|
|
|
match std::fs::metadata(&full) {
|
|
|
|
|
|
Ok(m) if m.is_dir() => Resp::json(&json!({"text": format!("「{path}」是資料夾,請先壓縮成一個檔案。"), "ok": false})),
|
|
|
|
|
|
Ok(m) if m.len() > FETCH_FILE_MAX => Resp::json(&json!({"text": format!("「{path}」太大了(最多 20 MB)。"), "ok": false})),
|
|
|
|
|
|
Ok(_) => match std::fs::read(&full) {
|
|
|
|
|
|
Ok(b) => {
|
|
|
|
|
|
use base64::Engine;
|
|
|
|
|
|
let name = full.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "檔案".into());
|
|
|
|
|
|
Resp::json(&json!({"ok": true, "text": "", "name": name, "data": base64::engine::general_purpose::STANDARD.encode(&b)}))
|
|
|
|
|
|
}
|
|
|
|
|
|
Err(e) => Resp::json(&json!({"text": format!("讀不到「{path}」:{e}"), "ok": false})),
|
|
|
|
|
|
},
|
|
|
|
|
|
Err(_) => Resp::json(&json!({"text": format!("這台電腦上找不到「{path}」。"), "ok": false})),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-05 07:00:31 +00:00
|
|
|
|
// ---------- a model on the person's network, for a brain on a server ----------
|
|
|
|
|
|
// A brain in the cloud can't reach a model at a private address (a Qwen server at work, Ollama at
|
|
|
|
|
|
// home). Its hands can: the brain hands them the request and gets the whole answer back.
|
|
|
|
|
|
|
|
|
|
|
|
const MODEL_FETCH: &str = "model_fetch";
|
|
|
|
|
|
|
|
|
|
|
|
/// Private, link-local or loopback address (by name: localhost and *.local).
|
|
|
|
|
|
fn private_host(host: &str) -> bool {
|
|
|
|
|
|
let h = host.trim_matches(['[', ']']);
|
|
|
|
|
|
if h == "localhost" || h.ends_with(".local") {
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
match h.parse::<std::net::IpAddr>() {
|
2026-11-05 07:48:20 +00:00
|
|
|
|
// Not link-local: a cloud VM's metadata service (169.254.169.254) must never be
|
|
|
|
|
|
// reachable through the hands, only real model servers on the person's network.
|
|
|
|
|
|
Ok(std::net::IpAddr::V4(a)) => (a.is_private() || a.is_loopback()) && !a.is_link_local(),
|
|
|
|
|
|
Ok(std::net::IpAddr::V6(a)) => (a.is_loopback() || (a.segments()[0] & 0xfe00) == 0xfc00) && !a.is_unicast_link_local(),
|
2026-11-05 07:00:31 +00:00
|
|
|
|
Err(_) => false,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn host_port(url: &str) -> Option<(String, u16)> {
|
|
|
|
|
|
let (scheme, rest) = url.split_once("://")?;
|
|
|
|
|
|
let auth = rest.split('/').next()?;
|
|
|
|
|
|
let auth = auth.rsplit('@').next()?;
|
|
|
|
|
|
let (h, p) = match auth.rsplit_once(':') {
|
|
|
|
|
|
Some((h, p)) if !h.ends_with(']') || auth.starts_with('[') => (h.to_string(), p.parse().ok()?),
|
|
|
|
|
|
_ => (auth.to_string(), if scheme == "https" { 443 } else { 80 }),
|
|
|
|
|
|
};
|
|
|
|
|
|
Some((h, p))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The hands to send a model request through: the model is at a private address this brain
|
|
|
|
|
|
/// can't open a connection to, and the person's computer lends this brain its hands.
|
|
|
|
|
|
pub fn model_hands_for(url: &str) -> Option<String> {
|
|
|
|
|
|
let s = hub().settings();
|
2026-11-05 08:47:28 +00:00
|
|
|
|
// The person's own computer (the one they type on) is the one on their network; the first
|
|
|
|
|
|
// hands by id was a desktop in the server room that can't reach a model at their office.
|
|
|
|
|
|
let hands = my_hands(&s.host_id);
|
|
|
|
|
|
let person = person_host();
|
|
|
|
|
|
let hand = hands.iter().find(|h| **h == person).or_else(|| hands.first()).cloned()?;
|
2026-11-05 07:00:31 +00:00
|
|
|
|
let (h, p) = host_port(url)?;
|
|
|
|
|
|
if !private_host(&h) || h == "localhost" || h.starts_with("127.") {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
static SEEN: Mutex<Option<HashMap<String, (bool, std::time::Instant)>>> = Mutex::new(None);
|
|
|
|
|
|
let key = format!("{h}:{p}");
|
|
|
|
|
|
if let Some((reach, at)) = SEEN.lock().unwrap().get_or_insert_with(HashMap::new).get(&key) {
|
|
|
|
|
|
if at.elapsed().as_secs() < 300 {
|
|
|
|
|
|
return if *reach { None } else { Some(hand) };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
use std::net::ToSocketAddrs;
|
|
|
|
|
|
let reach = (h.as_str(), p).to_socket_addrs().ok().and_then(|mut a| a.next()).map(|a| std::net::TcpStream::connect_timeout(&a, std::time::Duration::from_millis(1500)).is_ok()).unwrap_or(false);
|
|
|
|
|
|
SEEN.lock().unwrap().get_or_insert_with(HashMap::new).insert(key, (reach, std::time::Instant::now()));
|
|
|
|
|
|
if reach { None } else { Some(hand) }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Send a model request (POST, JSON) through the hands; gives back its status and whole body.
|
|
|
|
|
|
pub fn model_via_hands(hand: &str, url: &str, headers: &[(String, String)], body: &str) -> Result<(u16, Vec<u8>), String> {
|
|
|
|
|
|
let args = json!({"url": url, "headers": headers, "body": body}).to_string();
|
|
|
|
|
|
let req = json!({"tool": MODEL_FETCH, "args": args, "who": "大腦"}).to_string();
|
|
|
|
|
|
let name = peer_name(hand);
|
2026-11-05 08:47:28 +00:00
|
|
|
|
let whom = if name.is_empty() { "使用者的電腦".to_string() } else { format!("「{name}」") };
|
2026-11-05 07:00:31 +00:00
|
|
|
|
let r = crate::tunnel::call(hand, "POST", "/api/device/run", &[], req.as_bytes(), 600).ok_or_else(|| format!("模型在只有{whom}連得到的網路上,但大腦現在和帳號伺服器沒有連線"))?;
|
|
|
|
|
|
let a = r.map_err(|e| format!("透過{whom}連模型失敗:{e}"))?;
|
|
|
|
|
|
let v = answer_value(a.status, &a.body, &whom)?;
|
|
|
|
|
|
if let Some(e) = v["error"].as_str() {
|
|
|
|
|
|
return Err(format!("{whom}也連不到模型:{e}"));
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok((v["status"].as_u64().unwrap_or(502) as u16, v["text"].as_str().unwrap_or("").as_bytes().to_vec()))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// On the hands: make the brain's model request, only to an address on this computer's network.
|
|
|
|
|
|
fn model_fetch_here(args: &str) -> Resp {
|
|
|
|
|
|
let v: Value = serde_json::from_str(args).unwrap_or(Value::Null);
|
|
|
|
|
|
let url = v["url"].as_str().unwrap_or("");
|
|
|
|
|
|
if !host_port(url).map(|(h, _)| private_host(&h)).unwrap_or(false) {
|
|
|
|
|
|
return Resp::error(403, "只幫大腦連這台電腦網路裡的模型");
|
|
|
|
|
|
}
|
|
|
|
|
|
let mut r = agent(600).post(url).header("Content-Type", "application/json");
|
|
|
|
|
|
for h in v["headers"].as_array().into_iter().flatten() {
|
|
|
|
|
|
if let (Some(k), Some(val)) = (h[0].as_str(), h[1].as_str()) {
|
|
|
|
|
|
r = r.header(k, val);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
match r.config().http_status_as_error(false).build().send(v["body"].as_str().unwrap_or("").as_bytes()) {
|
|
|
|
|
|
Ok(mut resp) => {
|
|
|
|
|
|
let status = resp.status().as_u16();
|
|
|
|
|
|
let text = resp.body_mut().with_config().limit(50 * 1024 * 1024).read_to_string().unwrap_or_default();
|
|
|
|
|
|
Resp::json(&json!({"status": status, "text": text}))
|
|
|
|
|
|
}
|
|
|
|
|
|
Err(e) => Resp::json(&json!({"error": why_unreachable(&e)})),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
// ---------- the local server ----------
|
|
|
|
|
|
|
|
|
|
|
|
static LAN_GEN: AtomicU64 = AtomicU64::new(0);
|
|
|
|
|
|
static LAN_BOUND: Mutex<Option<u16>> = Mutex::new(None);
|
|
|
|
|
|
|
|
|
|
|
|
pub fn lan_port() -> Option<u16> {
|
|
|
|
|
|
*LAN_BOUND.lock().unwrap()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Start (or stop) answering phones on the local network.
|
|
|
|
|
|
pub fn set_lan(on: bool) -> Result<u16, String> {
|
|
|
|
|
|
let gen = LAN_GEN.fetch_add(1, Ordering::SeqCst) + 1;
|
|
|
|
|
|
*LAN_BOUND.lock().unwrap() = None;
|
|
|
|
|
|
if !on {
|
|
|
|
|
|
return Ok(0);
|
|
|
|
|
|
}
|
|
|
|
|
|
let port_env: Option<u16> = std::env::var("ANYBOT_PHONE_PORT").ok().and_then(|p| p.parse().ok());
|
|
|
|
|
|
let start = port_env.unwrap_or(LAN_PORT);
|
|
|
|
|
|
// The old listener notices the new generation within 50 ms; give it the port back.
|
|
|
|
|
|
let listener = (0..40)
|
|
|
|
|
|
.find_map(|_| {
|
|
|
|
|
|
let l = (start..start + 10).find_map(|p| std::net::TcpListener::bind(("0.0.0.0", p)).ok());
|
|
|
|
|
|
if l.is_none() {
|
|
|
|
|
|
std::thread::sleep(Duration::from_millis(60));
|
|
|
|
|
|
}
|
|
|
|
|
|
l
|
|
|
|
|
|
})
|
|
|
|
|
|
.ok_or("找不到可用的連接埠")?;
|
|
|
|
|
|
let port = listener.local_addr().map_err(|e| e.to_string())?.port();
|
|
|
|
|
|
*LAN_BOUND.lock().unwrap() = Some(port);
|
People watch and help on the bots' computer through VNC (noVNC), carried by the account server
The core: the bot works the computer; a person watches it like a colleague, and takes over
when it needs them. VNC is made for that (only what changed, every mouse move and key, touch on
phones), so the pictures sent one by one go, and with them their own input path.
- vnc.rs: a VNC proxy to the box's x11vnc through one docker exec (a Python bridge that ends
with either side). It signs in to the box with the box's own password (its VNC_PASSWORD) and
offers the viewer no password, so the password never leaves the brain. The person's clicks
and keys make the bot's steps wait until 8 s after they stop; text typed in the viewer's box
(any language) is pasted in the box.
- Account server: /screen (the viewer page) and /novnc.js (noVNC 1.6.0, MPL-2.0, bundled);
/relay/vnc/<host> takes the browser's WebSocket (account session, or a 10-minute ticket the
app asks for with POST /relay/screen-ticket; owners and admins) and carries it over that
AnyBot's lasting connection (vopen / vdata / vtext / vclose). Both ends of that connection
wait 2 ms instead of 40 ms to read while things move: a request and its answer took up to
160 ms more than needed.
- Phone page: 看 Bot 的電腦 and 接管 open the viewer page (also on the local network: /api/vnc on
the AnyBot's own phone server). This Mac's own screen moved to the ⋯ menu.
- Mac app: 看畫面 opens the viewer in the browser (a screen of a brain: the brain's computer by
ticket; else this computer's box, noVNC straight from it). The in-app picture viewer, the
live JPEG stream, the person's click/move/scroll/key endpoints and the input shell are gone.
- Opening a Mac app uses LaunchServices (`open -a`): agent-desktop's launch waited on the window
through accessibility and failed without 輔助使用 now and then.
Measured on this Mac (made-up account, local relay + headless brain + the real box): viewer
signed in in 0.4–0.7 s, a request and its answer through relay + brain + box 25–58 ms median;
headless Chrome shows the desktop live in /screen. remote_e2e 28/28 ×2, 149 unit tests, relay 11.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:05:44 +00:00
|
|
|
|
std::thread::spawn(move || httpd::serve_upgrade(listener, Arc::new(|r| handle(r, false)), Some(Arc::new(lan_vnc)), Arc::new(move || LAN_GEN.load(Ordering::SeqCst) == gen)));
|
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
|
|
|
|
Ok(port)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
People watch and help on the bots' computer through VNC (noVNC), carried by the account server
The core: the bot works the computer; a person watches it like a colleague, and takes over
when it needs them. VNC is made for that (only what changed, every mouse move and key, touch on
phones), so the pictures sent one by one go, and with them their own input path.
- vnc.rs: a VNC proxy to the box's x11vnc through one docker exec (a Python bridge that ends
with either side). It signs in to the box with the box's own password (its VNC_PASSWORD) and
offers the viewer no password, so the password never leaves the brain. The person's clicks
and keys make the bot's steps wait until 8 s after they stop; text typed in the viewer's box
(any language) is pasted in the box.
- Account server: /screen (the viewer page) and /novnc.js (noVNC 1.6.0, MPL-2.0, bundled);
/relay/vnc/<host> takes the browser's WebSocket (account session, or a 10-minute ticket the
app asks for with POST /relay/screen-ticket; owners and admins) and carries it over that
AnyBot's lasting connection (vopen / vdata / vtext / vclose). Both ends of that connection
wait 2 ms instead of 40 ms to read while things move: a request and its answer took up to
160 ms more than needed.
- Phone page: 看 Bot 的電腦 and 接管 open the viewer page (also on the local network: /api/vnc on
the AnyBot's own phone server). This Mac's own screen moved to the ⋯ menu.
- Mac app: 看畫面 opens the viewer in the browser (a screen of a brain: the brain's computer by
ticket; else this computer's box, noVNC straight from it). The in-app picture viewer, the
live JPEG stream, the person's click/move/scroll/key endpoints and the input shell are gone.
- Opening a Mac app uses LaunchServices (`open -a`): agent-desktop's launch waited on the window
through accessibility and failed without 輔助使用 now and then.
Measured on this Mac (made-up account, local relay + headless brain + the real box): viewer
signed in in 0.4–0.7 s, a request and its answer through relay + brain + box 25–58 ms median;
headless Chrome shows the desktop live in /screen. remote_e2e 28/28 ×2, 149 unit tests, relay 11.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:05:44 +00:00
|
|
|
|
/// The phone page on the local network watching the bots' computer: /api/vnc, signed in with
|
|
|
|
|
|
/// the phone's password like every other request here; the VNC stream goes straight to the box.
|
|
|
|
|
|
fn lan_vnc(req: Req, mut c: std::net::TcpStream) {
|
|
|
|
|
|
use tungstenite::{protocol::Role, Message, WebSocket};
|
|
|
|
|
|
if req.path != "/api/vnc" {
|
|
|
|
|
|
return httpd::write_response(&mut c, &Resp::error(404, "沒有這個網址"));
|
|
|
|
|
|
}
|
|
|
|
|
|
if devices_ok(&req.bearer()).is_none() {
|
|
|
|
|
|
return httpd::write_response(&mut c, &Resp::error(401, "請先登入"));
|
|
|
|
|
|
}
|
Remove what nothing uses and keep one copy of what was written several times
Unused (found by taking away the dead-code allowances; nothing else used them, tests aside):
computer::specs, quiet_button, the never-made Poke::Spin, ten icons and their SVGs, the
theme's chrome/bubble_agent colours, ImportRow.line, voice's multipart/transcribe/speak
wrappers, and the old keychain writers vault::save/remove/index_add with their live test
(the vault store replaced them; reading old keychain entries for the import stays).
One copy each: percent-encoding (httpd::url_encode, was four), tool arguments (tools::args_of,
was three), the WebSocket handshake (httpd::accept_websocket, was three), the read pacing
(wire::Pace, was two: relay and app), docker with input and a time limit (dk_with_input, was
two). Docs: FEATURES §看畫面 (VNC), protocol (v* frames, /screen, tickets), architecture.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:21:47 +00:00
|
|
|
|
if !httpd::accept_websocket(&req, &mut c) {
|
People watch and help on the bots' computer through VNC (noVNC), carried by the account server
The core: the bot works the computer; a person watches it like a colleague, and takes over
when it needs them. VNC is made for that (only what changed, every mouse move and key, touch on
phones), so the pictures sent one by one go, and with them their own input path.
- vnc.rs: a VNC proxy to the box's x11vnc through one docker exec (a Python bridge that ends
with either side). It signs in to the box with the box's own password (its VNC_PASSWORD) and
offers the viewer no password, so the password never leaves the brain. The person's clicks
and keys make the bot's steps wait until 8 s after they stop; text typed in the viewer's box
(any language) is pasted in the box.
- Account server: /screen (the viewer page) and /novnc.js (noVNC 1.6.0, MPL-2.0, bundled);
/relay/vnc/<host> takes the browser's WebSocket (account session, or a 10-minute ticket the
app asks for with POST /relay/screen-ticket; owners and admins) and carries it over that
AnyBot's lasting connection (vopen / vdata / vtext / vclose). Both ends of that connection
wait 2 ms instead of 40 ms to read while things move: a request and its answer took up to
160 ms more than needed.
- Phone page: 看 Bot 的電腦 and 接管 open the viewer page (also on the local network: /api/vnc on
the AnyBot's own phone server). This Mac's own screen moved to the ⋯ menu.
- Mac app: 看畫面 opens the viewer in the browser (a screen of a brain: the brain's computer by
ticket; else this computer's box, noVNC straight from it). The in-app picture viewer, the
live JPEG stream, the person's click/move/scroll/key endpoints and the input shell are gone.
- Opening a Mac app uses LaunchServices (`open -a`): agent-desktop's launch waited on the window
through accessibility and failed without 輔助使用 now and then.
Measured on this Mac (made-up account, local relay + headless brain + the real box): viewer
signed in in 0.4–0.7 s, a request and its answer through relay + brain + box 25–58 ms median;
headless Chrome shows the desktop live in /screen. remote_e2e 28/28 ×2, 149 unit tests, relay 11.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:05:44 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
let _ = c.set_read_timeout(Some(Duration::from_millis(2)));
|
|
|
|
|
|
let mut ws = WebSocket::from_raw_socket(c, Role::Server, None);
|
|
|
|
|
|
let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
|
|
|
|
|
|
let send = move |b: &[u8]| tx.send(b.to_vec()).is_ok();
|
|
|
|
|
|
let Ok(mut session) = crate::vnc::open(send.clone()) else { return };
|
|
|
|
|
|
if !send(crate::vnc::GREETING) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
'live: loop {
|
|
|
|
|
|
match ws.read() {
|
|
|
|
|
|
Ok(Message::Binary(b)) => {
|
|
|
|
|
|
if !session.feed(&b, &send) {
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok(Message::Text(t)) => crate::vnc::person_typed(&t),
|
|
|
|
|
|
Ok(Message::Close(_)) => break,
|
|
|
|
|
|
Ok(_) => {}
|
|
|
|
|
|
Err(tungstenite::Error::Io(e)) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => {}
|
|
|
|
|
|
Err(_) => break,
|
|
|
|
|
|
}
|
|
|
|
|
|
loop {
|
|
|
|
|
|
match rx.try_recv() {
|
|
|
|
|
|
// An empty send: the box's side ended.
|
|
|
|
|
|
Ok(b) if b.is_empty() => break 'live,
|
|
|
|
|
|
Ok(b) => {
|
|
|
|
|
|
if ws.write(Message::Binary(b.into())).is_err() {
|
|
|
|
|
|
break 'live;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Err(_) => break,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
match ws.flush() {
|
|
|
|
|
|
Err(tungstenite::Error::Io(e)) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => {}
|
|
|
|
|
|
Err(_) => break,
|
|
|
|
|
|
Ok(()) => {}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
let _ = ws.close(None);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
/// Addresses a phone can open (local network and Tailscale).
|
|
|
|
|
|
pub fn addresses(port: u16) -> Vec<String> {
|
|
|
|
|
|
// macOS: ifconfig ("inet 192.168.1.5 netmask…"); Linux: `ip -4 -o addr` ("… inet 192.168.1.5/24 …").
|
|
|
|
|
|
let mut out = std::process::Command::new("ifconfig").output().map(|o| String::from_utf8_lossy(&o.stdout).to_string()).unwrap_or_default();
|
|
|
|
|
|
if out.trim().is_empty() {
|
|
|
|
|
|
out = std::process::Command::new("ip").args(["-4", "-o", "addr"]).output().map(|o| String::from_utf8_lossy(&o.stdout).to_string()).unwrap_or_default();
|
|
|
|
|
|
}
|
|
|
|
|
|
let mut v: Vec<String> = out
|
|
|
|
|
|
.lines()
|
|
|
|
|
|
.filter_map(|l| l.split("inet ").nth(1))
|
|
|
|
|
|
.filter_map(|l| l.split_whitespace().next())
|
|
|
|
|
|
.map(|ip| ip.split('/').next().unwrap_or(ip))
|
|
|
|
|
|
.filter(|ip| !ip.starts_with("127.") && !ip.starts_with("169.254."))
|
|
|
|
|
|
.map(|ip| format!("http://{ip}:{port}"))
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
v.dedup();
|
|
|
|
|
|
v
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------- the relay connection (this AnyBot as a "host") ----------
|
|
|
|
|
|
|
|
|
|
|
|
static RELAY_GEN: AtomicU64 = AtomicU64::new(0);
|
|
|
|
|
|
|
2026-11-04 11:30:32 +00:00
|
|
|
|
/// One agent per timeout, kept: its connections stay open between requests (no new TCP and TLS
|
|
|
|
|
|
/// handshake each time).
|
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 agent(secs: u64) -> ureq::Agent {
|
2026-11-04 11:30:32 +00:00
|
|
|
|
static AGENTS: OnceLock<Mutex<HashMap<u64, ureq::Agent>>> = OnceLock::new();
|
|
|
|
|
|
AGENTS
|
|
|
|
|
|
.get_or_init(Mutex::default)
|
|
|
|
|
|
.lock()
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.entry(secs)
|
|
|
|
|
|
.or_insert_with(|| ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(secs))).http_status_as_error(false).build().into())
|
|
|
|
|
|
.clone()
|
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
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-04 11:07:55 +00:00
|
|
|
|
/// Why a request never got an answer, in plain words (never an error code).
|
|
|
|
|
|
pub fn why_unreachable(e: &ureq::Error) -> String {
|
|
|
|
|
|
use std::io::ErrorKind as K;
|
|
|
|
|
|
use ureq::Error as E;
|
|
|
|
|
|
let text = e.to_string().to_lowercase();
|
2026-11-04 23:56:19 +00:00
|
|
|
|
// A TLS failure often comes wrapped as an I/O error: look at the words first.
|
|
|
|
|
|
if text.contains("certificate") || text.contains("tls") || text.contains("handshake") {
|
|
|
|
|
|
return "和伺服器的安全連線失敗:對方的憑證不對(網址可能連到了別的服務,或憑證過期)".to_string();
|
|
|
|
|
|
}
|
2026-11-04 11:07:55 +00:00
|
|
|
|
let w = match e {
|
|
|
|
|
|
E::HostNotFound => "找不到伺服器:這台電腦可能沒連上網路,或伺服器網址打錯了",
|
|
|
|
|
|
E::ConnectionFailed => "伺服器沒有回應:可能沒開機,或網路把連線擋住了",
|
|
|
|
|
|
E::Timeout(_) => "等太久沒有回應:網路很慢或斷斷續續",
|
|
|
|
|
|
E::Io(io) => match io.kind() {
|
|
|
|
|
|
K::ConnectionRefused => "伺服器沒有開(連線被拒絕)",
|
|
|
|
|
|
K::ConnectionReset | K::ConnectionAborted | K::BrokenPipe | K::UnexpectedEof => "連線到一半斷掉了",
|
|
|
|
|
|
K::TimedOut => "等太久沒有回應:網路很慢或斷斷續續",
|
|
|
|
|
|
K::NetworkUnreachable | K::HostUnreachable | K::NetworkDown | K::AddrNotAvailable => "這台電腦沒有連上網路",
|
|
|
|
|
|
_ => "網路連線出了問題",
|
|
|
|
|
|
},
|
|
|
|
|
|
_ if text.contains("dns") || text.contains("resolve") => "找不到伺服器:這台電腦可能沒連上網路,或伺服器網址打錯了",
|
|
|
|
|
|
_ => "網路連線出了問題",
|
|
|
|
|
|
};
|
|
|
|
|
|
w.to_string()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// An answer that wasn't a yes, in plain words. `whom` names the other side (大腦, 那台電腦…).
|
|
|
|
|
|
pub fn why_refused(st: u16, v: &Value, whom: &str) -> String {
|
|
|
|
|
|
if let Some(e) = v["error"].as_str().filter(|e| !e.is_empty()) {
|
|
|
|
|
|
return e.replace("那台 AnyBot ", whom).replace("那台 AnyBot", whom);
|
|
|
|
|
|
}
|
|
|
|
|
|
match st {
|
|
|
|
|
|
401 | 403 => "這台電腦的帳號登入失效了,請到「設定 › 手機與帳號」重新登入".into(),
|
|
|
|
|
|
404 => format!("{whom}不在這個帳號裡了(可能被移除,或兩邊 AnyBot 版本不同)"),
|
|
|
|
|
|
413 => "檔案太大,帳號伺服器不收".into(),
|
|
|
|
|
|
502 | 503 => "帳號伺服器暫時無法使用(可能正在重新啟動),稍後會自動再試".into(),
|
|
|
|
|
|
504 => format!("{whom}沒有及時回應(可能太忙或網路很慢)"),
|
|
|
|
|
|
500..=599 => "帳號伺服器出了問題,稍後會自動再試".into(),
|
|
|
|
|
|
_ => format!("{whom}的回應看不懂,請把兩邊的 AnyBot 都更新到最新版"),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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 relay_post(url: &str, bearer: &str, body: &Value) -> Result<Value, String> {
|
|
|
|
|
|
let mut r = agent(30).post(url).header("Content-Type", "application/json");
|
|
|
|
|
|
if !bearer.is_empty() {
|
|
|
|
|
|
r = r.header("Authorization", &format!("Bearer {bearer}"));
|
|
|
|
|
|
}
|
2026-11-04 11:07:55 +00:00
|
|
|
|
let mut resp = r.send(body.to_string().as_bytes()).map_err(|e| format!("連不到帳號伺服器:{}", why_unreachable(&e)))?;
|
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 st = resp.status().as_u16();
|
|
|
|
|
|
let text = resp.body_mut().read_to_string().unwrap_or_default();
|
|
|
|
|
|
let v: Value = serde_json::from_str(&text).unwrap_or(Value::Null);
|
|
|
|
|
|
if !(200..300).contains(&st) {
|
2026-11-04 11:07:55 +00:00
|
|
|
|
return Err(why_refused(st, &v, "帳號伺服器"));
|
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
|
|
|
|
}
|
|
|
|
|
|
Ok(v)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn base(url: &str) -> String {
|
|
|
|
|
|
url.trim().trim_end_matches('/').to_string()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Sign in (or create the account) at the relay and register this AnyBot as one of its hosts.
|
|
|
|
|
|
/// Returns the settings to keep.
|
|
|
|
|
|
pub fn relay_join(base_settings: &Settings, url: &str, user: &str, password: &str, create: bool, invite: &str) -> Result<Settings, String> {
|
2026-11-05 07:48:20 +00:00
|
|
|
|
// The relay signs people in by the trimmed, lowered name; the backup key must be derived
|
|
|
|
|
|
// from exactly the same string, or two computers signing in as "Dan" and "dan" get keys
|
|
|
|
|
|
// that cannot open each other's backups.
|
|
|
|
|
|
let user = user.trim().to_lowercase();
|
|
|
|
|
|
let user = user.as_str();
|
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 host_name = base_settings.host_name.as_str();
|
|
|
|
|
|
let url = base(url);
|
|
|
|
|
|
// Plain http only inside one machine or one Docker network (e.g. http://relay:8080).
|
|
|
|
|
|
let host = url.strip_prefix("http://").map(|r| r.split(['/', ':']).next().unwrap_or("")).unwrap_or("");
|
|
|
|
|
|
// Tailscale (100.64.0.0/10 and *.ts.net) is already encrypted end to end.
|
|
|
|
|
|
let tailscale = host.ends_with(".ts.net") || host.split('.').collect::<Vec<_>>().as_slice().first().map(|a| *a == "100").unwrap_or(false) && host.split('.').nth(1).and_then(|b| b.parse::<u8>().ok()).map(|b| (64..128).contains(&b)).unwrap_or(false);
|
|
|
|
|
|
let internal = host == "127.0.0.1" || host == "localhost" || tailscale || (!host.is_empty() && !host.contains('.'));
|
|
|
|
|
|
if !url.starts_with("https://") && !internal {
|
|
|
|
|
|
return Err("帳號伺服器要用 https:// 開頭的網址(這台電腦或 Tailscale 位址可以用 http://)".into());
|
|
|
|
|
|
}
|
|
|
|
|
|
if create {
|
2026-11-04 13:34:15 +00:00
|
|
|
|
if let Err(e) = relay_post(&format!("{url}/relay/signup"), "", &json!({"user": user, "password": password, "invite": invite})) {
|
|
|
|
|
|
// The account may be there already (made earlier, or on the phone): just sign in with it.
|
|
|
|
|
|
if relay_post(&format!("{url}/relay/login"), "", &json!({"user": user, "password": password, "device": host_name})).is_err() {
|
|
|
|
|
|
return Err(if e.contains("不開放註冊") || e.contains("邀請碼") {
|
|
|
|
|
|
"這台帳號伺服器已經有帳號了,不能再註冊新的。請取消勾選「建立新帳號」,用已經有的帳號登入。忘記密碼的話,在帳號伺服器那台電腦執行:anybot-relay --data 資料夾 --reset-password 帳號".into()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
e
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
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 v = relay_post(&format!("{url}/relay/login"), "", &json!({"user": user, "password": password, "device": host_name}))?;
|
|
|
|
|
|
let session = v["token"].as_str().ok_or("帳號伺服器沒有給登入憑證")?.to_string();
|
|
|
|
|
|
let h = relay_post(&format!("{url}/relay/hosts"), &session, &json!({"name": host_name}))?;
|
|
|
|
|
|
// The desktop doesn't need to stay signed in as a person; only the host key is kept.
|
|
|
|
|
|
let _ = relay_post(&format!("{url}/relay/logout"), &session, &json!({}));
|
|
|
|
|
|
let mut s = base_settings.clone();
|
|
|
|
|
|
s.relay_url = url;
|
|
|
|
|
|
s.relay_user = user.to_string();
|
|
|
|
|
|
s.host_id = h["id"].as_str().unwrap_or("").to_string();
|
|
|
|
|
|
s.host_token = h["token"].as_str().unwrap_or("").to_string();
|
|
|
|
|
|
s.backup_key = httpd::derive_key(password, user).iter().map(|b| format!("{b:02x}")).collect();
|
|
|
|
|
|
if s.host_id.is_empty() || s.host_token.is_empty() {
|
|
|
|
|
|
return Err("帳號伺服器沒有給這台電腦的金鑰".into());
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok(s)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Keep the outgoing connection to the relay: wait for the phone's requests and answer them.
|
|
|
|
|
|
pub fn relay_restart(s: &Settings) {
|
|
|
|
|
|
let gen = RELAY_GEN.fetch_add(1, Ordering::SeqCst) + 1;
|
|
|
|
|
|
if s.relay_url.is_empty() || s.host_token.is_empty() {
|
|
|
|
|
|
hub().set_relay_status("");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
let (url, token) = (s.relay_url.clone(), s.host_token.clone());
|
|
|
|
|
|
hub().set_relay_status("連線中…");
|
2026-11-04 11:30:32 +00:00
|
|
|
|
std::thread::spawn(move || {
|
|
|
|
|
|
// 長連線 first; an older account server without it gets asked over and over instead.
|
|
|
|
|
|
if crate::tunnel::run(&url, &token, &|| alive(gen), &|m| hub().set_relay_status(m)) || !alive(gen) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// A few workers, so a slow request (a long-poll) doesn't block the others.
|
|
|
|
|
|
for _ in 0..4 {
|
|
|
|
|
|
let (url, token) = (url.clone(), token.clone());
|
|
|
|
|
|
std::thread::spawn(move || relay_worker(&url, &token, gen));
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
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 alive(gen: u64) -> bool {
|
|
|
|
|
|
RELAY_GEN.load(Ordering::SeqCst) == gen
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn relay_worker(url: &str, token: &str, gen: u64) {
|
|
|
|
|
|
let mut backoff = 1u64;
|
|
|
|
|
|
while alive(gen) {
|
|
|
|
|
|
let r = agent(40).get(&format!("{url}/relay/host/next")).header("Authorization", &format!("Bearer {token}")).call();
|
|
|
|
|
|
let mut resp = match r {
|
|
|
|
|
|
Ok(r) => r,
|
|
|
|
|
|
Err(e) => {
|
2026-11-04 11:07:55 +00:00
|
|
|
|
hub().set_relay_status(&format!("連不上帳號伺服器:{}", why_unreachable(&e)));
|
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
|
|
|
|
std::thread::sleep(Duration::from_secs(backoff));
|
|
|
|
|
|
backoff = (backoff * 2).min(30);
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
let st = resp.status().as_u16();
|
|
|
|
|
|
if st == 401 {
|
|
|
|
|
|
hub().set_relay_status("這台電腦的金鑰失效了,請重新登入帳號");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if st == 204 {
|
|
|
|
|
|
hub().set_relay_status("已連線");
|
|
|
|
|
|
backoff = 1;
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
let text = resp.body_mut().read_to_string().unwrap_or_default();
|
|
|
|
|
|
let Ok(job) = serde_json::from_str::<Value>(&text) else {
|
2026-11-04 11:07:55 +00:00
|
|
|
|
hub().set_relay_status(&format!("連不上帳號伺服器:{}", why_refused(st, &Value::Null, "帳號伺服器")));
|
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
|
|
|
|
std::thread::sleep(Duration::from_secs(backoff));
|
2026-11-04 11:07:55 +00:00
|
|
|
|
backoff = (backoff * 2).min(30);
|
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
|
|
|
|
continue;
|
|
|
|
|
|
};
|
|
|
|
|
|
hub().set_relay_status("已連線");
|
|
|
|
|
|
backoff = 1;
|
|
|
|
|
|
let id = job["id"].as_str().unwrap_or("").to_string();
|
|
|
|
|
|
let req = req_from_json(&job);
|
|
|
|
|
|
// Answer on a separate thread so this worker can pick up the next request at once.
|
|
|
|
|
|
let (url, token) = (url.to_string(), token.to_string());
|
|
|
|
|
|
std::thread::spawn(move || {
|
|
|
|
|
|
let resp = handle(req, true);
|
|
|
|
|
|
let _ = agent(60).post(&format!("{url}/relay/host/reply/{id}")).header("Authorization", &format!("Bearer {token}")).header("Content-Type", "application/json").send(resp_to_json(&resp).to_string().as_bytes());
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn req_from_json(job: &Value) -> Req {
|
|
|
|
|
|
use base64::Engine;
|
|
|
|
|
|
Req {
|
|
|
|
|
|
method: job["method"].as_str().unwrap_or("GET").to_string(),
|
|
|
|
|
|
path: job["path"].as_str().unwrap_or("/").to_string(),
|
|
|
|
|
|
query: job["query"].as_object().map(|o| o.iter().map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string())).collect()).unwrap_or_default(),
|
|
|
|
|
|
// Who asked, as the relay vouches for it (團隊: owner / admin / member; "device" for 裝置連結).
|
2026-11-04 12:38:45 +00:00
|
|
|
|
headers: [("x-anybot-user", job["user"].as_str().unwrap_or("")), ("x-anybot-role", job["role"].as_str().unwrap_or("owner")), ("x-anybot-from", job["from"].as_str().unwrap_or(""))].iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
|
2026-11-04 11:30:32 +00:00
|
|
|
|
body: {
|
|
|
|
|
|
let b = base64::engine::general_purpose::STANDARD.decode(job["body"].as_str().unwrap_or("")).unwrap_or_default();
|
|
|
|
|
|
if job["z"].as_bool().unwrap_or(false) {
|
|
|
|
|
|
crate::tunnel::unzip(&b).unwrap_or_default()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
b
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
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
|
|
|
|
peer: "relay".into(),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn resp_to_json(r: &Resp) -> Value {
|
|
|
|
|
|
use base64::Engine;
|
|
|
|
|
|
json!({"status": r.status, "ctype": r.ctype, "body": base64::engine::general_purpose::STANDARD.encode(&r.body), "headers": r.headers})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Upload an encrypted copy of AnyBot's data to the relay.
|
|
|
|
|
|
pub fn backup_now(s: &Settings, state_json: &[u8]) -> Result<usize, String> {
|
|
|
|
|
|
let key = key_of(&s.backup_key).ok_or("還沒登入帳號")?;
|
|
|
|
|
|
let sealed = httpd::seal(&key, state_json);
|
|
|
|
|
|
let mut resp = agent(120)
|
|
|
|
|
|
.put(&format!("{}/relay/backup", s.relay_url))
|
|
|
|
|
|
.header("Authorization", &format!("Bearer {}", s.host_token))
|
|
|
|
|
|
.header("Content-Type", "application/octet-stream")
|
|
|
|
|
|
.send(&sealed[..])
|
|
|
|
|
|
.map_err(|e| format!("備份上傳失敗:{e}"))?;
|
|
|
|
|
|
let st = resp.status().as_u16();
|
|
|
|
|
|
if !(200..300).contains(&st) {
|
|
|
|
|
|
return Err(format!("備份上傳失敗:{}", resp.body_mut().read_to_string().unwrap_or_default()));
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok(sealed.len())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Download and open the newest backup of this account (for a new computer).
|
|
|
|
|
|
pub fn restore(url: &str, user: &str, password: &str) -> Result<Vec<u8>, String> {
|
2026-11-05 07:48:20 +00:00
|
|
|
|
// Same name shape the relay (and relay_join) use for the backup key.
|
|
|
|
|
|
let user = user.trim().to_lowercase();
|
|
|
|
|
|
let user = user.as_str();
|
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 url = base(url);
|
|
|
|
|
|
let v = relay_post(&format!("{url}/relay/login"), "", &json!({"user": user, "password": password, "device": "還原備份"}))?;
|
|
|
|
|
|
let session = v["token"].as_str().unwrap_or("").to_string();
|
|
|
|
|
|
let mut resp = agent(120).get(&format!("{url}/relay/backup")).header("Authorization", &format!("Bearer {session}")).call().map_err(|e| e.to_string())?;
|
|
|
|
|
|
let _ = relay_post(&format!("{url}/relay/logout"), &session, &json!({}));
|
|
|
|
|
|
if resp.status().as_u16() == 404 {
|
|
|
|
|
|
return Err("這個帳號還沒有備份".into());
|
|
|
|
|
|
}
|
|
|
|
|
|
let sealed = resp.body_mut().with_config().limit(200 * 1024 * 1024).read_to_vec().map_err(|e| e.to_string())?;
|
|
|
|
|
|
httpd::open(&httpd::derive_key(password, user), &sealed).ok_or_else(|| "備份打不開(密碼不對,或備份壞了)".to_string())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn key_of(hex: &str) -> Option<[u8; 32]> {
|
|
|
|
|
|
if hex.len() != 64 {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
let mut k = [0u8; 32];
|
|
|
|
|
|
for i in 0..32 {
|
|
|
|
|
|
k[i] = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()?;
|
|
|
|
|
|
}
|
|
|
|
|
|
Some(k)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 裝置連結: run a tool on another AnyBot of this account (through the relay).
|
|
|
|
|
|
pub fn run_on_device(host_id: &str, tool: &str, args: &str, who: &str) -> Result<Value, String> {
|
|
|
|
|
|
let s = hub().settings();
|
|
|
|
|
|
if s.relay_url.is_empty() {
|
|
|
|
|
|
return Err("沒有登入帳號,連不到其他裝置".into());
|
|
|
|
|
|
}
|
2026-11-04 11:30:32 +00:00
|
|
|
|
let body = json!({"tool": tool, "args": args, "who": who}).to_string();
|
|
|
|
|
|
if let Some(r) = crate::tunnel::call(host_id, "POST", "/api/device/run", &[], body.as_bytes(), 50) {
|
|
|
|
|
|
return r.map_err(|e| format!("連不到那台電腦:{e}")).and_then(|a| answer_value(a.status, &a.body, "那台電腦"));
|
|
|
|
|
|
}
|
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 url = format!("{}/relay/hosts/{host_id}/api/device/run", s.relay_url);
|
|
|
|
|
|
let mut resp = agent(45)
|
|
|
|
|
|
.post(&url)
|
|
|
|
|
|
.header("Authorization", &format!("Bearer {}", s.host_token))
|
|
|
|
|
|
.header("Content-Type", "application/json")
|
|
|
|
|
|
.send(json!({"tool": tool, "args": args, "who": who}).to_string().as_bytes())
|
2026-11-04 11:07:55 +00:00
|
|
|
|
.map_err(|e| format!("連不到帳號伺服器,所以也連不到那台電腦:{}", why_unreachable(&e)))?;
|
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 st = resp.status().as_u16();
|
|
|
|
|
|
let text = resp.body_mut().with_config().limit(40 * 1024 * 1024).read_to_string().unwrap_or_default();
|
|
|
|
|
|
let v: Value = serde_json::from_str(&text).unwrap_or(Value::Null);
|
|
|
|
|
|
if !(200..300).contains(&st) {
|
2026-11-04 11:07:55 +00:00
|
|
|
|
return Err(why_refused(st, &v, "那台電腦"));
|
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
|
|
|
|
}
|
|
|
|
|
|
Ok(v)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-04 09:46:43 +00:00
|
|
|
|
// ---------- 大腦: this AnyBot as a screen of another ----------
|
|
|
|
|
|
|
|
|
|
|
|
fn brain_url(s: &Settings, rest: &str) -> Result<String, String> {
|
|
|
|
|
|
if s.relay_url.is_empty() || s.host_token.is_empty() {
|
|
|
|
|
|
return Err("沒有登入帳號".into());
|
|
|
|
|
|
}
|
|
|
|
|
|
if s.brain_id.is_empty() {
|
|
|
|
|
|
return Err("沒有選大腦".into());
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok(format!("{}/relay/hosts/{}/api/sync{rest}", s.relay_url, s.brain_id))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-04 11:30:32 +00:00
|
|
|
|
/// A JSON answer, or what went wrong in words.
|
|
|
|
|
|
fn answer_value(st: u16, bytes: &[u8], whom: &str) -> Result<Value, String> {
|
|
|
|
|
|
let v: Value = serde_json::from_slice(bytes).unwrap_or(Value::Null);
|
2026-11-04 09:46:43 +00:00
|
|
|
|
if !(200..300).contains(&st) {
|
2026-11-04 11:30:32 +00:00
|
|
|
|
return Err(why_refused(st, &v, whom));
|
2026-11-04 11:07:55 +00:00
|
|
|
|
}
|
|
|
|
|
|
if v.is_null() {
|
2026-11-04 11:30:32 +00:00
|
|
|
|
return Err(format!("{whom}的回應看不懂,請把兩邊的 AnyBot 都更新到最新版"));
|
2026-11-04 09:46:43 +00:00
|
|
|
|
}
|
|
|
|
|
|
Ok(v)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-04 11:30:32 +00:00
|
|
|
|
/// Ask the brain: over the lasting connection when it is open, else an ordinary request.
|
|
|
|
|
|
fn to_brain(s: &Settings, method: &str, rest: &str, query: &[(&str, String)], body: &[u8], secs: u64) -> Result<Value, String> {
|
|
|
|
|
|
let (st, bytes) = to_brain_raw(s, method, rest, query, body, secs)?;
|
|
|
|
|
|
answer_value(st, &bytes, "大腦")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn to_brain_raw(s: &Settings, method: &str, rest: &str, query: &[(&str, String)], body: &[u8], secs: u64) -> Result<(u16, Vec<u8>), String> {
|
|
|
|
|
|
if s.brain_id.is_empty() {
|
|
|
|
|
|
return Err("沒有選大腦".into());
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(r) = crate::tunnel::call(&s.brain_id, method, &format!("/api/sync{rest}"), query, body, secs) {
|
People watch and help on the bots' computer through VNC (noVNC), carried by the account server
The core: the bot works the computer; a person watches it like a colleague, and takes over
when it needs them. VNC is made for that (only what changed, every mouse move and key, touch on
phones), so the pictures sent one by one go, and with them their own input path.
- vnc.rs: a VNC proxy to the box's x11vnc through one docker exec (a Python bridge that ends
with either side). It signs in to the box with the box's own password (its VNC_PASSWORD) and
offers the viewer no password, so the password never leaves the brain. The person's clicks
and keys make the bot's steps wait until 8 s after they stop; text typed in the viewer's box
(any language) is pasted in the box.
- Account server: /screen (the viewer page) and /novnc.js (noVNC 1.6.0, MPL-2.0, bundled);
/relay/vnc/<host> takes the browser's WebSocket (account session, or a 10-minute ticket the
app asks for with POST /relay/screen-ticket; owners and admins) and carries it over that
AnyBot's lasting connection (vopen / vdata / vtext / vclose). Both ends of that connection
wait 2 ms instead of 40 ms to read while things move: a request and its answer took up to
160 ms more than needed.
- Phone page: 看 Bot 的電腦 and 接管 open the viewer page (also on the local network: /api/vnc on
the AnyBot's own phone server). This Mac's own screen moved to the ⋯ menu.
- Mac app: 看畫面 opens the viewer in the browser (a screen of a brain: the brain's computer by
ticket; else this computer's box, noVNC straight from it). The in-app picture viewer, the
live JPEG stream, the person's click/move/scroll/key endpoints and the input shell are gone.
- Opening a Mac app uses LaunchServices (`open -a`): agent-desktop's launch waited on the window
through accessibility and failed without 輔助使用 now and then.
Measured on this Mac (made-up account, local relay + headless brain + the real box): viewer
signed in in 0.4–0.7 s, a request and its answer through relay + brain + box 25–58 ms median;
headless Chrome shows the desktop live in /screen. remote_e2e 28/28 ×2, 149 unit tests, relay 11.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:05:44 +00:00
|
|
|
|
return r.map(|a| (a.status, a.body));
|
2026-11-04 11:30:32 +00:00
|
|
|
|
}
|
Remove what nothing uses and keep one copy of what was written several times
Unused (found by taking away the dead-code allowances; nothing else used them, tests aside):
computer::specs, quiet_button, the never-made Poke::Spin, ten icons and their SVGs, the
theme's chrome/bubble_agent colours, ImportRow.line, voice's multipart/transcribe/speak
wrappers, and the old keychain writers vault::save/remove/index_add with their live test
(the vault store replaced them; reading old keychain entries for the import stays).
One copy each: percent-encoding (httpd::url_encode, was four), tool arguments (tools::args_of,
was three), the WebSocket handshake (httpd::accept_websocket, was three), the read pacing
(wire::Pace, was two: relay and app), docker with input and a time limit (dk_with_input, was
two). Docs: FEATURES §看畫面 (VNC), protocol (v* frames, /screen, tickets), architecture.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:21:47 +00:00
|
|
|
|
let q: Vec<String> = query.iter().map(|(k, v)| format!("{k}={}", httpd::url_encode(v))).collect();
|
2026-11-04 11:30:32 +00:00
|
|
|
|
let url = brain_url(s, &format!("{rest}{}{}", if q.is_empty() { "" } else { "?" }, q.join("&")))?;
|
|
|
|
|
|
let auth = format!("Bearer {}", s.host_token);
|
|
|
|
|
|
let r = if method == "GET" { agent(secs).get(&url).header("Authorization", &auth).call() } else { agent(secs).post(&url).header("Authorization", &auth).header("Content-Type", "application/octet-stream").send(body) };
|
|
|
|
|
|
let mut resp = r.map_err(|e| format!("連不到帳號伺服器:{}", why_unreachable(&e)))?;
|
|
|
|
|
|
let st = resp.status().as_u16();
|
|
|
|
|
|
let bytes = resp.body_mut().with_config().limit(200 * 1024 * 1024).read_to_vec().map_err(|_| "和大腦的連線到一半斷掉了".to_string())?;
|
People watch and help on the bots' computer through VNC (noVNC), carried by the account server
The core: the bot works the computer; a person watches it like a colleague, and takes over
when it needs them. VNC is made for that (only what changed, every mouse move and key, touch on
phones), so the pictures sent one by one go, and with them their own input path.
- vnc.rs: a VNC proxy to the box's x11vnc through one docker exec (a Python bridge that ends
with either side). It signs in to the box with the box's own password (its VNC_PASSWORD) and
offers the viewer no password, so the password never leaves the brain. The person's clicks
and keys make the bot's steps wait until 8 s after they stop; text typed in the viewer's box
(any language) is pasted in the box.
- Account server: /screen (the viewer page) and /novnc.js (noVNC 1.6.0, MPL-2.0, bundled);
/relay/vnc/<host> takes the browser's WebSocket (account session, or a 10-minute ticket the
app asks for with POST /relay/screen-ticket; owners and admins) and carries it over that
AnyBot's lasting connection (vopen / vdata / vtext / vclose). Both ends of that connection
wait 2 ms instead of 40 ms to read while things move: a request and its answer took up to
160 ms more than needed.
- Phone page: 看 Bot 的電腦 and 接管 open the viewer page (also on the local network: /api/vnc on
the AnyBot's own phone server). This Mac's own screen moved to the ⋯ menu.
- Mac app: 看畫面 opens the viewer in the browser (a screen of a brain: the brain's computer by
ticket; else this computer's box, noVNC straight from it). The in-app picture viewer, the
live JPEG stream, the person's click/move/scroll/key endpoints and the input shell are gone.
- Opening a Mac app uses LaunchServices (`open -a`): agent-desktop's launch waited on the window
through accessibility and failed without 輔助使用 now and then.
Measured on this Mac (made-up account, local relay + headless brain + the real box): viewer
signed in in 0.4–0.7 s, a request and its answer through relay + brain + box 25–58 ms median;
headless Chrome shows the desktop live in /screen. remote_e2e 28/28 ×2, 149 unit tests, relay 11.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:05:44 +00:00
|
|
|
|
Ok((st, bytes))
|
2026-11-04 11:30:32 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-04 09:46:43 +00:00
|
|
|
|
/// What changed at the brain since `since` (waits up to LONG_POLL_SECS when nothing has).
|
|
|
|
|
|
pub fn sync_pull(s: &Settings, since: u64, client: &str) -> Result<Value, String> {
|
2026-11-04 11:30:32 +00:00
|
|
|
|
to_brain(s, "GET", "", &[("since", since.to_string()), ("client", client.to_string())], &[], LONG_POLL_SECS + 25)
|
2026-11-04 09:46:43 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Send edits ("edit") or a chat action ("act") to the brain.
|
|
|
|
|
|
pub fn sync_post(s: &Settings, what: &str, body: &Value) -> Result<Value, String> {
|
2026-11-04 11:30:32 +00:00
|
|
|
|
to_brain(s, "POST", &format!("/{what}"), &[], body.to_string().as_bytes(), 60)
|
2026-11-04 09:46:43 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// A file for the next message, kept at the brain; gives back its path there.
|
|
|
|
|
|
pub fn sync_upload(s: &Settings, bot: u64, name: &str, bytes: &[u8]) -> Result<String, String> {
|
2026-11-04 11:30:32 +00:00
|
|
|
|
let v = to_brain(s, "POST", &format!("/upload/{bot}"), &[("name", name.to_string())], bytes, 120)?;
|
2026-11-04 09:46:43 +00:00
|
|
|
|
v["path"].as_str().map(String::from).ok_or("大腦沒有收下檔案".into())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-05 08:47:28 +00:00
|
|
|
|
/// The brain's bots' computer as a JPEG (`thumb`: the small picture that never wakes it).
|
|
|
|
|
|
pub fn sync_screen(s: &Settings, thumb: bool) -> Result<Vec<u8>, String> {
|
|
|
|
|
|
let (st, bytes) = to_brain_raw(s, "GET", "/screen", &[("thumb", if thumb { "1" } else { "0" }.to_string())], &[], 30)?;
|
|
|
|
|
|
if !(200..300).contains(&st) {
|
|
|
|
|
|
return Err(why_refused(st, &serde_json::from_slice(&bytes).unwrap_or(Value::Null), "大腦"));
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok(bytes)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
People watch and help on the bots' computer through VNC (noVNC), carried by the account server
The core: the bot works the computer; a person watches it like a colleague, and takes over
when it needs them. VNC is made for that (only what changed, every mouse move and key, touch on
phones), so the pictures sent one by one go, and with them their own input path.
- vnc.rs: a VNC proxy to the box's x11vnc through one docker exec (a Python bridge that ends
with either side). It signs in to the box with the box's own password (its VNC_PASSWORD) and
offers the viewer no password, so the password never leaves the brain. The person's clicks
and keys make the bot's steps wait until 8 s after they stop; text typed in the viewer's box
(any language) is pasted in the box.
- Account server: /screen (the viewer page) and /novnc.js (noVNC 1.6.0, MPL-2.0, bundled);
/relay/vnc/<host> takes the browser's WebSocket (account session, or a 10-minute ticket the
app asks for with POST /relay/screen-ticket; owners and admins) and carries it over that
AnyBot's lasting connection (vopen / vdata / vtext / vclose). Both ends of that connection
wait 2 ms instead of 40 ms to read while things move: a request and its answer took up to
160 ms more than needed.
- Phone page: 看 Bot 的電腦 and 接管 open the viewer page (also on the local network: /api/vnc on
the AnyBot's own phone server). This Mac's own screen moved to the ⋯ menu.
- Mac app: 看畫面 opens the viewer in the browser (a screen of a brain: the brain's computer by
ticket; else this computer's box, noVNC straight from it). The in-app picture viewer, the
live JPEG stream, the person's click/move/scroll/key endpoints and the input shell are gone.
- Opening a Mac app uses LaunchServices (`open -a`): agent-desktop's launch waited on the window
through accessibility and failed without 輔助使用 now and then.
Measured on this Mac (made-up account, local relay + headless brain + the real box): viewer
signed in in 0.4–0.7 s, a request and its answer through relay + brain + box 25–58 ms median;
headless Chrome shows the desktop live in /screen. remote_e2e 28/28 ×2, 149 unit tests, relay 11.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:05:44 +00:00
|
|
|
|
/// A link for this account's browser to watch the brain's computer (/screen on the account
|
|
|
|
|
|
/// server; one-time, it works for ten minutes).
|
|
|
|
|
|
pub fn screen_ticket(s: &Settings) -> Result<String, String> {
|
|
|
|
|
|
if s.relay_url.is_empty() || s.brain_id.is_empty() {
|
|
|
|
|
|
return Err("這台沒有連到帳號的大腦".into());
|
2026-11-05 14:34:36 +00:00
|
|
|
|
}
|
People watch and help on the bots' computer through VNC (noVNC), carried by the account server
The core: the bot works the computer; a person watches it like a colleague, and takes over
when it needs them. VNC is made for that (only what changed, every mouse move and key, touch on
phones), so the pictures sent one by one go, and with them their own input path.
- vnc.rs: a VNC proxy to the box's x11vnc through one docker exec (a Python bridge that ends
with either side). It signs in to the box with the box's own password (its VNC_PASSWORD) and
offers the viewer no password, so the password never leaves the brain. The person's clicks
and keys make the bot's steps wait until 8 s after they stop; text typed in the viewer's box
(any language) is pasted in the box.
- Account server: /screen (the viewer page) and /novnc.js (noVNC 1.6.0, MPL-2.0, bundled);
/relay/vnc/<host> takes the browser's WebSocket (account session, or a 10-minute ticket the
app asks for with POST /relay/screen-ticket; owners and admins) and carries it over that
AnyBot's lasting connection (vopen / vdata / vtext / vclose). Both ends of that connection
wait 2 ms instead of 40 ms to read while things move: a request and its answer took up to
160 ms more than needed.
- Phone page: 看 Bot 的電腦 and 接管 open the viewer page (also on the local network: /api/vnc on
the AnyBot's own phone server). This Mac's own screen moved to the ⋯ menu.
- Mac app: 看畫面 opens the viewer in the browser (a screen of a brain: the brain's computer by
ticket; else this computer's box, noVNC straight from it). The in-app picture viewer, the
live JPEG stream, the person's click/move/scroll/key endpoints and the input shell are gone.
- Opening a Mac app uses LaunchServices (`open -a`): agent-desktop's launch waited on the window
through accessibility and failed without 輔助使用 now and then.
Measured on this Mac (made-up account, local relay + headless brain + the real box): viewer
signed in in 0.4–0.7 s, a request and its answer through relay + brain + box 25–58 ms median;
headless Chrome shows the desktop live in /screen. remote_e2e 28/28 ×2, 149 unit tests, relay 11.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:05:44 +00:00
|
|
|
|
let url = format!("{}/relay/screen-ticket", s.relay_url.trim_end_matches('/'));
|
|
|
|
|
|
let mut r = agent(15)
|
|
|
|
|
|
.post(&url)
|
|
|
|
|
|
.header("Authorization", &format!("Bearer {}", s.host_token))
|
|
|
|
|
|
.header("Content-Type", "application/json")
|
|
|
|
|
|
.send(json!({"host": s.brain_id}).to_string().as_bytes())
|
|
|
|
|
|
.map_err(|e| format!("連不到帳號伺服器:{}", why_unreachable(&e)))?;
|
|
|
|
|
|
let v: Value = r.body_mut().read_json().map_err(|_| "帳號伺服器的回答看不懂".to_string())?;
|
|
|
|
|
|
v["ticket"].as_str().map(String::from).ok_or_else(|| "帳號伺服器沒有給看畫面的連結(它可能是舊版,請更新)".into())
|
2026-11-05 14:34:36 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-04 11:30:32 +00:00
|
|
|
|
/// A file the brain keeps (an attachment shown in a chat), as bytes.
|
|
|
|
|
|
pub fn sync_file(s: &Settings, path: &str) -> Result<Vec<u8>, String> {
|
|
|
|
|
|
let (st, bytes) = to_brain_raw(s, "GET", "/file", &[("path", path.to_string())], &[], 120)?;
|
|
|
|
|
|
if !(200..300).contains(&st) {
|
|
|
|
|
|
return Err(why_refused(st, &serde_json::from_slice(&bytes).unwrap_or(Value::Null), "大腦"));
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok(bytes)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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 notification on the person's phones (through the relay; phones that turned them on).
|
|
|
|
|
|
pub fn notify(title: &str, body: &str, bot: u64) {
|
|
|
|
|
|
let s = hub().settings();
|
|
|
|
|
|
if s.relay_url.is_empty() || s.host_token.is_empty() {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
let (title, body) = (title.to_string(), body.to_string());
|
|
|
|
|
|
std::thread::spawn(move || {
|
|
|
|
|
|
let _ = relay_post(&format!("{}/relay/host/notify", s.relay_url), &s.host_token, &json!({"title": title, "body": body, "bot": bot}));
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 發布網站: upload a folder (or one page) to the account server; returns the public address.
|
|
|
|
|
|
pub fn publish_site(name: &str, local: &std::path::Path) -> Result<String, String> {
|
|
|
|
|
|
use base64::Engine;
|
|
|
|
|
|
let s = hub().settings();
|
|
|
|
|
|
if s.relay_url.is_empty() || s.host_token.is_empty() {
|
|
|
|
|
|
return Err("要先在 設定 › 手機與帳號 登入帳號,才能發布網站".into());
|
|
|
|
|
|
}
|
|
|
|
|
|
let mut files = vec![];
|
|
|
|
|
|
let mut total = 0u64;
|
|
|
|
|
|
if local.is_file() {
|
|
|
|
|
|
let bytes = std::fs::read(local).map_err(|e| e.to_string())?;
|
|
|
|
|
|
let is_html = local.extension().map(|e| e == "html" || e == "htm").unwrap_or(false);
|
|
|
|
|
|
let n = if is_html { "index.html".to_string() } else { local.file_name().unwrap_or_default().to_string_lossy().to_string() };
|
|
|
|
|
|
if bytes.len() > 25 * 1024 * 1024 {
|
|
|
|
|
|
return Err("網頁太大了(最多 25 MB)".into());
|
|
|
|
|
|
}
|
|
|
|
|
|
files.push(json!({"path": n, "data": base64::engine::general_purpose::STANDARD.encode(bytes)}));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
let mut stack = vec![local.to_path_buf()];
|
|
|
|
|
|
while let Some(d) = stack.pop() {
|
|
|
|
|
|
for e in std::fs::read_dir(&d).map_err(|e| format!("讀不到資料夾:{e}"))?.flatten() {
|
|
|
|
|
|
let p = e.path();
|
|
|
|
|
|
let fname = e.file_name().to_string_lossy().to_string();
|
|
|
|
|
|
if fname.starts_with('.') || fname == "node_modules" {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
if p.is_dir() {
|
|
|
|
|
|
stack.push(p);
|
|
|
|
|
|
} else if let Ok(bytes) = std::fs::read(&p) {
|
|
|
|
|
|
total += bytes.len() as u64;
|
|
|
|
|
|
if files.len() >= 500 || total > 25 * 1024 * 1024 {
|
|
|
|
|
|
return Err("網站太大了(最多 500 個檔案、25 MB)".into());
|
|
|
|
|
|
}
|
|
|
|
|
|
let rel = p.strip_prefix(local).unwrap_or(&p).to_string_lossy().replace('\\', "/");
|
|
|
|
|
|
files.push(json!({"path": rel, "data": base64::engine::general_purpose::STANDARD.encode(bytes)}));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if !files.iter().any(|f| f["path"] == "index.html") {
|
|
|
|
|
|
return Err("找不到 index.html(網站的首頁)".into());
|
|
|
|
|
|
}
|
|
|
|
|
|
let v = relay_post(&format!("{}/relay/host/sites/{name}", s.relay_url), &s.host_token, &json!({"files": files}))?;
|
|
|
|
|
|
let url = v["url"].as_str().filter(|u| !u.is_empty()).map(String::from).unwrap_or_else(|| format!("{}{}", s.relay_url, v["path"].as_str().unwrap_or("")));
|
|
|
|
|
|
Ok(url)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 團隊: who else this AnyBot is shared with (user, role).
|
|
|
|
|
|
pub fn members(s: &Settings) -> Result<Vec<(String, String)>, String> {
|
|
|
|
|
|
let mut r = agent(10).get(&format!("{}/relay/host/members", s.relay_url)).header("Authorization", &format!("Bearer {}", s.host_token)).call().map_err(|e| e.to_string())?;
|
|
|
|
|
|
let v: Value = serde_json::from_str(&r.body_mut().read_to_string().unwrap_or_default()).unwrap_or(Value::Null);
|
|
|
|
|
|
Ok(v["members"].as_array().map(|a| a.iter().map(|m| (m["user"].as_str().unwrap_or("").to_string(), m["role"].as_str().unwrap_or("").to_string())).collect()).unwrap_or_default())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Share with another account ("member" / "admin"), or stop sharing ("remove").
|
|
|
|
|
|
pub fn set_member(s: &Settings, user: &str, role: &str) -> Result<(), String> {
|
|
|
|
|
|
relay_post(&format!("{}/relay/host/members", s.relay_url), &s.host_token, &json!({"user": user, "role": role})).map(|_| ())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The other AnyBots signed in to this account (for 裝置連結).
|
2026-11-04 12:56:06 +00:00
|
|
|
|
/// 加一台電腦: a code (ten minutes, once) another computer uses to join this account, as hands for
|
|
|
|
|
|
/// `for_host` (empty = any AnyBot of the account).
|
|
|
|
|
|
pub fn pair_code(s: &Settings, for_host: &str) -> Result<String, String> {
|
|
|
|
|
|
let v = relay_post(&format!("{}/relay/pair", s.relay_url), &s.host_token, &json!({"for": for_host}))?;
|
|
|
|
|
|
v["code"].as_str().map(String::from).ok_or("帳號伺服器沒有給配對碼".into())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Join the account with a 配對碼 (no password). Gives the settings to keep.
|
|
|
|
|
|
pub fn pair_claim(base_settings: &Settings, url: &str, code: &str) -> Result<Settings, String> {
|
|
|
|
|
|
let url = check_server(url)?;
|
|
|
|
|
|
let v = relay_post(&format!("{url}/relay/pair/claim"), "", &json!({"code": code, "name": base_settings.host_name}))?;
|
|
|
|
|
|
let mut s = base_settings.clone();
|
|
|
|
|
|
s.relay_url = url;
|
|
|
|
|
|
s.relay_user = v["user"].as_str().unwrap_or("").to_string();
|
|
|
|
|
|
s.host_id = v["id"].as_str().unwrap_or("").to_string();
|
|
|
|
|
|
s.host_token = v["token"].as_str().unwrap_or("").to_string();
|
|
|
|
|
|
// No password here, so no key for backups (hands keep nothing to back up).
|
|
|
|
|
|
s.backup_key.clear();
|
|
|
|
|
|
s.hands_for = v["for"].as_str().unwrap_or("").to_string();
|
|
|
|
|
|
s.hands_for_name = v["for_name"].as_str().unwrap_or("").to_string();
|
|
|
|
|
|
if s.host_id.is_empty() || s.host_token.is_empty() {
|
|
|
|
|
|
return Err("帳號伺服器沒有給這台電腦的金鑰".into());
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok(s)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-04 12:38:45 +00:00
|
|
|
|
/// The account's AnyBots as the account server sees them (for choosing a brain).
|
|
|
|
|
|
pub fn peers(s: &Settings) -> Result<Vec<Value>, String> {
|
|
|
|
|
|
let mut r = agent(15).get(&format!("{}/relay/host/peers", s.relay_url)).header("Authorization", &format!("Bearer {}", s.host_token)).call().map_err(|e| format!("連不到帳號伺服器:{}", why_unreachable(&e)))?;
|
|
|
|
|
|
let st = r.status().as_u16();
|
|
|
|
|
|
let v: Value = serde_json::from_str(&r.body_mut().read_to_string().unwrap_or_default()).unwrap_or(Value::Null);
|
|
|
|
|
|
if !(200..300).contains(&st) {
|
|
|
|
|
|
return Err(why_refused(st, &v, "帳號伺服器"));
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok(v["hosts"].as_array().cloned().unwrap_or_default())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-05 06:25:01 +00:00
|
|
|
|
/// Right after signing in: where this account's bots already are. Another AnyBot of the account
|
|
|
|
|
|
/// that is on now and thinks for itself (not hands, not showing another) is the brain to sync
|
|
|
|
|
|
/// with; with none, the account's newest backup (if there is one) is what to take in.
|
|
|
|
|
|
pub struct AccountFind {
|
|
|
|
|
|
pub brain: Option<(String, String)>,
|
|
|
|
|
|
pub backup: Option<Vec<u8>>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn find_account_bots(s: &Settings, password: &str) -> AccountFind {
|
|
|
|
|
|
let brain = peers(s).ok().and_then(|hosts| pick_brain(&hosts, &s.host_id));
|
|
|
|
|
|
let backup = if brain.is_none() { restore(&s.relay_url, &s.relay_user, password).ok() } else { None };
|
|
|
|
|
|
AccountFind { brain, backup }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The brain among the account's AnyBots (relay `hosts` rows), never this one.
|
|
|
|
|
|
pub fn pick_brain(hosts: &[Value], me: &str) -> Option<(String, String)> {
|
|
|
|
|
|
hosts
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.filter(|h| h["online"].as_bool() == Some(true) && h["hands"].as_bool() != Some(true) && h["uses"].as_str().unwrap_or("").is_empty())
|
|
|
|
|
|
.filter_map(|h| Some((h["id"].as_str()?.to_string(), h["name"].as_str().unwrap_or("AnyBot").to_string())))
|
|
|
|
|
|
.find(|(id, _)| !id.is_empty() && id != me)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-04 12:38:45 +00:00
|
|
|
|
/// Is this an AnyBot account server? (Gives the address to use.)
|
|
|
|
|
|
pub fn check_server(url: &str) -> Result<String, String> {
|
|
|
|
|
|
let mut u = url.trim().trim_end_matches('/').to_string();
|
|
|
|
|
|
if u.is_empty() {
|
|
|
|
|
|
return Err("請輸入帳號伺服器的網址".into());
|
|
|
|
|
|
}
|
|
|
|
|
|
if !u.contains("://") {
|
|
|
|
|
|
let host = u.split(['/', ':']).next().unwrap_or("");
|
|
|
|
|
|
let plain = host == "localhost" || host.parse::<std::net::IpAddr>().is_ok() || !host.contains('.');
|
|
|
|
|
|
u = format!("{}{u}", if plain { "http://" } else { "https://" });
|
|
|
|
|
|
}
|
|
|
|
|
|
let mut r = agent(15).get(&format!("{u}/api/hello")).call().map_err(|e| format!("連不到 {u}:{}", why_unreachable(&e)))?;
|
|
|
|
|
|
let v: Value = serde_json::from_str(&r.body_mut().read_to_string().unwrap_or_default()).unwrap_or(Value::Null);
|
|
|
|
|
|
if v["app"] != "anybot-relay" {
|
|
|
|
|
|
return Err(format!("{u} 有回應,但不是 AnyBot 帳號伺服器(網址打錯了?)"));
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok(u)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-04 12:56:06 +00:00
|
|
|
|
/// AnyBots of the account that lend their hands (not offered as a brain).
|
|
|
|
|
|
pub fn is_hands(id: &str) -> bool {
|
|
|
|
|
|
hands_ids().lock().unwrap().contains(id)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn hands_ids() -> &'static Mutex<std::collections::HashSet<String>> {
|
|
|
|
|
|
static H: OnceLock<Mutex<std::collections::HashSet<String>>> = OnceLock::new();
|
|
|
|
|
|
H.get_or_init(Mutex::default)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-05 06:40:01 +00:00
|
|
|
|
/// The account's computers as last seen: id → (name, the brain it uses, lends its hands).
|
Clippy is clean on macOS and Linux, for the app and the relay
Every warning `cargo clippy --all-targets` gave (50 on macOS, 8 more only on
Linux, 1 in the relay), fixed without changing what anything does:
- long types get a name (Peer, Members, Imported, Joining, Mention, Stream…);
- the two big enum variants are boxed (the vault's login plan, joining an
account);
- a test now waits for the copy it relaunched (it left a zombie behind);
- a hint's format had a dead `if` whose two branches were both "";
- the rest is what clippy suggests: sort_by_key, as_chunks, is_none_or,
is_multiple_of, while let, from_ref, next_back, struct init, no casts to
the same type.
Checked: cargo test (177 macOS, 181 Linux), relay tests (11), remote_e2e
28/28, and the live Linux test (X11; Wayland clicks, keys, Chinese typing,
scroll and screenshots).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
2026-11-07 02:10:28 +00:00
|
|
|
|
type PeerRows = Mutex<HashMap<String, (String, String, bool)>>;
|
|
|
|
|
|
|
|
|
|
|
|
fn peer_rows() -> &'static PeerRows {
|
|
|
|
|
|
static P: OnceLock<PeerRows> = OnceLock::new();
|
2026-11-05 06:40:01 +00:00
|
|
|
|
P.get_or_init(Mutex::default)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Computers of the account that lend their hands to this brain (`me`), by id.
|
|
|
|
|
|
pub fn my_hands(me: &str) -> Vec<String> {
|
|
|
|
|
|
if me.is_empty() {
|
|
|
|
|
|
return vec![];
|
|
|
|
|
|
}
|
|
|
|
|
|
let mut v: Vec<String> = peer_rows().lock().unwrap().iter().filter(|(_, (_, uses, hands))| *hands && uses == me).map(|(id, _)| id.clone()).collect();
|
|
|
|
|
|
v.sort();
|
|
|
|
|
|
v
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-05 08:47:28 +00:00
|
|
|
|
fn person_cell() -> &'static Mutex<String> {
|
|
|
|
|
|
static P: OnceLock<Mutex<String>> = OnceLock::new();
|
|
|
|
|
|
P.get_or_init(Mutex::default)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-05 11:41:49 +00:00
|
|
|
|
/// The account computer the person last typed on (they are sitting at it). Kept beside the data,
|
|
|
|
|
|
/// so a brain that restarts (an update) still knows it.
|
2026-11-05 08:47:28 +00:00
|
|
|
|
pub fn set_person_host(id: &str) {
|
2026-11-05 11:41:49 +00:00
|
|
|
|
let mut cur = person_cell().lock().unwrap();
|
|
|
|
|
|
if *cur != id {
|
|
|
|
|
|
*cur = id.to_string();
|
|
|
|
|
|
let _ = std::fs::write(person_file(), id);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn person_file() -> std::path::PathBuf {
|
|
|
|
|
|
crate::model::State::path().with_file_name("person-computer.txt")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// At start: the computer the person used last time.
|
|
|
|
|
|
pub fn load_person_host() -> Option<String> {
|
|
|
|
|
|
let id = std::fs::read_to_string(person_file()).ok()?.trim().to_string();
|
|
|
|
|
|
(!id.is_empty() && id.len() <= 64 && id.chars().all(|c| c.is_ascii_alphanumeric())).then(|| {
|
|
|
|
|
|
*person_cell().lock().unwrap() = id.clone();
|
|
|
|
|
|
id
|
|
|
|
|
|
})
|
2026-11-05 08:47:28 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn person_host() -> String {
|
|
|
|
|
|
person_cell().lock().unwrap().clone()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The brain an account computer shows (empty: it thinks for itself), as last seen.
|
|
|
|
|
|
pub fn peer_uses(id: &str) -> String {
|
|
|
|
|
|
peer_rows().lock().unwrap().get(id).map(|r| r.1.clone()).unwrap_or_default()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-05 06:40:01 +00:00
|
|
|
|
/// A computer's own name as the account server knows it.
|
|
|
|
|
|
pub fn peer_name(id: &str) -> String {
|
|
|
|
|
|
peer_rows().lock().unwrap().get(id).map(|r| r.0.clone()).unwrap_or_default()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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 devices_online(s: &Settings) -> Vec<(String, String, bool)> {
|
|
|
|
|
|
if s.relay_url.is_empty() {
|
|
|
|
|
|
return vec![];
|
|
|
|
|
|
}
|
|
|
|
|
|
let Ok(mut r) = agent(10).get(&format!("{}/relay/host/peers", s.relay_url)).header("Authorization", &format!("Bearer {}", s.host_token)).call() else { return vec![] };
|
|
|
|
|
|
let v: Value = serde_json::from_str(&r.body_mut().read_to_string().unwrap_or_default()).unwrap_or(Value::Null);
|
2026-11-05 06:40:01 +00:00
|
|
|
|
*peer_rows().lock().unwrap() = v["hosts"]
|
|
|
|
|
|
.as_array()
|
|
|
|
|
|
.into_iter()
|
|
|
|
|
|
.flatten()
|
|
|
|
|
|
.filter_map(|h| Some((h["id"].as_str()?.to_string(), (h["name"].as_str().unwrap_or("").to_string(), h["uses"].as_str().unwrap_or("").to_string(), h["hands"].as_bool() == Some(true)))))
|
|
|
|
|
|
.collect();
|
2026-11-04 12:56:06 +00:00
|
|
|
|
*hands_ids().lock().unwrap() = v["hosts"].as_array().into_iter().flatten().filter(|h| h["hands"].as_bool() == Some(true) || !h["uses"].as_str().unwrap_or("").is_empty()).filter_map(|h| h["id"].as_str().map(String::from)).collect();
|
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["hosts"].as_array().map(|a| a.iter().map(|h| (h["id"].as_str().unwrap_or("").to_string(), h["name"].as_str().unwrap_or("").to_string(), h["online"].as_bool().unwrap_or(false))).collect()).unwrap_or_default()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------- the phone page ----------
|
|
|
|
|
|
|
|
|
|
|
|
const PAGE: &str = include_str!("../mobile/index.html");
|
People watch and help on the bots' computer through VNC (noVNC), carried by the account server
The core: the bot works the computer; a person watches it like a colleague, and takes over
when it needs them. VNC is made for that (only what changed, every mouse move and key, touch on
phones), so the pictures sent one by one go, and with them their own input path.
- vnc.rs: a VNC proxy to the box's x11vnc through one docker exec (a Python bridge that ends
with either side). It signs in to the box with the box's own password (its VNC_PASSWORD) and
offers the viewer no password, so the password never leaves the brain. The person's clicks
and keys make the bot's steps wait until 8 s after they stop; text typed in the viewer's box
(any language) is pasted in the box.
- Account server: /screen (the viewer page) and /novnc.js (noVNC 1.6.0, MPL-2.0, bundled);
/relay/vnc/<host> takes the browser's WebSocket (account session, or a 10-minute ticket the
app asks for with POST /relay/screen-ticket; owners and admins) and carries it over that
AnyBot's lasting connection (vopen / vdata / vtext / vclose). Both ends of that connection
wait 2 ms instead of 40 ms to read while things move: a request and its answer took up to
160 ms more than needed.
- Phone page: 看 Bot 的電腦 and 接管 open the viewer page (also on the local network: /api/vnc on
the AnyBot's own phone server). This Mac's own screen moved to the ⋯ menu.
- Mac app: 看畫面 opens the viewer in the browser (a screen of a brain: the brain's computer by
ticket; else this computer's box, noVNC straight from it). The in-app picture viewer, the
live JPEG stream, the person's click/move/scroll/key endpoints and the input shell are gone.
- Opening a Mac app uses LaunchServices (`open -a`): agent-desktop's launch waited on the window
through accessibility and failed without 輔助使用 now and then.
Measured on this Mac (made-up account, local relay + headless brain + the real box): viewer
signed in in 0.4–0.7 s, a request and its answer through relay + brain + box 25–58 ms median;
headless Chrome shows the desktop live in /screen. remote_e2e 28/28 ×2, 149 unit tests, relay 11.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:05:44 +00:00
|
|
|
|
/// The bots' computer, watched and used in a browser (noVNC; see vnc.rs).
|
|
|
|
|
|
const SCREEN_PAGE: &str = include_str!("../mobile/screen.html");
|
|
|
|
|
|
const NOVNC: &str = include_str!("../mobile/novnc.js");
|
2026-11-05 11:20:29 +00:00
|
|
|
|
pub const MANIFEST: &str = r##"{"name":"AnyBot","short_name":"AnyBot","start_url":"./","display":"standalone","background_color":"#121212","theme_color":"#121212","icons":[{"src":"icon.svg","sizes":"any","type":"image/svg+xml"},{"src":"icon-192.png","sizes":"192x192","type":"image/png","purpose":"any maskable"},{"src":"icon-512.png","sizes":"512x512","type":"image/png","purpose":"any maskable"}]}"##;
|
|
|
|
|
|
/// The app's icon, the same drawing everywhere (icons/anybot.svg is the Mac/Linux one).
|
|
|
|
|
|
pub const ICON: &str = include_str!("../mobile/icon.svg");
|
|
|
|
|
|
/// PNG sizes for the iPhone home screen (it ignores SVG) and Android.
|
|
|
|
|
|
pub const ICON_PNGS: [(&str, &[u8]); 3] = [("icon-180.png", include_bytes!("../mobile/icon-180.png")), ("icon-192.png", include_bytes!("../mobile/icon-192.png")), ("icon-512.png", include_bytes!("../mobile/icon-512.png"))];
|
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 page() -> Resp {
|
|
|
|
|
|
Resp::bytes("text/html; charset=utf-8", PAGE.as_bytes().to_vec()).with_header("Content-Security-Policy", "default-src 'self'; img-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod tests {
|
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
2026-11-05 07:00:31 +00:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn model_requests_go_only_to_private_addresses() {
|
|
|
|
|
|
assert!(private_host("10.184.10.252") && private_host("192.168.1.5") && private_host("localhost") && private_host("nas.local"));
|
|
|
|
|
|
assert!(!private_host("api.x.ai") && !private_host("8.8.8.8"));
|
2026-11-05 07:48:20 +00:00
|
|
|
|
// A cloud VM's metadata service answers on link-local: the hands must never reach it.
|
|
|
|
|
|
assert!(!private_host("169.254.169.254") && !private_host("169.254.0.1"));
|
2026-11-05 07:00:31 +00:00
|
|
|
|
assert_eq!(host_port("http://10.184.10.252:8000/v1"), Some(("10.184.10.252".into(), 8000)));
|
|
|
|
|
|
assert_eq!(host_port("https://api.x.ai/v1"), Some(("api.x.ai".into(), 443)));
|
|
|
|
|
|
assert_eq!(model_fetch_here(&json!({"url": "https://api.x.ai/v1/chat/completions"}).to_string()).status, 403);
|
2026-11-05 07:48:20 +00:00
|
|
|
|
assert_eq!(model_fetch_here(&json!({"url": "http://169.254.169.254/latest/meta-data/"}).to_string()).status, 403);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn lan_login_lock_is_per_device() {
|
|
|
|
|
|
// One noisy device guessing passwords must not lock the owner's phone out (a single
|
|
|
|
|
|
// global counter used to do exactly that).
|
|
|
|
|
|
let mut s = Settings { password: httpd::hash_password("pw-123"), ..Default::default() };
|
|
|
|
|
|
s.host_name = "測試 Mac".into();
|
|
|
|
|
|
hub().set_settings(&s);
|
|
|
|
|
|
let bad = |ip: &str| handle(Req { method: "POST".into(), path: "/api/login".into(), body: br#"{"password":"nope"}"#.to_vec(), peer: ip.into(), ..Default::default() }, false);
|
|
|
|
|
|
for _ in 0..MAX_FAILS {
|
|
|
|
|
|
assert_eq!(bad("192.168.1.66:5000").status, 401);
|
|
|
|
|
|
}
|
|
|
|
|
|
// The noisy device is now locked out…
|
|
|
|
|
|
assert_eq!(bad("192.168.1.66:5000").status, 429);
|
|
|
|
|
|
// …while every other device on the LAN can still sign in.
|
|
|
|
|
|
let ok = handle(Req { method: "POST".into(), path: "/api/login".into(), body: br#"{"password":"pw-123"}"#.to_vec(), peer: "192.168.1.77:5001".into(), ..Default::default() }, false);
|
|
|
|
|
|
assert_eq!(ok.status, 200);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn roles_gate_the_sync_and_hands_apis() {
|
|
|
|
|
|
// 大腦 sync (mirror) and 裝置連結 (device) are separate doors on the hands machine:
|
|
|
|
|
|
// a chat admin must not reach either, and a brain may not run tools through the sync door.
|
|
|
|
|
|
assert!(role_allows("device", "POST", &["api", "device", "run"]));
|
|
|
|
|
|
assert!(!role_allows("device", "GET", &["api", "sync"]));
|
|
|
|
|
|
assert!(role_allows("mirror", "GET", &["api", "sync"]));
|
|
|
|
|
|
assert!(role_allows("mirror", "POST", &["api", "sync", "edit"]));
|
|
|
|
|
|
assert!(!role_allows("mirror", "POST", &["api", "device", "run"]));
|
|
|
|
|
|
assert!(role_allows("admin", "POST", &["api", "bots", "1", "send"]));
|
|
|
|
|
|
assert!(!role_allows("admin", "POST", &["api", "device", "run"]));
|
|
|
|
|
|
assert!(!role_allows("admin", "GET", &["api", "sync"]));
|
|
|
|
|
|
assert!(role_allows("member", "POST", &["api", "bots", "1", "send"]));
|
|
|
|
|
|
assert!(!role_allows("member", "POST", &["api", "bots", "1", "stop"]));
|
|
|
|
|
|
|
|
|
|
|
|
// A hands machine takes tool orders only through the relay, only when the person opened
|
|
|
|
|
|
// it (裝置連結), and only from the brain it was told to serve.
|
|
|
|
|
|
let open_for_one = Settings { device_link: true, hands_for: "brain1".into(), hands_for_name: "雲端大腦".into(), ..Default::default() };
|
|
|
|
|
|
assert_eq!(device_run(&json!({"tool": "x"}), &open_for_one, false, "brain1").status, 403);
|
|
|
|
|
|
assert_eq!(device_run(&json!({"tool": "x"}), &Settings { device_link: false, hands_for: "brain1".into(), ..Default::default() }, true, "brain1").status, 403);
|
|
|
|
|
|
assert_eq!(device_run(&json!({"tool": "x"}), &open_for_one, true, "other-brain").status, 403);
|
|
|
|
|
|
// The chosen brain reaches past the gates (and gets "this tool can't run remotely" for x).
|
|
|
|
|
|
assert_eq!(device_run(&json!({"tool": "x"}), &open_for_one, true, "brain1").status, 400);
|
2026-11-05 07:00:31 +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
|
|
|
|
#[test]
|
|
|
|
|
|
fn phone_login_and_api() {
|
|
|
|
|
|
let mut s = Settings { password: httpd::hash_password("pw-123"), ..Default::default() };
|
|
|
|
|
|
s.host_name = "測試 Mac".into();
|
|
|
|
|
|
hub().set_settings(&s);
|
|
|
|
|
|
// Not signed in: refused.
|
|
|
|
|
|
let r = handle(Req { method: "GET".into(), path: "/api/state".into(), ..Default::default() }, false);
|
|
|
|
|
|
assert_eq!(r.status, 401);
|
|
|
|
|
|
let bad = handle(Req { method: "POST".into(), path: "/api/login".into(), body: br#"{"password":"nope"}"#.to_vec(), ..Default::default() }, false);
|
|
|
|
|
|
assert_eq!(bad.status, 401);
|
|
|
|
|
|
let ok = handle(Req { method: "POST".into(), path: "/api/login".into(), body: br#"{"password":"pw-123","device":"iPhone"}"#.to_vec(), ..Default::default() }, false);
|
|
|
|
|
|
assert_eq!(ok.status, 200);
|
|
|
|
|
|
let token = serde_json::from_slice::<Value>(&ok.body).unwrap()["token"].as_str().unwrap().to_string();
|
|
|
|
|
|
hub().publish(View { host: "測試 Mac".into(), bots: vec![json!({"id": 1, "name": "小助理"})], ..Default::default() });
|
|
|
|
|
|
let mut req = Req { method: "GET".into(), path: "/api/state".into(), ..Default::default() };
|
|
|
|
|
|
req.headers.insert("authorization".into(), format!("Bearer {token}"));
|
|
|
|
|
|
let st = handle(req.clone(), false);
|
|
|
|
|
|
assert_eq!(st.status, 200, "{}", String::from_utf8_lossy(&st.body));
|
|
|
|
|
|
let v: Value = serde_json::from_slice(&st.body).unwrap();
|
|
|
|
|
|
assert_eq!(v["bots"][0]["name"], "小助理");
|
|
|
|
|
|
// Sending queues a command for the app.
|
|
|
|
|
|
let mut send = Req { method: "POST".into(), path: "/api/bots/1/send".into(), body: r#"{"text":"你好"}"#.as_bytes().to_vec(), ..Default::default() };
|
|
|
|
|
|
send.headers = req.headers.clone();
|
|
|
|
|
|
assert_eq!(handle(send, false).status, 200);
|
|
|
|
|
|
let cmds = hub().take_cmds();
|
|
|
|
|
|
assert!(cmds.iter().any(|c| matches!(c, Cmd::DeviceAdded(d) if d.name == "iPhone")));
|
2026-11-05 08:47:28 +00:00
|
|
|
|
assert!(cmds.contains(&Cmd::Send { bot: 1, text: "你好".into(), attachments: vec![], from: 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
|
|
|
|
// The Mac's own screen stays closed unless allowed.
|
|
|
|
|
|
let mut mac = Req { method: "GET".into(), path: "/api/screen/mac".into(), ..Default::default() };
|
|
|
|
|
|
mac.headers = req.headers.clone();
|
|
|
|
|
|
assert_eq!(handle(mac, false).status, 403);
|
|
|
|
|
|
// Device link needs the relay and the switch.
|
|
|
|
|
|
assert_eq!(handle(Req { method: "POST".into(), path: "/api/device/run".into(), body: br#"{"tool":"shell"}"#.to_vec(), ..Default::default() }, true).status, 403);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-05 06:25:01 +00:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn the_brain_is_an_online_one_that_thinks_for_itself() {
|
|
|
|
|
|
let hosts = vec![
|
|
|
|
|
|
json!({"id": "me", "name": "這台", "online": true, "hands": false, "uses": ""}),
|
|
|
|
|
|
json!({"id": "h", "name": "伺服器手腳", "online": true, "hands": true, "uses": ""}),
|
|
|
|
|
|
json!({"id": "s", "name": "筆電", "online": true, "hands": false, "uses": "b"}),
|
|
|
|
|
|
json!({"id": "old", "name": "舊的", "online": false, "hands": false, "uses": ""}),
|
|
|
|
|
|
json!({"id": "b", "name": "家裡的 Mac", "online": true, "hands": false, "uses": ""}),
|
|
|
|
|
|
];
|
|
|
|
|
|
assert_eq!(pick_brain(&hosts, "me"), Some(("b".into(), "家裡的 Mac".into())));
|
|
|
|
|
|
assert_eq!(pick_brain(&hosts[..4], "me"), None);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
#[test]
|
|
|
|
|
|
fn team_roles() {
|
|
|
|
|
|
assert!(role_allows("member", "POST", &["api", "bots", "3", "send"]));
|
|
|
|
|
|
assert!(role_allows("member", "GET", &["api", "state"]));
|
|
|
|
|
|
for (m, p) in [("POST", &["api", "bots", "3", "answer"][..]), ("POST", &["api", "bots", "3", "stop"][..]), ("GET", &["api", "screen", "box"][..]), ("POST", &["api", "device", "run"][..])] {
|
|
|
|
|
|
assert!(!role_allows("member", m, p), "{p:?}");
|
|
|
|
|
|
}
|
|
|
|
|
|
assert!(role_allows("admin", "POST", &["api", "bots", "3", "answer"]));
|
|
|
|
|
|
assert!(!role_allows("admin", "POST", &["api", "device", "run"]));
|
|
|
|
|
|
assert!(role_allows("device", "POST", &["api", "device", "run"]) && !role_allows("device", "GET", &["api", "state"]));
|
|
|
|
|
|
assert!(!role_allows("stranger", "GET", &["api", "state"]));
|
|
|
|
|
|
// The relay's word reaches handle() as headers.
|
|
|
|
|
|
let req = req_from_json(&json!({"method": "POST", "path": "/api/bots/1/answer", "user": "amy", "role": "member", "body": ""}));
|
|
|
|
|
|
assert_eq!(handle(req, true).status, 403);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn relay_messages_round_trip() {
|
|
|
|
|
|
let r = Resp::bytes("image/jpeg", vec![1, 2, 3]).with_header("X-Size", "2x2");
|
|
|
|
|
|
let j = resp_to_json(&r);
|
|
|
|
|
|
assert_eq!(j["status"], 200);
|
|
|
|
|
|
let req = req_from_json(&json!({"method": "POST", "path": "/api/bots/2/send", "query": {"since": "4"}, "body": "eyJ0ZXh0IjoiaGkifQ=="}));
|
|
|
|
|
|
assert_eq!(req.json()["text"], "hi");
|
|
|
|
|
|
assert_eq!(req.query["since"], "4");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
One-step install: install.sh, fixed Linux and server builds, clearer README
- install.sh (curl | sh): app, hands, server and deps modes; installs system packages
(right names per release, e.g. libasound2t64 on Ubuntu 24.04), Rust via rustup (1.95+),
Docker for the server; limits parallel compiles on small machines
- Server Docker image: add the ALSA build/runtime packages (the build failed), cache the
cargo registry and target between rebuilds, JOBS build arg
- 加一台電腦 now gives a line that installs AnyBot on a blank computer before joining it
- Makefile: make deps installs Rust too, make doctor, clear message when Rust is missing/old
- README rewritten: one line per use, getting started, update/remove, troubleshooting,
deployment options; docs/install.md and multi-device.md follow it
- Tested on fresh ubuntu:24.04 (app) and debian:12 (curl | sh hands) containers
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 16:52:10 +00:00
|
|
|
|
|
|
|
|
|
|
/// Where the one-step installer lives (it also installs AnyBot on a computer that has nothing yet).
|
|
|
|
|
|
pub const INSTALL_URL: &str = "https://code.30cm.net/daniel.w/AnyBot/raw/branch/main/install.sh";
|
|
|
|
|
|
|
|
|
|
|
|
/// The line 「加一台電腦」 shows: pasted into any Linux or Mac terminal, it installs AnyBot when it
|
|
|
|
|
|
/// isn't there yet and joins this computer as hands (`anybot setup 網址 配對碼`).
|
|
|
|
|
|
pub fn pair_line(relay_url: &str, code: &str) -> String {
|
|
|
|
|
|
format!("curl -fsSL {INSTALL_URL} | sh -s -- hands {} {code}", relay_url.trim_end_matches('/'))
|
|
|
|
|
|
}
|
2026-11-05 06:25:01 +00:00
|
|
|
|
|
|
|
|
|
|
/// 登入後同步 against a real account server: ANYBOT_RELAY_URL=http://127.0.0.1:PORT
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod account_sync_live {
|
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
#[ignore]
|
|
|
|
|
|
fn a_new_computer_gets_the_accounts_bots_after_signing_in() {
|
|
|
|
|
|
let url = std::env::var("ANYBOT_RELAY_URL").expect("ANYBOT_RELAY_URL");
|
|
|
|
|
|
let (user, pw) = (format!("t{}", crate::httpd::random_hex(4)), "sync-pass-123");
|
|
|
|
|
|
// The first computer signs up and backs up its bots.
|
|
|
|
|
|
let a = relay_join(&Settings { host_name: "第一台".into(), ..Default::default() }, &url, &user, pw, true, "").unwrap();
|
|
|
|
|
|
let mut st = crate::model::State::samples();
|
|
|
|
|
|
st.remote = Default::default();
|
|
|
|
|
|
backup_now(&a, &serde_json::to_vec(&st).unwrap()).unwrap();
|
|
|
|
|
|
// A new computer signs in: no other brain is on, so the backup is what it takes in.
|
|
|
|
|
|
let b = relay_join(&Settings { host_name: "新電腦".into(), ..Default::default() }, &url, &user, pw, false, "").unwrap();
|
|
|
|
|
|
let found = find_account_bots(&b, pw);
|
|
|
|
|
|
assert!(found.brain.is_none());
|
|
|
|
|
|
let got: crate::model::State = serde_json::from_slice(&found.backup.expect("backup")).unwrap();
|
|
|
|
|
|
let mut here = crate::model::State::first_run();
|
|
|
|
|
|
assert_eq!(here.take_in_bots(&got), 5);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|