AnyBot/src/main.rs

389 lines
16 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.

#![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;
#[cfg(target_os = "windows")]
mod win;
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 fetch_skill_text(src: &str) -> Result<String, String> {
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<String> = 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)))
}),
)
}