需要處理: 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:
parent
c3e2e28ec4
commit
46c0684cb4
|
|
@ -647,8 +647,14 @@ function renderAsk() {
|
|||
const a = S.approvals.find((x) => x.bot === S.open);
|
||||
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; }
|
||||
// 需要處理 (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").querySelectorAll("button").forEach((b) => b.onclick = async () => {
|
||||
}
|
||||
$("ask").querySelectorAll("button[data-a]").forEach((b) => b.onclick = async () => {
|
||||
$("ask").innerHTML = "";
|
||||
try { await api(base() + `api/bots/${S.open}/answer`, {json: {answer: b.dataset.a}}); } catch (e) { toast(e.message); }
|
||||
});
|
||||
|
|
|
|||
105
src/agent.rs
105
src/agent.rs
|
|
@ -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 }));
|
||||
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, ¬ed(&moved, &call.id, &output));
|
||||
send(StreamEvent::ToolResult(result.clone()));
|
||||
history.push(result);
|
||||
continue;
|
||||
}
|
||||
// 生活卡片: drawn by the window from the call itself.
|
||||
if call.name == "show_card" {
|
||||
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Done));
|
||||
|
|
@ -1374,6 +1414,71 @@ mod tests {
|
|||
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.
|
||||
/// Dangerous commands stay blocked.
|
||||
#[test]
|
||||
|
|
|
|||
166
src/app.rs
166
src/app.rs
|
|
@ -44,6 +44,9 @@ pub const SIDEBAR_DEFAULT_WIDTH: f32 = 280.0;
|
|||
pub const SIDEBAR_MIN_WIDTH: f32 = 240.0;
|
||||
pub const SIDEBAR_MAX_WIDTH: f32 = 400.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_EXPAND_FROM: f32 = 220.0;
|
||||
/// `--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") => {
|
||||
// The real box, so the 電腦 card shows its screen.
|
||||
crate::cloudbox::start_background();
|
||||
|
|
@ -550,6 +560,13 @@ impl App {
|
|||
app.show_settings = true;
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -756,6 +773,13 @@ impl App {
|
|||
if !screens.is_empty() {
|
||||
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));
|
||||
// 需要處理: 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.
|
||||
providers::set_eyes(self.eyes());
|
||||
|
|
@ -1293,6 +1317,7 @@ impl App {
|
|||
}
|
||||
}
|
||||
ApprovalKind::EnableThisComputer => self.state.local_execution = true,
|
||||
ApprovalKind::Handoff => {}
|
||||
ApprovalKind::Action => {
|
||||
if let Some(b) = self.state.bot_mut(bot_id) {
|
||||
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) {
|
||||
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 => {
|
||||
let Some(m) = b.messages.last_mut() else { continue };
|
||||
|
|
@ -1734,6 +1763,10 @@ impl App {
|
|||
fn rail(&mut self, ui: &mut egui::Ui) {
|
||||
let p = self.pal;
|
||||
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() {
|
||||
self.state.sidebar_collapsed = false;
|
||||
self.mark();
|
||||
|
|
@ -2020,9 +2053,11 @@ impl App {
|
|||
self.voice_toggle(&ctx, bot.id);
|
||||
}
|
||||
}
|
||||
if self.approvals.contains_key(&bot.id) {
|
||||
if crate::icons::pill(&mut right, crate::icons::Icon::Shield, &t("需要你允許"), p.accent, Color32::WHITE, 36.0).on_hover_text(t("打開請求視窗")).clicked() {
|
||||
if let Some(kind) = self.approvals.get(&bot.id).map(|a| a.kind) {
|
||||
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.stick_bottom = true;
|
||||
}
|
||||
}
|
||||
let right_w = bar.right() - right.min_rect().left();
|
||||
|
|
@ -2096,11 +2131,12 @@ impl App {
|
|||
ApprovalKind::EnableThisComputer => format!("允許 {} 在你的電腦上執行任務嗎?", bot.name),
|
||||
ApprovalKind::Action if machine.is_empty() => format!("允許 {} {}嗎?", bot.name, what),
|
||||
ApprovalKind::Action => format!("允許 {} 在 {machine} 上{what}嗎?", bot.name),
|
||||
ApprovalKind::Handoff => format!("{}:需要你", bot.name),
|
||||
};
|
||||
let note = match kind {
|
||||
ApprovalKind::EnableTools => Some("這個 Bot 的電腦工具還沒打開。它想做的事:"),
|
||||
ApprovalKind::EnableThisComputer => Some("「在這台電腦上執行」還沒打開,Bot 平常只在它自己的電腦上做事。它想在你的電腦上:"),
|
||||
ApprovalKind::Action => None,
|
||||
ApprovalKind::Action | ApprovalKind::Handoff => None,
|
||||
};
|
||||
let mut answer = None;
|
||||
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::EnableThisComputer => Some("一律允許(打開「在這台電腦上執行」)".to_string()),
|
||||
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 ui.button(label).clicked() {
|
||||
|
|
@ -2153,7 +2189,9 @@ impl App {
|
|||
|
||||
fn approval_card(&mut self, ui: &mut egui::Ui, ctx: &egui::Context, bot: &Bot) {
|
||||
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;
|
||||
}
|
||||
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
|
||||
/// attention (the Dock icon bounces when AnyBot isn't in front).
|
||||
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 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();
|
||||
// 「{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 {
|
||||
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.
|
||||
fn approval_popup(&mut self, ctx: &egui::Context) {
|
||||
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));
|
||||
let Some(&bot_id) = ids.first() 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)));
|
||||
}
|
||||
}
|
||||
// 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).
|
||||
if self.frames == need * 3 / 4 {
|
||||
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)) {
|
||||
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.main_pane(ui);
|
||||
self.bot_editor(ctx);
|
||||
|
|
@ -3886,8 +4031,9 @@ fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_compu
|
|||
- 公開網頁:web_fetch。\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。不要猜網址;找不到想要的功能(例如排序、篩選),就看欄位標題、篩選器、下拉選單或分頁標籤,還是沒有就照頁面現有的資料自己整理,並說明網站沒有這個功能。
|
||||
- 絕對不要直接呼叫網站的內部 API、下載網站的程式碼來研究,或用程式大量抓資料:網站會把帳號當成機器人(例如跳出拼圖驗證、封鎖帳號)。出現驗證碼、拼圖或簡訊驗證時,停下來請使用者在 AnyBot 瀏覽器視窗完成。
|
||||
- 如果讀到的是登入頁,請使用者在跳出來的 AnyBot 瀏覽器視窗登入一次(之後會記住),登入好再讀。絕對不要叫使用者去改瀏覽器的設定或選單(例如「允許來自 Apple 事件的 JavaScript」),那不需要。也不要說要從 Chrome 複製登入或 Cookie,沒有這個功能。\n\
|
||||
- 絕對不要直接呼叫網站的內部 API、下載網站的程式碼來研究,或用程式大量抓資料:網站會把帳號當成機器人(例如跳出拼圖驗證、封鎖帳號)。
|
||||
- 需要使用者親手做的步驟(登入、輸入密碼、驗證碼、拼圖、簡訊或 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\
|
||||
- 你也能像人一樣操作螢幕({screens})。computer_use_screenshot 看畫面,再用 computer_use_click / computer_use_typing / computer_use_press_key / computer_use_scroll / computer_use_drag 操作;座標用最新截圖上的像素。每做一步就再截圖確認。用在檔案和網頁工具做不到的事(例如操作其他 App、在 Chrome 切換設定檔)。\n\
|
||||
{}\
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
self.cu.viewer_open = 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 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 = if brain { format!("{title}(在大腦「{}」上)", self.state.remote.brain_name) } else { title };
|
||||
|
||||
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")
|
||||
.id(egui::Id::unique("box-viewer"))
|
||||
.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) }),
|
||||
)
|
||||
.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| {
|
||||
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| {
|
||||
let (rect, r) = ui.allocate_exact_size(vec2(28.0, 28.0), egui::Sense::click());
|
||||
if r.hovered() {
|
||||
|
|
@ -1158,6 +1188,10 @@ impl App {
|
|||
close_clicked = true;
|
||||
}
|
||||
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() {
|
||||
let control = self.cu.control;
|
||||
self.box_task(move || {
|
||||
|
|
@ -1184,6 +1218,19 @@ impl App {
|
|||
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);
|
||||
|
|
@ -1200,7 +1247,17 @@ impl App {
|
|||
};
|
||||
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 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 {
|
||||
ui.painter().rect_stroke(resp.rect, 8, Stroke::new(2.0, p.accent), egui::StrokeKind::Outside);
|
||||
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();
|
||||
}
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(VIEWER_FRAME_MS));
|
||||
|
|
|
|||
|
|
@ -533,6 +533,7 @@ impl App {
|
|||
ApprovalKind::Action => "action",
|
||||
ApprovalKind::EnableTools => "tools",
|
||||
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})
|
||||
})
|
||||
|
|
@ -1015,6 +1016,7 @@ impl App {
|
|||
let kind = match a["kind"].as_str().unwrap_or("") {
|
||||
"tools" => ApprovalKind::EnableTools,
|
||||
"local" => ApprovalKind::EnableThisComputer,
|
||||
"handoff" => ApprovalKind::Handoff,
|
||||
_ => ApprovalKind::Action,
|
||||
};
|
||||
let (reply, _) = channel();
|
||||
|
|
|
|||
|
|
@ -105,7 +105,8 @@ impl App {
|
|||
let status = self.streams.get(&bot.id).and_then(|l| l.status.clone()).unwrap_or_else(|| t("工作中…"));
|
||||
let mut clicked = false;
|
||||
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 waiting {
|
||||
|
|
@ -173,21 +174,28 @@ impl App {
|
|||
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 state = if brain {
|
||||
let label = self.mir.box_state["label"].as_str().map(t).unwrap_or_else(|| t("連線中…"));
|
||||
format!("{label} · {}", t("在大腦「{name}」上").replace("{name}", &brain_name))
|
||||
self.mir.box_state["label"].as_str().map(t).unwrap_or_else(|| t("連線中…"))
|
||||
} else if available {
|
||||
t(&run.label())
|
||||
} else {
|
||||
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() {
|
||||
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);
|
||||
ui.painter().text(rect.left_top() + vec2(10.0, 33.0), egui::Align2::LEFT_TOP, state, egui::FontId::proportional(12.0), p.text3);
|
||||
// Every line stops before the picture (long names end in …).
|
||||
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.
|
||||
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 {
|
||||
Some((tex, size)) => {
|
||||
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);
|
||||
view = r.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(tip).clicked();
|
||||
if !choices.is_empty() {
|
||||
panel_select(ui, p, "做事的電腦", ("computer", bot.id), ¤t, |ui| {
|
||||
let shown = super::sidebar_ui::shorten_width(ui, ¤t, (ui.available_width() - 120.0).max(60.0), 13.0);
|
||||
panel_select(ui, p, "做事的電腦", ("computer", bot.id), &shown, |ui| {
|
||||
for (name, kind) in &choices {
|
||||
let what = t(match kind.as_str() {
|
||||
"own" => "它自己的電腦",
|
||||
|
|
|
|||
|
|
@ -41,8 +41,10 @@ fn approval_json(bot: &Bot, a: &Approval) -> serde_json::Value {
|
|||
ApprovalKind::EnableTools => format!("允許 {} 使用電腦工具嗎?", bot.name),
|
||||
ApprovalKind::EnableThisComputer => format!("允許 {} 在你的電腦上執行任務嗎?", 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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
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 {
|
||||
t("正在工作…")
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -614,4 +614,13 @@ pub const ROWS: &[(&str, &str, &str)] = &[
|
|||
("在「{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 のコンピューター」)。常に起動していて、スリープしません。"),
|
||||
("把 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}」で操作してください"),
|
||||
];
|
||||
|
|
|
|||
21
src/main.rs
21
src/main.rs
|
|
@ -235,15 +235,18 @@ fn main() -> eframe::Result<()> {
|
|||
app::App::serve(&ctx);
|
||||
}
|
||||
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("AnyBot")
|
||||
.with_inner_size(shot_size())
|
||||
.with_min_inner_size([app::SIDEBAR_MIN_WIDTH + app::CHAT_MIN_WIDTH, 480.0])
|
||||
.with_app_id("anybot")
|
||||
.with_icon(app_icon()),
|
||||
..Default::default()
|
||||
};
|
||||
let mut viewport = egui::ViewportBuilder::default()
|
||||
.with_title("AnyBot")
|
||||
.with_inner_size(shot_size())
|
||||
.with_min_inner_size([app::SIDEBAR_MIN_WIDTH + app::CHAT_MIN_WIDTH, 480.0])
|
||||
.with_app_id("anybot")
|
||||
.with_icon(app_icon());
|
||||
// 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(
|
||||
"AnyBot",
|
||||
options,
|
||||
|
|
|
|||
|
|
@ -223,6 +223,10 @@ pub enum ApprovalKind {
|
|||
EnableTools,
|
||||
/// 在這台電腦上執行 is off: let it work on this Mac? ("always" turns the setting on.)
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
match name {
|
||||
HANDOFF_TOOL => "請你接手電腦",
|
||||
"list_dir" => "查看資料夾",
|
||||
"search" => "搜尋檔案",
|
||||
"read_file" => "讀取檔案",
|
||||
|
|
|
|||
Loading…
Reference in New Issue