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
This commit is contained in:
parent
a202a96c02
commit
e8e5e6fba1
|
|
@ -91,7 +91,8 @@ header .sub{font-size:12px;color:var(--text3);font-weight:400}
|
||||||
.ic{vertical-align:-2px;flex:none}
|
.ic{vertical-align:-2px;flex:none}
|
||||||
.tool.waiting .st{color:var(--accent)}.tool.failed .st{color:var(--danger)}
|
.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}
|
.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{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 .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)}
|
.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 busy = (S.bots.find((b) => b.id === S.open) || {}).busy;
|
||||||
const html = S.msgs.map((m, i) => {
|
const html = S.msgs.map((m, i) => {
|
||||||
if (m.role === "user") {
|
if (m.role === "user") {
|
||||||
const atts = (m.attachments || []).map((a) => `<div class="att">${ic(a.image ? "image" : "file", 14)} ${esc(a.name)}</div>`).join("");
|
const atts = (m.attachments || []).map(attHtml).join("");
|
||||||
if (m.summary) return "";
|
if (m.summary) return "";
|
||||||
return `<div class="m user">${m.wake ? `<div class="via">${ic("alarm-clock", 13)} 例行任務</div>` : ""}${m.text ? `<div class="bubble">${esc(m.text)}</div>` : ""}${atts}</div>`;
|
return `<div class="m user">${m.wake ? `<div class="via">${ic("alarm-clock", 13)} 例行任務</div>` : ""}${m.text ? `<div class="bubble">${esc(m.text)}</div>` : ""}${atts}</div>`;
|
||||||
}
|
}
|
||||||
|
|
@ -551,12 +552,14 @@ function renderMsgs() {
|
||||||
const body = m.text ? `<div class="bubble">${md(m.text)}</div>` : (last && busy && !tools ? `<div class="typing"><i></i><i></i><i></i></div>` : "");
|
const body = m.text ? `<div class="bubble">${md(m.text)}</div>` : (last && busy && !tools ? `<div class="typing"><i></i><i></i><i></i></div>` : "");
|
||||||
const who = m.author ? `<div class="who">${esc(m.author)}</div>` : "";
|
const who = m.author ? `<div class="who">${esc(m.author)}</div>` : "";
|
||||||
const via = m.text && m.via && !busy ? `<div class="via">${esc(m.via)}${(m.reactions || []).length ? " · " + m.reactions.join(" ") : ""}</div>` : "";
|
const via = m.text && m.via && !busy ? `<div class="via">${esc(m.via)}${(m.reactions || []).length ? " · " + m.reactions.join(" ") : ""}</div>` : "";
|
||||||
return `<div class="m bot ${m.error ? "err" : ""}">${who}${body}${tools}${last ? "" : ""}${via}</div>`;
|
const atts = (m.attachments || []).map(attHtml).join("");
|
||||||
|
return `<div class="m bot ${m.error ? "err" : ""}">${who}${body}${tools}${atts}${last ? "" : ""}${via}</div>`;
|
||||||
}).join("");
|
}).join("");
|
||||||
$("msgs").innerHTML = html || `<div class="empty">還沒有訊息,說聲嗨吧</div>`;
|
$("msgs").innerHTML = html || `<div class="empty">還沒有訊息,說聲嗨吧</div>`;
|
||||||
$("b-send").innerHTML = busy && !$("input").value.trim() && !S.pending.length
|
$("b-send").innerHTML = busy && !$("input").value.trim() && !S.pending.length
|
||||||
? `<svg viewBox="0 0 24 24" fill="currentColor"><rect x="7" y="7" width="10" height="10" rx="2"/></svg>`
|
? `<svg viewBox="0 0 24 24" fill="currentColor"><rect x="7" y="7" width="10" height="10" rx="2"/></svg>`
|
||||||
: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M12 19V5M6 11l6-6 6 6"/></svg>`;
|
: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M12 19V5M6 11l6-6 6 6"/></svg>`;
|
||||||
|
loadFiles();
|
||||||
}
|
}
|
||||||
function card(c) {
|
function card(c) {
|
||||||
if (!c) return "";
|
if (!c) return "";
|
||||||
|
|
@ -597,6 +600,36 @@ async function openPage(id, rev) {
|
||||||
}
|
}
|
||||||
$("pver").onchange = () => openPage(S.page, +$("pver").value || 0);
|
$("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(); };
|
$("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 <img src> can't send it), kept for the next redraw.
|
||||||
|
const FILES = {};
|
||||||
|
function attHtml(a) {
|
||||||
|
if (!a.path) return `<div class="att">${ic(a.image ? "image" : "file", 14)} ${esc(a.name)}</div>`;
|
||||||
|
if (a.image) return `<div class="attimg" data-file="${esc(a.path)}">${FILES[a.path] ? `<img alt="${esc(a.name)}" src="${FILES[a.path]}">` : `<div class="wait">${ic("image", 14)} ${esc(a.name)}</div>`}</div>`;
|
||||||
|
return `<a class="att" data-file="${esc(a.path)}" data-name="${esc(a.name)}">${ic("file", 14)} ${esc(a.name)}</a>`;
|
||||||
|
}
|
||||||
|
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 = `<img alt="" src="${u}">`; } catch (_) { el.innerHTML = `<div class="wait">這張圖讀不到</div>`; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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;
|
const dark = () => matchMedia("(prefers-color-scheme: dark)").matches;
|
||||||
async function loadCharts() {
|
async function loadCharts() {
|
||||||
for (const el of document.querySelectorAll(".chart[data-src]")) {
|
for (const el of document.querySelectorAll(".chart[data-src]")) {
|
||||||
|
|
|
||||||
102
src/agent.rs
102
src/agent.rs
|
|
@ -2,6 +2,7 @@
|
||||||
// rules say so), give it the results, and repeat until it answers in plain words.
|
// 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.
|
// 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::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::mpsc::{channel, Sender};
|
use std::sync::mpsc::{channel, Sender};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
@ -279,7 +280,7 @@ fn element_named(history: &[Message], r: &str) -> Option<String> {
|
||||||
/// counted: doing those again and again is how a long page or a list is read.
|
/// counted: doing those again and again is how a long page or a list is read.
|
||||||
fn step_key(name: &str, args: &str) -> Option<String> {
|
fn step_key(name: &str, args: &str) -> Option<String> {
|
||||||
if !(computer::is_computer_tool(name) || web::is_web_tool(name))
|
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;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
@ -313,6 +314,37 @@ fn refresh_own(job: &mut Job, bot_id: u64) -> Option<String> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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::<Vec<_>>().join("\n") };
|
||||||
|
let mut first: Option<String> = 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).
|
/// "Always allow" for this turn: the grant is kept (once).
|
||||||
fn keep_grant(grants: &mut Vec<String>, g: String) {
|
fn keep_grant(grants: &mut Vec<String>, g: String) {
|
||||||
if !g.is_empty() && !grants.contains(&g) {
|
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();
|
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
|
// Every step ends the same way: its status, then its result (with the notes) and
|
||||||
// any picture, shown and kept.
|
// any picture, shown and kept.
|
||||||
let finish = |history: &mut Vec<Message>, output: &str, status: ToolStatus, image: Option<String>| {
|
let finish = |history: &mut Vec<Message>, output: &str, status: ToolStatus, image: Option<String>| {
|
||||||
|
|
@ -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::Sealed { root }, .. }) => Ok(tools::resolve(&root, &path)),
|
||||||
Ok(tools::Target { place: tools::Place::Local { workdir }, .. }) => Ok(tools::resolve(&workdir, &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)),
|
Ok(_) => Err(format!("這台電腦上的檔案不能直接傳,請先複製到「{}」。", job.roles.own)),
|
||||||
Err(e) => Err(e),
|
Err(e) => Err(e),
|
||||||
};
|
};
|
||||||
// Secrets (SSH keys, the app's own data folder, .env…) never ride into the chat,
|
// Secrets (SSH keys, the app's own data folder, .env…) never ride into the chat,
|
||||||
// same rule as read_file/edit_file.
|
// 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 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) => {
|
Ok(a) => {
|
||||||
let out = format!("已把「{}」傳到聊天裡({}),使用者點一下就能預覽。", a.name, crate::attach::human(a.bytes));
|
let out = format!("已把「{}」傳到聊天裡({}),使用者點一下就能預覽。", a.name, crate::attach::human(a.bytes));
|
||||||
send(StreamEvent::Attach(a));
|
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,
|
(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.
|
// 裝置連結: another of the person's computers; seeing and operating it ask once each.
|
||||||
(Ok(t), Ok(())) if is_screen && on_device.is_some() => {
|
(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) {
|
if grants.contains(&grant) {
|
||||||
Decision::Allow
|
Decision::Allow
|
||||||
} else {
|
} 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 => {
|
(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") {
|
if grants.iter().any(|g| g == "computer:see") {
|
||||||
Decision::Allow
|
Decision::Allow
|
||||||
} else {
|
} 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),
|
(Ok(()), Err(e)) => (e.clone(), ToolStatus::Failed),
|
||||||
(Err(x), _) => x,
|
(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);
|
finish(&mut history, &output, status, image);
|
||||||
if cancel.load(Ordering::Relaxed) {
|
if cancel.load(Ordering::Relaxed) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -1227,6 +1300,25 @@ mod tests {
|
||||||
assert!(!mentions("在 macOS 的登入機制下,幫 Bot 的電腦截圖", &mac, &roles));
|
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]
|
#[test]
|
||||||
fn long_turns_check_in_instead_of_stopping() {
|
fn long_turns_check_in_instead_of_stopping() {
|
||||||
let mut h = vec![Message::user("修好它"), Message::tool_result("t1", "shell", "ok")];
|
let mut h = vec![Message::user("修好它"), Message::tool_result("t1", "shell", "ok")];
|
||||||
|
|
|
||||||
|
|
@ -4024,7 +4024,7 @@ fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_compu
|
||||||
做完 App 操作後,用一句話告訴使用者結果(例如排程的時間)。\n\n\
|
做完 App 操作後,用一句話告訴使用者結果(例如排程的時間)。\n\n\
|
||||||
你也可以上網和用使用者的瀏覽器:\n\
|
你也可以上網和用使用者的瀏覽器:\n\
|
||||||
- 公開網頁:web_fetch。\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。不要猜網址;找不到想要的功能(例如排序、篩選),就看欄位標題、篩選器、下拉選單或分頁標籤,還是沒有就照頁面現有的資料自己整理,並說明網站沒有這個功能。
|
- 網頁上的事(任何電腦、任何網站)一律用 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、瀏覽紀錄、密碼或設定檔資料庫。
|
- 絕對不要直接呼叫網站的內部 API、下載網站的程式碼來研究,或用程式大量抓資料:網站會把帳號當成機器人(例如跳出拼圖驗證、封鎖帳號)。也絕對不要用指令去讀瀏覽器的 Cookie、瀏覽紀錄、密碼或設定檔資料庫。
|
||||||
- 需要使用者親手做的步驟(讀到登入頁、輸入密碼、驗證碼、拼圖、簡訊或 email 驗證、確認付款):先在那台電腦上把那一頁打開、點到要輸入的地方,再呼叫 hand_over_computer,instruction 寫清楚要他做什麼。聊天裡會出現那台電腦的即時畫面,他點「接管」做完後按「我完成了」,你再確認、接著做(登入一次之後就會記住)。不要只用文字叫他自己去找視窗,絕對不要替他打密碼,也不要叫他改瀏覽器的設定或選單、或說要從 Chrome 複製登入或 Cookie(都不需要,也沒有這個功能)。\n\
|
- 需要使用者親手做的步驟(讀到登入頁、輸入密碼、驗證碼、拼圖、簡訊或 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(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使用者要你追蹤、整理、比較、研究一件之後還會回來看的事(例如「幫我追蹤台灣 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做好的檔案(試算表、文件、Markdown、網頁、圖片)用 send_file 傳進聊天,使用者點一下就能預覽;不要只說檔案放在哪裡。適合的時候可以用 react 對使用者的訊息按個表情(例如謝謝你時按 ❤️),不用每次都按。");
|
||||||
s.push_str("\n\n");
|
s.push_str("\n\n");
|
||||||
s.push_str(&crate::i18n::reply_instruction(&st.language));
|
s.push_str(&crate::i18n::reply_instruction(&st.language));
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,18 @@ pub fn store(bot_id: u64, src: &Path) -> Result<Attachment, String> {
|
||||||
Ok(Attachment { image: is_image_name(&name), name, path: dst.display().to_string(), bytes: meta.len(), on_box: String::new() })
|
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<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() })
|
||||||
|
}
|
||||||
|
|
||||||
/// A picture on the clipboard (e.g. a screenshot copied with ⌃⇧⌘4) becomes an attachment.
|
/// A picture on the clipboard (e.g. a screenshot copied with ⌃⇧⌘4) becomes an attachment.
|
||||||
pub fn paste_image(bot_id: u64) -> Option<Attachment> {
|
pub fn paste_image(bot_id: u64) -> Option<Attachment> {
|
||||||
let dir = dir_for(bot_id);
|
let dir = dir_for(bot_id);
|
||||||
|
|
|
||||||
|
|
@ -407,6 +407,11 @@ pub fn read(max_chars: usize, profile: Option<&str>) -> Result<String, String> {
|
||||||
if login_hint {
|
if login_hint {
|
||||||
out.push_str("\n\n(這頁看起來要登入。請使用者在 AnyBot 瀏覽器視窗登入一次,之後就會記住。)");
|
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)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1793,6 +1793,7 @@ pub fn computer_run(name: &str, v: &Value) -> Result<(String, Option<String>), S
|
||||||
match name {
|
match name {
|
||||||
"computer_use_check_permissions" => return Ok(("這台電腦不需要另外的權限:可以截圖,也可以操作滑鼠和鍵盤。".into(), None)),
|
"computer_use_check_permissions" => return Ok(("這台電腦不需要另外的權限:可以截圖,也可以操作滑鼠和鍵盤。".into(), None)),
|
||||||
"computer_use_screenshot" => return box_look(None, None, false),
|
"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() {
|
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<String>), S
|
||||||
box_look(Some(&script), Some(note), crate::computer::is_pointer_step(name))
|
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>), 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";
|
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
|
/// 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}");
|
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
|
/// 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
|
/// 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).
|
/// (xterm has no accessibility; clicking a number there used to type into the terminal).
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ use base64::Engine;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::sync::Mutex;
|
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
|
/// 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
|
/// start to change; estimated). The box waits for the screen to stop changing instead, up to
|
||||||
/// about this long.
|
/// 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")
|
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 {
|
pub fn is_computer_tool(name: &str) -> bool {
|
||||||
NAMES.contains(&name)
|
NAMES.contains(&name)
|
||||||
}
|
}
|
||||||
|
|
@ -227,6 +230,7 @@ pub fn is_computer_tool(name: &str) -> bool {
|
||||||
pub fn label(name: &str) -> &'static str {
|
pub fn label(name: &str) -> &'static str {
|
||||||
match name {
|
match name {
|
||||||
"computer_use_screenshot" => "看你的螢幕",
|
"computer_use_screenshot" => "看你的螢幕",
|
||||||
|
"computer_use_show" => "把畫面傳給你看",
|
||||||
"computer_use_click" => "點一下",
|
"computer_use_click" => "點一下",
|
||||||
"computer_use_typing" => "打字",
|
"computer_use_typing" => "打字",
|
||||||
"computer_use_press_key" => "按鍵",
|
"computer_use_press_key" => "按鍵",
|
||||||
|
|
@ -251,6 +255,7 @@ pub fn describe(name: &str, args: &str) -> String {
|
||||||
};
|
};
|
||||||
match name {
|
match name {
|
||||||
"computer_use_screenshot" => "螢幕截圖".into(),
|
"computer_use_screenshot" => "螢幕截圖".into(),
|
||||||
|
"computer_use_show" => "畫面傳進聊天".into(),
|
||||||
"computer_use_click" => {
|
"computer_use_click" => {
|
||||||
let b = v["button"].as_str().unwrap_or("left");
|
let b = v["button"].as_str().unwrap_or("left");
|
||||||
let n = v["count"].as_u64().unwrap_or(1);
|
let n = v["count"].as_u64().unwrap_or(1);
|
||||||
|
|
@ -281,6 +286,8 @@ pub fn specs_for(computers: &[String], default: &str, chosen: bool) -> Vec<Value
|
||||||
let mut v = vec![
|
let mut v = vec![
|
||||||
json!({"name": "computer_use_screenshot", "description": "See a computer's screen: a picture with every button, link, field and menu item boxed and numbered, and the same numbers in a list with their names. Act on a control by its number (ref). Every action below already returns the next picture, so call this only to start or to look again.",
|
json!({"name": "computer_use_screenshot", "description": "See a computer's screen: a picture with every button, link, field and menu item boxed and numbered, and the same numbers in a list with their names. Act on a control by its number (ref). Every action below already returns the next picture, so call this only to start or to look again.",
|
||||||
"parameters": {"type": "object", "properties": {}}}),
|
"parameters": {"type": "object", "properties": {}}}),
|
||||||
|
json!({"name": "computer_use_show", "description": "Show the person a computer's screen: a picture of it as they would see it (no numbers) goes into the chat for them, and you see it too. Use it when they ask to see a screen (截圖給我看、讓我看看畫面). One call, no file needed.",
|
||||||
|
"parameters": {"type": "object", "properties": {}}}),
|
||||||
json!({"name": "computer_use_click", "description": "Click a control by its number (ref) from the latest picture. Only for something without a number (a spot in a picture or game): x, y in the picture's pixels. Returns the next picture.",
|
json!({"name": "computer_use_click", "description": "Click a control by its number (ref) from the latest picture. Only for something without a number (a spot in a picture or game): x, y in the picture's pixels. Returns the next picture.",
|
||||||
"parameters": {"type": "object", "properties": {"ref": rf, "x": {"type": "number"}, "y": {"type": "number"}, "button": {"type": "string", "enum": ["left", "right", "middle"]}, "count": {"type": "integer", "description": "2 for a double click"}, "modifier_keys": mods}}}),
|
"parameters": {"type": "object", "properties": {"ref": rf, "x": {"type": "number"}, "y": {"type": "number"}, "button": {"type": "string", "enum": ["left", "right", "middle"]}, "count": {"type": "integer", "description": "2 for a double click"}, "modifier_keys": mods}}}),
|
||||||
json!({"name": "computer_use_typing", "description": "Type text (any language) into a field by its number (ref; it is clicked first), or where the cursor is. submit=true presses return after it (send, search, run a command). Returns the next picture.",
|
json!({"name": "computer_use_typing", "description": "Type text (any language) into a field by its number (ref; it is clicked first), or where the cursor is. submit=true presses return after it (send, search, run a command). Returns the next picture.",
|
||||||
|
|
@ -866,6 +873,10 @@ fn run_inner(name: &str, v: &Value) -> Result<(String, Option<String>), String>
|
||||||
}
|
}
|
||||||
match name {
|
match name {
|
||||||
"computer_use_screenshot" => return look(None, false),
|
"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" => {
|
"computer_use_check_permissions" => {
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
return Ok((crate::desk::describe().into(), None));
|
return Ok((crate::desk::describe().into(), None));
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,19 @@ fn shorten(v: &str) -> String {
|
||||||
format!("{head} …(中間省略 {} 字)… {tail}", n - VALUE_HEAD - VALUE_TAIL)
|
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
|
/// 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).
|
/// no place are left out: they can't be shown or clicked).
|
||||||
pub fn elements_of(tree: &Value, app: &str) -> Vec<crate::marks::Element> {
|
pub fn elements_of(tree: &Value, app: &str) -> Vec<crate::marks::Element> {
|
||||||
|
|
@ -128,14 +141,25 @@ pub fn elements_of(tree: &Value, app: &str) -> Vec<crate::marks::Element> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let role = n["role"].as_str().unwrap_or("");
|
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 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"];
|
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()) {
|
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 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;
|
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.
|
// Hidden or scrolled out of view: nothing a click could reach. A nameless row whose
|
||||||
let unseen = has("hidden") || has("offscreen");
|
// 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" {
|
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![];
|
let mut flags = vec![];
|
||||||
for (k, word) in [("checked", "已勾"), ("selected", "已選"), ("disabled", "停用"), ("focused", "游標在這")] {
|
for (k, word) in [("checked", "已勾"), ("selected", "已選"), ("disabled", "停用"), ("focused", "游標在這")] {
|
||||||
|
|
@ -188,6 +212,15 @@ mod tests {
|
||||||
]});
|
]});
|
||||||
let names: Vec<String> = elements_of(&tree, "App").into_iter().map(|e| e.name).collect();
|
let names: Vec<String> = elements_of(&tree, "App").into_iter().map(|e| e.name).collect();
|
||||||
assert_eq!(names, vec!["Close".to_string()]);
|
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]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -298,7 +298,7 @@ pub fn message_json(m: &crate::model::Message, names: &HashMap<u64, String>, pag
|
||||||
"wake": m.wake,
|
"wake": m.wake,
|
||||||
"author": if m.author == 0 { String::new() } else { names.get(&m.author).cloned().unwrap_or_default() },
|
"author": if m.author == 0 { String::new() } else { names.get(&m.author).cloned().unwrap_or_default() },
|
||||||
"reactions": m.reactions,
|
"reactions": m.reactions,
|
||||||
"attachments": m.attachments.iter().map(|a| json!({"name": a.name, "image": a.image})).collect::<Vec<_>>(),
|
"attachments": m.attachments.iter().map(|a| json!({"name": a.name, "image": a.image, "path": a.path})).collect::<Vec<_>>(),
|
||||||
"tools": calls,
|
"tools": calls,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
@ -466,6 +466,12 @@ pub fn handle(req: Req, via_relay: bool) -> Resp {
|
||||||
Err(e) => Resp::error(503, &e),
|
Err(e) => Resp::error(503, &e),
|
||||||
},
|
},
|
||||||
// A file shown in a chat (only what AnyBot keeps as attachments).
|
// 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"]) => {
|
("GET", ["api", "sync", "file"]) => {
|
||||||
let want = std::path::PathBuf::from(req.query.get("path").cloned().unwrap_or_default());
|
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());
|
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.
|
// 大腦: the owner's other AnyBots showing this one's bots.
|
||||||
"mirror" => sync,
|
"mirror" => sync,
|
||||||
"owner" | "admin" => !device && !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,
|
_ => 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<u8>)> {
|
||||||
|
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 {
|
fn login(req: &Req) -> Resp {
|
||||||
// One counter per source address, cleaned as it is touched.
|
// One counter per source address, cleaned as it is touched.
|
||||||
let ip = req.peer.split(':').next().unwrap_or(&req.peer).to_string();
|
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);
|
let (text, ok) = crate::macapps::run(tool, args);
|
||||||
return Resp::json(&json!({"text": text, "ok": ok}));
|
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") {
|
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).
|
// 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();
|
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, "這個工具不能遠端使用")
|
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 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
|
// 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.
|
// home). Its hands can: the brain hands them the request and gets the whole answer back.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue