需要處理: a bot hands a step to the person with its computer's live screen in the chat; the window fills to the top; the 電腦 card and viewer keep their text in place

- hand_over_computer (the original's box hand-off card): instruction, live screen with 接管,
  跳過 / 我完成了 / 接管; 我完成了 also in the viewer; a typed message answers it too. Header,
  sidebar and notifications say 等你處理 / 「{name}:需要你」; the phone shows it with 接管.
- Mac: the content reaches the top (no grey title bar); the top strip drags the window.
- Panel 電腦 card: each line stops before the picture; the brain's name on its own line.
- Viewer: buttons first, the title ends in … when narrow; the picture is centred.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
王性驊 2026-11-05 17:29:55 +08:00
parent c3e2e28ec4
commit 46c0684cb4
12 changed files with 393 additions and 42 deletions

View File

@ -647,8 +647,14 @@ function renderAsk() {
const a = S.approvals.find((x) => x.bot === S.open); const a = S.approvals.find((x) => x.bot === S.open);
if (!a) { $("ask").innerHTML = ""; return; } if (!a) { $("ask").innerHTML = ""; return; }
if (S.role === "member") { $("ask").innerHTML = `<div class="ask"><div class="q">${esc(a.title)}</div><div class="why">等擁有者或管理員決定</div></div>`; return; } if (S.role === "member") { $("ask").innerHTML = `<div class="ask"><div class="q">${esc(a.title)}</div><div class="why">等擁有者或管理員決定</div></div>`; return; }
// 需要處理 (a step only you can do on a computer): its screen is one tap away, then 我完成了 / 跳過.
if (a.kind === "handoff") {
$("ask").innerHTML = `<div class="ask"><div class="q">需要處理 · ${esc(a.computer || "")}</div><div class="why">${esc(a.reason)}</div><div class="btns"><button class="btn" data-screen="1">接管</button><button class="btn main" data-a="once">我完成了</button><button class="btn no" data-a="reject" title="取消此要求且不執行該步驟;Bot 會跳過它並繼續">跳過</button></div></div>`;
$("ask").querySelector("[data-screen]").onclick = () => { S.back = S.view; show("screen"); $("seg").children[0].textContent = ownComputer(); $("seg").children[1].style.display = S.mac ? "" : "none"; startFrames(); };
} else {
$("ask").innerHTML = `<div class="ask"><div class="q">${esc(a.title)}</div><div class="why">${esc(a.reason)}</div><div class="btns"><button class="btn main" data-a="once">允許一次</button>${a.can_always ? `<button class="btn" data-a="always">一律允許</button>` : ""}<button class="btn no" data-a="reject">拒絕</button></div></div>`; $("ask").innerHTML = `<div class="ask"><div class="q">${esc(a.title)}</div><div class="why">${esc(a.reason)}</div><div class="btns"><button class="btn main" data-a="once">允許一次</button>${a.can_always ? `<button class="btn" data-a="always">一律允許</button>` : ""}<button class="btn no" data-a="reject">拒絕</button></div></div>`;
$("ask").querySelectorAll("button").forEach((b) => b.onclick = async () => { }
$("ask").querySelectorAll("button[data-a]").forEach((b) => b.onclick = async () => {
$("ask").innerHTML = ""; $("ask").innerHTML = "";
try { await api(base() + `api/bots/${S.open}/answer`, {json: {answer: b.dataset.a}}); } catch (e) { toast(e.message); } try { await api(base() + `api/bots/${S.open}/answer`, {json: {answer: b.dataset.a}}); } catch (e) { toast(e.message); }
}); });

View File

@ -515,6 +515,46 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
send(StreamEvent::NeedApproval(Approval { call_id: call.id.clone(), tool: call.name.clone(), reason, grant, reply: atx, kind })); send(StreamEvent::NeedApproval(Approval { call_id: call.id.clone(), tool: call.name.clone(), reason, grant, reply: atx, kind }));
wait_answer(arx, &cancel) wait_answer(arx, &cancel)
}; };
// 需要處理 (the original's box hand-off): a step only the person can do — signing
// in, a captcha, a code from their phone. A card with the computer's live screen
// appears in the chat; they take over, do it, and say 「我完成了」 or 「跳過」. A
// message typed meanwhile answers it too.
if call.name == tools::HANDOFF_TOOL {
let instruction = tools::args_of(&call.args)["instruction"].as_str().unwrap_or("").trim().to_string();
let (output, status) = match screen_target(&job, &call.args) {
Err(e) => (e, ToolStatus::Failed),
Ok(_) if instruction.is_empty() => ("請在 instruction 寫清楚要使用者做什麼。".to_string(), ToolStatus::Failed),
Ok(t) => {
let (atx, arx) = channel();
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Waiting));
send(StreamEvent::NeedApproval(Approval { call_id: call.id.clone(), tool: call.name.clone(), reason: instruction.clone(), grant: t.name.clone(), reply: atx, kind: ApprovalKind::Handoff }));
let answer = loop {
if cancel.load(Ordering::Relaxed) {
return;
}
match arx.recv_timeout(Duration::from_millis(200)) {
Ok(a) => break Some(a),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
if !job.steer.lock().unwrap().is_empty() {
break None;
}
}
Err(_) => break Some(ApprovalAnswer::Reject),
}
};
match answer {
Some(ApprovalAnswer::Reject) => ("使用者選擇跳過這一步。不要再要求同一件事:沒有它就繼續其餘的工作,做不到的照實說。".to_string(), ToolStatus::Denied),
Some(_) => (format!("[{}] 使用者按了「我完成了」。先截圖確認畫面,再接著做。", t.name), ToolStatus::Done),
None => ("使用者沒有按按鈕,而是傳了訊息(就在下面)。照他說的做。".to_string(), ToolStatus::Done),
}
}
};
send(StreamEvent::ToolStatus(call.id.clone(), status));
let result = Message::tool_result(&call.id, &call.name, &noted(&moved, &call.id, &output));
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
}
// 生活卡片: drawn by the window from the call itself. // 生活卡片: drawn by the window from the call itself.
if call.name == "show_card" { if call.name == "show_card" {
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Done)); send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Done));
@ -1374,6 +1414,71 @@ mod tests {
assert!(result.contains("note.txt"), "{result}"); assert!(result.contains("note.txt"), "{result}");
} }
/// 需要處理: the model hands a step to the person; the card's request carries the instruction
/// and the computer, 「我完成了」 tells the bot to carry on, 「跳過」 that the step is skipped.
#[test]
fn a_hand_off_waits_for_the_person_and_says_what_they_chose() {
use std::io::{Read, Write};
use std::net::TcpListener;
for (answer, expect, status) in [(ApprovalAnswer::Once, "我完成了", ToolStatus::Done), (ApprovalAnswer::Reject, "跳過", ToolStatus::Denied)] {
let l = TcpListener::bind("127.0.0.1:0").unwrap();
let port = l.local_addr().unwrap().port();
std::thread::spawn(move || {
let replies = [
"data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"h1\",\"function\":{\"name\":\"hand_over_computer\",\"arguments\":\"{\\\"instruction\\\":\\\"請登入 Threads\\\"}\"}}]}}]}\n\ndata: [DONE]\n\n",
"data: {\"choices\":[{\"delta\":{\"content\":\"好\"}}]}\n\ndata: [DONE]\n\n",
];
for body in replies {
let (mut s, _) = l.accept().unwrap();
let mut req = Vec::new();
let mut buf = [0u8; 8192];
loop {
let n = s.read(&mut buf).unwrap_or(0);
if n == 0 {
break;
}
req.extend_from_slice(&buf[..n]);
let t = String::from_utf8_lossy(&req).to_string();
if let Some(end) = t.find("\r\n\r\n") {
let len = t[..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 _ = s.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n");
let _ = s.write_all(body.as_bytes());
}
});
let job = Job {
req: ChatRequest { kind: ProviderKind::Custom, base_url: format!("http://127.0.0.1:{port}/v1"), api_key: String::new(), model: "m".into(), system: String::new(), history: vec![Message::user("幫我登入 Threads")], tools: vec![], session: String::new() },
computers: vec![tools::Target { name: "王小明的Mac mini".into(), place: tools::Place::Device { host: "m".into() } }],
default_computer: "王小明的Mac mini".into(),
roles: tools::Roles { own: "Master 的電腦".into(), person: Some("王小明的Mac mini".into()), server: true },
..test_job()
};
let (tx, rx) = channel();
spawn(job, 1, tx, Arc::new(AtomicBool::new(false)), eframe::egui::Context::default());
let (mut asked, mut result, mut last) = (None, String::new(), None);
loop {
match rx.recv_timeout(Duration::from_secs(10)).expect("loop stalled") {
(_, StreamEvent::NeedApproval(a)) => {
asked = Some((a.kind, a.reason.clone(), a.grant.clone()));
let _ = a.reply.send(answer.clone());
}
(_, StreamEvent::ToolStatus(_, st)) => last = Some(st),
(_, StreamEvent::ToolResult(m)) => result = m.text,
(_, StreamEvent::Done) => break,
(_, StreamEvent::Error(e)) => panic!("{e}"),
_ => {}
}
}
assert_eq!(asked, Some((ApprovalKind::Handoff, "請登入 Threads".to_string(), "王小明的Mac mini".to_string())));
assert!(result.contains(expect), "{result}");
assert_eq!(last, Some(status));
}
}
/// 直接操作: tools off, this Mac off, a command that changes files — and still no question. /// 直接操作: tools off, this Mac off, a command that changes files — and still no question.
/// Dangerous commands stay blocked. /// Dangerous commands stay blocked.
#[test] #[test]

View File

@ -44,6 +44,9 @@ pub const SIDEBAR_DEFAULT_WIDTH: f32 = 280.0;
pub const SIDEBAR_MIN_WIDTH: f32 = 240.0; pub const SIDEBAR_MIN_WIDTH: f32 = 240.0;
pub const SIDEBAR_MAX_WIDTH: f32 = 400.0; pub const SIDEBAR_MAX_WIDTH: f32 = 400.0;
pub const SIDEBAR_RAIL_WIDTH: f32 = 88.0; pub const SIDEBAR_RAIL_WIDTH: f32 = 88.0;
/// Mac: the window has no title bar of its own (the content reaches the top); this strip at the
/// top moves the window, and the traffic lights sit in its left corner.
pub const TITLEBAR_H: f32 = 28.0;
pub const SIDEBAR_COLLAPSE_BELOW: f32 = 210.0; pub const SIDEBAR_COLLAPSE_BELOW: f32 = 210.0;
pub const SIDEBAR_EXPAND_FROM: f32 = 220.0; pub const SIDEBAR_EXPAND_FROM: f32 = 220.0;
/// `--sand-chat-min-width:424px` (index CSS, class sand-1fiktjy). /// `--sand-chat-min-width:424px` (index CSS, class sand-1fiktjy).
@ -491,6 +494,13 @@ impl App {
} }
} }
} }
// 需要處理: the bot waits for the person on its own computer (the hand-off card).
Ok("handoff") => {
app.state.selected = 1;
let (reply, _) = channel();
let own = app.state.bot(1).map(|b| crate::model::own_computer_name(&b.name)).unwrap_or_default();
app.approvals.insert(1, Approval { call_id: "h1".into(), tool: tools::HANDOFF_TOOL.into(), reason: "請在 Threads 登入頁輸入帳號和密碼後登入,好了按「我完成了」。".into(), grant: own, reply, kind: ApprovalKind::Handoff });
}
Ok("panel") => { Ok("panel") => {
// The real box, so the 電腦 card shows its screen. // The real box, so the 電腦 card shows its screen.
crate::cloudbox::start_background(); crate::cloudbox::start_background();
@ -550,6 +560,13 @@ impl App {
app.show_settings = true; app.show_settings = true;
app.settings_tab = SettingsTab::Remote; app.settings_tab = SettingsTab::Remote;
} }
// …or the side panel, or the computer viewer, as they look with that data.
if app.screenshot.is_some() && !app.demo {
match std::env::var("ANYBOT_SHOT_VIEW").as_deref() {
Ok("panel") => app.panel = Some(panel_ui::PanelTab::Activity),
_ => {}
}
}
app app
} }
@ -756,6 +773,13 @@ impl App {
if !screens.is_empty() { if !screens.is_empty() {
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));
// 需要處理: hand a step to the person on that screen (the original's box hand-off).
tool_specs.push(serde_json::json!({"name": tools::HANDOFF_TOOL,
"description": "Ask the person to do one step themselves on a computer's screen: signing in (you never type their passwords), a captcha or puzzle, a code sent to their phone or email, approving a payment, or anything else only they may do. First get the computer to that point (open the page, click into the sign-in form), then call this with a short instruction. A card with that computer's live screen appears in the chat; they take over, do it, and press 「我完成了」 (or 「跳過」). The result says which; after 「我完成了」 take a screenshot and carry on. Don't ask them in words to find a window themselves.",
"parameters": {"type": "object", "properties": {
"instruction": {"type": "string", "description": "What they should do, one or two short sentences in their language, e.g. 「請在 Threads 登入頁輸入帳號和密碼後登入」."},
"computer": {"type": "string", "enum": screens.clone(), "description": format!("Which computer's screen. Default: {default_screen}.")}},
"required": ["instruction"]}}));
} }
// 看圖小幫手: the person's models that may see pictures, for a bot whose model can't. // 看圖小幫手: the person's models that may see pictures, for a bot whose model can't.
providers::set_eyes(self.eyes()); providers::set_eyes(self.eyes());
@ -1293,6 +1317,7 @@ impl App {
} }
} }
ApprovalKind::EnableThisComputer => self.state.local_execution = true, ApprovalKind::EnableThisComputer => self.state.local_execution = true,
ApprovalKind::Handoff => {}
ApprovalKind::Action => { ApprovalKind::Action => {
if let Some(b) = self.state.bot_mut(bot_id) { if let Some(b) = self.state.bot_mut(bot_id) {
if !g.is_empty() && !b.allow_rules.contains(g) { if !g.is_empty() && !b.allow_rules.contains(g) {
@ -1428,6 +1453,10 @@ impl App {
if let Some(c) = b.messages.iter_mut().rev().flat_map(|m| m.tool_calls.iter_mut()).find(|c| c.id == call_id) { if let Some(c) = b.messages.iter_mut().rev().flat_map(|m| m.tool_calls.iter_mut()).find(|c| c.id == call_id) {
c.status = st; c.status = st;
} }
// The step moved on without the buttons (a message answered a hand-off).
if st != ToolStatus::Waiting && self.approvals.get(&id).map(|a| a.call_id == call_id).unwrap_or(false) {
self.approvals.remove(&id);
}
} }
other => { other => {
let Some(m) = b.messages.last_mut() else { continue }; let Some(m) = b.messages.last_mut() else { continue };
@ -1734,6 +1763,10 @@ impl App {
fn rail(&mut self, ui: &mut egui::Ui) { fn rail(&mut self, ui: &mut egui::Ui) {
let p = self.pal; let p = self.pal;
ui.vertical_centered(|ui| { ui.vertical_centered(|ui| {
// (The Mac's window buttons sit in the top corner.)
if cfg!(target_os = "macos") {
ui.add_space(TITLEBAR_H - 6.0);
}
if icon_button(ui, "◨", "展開側邊欄", p).clicked() { if icon_button(ui, "◨", "展開側邊欄", p).clicked() {
self.state.sidebar_collapsed = false; self.state.sidebar_collapsed = false;
self.mark(); self.mark();
@ -2020,9 +2053,11 @@ impl App {
self.voice_toggle(&ctx, bot.id); self.voice_toggle(&ctx, bot.id);
} }
} }
if self.approvals.contains_key(&bot.id) { if let Some(kind) = self.approvals.get(&bot.id).map(|a| a.kind) {
if crate::icons::pill(&mut right, crate::icons::Icon::Shield, &t("需要你允許"), p.accent, Color32::WHITE, 36.0).on_hover_text(t("打開請求視窗")).clicked() { let label = if kind == ApprovalKind::Handoff { t("等你處理") } else { t("需要你允許") };
if crate::icons::pill(&mut right, crate::icons::Icon::Shield, &label, p.accent, Color32::WHITE, 36.0).on_hover_text(t("打開請求視窗")).clicked() {
self.popup_hidden.remove(&bot.id); self.popup_hidden.remove(&bot.id);
self.stick_bottom = true;
} }
} }
let right_w = bar.right() - right.min_rect().left(); let right_w = bar.right() - right.min_rect().left();
@ -2096,11 +2131,12 @@ impl App {
ApprovalKind::EnableThisComputer => format!("允許 {} 在你的電腦上執行任務嗎?", bot.name), ApprovalKind::EnableThisComputer => format!("允許 {} 在你的電腦上執行任務嗎?", bot.name),
ApprovalKind::Action if machine.is_empty() => format!("允許 {} {}嗎?", bot.name, what), ApprovalKind::Action if machine.is_empty() => format!("允許 {} {}嗎?", bot.name, what),
ApprovalKind::Action => format!("允許 {} 在 {machine} 上{what}嗎?", bot.name), ApprovalKind::Action => format!("允許 {} 在 {machine} 上{what}嗎?", bot.name),
ApprovalKind::Handoff => format!("{}:需要你", bot.name),
}; };
let note = match kind { let note = match kind {
ApprovalKind::EnableTools => Some("這個 Bot 的電腦工具還沒打開。它想做的事:"), ApprovalKind::EnableTools => Some("這個 Bot 的電腦工具還沒打開。它想做的事:"),
ApprovalKind::EnableThisComputer => Some("「在這台電腦上執行」還沒打開,Bot 平常只在它自己的電腦上做事。它想在你的電腦上:"), ApprovalKind::EnableThisComputer => Some("「在這台電腦上執行」還沒打開,Bot 平常只在它自己的電腦上做事。它想在你的電腦上:"),
ApprovalKind::Action => None, ApprovalKind::Action | ApprovalKind::Handoff => None,
}; };
let mut answer = None; let mut answer = None;
ui.label(RichText::new(title).strong().size(if big { 17.0 } else { 14.0 })); ui.label(RichText::new(title).strong().size(if big { 17.0 } else { 14.0 }));
@ -2128,7 +2164,7 @@ impl App {
ApprovalKind::EnableTools => Some("一律允許這個 Bot 使用電腦".to_string()), ApprovalKind::EnableTools => Some("一律允許這個 Bot 使用電腦".to_string()),
ApprovalKind::EnableThisComputer => Some("一律允許(打開「在這台電腦上執行」)".to_string()), ApprovalKind::EnableThisComputer => Some("一律允許(打開「在這台電腦上執行」)".to_string()),
ApprovalKind::Action if !grant.is_empty() => Some(format!("一律允許{}", grant_label(&grant))), ApprovalKind::Action if !grant.is_empty() => Some(format!("一律允許{}", grant_label(&grant))),
ApprovalKind::Action => None, ApprovalKind::Action | ApprovalKind::Handoff => None,
}; };
if let Some(label) = always { if let Some(label) = always {
if ui.button(label).clicked() { if ui.button(label).clicked() {
@ -2153,7 +2189,9 @@ impl App {
fn approval_card(&mut self, ui: &mut egui::Ui, ctx: &egui::Context, bot: &Bot) { fn approval_card(&mut self, ui: &mut egui::Ui, ctx: &egui::Context, bot: &Bot) {
let p = self.pal; let p = self.pal;
if !self.approvals.contains_key(&bot.id) { let Some(kind) = self.approvals.get(&bot.id).map(|a| a.kind) else { return };
if kind == ApprovalKind::Handoff {
self.handoff_card(ui, ctx, bot);
return; return;
} }
let mut answer = None; let mut answer = None;
@ -2173,6 +2211,91 @@ impl App {
} }
} }
/// 需要處理 (the original's box hand-off card): what the bot needs the person to do, the
/// computer's live screen with 「接管」 on it, and 跳過 / 我完成了 / 接管.
fn handoff_card(&mut self, ui: &mut egui::Ui, ctx: &egui::Context, bot: &Bot) {
let p = self.pal;
let Some(a) = self.approvals.get(&bot.id) else { return };
let (instruction, computer) = (a.reason.clone(), a.grant.clone());
// Its own computer shows here (on a screen of a brain, the brain's); another computer of
// the person's is the one in front of them.
let own = computer == crate::model::own_computer_name(&bot.name) || computer == crate::model::BOT_COMPUTER_NAME;
let shows = own && (self.mir.on || crate::cloudbox::available());
let thumb = if shows { self.box_thumbnail(ctx) } else { None };
let width = (ui.available_width() - 32.0 - 28.0).clamp(200.0, 560.0);
enum Act {
Skip,
Done,
TakeOver,
}
let mut act = None;
ui.horizontal_top(|ui| {
ui.add_space(32.0);
Frame::new().fill(p.elevated).stroke(Stroke::new(2.0, p.accent)).corner_radius(14).inner_margin(Margin::symmetric(14, 12)).show(ui, |ui| {
ui.vertical(|ui| {
ui.set_width(width);
ui.horizontal(|ui| {
crate::icons::label(ui, crate::icons::Icon::Monitor, &t("電腦"), FONT_XS, p.text3);
ui.label(RichText::new(sidebar_ui::shorten_width(ui, &format!("· {computer}"), (ui.available_width() - 4.0).max(40.0), FONT_XS)).size(FONT_XS).color(p.text3));
});
ui.add_space(2.0);
ui.label(RichText::new(t("需要處理")).strong().size(15.0));
ui.add(egui::Label::new(RichText::new(&instruction).size(13.5).color(p.text2)).wrap());
ui.add_space(8.0);
// The screen, 16:10, with 「接管」 on it.
let sw = width.min(420.0);
let (rect, r) = ui.allocate_exact_size(vec2(sw, sw * 0.625), if shows { egui::Sense::click() } else { egui::Sense::hover() });
match thumb {
Some((tex, size)) => {
egui::Image::new((tex, size)).fit_to_exact_size(rect.size()).corner_radius(10).paint_at(ui, rect);
}
None => {
ui.painter().rect_filled(rect, 10, Color32::from_rgb(0x2b, 0x2f, 0x36));
let text = if shows { t("連結中") } else { t("請到「{name}」上操作").replace("{name}", &computer) };
ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, sidebar_ui::shorten_width(ui, &text, rect.width() - 20.0, FONT_BASE), egui::FontId::proportional(FONT_BASE), Color32::from_gray(220));
}
}
ui.painter().rect_stroke(rect, 10, Stroke::new(1.0, p.border), egui::StrokeKind::Inside);
if shows {
let label = t("接管");
let g = ui.painter().layout_no_wrap(label.clone(), egui::FontId::proportional(13.0), Color32::WHITE);
let pill = egui::Rect::from_min_size(rect.right_bottom() - vec2(g.size().x + 28.0, 38.0), vec2(g.size().x + 20.0, 28.0));
ui.painter().rect_filled(pill, 14, Color32::from_black_alpha(if r.hovered() { 200 } else { 160 }));
ui.painter().galley(pill.center() - g.size() / 2.0, g, Color32::WHITE);
if r.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(t("接管電腦")).clicked() {
act = Some(Act::TakeOver);
}
}
ui.add_space(10.0);
ui.horizontal(|ui| {
if ui.button(t("跳過")).on_hover_text(t("取消此要求且不執行該步驟;Bot 會跳過它並繼續")).clicked() {
act = Some(Act::Skip);
}
if ui.button(t("我完成了")).clicked() {
act = Some(Act::Done);
}
if shows && ui.add(egui::Button::new(RichText::new(t("接管")).color(p.on_primary)).fill(p.primary).corner_radius(8)).clicked() {
act = Some(Act::TakeOver);
}
});
});
});
});
ui.add_space(10.0);
match act {
Some(Act::Skip) => {
self.give_back_computer();
self.answer(ctx, bot.id, ApprovalAnswer::Reject);
}
Some(Act::Done) => {
self.give_back_computer();
self.answer(ctx, bot.id, ApprovalAnswer::Once);
}
Some(Act::TakeOver) => self.take_over_computer(),
None => {}
}
}
/// A new request: show it as a pop-up (even over another bot's chat) and get the person's /// A new request: show it as a pop-up (even over another bot's chat) and get the person's
/// attention (the Dock icon bounces when AnyBot isn't in front). /// attention (the Dock icon bounces when AnyBot isn't in front).
fn on_new_approval(&mut self, ctx: &egui::Context, bot_id: u64) { fn on_new_approval(&mut self, ctx: &egui::Context, bot_id: u64) {
@ -2183,10 +2306,12 @@ impl App {
if self.state.notify && self.away() { if self.state.notify && self.away() {
if let (Some(b), Some(a)) = (self.state.bot(bot_id), self.approvals.get(&bot_id)) { if let (Some(b), Some(a)) = (self.state.bot(bot_id), self.approvals.get(&bot_id)) {
let what = a.reason.split('\n').next().unwrap_or("").to_string(); let what = a.reason.split('\n').next().unwrap_or("").to_string();
// 「{callee} 需要你」 for a step only the person can do.
let title = if a.kind == ApprovalKind::Handoff { format!("{}:需要你", b.name) } else { format!("{} 需要你允許", b.name) };
if !self.headless { if !self.headless {
notify::banner(&format!("{} 需要你允許", b.name), &what); notify::banner(&title, &what);
} }
crate::remote::notify(&format!("{} 需要你允許", b.name), &what, bot_id); crate::remote::notify(&title, &what, bot_id);
} }
} }
} }
@ -2213,7 +2338,8 @@ impl App {
/// The pop-up: a dimmed window with the request in the middle, so it can't be missed. /// The pop-up: a dimmed window with the request in the middle, so it can't be missed.
fn approval_popup(&mut self, ctx: &egui::Context) { fn approval_popup(&mut self, ctx: &egui::Context) {
let selected = self.state.selected; let selected = self.state.selected;
let mut ids: Vec<u64> = self.approvals.keys().copied().filter(|id| !self.popup_hidden.contains(id)).collect(); // (A hand-off is the card in the chat, with the live screen, like the original's.)
let mut ids: Vec<u64> = self.approvals.iter().filter(|(id, a)| !self.popup_hidden.contains(id) && a.kind != ApprovalKind::Handoff).map(|(id, _)| *id).collect();
ids.sort_by_key(|id| (*id != selected, *id)); ids.sort_by_key(|id| (*id != selected, *id));
let Some(&bot_id) = ids.first() else { return }; let Some(&bot_id) = ids.first() else { return };
let Some(bot) = self.state.bot(bot_id).cloned() else { return }; let Some(bot) = self.state.bot(bot_id).cloned() else { return };
@ -3291,6 +3417,11 @@ impl App {
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(vec2(w, h))); ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(vec2(w, h)));
} }
} }
// Real data: the computer viewer opens a little after start, as a person would open it (a
// screen of a brain has said so by then, so it shows the brain's computer).
if self.frames == 10 && !self.demo && std::env::var("ANYBOT_SHOT_VIEW").as_deref() == Ok("viewer") {
self.open_viewer();
}
// ANYBOT_SHOT_PAGE=id: open that page late (a screen gets its pages from the brain first). // ANYBOT_SHOT_PAGE=id: open that page late (a screen gets its pages from the brain first).
if self.frames == need * 3 / 4 { if self.frames == need * 3 / 4 {
if let Some(id) = std::env::var("ANYBOT_SHOT_PAGE").ok().and_then(|v| v.parse().ok()) { if let Some(id) = std::env::var("ANYBOT_SHOT_PAGE").ok().and_then(|v| v.parse().ok()) {
@ -3605,6 +3736,20 @@ impl eframe::App for App {
if ctx.input(|i| i.modifiers.command && i.key_pressed(Key::Comma)) { if ctx.input(|i| i.modifiers.command && i.key_pressed(Key::Comma)) {
self.show_settings = true; self.show_settings = true;
} }
// Mac: the top strip moves the window like a title bar would (buttons drawn over it still
// take their clicks: they come later, so they are on top); a double click zooms.
if cfg!(target_os = "macos") {
let full = ui.max_rect();
let strip = egui::Rect::from_min_max(full.min, egui::pos2(full.max.x, full.min.y + TITLEBAR_H));
let r = ui.interact(strip, egui::Id::unique("titlebar-drag"), egui::Sense::click_and_drag());
if r.drag_started_by(egui::PointerButton::Primary) {
ctx.send_viewport_cmd(egui::ViewportCommand::StartDrag);
}
if r.double_clicked() {
let zoomed = ctx.input(|i| i.viewport().maximized.unwrap_or(false));
ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(!zoomed));
}
}
self.sidebar(ui); self.sidebar(ui);
self.main_pane(ui); self.main_pane(ui);
self.bot_editor(ctx); self.bot_editor(ctx);
@ -3886,8 +4031,9 @@ fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_compu
- 公開網頁:web_fetch。\n\ - 公開網頁:web_fetch。\n\
- 要登入才看得到的(Threads、Instagram、Facebook、Gmail、銀行…):用 browser_open 在 AnyBot 自己的瀏覽器視窗打開,再用 browser_read 讀分頁內容。例如看 Threads 帳號:browser_open(\"https://www.threads.net\") 然後 browser_read。\n\ - 要登入才看得到的(Threads、Instagram、Facebook、Gmail、銀行…):用 browser_open 在 AnyBot 自己的瀏覽器視窗打開,再用 browser_read 讀分頁內容。例如看 Threads 帳號:browser_open(\"https://www.threads.net\") 然後 browser_read。\n\
- 在網頁上做事(後台、報表、表單)要像人一樣操作:先 browser_screenshot 看一眼(圖上每個可點的東西都有編號),再用 browser_click/browser_type/browser_select/browser_press 照編號操作;表格用 browser_table 讀,找字用 browser_find,要等載入用 browser_wait,匯出報表用 browser_download。不要猜網址;找不到想要的功能(例如排序、篩選),就看欄位標題、篩選器、下拉選單或分頁標籤,還是沒有就照頁面現有的資料自己整理,並說明網站沒有這個功能。 - 在網頁上做事(後台、報表、表單)要像人一樣操作:先 browser_screenshot 看一眼(圖上每個可點的東西都有編號),再用 browser_click/browser_type/browser_select/browser_press 照編號操作;表格用 browser_table 讀,找字用 browser_find,要等載入用 browser_wait,匯出報表用 browser_download。不要猜網址;找不到想要的功能(例如排序、篩選),就看欄位標題、篩選器、下拉選單或分頁標籤,還是沒有就照頁面現有的資料自己整理,並說明網站沒有這個功能。
- 絕對不要直接呼叫網站的內部 API、下載網站的程式碼來研究,或用程式大量抓資料:網站會把帳號當成機器人(例如跳出拼圖驗證、封鎖帳號)。出現驗證碼、拼圖或簡訊驗證時,停下來請使用者在 AnyBot 瀏覽器視窗完成。 - 絕對不要直接呼叫網站的內部 API、下載網站的程式碼來研究,或用程式大量抓資料:網站會把帳號當成機器人(例如跳出拼圖驗證、封鎖帳號)。
- 如果讀到的是登入頁,請使用者在跳出來的 AnyBot 瀏覽器視窗登入一次(之後會記住),登入好再讀。絕對不要叫使用者去改瀏覽器的設定或選單(例如「允許來自 Apple 事件的 JavaScript」),那不需要。也不要說要從 Chrome 複製登入或 Cookie,沒有這個功能。\n\ - 需要使用者親手做的步驟(登入、輸入密碼、驗證碼、拼圖、簡訊或 email 驗證、確認付款):先在電腦上把那一頁打開、點到要輸入的地方,再呼叫 hand_over_computer,instruction 寫清楚要他做什麼。聊天裡會出現那台電腦的即時畫面,他點「接管」做完後按「我完成了」,你再截圖確認、接著做。不要只用文字叫他自己去找視窗;也絕對不要替他打密碼。
- 如果讀到的是登入頁:把登入頁開在那台電腦上,用 hand_over_computer 請使用者登入一次(之後會記住),他按「我完成了」再讀。絕對不要叫使用者去改瀏覽器的設定或選單(例如「允許來自 Apple 事件的 JavaScript」),那不需要。也不要說要從 Chrome 複製登入或 Cookie,沒有這個功能。\n\
- AnyBot 瀏覽器有設定檔(profile),每個有自己的登入。使用者說要換 profile/設定檔/另一個帳號時,用 browser_profiles:list 看有哪些,use 切換,add 新增(新的要登入一次)。也可以在 browser_open / browser_read 帶 profile 參數。使用者想用自己平常的 Chrome(自己的 Chrome 設定檔、已經登入的帳號)時,用 profile「你的瀏覽器」:請使用者在 Chrome 切到想要的設定檔,再 browser_read。\n\ - AnyBot 瀏覽器有設定檔(profile),每個有自己的登入。使用者說要換 profile/設定檔/另一個帳號時,用 browser_profiles:list 看有哪些,use 切換,add 新增(新的要登入一次)。也可以在 browser_open / browser_read 帶 profile 參數。使用者想用自己平常的 Chrome(自己的 Chrome 設定檔、已經登入的帳號)時,用 profile「你的瀏覽器」:請使用者在 Chrome 切到想要的設定檔,再 browser_read。\n\
- 你也能像人一樣操作螢幕({screens})。computer_use_screenshot 看畫面,再用 computer_use_click / computer_use_typing / computer_use_press_key / computer_use_scroll / computer_use_drag 操作;座標用最新截圖上的像素。每做一步就再截圖確認。用在檔案和網頁工具做不到的事(例如操作其他 App、在 Chrome 切換設定檔)。\n\ - 你也能像人一樣操作螢幕({screens})。computer_use_screenshot 看畫面,再用 computer_use_click / computer_use_typing / computer_use_press_key / computer_use_scroll / computer_use_drag 操作;座標用最新截圖上的像素。每做一步就再截圖確認。用在檔案和網頁工具做不到的事(例如操作其他 App、在 Chrome 切換設定檔)。\n\
{}\ {}\

View File

@ -977,6 +977,40 @@ impl App {
} }
} }
/// 接管: open the computer and take control (the bot waits for the person anyway).
pub(super) fn take_over_computer(&mut self) {
self.open_viewer();
if self.cu.control {
return;
}
self.cu.control = true;
match self.cu.remote.clone() {
Some(s) => {
std::thread::spawn(move || {
let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "hold", "on": true}));
});
}
None => cloudbox::HELD_BY_PERSON.store(true, Ordering::SeqCst),
}
}
/// 我完成了 / 跳過: the bot gets its computer back (its steps would otherwise wait for the
/// person to let go).
pub(super) fn give_back_computer(&mut self) {
if !self.cu.control {
return;
}
self.cu.control = false;
match self.cu.remote.clone() {
Some(s) => {
std::thread::spawn(move || {
let _ = crate::remote::sync_post(&s, "screen", &serde_json::json!({"kind": "hold", "on": false}));
});
}
None => cloudbox::HELD_BY_PERSON.store(false, Ordering::SeqCst),
}
}
fn close_viewer(&mut self) { fn close_viewer(&mut self) {
self.cu.viewer_open = false; self.cu.viewer_open = false;
self.cu.control = false; self.cu.control = false;
@ -1116,8 +1150,11 @@ impl App {
let default = vec2((screen.width() * 0.62).min(900.0), (screen.width() * 0.62).min(900.0) * 0.625 + 52.0); let default = vec2((screen.width() * 0.62).min(900.0), (screen.width() * 0.62).min(900.0) * 0.625 + 52.0);
let busy = if brain { self.mir.box_state["busy"].as_bool().unwrap_or(false) } else { cloudbox::is_busy() }; let busy = if brain { self.mir.box_state["busy"].as_bool().unwrap_or(false) } else { cloudbox::is_busy() };
let title = self.state.bot(self.state.selected).map(|b| crate::model::own_computer_name(&b.name)).unwrap_or_else(|| crate::model::BOT_COMPUTER_NAME.to_string()); let title = self.state.bot(self.state.selected).map(|b| crate::model::own_computer_name(&b.name)).unwrap_or_else(|| crate::model::BOT_COMPUTER_NAME.to_string());
let title = if brain { format!("{title}(在大腦「{}」上)", self.state.remote.brain_name) } else { title };
let mut close_clicked = false; let mut close_clicked = false;
let mut done_clicked = false;
let selected = self.state.selected;
let handoff = self.approvals.get(&selected).map(|a| a.kind == crate::providers::ApprovalKind::Handoff).unwrap_or(false);
egui::Window::new("box-viewer") egui::Window::new("box-viewer")
.id(egui::Id::unique("box-viewer")) .id(egui::Id::unique("box-viewer"))
.title_bar(false) .title_bar(false)
@ -1135,16 +1172,9 @@ impl App {
.shadow(egui::epaint::Shadow { offset: [0, 14], blur: 40, spread: 0, color: Color32::from_black_alpha(60) }), .shadow(egui::epaint::Shadow { offset: [0, 14], blur: 40, spread: 0, color: Color32::from_black_alpha(60) }),
) )
.show(ctx, |ui| { .show(ctx, |ui| {
// Buttons first, from the right; the title and state get what is left and end in
// … when it's short (the state used to slide under 「取得控制權」 in a narrow window).
ui.horizontal(|ui| { ui.horizontal(|ui| {
monitor_icon(ui, 20.0, p.text);
ui.label(RichText::new(&title).strong().size(FONT_LG));
status_pill(ui, &run, p);
if busy && !self.cu.control {
crate::icons::label(ui, crate::icons::Icon::Bot, &t("Bot 正在操作"), FONT_XS, p.accent);
}
if self.cu.control {
crate::icons::label(ui, crate::icons::Icon::Users, &t("你正在操作,Bot 已暫停"), FONT_XS, p.accent);
}
ui.with_layout(Layout::right_to_left(Align::Center), |ui| { ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
let (rect, r) = ui.allocate_exact_size(vec2(28.0, 28.0), egui::Sense::click()); let (rect, r) = ui.allocate_exact_size(vec2(28.0, 28.0), egui::Sense::click());
if r.hovered() { if r.hovered() {
@ -1158,6 +1188,10 @@ impl App {
close_clicked = true; close_clicked = true;
} }
ui.add_space(4.0); ui.add_space(4.0);
// The bot is waiting for the person here: done, and it carries on.
if handoff && ui.add(egui::Button::new(RichText::new(t("我完成了")).strong().color(p.on_primary)).fill(p.primary).corner_radius(8).min_size(vec2(84.0, 30.0))).clicked() {
done_clicked = true;
}
if !brain && ui.add(secondary_button("全螢幕")).on_hover_text(t("在瀏覽器用 noVNC 全螢幕開啟")).clicked() { if !brain && ui.add(secondary_button("全螢幕")).on_hover_text(t("在瀏覽器用 noVNC 全螢幕開啟")).clicked() {
let control = self.cu.control; let control = self.cu.control;
self.box_task(move || { self.box_task(move || {
@ -1184,6 +1218,19 @@ impl App {
None => cloudbox::HELD_BY_PERSON.store(self.cu.control, Ordering::SeqCst), None => cloudbox::HELD_BY_PERSON.store(self.cu.control, Ordering::SeqCst),
} }
} }
ui.add_space(8.0);
ui.with_layout(Layout::left_to_right(Align::Center), |ui| {
monitor_icon(ui, 20.0, p.text);
let note = if self.cu.control { Some(t("你正在操作,Bot 已暫停")) } else if busy { Some(t("Bot 正在操作")) } else { None };
let room = (ui.available_width() - 96.0 - if note.is_some() { 120.0 } else { 0.0 }).max(60.0);
ui.label(RichText::new(super::sidebar_ui::shorten_width(ui, &title, room, FONT_LG)).strong().size(FONT_LG));
if ui.available_width() > 80.0 {
status_pill(ui, &run, p);
}
if let Some(n) = note.filter(|_| ui.available_width() > 110.0) {
crate::icons::label(ui, if self.cu.control { crate::icons::Icon::Users } else { crate::icons::Icon::Bot }, &n, FONT_XS, p.accent);
}
});
}); });
}); });
ui.add_space(6.0); ui.add_space(6.0);
@ -1200,7 +1247,17 @@ impl App {
}; };
egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| { egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| {
let sense = if self.cu.control { egui::Sense::click_and_drag() } else { egui::Sense::hover() }; let sense = if self.cu.control { egui::Sense::click_and_drag() } else { egui::Sense::hover() };
let resp = ui.add(egui::Image::new((tex_id, size)).corner_radius(8).sense(sense)); // Centred in the window (it sat at the left with an empty band beside it).
let resp = if self.cu.fit {
let pad = ((ui.available_width() - size.x) / 2.0).max(0.0);
ui.horizontal(|ui| {
ui.add_space(pad);
ui.add(egui::Image::new((tex_id, size)).corner_radius(8).sense(sense))
})
.inner
} else {
ui.add(egui::Image::new((tex_id, size)).corner_radius(8).sense(sense))
};
if self.cu.control { if self.cu.control {
ui.painter().rect_stroke(resp.rect, 8, Stroke::new(2.0, p.accent), egui::StrokeKind::Outside); ui.painter().rect_stroke(resp.rect, 8, Stroke::new(2.0, p.accent), egui::StrokeKind::Outside);
self.forward_input(ui, &resp, (fw, fh)); self.forward_input(ui, &resp, (fw, fh));
@ -1233,7 +1290,11 @@ impl App {
} }
} }
}); });
if close_clicked || !open { if done_clicked {
self.give_back_computer();
self.answer(ctx, selected, crate::providers::ApprovalAnswer::Once);
self.close_viewer();
} else if close_clicked || !open {
self.close_viewer(); self.close_viewer();
} }
ctx.request_repaint_after(std::time::Duration::from_millis(VIEWER_FRAME_MS)); ctx.request_repaint_after(std::time::Duration::from_millis(VIEWER_FRAME_MS));

View File

@ -533,6 +533,7 @@ impl App {
ApprovalKind::Action => "action", ApprovalKind::Action => "action",
ApprovalKind::EnableTools => "tools", ApprovalKind::EnableTools => "tools",
ApprovalKind::EnableThisComputer => "local", ApprovalKind::EnableThisComputer => "local",
ApprovalKind::Handoff => "handoff",
}; };
json!({"bot": id, "call_id": a.call_id, "tool": a.tool, "reason": a.reason, "grant": a.grant, "kind": kind}) json!({"bot": id, "call_id": a.call_id, "tool": a.tool, "reason": a.reason, "grant": a.grant, "kind": kind})
}) })
@ -1015,6 +1016,7 @@ impl App {
let kind = match a["kind"].as_str().unwrap_or("") { let kind = match a["kind"].as_str().unwrap_or("") {
"tools" => ApprovalKind::EnableTools, "tools" => ApprovalKind::EnableTools,
"local" => ApprovalKind::EnableThisComputer, "local" => ApprovalKind::EnableThisComputer,
"handoff" => ApprovalKind::Handoff,
_ => ApprovalKind::Action, _ => ApprovalKind::Action,
}; };
let (reply, _) = channel(); let (reply, _) = channel();

View File

@ -105,7 +105,8 @@ impl App {
let status = self.streams.get(&bot.id).and_then(|l| l.status.clone()).unwrap_or_else(|| t("工作中…")); let status = self.streams.get(&bot.id).and_then(|l| l.status.clone()).unwrap_or_else(|| t("工作中…"));
let mut clicked = false; let mut clicked = false;
panel_card(ui, p, |ui| { panel_card(ui, p, |ui| {
clicked = if waiting { panel_row(ui, p, Some(crate::icons::Icon::Shield), &t("需要你允許"), "", true).clicked() } else { panel_row(ui, p, Some(crate::icons::Icon::Stop), &status, &t("停止"), false).clicked() }; let label = if self.approvals.get(&bot.id).map(|a| a.kind == crate::providers::ApprovalKind::Handoff).unwrap_or(false) { t("等你處理") } else { t("需要你允許") };
clicked = if waiting { panel_row(ui, p, Some(crate::icons::Icon::Shield), &label, "", true).clicked() } else { panel_row(ui, p, Some(crate::icons::Icon::Stop), &status, &t("停止"), false).clicked() };
}); });
if clicked { if clicked {
if waiting { if waiting {
@ -173,21 +174,28 @@ impl App {
let run = crate::cloudbox::run_state(); let run = crate::cloudbox::run_state();
let running = if brain { self.mir.box_state["running"].as_bool().unwrap_or(false) } else { run.is_running() }; let running = if brain { self.mir.box_state["running"].as_bool().unwrap_or(false) } else { run.is_running() };
let state = if brain { let state = if brain {
let label = self.mir.box_state["label"].as_str().map(t).unwrap_or_else(|| t("連線中…")); self.mir.box_state["label"].as_str().map(t).unwrap_or_else(|| t("連線中…"))
format!("{label} · {}", t("在大腦「{name}」上").replace("{name}", &brain_name))
} else if available { } else if available {
t(&run.label()) t(&run.label())
} else { } else {
t("沒有 Docker") t("沒有 Docker")
}; };
let (rect, r) = ui.allocate_exact_size(vec2(ui.available_width(), 64.0), egui::Sense::click()); // Where it is, on its own line (it ran into the picture when it shared the state's).
let place = brain.then(|| t("在大腦「{name}」上").replace("{name}", &brain_name));
let (rect, r) = ui.allocate_exact_size(vec2(ui.available_width(), if place.is_some() { 72.0 } else { 64.0 }), egui::Sense::click());
if r.hovered() { if r.hovered() {
ui.painter().rect_filled(rect, 9, p.hover); ui.painter().rect_filled(rect, 9, p.hover);
} }
ui.painter().text(rect.left_top() + vec2(10.0, 15.0), egui::Align2::LEFT_TOP, &own_name, egui::FontId::proportional(13.0), p.text); // Every line stops before the picture (long names end in …).
ui.painter().text(rect.left_top() + vec2(10.0, 33.0), egui::Align2::LEFT_TOP, state, egui::FontId::proportional(12.0), p.text3); let text_w = (rect.width() - 86.0 - 10.0 - 8.0).max(40.0);
let top = if place.is_some() { 9.0 } else { 15.0 };
ui.painter().text(rect.left_top() + vec2(10.0, top), egui::Align2::LEFT_TOP, super::sidebar_ui::shorten_width(ui, &own_name, text_w, 13.0), egui::FontId::proportional(13.0), p.text);
ui.painter().text(rect.left_top() + vec2(10.0, top + 18.0), egui::Align2::LEFT_TOP, super::sidebar_ui::shorten_width(ui, &state, text_w, 12.0), egui::FontId::proportional(12.0), p.text3);
if let Some(place) = &place {
ui.painter().text(rect.left_top() + vec2(10.0, top + 35.0), egui::Align2::LEFT_TOP, super::sidebar_ui::shorten_width(ui, place, text_w, 12.0), egui::FontId::proportional(12.0), p.text3);
}
// The screen: 16:10, rounded, the real desktop when there is a picture. // The screen: 16:10, rounded, the real desktop when there is a picture.
let box_r = egui::Rect::from_min_size(rect.right_top() + vec2(-86.0, 8.0), vec2(78.0, 48.0)); let box_r = egui::Rect::from_min_size(egui::pos2(rect.right() - 86.0, rect.center().y - 24.0), vec2(78.0, 48.0));
match thumb { match thumb {
Some((tex, size)) => { Some((tex, size)) => {
let mut img = egui::Image::new((tex, size)).fit_to_exact_size(box_r.size()).corner_radius(7); let mut img = egui::Image::new((tex, size)).fit_to_exact_size(box_r.size()).corner_radius(7);
@ -211,7 +219,8 @@ impl App {
let tip = if running { t("看「{name}」的螢幕,也可以取得控制權自己操作") } else { t("叫醒「{name}」並看它的螢幕") }.replace("{name}", &own_name); let tip = if running { t("看「{name}」的螢幕,也可以取得控制權自己操作") } else { t("叫醒「{name}」並看它的螢幕") }.replace("{name}", &own_name);
view = r.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(tip).clicked(); view = r.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(tip).clicked();
if !choices.is_empty() { if !choices.is_empty() {
panel_select(ui, p, "做事的電腦", ("computer", bot.id), &current, |ui| { let shown = super::sidebar_ui::shorten_width(ui, &current, (ui.available_width() - 120.0).max(60.0), 13.0);
panel_select(ui, p, "做事的電腦", ("computer", bot.id), &shown, |ui| {
for (name, kind) in &choices { for (name, kind) in &choices {
let what = t(match kind.as_str() { let what = t(match kind.as_str() {
"own" => "它自己的電腦", "own" => "它自己的電腦",

View File

@ -41,8 +41,10 @@ fn approval_json(bot: &Bot, a: &Approval) -> serde_json::Value {
ApprovalKind::EnableTools => format!("允許 {} 使用電腦工具嗎?", bot.name), ApprovalKind::EnableTools => format!("允許 {} 使用電腦工具嗎?", bot.name),
ApprovalKind::EnableThisComputer => format!("允許 {} 在你的電腦上執行任務嗎?", bot.name), ApprovalKind::EnableThisComputer => format!("允許 {} 在你的電腦上執行任務嗎?", bot.name),
ApprovalKind::Action => format!("{} 想要{what}", bot.name), ApprovalKind::Action => format!("{} 想要{what}", bot.name),
ApprovalKind::Handoff => format!("{}:需要你", bot.name),
}; };
json!({"bot": bot.id, "title": title, "reason": a.reason, "tool": a.tool, "can_always": a.kind != ApprovalKind::Action || !a.grant.is_empty()}) let handoff = a.kind == ApprovalKind::Handoff;
json!({"bot": bot.id, "title": title, "reason": a.reason, "tool": a.tool, "can_always": !handoff && (a.kind != ApprovalKind::Action || !a.grant.is_empty()), "kind": if handoff { "handoff" } else { "ask" }, "computer": if handoff { a.grant.as_str() } else { "" }})
} }
impl App { impl App {

View File

@ -745,7 +745,7 @@ impl App {
}; };
painter.galley(egui::pos2(tx, rect.top() + 9.0), one_line(b.name.clone(), 15.0, p.text), p.text); painter.galley(egui::pos2(tx, rect.top() + 9.0), one_line(b.name.clone(), 15.0, p.text), p.text);
let sub = if waiting { let sub = if waiting {
t("需要你允許") if self.approvals.get(&b.id).map(|a| a.kind == crate::providers::ApprovalKind::Handoff).unwrap_or(false) { t("等你處理") } else { t("需要你允許") }
} else if streaming { } else if streaming {
t("正在工作…") t("正在工作…")
} else { } else {

View File

@ -614,4 +614,13 @@ pub const ROWS: &[(&str, &str, &str)] = &[
("在「{name}」上做事 · 點一下換電腦", "Works on “{name}” · click to switch computers", "「{name}」で作業 · クリックで切り替え"), ("在「{name}」上做事 · 點一下換電腦", "Works on “{name}” · click to switch computers", "「{name}」で作業 · クリックで切り替え"),
("一台 Linux 電腦,你所有的 Bot 共用,每個 Bot 有自己的資料夾(例如「Master 的電腦」)。一直開著,不會休眠。", "A Linux computer all your bots share; each bot has its own folder (e.g. “Master's computer”). Always on, it never sleeps.", "すべての Bot が共有する Linux コンピューター。Bot ごとにフォルダーがあります(例:「Master のコンピューター」)。常に起動していて、スリープしません。"), ("一台 Linux 電腦,你所有的 Bot 共用,每個 Bot 有自己的資料夾(例如「Master 的電腦」)。一直開著,不會休眠。", "A Linux computer all your bots share; each bot has its own folder (e.g. “Master's computer”). Always on, it never sleeps.", "すべての Bot が共有する Linux コンピューター。Bot ごとにフォルダーがあります(例:「Master のコンピューター」)。常に起動していて、スリープしません。"),
("把 Bot 的檔案備份到這台電腦;每天也會自動存一份", "Back up the bots' files to this computer; a copy is also saved every day", "Bot のファイルをこのコンピューターにバックアップします。毎日自動でも保存します"), ("把 Bot 的檔案備份到這台電腦;每天也會自動存一份", "Back up the bots' files to this computer; a copy is also saved every day", "Bot のファイルをこのコンピューターにバックアップします。毎日自動でも保存します"),
("等你處理", "Waiting for you", "対応待ち"),
("需要處理", "Action needed", "対応が必要です"),
("接管", "Take over", "操作を代わる"),
("接管電腦", "Take over the computer", "コンピューターの操作を代わる"),
("我完成了", "I'm done", "完了しました"),
("跳過", "Skip", "スキップ"),
("取消此要求且不執行該步驟;Bot 會跳過它並繼續", "Cancel this request without doing the step; the Bot continues without it", "この依頼を取り消し、その手順は行いません。Bot はそれを飛ばして続けます"),
("連結中", "Connecting", "接続中"),
("請到「{name}」上操作", "Please do it on “{name}”", "「{name}」で操作してください"),
]; ];

View File

@ -235,15 +235,18 @@ fn main() -> eframe::Result<()> {
app::App::serve(&ctx); app::App::serve(&ctx);
} }
let options = eframe::NativeOptions { let mut viewport = egui::ViewportBuilder::default()
viewport: egui::ViewportBuilder::default()
.with_title("AnyBot") .with_title("AnyBot")
.with_inner_size(shot_size()) .with_inner_size(shot_size())
.with_min_inner_size([app::SIDEBAR_MIN_WIDTH + app::CHAT_MIN_WIDTH, 480.0]) .with_min_inner_size([app::SIDEBAR_MIN_WIDTH + app::CHAT_MIN_WIDTH, 480.0])
.with_app_id("anybot") .with_app_id("anybot")
.with_icon(app_icon()), .with_icon(app_icon());
..Default::default() // On a Mac the window is one surface to the very top (the grey system title bar didn't match
}; // a dark window); the traffic lights sit over the sidebar's empty corner.
if cfg!(target_os = "macos") {
viewport = viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false);
}
let options = eframe::NativeOptions { viewport, ..Default::default() };
eframe::run_native( eframe::run_native(
"AnyBot", "AnyBot",
options, options,

View File

@ -223,6 +223,10 @@ pub enum ApprovalKind {
EnableTools, EnableTools,
/// 在這台電腦上執行 is off: let it work on this Mac? ("always" turns the setting on.) /// 在這台電腦上執行 is off: let it work on this Mac? ("always" turns the setting on.)
EnableThisComputer, EnableThisComputer,
/// 需要處理: a step only the person can do on a computer (signing in, a captcha…). `reason`
/// is what to do, `grant` the computer's name; Once = 「我完成了」, Reject = 「跳過」 (the
/// original's box hand-off card).
Handoff,
} }
pub struct AppCall { pub struct AppCall {

View File

@ -60,8 +60,12 @@ pub fn specs() -> Vec<Value> {
] ]
} }
/// 請你接手: the step that asks the person to do something by hand on a computer.
pub const HANDOFF_TOOL: &str = "hand_over_computer";
pub fn label(name: &str) -> &'static str { pub fn label(name: &str) -> &'static str {
match name { match name {
HANDOFF_TOOL => "請你接手電腦",
"list_dir" => "查看資料夾", "list_dir" => "查看資料夾",
"search" => "搜尋檔案", "search" => "搜尋檔案",
"read_file" => "讀取檔案", "read_file" => "讀取檔案",