AnyBot/src/main.rs

338 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

mod agent;
mod app;
mod appctl;
mod attach;
mod axread;
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 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 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<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 {
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);
}
});
}
}
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);
// 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);
}
// (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)))
}),
)
}