363 lines
14 KiB
Rust
363 lines
14 KiB
Rust
#![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<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);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
const USAGE: &str = "用法:
|
||
anybot 打開 AnyBot 視窗
|
||
anybot --serve 不開視窗,讓 Bot 繼續工作、接手機
|
||
anybot --ask 問題… 問第一個 Bot 一句,印出回答(測試用)
|
||
anybot setup … 在沒有螢幕的機器上設定
|
||
anybot --screenshot 檔名 把視窗畫成一張圖
|
||
資料夾:ANYBOT_DATA_DIR(沒設就用這個使用者的 AnyBot 資料夾)";
|
||
|
||
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);
|
||
}
|
||
// `--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)))
|
||
}),
|
||
)
|
||
}
|