Pictures for models that can't see them; bots can read the chat's attachments

- 看圖小幫手: when a bot's model can't take pictures, a model of the
  person's that can (Grok, Claude, GPT, Gemini… whichever has a key)
  describes each picture the person sent, text and numbers included, and
  the bot gets that description. Remembered per picture; helpers that
  fail are skipped for ten minutes.
- On the Mac, commands still can't read AnyBot's data folder (the keys),
  but may read the chat's attachments in it (not write them).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
This commit is contained in:
王性驊 2026-11-04 21:52:06 +08:00
parent dd27ed7c4a
commit 64e67673c8
3 changed files with 273 additions and 5 deletions

View File

@ -707,6 +707,8 @@ impl App {
let default_screen = if screens.contains(&default_computer) { default_computer.clone() } else { screens[0].clone() }; let default_screen = if screens.contains(&default_computer) { default_computer.clone() } else { screens[0].clone() };
tool_specs.extend(crate::computer::specs_for(&screens, &default_screen)); tool_specs.extend(crate::computer::specs_for(&screens, &default_screen));
} }
// 看圖小幫手: the person's models that may see pictures, for a bot whose model can't.
providers::set_eyes(self.eyes());
let req = ChatRequest { let req = ChatRequest {
kind: b.provider, kind: b.provider,
base_url: providers::resolve_base(b.provider, &ps.base_url), base_url: providers::resolve_base(b.provider, &ps.base_url),
@ -1297,6 +1299,34 @@ impl App {
} }
} }
/// Models to describe pictures with, best first: each provider with a key, its own default
/// model, then models of it known to see pictures.
fn eyes(&self) -> Vec<providers::Eyes> {
use ProviderKind as K;
let mut v = vec![];
for kind in [K::Xai, K::Anthropic, K::OpenAi, K::Gemini, K::OpenRouter, K::OpenCodeGo, K::Custom, K::Ollama] {
let ps = self.state.provider(kind);
let key = providers::resolve_key(kind, &ps.api_key);
if kind.needs_key() && key.is_empty() {
continue;
}
if matches!(kind, K::Custom | K::Ollama) && ps.base_url.trim().is_empty() {
continue;
}
let base = providers::resolve_base(kind, &ps.base_url);
let mut models: Vec<String> = vec![];
if !ps.default_model.trim().is_empty() {
models.push(ps.default_model.trim().to_string());
}
models.extend(providers::seeing_models(kind).iter().map(|m| m.to_string()));
models.dedup();
for m in models {
v.push(providers::Eyes { kind, base_url: base.clone(), api_key: key.clone(), model: m });
}
}
v
}
fn models_for(&self, kind: ProviderKind) -> Vec<String> { fn models_for(&self, kind: ProviderKind) -> Vec<String> {
let ps = self.state.provider(kind); let ps = self.state.provider(kind);
let mut v: Vec<String> = if ps.known_models.is_empty() { let mut v: Vec<String> = if ps.known_models.is_empty() {

View File

@ -371,9 +371,10 @@ pub fn run_step(req: &ChatRequest, cancel: &AtomicBool, send: &dyn Fn(StreamEven
let req = &repaired; let req = &repaired;
let mut blind_copy: Option<ChatRequest> = None; let mut blind_copy: Option<ChatRequest> = None;
loop { loop {
// A model that can't see pictures gets the request without them. // A model that can't see pictures gets the request without them: the person's pictures
// described by a model that can (看圖小幫手), screenshots as a short note.
if !sees_images(req) && blind_copy.is_none() { if !sees_images(req) && blind_copy.is_none() {
blind_copy = Some(without_images(req)); blind_copy = Some(if DESCRIBING.with(|d| d.get()) { without_images(req) } else { with_descriptions(req, cancel, send) });
} }
let req = blind_copy.as_ref().unwrap_or(req); let req = blind_copy.as_ref().unwrap_or(req);
let result = match req.kind { let result = match req.kind {
@ -519,6 +520,142 @@ pub fn repair_history(history: &[Message]) -> Vec<Message> {
out out
} }
// ---------- 看圖小幫手 ----------
/// A model of the person's that sees pictures (another provider, or another model).
#[derive(Clone, Debug, PartialEq)]
pub struct Eyes {
pub kind: ProviderKind,
pub base_url: String,
pub api_key: String,
pub model: String,
}
static EYES: std::sync::Mutex<Vec<Eyes>> = std::sync::Mutex::new(Vec::new());
thread_local! {
/// Inside a helper's own call (it never asks a helper itself).
static DESCRIBING: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// The models to ask, best first (the app sets this from the person's providers).
pub fn set_eyes(v: Vec<Eyes>) {
*EYES.lock().unwrap() = v;
}
/// Models likely to see pictures, by provider.
pub fn seeing_models(kind: ProviderKind) -> &'static [&'static str] {
match kind {
ProviderKind::Xai => &["grok-4", "grok-4-fast"],
ProviderKind::Anthropic => &["claude-sonnet-5-5", "claude-haiku-5-5"],
ProviderKind::OpenAi => &["gpt-5-mini", "gpt-4.1"],
ProviderKind::Gemini => &["gemini-2.5-flash"],
ProviderKind::OpenRouter => &["openrouter/auto"],
_ => &[],
}
}
const EYES_PROMPT: &str = "你是「看圖小幫手」:替一個看不到圖片的 AI 助理看圖。用繁體中文把圖片完整描述出來,讓它不用看圖也能幫使用者:畫面是什麼、主要的東西和位置、所有看得到的文字(原文照抄,包括數字、表格、按鈕和錯誤訊息)、顏色或狀態等重點。只描述,不評論、不問問題、不說你是誰。";
/// Descriptions already made (by the picture), kept across restarts.
fn notes_path() -> std::path::PathBuf {
crate::model::State::path().with_file_name("image-notes.json")
}
fn notes() -> &'static std::sync::Mutex<std::collections::HashMap<String, (String, String)>> {
static N: std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<String, (String, String)>>> = std::sync::OnceLock::new();
N.get_or_init(|| std::sync::Mutex::new(if cfg!(test) { Default::default() } else { std::fs::read(notes_path()).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default() }))
}
/// Helpers that just failed (wrong model name, no credit, down…), skipped for a while.
fn failed_eyes() -> &'static std::sync::Mutex<std::collections::HashMap<String, std::time::Instant>> {
static F: std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<String, std::time::Instant>>> = std::sync::OnceLock::new();
F.get_or_init(Default::default)
}
fn picture_key(b64: &str) -> String {
let d = ring::digest::digest(&ring::digest::SHA256, b64.as_bytes());
d.as_ref()[..12].iter().map(|b| format!("{b:02x}")).collect()
}
/// Ask the first helper that can see pictures to describe this one: (description, model).
fn describe(picture: &str, ask: &str, not: &ChatRequest, cancel: &AtomicBool) -> Option<(String, String)> {
let key = picture_key(picture);
if let Some(n) = notes().lock().unwrap().get(&key) {
return Some(n.clone());
}
let helpers: Vec<Eyes> = EYES.lock().unwrap().clone();
for e in helpers {
let req = ChatRequest {
kind: e.kind,
base_url: e.base_url.clone(),
api_key: e.api_key.clone(),
model: e.model.clone(),
system: EYES_PROMPT.into(),
history: vec![Message { images: vec![picture.to_string()], ..Message::user(ask) }],
tools: vec![],
session: String::new(),
};
let id = vision_key(&req);
let resting = failed_eyes().lock().unwrap().get(&id).map(|t| t.elapsed().as_secs() < 600).unwrap_or(false);
if (req.base_url == not.base_url && req.model == not.model) || !sees_images(&req) || resting || cancel.load(Ordering::Relaxed) {
continue;
}
DESCRIBING.with(|d| d.set(true));
let out = run_step(&req, cancel, &|_| {});
DESCRIBING.with(|d| d.set(false));
match out {
// It may have turned out not to see pictures after all (and answered without).
Ok(o) if sees_images(&req) && !o.text.trim().is_empty() => {
let note = (o.text.trim().to_string(), e.model.clone());
let mut all = notes().lock().unwrap();
if all.len() > 500 {
all.clear();
}
all.insert(key, note.clone());
if let (false, Ok(b)) = (cfg!(test), serde_json::to_vec(&*all)) {
let _ = std::fs::write(notes_path(), b);
}
return Some(note);
}
_ => {
failed_eyes().lock().unwrap().insert(id, std::time::Instant::now());
}
}
}
None
}
/// The request for a model that can't see pictures: each of the person's pictures replaced by
/// what a helper saw in it; screenshots by a short note.
fn with_descriptions(req: &ChatRequest, cancel: &AtomicBool, send: &dyn Fn(StreamEvent)) -> ChatRequest {
let mut r = req.clone();
let mut told = false;
for m in r.history.iter_mut().filter(|m| !m.images.is_empty()) {
if m.role == Role::Tool {
m.images.clear();
m.text = if m.text.is_empty() { BLIND_SCREENSHOT_NOTE.to_string() } else { format!("{}\n{BLIND_SCREENSHOT_NOTE}", m.text) };
continue;
}
let pictures = std::mem::take(&mut m.images);
let ask = format!("請描述這張圖片。使用者附上它時說:「{}」", m.text.chars().take(300).collect::<String>());
let mut added = String::new();
for (i, p) in pictures.iter().enumerate() {
if !told && !notes().lock().unwrap().contains_key(&picture_key(p)) {
send(StreamEvent::Retrying("這個模型看不到圖片,正在請另一個看得懂圖的模型先看一遍…".into()));
told = true;
}
let which = if pictures.len() > 1 { format!("第 {} 張圖片", i + 1) } else { "這張圖片".to_string() };
added.push_str(&match describe(p, &ask, req, cancel) {
Some((d, model)) => format!("\n\n(你看不到圖片;以下是「{model}」看過{which}後的描述)\n{d}"),
None => format!("\n\n{BLIND_ATTACHMENT_NOTE}(帳號裡也沒有看得懂圖片的模型可以代看。可以請使用者到「設定 › 模型」加一個,例如 Grok、Claude、GPT 或 Gemini。)"),
});
}
m.text.push_str(&added);
}
r
}
/// The same request with pictures replaced by a short note. /// The same request with pictures replaced by a short note.
fn without_images(req: &ChatRequest) -> ChatRequest { fn without_images(req: &ChatRequest) -> ChatRequest {
let mut r = req.clone(); let mut r = req.clone();
@ -1231,6 +1368,82 @@ mod tests {
assert!(!sees_images(&req)); assert!(!sees_images(&req));
} }
/// A fake model: refuses pictures when `blind`, else answers `reply`; hands over each request.
fn fake_model(blind: bool, reply: &'static str) -> (String, std::sync::mpsc::Receiver<String>) {
let l = TcpListener::bind("127.0.0.1:0").unwrap();
let port = l.local_addr().unwrap().port();
let (tx, rx) = std::sync::mpsc::channel::<String>();
std::thread::spawn(move || {
for s in l.incoming().flatten() {
let mut s = s;
let mut req = Vec::new();
let mut buf = [0u8; 65536];
loop {
let k = s.read(&mut buf).unwrap_or(0);
if k == 0 {
break;
}
req.extend_from_slice(&buf[..k]);
let text = String::from_utf8_lossy(&req).to_string();
if let Some(end) = text.find("\r\n\r\n") {
let len = text[..end].lines().find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0))).unwrap_or(0);
if req.len() >= end + 4 + len {
break;
}
}
}
let text = String::from_utf8_lossy(&req).to_string();
let _ = tx.send(text.clone());
if blind && text.contains("image_url") {
let body = r#"{"error":{"message":"This model does not support image input"}}"#;
let _ = write!(s, "HTTP/1.1 400 Bad Request\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
} else {
let chunk = serde_json::json!({"choices": [{"delta": {"content": reply}}]});
let _ = write!(s, "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\ndata: {chunk}\n\ndata: [DONE]\n\n");
}
}
});
(format!("http://127.0.0.1:{port}/v1"), rx)
}
#[test]
fn a_model_that_sees_describes_pictures_for_one_that_cannot() {
let (blind_url, blind_rx) = fake_model(true, "好的,我看到描述了");
let (eyes_url, eyes_rx) = fake_model(false, "一張報表截圖,上面寫著「十月營收 42 萬」。");
set_eyes(vec![
// Not there (a typo in a model name, a dead server…): the next one is asked.
Eyes { kind: ProviderKind::Custom, base_url: "http://127.0.0.1:9/v1".into(), api_key: String::new(), model: "gone".into() },
Eyes { kind: ProviderKind::Custom, base_url: eyes_url, api_key: String::new(), model: "eyes-model".into() },
]);
let mut photo = Message::user("幫我看這張圖");
photo.images = vec!["UElDVFVSRS1PTkU=".into()];
let req = ChatRequest { kind: ProviderKind::Custom, base_url: blind_url, api_key: String::new(), model: "text-only".into(), system: String::new(), history: vec![photo], tools: vec![], session: String::new() };
let said = std::sync::Mutex::new(vec![]);
let out = run_step(&req, &AtomicBool::new(false), &|e| {
if let StreamEvent::Retrying(m) = e {
said.lock().unwrap().push(m);
}
})
.unwrap();
assert_eq!(out.text, "好的,我看到描述了");
// The helper got the picture and what the person said with it.
let helper = eyes_rx.recv().unwrap();
assert!(helper.contains("UElDVFVSRS1PTkU=") && helper.contains("幫我看這張圖"));
// The blind model got the description instead of the picture.
let _refused = blind_rx.recv().unwrap();
let again = blind_rx.recv().unwrap();
assert!(!again.contains("UElDVFVSRS1PTkU=") && again.contains("十月營收 42 萬") && again.contains("eyes-model"), "{again}");
assert!(said.lock().unwrap().iter().any(|m| m.contains("看得懂圖")));
// The same picture later: described from memory, no second call to the helper.
let mut photo2 = Message::user("再看一次");
photo2.images = vec!["UElDVFVSRS1PTkU=".into()];
let req2 = ChatRequest { history: vec![photo2], ..req.clone() };
run_step(&req2, &AtomicBool::new(false), &|_| {}).unwrap();
assert!(blind_rx.recv().unwrap().contains("十月營收 42 萬"));
assert!(eyes_rx.recv_timeout(std::time::Duration::from_millis(300)).is_err());
set_eyes(vec![]);
}
#[test] #[test]
fn steps_without_results_are_closed() { fn steps_without_results_are_closed() {
let mut a = Message::assistant("", ""); let mut a = Message::assistant("", "");

View File

@ -427,6 +427,12 @@ pub fn protected_dirs() -> Vec<PathBuf> {
v v
} }
/// Inside the protected folders, what commands may still read: the chat's attachments (a picture
/// the person sent is the bot's to look at; the keys next to it are not).
pub fn readable_dirs() -> Vec<PathBuf> {
crate::model::State::path().parent().map(|d| vec![d.join("attachments")]).unwrap_or_default()
}
fn sandbox_exec() -> Option<&'static str> { fn sandbox_exec() -> Option<&'static str> {
let p = "/usr/bin/sandbox-exec"; let p = "/usr/bin/sandbox-exec";
(cfg!(target_os = "macos") && Path::new(p).exists()).then_some(p) (cfg!(target_os = "macos") && Path::new(p).exists()).then_some(p)
@ -440,12 +446,13 @@ fn real(p: &Path) -> String {
/// The shell command, wrapped in the sandbox when the system has one. /// The shell command, wrapped in the sandbox when the system has one.
#[cfg(test)] #[cfg(test)]
pub fn build_command(cmd: &str, workdir: &Path, protected: &[PathBuf], conversation: &str, request: &str) -> Command { pub fn build_command(cmd: &str, workdir: &Path, protected: &[PathBuf], conversation: &str, request: &str) -> Command {
build_command_sealed(cmd, workdir, protected, None, conversation, request) build_command_sealed(cmd, workdir, protected, &[], None, conversation, request)
} }
/// `seal = Some(home)`: the bot's own computer. Nothing under the person's home folder is /// `seal = Some(home)`: the bot's own computer. Nothing under the person's home folder is
/// visible or writable except the bot's folder (`workdir`), like a separate machine. /// visible or writable except the bot's folder (`workdir`), like a separate machine.
pub fn build_command_sealed(cmd: &str, workdir: &Path, protected: &[PathBuf], seal: Option<&Path>, conversation: &str, request: &str) -> Command { /// `readable`: folders inside `protected` that may still be read (never written).
pub fn build_command_sealed(cmd: &str, workdir: &Path, protected: &[PathBuf], readable: &[PathBuf], seal: Option<&Path>, conversation: &str, request: &str) -> Command {
let mut c = match sandbox_exec() { let mut c = match sandbox_exec() {
Some(se) if !protected.is_empty() || seal.is_some() => { Some(se) if !protected.is_empty() || seal.is_some() => {
let rules: String = (0..protected.len()).map(|i| format!(" (subpath (param \"P{i}\"))")).collect(); let rules: String = (0..protected.len()).map(|i| format!(" (subpath (param \"P{i}\"))")).collect();
@ -453,10 +460,18 @@ pub fn build_command_sealed(cmd: &str, workdir: &Path, protected: &[PathBuf], se
if seal.is_some() { if seal.is_some() {
profile.push_str("(deny file-read* file-write* (with no-report) (require-all (subpath (param \"HOME\")) (require-not (subpath (param \"ROOT\")))))"); profile.push_str("(deny file-read* file-write* (with no-report) (require-all (subpath (param \"HOME\")) (require-not (subpath (param \"ROOT\")))))");
} }
// Later rules win: reading these is allowed again (writing stays denied).
let open: Vec<&PathBuf> = readable.iter().filter(|r| protected.iter().any(|p| r.starts_with(p))).collect();
for i in 0..open.len() {
profile.push_str(&format!("(allow file-read* (subpath (param \"R{i}\")))"));
}
let mut c = Command::new(se); let mut c = Command::new(se);
for (i, p) in protected.iter().enumerate() { for (i, p) in protected.iter().enumerate() {
c.arg("-D").arg(format!("P{i}={}", real(p))); c.arg("-D").arg(format!("P{i}={}", real(p)));
} }
for (i, r) in open.iter().enumerate() {
c.arg("-D").arg(format!("R{i}={}", real(r)));
}
if let Some(home) = seal { if let Some(home) = seal {
c.arg("-D").arg(format!("HOME={}", real(home))).arg("-D").arg(format!("ROOT={}", real(workdir))); c.arg("-D").arg(format!("HOME={}", real(home))).arg("-D").arg(format!("ROOT={}", real(workdir)));
} }
@ -962,7 +977,7 @@ fn edit_file(p: &Path, old: &str, new: &str) -> Result<String, String> {
} }
fn shell(cmd: &str, workdir: &Path, timeout_ms: u64, cancel: &AtomicBool, ids: &RunIds, seal: Option<&Path>) -> Result<String, String> { fn shell(cmd: &str, workdir: &Path, timeout_ms: u64, cancel: &AtomicBool, ids: &RunIds, seal: Option<&Path>) -> Result<String, String> {
run_command(build_command_sealed(cmd, workdir, &protected_dirs(), seal, &ids.conversation, &ids.request), cmd, timeout_ms, cancel, ids) run_command(build_command_sealed(cmd, workdir, &protected_dirs(), &readable_dirs(), seal, &ids.conversation, &ids.request), cmd, timeout_ms, cancel, ids)
} }
/// Run a wrapper command (ssh / docker exec) the same way: background hand-off, stop, output. /// Run a wrapper command (ssh / docker exec) the same way: background hand-off, stop, output.
@ -1185,6 +1200,16 @@ mod tests {
assert!(!String::from_utf8_lossy(&out.stdout).contains("top-secret")); assert!(!String::from_utf8_lossy(&out.stdout).contains("top-secret"));
let out = build_command(&format!("cat '{}/k.txt'", secret.display()), &w, &[], "1", "r").output().unwrap(); let out = build_command(&format!("cat '{}/k.txt'", secret.display()), &w, &[], "1", "r").output().unwrap();
assert!(String::from_utf8_lossy(&out.stdout).contains("top-secret")); assert!(String::from_utf8_lossy(&out.stdout).contains("top-secret"));
// The chat's attachments inside it: readable, not writable; the keys next to them: hidden.
let att = secret.join("attachments");
std::fs::create_dir_all(&att).unwrap();
std::fs::write(att.join("photo.txt"), "the-photo").unwrap();
let run = |cmd: String| build_command_sealed(&cmd, &w, &[secret.clone()], &[att.clone()], None, "1", "r").output().unwrap();
assert!(String::from_utf8_lossy(&run(format!("cat '{}/photo.txt'", att.display())).stdout).contains("the-photo"));
assert!(!String::from_utf8_lossy(&run(format!("cat '{}/k.txt'", secret.display())).stdout).contains("top-secret"));
run(format!("echo changed > '{}/photo.txt'", att.display()));
assert_eq!(std::fs::read_to_string(att.join("photo.txt")).unwrap(), "the-photo");
let _ = std::fs::remove_dir_all(&secret);
} }
#[test] #[test]