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
|
|
|
|
// Files and pictures the person gives a bot with a message (the original: 附件 — drag files in,
|
|
|
|
|
|
// paste a picture, or pick files; pictures go to the model, documents are read for it).
|
|
|
|
|
|
// Each one is copied into AnyBot's data folder, so the chat keeps working after the original moves.
|
|
|
|
|
|
|
|
|
|
|
|
use base64::Engine;
|
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
|
|
|
|
|
|
|
|
/// Largest file accepted (estimated).
|
|
|
|
|
|
pub const MAX_FILE_BYTES: u64 = 52_428_800;
|
|
|
|
|
|
/// Text read from one file for the model (estimated; the original reads 20,000 characters of mail
|
|
|
|
|
|
/// and notes).
|
|
|
|
|
|
pub const MAX_TEXT_CHARS: usize = 20_000;
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
|
|
|
|
|
|
pub struct Attachment {
|
|
|
|
|
|
pub name: String,
|
|
|
|
|
|
/// The copy in AnyBot's data folder.
|
|
|
|
|
|
pub path: String,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub image: bool,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub bytes: u64,
|
|
|
|
|
|
/// Where it was also put on the bot's own computer (empty when not).
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub on_box: String,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn dir_for(bot_id: u64) -> PathBuf {
|
|
|
|
|
|
crate::model::State::path().parent().map(|p| p.to_path_buf()).unwrap_or_else(std::env::temp_dir).join("attachments").join(format!("bot-{bot_id}"))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn is_image_name(name: &str) -> bool {
|
|
|
|
|
|
let n = name.to_lowercase();
|
|
|
|
|
|
[".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".heic", ".tif", ".tiff"].iter().any(|e| n.ends_with(e))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Copy a file in as an attachment.
|
|
|
|
|
|
pub fn store(bot_id: u64, src: &Path) -> Result<Attachment, String> {
|
|
|
|
|
|
let meta = std::fs::metadata(src).map_err(|e| format!("讀不到 {}:{e}", src.display()))?;
|
|
|
|
|
|
if meta.is_dir() {
|
|
|
|
|
|
return Err(format!("「{}」是資料夾,請壓縮後再附加,或請 Bot 直接處理那個資料夾。", src.display()));
|
|
|
|
|
|
}
|
|
|
|
|
|
if meta.len() > MAX_FILE_BYTES {
|
|
|
|
|
|
return Err(format!("「{}」太大了(最多 50 MB)", src.display()));
|
|
|
|
|
|
}
|
|
|
|
|
|
let name = src.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "附件".into());
|
|
|
|
|
|
let dir = dir_for(bot_id);
|
|
|
|
|
|
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
|
|
|
|
|
let dst = dir.join(format!("{}-{name}", chrono::Local::now().format("%Y%m%d%H%M%S%3f")));
|
|
|
|
|
|
std::fs::copy(src, &dst).map_err(|e| format!("複製失敗:{e}"))?;
|
|
|
|
|
|
Ok(Attachment { image: is_image_name(&name), name, path: dst.display().to_string(), bytes: meta.len(), on_box: String::new() })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Real-use fixes: the person sees the screens the bot shows, files reach the phone, no blank-tab loops
From real runs on the cloud brain with the person's Mac (Threads patrol, a post draft, "show me my
screen"):
- computer_use_show: a plain picture of any computer's screen goes into the chat (the bot sees it
too). The bot used to describe a screenshot in words and fail to send it.
- The phone shows the bot's attachments: pictures in place, other files download on a tap (api/file,
only from the chats' file folder). It showed nothing for the bot and names only for the person.
- send_file works from the person's other computers (fetch_file over the relay, 20 MB, secrets stay).
- The same call with the same answer again and again is warned the 3rd time and stopped from the
4th (a blank tab was read 11 times, then the bot said Threads was logged out without opening it);
a blank tab says so; checking a login means opening the site.
- Screenshot lists: a nameless row around a named button isn't numbered, a nameless row takes the
words inside it, empty spacer rows aren't numbered (System Settings: 63 numbers -> 33, all named).
- Writing "in my voice" reads the person's own recent posts first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
2026-11-06 11:37:45 +00:00
|
|
|
|
/// A picture or file the bot hands over (a screen it shows, a file fetched from another computer).
|
|
|
|
|
|
pub fn store_bytes(bot_id: u64, name: &str, bytes: &[u8]) -> Result<Attachment, String> {
|
|
|
|
|
|
if bytes.len() as u64 > MAX_FILE_BYTES {
|
|
|
|
|
|
return Err(format!("「{name}」太大了(最多 50 MB)"));
|
|
|
|
|
|
}
|
|
|
|
|
|
let dir = dir_for(bot_id);
|
|
|
|
|
|
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
|
|
|
|
|
let dst = dir.join(format!("{}-{name}", chrono::Local::now().format("%Y%m%d%H%M%S%3f")));
|
|
|
|
|
|
std::fs::write(&dst, bytes).map_err(|e| format!("存不下來:{e}"))?;
|
|
|
|
|
|
Ok(Attachment { image: is_image_name(name), name: name.to_string(), path: dst.display().to_string(), bytes: bytes.len() as u64, on_box: String::new() })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
/// A picture on the clipboard (e.g. a screenshot copied with ⌃⇧⌘4) becomes an attachment.
|
|
|
|
|
|
pub fn paste_image(bot_id: u64) -> Option<Attachment> {
|
|
|
|
|
|
let dir = dir_for(bot_id);
|
|
|
|
|
|
std::fs::create_dir_all(&dir).ok()?;
|
|
|
|
|
|
let name = format!("貼上的圖片 {}.png", chrono::Local::now().format("%H%M%S"));
|
|
|
|
|
|
let dst = dir.join(format!("{}-{name}", chrono::Local::now().format("%Y%m%d%H%M%S%3f")));
|
|
|
|
|
|
if !cfg!(target_os = "macos") {
|
|
|
|
|
|
let png = crate::platform::clipboard_png_linux()?;
|
|
|
|
|
|
std::fs::write(&dst, &png).ok()?;
|
|
|
|
|
|
return Some(Attachment { name, path: dst.display().to_string(), image: true, bytes: png.len() as u64, on_box: String::new() });
|
|
|
|
|
|
}
|
|
|
|
|
|
let script = r#"ObjC.import('AppKit');
|
|
|
|
|
|
function run(argv) {
|
|
|
|
|
|
const img = $.NSImage.alloc.initWithPasteboard($.NSPasteboard.generalPasteboard);
|
|
|
|
|
|
if (img.isNil()) return 'none';
|
|
|
|
|
|
const rep = $.NSBitmapImageRep.imageRepWithData(img.TIFFRepresentation);
|
|
|
|
|
|
const png = rep.representationUsingTypeProperties($.NSBitmapImageFileTypePNG, $({}));
|
|
|
|
|
|
return png.writeToFileAtomically(argv[0], true) ? 'ok' : 'fail';
|
|
|
|
|
|
}"#;
|
|
|
|
|
|
let out = std::process::Command::new("osascript").args(["-l", "JavaScript", "-e", script, "--"]).arg(&dst).output().ok()?;
|
|
|
|
|
|
if String::from_utf8_lossy(&out.stdout).trim() != "ok" {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
let bytes = std::fs::metadata(&dst).map(|m| m.len()).unwrap_or(0);
|
|
|
|
|
|
Some(Attachment { name, path: dst.display().to_string(), image: true, bytes, on_box: String::new() })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The Finder file picker (several files at once).
|
|
|
|
|
|
pub fn pick_files() -> Vec<PathBuf> {
|
|
|
|
|
|
if !cfg!(target_os = "macos") {
|
|
|
|
|
|
return crate::platform::pick_files_linux(&crate::i18n::t("選擇要附加的檔案"));
|
|
|
|
|
|
}
|
|
|
|
|
|
let script = r#"function run() {
|
|
|
|
|
|
const app = Application.currentApplication(); app.includeStandardAdditions = true;
|
|
|
|
|
|
try { return app.chooseFile({withPrompt: '選擇要附加的檔案', multipleSelectionsAllowed: true}).map(f => f.toString()).join('\n'); } catch (e) { return ''; }
|
|
|
|
|
|
}"#;
|
|
|
|
|
|
std::process::Command::new("osascript")
|
|
|
|
|
|
.args(["-l", "JavaScript", "-e", script])
|
|
|
|
|
|
.output()
|
|
|
|
|
|
.map(|o| String::from_utf8_lossy(&o.stdout).lines().map(str::trim).filter(|l| !l.is_empty()).map(PathBuf::from).collect())
|
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn human(b: u64) -> String {
|
|
|
|
|
|
crate::cloudbox::human(b)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// A picture, sized like a screenshot, as base64 JPEG.
|
|
|
|
|
|
pub fn image_b64(path: &str) -> Option<String> {
|
|
|
|
|
|
// The viewer's decoder: every common format, SVG, and HEIC/AVIF through sips or ffmpeg.
|
|
|
|
|
|
let img = image::DynamicImage::ImageRgba8(crate::preview::decode_image(std::path::Path::new(path))?);
|
|
|
|
|
|
let img = img.to_rgb8();
|
|
|
|
|
|
let (w, h) = img.dimensions();
|
|
|
|
|
|
let (fw, fh, resize) = crate::computer::fit(w, h);
|
|
|
|
|
|
let img = if resize { image::imageops::resize(&img, fw, fh, image::imageops::FilterType::Triangle) } else { img };
|
|
|
|
|
|
crate::computer::encode_jpeg(&img).map(|j| base64::engine::general_purpose::STANDARD.encode(j))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn clip(s: &str) -> String {
|
|
|
|
|
|
let n = s.chars().count();
|
|
|
|
|
|
if n <= MAX_TEXT_CHARS {
|
|
|
|
|
|
s.to_string()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
format!("{}\n…(後面省略 {} 個字)", s.chars().take(MAX_TEXT_CHARS).collect::<String>(), n - MAX_TEXT_CHARS)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The text inside a document, when it can be read here.
|
|
|
|
|
|
pub fn read_text(path: &str, name: &str) -> Option<String> {
|
|
|
|
|
|
let n = name.to_lowercase();
|
|
|
|
|
|
let ext = n.rsplit('.').next().unwrap_or("");
|
|
|
|
|
|
// Word and web pages: read in AnyBot itself (same on every system).
|
|
|
|
|
|
if ext == "docx" {
|
|
|
|
|
|
if let Ok(md) = crate::preview::docx_markdown(std::path::Path::new(path)) {
|
|
|
|
|
|
return Some(md).filter(|t| !t.trim().is_empty());
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if ext == "html" || ext == "htm" {
|
|
|
|
|
|
let bytes = std::fs::read(path).ok()?;
|
|
|
|
|
|
return Some(crate::preview::html_markdown(&String::from_utf8_lossy(&bytes))).filter(|t| !t.trim().is_empty());
|
|
|
|
|
|
}
|
|
|
|
|
|
if ["docx", "doc", "rtf", "rtfd", "odt", "webarchive", "pages"].contains(&ext) && cfg!(target_os = "macos") {
|
|
|
|
|
|
let out = std::process::Command::new("textutil").args(["-convert", "txt", "-stdout", path]).output().ok()?;
|
|
|
|
|
|
return out.status.success().then(|| String::from_utf8_lossy(&out.stdout).to_string()).filter(|t| !t.trim().is_empty());
|
|
|
|
|
|
}
|
|
|
|
|
|
if ext == "pdf" {
|
|
|
|
|
|
// pdftotext keeps the layout best; without it, read the text in AnyBot (pure Rust).
|
|
|
|
|
|
if let Some(t) = std::process::Command::new("pdftotext").env("PATH", crate::cloudbox::tool_path()).args(["-layout", path, "-"]).output().ok().filter(|o| o.status.success()).map(|o| String::from_utf8_lossy(&o.stdout).to_string()).filter(|t| !t.trim().is_empty()) {
|
|
|
|
|
|
return Some(t);
|
|
|
|
|
|
}
|
|
|
|
|
|
let bytes = std::fs::read(path).ok()?;
|
|
|
|
|
|
// NFKC: PDFs often carry CJK radicals ("⽂") for the ordinary characters ("文").
|
|
|
|
|
|
use unicode_normalization::UnicodeNormalization;
|
|
|
|
|
|
return std::panic::catch_unwind(|| pdf_extract::extract_text_from_mem(&bytes).ok()).ok().flatten().map(|t| t.nfkc().collect::<String>()).filter(|t| !t.trim().is_empty());
|
|
|
|
|
|
}
|
|
|
|
|
|
let bytes = std::fs::read(path).ok()?;
|
|
|
|
|
|
if bytes.len() > 2 * 1024 * 1024 || bytes.iter().take(8000).any(|b| *b == 0) {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
String::from_utf8(bytes).ok()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// What the model gets for a message's attachments: extra text and pictures.
|
|
|
|
|
|
pub fn for_model(atts: &[Attachment]) -> (String, Vec<String>) {
|
|
|
|
|
|
let mut text = String::new();
|
|
|
|
|
|
let mut images = vec![];
|
|
|
|
|
|
for a in atts {
|
2026-11-05 09:07:04 +00:00
|
|
|
|
let where_ = if a.on_box.is_empty() { format!("檔案存在 AnyBot 所在的電腦:{}", a.path) } else { format!("檔案已放進你自己的電腦:{}", a.on_box) };
|
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 a.image {
|
|
|
|
|
|
match image_b64(&a.path) {
|
|
|
|
|
|
Some(b) => {
|
|
|
|
|
|
images.push(b);
|
|
|
|
|
|
text.push_str(&format!("\n\n[附件圖片「{}」,就是下面這張。{where_}]", a.name));
|
|
|
|
|
|
}
|
|
|
|
|
|
None => text.push_str(&format!("\n\n[附件圖片「{}」讀不出來。{where_}]", a.name)),
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if let Some(t) = read_text(&a.path, &a.name) {
|
|
|
|
|
|
text.push_str(&format!("\n\n[附件「{}」({})。{where_}]\n```\n{}\n```", a.name, human(a.bytes), clip(&t)));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
text.push_str(&format!("\n\n[附件「{}」({}),這裡讀不出內容,可以用工具處理。{where_}]", a.name, human(a.bytes)));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
(text, images)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod tests {
|
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn text_and_images_reach_the_model() {
|
|
|
|
|
|
let dir = std::env::temp_dir().join(format!("anybot-attach-test-{}", std::process::id()));
|
|
|
|
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
|
|
|
|
let txt = dir.join("筆記.md");
|
|
|
|
|
|
std::fs::write(&txt, "# 標題\n內容 hello").unwrap();
|
|
|
|
|
|
let png = dir.join("圖.png");
|
|
|
|
|
|
image::RgbImage::from_pixel(2000, 1000, image::Rgb([10, 20, 30])).save(&png).unwrap();
|
|
|
|
|
|
let bin = dir.join("data.bin");
|
|
|
|
|
|
std::fs::write(&bin, [0u8, 1, 2, 0, 5]).unwrap();
|
|
|
|
|
|
let atts: Vec<Attachment> = [&txt, &png, &bin].iter().map(|p| store(99_999, p).unwrap()).collect();
|
|
|
|
|
|
assert!(atts[1].image && !atts[0].image);
|
|
|
|
|
|
let (text, images) = for_model(&atts);
|
|
|
|
|
|
assert!(text.contains("內容 hello") && text.contains("附件圖片「圖.png」") && text.contains("讀不出內容"), "{text}");
|
|
|
|
|
|
assert_eq!(images.len(), 1);
|
|
|
|
|
|
for a in &atts {
|
|
|
|
|
|
let _ = std::fs::remove_file(&a.path);
|
|
|
|
|
|
}
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
|
|
|
|
assert!(store(1, Path::new("/nonexistent/x")).is_err());
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|