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 {
|
|
|
|
|
|
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 {
|
|
|
|
|
|
Send { bot: u64, text: String, attachments: Vec<String> },
|
|
|
|
|
|
Stop { bot: u64 },
|
|
|
|
|
|
Answer { bot: u64, answer: String },
|
|
|
|
|
|
Read { bot: u64 },
|
|
|
|
|
|
NewThread { bot: u64 },
|
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 },
|
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>,
|
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>,
|
|
|
|
|
|
fails: Mutex<(u32, Option<Instant>)>,
|
|
|
|
|
|
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 {
|
|
|
|
|
|
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-04 09:46:43 +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((0, None)), 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,
|
|
|
|
|
|
"attachments": m.attachments.iter().map(|a| json!({"name": a.name, "image": a.image})).collect::<Vec<_>>(),
|
|
|
|
|
|
"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();
|
|
|
|
|
|
let path: Vec<&str> = parts.iter().map(|s| *s).collect();
|
|
|
|
|
|
match (req.method.as_str(), path.as_slice()) {
|
|
|
|
|
|
("GET", []) | ("GET", ["index.html"]) => return page(),
|
|
|
|
|
|
("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()),
|
|
|
|
|
|
("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);
|
|
|
|
|
|
let v = hub().wait_newer(since, if since == 0 { 0 } else { LONG_POLL_SECS });
|
|
|
|
|
|
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,
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
頁面: 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"),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
("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 };
|
|
|
|
|
|
Cmd::Send { bot, text, attachments }
|
|
|
|
|
|
}
|
|
|
|
|
|
"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 },
|
|
|
|
|
|
"upload" => return upload(bot, &req),
|
|
|
|
|
|
_ => return Resp::error(404, "沒有這個動作"),
|
|
|
|
|
|
};
|
|
|
|
|
|
hub().push(cmd);
|
|
|
|
|
|
Resp::json(&json!({"ok": true}))
|
|
|
|
|
|
}
|
|
|
|
|
|
("GET", ["api", "screen", which]) => screen(which, &s),
|
|
|
|
|
|
("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),
|
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).
|
|
|
|
|
|
("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(),
|
|
|
|
|
|
},
|
|
|
|
|
|
"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 },
|
|
|
|
|
|
_ => 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.
|
|
|
|
|
|
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,
|
頁面: 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
|
|
|
|
"member" => matches!((method, path), ("GET", ["api", "state"]) | ("GET", ["api", "bots", _]) | ("GET", ["api", "pages", ..]) | ("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,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn login(req: &Req) -> Resp {
|
|
|
|
|
|
{
|
|
|
|
|
|
let mut f = hub().fails.lock().unwrap();
|
|
|
|
|
|
if let (n, Some(t)) = *f {
|
|
|
|
|
|
if n >= MAX_FAILS && t.elapsed() < Duration::from_secs(60) {
|
|
|
|
|
|
return Resp::error(429, "密碼錯太多次,請一分鐘後再試");
|
|
|
|
|
|
}
|
|
|
|
|
|
if t.elapsed() >= Duration::from_secs(60) {
|
|
|
|
|
|
*f = (0, None);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
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) {
|
|
|
|
|
|
let mut f = hub().fails.lock().unwrap();
|
|
|
|
|
|
*f = (f.0 + 1, Some(Instant::now()));
|
|
|
|
|
|
// Slow guessing down a little more.
|
|
|
|
|
|
std::thread::sleep(Duration::from_millis(600));
|
|
|
|
|
|
return Resp::error(401, if stored.is_empty() { "這台 AnyBot 還沒設定手機密碼" } else { "密碼不對" });
|
|
|
|
|
|
}
|
|
|
|
|
|
*hub().fails.lock().unwrap() = (0, None);
|
|
|
|
|
|
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.
|
|
|
|
|
|
fn screen(which: &str, s: &Settings) -> Resp {
|
|
|
|
|
|
match which {
|
|
|
|
|
|
"box" => {
|
|
|
|
|
|
crate::cloudbox::touch();
|
|
|
|
|
|
match crate::cloudbox::frame() {
|
|
|
|
|
|
Some(img) => {
|
|
|
|
|
|
let rgb = image::DynamicImage::ImageRgba8(img).to_rgb8();
|
|
|
|
|
|
match crate::computer::encode_jpeg(&rgb) {
|
|
|
|
|
|
Some(j) => Resp::bytes("image/jpeg", j).with_header("X-Size", &format!("{}x{}", rgb.width(), rgb.height())),
|
|
|
|
|
|
None => Resp::error(500, "畫面編碼失敗"),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
None => Resp::error(503, "Bot 的電腦在休息,按「叫醒」"),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
"mac" if s.mac_from_phone => {
|
|
|
|
|
|
let (text, ok, img) = crate::computer::run_by("computer_use_screenshot", "{}", "手機");
|
|
|
|
|
|
match (ok, img) {
|
|
|
|
|
|
(true, Some(b)) => {
|
|
|
|
|
|
use base64::Engine;
|
|
|
|
|
|
let bytes = base64::engine::general_purpose::STANDARD.decode(b).unwrap_or_default();
|
|
|
|
|
|
let size = image::load_from_memory(&bytes).map(|i| format!("{}x{}", i.width(), i.height())).unwrap_or_default();
|
|
|
|
|
|
Resp::bytes("image/jpeg", bytes).with_header("X-Size", &size)
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => Resp::error(503, &text),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
"mac" => Resp::error(403, "這台 Mac 沒有開放從手機操作(AnyBot 設定 › 手機與帳號)"),
|
|
|
|
|
|
_ => Resp::error(404, "沒有這個畫面"),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The phone taps, types or scrolls on a screen (coordinates in the frame's pixels).
|
|
|
|
|
|
fn screen_input(which: &str, v: &Value, s: &Settings) -> Resp {
|
|
|
|
|
|
let kind = v["kind"].as_str().unwrap_or("");
|
|
|
|
|
|
if which == "box" && kind == "wake" {
|
|
|
|
|
|
return match crate::cloudbox::ensure_running() {
|
|
|
|
|
|
Ok(()) => Resp::json(&json!({"ok": true})),
|
|
|
|
|
|
Err(e) => Resp::error(500, &e),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
let (name, args) = match kind {
|
|
|
|
|
|
"click" => ("computer_use_click", json!({"x": v["x"], "y": v["y"], "button": v["button"].as_str().unwrap_or("left"), "count": v["count"].as_u64().unwrap_or(1)})),
|
|
|
|
|
|
"type" => ("computer_use_typing", json!({"text": v["text"]})),
|
|
|
|
|
|
"key" => ("computer_use_press_key", json!({"key": v["key"]})),
|
|
|
|
|
|
"scroll" => ("computer_use_scroll", json!({"x": v["x"], "y": v["y"], "direction": v["direction"].as_str().unwrap_or("down"), "amount": v["amount"].as_i64().unwrap_or(3)})),
|
|
|
|
|
|
_ => return Resp::error(400, "不認得的操作"),
|
|
|
|
|
|
};
|
|
|
|
|
|
match which {
|
|
|
|
|
|
"box" => {
|
|
|
|
|
|
crate::cloudbox::touch();
|
|
|
|
|
|
match crate::cloudbox::computer_run(name, &args) {
|
|
|
|
|
|
Ok(_) => Resp::json(&json!({"ok": true})),
|
|
|
|
|
|
Err(e) => Resp::error(500, &e),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
"mac" if s.mac_from_phone => {
|
|
|
|
|
|
let (t, ok, _) = crate::computer::run_by(name, &args.to_string(), "手機");
|
|
|
|
|
|
if ok {
|
|
|
|
|
|
Resp::json(&json!({"ok": true}))
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Resp::error(500, &t)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => Resp::error(403, "這台 Mac 沒有開放從手機操作"),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 裝置連結: 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");
|
|
|
|
|
|
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}));
|
|
|
|
|
|
}
|
|
|
|
|
|
if matches!(tool, "shell" | "read_file" | "edit_file" | "list_dir" | "search" | "read_terminal" | "write_terminal") {
|
|
|
|
|
|
let t = crate::tools::Target { name: crate::model::THIS_COMPUTER_NAME.into(), place: crate::tools::Place::Local { workdir: crate::tools::expand_home(v["workdir"].as_str().unwrap_or("~")) } };
|
|
|
|
|
|
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, "這個工具不能遠端使用")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------- 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);
|
|
|
|
|
|
std::thread::spawn(move || httpd::serve(listener, Arc::new(|r| handle(r, false)), Arc::new(move || LAN_GEN.load(Ordering::SeqCst) == gen)));
|
|
|
|
|
|
Ok(port)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 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();
|
|
|
|
|
|
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("tls") || text.contains("certificate") => "和伺服器的安全連線失敗(伺服器的憑證有問題)",
|
|
|
|
|
|
_ 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> {
|
|
|
|
|
|
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> {
|
|
|
|
|
|
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) {
|
|
|
|
|
|
return r.map(|a| (a.status, a.body));
|
|
|
|
|
|
}
|
|
|
|
|
|
let q: Vec<String> = query.iter().map(|(k, v)| format!("{k}={}", url_escape(v))).collect();
|
|
|
|
|
|
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())?;
|
|
|
|
|
|
Ok((st, bytes))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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-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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-04 09:46:43 +00:00
|
|
|
|
fn url_escape(s: &str) -> String {
|
|
|
|
|
|
s.bytes()
|
|
|
|
|
|
.map(|b| match b {
|
|
|
|
|
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => (b as char).to_string(),
|
|
|
|
|
|
_ => format!("%{b:02X}"),
|
|
|
|
|
|
})
|
|
|
|
|
|
.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
|
|
|
|
/// 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())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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-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");
|
|
|
|
|
|
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"}]}"##;
|
|
|
|
|
|
pub const ICON: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96"><rect width="96" height="96" rx="22" fill="#161616"/><circle cx="48" cy="48" r="24" fill="none" stroke="#f5f5f5" stroke-width="7"/><circle cx="48" cy="48" r="7" fill="#f5f5f5"/></svg>"##;
|
|
|
|
|
|
|
|
|
|
|
|
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::*;
|
|
|
|
|
|
|
|
|
|
|
|
#[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")));
|
|
|
|
|
|
assert!(cmds.contains(&Cmd::Send { bot: 1, text: "你好".into(), attachments: vec![] }));
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[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('/'))
|
|
|
|
|
|
}
|