diff --git a/mobile/index.html b/mobile/index.html
index f5027ad..9c16e19 100644
--- a/mobile/index.html
+++ b/mobile/index.html
@@ -91,7 +91,8 @@ header .sub{font-size:12px;color:var(--text3);font-weight:400}
.ic{vertical-align:-2px;flex:none}
.tool.waiting .st{color:var(--accent)}.tool.failed .st{color:var(--danger)}
.tool .d{font:12px var(--mono);color:var(--text3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
-.att{display:inline-flex;gap:6px;align-items:center;font-size:12.5px;background:var(--raise);border-radius:8px;padding:3px 8px;margin:3px 0 0}
+.att{display:inline-flex;gap:6px;align-items:center;font-size:12.5px;background:var(--raise);border-radius:8px;padding:3px 8px;margin:3px 0 0;color:inherit;text-decoration:none;cursor:pointer}
+.attimg{margin:6px 0 0;max-width:100%}.attimg img{display:block;max-width:100%;max-height:70vh;border-radius:12px;border:1px solid var(--line);cursor:zoom-in}.attimg .wait{font-size:12.5px;color:var(--text3)}
.card{border:1px solid var(--line);border-radius:14px;padding:12px 14px;margin:6px 0;background:var(--surface);max-width:420px}
.card .t{font-weight:600}.card .s{color:var(--text3);font-size:13px;margin-bottom:6px}
.card .r{display:flex;justify-content:space-between;gap:10px;font-size:13.5px;padding:3px 0;border-top:1px solid var(--line)}
@@ -541,7 +542,7 @@ function renderMsgs() {
const busy = (S.bots.find((b) => b.id === S.open) || {}).busy;
const html = S.msgs.map((m, i) => {
if (m.role === "user") {
- const atts = (m.attachments || []).map((a) => `
${ic(a.image ? "image" : "file", 14)} ${esc(a.name)}
`).join("");
+ const atts = (m.attachments || []).map(attHtml).join("");
if (m.summary) return "";
return `${m.wake ? `
${ic("alarm-clock", 13)} 例行任務
` : ""}${m.text ? `
${esc(m.text)}
` : ""}${atts}
`;
}
@@ -551,12 +552,14 @@ function renderMsgs() {
const body = m.text ? `${md(m.text)}
` : (last && busy && !tools ? `
` : "");
const who = m.author ? `${esc(m.author)}
` : "";
const via = m.text && m.via && !busy ? `${esc(m.via)}${(m.reactions || []).length ? " · " + m.reactions.join(" ") : ""}
` : "";
- return `${who}${body}${tools}${last ? "" : ""}${via}
`;
+ const atts = (m.attachments || []).map(attHtml).join("");
+ return `${who}${body}${tools}${atts}${last ? "" : ""}${via}
`;
}).join("");
$("msgs").innerHTML = html || `還沒有訊息,說聲嗨吧
`;
$("b-send").innerHTML = busy && !$("input").value.trim() && !S.pending.length
? ``
: ``;
+ loadFiles();
}
function card(c) {
if (!c) return "";
@@ -597,6 +600,36 @@ async function openPage(id, rev) {
}
$("pver").onchange = () => openPage(S.page, +$("pver").value || 0);
$("b-back3").onclick = () => { show(S.pageBack && S.pageBack !== "page" ? S.pageBack : "list"); if (S.view === "chat") loadChat(); };
+// Files in a chat: pictures shown in place (a screen the bot showed, a photo), other files open on a tap.
+// Fetched with the login (an
can't send it), kept for the next redraw.
+const FILES = {};
+function attHtml(a) {
+ if (!a.path) return `${ic(a.image ? "image" : "file", 14)} ${esc(a.name)}
`;
+ if (a.image) return `${FILES[a.path] ? `

` : `
${ic("image", 14)} ${esc(a.name)}
`}
`;
+ return `${ic("file", 14)} ${esc(a.name)}`;
+}
+async function fileUrl(path) {
+ if (!FILES[path]) {
+ const r = await api(base() + "api/file?path=" + encodeURIComponent(path), {raw: true});
+ if (!r.ok) throw new Error("找不到這個檔案");
+ FILES[path] = URL.createObjectURL(await r.blob());
+ }
+ return FILES[path];
+}
+async function loadFiles() {
+ for (const el of document.querySelectorAll(".attimg[data-file]")) {
+ if (el.querySelector("img")) continue;
+ try { const u = await fileUrl(el.dataset.file); el.innerHTML = `
`; } catch (_) { el.innerHTML = `這張圖讀不到
`; }
+ }
+}
+document.addEventListener("click", async (e) => {
+ const img = e.target.closest && e.target.closest(".attimg img");
+ if (img) { window.open(img.src, "_blank"); return; }
+ const f = e.target.closest && e.target.closest("a.att[data-file]");
+ if (!f) return;
+ e.preventDefault();
+ try { const u = await fileUrl(f.dataset.file); const a = document.createElement("a"); a.href = u; a.download = f.dataset.name || "檔案"; document.body.appendChild(a); a.click(); a.remove(); } catch (err) { toast(err.message); }
+});
const dark = () => matchMedia("(prefers-color-scheme: dark)").matches;
async function loadCharts() {
for (const el of document.querySelectorAll(".chart[data-src]")) {
diff --git a/src/agent.rs b/src/agent.rs
index 668aa1b..7086500 100644
--- a/src/agent.rs
+++ b/src/agent.rs
@@ -2,6 +2,7 @@
// rules say so), give it the results, and repeat until it answers in plain words.
// Long chats are folded into a summary before they outgrow the model's context.
+use base64::Engine;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{channel, Sender};
use std::sync::{Arc, Mutex};
@@ -279,7 +280,7 @@ fn element_named(history: &[Message], r: &str) -> Option {
/// counted: doing those again and again is how a long page or a list is read.
fn step_key(name: &str, args: &str) -> Option {
if !(computer::is_computer_tool(name) || web::is_web_tool(name))
- || matches!(name, "computer_use_screenshot" | "browser_read" | "browser_screenshot" | "browser_wait" | "computer_use_scroll" | "browser_scroll" | "computer_use_press_key" | "browser_press" | "browser_back")
+ || matches!(name, "computer_use_screenshot" | "computer_use_show" | "browser_read" | "browser_screenshot" | "browser_wait" | "computer_use_scroll" | "browser_scroll" | "computer_use_press_key" | "browser_press" | "browser_back")
{
return None;
}
@@ -313,6 +314,37 @@ fn refresh_own(job: &mut Job, bot_id: u64) -> Option {
}
}
+/// How many times in a row this very call (same tool, same arguments) already came back with the
+/// very same answer among the latest steps. Waiting tools are left out: there the same answer is
+/// the point.
+fn same_answers(history: &[Message], name: &str, args: &str) -> usize {
+ if matches!(name, "read_terminal" | "browser_wait" | "write_terminal") {
+ return 0;
+ }
+ let want = tools::args_of(args);
+ let calls: std::collections::HashMap<&str, (&str, &str)> = history.iter().rev().take(80).flat_map(|m| m.tool_calls.iter()).map(|c| (c.id.as_str(), (c.name.as_str(), c.args.as_str()))).collect();
+ // A warning put in front of an answer doesn't make it a different answer.
+ let bare = |t: &str| -> String { t.lines().skip_while(|l| l.starts_with("⚠️")).collect::>().join("\n") };
+ let mut first: Option = None;
+ let mut n = 0;
+ for m in history.iter().rev().filter(|m| m.role == Role::Tool).take(12) {
+ let Some((cn, ca)) = calls.get(m.tool_call_id.as_str()) else { continue };
+ if *cn != name || tools::args_of(ca) != want {
+ continue;
+ }
+ let text = bare(&m.text);
+ match &first {
+ None => {
+ first = Some(text);
+ n = 1;
+ }
+ Some(f) if *f == text => n += 1,
+ _ => break,
+ }
+ }
+ n
+}
+
/// "Always allow" for this turn: the grant is kept (once).
fn keep_grant(grants: &mut Vec, g: String) {
if !g.is_empty() && !grants.contains(&g) {
@@ -586,6 +618,22 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
repeat_note = "⚠️ 這一步你已經做了 3 次。如果畫面還不是你要的,下一步一定要換方法,不要再做同一步。\n".into();
}
}
+ // The same look again and again with the same answer (a blank tab read eleven
+ // times in a row): the third time with a warning, after that not at all.
+ if repeat_note.is_empty() {
+ let same = same_answers(&history, &call.name, &call.args);
+ if same >= 3 {
+ let text = format!("沒有執行:同一個工具、同樣的參數你已經做了 {same} 次,每次結果都一模一樣,再做也不會不同。換一個做法(例如用 browser_open 打開要看的網址),或直接告訴使用者你看到了什麼。");
+ send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Failed));
+ let result = Message::tool_result(&call.id, &call.name, &text);
+ send(StreamEvent::ToolResult(result.clone()));
+ history.push(result);
+ continue;
+ }
+ if same == 2 {
+ repeat_note = "⚠️ 同一步你已經做了 2 次,結果都一模一樣。如果這次還一樣,就換一個做法。\n".into();
+ }
+ }
// Every step ends the same way: its status, then its result (with the notes) and
// any picture, shown and kept.
let finish = |history: &mut Vec, output: &str, status: ToolStatus, image: Option| {
@@ -721,13 +769,28 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
},
Ok(tools::Target { place: tools::Place::Sealed { root }, .. }) => Ok(tools::resolve(&root, &path)),
Ok(tools::Target { place: tools::Place::Local { workdir }, .. }) => Ok(tools::resolve(&workdir, &path)),
+ // Another of the person's computers: it sends the file over, then it is
+ // put in the chat like any other.
+ Ok(tools::Target { place: tools::Place::Device { host, dir }, .. }) => {
+ let args = serde_json::json!({"path": path, "workdir": dir}).to_string();
+ crate::remote::run_on_device(&host, crate::remote::FETCH_FILE, &args, &job.via_name).and_then(|v| {
+ let bytes = v["data"].as_str().and_then(|d| base64::engine::general_purpose::STANDARD.decode(d).ok()).ok_or_else(|| v["text"].as_str().unwrap_or("那台電腦沒有把檔案傳過來").to_string())?;
+ let dst = std::env::temp_dir().join(format!("anybot-fetch-{}", std::process::id())).join(v["name"].as_str().unwrap_or("檔案"));
+ let _ = std::fs::create_dir_all(dst.parent().unwrap_or(std::path::Path::new(".")));
+ std::fs::write(&dst, &bytes).map_err(|e| e.to_string())?;
+ Ok(dst)
+ })
+ }
Ok(_) => Err(format!("這台電腦上的檔案不能直接傳,請先複製到「{}」。", job.roles.own)),
Err(e) => Err(e),
};
// Secrets (SSH keys, the app's own data folder, .env…) never ride into the chat,
// same rule as read_file/edit_file.
let local = local.and_then(|p| if tools::is_ignored(&p) || tools::is_ignored(std::path::Path::new(&path)) { Err("這個檔案是機密的,不能傳到聊天。".to_string()) } else { Ok(p) });
- let (output, ok) = match local.and_then(|p| crate::attach::store(bot_id, &p)) {
+ let stored = local.and_then(|p| crate::attach::store(bot_id, &p));
+ // (A file fetched from another computer was only parked here on its way.)
+ let _ = std::fs::remove_dir_all(std::env::temp_dir().join(format!("anybot-fetch-{}", std::process::id())));
+ let (output, ok) = match stored {
Ok(a) => {
let out = format!("已把「{}」傳到聊天裡({}),使用者點一下就能預覽。", a.name, crate::attach::human(a.bytes));
send(StreamEvent::Attach(a));
@@ -1033,15 +1096,16 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
(Ok(_), Ok(())) if is_screen && call.name == "computer_use_check_permissions" => Decision::Allow,
// 裝置連結: another of the person's computers; seeing and operating it ask once each.
(Ok(t), Ok(())) if is_screen && on_device.is_some() => {
- let grant = if call.name == "computer_use_screenshot" { format!("device-see:{}", t.name) } else { format!("device-control:{}", t.name) };
+ let looks = matches!(call.name.as_str(), "computer_use_screenshot" | "computer_use_show");
+ let grant = if looks { format!("device-see:{}", t.name) } else { format!("device-control:{}", t.name) };
if grants.contains(&grant) {
Decision::Allow
} else {
- Decision::Ask { reason: format!("{}「{}」的螢幕:{what}", if call.name == "computer_use_screenshot" { "看" } else { "操作" }, t.name), grant }
+ Decision::Ask { reason: format!("{}「{}」的螢幕:{what}", if looks { "看" } else { "操作" }, t.name), grant }
}
}
(Ok(_), Ok(())) if is_screen => {
- if call.name == "computer_use_screenshot" {
+ if matches!(call.name.as_str(), "computer_use_screenshot" | "computer_use_show") {
if grants.iter().any(|g| g == "computer:see") {
Decision::Allow
} else {
@@ -1169,6 +1233,15 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
(Ok(()), Err(e)) => (e.clone(), ToolStatus::Failed),
(Err(x), _) => x,
};
+ // 給使用者看畫面: the picture goes into the chat for the person (the bot still sees it).
+ if call.name == "computer_use_show" {
+ if let Some(bytes) = image.as_deref().and_then(|b| base64::engine::general_purpose::STANDARD.decode(b).ok()) {
+ let name = format!("{} 的畫面 {}.jpg", target.as_ref().map(|t| t.name.as_str()).unwrap_or("電腦"), chrono::Local::now().format("%H%M%S"));
+ if let Ok(a) = crate::attach::store_bytes(bot_id, &name, &bytes) {
+ send(StreamEvent::Attach(a));
+ }
+ }
+ }
finish(&mut history, &output, status, image);
if cancel.load(Ordering::Relaxed) {
return;
@@ -1227,6 +1300,25 @@ mod tests {
assert!(!mentions("在 macOS 的登入機制下,幫 Bot 的電腦截圖", &mac, &roles));
}
+ #[test]
+ fn the_same_answer_again_and_again_is_counted() {
+ use crate::model::ToolCall;
+ let call = |id: &str, args: &str| Message { role: Role::Assistant, tool_calls: vec![ToolCall { id: id.into(), name: "browser_read".into(), args: args.into(), status: ToolStatus::Done }], ..Default::default() };
+ let mut h = vec![Message::user("看得到我的 Threads 嗎?")];
+ for i in 0..3 {
+ h.push(call(&format!("c{i}"), r#"{"computer":"Bot 的電腦"}"#));
+ let warn = if i == 2 { "⚠️ 同一步你已經做了 2 次,結果都一模一樣。\n" } else { "" };
+ h.push(Message::tool_result(&format!("c{i}"), "browser_read", &format!("{warn}網址:about:blank")));
+ }
+ assert_eq!(same_answers(&h, "browser_read", r#"{ "computer": "Bot 的電腦" }"#), 3);
+ // Another page in between: counting starts again.
+ h.push(call("c9", r#"{"computer":"Bot 的電腦"}"#));
+ h.push(Message::tool_result("c9", "browser_read", "網址:https://www.threads.com/"));
+ assert_eq!(same_answers(&h, "browser_read", r#"{"computer":"Bot 的電腦"}"#), 1);
+ // Waiting on a terminal is allowed to look the same.
+ assert_eq!(same_answers(&h, "read_terminal", "{}"), 0);
+ }
+
#[test]
fn long_turns_check_in_instead_of_stopping() {
let mut h = vec![Message::user("修好它"), Message::tool_result("t1", "shell", "ok")];
diff --git a/src/app.rs b/src/app.rs
index b955a6f..7ee57fd 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -4024,7 +4024,7 @@ fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_compu
做完 App 操作後,用一句話告訴使用者結果(例如排程的時間)。\n\n\
你也可以上網和用使用者的瀏覽器:\n\
- 公開網頁:web_fetch。\n\
- - 要登入才看得到的(Threads、Instagram、Facebook、Gmail、銀行…):用 browser_open 在 AnyBot 自己的瀏覽器視窗打開:它會等頁面載入,直接回傳頁面內容和可以點的編號(不用再 browser_read;之後頁面變了才用 browser_read 重讀)。例如看 Threads 帳號:browser_open(\"https://www.threads.net\")。\n\
+ - 要登入才看得到的(Threads、Instagram、Facebook、Gmail、銀行…):用 browser_open 在 AnyBot 自己的瀏覽器視窗打開:它會等頁面載入,直接回傳頁面內容和可以點的編號(不用再 browser_read;之後頁面變了才用 browser_read 重讀)。例如看 Threads 帳號:browser_open(\"https://www.threads.net\")。要知道某個網站有沒有登入,一定要打開那個網站看,不要看目前的分頁(常常是空白頁)就下結論。\n\
- 網頁上的事(任何電腦、任何網站)一律用 browser_* 工具:先 browser_read(或 browser_screenshot)拿到每個連結、按鈕、欄位的編號和網址,再用 browser_click/browser_type/browser_select/browser_press 照編號操作;要去的頁面有網址就直接 browser_open。網頁上絕對不要看截圖猜座標點。表格用 browser_table 讀,找字用 browser_find,要等載入用 browser_wait,匯出報表用 browser_download。不要猜網址;找不到想要的功能(例如排序、篩選),就看欄位標題、篩選器、下拉選單或分頁標籤,還是沒有就照頁面現有的資料自己整理,並說明網站沒有這個功能。
- 絕對不要直接呼叫網站的內部 API、下載網站的程式碼來研究,或用程式大量抓資料:網站會把帳號當成機器人(例如跳出拼圖驗證、封鎖帳號)。也絕對不要用指令去讀瀏覽器的 Cookie、瀏覽紀錄、密碼或設定檔資料庫。
- 需要使用者親手做的步驟(讀到登入頁、輸入密碼、驗證碼、拼圖、簡訊或 email 驗證、確認付款):先在那台電腦上把那一頁打開、點到要輸入的地方,再呼叫 hand_over_computer,instruction 寫清楚要他做什麼。聊天裡會出現那台電腦的即時畫面,他點「接管」做完後按「我完成了」,你再確認、接著做(登入一次之後就會記住)。不要只用文字叫他自己去找視窗,絕對不要替他打密碼,也不要叫他改瀏覽器的設定或選單、或說要從 Chrome 複製登入或 Cookie(都不需要,也沒有這個功能)。\n\
@@ -4090,6 +4090,8 @@ fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_compu
s.push_str(b.instructions.trim());
}
s.push_str("\n\n使用者要你追蹤、整理、比較、研究一件之後還會回來看的事(例如「幫我追蹤台灣 FSD 狀況」),就用 write_page 做成一頁:先寫結論和現況,再寫細節和來源(附日期),適合時用表格、```stats、```steps、圖表。回答時簡短說重點,並用 [頁面標題](page:ID) 連到它。要定期追蹤時,再用 create_routine 設例行任務,每次執行:read_page、查新消息,有實質進展才用 write_page 更新(note 寫改了什麼)並在聊天裡說;沒有新消息就簡短回答「沒有新消息」,不要更新頁面。");
+ s.push_str("\n\n幫使用者寫貼文、留言或文案,要「用我的語氣」時:先打開他的帳號,讀最近幾則他自己寫的貼文,照那樣的長度、用詞、標點和開頭結尾寫,不要只看自介。給草稿就好,沒有他說「發」不要自己發出去。");
+ s.push_str("\n\n使用者要看某台電腦的畫面(截圖給我看、讓我看看)時,用 computer_use_show:畫面會直接出現在聊天裡。");
s.push_str("\n\n做好的檔案(試算表、文件、Markdown、網頁、圖片)用 send_file 傳進聊天,使用者點一下就能預覽;不要只說檔案放在哪裡。適合的時候可以用 react 對使用者的訊息按個表情(例如謝謝你時按 ❤️),不用每次都按。");
s.push_str("\n\n");
s.push_str(&crate::i18n::reply_instruction(&st.language));
diff --git a/src/attach.rs b/src/attach.rs
index 94d6d10..c7a5300 100644
--- a/src/attach.rs
+++ b/src/attach.rs
@@ -52,6 +52,18 @@ pub fn store(bot_id: u64, src: &Path) -> Result {
Ok(Attachment { image: is_image_name(&name), name, path: dst.display().to_string(), bytes: meta.len(), on_box: String::new() })
}
+/// 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 {
+ 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() })
+}
+
/// A picture on the clipboard (e.g. a screenshot copied with ⌃⇧⌘4) becomes an attachment.
pub fn paste_image(bot_id: u64) -> Option {
let dir = dir_for(bot_id);
diff --git a/src/botbrowser.rs b/src/botbrowser.rs
index 446c495..68c9861 100644
--- a/src/botbrowser.rs
+++ b/src/botbrowser.rs
@@ -407,6 +407,11 @@ pub fn read(max_chars: usize, profile: Option<&str>) -> Result {
if login_hint {
out.push_str("\n\n(這頁看起來要登入。請使用者在 AnyBot 瀏覽器視窗登入一次,之後就會記住。)");
}
+ // A blank tab says nothing about a site (a bot read one eleven times and called the person
+ // logged out of Threads without ever opening it).
+ if url.is_empty() || url == "about:blank" || url.starts_with("chrome://newtab") {
+ out.push_str("\n\n(這個分頁是空白的,不代表任何網站的狀態。要看哪個網站(例如有沒有登入),就用 browser_open 打開它。)");
+ }
Ok(out)
}
diff --git a/src/cloudbox.rs b/src/cloudbox.rs
index 59d9c94..99445f6 100644
--- a/src/cloudbox.rs
+++ b/src/cloudbox.rs
@@ -1793,6 +1793,7 @@ pub fn computer_run(name: &str, v: &Value) -> Result<(String, Option), S
match name {
"computer_use_check_permissions" => return Ok(("這台電腦不需要另外的權限:可以截圖,也可以操作滑鼠和鍵盤。".into(), None)),
"computer_use_screenshot" => return box_look(None, None, false),
+ "computer_use_show" => return box_show(),
_ => {}
}
if HELD_BY_PERSON.load(Ordering::SeqCst) || crate::vnc::person_active() {
@@ -1809,6 +1810,17 @@ pub fn computer_run(name: &str, v: &Value) -> Result<(String, Option), S
box_look(Some(&script), Some(note), crate::computer::is_pointer_step(name))
}
+/// The box's screen as the person would see it (no numbers), at the bot's picture size.
+fn box_show() -> Result<(String, Option), String> {
+ let out = dk_with_input(&["exec", "-i", "-u", "bot", "-e", "DISPLAY=:1", container(), "sh", "-c", "import -window root -quality 90 jpeg:- 2>/dev/null"], Some(b""), 30_000).map_err(|e| format!("拍不到畫面:{e}"))?;
+ let img = image::load_from_memory(&out).map_err(|e| format!("截圖讀不出來:{e}"))?.to_rgb8();
+ let (w, h) = img.dimensions();
+ let (fw, fh, resize) = crate::computer::fit(w, h);
+ let pic = if resize { image::imageops::resize(&img, fw, fh, image::imageops::FilterType::Triangle) } else { img };
+ let jpeg = crate::computer::encode_jpeg(&pic).ok_or("截圖太大了,沒辦法送出")?;
+ Ok((format!("{}({fw}×{fh})", crate::computer::SHOWN_NOTE), Some(base64::engine::general_purpose::STANDARD.encode(&jpeg))))
+}
+
const BATCH_MARK: &str = "/tmp/anybot-batch.done";
/// Do a planned sequence of steps on the box in one round trip: the steps' scripts chained with
@@ -2098,6 +2110,19 @@ mod tests {
assert!(again.contains("畫面和上一步完全一樣"), "{again}");
}
+ /// computer_use_show: a plain picture of the box's screen (no numbers) comes back for the chat.
+ #[test]
+ #[ignore]
+ fn box_show_is_a_plain_picture() {
+ ensure_running().expect("box starts");
+ let (text, img) = computer_run("computer_use_show", &serde_json::json!({})).unwrap();
+ assert!(text.contains("放進聊天"), "{text}");
+ let jpeg = base64::engine::general_purpose::STANDARD.decode(img.expect("a picture")).unwrap();
+ let pic = image::load_from_memory(&jpeg).unwrap();
+ println!("{text} — {}×{}, {} KB", pic.width(), pic.height(), jpeg.len() / 1024);
+ assert_eq!((pic.width(), pic.height()), (1024, 640));
+ }
+
/// Numbers only on what can be seen: links scrolled up under the browser's toolbar get none (or
/// their visible part's centre), and nothing is numbered under a window that covers the page
/// (xterm has no accessibility; clicking a number there used to type into the terminal).
diff --git a/src/computer.rs b/src/computer.rs
index 41869c9..a9b1297 100644
--- a/src/computer.rs
+++ b/src/computer.rs
@@ -12,7 +12,7 @@ use base64::Engine;
use serde_json::{json, Value};
use std::sync::Mutex;
-pub const NAMES: [&str; 10] = ["computer_use_screenshot", "computer_use_click", "computer_use_typing", "computer_use_press_key", "computer_use_scroll", "computer_use_mouse_move", "computer_use_drag", "computer_use_check_permissions", "computer_use_open_app", "computer_use_batch"];
+pub const NAMES: [&str; 11] = ["computer_use_screenshot", "computer_use_show", "computer_use_click", "computer_use_typing", "computer_use_press_key", "computer_use_scroll", "computer_use_mouse_move", "computer_use_drag", "computer_use_check_permissions", "computer_use_open_app", "computer_use_batch"];
/// Wait after an action before taking the picture that comes back with it (menus open, pages
/// start to change; estimated). The box waits for the screen to stop changing instead, up to
/// about this long.
@@ -220,6 +220,9 @@ pub fn is_pointer_step(name: &str) -> bool {
matches!(name, "computer_use_click" | "computer_use_scroll" | "computer_use_drag")
}
+/// What the bot is told after computer_use_show (the picture itself goes into the chat).
+pub const SHOWN_NOTE: &str = "已把這台電腦現在的畫面(使用者看到的樣子,沒有編號)放進聊天,使用者看得到這張圖;你也看得到,可以用一兩句話說明畫面上有什麼";
+
pub fn is_computer_tool(name: &str) -> bool {
NAMES.contains(&name)
}
@@ -227,6 +230,7 @@ pub fn is_computer_tool(name: &str) -> bool {
pub fn label(name: &str) -> &'static str {
match name {
"computer_use_screenshot" => "看你的螢幕",
+ "computer_use_show" => "把畫面傳給你看",
"computer_use_click" => "點一下",
"computer_use_typing" => "打字",
"computer_use_press_key" => "按鍵",
@@ -251,6 +255,7 @@ pub fn describe(name: &str, args: &str) -> String {
};
match name {
"computer_use_screenshot" => "螢幕截圖".into(),
+ "computer_use_show" => "畫面傳進聊天".into(),
"computer_use_click" => {
let b = v["button"].as_str().unwrap_or("left");
let n = v["count"].as_u64().unwrap_or(1);
@@ -281,6 +286,8 @@ pub fn specs_for(computers: &[String], default: &str, chosen: bool) -> Vec Result<(String, Option), String>
}
match name {
"computer_use_screenshot" => return look(None, false),
+ "computer_use_show" => {
+ let (w, h, jpeg) = plain_picture()?;
+ return Ok((format!("{SHOWN_NOTE}({w}×{h})"), Some(base64::engine::general_purpose::STANDARD.encode(&jpeg))));
+ }
"computer_use_check_permissions" => {
#[cfg(target_os = "linux")]
return Ok((crate::desk::describe().into(), None));
diff --git a/src/desktop.rs b/src/desktop.rs
index ee32104..1dc6c8b 100644
--- a/src/desktop.rs
+++ b/src/desktop.rs
@@ -120,6 +120,19 @@ fn shorten(v: &str) -> String {
format!("{head} …(中間省略 {} 字)… {tail}", n - VALUE_HEAD - VALUE_TAIL)
}
+/// The first words inside a node (a row's label).
+fn words_inside(n: &Value) -> String {
+ n["children"].as_array().into_iter().flatten().find_map(|c| {
+ let w = c["name"].as_str().or_else(|| c["value"].as_str()).unwrap_or("").trim().to_string();
+ if w.is_empty() { Some(words_inside(c)).filter(|w| !w.is_empty()) } else { Some(w) }
+ }).unwrap_or_default()
+}
+
+/// Something inside this node is a control with a name.
+fn named_inside(n: &Value) -> bool {
+ n["children"].as_array().into_iter().flatten().any(|c| (c["ref_id"].is_string() && active(c["role"].as_str().unwrap_or("")) && !c["name"].as_str().unwrap_or("").trim().is_empty()) || named_inside(c))
+}
+
/// The controls and text of a snapshot, with where each is on the screen (nodes the app gave
/// no place are left out: they can't be shown or clicked).
pub fn elements_of(tree: &Value, app: &str) -> Vec {
@@ -128,14 +141,25 @@ pub fn elements_of(tree: &Value, app: &str) -> Vec {
return;
}
let role = n["role"].as_str().unwrap_or("");
- let name = n["name"].as_str().unwrap_or("").trim().to_string();
+ let mut name = n["name"].as_str().unwrap_or("").trim().to_string();
+ // A nameless row, cell or list item goes by the words inside it (an icon-only button
+ // keeps its number without a name: it can still be clicked).
+ let row_like = matches!(role, "row" | "cell" | "treeitem" | "outlinerow" | "listitem");
+ // …unless a named control inside it is what gets clicked (System Settings' sidebar).
+ let wraps_named = name.is_empty() && named_inside(n);
+ if name.is_empty() && row_like && !wraps_named {
+ name = words_inside(n);
+ }
let value = n["value"].as_str().map(str::to_string).or_else(|| n["value"].as_f64().map(|f| f.to_string())).unwrap_or_default();
let b = &n["bounds"];
if let (Some(x), Some(y), Some(w), Some(h)) = (b["x"].as_f64(), b["y"].as_f64(), b["width"].as_f64(), b["height"].as_f64()) {
let is_active = n["ref_id"].is_string() && active(role);
let has = |k: &str| n["states"].as_array().map(|a| a.iter().any(|x| x == k)).unwrap_or(false) || n[k] == true;
- // Hidden or scrolled out of view: nothing a click could reach.
- let unseen = has("hidden") || has("offscreen");
+ // Hidden or scrolled out of view: nothing a click could reach. A nameless row whose
+ // own button has the name (System Settings' sidebar) needs no number of its own: a
+ // list of 「項目」 told the bot nothing.
+ let empty = name.is_empty() && value.trim().is_empty();
+ let unseen = has("hidden") || has("offscreen") || (is_active && wraps_named) || (row_like && empty);
if !unseen && w >= 1.0 && h >= 1.0 && x + w > 0.0 && y + h > 0.0 && (is_active || !name.is_empty() || !value.trim().is_empty()) && role != "window" && role != "group" {
let mut flags = vec![];
for (k, word) in [("checked", "已勾"), ("selected", "已選"), ("disabled", "停用"), ("focused", "游標在這")] {
@@ -188,6 +212,15 @@ mod tests {
]});
let names: Vec = elements_of(&tree, "App").into_iter().map(|e| e.name).collect();
assert_eq!(names, vec!["Close".to_string()]);
+ // A nameless row around a named button: only the button is numbered.
+ let rows = serde_json::json!({"role": "window", "children": [
+ {"role": "row", "name": "", "ref_id": "r", "bounds": {"x": 0.0, "y": 100.0, "width": 200.0, "height": 30.0}, "children": [
+ {"role": "button", "name": "Wi-Fi", "ref_id": "w", "bounds": {"x": 10.0, "y": 105.0, "width": 80.0, "height": 20.0}}]},
+ {"role": "row", "name": "", "ref_id": "r2", "bounds": {"x": 0.0, "y": 140.0, "width": 200.0, "height": 30.0}}
+ ]});
+ let kept: Vec<(String, String)> = elements_of(&rows, "App").into_iter().map(|e| (e.kind, e.name)).collect();
+ assert_eq!(kept[0].1, "Wi-Fi");
+ assert_eq!(kept.len(), 1, "an empty spacer row gets no number: {kept:?}");
}
#[test]
diff --git a/src/remote.rs b/src/remote.rs
index 5878c5f..4a8aa30 100644
--- a/src/remote.rs
+++ b/src/remote.rs
@@ -298,7 +298,7 @@ pub fn message_json(m: &crate::model::Message, names: &HashMap, pag
"wake": m.wake,
"author": if m.author == 0 { String::new() } else { names.get(&m.author).cloned().unwrap_or_default() },
"reactions": m.reactions,
- "attachments": m.attachments.iter().map(|a| json!({"name": a.name, "image": a.image})).collect::>(),
+ "attachments": m.attachments.iter().map(|a| json!({"name": a.name, "image": a.image, "path": a.path})).collect::>(),
"tools": calls,
}))
}
@@ -466,6 +466,12 @@ pub fn handle(req: Req, via_relay: bool) -> Resp {
Err(e) => Resp::error(503, &e),
},
// A file shown in a chat (only what AnyBot keeps as attachments).
+ // A file in a chat (a picture the bot showed, a file it sent, one the person attached),
+ // for the phone to show or download. Only from the chats' own file folder.
+ ("GET", ["api", "file"]) => match chat_file(&req.query.get("path").cloned().unwrap_or_default()) {
+ Some((f, b)) => Resp::bytes(content_type(&f), b),
+ None => Resp::error(404, "找不到這個檔案"),
+ },
("GET", ["api", "sync", "file"]) => {
let want = std::path::PathBuf::from(req.query.get("path").cloned().unwrap_or_default());
let root = crate::model::State::path().parent().map(|p| p.join("attachments")).and_then(|p| p.canonicalize().ok());
@@ -521,11 +527,34 @@ pub fn role_allows(role: &str, method: &str, path: &[&str]) -> bool {
// 大腦: the owner's other AnyBots showing this one's bots.
"mirror" => sync,
"owner" | "admin" => !device && !sync,
- "member" => matches!((method, path), ("GET", ["api", "state"]) | ("GET", ["api", "bots", _]) | ("GET", ["api", "pages", ..]) | ("POST", ["api", "bots", _, "send" | "read" | "upload"])),
+ "member" => matches!((method, path), ("GET", ["api", "state"]) | ("GET", ["api", "bots", _]) | ("GET", ["api", "pages", ..]) | ("GET", ["api", "file"]) | ("POST", ["api", "bots", _, "send" | "read" | "upload"])),
_ => false,
}
}
+/// A chat file by its path, only when it really is inside the chats' file folder.
+fn chat_file(want: &str) -> Option<(std::path::PathBuf, Vec)> {
+ let root = crate::model::State::path().parent()?.join("attachments").canonicalize().ok()?;
+ let f = std::path::PathBuf::from(want).canonicalize().ok()?;
+ if !f.starts_with(&root) || !f.is_file() {
+ return None;
+ }
+ let b = std::fs::read(&f).ok()?;
+ Some((f, b))
+}
+
+fn content_type(f: &std::path::Path) -> &'static str {
+ match f.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref() {
+ Some("jpg" | "jpeg") => "image/jpeg",
+ Some("png") => "image/png",
+ Some("gif") => "image/gif",
+ Some("webp") => "image/webp",
+ Some("pdf") => "application/pdf",
+ Some("txt" | "md" | "csv") => "text/plain; charset=utf-8",
+ _ => "application/octet-stream",
+ }
+}
+
fn login(req: &Req) -> Resp {
// One counter per source address, cleaned as it is touched.
let ip = req.peer.split(':').next().unwrap_or(&req.peer).to_string();
@@ -659,6 +688,10 @@ fn device_run(v: &Value, s: &Settings, via_relay: bool, from: &str) -> Resp {
let (text, ok) = crate::macapps::run(tool, args);
return Resp::json(&json!({"text": text, "ok": ok}));
}
+ // send_file from this computer: the file itself goes to the brain, which puts it in the chat.
+ if tool == FETCH_FILE {
+ return fetch_file_here(args);
+ }
if matches!(tool, "shell" | "read_file" | "edit_file" | "list_dir" | "search" | "read_terminal" | "write_terminal") {
// The brain passes the bot's work folder inside the tool's args (empty = this home).
let dir = crate::tools::args_of(args).get("workdir").and_then(Value::as_str).unwrap_or("").trim().to_string();
@@ -674,6 +707,36 @@ fn device_run(v: &Value, s: &Settings, via_relay: bool, from: &str) -> Resp {
Resp::error(400, "這個工具不能遠端使用")
}
+/// A file on this computer, for the brain's send_file (the brain has no other way to it).
+pub const FETCH_FILE: &str = "fetch_file";
+/// Largest file sent that way: the relay carries 30 MB, and base64 adds a third.
+const FETCH_FILE_MAX: u64 = 20 * 1024 * 1024;
+
+fn fetch_file_here(args: &str) -> Resp {
+ let v = crate::tools::args_of(args);
+ let path = v["path"].as_str().unwrap_or("").trim().to_string();
+ let dir = v["workdir"].as_str().unwrap_or("").trim().to_string();
+ let base = crate::tools::expand_home(if dir.is_empty() { "~" } else { &dir });
+ let full = crate::tools::resolve(&base, &path);
+ // Same rule as reading a file: keys, the app's own data and other secrets never leave.
+ if crate::tools::is_ignored(&full) || crate::tools::is_ignored(std::path::Path::new(&path)) {
+ return Resp::json(&json!({"text": "這個檔案是機密的,不能傳到聊天。", "ok": false}));
+ }
+ match std::fs::metadata(&full) {
+ Ok(m) if m.is_dir() => Resp::json(&json!({"text": format!("「{path}」是資料夾,請先壓縮成一個檔案。"), "ok": false})),
+ Ok(m) if m.len() > FETCH_FILE_MAX => Resp::json(&json!({"text": format!("「{path}」太大了(最多 20 MB)。"), "ok": false})),
+ Ok(_) => match std::fs::read(&full) {
+ Ok(b) => {
+ use base64::Engine;
+ let name = full.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "檔案".into());
+ Resp::json(&json!({"ok": true, "text": "", "name": name, "data": base64::engine::general_purpose::STANDARD.encode(&b)}))
+ }
+ Err(e) => Resp::json(&json!({"text": format!("讀不到「{path}」:{e}"), "ok": false})),
+ },
+ Err(_) => Resp::json(&json!({"text": format!("這台電腦上找不到「{path}」。"), "ok": false})),
+ }
+}
+
// ---------- a model on the person's network, for a brain on a server ----------
// A brain in the cloud can't reach a model at a private address (a Qwen server at work, Ollama at
// home). Its hands can: the brain hands them the request and gets the whole answer back.