#![recursion_limit = "512"] // the one `computer` tool's schema is one deep json! literal mod agent; mod app; mod appctl; mod attach; mod axread; mod axwrite; mod avatar; mod bidi; mod botbrowser; mod browser; mod cloudbox; mod channels; mod computer; mod desktop; mod diagram; mod platform; mod mic; mod pages; #[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 facts; mod httpd; mod intents; mod i18n; mod i18n_table; mod icons; mod lock; mod macapps; mod marks; mod mcp; mod memory; mod model; mod notify; mod oplog; mod phone; mod policy; mod preview; mod providers; mod remote; mod routines; mod setup; mod teach; mod share; mod teams; mod theme; mod tools; mod triggers; mod embed; mod store; mod docindex; mod vault; mod vault_store; mod vnc; mod voice; mod tunnel; mod web; mod wire; 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 { 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 { svg_icon(include_str!("../icons/anybot.svg"), 256) } /// An SVG drawn to a square RGBA picture of `px` pixels. fn svg_icon(svg: &str, px: u32) -> egui::IconData { 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.as_chunks_mut::<4>().0 { 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] [file.svg]`: the app icon (or that SVG) as a PNG, drawn at /// that size (for the Mac bundle, Linux packages and the web icons). fn write_icon(out: &str, px: u32, svg: Option<&str>) -> Result<(), String> { let icon = match svg { Some(path) => svg_icon(&std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?, px), None => svg_icon(include_str!("../icons/anybot.svg"), px), }; let img = image::RgbaImage::from_raw(icon.width, icon.height, icon.rgba).ok_or("icon")?; img.save(out).map_err(|e| e.to_string()) } /// This data folder for this copy (`--serve`, `--ask`), or a clear word and exit when another /// AnyBot is using it. fn take_folder_or_exit() -> lock::DataLock { match lock::take(lock::WAIT) { Ok(l) => l, Err(msg) => { eprintln!("AnyBot:{msg}"); std::process::exit(1); } } } /// What a second window shows instead of opening: another AnyBot is using this data folder. fn busy_window(msg: String) -> eframe::Result<()> { eprintln!("AnyBot:{msg}"); let viewport = egui::ViewportBuilder::default().with_title("AnyBot").with_inner_size([460.0, 240.0]).with_resizable(false).with_app_id("anybot").with_icon(app_icon()); eframe::run_native( "AnyBot", eframe::NativeOptions { viewport, ..Default::default() }, Box::new(move |cc| { load_fonts(&cc.egui_ctx); Ok(Box::new(Busy(msg))) }), ) } struct Busy(String); impl eframe::App for Busy { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { egui::CentralPanel::default().show(ui, |ui| { ui.add_space(10.0); ui.label(egui::RichText::new("AnyBot 已經在執行").size(17.0).strong()); ui.add_space(8.0); ui.label(&self.0); ui.add_space(14.0); if ui.button("關閉").clicked() { ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); } }); } } const USAGE: &str = "用法: anybot 打開 AnyBot 視窗 anybot --serve 不開視窗,讓 Bot 繼續工作、接手機 anybot --ask 問題… 問第一個 Bot 一句,印出回答(測試用) anybot setup … 在沒有螢幕的機器上設定 anybot --screenshot 檔名 把視窗畫成一張圖 資料夾:ANYBOT_DATA_DIR(沒設就用這個使用者的 AnyBot 資料夾)"; fn fetch_skill_text(src: &str) -> Result { if src.starts_with("http://") || src.starts_with("https://") { use std::io::Read; let agent: ureq::Agent = ureq::Agent::config_builder().timeout_global(Some(std::time::Duration::from_secs(20))).http_status_as_error(false).build().into(); let mut r = agent.get(src).call().map_err(|e| format!("抓不到技能檔:{e}"))?; let mut text = String::new(); r.body_mut().as_reader().take(400_000).read_to_string(&mut text).map_err(|e| format!("技能檔讀不完:{e}"))?; Ok(text) } else { std::fs::read_to_string(src).map_err(|e| format!("讀不到檔案:{e}")) } } fn main() -> eframe::Result<()> { let args: Vec = std::env::args().collect(); // P7: install a skill file (a path or a URL) and be done: skills are .md files you can keep in a repo. if let Some(i) = args.iter().position(|a| a == "--install-skill") { let src = args.get(i + 1).cloned().unwrap_or_default(); if src.is_empty() { eprintln!("用法:anybot --install-skill <技能檔路徑或網址>"); std::process::exit(2); } let text = match fetch_skill_text(&src) { Ok(t) => t, Err(e) => { eprintln!("{e}"); std::process::exit(1); } }; match crate::memory::install_skill_text(&text) { Ok(name) => println!("已安裝技能「{name}」(在 設定 › 整合 › 技能 可看)"), Err(e) => eprintln!("{e}") }; return Ok(()); } let screenshot = args.iter().position(|a| a == "--screenshot").and_then(|i| args.get(i + 1)).map(PathBuf::from); // For tests that start AnyBots of their own: a whole settings file (the sample bots) to edit. if args.get(1).map(String::as_str) == Some("--sample-state") { let out = args.get(2).cloned().unwrap_or_else(|| "state.json".into()); let mut st = model::State::samples(); st.remote = Default::default(); if let Err(e) = std::fs::write(&out, serde_json::to_vec_pretty(&st).unwrap_or_default()) { eprintln!("{e}"); std::process::exit(1); } return Ok(()); } 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, args.get(4).map(String::as_str)) { eprintln!("{e}"); std::process::exit(1); } return Ok(()); } // For testing on a new system: `anybot --tool computer_use_screenshot '{}' computer_use_click // '{"ref":"7"}' …` runs computer tools in order (one process, so a picture's numbers carry // over), prints what the bot would hear, and saves the numbered pictures that come back 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 }); } // 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 setup`: make this computer a brain, or hands for a brain (in a terminal). It takes // the data folder only while it writes the settings (see setup.rs). if args.get(1).map(String::as_str) == Some("setup") { std::process::exit(setup::run(&args[2..])); } // Everything below uses the data folder (the chats, the work log): one AnyBot at a time. if args.get(1).map(String::as_str) == Some("--ask") { let text = args[2..].join(" "); let ctx = egui::Context::default(); let _folder = take_folder_or_exit(); 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(); let _folder = take_folder_or_exit(); app::App::serve(&ctx); } // `--help` or a flag this build doesn't know: say what there is and stop. (It used to open the // window on the real data folder, next to the AnyBot already using it.) Single-dash arguments // are macOS's own (-psn_…, -NSDocumentRevisionsDebugMode) and are left alone. if let Some(bad) = args.iter().skip(1).find(|a| a.as_str() == "-h" || (a.starts_with("--") && !matches!(a.as_str(), "--screenshot" | "--serve"))) { if bad != "--help" && bad != "-h" { eprintln!("AnyBot:不認得「{bad}」。"); } eprintln!("{USAGE}"); std::process::exit(if bad == "--help" || bad == "-h" { 0 } else { 2 }); } // (A screenshot run is a tool next to the AnyBot that owns the folder; it doesn't take it.) let _folder = if screenshot.is_some() { None } else { match lock::take(lock::WAIT) { Ok(l) => Some(l), Err(msg) => return busy_window(msg), } }; let mut 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()); // On a Mac the window is one surface to the very top (the grey system title bar didn't match // a dark window); the traffic lights sit over the sidebar's empty corner. if cfg!(target_os = "macos") { viewport = viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false); } let options = eframe::NativeOptions { viewport, ..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))) }), ) }