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
|
|
|
mod agent;
|
|
|
|
|
mod app;
|
|
|
|
|
mod appctl;
|
|
|
|
|
mod attach;
|
|
|
|
|
mod axread;
|
|
|
|
|
mod avatar;
|
|
|
|
|
mod botbrowser;
|
|
|
|
|
mod browser;
|
|
|
|
|
mod cloudbox;
|
|
|
|
|
mod channels;
|
|
|
|
|
mod computer;
|
2026-11-04 09:19:59 +00:00
|
|
|
mod desktop;
|
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
|
|
|
mod diagram;
|
|
|
|
|
mod platform;
|
|
|
|
|
mod mic;
|
|
|
|
|
#[cfg(target_os = "linux")]
|
|
|
|
|
mod x11;
|
|
|
|
|
#[cfg(target_os = "linux")]
|
|
|
|
|
mod wl;
|
|
|
|
|
#[cfg(target_os = "linux")]
|
|
|
|
|
mod portal;
|
|
|
|
|
#[cfg(target_os = "linux")]
|
|
|
|
|
mod desk;
|
|
|
|
|
mod egress;
|
|
|
|
|
mod httpd;
|
|
|
|
|
mod i18n;
|
|
|
|
|
mod i18n_table;
|
|
|
|
|
mod icons;
|
|
|
|
|
mod macapps;
|
|
|
|
|
mod mcp;
|
|
|
|
|
mod memory;
|
|
|
|
|
mod model;
|
|
|
|
|
mod notify;
|
|
|
|
|
mod phone;
|
|
|
|
|
mod policy;
|
|
|
|
|
mod preview;
|
|
|
|
|
mod providers;
|
|
|
|
|
mod remote;
|
|
|
|
|
mod routines;
|
|
|
|
|
mod teach;
|
|
|
|
|
mod share;
|
|
|
|
|
mod teams;
|
|
|
|
|
mod theme;
|
|
|
|
|
mod tools;
|
|
|
|
|
mod triggers;
|
|
|
|
|
mod vault;
|
|
|
|
|
mod vault_store;
|
|
|
|
|
mod voice;
|
|
|
|
|
mod web;
|
|
|
|
|
|
|
|
|
|
use eframe::egui;
|
|
|
|
|
use std::path::PathBuf;
|
|
|
|
|
|
|
|
|
|
/// PingFang ships as a downloadable system asset on macOS; its folder name varies per Mac.
|
|
|
|
|
fn pingfang_path() -> Option<PathBuf> {
|
|
|
|
|
let root = std::path::Path::new("/System/Library/AssetsV2");
|
|
|
|
|
for set in std::fs::read_dir(root).ok()?.flatten() {
|
|
|
|
|
if !set.file_name().to_string_lossy().starts_with("com_apple_MobileAsset_Font") {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
for asset in std::fs::read_dir(set.path()).into_iter().flatten().flatten() {
|
|
|
|
|
let p = asset.path().join("AssetData/PingFang.ttc");
|
|
|
|
|
if p.exists() {
|
|
|
|
|
return Some(p);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// PingFang TC Regular inside PingFang.ttc.
|
|
|
|
|
const PINGFANG_TC_INDEX: u32 = 2;
|
|
|
|
|
|
|
|
|
|
/// The window's first size (screenshot mode can ask for another, e.g. ANYBOT_SHOT_SIZE=760x560).
|
|
|
|
|
fn shot_size() -> [f32; 2] {
|
|
|
|
|
std::env::var("ANYBOT_SHOT_SIZE").ok().and_then(|v| {
|
|
|
|
|
let (w, h) = v.split_once('x')?;
|
|
|
|
|
Some([w.parse().ok()?, h.parse().ok()?])
|
|
|
|
|
}).unwrap_or([1180.0, 780.0])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 粉圓體, bundled.
|
|
|
|
|
pub const HUNINN: &[u8] = include_bytes!("../fonts/jf-openhuninn-2.1.ttf");
|
|
|
|
|
|
|
|
|
|
/// "huninn" (粉圓體, the default) or "system".
|
|
|
|
|
pub fn font_choice() -> String {
|
|
|
|
|
model::State::path().parent().and_then(|d| std::fs::read_to_string(d.join("font.txt")).ok()).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).unwrap_or_else(|| "huninn".into())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn load_fonts(ctx: &egui::Context) {
|
|
|
|
|
let mut fonts = egui::FontDefinitions::default();
|
|
|
|
|
// One font for Chinese *and* English: mixing egui's built-in Latin font with a Chinese
|
|
|
|
|
// fallback put the two on different baselines and sizes ("Bot 設定" looked broken).
|
|
|
|
|
// So a CJK font that has good Latin letters goes first; egui's own fonts stay behind it
|
|
|
|
|
// only for symbols and emoji it lacks.
|
|
|
|
|
let mut candidates: Vec<(PathBuf, u32)> = pingfang_path().map(|p| vec![(p, PINGFANG_TC_INDEX)]).unwrap_or_default();
|
|
|
|
|
candidates.extend(
|
|
|
|
|
[
|
|
|
|
|
("/System/Library/Fonts/Hiragino Sans GB.ttc", 0),
|
|
|
|
|
("/System/Library/Fonts/STHeiti Medium.ttc", 0),
|
|
|
|
|
("/System/Library/Fonts/Supplemental/Arial Unicode.ttf", 0),
|
|
|
|
|
("C:\\Windows\\Fonts\\msjh.ttc", 0),
|
|
|
|
|
("C:\\Windows\\Fonts\\msyh.ttc", 0),
|
|
|
|
|
// Linux: Noto Sans CJK (face 3 = Traditional Chinese) on Debian/Ubuntu, Arch, Fedora; then WenQuanYi.
|
|
|
|
|
("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", 3),
|
|
|
|
|
("/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", 3),
|
|
|
|
|
("/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc", 3),
|
|
|
|
|
("/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", 0),
|
|
|
|
|
]
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|(p, i)| (PathBuf::from(p), i)),
|
|
|
|
|
);
|
|
|
|
|
// Code: the original's `--cursor-font-family-mono` list is ui-monospace, SFMono-Regular, Menlo…
|
|
|
|
|
for path in ["/System/Library/Fonts/SFNSMono.ttf", "/System/Library/Fonts/Menlo.ttc"] {
|
|
|
|
|
if let Ok(bytes) = std::fs::read(path) {
|
|
|
|
|
fonts.font_data.insert("mono".into(), egui::FontData::from_owned(bytes).into());
|
|
|
|
|
fonts.families.entry(egui::FontFamily::Monospace).or_default().insert(0, "mono".into());
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for (path, index) in candidates {
|
|
|
|
|
if let Ok(bytes) = std::fs::read(&path) {
|
|
|
|
|
let mut data = egui::FontData::from_owned(bytes);
|
|
|
|
|
data.index = index;
|
|
|
|
|
fonts.font_data.insert("cjk".into(), data.into());
|
|
|
|
|
fonts.families.entry(egui::FontFamily::Proportional).or_default().insert(0, "cjk".into());
|
|
|
|
|
// Code stays in the monospace font; Chinese inside code falls back to the same font.
|
|
|
|
|
fonts.families.entry(egui::FontFamily::Monospace).or_default().push("cjk".into());
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// 粉圓體 (jf open 粉圓, SIL OFL 1.1, fonts/OFL.txt) goes first everywhere it has the letter;
|
|
|
|
|
// the system font above fills in what it lacks (some simplified characters, symbols).
|
|
|
|
|
if font_choice() == "huninn" {
|
|
|
|
|
fonts.font_data.insert("huninn".into(), egui::FontData::from_static(HUNINN).into());
|
|
|
|
|
fonts.families.entry(egui::FontFamily::Proportional).or_default().insert(0, "huninn".into());
|
|
|
|
|
let mono = fonts.families.entry(egui::FontFamily::Monospace).or_default();
|
|
|
|
|
let at = mono.iter().position(|f| f == "cjk").unwrap_or(mono.len());
|
|
|
|
|
mono.insert(at, "huninn".into());
|
|
|
|
|
}
|
|
|
|
|
ctx.set_fonts(fonts);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The window icon (Linux taskbars and window switchers; macOS uses the app bundle's).
|
|
|
|
|
fn app_icon() -> egui::IconData {
|
|
|
|
|
let svg = include_str!("../icons/anybot.svg");
|
|
|
|
|
let px = 256u32;
|
|
|
|
|
let tree = resvg::usvg::Tree::from_str(svg, &resvg::usvg::Options::default()).expect("icon svg");
|
|
|
|
|
let mut pm = resvg::tiny_skia::Pixmap::new(px, px).expect("icon pixmap");
|
|
|
|
|
let s = px as f32 / tree.size().width();
|
|
|
|
|
resvg::render(&tree, resvg::tiny_skia::Transform::from_scale(s, s), &mut pm.as_mut());
|
|
|
|
|
// tiny-skia is premultiplied; IconData wants straight alpha.
|
|
|
|
|
let mut rgba = pm.take();
|
|
|
|
|
for c in rgba.chunks_exact_mut(4) {
|
|
|
|
|
let a = c[3] as u32;
|
|
|
|
|
if a > 0 && a < 255 {
|
|
|
|
|
for v in &mut c[..3] {
|
|
|
|
|
*v = ((*v as u32 * 255 + a / 2) / a).min(255) as u8;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
egui::IconData { rgba, width: px, height: px }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// `anybot --write-icon out.png [size]`: the app icon as a PNG (for Linux packages).
|
|
|
|
|
fn write_icon(out: &str, px: u32) -> Result<(), String> {
|
|
|
|
|
let icon = app_icon();
|
|
|
|
|
let img = image::RgbaImage::from_raw(icon.width, icon.height, icon.rgba).ok_or("icon")?;
|
|
|
|
|
let img = if px != icon.width { image::imageops::resize(&img, px, px, image::imageops::FilterType::Lanczos3) } else { img };
|
|
|
|
|
img.save(out).map_err(|e| e.to_string())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn main() -> eframe::Result<()> {
|
|
|
|
|
let args: Vec<String> = std::env::args().collect();
|
|
|
|
|
let screenshot = args.iter().position(|a| a == "--screenshot").and_then(|i| args.get(i + 1)).map(PathBuf::from);
|
|
|
|
|
if args.get(1).map(String::as_str) == Some("--write-icon") {
|
|
|
|
|
let out = args.get(2).cloned().unwrap_or_else(|| "anybot.png".into());
|
|
|
|
|
let px = args.get(3).and_then(|v| v.parse().ok()).unwrap_or(256);
|
|
|
|
|
if let Err(e) = write_icon(&out, px) {
|
|
|
|
|
eprintln!("{e}");
|
|
|
|
|
std::process::exit(1);
|
|
|
|
|
}
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
// For testing on a new system: `anybot --tool computer_use_screenshot '{}' computer_use_click
|
|
|
|
|
// '{"x":10,"y":20}' …` runs computer tools in order (one process, so the screenshot's scale
|
|
|
|
|
// carries over), prints what the bot would hear, and saves screenshots as anybot-tool-N.jpg
|
|
|
|
|
// in the temp folder.
|
|
|
|
|
if args.get(1).map(String::as_str) == Some("--tool") {
|
|
|
|
|
let mut all_ok = true;
|
|
|
|
|
for (i, pair) in args[2..].chunks(2).enumerate() {
|
|
|
|
|
let json = pair.get(1).cloned().unwrap_or_else(|| "{}".into());
|
|
|
|
|
let (text, ok, img) = computer::run(&pair[0], &json);
|
|
|
|
|
println!("{} {}: {text}", pair[0], if ok { "ok" } else { "failed" });
|
|
|
|
|
if let Some(b64) = img {
|
|
|
|
|
use base64::Engine;
|
|
|
|
|
let out = std::env::temp_dir().join(format!("anybot-tool-{i}.jpg"));
|
|
|
|
|
let _ = std::fs::write(&out, base64::engine::general_purpose::STANDARD.decode(b64).unwrap_or_default());
|
|
|
|
|
println!(" saved {}", out.display());
|
|
|
|
|
}
|
|
|
|
|
all_ok &= ok;
|
|
|
|
|
std::thread::sleep(std::time::Duration::from_millis(300));
|
|
|
|
|
}
|
|
|
|
|
std::process::exit(if all_ok { 0 } else { 1 });
|
|
|
|
|
}
|
2026-11-04 09:19:59 +00:00
|
|
|
// For trying agent-desktop on a new system: `anybot --desktop snapshot '{"app":"Finder"}'`.
|
|
|
|
|
if args.get(1).map(String::as_str) == Some("--desktop") {
|
|
|
|
|
let cmd = args.get(2).cloned().unwrap_or_else(|| "apps".into());
|
|
|
|
|
let v: serde_json::Value = serde_json::from_str(args.get(3).map(String::as_str).unwrap_or("{}")).unwrap_or_default();
|
|
|
|
|
match desktop::run(&cmd, &v) {
|
|
|
|
|
Ok(out) => println!("{}", serde_json::to_string_pretty(&out).unwrap_or_default()),
|
|
|
|
|
Err(e) => {
|
|
|
|
|
eprintln!("{e}");
|
|
|
|
|
std::process::exit(1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
std::process::exit(0);
|
|
|
|
|
}
|
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 args.get(1).map(String::as_str) == Some("--ask") {
|
|
|
|
|
let text = args[2..].join(" ");
|
|
|
|
|
let ctx = egui::Context::default();
|
|
|
|
|
app::App::ask_cli(&ctx, &text);
|
|
|
|
|
}
|
|
|
|
|
// No window: keep the bots working and answer the phone (always-on Mac or server).
|
|
|
|
|
if args.iter().any(|a| a == "--serve") {
|
|
|
|
|
let ctx = egui::Context::default();
|
|
|
|
|
app::App::serve(&ctx);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let options = eframe::NativeOptions {
|
|
|
|
|
viewport: egui::ViewportBuilder::default()
|
|
|
|
|
.with_title("AnyBot")
|
|
|
|
|
.with_inner_size(shot_size())
|
|
|
|
|
.with_min_inner_size([app::SIDEBAR_MIN_WIDTH + app::CHAT_MIN_WIDTH, 480.0])
|
|
|
|
|
.with_app_id("anybot")
|
|
|
|
|
.with_icon(app_icon()),
|
|
|
|
|
..Default::default()
|
|
|
|
|
};
|
|
|
|
|
eframe::run_native(
|
|
|
|
|
"AnyBot",
|
|
|
|
|
options,
|
|
|
|
|
Box::new(move |cc| {
|
|
|
|
|
load_fonts(&cc.egui_ctx);
|
|
|
|
|
Ok(Box::new(app::App::new(&cc.egui_ctx, screenshot)))
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
}
|