AnyBot/src/app.rs

4413 lines
237 KiB
Rust
Raw Normal View History

// The window: bot list on the left, the chosen bot's chat on the right,
// plus the bot editor and the settings (look and AI providers).
use chrono::Datelike;
use eframe::egui::{self, vec2, Align, Color32, CornerRadius, Frame, Key, Layout, Margin, RichText, Stroke};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{channel, Receiver, Sender};
use std::sync::{Arc, Mutex};
use std::time::Instant;
use crate::agent;
use crate::appctl;
use crate::avatar;
use crate::notify;
use crate::policy;
use crate::web;
use crate::model::{Accent, Bot, Message, Role, State, Theme, ToolStatus, TurnOutcome, TurnSettlement};
use crate::providers::{self, AppCall, Approval, ApprovalAnswer, ApprovalKind, ChatRequest, ProviderKind, StreamEvent, ALL_PROVIDERS};
use crate::tools;
use crate::i18n::t;
use crate::theme::{Palette, DARK, LIGHT};
mod attach_ui;
mod channels_ui;
mod computers_ui;
mod chat_ui;
mod files_ui;
mod panel_ui;
mod remote_ui;
mod sidebar_ui;
mod menu_ui;
mod vault_ui;
mod markdown_ui;
mod welcome_ui;
mod mirror_ui;
mod pages_ui;
// Sidebar rules from index.eager-app: defaults `FT={expandedWidth:280}` (@912025),
// clamp `jT(..,240,400)` (@914394), and the drag rule in `QJe` (@969523):
// folds to an 88 px rail below 210 px, unfolds from the rail only past 220 px.
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).
pub const CHAT_MIN_WIDTH: f32 = 424.0;
/// Transcript text: `--sand-transcript-prose-line-height:22px` pairs with `--sand-font-size-lg:15px`.
/// The original's type scale (renderer index.css `--sand-font-size-*`): xxs 11, xs 12, sm 13,
/// base 14, lg 15, xl 17, xxl 22, xxxl 26 px, with `--sand-line-height-*` 16/16/18/20/22/24/28/32.
pub const FONT_XS: f32 = 12.0;
pub const FONT_SM: f32 = 13.0;
pub const FONT_BASE: f32 = 14.0;
pub const FONT_LG: f32 = 15.0;
pub const FONT_XL: f32 = 17.0;
pub const LINE_HEIGHT_SM: f32 = 18.0;
pub const LINE_HEIGHT_LG: f32 = 22.0;
pub const LINE_HEIGHT_XL: f32 = 24.0;
/// Chat text uses the lg step (15 px on 22 px lines).
pub const TRANSCRIPT_FONT: f32 = FONT_LG;
pub const TRANSCRIPT_LINE_HEIGHT: f32 = LINE_HEIGHT_LG;
/// `--sand-transcript-prose-bubble-radius:18px`, one-line bubble `--sand-transcript-prose-one-line-height:36px`.
pub const BUBBLE_RADIUS: u8 = 18;
pub const BUBBLE_ONE_LINE_HEIGHT: f32 = 36.0;
pub const TRANSCRIPT_MAX_WIDTH: f32 = 720.0;
/// Space kept above and below Settings / Bot 設定 so they never run off the window (estimated).
const WINDOW_SCREEN_MARGIN: f32 = 80.0;
/// Provider names in Settings fit on one line (「自訂(OpenAI 相容)」) (estimated).
const PROVIDER_LIST_WIDTH: f32 = 180.0;
/// Settings: dialog width, its left-hand page list, and a steady height so tabs don't jump.
const SETTINGS_WIDTH: f32 = 760.0;
const SETTINGS_NAV_WIDTH: f32 = 150.0;
const SETTINGS_HEIGHT: f32 = 600.0;
/// Bot 設定 dialog width.
const EDITOR_WIDTH: f32 = 540.0;
/// The label column in form rows ("預設電腦", "模型可讀長度"…).
const FORM_LABEL_WIDTH: f32 = 96.0;
/// Tool steps under a reply start a little in from the bubble's edge.
pub const SCREENSHOT_FRAMES: u32 = 120;
#[derive(PartialEq, Clone, Copy)]
/// Like the original's seven panes (一般、整合、電腦、權限、保險庫、用量與帳單、更新); AnyBot has
/// 模型 (its own AI keys) where the original has 用量與帳單, and 帳戶 opens from 一般.
enum SettingsTab {
General,
Providers,
Channels,
Computers,
Review,
Vault,
Updates,
Remote,
}
pub struct App {
state: State,
pal: Palette,
demo: bool,
screenshot: Option<PathBuf>,
frames: u32,
shot_requested: bool,
shot: Arc<Mutex<Option<Arc<egui::ColorImage>>>>,
drafts: HashMap<u64, String>,
search: String,
streams: HashMap<u64, Live>,
/// A tool waiting for the person's yes/no, per bot.
approvals: HashMap<u64, Approval>,
/// App actions (e.g. deleting a routine) waiting for that yes/no.
app_pending: HashMap<u64, AppCall>,
/// Messages that arrived as a turn ended: they start the next turn.
pending_turns: Vec<(u64, Vec<String>)>,
last_tick: Instant,
/// The routines list for this bot is open.
show_routines: Option<u64>,
new_remote: (String, String),
new_browser_profile: String,
browser_note: std::sync::Arc<std::sync::Mutex<String>>,
confirm_remove_profile: Option<String>,
confirm_delete_routine: Option<(u64, u64)>,
/// A short message shown above the message box (e.g. after a restart).
notice: Option<String>,
/// Requests whose pop-up the person put off ("稍後再決定"); the chat card stays.
popup_hidden: std::collections::HashSet<u64>,
/// "全部允許,之後直接操作" was clicked in a request.
want_direct: std::cell::Cell<bool>,
/// Admin locks (policy.rs), read at start and when Settings opens.
managed: policy::Managed,
settings_was_open: bool,
/// When the program file this copy started from was built (its version).
exe_built: Option<std::time::SystemTime>,
/// A newer build has replaced the program file since this copy started.
newer_build: Option<std::time::SystemTime>,
last_build_check: Instant,
/// Next time to consider switching to a newer build by itself (30 s after start, then hourly).
next_update_apply: Instant,
window_focused: bool,
idle_secs: Option<f64>,
last_presence: Instant,
relaunching: bool,
/// The sidebar's "new version" banner was clicked.
relaunch_requested: bool,
new_allow_rule: String,
new_ask_rule: String,
side_drag: Option<f32>,
tx: Sender<(u64, StreamEvent)>,
rx: Receiver<(u64, StreamEvent)>,
show_settings: bool,
settings_tab: SettingsTab,
settings_provider: ProviderKind,
edit_bot: Option<(Bot, bool)>,
confirm_delete: Option<u64>,
model_fetch: HashMap<ProviderKind, Receiver<Result<Vec<String>, String>>>,
fetch_status: HashMap<ProviderKind, String>,
dirty: bool,
last_save: Instant,
stick_bottom: bool,
/// Bot 的電腦 viewer, rebuild dialog and the computers page.
cu: computers_ui::ComputerUi,
vault: vault_ui::VaultUi,
md: markdown_ui::MdState,
/// The page open large (頁面): its id, and an older revision when looking at one.
page_view: Option<(u64, Option<u32>)>,
/// Files and pictures waiting to go with the next message.
att: attach_ui::AttachUi,
/// 訊息管道 (Slack / Discord / Telegram).
ch: channels_ui::ChannelUi,
/// A shared bot file being picked.
share_rx: Option<Receiver<Result<String, String>>>,
/// 群組聊天: members still to answer in this round, and groups whose next member should start.
group_queue: HashMap<u64, std::collections::VecDeque<u64>>,
group_next: Vec<u64>,
/// 引用: the message being quoted in the next message, per bot.
quoting: HashMap<u64, String>,
/// Index of the message being drawn (for reactions).
msg_index: usize,
/// 手機與帳號.
rm: remote_ui::RemoteUi,
/// Running as `anybot --serve` (no window; the phone is the screen).
headless: bool,
/// The computer the person last typed to a bot on: an account computer's id, "" for this
/// one, None when not known (the phone isn't a computer). It is "我的電腦" to the bots.
typed_on: Option<String>,
/// 右側面板 and its tab (None = closed).
panel: Option<panel_ui::PanelTab>,
/// When each emoji reaction appeared (for its pop) and when each message first showed (fade-in).
popped: HashMap<(u64, usize, String), Instant>,
appeared: HashMap<(u64, usize), Instant>,
/// Messages whose steps / thinking are unfolded (⋯ › 顯示步驟).
steps_open: std::collections::HashSet<(u64, usize)>,
/// The file being previewed.
file_view: Option<files_ui::FileView>,
started_ms: i64,
/// The message whose hover menu (表情 / ⋯) is open: its buttons stay while it is.
menu_for: Option<(u64, usize)>,
/// Each face's motion (gaze, blink, pokes).
faces: HashMap<u64, sidebar_ui::Face>,
search_open: bool,
confirm_section_delete: Option<String>,
/// A group being renamed: (old name, what is typed).
renaming: Option<(String, String)>,
/// The group whose ⋯ menu is open (its button stays while it is).
group_menu_for: Option<String>,
/// When the launch greeting started.
splash_at: Option<f64>,
/// 大腦: this window shows another AnyBot's bots (a screen), or answers screens (the brain).
mir: mirror_ui::Mirror,
scr: mirror_ui::Screens,
ctx_handle: Option<egui::Context>,
}
impl App {
pub fn new(ctx: &egui::Context, screenshot: Option<PathBuf>) -> Self {
// ANYBOT_SHOT_REAL=1: a screenshot of the real data (and connections), not the demo.
let demo = screenshot.is_some() && std::env::var("ANYBOT_SHOT_REAL").is_err();
let state = if demo { State::demo() } else { State::load() };
let (tx, rx) = channel();
let mut app = App {
state,
pal: LIGHT,
demo,
screenshot,
frames: 0,
shot_requested: false,
shot: Arc::new(Mutex::new(None)),
drafts: HashMap::new(),
search: String::new(),
streams: HashMap::new(),
approvals: HashMap::new(),
app_pending: HashMap::new(),
pending_turns: vec![],
last_tick: Instant::now(),
show_routines: None,
new_remote: Default::default(),
new_browser_profile: String::new(),
browser_note: Default::default(),
confirm_remove_profile: None,
confirm_delete_routine: None,
notice: None,
popup_hidden: Default::default(),
want_direct: Default::default(),
managed: if demo { policy::Managed::default() } else { policy::read() },
settings_was_open: false,
exe_built: exe_modified(),
newer_build: None,
last_build_check: Instant::now(),
next_update_apply: Instant::now() + std::time::Duration::from_secs(notify::UPDATE_FIRST_CHECK_SECS),
window_focused: true,
idle_secs: None,
last_presence: Instant::now(),
relaunching: false,
relaunch_requested: false,
new_allow_rule: String::new(),
new_ask_rule: String::new(),
side_drag: None,
tx,
rx,
show_settings: false,
settings_tab: SettingsTab::General,
settings_provider: ProviderKind::Xai,
edit_bot: None,
confirm_delete: None,
model_fetch: HashMap::new(),
fetch_status: HashMap::new(),
dirty: false,
last_save: Instant::now(),
stick_bottom: true,
cu: Default::default(),
vault: Default::default(),
md: Default::default(),
page_view: None,
att: Default::default(),
ch: Default::default(),
share_rx: None,
group_queue: HashMap::new(),
group_next: vec![],
quoting: HashMap::new(),
msg_index: 0,
rm: Default::default(),
headless: false,
typed_on: None,
panel: None,
popped: HashMap::new(),
appeared: HashMap::new(),
steps_open: Default::default(),
file_view: None,
started_ms: crate::model::now_ms(),
menu_for: None,
faces: HashMap::new(),
search_open: false,
confirm_section_delete: None,
renaming: None,
group_menu_for: None,
splash_at: None,
mir: Default::default(),
scr: Default::default(),
ctx_handle: None,
};
crate::i18n::set(&app.state.language);
app.computers_start();
// The computer the person used last time (a brain that restarted for an update).
if !app.demo {
if let Some(h) = crate::remote::load_person_host() {
app.typed_on = Some(h);
}
}
if demo {
// The computer helper's pending step, so the picture shows the approval card.
let (reply, _) = channel();
// This Mac isn't allowed yet, so the bot's step pops up a request.
app.state.local_execution = false;
app.approvals.insert(5, Approval {
call_id: "c2".into(),
tool: "shell".into(),
reason: "在你的電腦上執行任務,而不只是在它自己的電腦上:mkdir -p 2026-04 2026-05 2026-06 && mv IMG_04*.jpg 2026-04/\n(在 你的電腦 上)".into(),
grant: "local:on".into(),
reply,
kind: ApprovalKind::EnableThisComputer,
});
app.streams.insert(5, Live {
cancel: Arc::new(AtomicBool::new(false)),
started: Instant::now(),
last: Instant::now(),
produced: true,
status: None,
paused: true,
steer: Arc::new(Mutex::new(vec!["2026-06 的照片先不要動".into()])),
direct: Arc::new(AtomicBool::new(false)),
turn_id: "demo".into(),
});
// Optional view for checking the dialogs in a picture.
match std::env::var("ANYBOT_SHOT_VIEW").as_deref() {
Ok("chat") => app.state.selected = 1,
// 頁面: a tracking page in the chat (pagecard), and opened (page).
Ok(v @ ("pagecard" | "page")) => {
app.state.selected = 1;
app.approvals.clear();
app.streams.remove(&5);
let (pages, next, id) = crate::pages::demo(crate::model::now_ms());
app.state.pages = pages;
app.state.next_page_id = next;
if let Some(b) = app.state.bot_mut(1) {
b.messages.push(Message::user("幫我追蹤一下台灣 FSD 狀況"));
let mut m = Message::assistant("交通部今天對台灣 FSD 有新的官方說法,但你 12 月交車時多半還是不能用,現在先別花錢。\n\nNOWnews 今天專訪交通部監理司專門委員。他說 7 月和 9 月已經開過兩次專家審查會議,交通部希望年底前完成相關審查,但不保證年底開放,而且路測計畫還沒核定。來源和細節都更新在[台灣 Tesla FSD 追蹤](page:1)。", "Master · Custom · qwen3.8-flash-next");
m.tool_calls.push(crate::model::ToolCall { id: "pg1".into(), name: "write_page".into(), args: serde_json::json!({"id": id, "title": "台灣 Tesla FSD 追蹤"}).to_string(), status: crate::model::ToolStatus::Done });
b.messages.push(m);
}
if v == "page" {
app.page_view = Some((id, None));
}
}
Ok("settings") => {
app.show_settings = true;
app.settings_tab = SettingsTab::Providers;
app.settings_provider = ProviderKind::Anthropic;
}
Ok("editor") => {
app.edit_bot = app.state.bot(5).cloned().map(|mut b| {
b.allow_rules = vec!["edit_file".into(), "shell:npm install".into()];
(b, false)
})
}
Ok("menu") => app.state.selected = 4,
Ok("rail") => app.state.sidebar_collapsed = true,
Ok("routines") => {
app.state.selected = 2;
app.show_routines = Some(2);
}
Ok("attach") => {
app.state.selected = 1;
let dir = std::env::temp_dir().join("anybot-shot-attach");
let _ = std::fs::create_dir_all(&dir);
let img = dir.join("行程截圖.png");
let mut pic = image::RgbImage::from_pixel(640, 400, image::Rgb([0xe8, 0xf0, 0xfb]));
for x in 40..600 { for y in 60..90 { pic.put_pixel(x, y, image::Rgb([0x16, 0x80, 0xf3])); } }
let _ = pic.save(&img);
let doc = dir.join("台南景點清單.md");
let _ = std::fs::write(&doc, "# 台南\n- 赤崁樓\n- 安平古堡\n");
for f in [&img, &doc] {
if let Ok(a) = crate::attach::store(1, f) {
app.att.pending.entry(1).or_default().push(a);
}
}
}
Ok("channels") => {
app.show_settings = true;
app.settings_tab = SettingsTab::Channels;
app.state.channels.push(crate::channels::Channel { id: 1, kind: crate::channels::Kind::Slack, name: "客服群".into(), bot_id: 2, token: "xoxb-demo".into(), target: "C0123ABCD".into(), enabled: true, client_id: String::new(), tenant: String::new() });
}
Ok("group") => {
let id = app.state.next_id;
app.state.next_id += 1;
let mut g = app.state.bots[0].clone();
g.id = id;
g.name = "旅遊小組".into();
g.description = "研究助理、寫作夥伴一起回答".into();
g.shape = "cloud".into();
g.members = vec![2, 3];
let mut m1 = Message::assistant("台南兩天:第一天安平、第二天府城老街。", "研究助理 · Claude · claude-opus-5-5");
m1.author = 2;
m1.reactions = vec!["👍".into()];
m1.tool_calls.push(crate::model::ToolCall { id: "card1".into(), name: "show_card".into(), args: serde_json::json!({"kind": "weather", "title": "台南 · 下週六", "subtitle": "中央氣象署預報", "rows": [{"label": "天氣", "value": "晴時多雲"}, {"label": "溫度", "value": "22–29°C"}, {"label": "降雨機率", "value": "10%"}], "link": "https://www.cwa.gov.tw"}).to_string(), status: crate::model::ToolStatus::Done });
let mut m2 = Message::assistant("補充一個寫遊記的角度:用「老屋裡的光」當主題,照片挑早上拍的。", "寫作夥伴 · OpenAI · gpt-5");
m2.author = 3;
g.messages = vec![Message::user("下個月去台南,大家給點建議"), m1, m2];
app.quoting.insert(id, "台南兩天:第一天安平、第二天府城老街。".into());
app.state.bots.push(g);
app.state.selected = id;
}
Ok("remote") => {
app.show_settings = true;
app.settings_tab = SettingsTab::Remote;
app.state.remote.password = "x$y".into();
app.state.remote.lan = true;
app.state.remote.relay_url = "https://anybot.example.com".into();
app.state.remote.relay_user = "daniel".into();
app.state.remote.host_token = "demo".into();
app.state.remote.host_name = "工作室 Mac".into();
app.state.remote.last_backup = chrono::Utc::now().timestamp() - 3_600;
app.state.remote.device_link = true;
app.state.remote.devices = vec![crate::remote::Device { name: "iPhone".into(), token_hash: String::new(), created: chrono::Utc::now().timestamp() - 86_400, last_seen: chrono::Utc::now().timestamp() - 600 }];
}
Ok("working") => {
// The computer helper mid-task with no pending request: the ring and phrase.
app.state.selected = 5;
app.approvals.remove(&5);
if let Some(b) = app.state.bot_mut(5) {
if let Some(m) = b.messages.iter_mut().rev().find(|m| m.role == Role::Assistant) {
for c in m.tool_calls.iter_mut() {
c.name = "read_file".into();
c.status = ToolStatus::Running;
}
}
}
}
Ok("files") | Ok("fileview") => {
for b in app.state.bots.iter_mut() {
if b.id == 2 || b.id == 3 {
b.folder = "工作".into();
}
}
let dir = std::env::temp_dir().join("anybot-shot-files");
let _ = std::fs::create_dir_all(&dir);
let csv = dir.join("orders_20261102_packages.csv");
let mut rows = String::from("訂單號,訂單時間,店鋪,商品名稱,數量\n");
for i in 0..22 {
rows.push_str(&format!("33165343590{:04},2026-10-29 20:13,{},特斯拉 Model Y 配件 {},{}\n", 5101 + i, ["3w旗艦店", "Tesla特斯拉官方", "特友之家旗艦店"][i % 3], i + 1, 1 + i % 3));
}
let _ = std::fs::write(&csv, rows);
let md = dir.join("台南兩天一夜.md");
let _ = std::fs::write(&md, "# 台南兩天一夜\n\n## 第一天\n- 赤崁樓\n- 國華街小吃\n\n## 第二天\n- 安平古堡\n- **四草綠色隧道**\n");
let html = dir.join("報價頁.html");
let _ = std::fs::write(&html, "<html><body><h1>報價</h1><p>總計 NT$ 12,800</p></body></html>");
let png = dir.join("行程地圖.png");
let mut pic = image::RgbImage::from_pixel(800, 500, image::Rgb([0xe8, 0xf0, 0xfb]));
for x in 100..700 { for y in 230..270 { pic.put_pixel(x, y, image::Rgb([0x16, 0x80, 0xf3])); } }
let _ = pic.save(&png);
let now = crate::model::now_ms();
let mut atts = vec![];
for f in [&csv, &md, &html, &png] {
if let Ok(a) = crate::attach::store(1, f) {
atts.push(a);
}
}
let mut u = Message::user("我有多了兩項訂單,請幫我更新,順便把行程整理成文件給我");
u.at = now - 26 * 3_600_000;
u.reactions = vec!["❤️".into()];
let mut a1 = Message::assistant("好,我去把新的兩張訂單抓下來,補進表格。", "");
a1.at = u.at + 4_000;
let mut a2 = Message::assistant("更新好了,兩張 10/31 的新訂單已經補在最上面。\n\n- 特友之家的車門垃圾桶(2 個裝)買 1 組\n- 特友黑洛第二代油門剎車踏板買 2 個\n\n行程和報價也整理好了:", "");
a2.at = u.at + 60_000;
a2.attachments = atts;
a2.reactions = vec!["👍".into()];
let mut u2 = Message::user("太好了,謝謝!");
u2.at = now - 60_000;
let mut b = app.state.bots[0].clone();
b.messages = vec![u, a1, a2, u2];
app.state.bots[0] = b;
app.state.selected = app.state.bots[0].id;
if std::env::var("ANYBOT_SHOT_VIEW").as_deref() == Ok("fileview") {
// ANYBOT_SHOT_FILE: preview that file instead of the sample spreadsheet.
match std::env::var("ANYBOT_SHOT_FILE") {
Ok(f) => {
let name = std::path::Path::new(&f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
app.open_file(&name, &f);
}
Err(_) => app.open_file("orders_20261102_packages.csv", &csv.display().to_string()),
}
}
}
// 需要處理: 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();
let _ = crate::cloudbox::ensure_running();
app.state.selected = 1;
app.panel = Some(panel_ui::PanelTab::Activity);
}
Ok("general") => {
app.show_settings = true;
app.settings_tab = SettingsTab::General;
}
Ok("computers") => {
app.show_settings = true;
app.settings_tab = SettingsTab::Computers;
}
Ok("markdown") => {
// Every Markdown element the chat draws, in one reply.
let text = "## 出差流程整理\n先看重點,**下週三前**要送出申請;細節在 [公司內網](https://example.com/travel)。\n第二行直接換行,不用空一行。\n\n### 步驟\n1. 填出差單\n2. 主管核准\n - 金額超過 3 萬要*總經理*簽\n - ~~紙本~~ 改用線上表單\n3. 訂機票和住宿\n\n- [x] 護照效期確認\n- [ ] 換外幣\n\n> 提醒:發票抬頭要寫公司全名。\n\n| 項目 | 上限 | 備註 |\n|---|--:|---|\n| 住宿 | 3,500 | 每晚 |\n| 交通 | 實報實銷 | 留收據 |\n| 餐費 | 800 | `每日` |\n\n```python\n# 算總額\ndef total(days, rate=3500):\n return days * rate + 800 * days\n```\n\n```mermaid\nflowchart LR\n A[填出差單] --> B{超過 3 萬?}\n B -->|是| C[總經理簽核]\n B -->|否| D[主管簽核]\n C --> E[訂機票住宿]\n D --> E\n```\n\n---\n有問題再問我!";
let mut u = Message::user("幫我整理出差流程");
u.at = crate::model::now_ms() - 120_000;
let mut a = Message::assistant(text, "");
a.at = u.at + 5_000;
let mut b = app.state.bots[0].clone();
b.messages = vec![u, a];
app.state.bots[0] = b;
app.state.selected = app.state.bots[0].id;
}
Ok("vault") => {
app.show_settings = true;
app.settings_tab = SettingsTab::Vault;
}
Ok("review") => {
app.show_settings = true;
app.settings_tab = SettingsTab::Review;
app.state.auto_review = true;
app.state.review_allow_rules = vec!["在工作資料夾裡建立或修改文字檔".into()];
app.state.review_ask_rules = vec!["刪除任何檔案".into(), "花錢或付款".into()];
}
_ => {}
}
if let Ok(l) = std::env::var("ANYBOT_SHOT_LANG") {
app.state.language = l.clone();
crate::i18n::set(&l);
}
if std::env::var("ANYBOT_SHOT_DARK").is_ok() {
app.state.theme = Theme::Dark;
}
// For checking each screen without the demo's waiting pop-up on top.
if std::env::var("ANYBOT_SHOT_NOPOPUP").is_ok() {
app.popup_hidden.extend(app.state.bots.iter().map(|b| b.id));
}
}
app.apply_theme(ctx);
// Real-data screenshots can open 設定 › 手機與帳號 (to press its buttons with ANYBOT_SHOT_CLICK).
if app.screenshot.is_some() && !app.demo && std::env::var("ANYBOT_SHOT_VIEW").as_deref() == Ok("remote") {
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
}
fn apply_theme(&mut self, ctx: &egui::Context) {
let dark = match self.state.theme {
Theme::Light => false,
Theme::Dark => true,
Theme::System => ctx.input(|i| i.raw.system_theme) == Some(egui::Theme::Dark),
};
self.pal = if dark { DARK } else { LIGHT };
let p = self.pal;
let mut v = if dark { egui::Visuals::dark() } else { egui::Visuals::light() };
v.panel_fill = p.pane;
v.window_fill = p.elevated;
v.extreme_bg_color = p.sidebar;
v.text_edit_bg_color = Some(p.sidebar);
v.faint_bg_color = p.hover;
v.override_text_color = Some(p.text);
v.window_stroke = Stroke::new(1.0, p.border);
v.window_corner_radius = CornerRadius::same(14);
v.menu_corner_radius = CornerRadius::same(10);
v.selection.bg_fill = p.focus.gamma_multiply(0.35);
v.selection.stroke = Stroke::new(1.0, p.focus);
v.hyperlink_color = p.accent;
for w in [&mut v.widgets.inactive, &mut v.widgets.hovered, &mut v.widgets.active, &mut v.widgets.open] {
w.corner_radius = CornerRadius::same(8);
}
v.widgets.inactive.weak_bg_fill = p.hover;
v.widgets.inactive.bg_fill = p.hover;
v.widgets.hovered.weak_bg_fill = p.active;
v.widgets.hovered.bg_fill = p.active;
v.widgets.active.weak_bg_fill = p.active;
v.widgets.inactive.bg_stroke = Stroke::NONE;
v.widgets.hovered.bg_stroke = Stroke::NONE;
v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, p.border);
ctx.set_visuals(v);
ctx.all_styles_mut(|s| {
// Menus like the original's: 16 px corners, 6 px inside, rows touching.
s.visuals.menu_corner_radius = CornerRadius::same(16);
s.spacing.menu_margin = Margin::same(6);
s.spacing.menu_spacing = 0.0;
s.visuals.popup_shadow = egui::epaint::Shadow { offset: [0, 10], blur: 30, spread: 0, color: Color32::from_black_alpha(70) };
s.spacing.item_spacing = vec2(8.0, 6.0);
s.spacing.button_padding = vec2(10.0, 5.0);
s.text_styles.insert(egui::TextStyle::Body, egui::FontId::proportional(FONT_BASE));
s.text_styles.insert(egui::TextStyle::Button, egui::FontId::proportional(FONT_BASE));
s.text_styles.insert(egui::TextStyle::Small, egui::FontId::proportional(FONT_XS));
s.text_styles.insert(egui::TextStyle::Heading, egui::FontId::proportional(FONT_XL));
s.text_styles.insert(egui::TextStyle::Monospace, egui::FontId::monospace(FONT_SM));
});
}
fn mark(&mut self) {
self.dirty = true;
}
fn bot_color(&self, b: &Bot) -> Color32 {
let c = avatar::color(&b.color);
// A black avatar would vanish on the dark page: lift it to a dark grey there.
if self.pal.pane != LIGHT.pane && (c.r() as u32 + c.g() as u32 + c.b() as u32) < 120 {
return Color32::from_gray(0x4a);
}
c
}
fn user_bubble(&self, b: &Bot) -> Color32 {
match self.state.accent {
Accent::BotColor if b.color != "black" => self.bot_color(b),
_ => self.pal.bubble_user,
}
}
// ---------- chat ----------
fn send(&mut self, ctx: &egui::Context, bot_id: u64) {
let mut text = self.drafts.get(&bot_id).cloned().unwrap_or_default();
if text.trim().is_empty() && !self.has_pending_attachments(bot_id) {
return;
}
// 引用: the quoted words go first, as a Markdown quote.
if let Some(q) = self.quoting.remove(&bot_id) {
let quoted: String = q.lines().take(8).map(|l| format!("> {l}\n")).collect();
text = format!("{quoted}\n{}", text.trim());
}
let attachments = self.take_attachments(bot_id);
if self.mir.on {
if self.mirror_send(bot_id, text.trim().to_string(), attachments) {
self.drafts.insert(bot_id, String::new());
}
return;
}
self.drafts.insert(bot_id, String::new());
// Typed here: this computer is the person's.
self.typed_on = Some(String::new());
// While it works, a new message steers the running turn (the original's `turnSteer`:
// it doesn't count as a new turn) and reaches the model at its next step.
if let Some(l) = self.streams.get(&bot_id) {
let (extra, _) = crate::attach::for_model(&attachments);
l.steer.lock().unwrap().push(format!("{}{extra}", text.trim()));
return;
}
if let Some(b) = self.state.bot_mut(bot_id) {
b.messages.push(Message { attachments, ..Message::user(text.trim()) });
}
self.start_reply(ctx, bot_id);
}
fn start_reply(&mut self, ctx: &egui::Context, bot_id: u64) {
if self.mir.on {
self.mirror_act(serde_json::json!({"bot": bot_id, "action": "reply"}));
return;
}
let Some(conv) = self.state.bot(bot_id).cloned() else { return };
// 群組聊天: the next member answers, with its own model, instructions and computers.
let speaker = if conv.members.is_empty() {
None
} else {
let q = self.group_queue.entry(bot_id).or_default();
if q.is_empty() {
q.extend(conv.members.iter().copied().filter(|m| *m != bot_id));
}
let mut next = None;
while let Some(m) = q.pop_front() {
if self.state.bot(m).is_some() {
next = Some(m);
break;
}
}
match next {
Some(m) => Some(m),
None => return,
}
};
let b = match speaker.and_then(|m| self.state.bot(m).cloned()) {
Some(member) => member,
None => conv.clone(),
};
let ps = self.state.provider(b.provider);
// Computer tools are always offered; if they're off for this bot (or this Mac isn't
// allowed yet), using one pops up a request instead of failing quietly.
let computers = self.targets_for(&b);
let roles = self.roles_for(&b, &computers);
let names: Vec<String> = computers.iter().map(|t| t.name.clone()).collect();
let (default_computer, missing_computer) = Self::chosen_computer(&b, &computers, &roles);
let mut tool_specs = appctl::specs();
tool_specs.extend(tools::specs_for(&names, &default_computer));
// Browsers: the bot's own computer's Chromium, this computer's AnyBot browser, and the
// account's other computers' ones (each with its own logins).
let browsers: Vec<String> = computers
.iter()
.filter(|t| match t.place {
tools::Place::Box { .. } => crate::cloudbox::available(),
tools::Place::Local { .. } => crate::botbrowser::available(),
tools::Place::Device { .. } => true,
_ => false,
})
.map(|t| t.name.clone())
.collect();
let browser_default = if browsers.contains(&default_computer) { default_computer.clone() } else { roles.person.clone().filter(|p| browsers.contains(p)).or_else(|| browsers.first().cloned()).unwrap_or_default() };
tool_specs.extend(web::specs_for(&browsers, &browser_default));
// Screens: the bot's own desktop (when it has one), this computer's (when it has one), and
// the account's other computers (their own AnyBot looks and clicks there).
let screens: Vec<String> = computers
.iter()
.filter(|t| match t.place {
tools::Place::Box { .. } => crate::cloudbox::available(),
tools::Place::Local { .. } => crate::computer::screen_supported(),
tools::Place::Device { .. } => true,
_ => false,
})
.map(|t| t.name.clone())
.collect();
// The person's Mac apps (mail, notes, calendar…): here on a Mac, or on the computer the
// person is using when this is a server.
let person_is_device = roles.person.as_ref().map(|n| computers.iter().any(|t| &t.name == n && matches!(t.place, tools::Place::Device { .. }))).unwrap_or(false);
let mac_apps = cfg!(target_os = "macos") || (roles.server && person_is_device);
if mac_apps {
tool_specs.extend(crate::macapps::specs().into_iter().filter(|t| self.state.messages_send || t["name"] != crate::macapps::SEND_MESSAGE));
}
tool_specs.extend(crate::mcp::specs());
tool_specs.extend(crate::phone::specs());
tool_specs.push(serde_json::json!({"name": "show_card", "description": "Show a tidy card in the chat for things like a flight, a restaurant or place, the weather, a product or an event (the original's life cards). Fill it from what you found; give the source link.",
"parameters": {"type": "object", "properties": {
"kind": {"type": "string", "enum": ["flight", "place", "weather", "product", "event", "other"]},
"title": {"type": "string"}, "subtitle": {"type": "string"},
"rows": {"type": "array", "items": {"type": "object", "properties": {"label": {"type": "string"}, "value": {"type": "string"}}, "required": ["label", "value"]}},
"link": {"type": "string"}}, "required": ["kind", "title"]}}));
tool_specs.push(serde_json::json!({"name": "react", "description": "Put an emoji reaction on the person's latest message, like a friend would (e.g. ❤️ for thanks or good news, 👍 for OK, 😂 for a joke, 🎉 for a win, 👀 when you'll look into it). Use it now and then where it fits; you can still answer in words.",
"parameters": {"type": "object", "properties": {"emoji": {"type": "string", "description": "One emoji"}}, "required": ["emoji"]}}));
tool_specs.push(serde_json::json!({"name": "send_file", "description": "Send a file you made (spreadsheet, document, Markdown, web page, picture, PDF…) into the chat as a file card the person can preview and download. Use this to hand over results instead of only saying where the file is.",
"parameters": {"type": "object", "properties": {"path": {"type": "string", "description": "File path (relative to your folder)"}, "computer": {"type": "string", "enum": names.clone()}}, "required": ["path"]}}));
tool_specs.push(serde_json::json!({"name": "open_for_person", "description": "Open a file you made (a web page, chart, report, picture, document) on the person's screen so they can see it, or a web address. From the bot's computer it is copied over first.",
"parameters": {"type": "object", "properties": {"path": {"type": "string", "description": "File path (relative to your folder) or http(s) address"}, "computer": {"type": "string", "enum": names.clone()}}, "required": ["path"]}}));
if !self.state.remote.host_token.is_empty() {
tool_specs.push(serde_json::json!({"name": "publish_site", "description": "Publish a web page or a folder with index.html that you made as a public website on the person's AnyBot account server. Publishing again with the same name replaces it. Give the person the address you get back.",
"parameters": {"type": "object", "properties": {"path": {"type": "string", "description": "Folder with index.html, or one .html file"}, "name": {"type": "string", "description": "Short site name: letters, digits and -"}, "computer": {"type": "string", "enum": names.clone()}}, "required": ["path", "name"]}}));
}
if crate::botbrowser::available() {
tool_specs.extend(crate::vault::specs());
}
if b.demos.iter().any(|d| d.shots.iter().any(|s| !s.is_empty())) {
tool_specs.push(crate::teach::view_step_spec());
}
if !screens.is_empty() {
let default_screen = if screens.contains(&default_computer) { default_computer.clone() } else { screens[0].clone() };
Whole control flow checked against real chats: what the bot sees is what is there, every step lands, nothing done twice Web - web_fetch kept pages with a <header> only up to it (taken for <head>): articles came back as their menus. - browser_open waits for the page to load and returns it like browser_read (one step fewer per page); actions wait for the page to hold still instead of fixed 1-2.5 s sleeps; one way to reach the tab. The bot's computer (box v6) - Numbers only on what can be seen: clipped to the window and the page's visible area, none under another app's window (a link scrolled under the toolbar was numbered on Back). - press_key with a typed character no longer swallows the keys after it (xdotool type --args 1). - The person's input through noVNC/TigerVNC is noticed again (fence, continuous updates, QEMU keys). - "Nothing changed" only after clicks, scrolls and drags; long text is pasted; one screen step at a time; one docker inspect per step when the box is known ready; poppler-utils for long PDFs. - One typing path and fewer round trips (stdin variant, unused picture path, extra batch execs gone). The person's computers - Linux: the window system's front app when it has one, and the reason when there is none (was 「」); hidden and off-screen controls get no number; wording no longer names a tool that doesn't exist. The loop - Without Docker, the bot's own computer runs no commands where there is no sandbox (Linux, Windows): it was a plain shell on the brain itself, keys in reach, asked nothing. - The bot's own computer is re-checked each step; unreachable computers are failures, not safety blocks; no direct-mode note on every result; an empty reply is asked again; scrolling and keys aren't counted as going in circles; steps aren't moved to a computer that can't do them. - One finish() for every step's result, keep_grant(), dead appctl list_computers/SERVING and the impossible server branches removed; duplicated prompt instructions merged. Tests: 162 (Mac) / 166 (Linux) unit; real box drills 6/6 on box v6; a brain without Chrome browses on its box; remote E2E 28/28. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
2026-11-06 09:44:59 +00:00
tool_specs.extend(crate::computer::specs_for(&screens, &default_screen, screens.contains(&default_computer)));
// 需要處理: 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());
let req = ChatRequest {
kind: b.provider,
base_url: providers::resolve_base(b.provider, &ps.base_url),
api_key: providers::resolve_key(b.provider, &ps.api_key),
model: b.model.clone(),
system: {
let mut sys = system_prompt(&b, &self.state, &computers, &default_computer, &roles, missing_computer.as_deref(), &self.computer_lines(&computers, &roles), mac_apps);
if speaker.is_some() {
let names: Vec<String> = conv.members.iter().filter_map(|m| self.state.bot(*m)).map(|x| x.name.clone()).collect();
sys.push_str(&format!("\n\n你在群組聊天「{}」裡,成員依序回答:{}。其他成員的發言以【名字】標示。只回答你擅長的部分,補充不同的觀點,不要重複別人說過的;沒有要補充就簡短說一句。", conv.name, names.join("、")));
}
sys
},
history: group_history(&conv.messages, speaker, &self.state),
tools: tool_specs,
session: format!("anybot-bot-{bot_id}"),
};
let via = if speaker.is_some() { format!("{} · {} · {}", b.name, b.provider.label(), b.model) } else { format!("{} · {}", b.provider.label(), b.model) };
if let Some(c) = self.state.bot_mut(bot_id) {
c.messages.push(Message { author: speaker.unwrap_or(0), ..Message::assistant("", &via) });
}
let cancel = Arc::new(AtomicBool::new(false));
let steer = Arc::new(Mutex::new(Vec::new()));
let turn_id = format!("turn-{bot_id}-{}", chrono::Utc::now().timestamp_millis());
let direct = Arc::new(AtomicBool::new(self.direct_on()));
self.streams.insert(bot_id, Live { cancel: cancel.clone(), started: Instant::now(), last: Instant::now(), produced: false, status: None, paused: false, steer: steer.clone(), turn_id, direct: direct.clone() });
self.stick_bottom = true;
let review = self.state.auto_review.then(|| agent::Review { allow: self.state.review_allow_rules.clone(), ask: self.state.review_ask_rules.clone() });
let job = agent::Job {
req,
computers,
default_computer,
roles,
local_execution: self.local_on(),
local_locked: !self.managed.local.allows() || self.state.local_never,
tools_enabled: b.tools_enabled,
direct,
grants: {
let mut g = b.allow_rules.clone();
if self.state.messages_no_ask {
g.push("messages:no-ask".into());
}
g
},
context_tokens: b.context_tokens.max(4_000),
via,
via_name: b.name.clone(),
steer,
review,
demos: b.demos.clone(),
};
agent::spawn(job, bot_id, self.tx.clone(), cancel, ctx.clone());
// (Its eyes start reading while it works; see face_pose.)
self.mark();
}
/// The computers a bot can be told to use, each under its own name: the bot's own
/// (「Master 的電腦」), this computer, the SSH ones, then the account's other computers.
pub(super) fn targets_for(&self, b: &Bot) -> Vec<tools::Target> {
let mut v = vec![tools::Target {
name: crate::model::own_computer_name(&b.name),
place: if crate::cloudbox::available() { tools::Place::Box { dir: crate::cloudbox::bot_dir(b.id) } } else { tools::Place::Sealed { root: crate::model::bot_computer_dir(b.id) } },
}];
// Two computers never share a name (the bot and the person name them to choose).
let add = |v: &mut Vec<tools::Target>, mut t: tools::Target| {
let base = t.name.clone();
let mut n = 2;
while v.iter().any(|x| x.name == t.name) {
t.name = format!("{base} ({n})");
n += 1;
}
v.push(t);
};
// A brain on a server works on the bots' own computer and the person's computers, never on
// the server itself (the person asked for it to be off the list).
if !self.headless {
add(&mut v, tools::Target { name: self.state.this_computer_name(), place: tools::Place::Local { workdir: tools::expand_home(if b.workdir.trim().is_empty() { "~" } else { b.workdir.trim() }) } });
}
for c in &self.state.remote_computers {
add(&mut v, tools::Target { name: c.name.clone(), place: tools::Place::Ssh { target: c.ssh_target.clone(), dir: c.dir.clone() } });
}
// 裝置連結: other AnyBots of this account that allow it, working in the bot's work folder.
for d in self.linked_devices() {
let mut d = d;
if let tools::Place::Device { dir, .. } = &mut d.place {
dir.clone_from(&b.workdir);
}
add(&mut v, d);
}
v
}
/// Which of these computers is the bot's own, which one the person is using, and whether
/// this AnyBot is a server. The person's computer is the one they last typed on; a server
/// that hasn't heard from any yet takes the account's only other computer, if there is one.
pub(super) fn roles_for(&self, b: &Bot, targets: &[tools::Target]) -> tools::Roles {
let server = self.headless;
let local = targets.iter().find(|t| matches!(t.place, tools::Place::Local { .. })).map(|t| t.name.clone());
let device = |h: &str| targets.iter().find(|t| matches!(&t.place, tools::Place::Device { host, .. } if host == h)).map(|t| t.name.clone());
let person = match self.typed_on.as_deref() {
Some(h) if !h.is_empty() => device(h).or_else(|| if server { None } else { local.clone() }),
_ if !server => local,
// Not heard yet (or only the phone): when the account has one other computer, that
// is it; with more, nobody guesses (the bot asks which one).
_ => {
let mut devices = targets.iter().filter(|t| matches!(t.place, tools::Place::Device { .. }));
match (devices.next(), devices.next()) {
(Some(only), None) => Some(only.name.clone()),
_ => None,
}
}
};
tools::Roles { own: crate::model::own_computer_name(&b.name), person, server }
}
/// The computer the person chose for this bot, by its name now (the bot's own is kept under a
/// fixed key so renaming the bot keeps it), and the chosen one when it isn't connected.
pub(super) fn chosen_computer(b: &Bot, targets: &[tools::Target], roles: &tools::Roles) -> (String, Option<String>) {
let want = b.default_computer.trim();
if want.is_empty() || want == crate::model::BOT_COMPUTER_NAME || want == roles.own {
return (roles.own.clone(), None);
}
// Unless the person chose another computer, a bot works on its own computer (in the
// cloud). 「你的電腦」 from older versions named no computer in particular: the bot's own.
if want == crate::model::THIS_COMPUTER_NAME {
return (roles.own.clone(), None);
}
if targets.iter().any(|t| t.name == want) {
return (want.to_string(), None);
}
(roles.own.clone(), Some(want.to_string()))
}
/// What to keep in `default_computer` for a computer chosen by name.
pub(super) fn computer_to_keep(b: &Bot, name: &str) -> String {
if name == crate::model::own_computer_name(&b.name) {
crate::model::BOT_COMPUTER_NAME.to_string()
} else {
name.to_string()
}
}
/// The computers to choose from for a bot (name, what it is) and the chosen one. A screen of a
/// brain asks the brain (only it knows the account's computers and which one is chosen).
pub(super) fn computer_choices(&self, b: &Bot) -> (Vec<(String, String)>, String) {
if self.mir.on {
let own = crate::model::own_computer_name(&b.name);
return match self.mir.computers.get(&b.id) {
Some(c) => (
c["names"].as_array().map(|a| a.iter().filter_map(|x| Some((x["name"].as_str()?.to_string(), x["kind"].as_str().unwrap_or("").to_string()))).collect()).unwrap_or_default(),
c["current"].as_str().unwrap_or(&own).to_string(),
),
None => (vec![(own.clone(), "own".into())], own),
};
}
let t = self.targets_for(b);
let r = self.roles_for(b, &t);
let (current, _) = Self::chosen_computer(b, &t, &r);
(t.iter().map(|x| (x.name.clone(), mirror_ui::computer_kind(x, &r).to_string())).collect(), current)
}
/// Choose the computer a bot works on (a screen sends it to the brain with its other edits).
pub(super) fn set_computer(&mut self, bot_id: u64, name: &str) {
if let Some(b) = self.state.bot_mut(bot_id) {
let keep = Self::computer_to_keep(b, name);
if b.default_computer != keep {
b.default_computer = keep;
self.mark();
}
}
// Shown at once on a screen (the brain's answer follows).
if let Some(c) = self.mir.computers.get_mut(&bot_id) {
c["current"] = serde_json::json!(name);
}
}
/// 查看電腦 and the instructions: each computer by its name, what it is, in plain words.
pub(super) fn computer_lines(&self, targets: &[tools::Target], roles: &tools::Roles) -> Vec<String> {
targets
.iter()
.map(|t| {
let yours = roles.person.as_deref() == Some(t.name.as_str());
match &t.place {
tools::Place::Box { dir } => {
let secrets = crate::cloudbox::secret_names();
format!(
"「{}」:你自己的 Linux 電腦(Debian,有桌面和 Chromium;你的資料夾是 {dir},可以 sudo 安裝套件),不是使用者的電腦,一律可用,需要時會自動喚醒。{}",
t.name,
if secrets.is_empty() { String::new() } else { format!("使用者設好的環境密鑰(已是環境變數,值不要印出來):{}。", secrets.join("、")) }
)
}
Whole control flow checked against real chats: what the bot sees is what is there, every step lands, nothing done twice Web - web_fetch kept pages with a <header> only up to it (taken for <head>): articles came back as their menus. - browser_open waits for the page to load and returns it like browser_read (one step fewer per page); actions wait for the page to hold still instead of fixed 1-2.5 s sleeps; one way to reach the tab. The bot's computer (box v6) - Numbers only on what can be seen: clipped to the window and the page's visible area, none under another app's window (a link scrolled under the toolbar was numbered on Back). - press_key with a typed character no longer swallows the keys after it (xdotool type --args 1). - The person's input through noVNC/TigerVNC is noticed again (fence, continuous updates, QEMU keys). - "Nothing changed" only after clicks, scrolls and drags; long text is pasted; one screen step at a time; one docker inspect per step when the box is known ready; poppler-utils for long PDFs. - One typing path and fewer round trips (stdin variant, unused picture path, extra batch execs gone). The person's computers - Linux: the window system's front app when it has one, and the reason when there is none (was 「」); hidden and off-screen controls get no number; wording no longer names a tool that doesn't exist. The loop - Without Docker, the bot's own computer runs no commands where there is no sandbox (Linux, Windows): it was a plain shell on the brain itself, keys in reach, asked nothing. - The bot's own computer is re-checked each step; unreachable computers are failures, not safety blocks; no direct-mode note on every result; an empty reply is asked again; scrolling and keys aren't counted as going in circles; steps aren't moved to a computer that can't do them. - One finish() for every step's result, keep_grant(), dead appctl list_computers/SERVING and the impossible server branches removed; duplicated prompt instructions merged. Tests: 162 (Mac) / 166 (Linux) unit; real box drills 6/6 on box v6; a brain without Chrome browses on its box; remote E2E 28/28. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
2026-11-06 09:44:59 +00:00
tools::Place::Sealed { root } if tools::sealed_runs_commands() => format!("「{}」:你自己的電腦(獨立資料夾 {},看不到使用者的檔案;沒有螢幕),一律可用。", t.name, root.display()),
tools::Place::Sealed { root } => format!("「{}」:你自己的電腦現在沒有開(AnyBot 所在的這台沒有 Docker):只能在你的資料夾 {} 讀寫檔案,不能執行指令,也沒有螢幕。", t.name, root.display()),
tools::Place::Local { workdir } => format!(
"「{}」:AnyBot 開著的這台{}(起始資料夾 {}){},{}。",
t.name,
if cfg!(target_os = "macos") { " Mac" } else { "電腦(快捷鍵用 Ctrl,不是 ⌘)" },
workdir.display(),
if yours { ",使用者現在就在用這台" } else { "" },
if self.state.local_execution { "已允許" } else { "還沒預先允許(直接用工具,App 會問使用者)" }
),
tools::Place::Ssh { target, .. } => format!("「{}」:使用者的另一台電腦,透過 SSH 連到 {target}。", t.name),
tools::Place::Device { .. } => format!(
"「{}」:使用者帳號裡的電腦,透過它上面開著的 AnyBot 操作(檔案、指令、瀏覽器、看畫面和點擊){}。",
t.name,
if yours { ",使用者現在就是在這台跟你說話" } else { "" }
),
}
})
.collect()
}
/// 查看電腦: the computers this bot really has, in plain words.
fn computers_text(&self, b: &Bot) -> String {
let targets = self.targets_for(b);
let roles = self.roles_for(b, &targets);
let (chosen, missing) = Self::chosen_computer(b, &targets, &roles);
let mut lines = self.computer_lines(&targets, &roles);
lines.push(format!("使用者選的電腦:「{chosen}」"));
if let Some(m) = missing {
lines.push(format!("(使用者原本選的「{m}」現在沒有連線:那台的 AnyBot 沒開或沒登入。)"));
}
lines.join("\n")
}
/// update_bot with a computer: the names are this bot's computers (the account's ones too),
/// which only the app knows; the rest of the change goes through appctl as usual.
fn update_bot_computer(&mut self, bot_id: u64, args: &str) -> appctl::Outcome {
let mut v: serde_json::Value = serde_json::from_str(args).unwrap_or_default();
let want = v["default_computer"].as_str().unwrap_or("").trim().to_string();
let fail = |text: String| appctl::Outcome { text, ok: false, wake: vec![], theme_changed: false, page: None };
let target = match v["bot"].as_str().map(str::trim).filter(|n| !n.is_empty()) {
Some(n) => match self.state.bots.iter().find(|b| b.name == n) {
Some(b) => b.id,
None => return fail(format!("找不到叫「{n}」的 Bot")),
},
None => bot_id,
};
let Some(b) = self.state.bot(target).cloned() else { return fail("找不到這個 Bot".into()) };
let targets = self.targets_for(&b);
let roles = self.roles_for(&b, &targets);
let name = if want == crate::model::BOT_COMPUTER_NAME || want == crate::model::THIS_COMPUTER_NAME { Self::chosen_computer(&Bot { default_computer: want.clone(), ..b.clone() }, &targets, &roles).0 } else { want.clone() };
if !targets.iter().any(|t| t.name == name) {
return fail(format!("沒有「{want}」這台電腦。可以用:{}", targets.iter().map(|t| t.name.as_str()).collect::<Vec<_>>().join("、")));
}
if let Some(bm) = self.state.bot_mut(target) {
bm.default_computer = Self::computer_to_keep(&b, &name);
}
self.mark();
if let Some(o) = v.as_object_mut() {
o.remove("default_computer");
}
let more = v.as_object().map(|o| o.keys().any(|k| k != "bot")).unwrap_or(false);
let note = format!("已把「{}」的電腦改成「{name}」。", b.name);
if more {
let mut out = appctl::apply(&mut self.state, bot_id, "update_bot", &v.to_string());
out.text = format!("{note}{}", out.text);
out
} else {
appctl::Outcome { text: note, ok: true, wake: vec![], theme_changed: false, page: None }
}
}
/// Record how a turn ended (success / cancelled / error) and clear its live state.
fn settle(&mut self, bot_id: u64, outcome: TurnOutcome) {
// 群組聊天: the next member answers after this one finishes well.
if self.group_queue.get(&bot_id).map(|q| !q.is_empty()).unwrap_or(false) {
if outcome == TurnOutcome::Success {
self.group_next.push(bot_id);
} else {
self.group_queue.remove(&bot_id);
}
}
let group_continues = self.group_next.contains(&bot_id);
if !group_continues {
self.channels_settled(bot_id, outcome);
}
if outcome == TurnOutcome::Success && !self.demo {
let in_call = self.call_state().map(|c| c.0 == bot_id).unwrap_or(false);
if let Some(b) = self.state.bot(bot_id).filter(|b| b.speak_replies && !in_call) {
if let Some(m) = b.messages.iter().rev().find(|m| m.role == Role::Assistant && !m.text.trim().is_empty() && !m.error) {
crate::voice::speak_as(&m.text, &b.voice, b.voice_rate);
}
}
}
if outcome == TurnOutcome::Success {
let now = self.faces.get(&bot_id).map(|f| f.now()).unwrap_or(0.0);
self.poke(bot_id, sidebar_ui::Poke::Nod, now);
}
let away = self.away() && self.state.notify;
if let Some(l) = self.streams.remove(&bot_id) {
if let Some(b) = self.state.bot_mut(bot_id) {
b.last_turn = Some(TurnSettlement { turn_id: l.turn_id, outcome, settled_at_ms: chrono::Utc::now().timestamp_millis() });
// 「Bot 完成任務…時通知你」: a banner when it finishes while you're away.
if outcome == TurnOutcome::Success && away && !self.demo {
let last = b.messages.iter().rev().find(|m| m.role == Role::Assistant && !m.text.is_empty()).map(|m| first_line(&m.text, 80)).unwrap_or_default();
if !self.headless {
notify::banner(&format!("{} 完成了", b.name), &last);
}
crate::remote::notify(&format!("{} 完成了", b.name), &last, bot_id);
}
}
// Messages that arrived too late to steer start the next turn.
let leftover: Vec<String> = std::mem::take(&mut *l.steer.lock().unwrap());
if !leftover.is_empty() {
self.pending_turns.push((bot_id, leftover));
}
}
self.approvals.remove(&bot_id);
self.app_pending.remove(&bot_id);
}
/// Wake a bot with a message: a new turn when idle, steering when it is already working.
fn wake(&mut self, ctx: &egui::Context, bot_id: u64, msg: Message) {
if let Some(l) = self.streams.get(&bot_id) {
l.steer.lock().unwrap().push(msg.text);
return;
}
let selected = self.state.selected;
if let Some(b) = self.state.bot_mut(bot_id) {
b.messages.push(msg);
if bot_id != selected {
b.unread = true;
}
}
self.start_reply(ctx, bot_id);
}
/// Run due routines (a routine firing is a "wake" turn).
fn tick_routines(&mut self, ctx: &egui::Context) {
if self.demo || self.last_tick.elapsed() < std::time::Duration::from_secs(1) {
return;
}
self.last_tick = Instant::now();
let now = crate::routines::now();
let mut fire = vec![];
for b in self.state.bots.iter_mut() {
if self.streams.contains_key(&b.id) {
continue; // fires once the bot is idle
}
for r in b.routines.iter_mut().filter(|r| !r.paused) {
if r.next_run.map(|t| t <= now).unwrap_or(false) {
r.last_run = Some(now);
r.next_run = crate::routines::next_run(&r.schedule, r.created_at, now);
fire.push((b.id, appctl::wake_message(r)));
break;
}
}
}
for (id, msg) in fire {
self.wake(ctx, id, msg);
self.dirty = true;
}
for id in std::mem::take(&mut self.group_next) {
if !self.streams.contains_key(&id) {
self.start_reply(ctx, id);
} else {
// Still busy: keep the turn queued instead of dropping it with the take().
self.group_next.push(id);
}
}
for (id, texts) in std::mem::take(&mut self.pending_turns) {
if let Some(b) = self.state.bot_mut(id) {
for t in texts {
b.messages.push(Message::user(&t));
}
}
if self.streams.contains_key(&id) {
// A turn just settled in the same tick we queued this one; starting now would
// replace the running Live and let two agent threads write one chat. Retry idle.
self.group_next.push(id);
} else {
self.start_reply(ctx, id);
}
}
}
fn run_app_action(&mut self, ctx: &egui::Context, bot_id: u64, call: AppCall) {
let out = if call.name == "list_computers" {
let text = self.state.bot(bot_id).cloned().map(|b| self.computers_text(&b)).unwrap_or_default();
appctl::Outcome { text, ok: true, wake: vec![], theme_changed: false, page: None }
} else if call.name == "update_bot" && serde_json::from_str::<serde_json::Value>(&call.args).map(|v| v["default_computer"].is_string()).unwrap_or(false) {
self.update_bot_computer(bot_id, &call.args)
} else {
appctl::apply(&mut self.state, bot_id, &call.name, &call.args)
};
// write_page: remember which page the call made, so its card shows under the reply.
if let Some(pid) = out.page {
if let Some(c) = self.state.bot_mut(bot_id).and_then(|b| b.messages.iter_mut().rev().take(12).flat_map(|m| m.tool_calls.iter_mut()).find(|c| c.id == call.call_id)) {
let mut a: serde_json::Value = serde_json::from_str(&c.args).unwrap_or(serde_json::json!({}));
a["id"] = serde_json::json!(pid);
// The card shows the page itself; the long content needn't stay twice in the chat.
if let Some(o) = a.as_object_mut() {
o.remove("content");
}
c.args = a.to_string();
}
}
if out.theme_changed {
self.apply_theme(ctx);
}
let _ = call.reply.send((out.text, out.ok));
for (target, msg) in out.wake {
self.wake(ctx, target, msg);
}
self.dirty = true;
}
fn stop(&mut self, bot_id: u64) {
if self.mir.on {
if self.streams.contains_key(&bot_id) || self.approvals.contains_key(&bot_id) {
self.mirror_act(serde_json::json!({"bot": bot_id, "action": "stop"}));
}
return;
}
self.approvals.remove(&bot_id);
if let Some(call) = self.app_pending.remove(&bot_id) {
let _ = call.reply.send(("已停止".into(), false));
}
if let Some(live) = self.streams.get(&bot_id) {
live.cancel.store(true, Ordering::Relaxed);
// Stopping keeps what was typed but not yet read: it becomes the next turn only if sent again.
live.steer.lock().unwrap().clear();
self.settle(bot_id, TurnOutcome::Cancelled);
if let Some(b) = self.state.bot_mut(bot_id) {
for c in b.messages.iter_mut().rev().take(6).flat_map(|m| m.tool_calls.iter_mut()) {
if matches!(c.status, ToolStatus::Waiting | ToolStatus::Running) {
c.status = ToolStatus::Denied;
}
}
if let Some(m) = b.messages.last_mut() {
if m.role == Role::Assistant && m.text.is_empty() && m.tool_calls.is_empty() {
m.text = "(已停止)".into();
}
}
}
self.mark();
}
}
/// "Restart" a stuck bot: stop its turn, approvals and background commands, and mark any
/// half-finished reply as interrupted so it can be retried. Chats and files stay.
fn restart_bot(&mut self, bot_id: u64) {
self.stop(bot_id);
let stopped = tools::stop_for(&bot_id.to_string());
if let Some(b) = self.state.bot_mut(bot_id) {
b.repair_interrupted();
let name = b.name.clone();
self.notice = Some(if stopped > 0 { format!("已重新啟動「{name}」,也停止了 {stopped} 個背景指令。") } else { format!("已重新啟動「{name}」。") });
}
self.dirty = true;
}
/// 直接操作: one switch instead of many. Turns on this Mac, every bot's tools and auto-review,
/// starts the "always ask" rules with a few safe defaults if there are none, and tells running
/// turns to stop asking.
/// The switches as they actually apply: an admin lock (policy) wins over the saved setting.
fn direct_on(&self) -> bool {
self.state.direct_mode && self.managed.direct.allows() && self.managed.local.allows()
}
fn local_on(&self) -> bool {
(self.state.local_execution || self.state.direct_mode && self.managed.direct.allows()) && self.managed.local.allows()
}
fn enable_direct_mode(&mut self) {
if !self.managed.direct.allows() || !self.managed.local.allows() {
self.notice = Some("「直接操作」已由管理員關閉,沒辦法打開。".into());
return;
}
let st = &mut self.state;
st.direct_mode = true;
st.local_execution = true;
st.auto_review = true;
for b in st.bots.iter_mut() {
b.tools_enabled = true;
}
if st.review_ask_rules.is_empty() {
st.review_ask_rules = crate::model::DEFAULT_ASK_RULES.iter().map(|s| s.to_string()).collect();
}
for l in self.streams.values() {
l.direct.store(true, Ordering::Relaxed);
}
// Other bots waiting on a "may I use the computer?" question can go on too.
let waiting: Vec<u64> = self.approvals.iter().filter(|(_, a)| a.kind != ApprovalKind::Action).map(|(id, _)| *id).collect();
for id in waiting {
if let Some(a) = self.approvals.remove(&id) {
let _ = a.reply.send(ApprovalAnswer::Always(a.grant.clone()));
}
}
self.notice = Some("已打開「直接操作」:Bot 會直接做事,只有危險操作和你的「一律詢問」規則會停下來問你。".into());
self.dirty = true;
}
fn disable_direct_mode(&mut self) {
self.state.direct_mode = false;
for l in self.streams.values() {
l.direct.store(false, Ordering::Relaxed);
}
self.dirty = true;
}
fn answer(&mut self, ctx: &egui::Context, bot_id: u64, answer: ApprovalAnswer) {
if self.mir.on {
let a = match answer {
ApprovalAnswer::Once => "once",
ApprovalAnswer::Always(_) => "always",
ApprovalAnswer::Reject => "reject",
};
self.approvals.remove(&bot_id);
self.popup_hidden.remove(&bot_id);
self.mirror_act(serde_json::json!({"bot": bot_id, "action": "answer", "answer": a}));
return;
}
if self.want_direct.replace(false) {
self.enable_direct_mode();
}
if let Some(call) = self.app_pending.remove(&bot_id) {
self.approvals.remove(&bot_id);
if answer == ApprovalAnswer::Reject {
let _ = call.reply.send(("使用者拒絕了這個操作。".into(), false));
} else {
self.run_app_action(ctx, bot_id, call);
}
self.mark();
return;
}
if let Some(a) = self.approvals.remove(&bot_id) {
self.popup_hidden.remove(&bot_id);
if let ApprovalAnswer::Always(g) = &answer {
match a.kind {
// "Always" on these turns the setting on, so it won't ask again.
ApprovalKind::EnableTools => {
if let Some(b) = self.state.bot_mut(bot_id) {
b.tools_enabled = true;
}
}
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) {
b.allow_rules.push(g.clone());
}
}
}
}
}
let _ = a.reply.send(answer);
if let Some(l) = self.streams.get_mut(&bot_id) {
l.paused = false;
l.last = Instant::now();
}
self.mark();
}
}
fn regenerate(&mut self, ctx: &egui::Context, bot_id: u64) {
if self.mir.on {
self.mirror_act(serde_json::json!({"bot": bot_id, "action": "regenerate"}));
return;
}
if self.streams.contains_key(&bot_id) {
return;
}
if let Some(b) = self.state.bot_mut(bot_id) {
while b.messages.last().map(|m| m.role != Role::User).unwrap_or(false) {
b.messages.pop();
}
if b.messages.is_empty() {
return;
}
}
self.start_reply(ctx, bot_id);
}
fn drain_events(&mut self, ctx: &egui::Context) {
while let Ok((id, ev)) = self.rx.try_recv() {
// A stopped request may still deliver a few pieces; ignore them.
if !self.streams.contains_key(&id) {
continue;
}
let selected = self.state.selected;
if let Some(live) = self.streams.get_mut(&id) {
live.last = Instant::now();
match &ev {
StreamEvent::Text(_) | StreamEvent::Thinking(_) | StreamEvent::ToolCall(_) => {
live.produced = true;
live.status = None;
}
StreamEvent::Retrying(msg) => {
live.status = Some(msg.clone());
live.started = Instant::now();
}
// A new model step gets the first-response deadline again.
StreamEvent::NextStep(_) => {
live.produced = false;
live.started = Instant::now();
live.status = None;
}
// Waiting for the person or a running tool is not a stall.
StreamEvent::NeedApproval(_) => live.paused = true,
StreamEvent::ToolStatus(_, st) => live.paused = matches!(st, ToolStatus::Running | ToolStatus::Waiting),
StreamEvent::ToolResult(_) => live.paused = false,
_ => {}
}
}
if let StreamEvent::NeedApproval(a) = ev {
if let Some(b) = self.state.bot_mut(id) {
if id != selected {
b.unread = true;
}
if let Some(c) = b.messages.iter_mut().rev().flat_map(|m| m.tool_calls.iter_mut()).find(|c| c.id == a.call_id) {
c.status = ToolStatus::Waiting;
}
}
self.approvals.insert(id, a);
self.on_new_approval(ctx, id);
continue;
}
if let StreamEvent::React(emoji) = &ev {
if let Some(b) = self.state.bot_mut(id) {
if let Some(i) = b.messages.iter().rposition(|m| m.role == Role::User && !m.wake) {
let m = &mut b.messages[i];
if !m.reactions.contains(emoji) {
m.reactions.push(emoji.clone());
self.popped.insert((id, i, emoji.clone()), Instant::now());
}
}
}
self.dirty = true;
continue;
}
if let StreamEvent::Attach(a) = ev {
if let Some(b) = self.state.bot_mut(id) {
if let Some(m) = b.messages.iter_mut().rev().find(|m| m.role == Role::Assistant) {
m.attachments.push(a);
}
}
self.dirty = true;
continue;
}
if let StreamEvent::AppAction(call) = ev {
match appctl::needs_approval(&call.name, &call.args, &self.state) {
Some(reason) => {
let (reply, _) = channel();
self.approvals.insert(id, Approval { call_id: call.call_id.clone(), tool: call.name.clone(), reason, grant: String::new(), reply, kind: ApprovalKind::Action });
self.on_new_approval(ctx, id);
self.app_pending.insert(id, call);
if let Some(l) = self.streams.get_mut(&id) {
l.paused = true;
}
}
None => self.run_app_action(ctx, id, call),
}
continue;
}
let mut settle_with = None;
let Some(b) = self.state.bot_mut(id) else { continue };
match ev {
StreamEvent::Alive | StreamEvent::Retrying(_) | StreamEvent::NeedApproval(_) | StreamEvent::AppAction(_) | StreamEvent::React(_) | StreamEvent::Attach(_) => continue,
StreamEvent::Steered(m) => b.messages.push(m),
StreamEvent::NextStep(via) => b.messages.push(Message::assistant("", &via)),
StreamEvent::ToolResult(m) => b.messages.push(m),
StreamEvent::Compacted(n, summary, boundary) => {
// The model's history can be shorter than the window's chat (group chats
// filter); land the cut at the real first-kept message, not the raw index.
let cut = b.messages.iter().position(|m| m.at == boundary && m.role == Role::User).unwrap_or(n.min(b.messages.len()));
b.messages.splice(..cut, [summary]);
}
StreamEvent::ToolStatus(call_id, st) => {
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 };
match other {
StreamEvent::Text(t) => m.text.push_str(&crate::i18n::reply_text(&t)),
StreamEvent::Thinking(t) => m.thinking.push_str(&t),
StreamEvent::ToolCall(c) => m.tool_calls.push(c),
StreamEvent::StepDone(full) => {
if m.role == Role::Assistant && !m.summary {
// Keep statuses the stream already set.
let mut full = full;
for c in full.tool_calls.iter_mut() {
if let Some(old) = m.tool_calls.iter().find(|o| o.id == c.id) {
c.status = old.status;
}
}
*m = full;
}
}
StreamEvent::Done => {
if m.role == Role::Assistant && m.text.is_empty() && m.tool_calls.is_empty() {
m.text = "(沒有回應內容)".into();
}
if id != selected {
b.unread = true;
}
settle_with = Some(TurnOutcome::Success);
}
StreamEvent::Error(e) => {
if m.role != Role::Assistant || !m.tool_calls.is_empty() {
b.messages.push(Message { error: true, ..Message::assistant(&e, "") });
} else {
if m.text.is_empty() {
m.text = e;
} else {
m.text.push_str(&format!("\n\n{e}"));
}
m.error = true;
}
settle_with = Some(TurnOutcome::Error);
}
_ => {}
}
}
}
if let Some(o) = settle_with {
self.settle(id, o);
}
self.dirty = true;
}
}
/// Stall rules: SAND-E0402 firstTokenStall (deadline is server-side, estimated here)
/// and SAND-E0415 streamIdleStall (35 s of silence after output started).
fn watchdog(&mut self) {
let first = std::time::Duration::from_secs(providers::FIRST_RESPONSE_TIMEOUT_SECS);
let idle = std::time::Duration::from_millis(providers::STREAM_IDLE_STALL_MS);
let stalled: Vec<(u64, &str)> = self
.streams
.iter()
.filter_map(|(id, l)| {
if l.paused {
None
} else if !l.produced && l.started.elapsed() > first {
Some((*id, "模型未能及時開始回應。請重試。"))
} else if l.produced && l.last.elapsed() > idle {
Some((*id, "回應中斷:模型超過 35 秒沒有送出任何內容。"))
} else {
None
}
})
.collect();
for (id, msg) in stalled {
if let Some(l) = self.streams.get(&id) {
l.cancel.store(true, Ordering::Relaxed);
}
self.settle(id, TurnOutcome::Error);
if let Some(m) = self.state.bot_mut(id).and_then(|b| b.messages.last_mut()).filter(|m| m.role == Role::Assistant) {
if m.text.is_empty() {
m.text = msg.into();
} else {
m.text.push_str(&format!("\n\n{msg}"));
}
m.error = true;
}
self.dirty = true;
}
}
fn fetch_models(&mut self, kind: ProviderKind) {
// A screen of a brain: the brain has the key, so it asks.
if self.mir.on {
let (tx, rx) = channel();
let s = self.state.remote.clone();
std::thread::spawn(move || {
let name = serde_json::to_value(kind).unwrap_or_default();
let r = crate::remote::sync_post(&s, "models", &serde_json::json!({"provider": name}))
.and_then(|v| v["error"].as_str().map(|e| Err(e.to_string())).unwrap_or_else(|| Ok(v["models"].as_array().into_iter().flatten().filter_map(|m| m.as_str().map(String::from)).collect())));
let _ = tx.send(r);
});
self.model_fetch.insert(kind, rx);
self.fetch_status.insert(kind, "正在請大腦取得模型清單…".into());
return;
}
let ps = self.state.provider(kind);
let base = providers::resolve_base(kind, &ps.base_url);
let key = providers::resolve_key(kind, &ps.api_key);
let (tx, rx) = channel();
std::thread::spawn(move || {
let _ = tx.send(providers::list_models(kind, &base, &key));
});
self.model_fetch.insert(kind, rx);
self.fetch_status.insert(kind, "正在取得模型清單…".into());
}
fn poll_fetches(&mut self) {
let mut done = vec![];
for (k, rx) in &self.model_fetch {
if let Ok(r) = rx.try_recv() {
done.push((*k, r));
}
}
for (k, r) in done {
self.model_fetch.remove(&k);
match r {
Ok(list) => {
self.fetch_status.insert(k, format!("連線成功,找到 {} 個模型", list.len()));
self.state.provider_mut(k).known_models = list;
self.mark();
}
Err(e) => {
self.fetch_status.insert(k, e);
}
}
}
}
/// Models to describe pictures with, best first: each provider with a key, its own default
/// model, then models of it known to see pictures.
fn eyes(&self) -> Vec<providers::Eyes> {
use ProviderKind as K;
let mut v = vec![];
for kind in [K::Xai, K::Anthropic, K::OpenAi, K::Gemini, K::OpenRouter, K::OpenCodeGo, K::Custom, K::Ollama] {
let ps = self.state.provider(kind);
let key = providers::resolve_key(kind, &ps.api_key);
if kind.needs_key() && key.is_empty() {
continue;
}
if matches!(kind, K::Custom | K::Ollama) && ps.base_url.trim().is_empty() {
continue;
}
let base = providers::resolve_base(kind, &ps.base_url);
let mut models: Vec<String> = vec![];
if !ps.default_model.trim().is_empty() {
models.push(ps.default_model.trim().to_string());
}
models.extend(providers::seeing_models(kind).iter().map(|m| m.to_string()));
models.dedup();
for m in models {
v.push(providers::Eyes { kind, base_url: base.clone(), api_key: key.clone(), model: m });
}
}
v
}
fn models_for(&self, kind: ProviderKind) -> Vec<String> {
let ps = self.state.provider(kind);
let mut v: Vec<String> = if ps.known_models.is_empty() {
kind.starter_models().iter().map(|s| s.to_string()).collect()
} else {
ps.known_models.clone()
};
if !ps.default_model.is_empty() && !v.contains(&ps.default_model) {
v.insert(0, ps.default_model.clone());
}
v
}
fn import_bot_dialog(&mut self) {
let (tx, rx) = channel();
self.share_rx = Some(rx);
std::thread::spawn(move || {
if let Some(p) = crate::share::choose_open() {
let _ = tx.send(std::fs::read_to_string(&p).map_err(|e| e.to_string()));
}
});
}
fn poll_import(&mut self) {
let Some(rx) = &self.share_rx else { return };
let Ok(r) = rx.try_recv() else { return };
self.share_rx = None;
let id = self.state.next_id;
let mut next = self.state.next_routine_id;
match r.and_then(|t| crate::share::import(&t, id, &mut next, crate::routines::now())) {
Ok(b) => {
let paused = b.routines.iter().filter(|r| r.trigger.is_some()).count();
self.state.next_id += 1;
self.state.next_routine_id = next;
self.notice = Some(format!("已匯入「{}」。{}", b.name, if paused > 0 { format!("有 {paused} 個事件觸發的例行任務先暫停了:請跟 Bot 說要用的 Token,再請它繼續。") } else { String::new() }));
self.state.bots.push(b);
self.state.selected = id;
self.mark();
}
Err(e) => self.notice = Some(format!("匯入失敗:{e}")),
}
}
fn new_bot(&mut self) {
let id = self.state.next_id;
let shape = avatar::SHAPES[(id as usize) % avatar::SHAPES.len()];
let color = avatar::COLORS[(id as usize) % avatar::COLORS.len()].0;
let base = self.state.bot(self.state.selected).map(|b| (b.provider, b.model.clone()));
// The first bot: the service whose key was just set.
let first = self.ready_provider().unwrap_or(ProviderKind::Xai);
let (provider, model) = base.unwrap_or_else(|| (first, self.default_model(first)));
let b = Bot {
id,
name: "新 Bot".into(),
description: String::new(),
instructions: String::new(),
shape: shape.into(),
color: color.into(),
provider,
model,
pinned: false,
messages: vec![],
unread: false,
tools_enabled: false,
workdir: crate::model::default_workdir(),
allow_rules: vec![],
demos: vec![],
speak_replies: false,
voice: String::new(),
voice_rate: 1.0,
voice_lang: String::new(),
folder: String::new(),
memories: vec![],
members: vec![],
threads: vec![],
context_tokens: agent::DEFAULT_CONTEXT_TOKENS,
routines: vec![],
last_turn: None,
default_computer: crate::model::default_computer(),
};
self.edit_bot = Some((b, true));
}
// ---------- sidebar ----------
/// Apply the original's drag rule (`QJe`) to a dragged width.
fn resize_sidebar(&mut self, raw: f32) {
let st = &mut self.state;
if st.sidebar_collapsed {
if raw >= SIDEBAR_EXPAND_FROM {
st.sidebar_collapsed = false;
st.sidebar_width = raw.clamp(SIDEBAR_MIN_WIDTH, SIDEBAR_MAX_WIDTH);
}
} else if raw < SIDEBAR_COLLAPSE_BELOW {
st.sidebar_collapsed = true;
} else {
st.sidebar_width = raw.clamp(SIDEBAR_MIN_WIDTH, SIDEBAR_MAX_WIDTH);
}
self.dirty = true;
}
fn sidebar(&mut self, root: &mut egui::Ui) {
let p = self.pal;
let collapsed = self.state.sidebar_collapsed;
let width = if collapsed { SIDEBAR_RAIL_WIDTH } else { self.state.sidebar_width.clamp(SIDEBAR_MIN_WIDTH, SIDEBAR_MAX_WIDTH) };
let panel = egui::Panel::left("sidebar")
.exact_size(width)
.resizable(false)
.show_separator_line(false)
.frame(Frame::new().fill(p.sidebar).inner_margin(Margin::symmetric(if collapsed { 8 } else { 10 }, 12)))
.show(root, |ui| {
if collapsed {
self.rail(ui);
return;
}
self.sidebar_body(ui);
});
// Drag the right edge to resize; narrow it far enough and it folds into the rail.
let edge = panel.response.rect;
let handle = egui::Rect::from_min_max(egui::pos2(edge.right() - 3.0, edge.top()), egui::pos2(edge.right() + 3.0, edge.bottom()));
let r = root.interact(handle, egui::Id::unique("sidebar-resize"), egui::Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal);
if r.drag_started() {
self.side_drag = Some(width);
}
if let Some(raw) = self.side_drag.as_mut() {
*raw += r.drag_delta().x;
let raw = *raw;
self.resize_sidebar(raw);
}
if r.drag_stopped() {
self.side_drag = None;
}
if r.hovered() || r.dragged() {
root.painter().vline(edge.right(), edge.y_range(), Stroke::new(2.0, p.border));
}
}
/// The folded sidebar: an 88 px rail of bot avatars.
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();
}
if icon_button(ui, "+", "新 Bot", p).clicked() {
self.new_bot();
}
ui.add_space(8.0);
let mut order: Vec<u64> = vec![self.state.primary_bot];
order.extend(self.pinned_ids());
order.extend(self.state.bots.iter().filter(|b| !b.pinned && b.id != self.state.primary_bot).map(|b| b.id));
egui::ScrollArea::vertical().auto_shrink([false, false]).max_height(ui.available_height() - 40.0).show(ui, |ui| {
for id in order {
let Some(b) = self.state.bot(id).cloned() else { continue };
let (rect, resp) = ui.allocate_exact_size(vec2(56.0, 48.0), egui::Sense::click());
if self.state.selected == id || resp.hovered() {
ui.painter().rect_filled(rect, 10, if self.state.selected == id { p.active } else { p.hover });
}
avatar::paint(ui.painter(), egui::Rect::from_center_size(rect.center(), vec2(32.0, 32.0)), &b.shape, self.bot_color(&b));
if b.unread || self.streams.contains_key(&id) {
ui.painter().circle_filled(rect.right_top() + vec2(-8.0, 8.0), 4.0, if b.unread { p.accent } else { p.text3 });
}
let resp = resp.on_hover_text(&b.name);
if resp.clicked() {
self.state.selected = id;
if let Some(b) = self.state.bot_mut(id) {
b.unread = false;
}
self.mark();
}
resp.context_menu(|ui| self.bot_menu_items(ui, id));
}
});
ui.with_layout(Layout::bottom_up(Align::Center), |ui| {
if icon_button(ui, "⚙", "設定", p).clicked() {
self.show_settings = true;
}
});
});
}
/// A bot's menu (right-click, and ⋯ at the top), grouped like the original's.
fn bot_menu_items(&mut self, ui: &mut egui::Ui, id: u64) {
use crate::icons::Icon;
let p = self.pal;
let Some(b) = self.state.bot(id).cloned() else { return };
ui.set_min_width(menu_ui::MENU_W);
if id != self.state.primary_bot && menu_ui::item(ui, p, Icon::Pin, if b.pinned { "取消釘選" } else { "釘選" }).clicked() {
self.set_pinned(id, !b.pinned, None);
ui.close();
}
self.folder_menu(ui, id);
if menu_ui::item(ui, p, Icon::Bell, if b.unread { "標示為已讀" } else { "標示為未讀" }).clicked() {
if let Some(b) = self.state.bot_mut(id) {
b.unread = !b.unread;
}
self.mark();
ui.close();
}
if id != self.state.primary_bot && menu_ui::item(ui, p, Icon::Star, "設為主要 Bot").clicked() {
self.state.primary_bot = id;
self.mark();
ui.close();
}
menu_ui::sep(ui, p);
if menu_ui::item(ui, p, Icon::Pencil, "編輯 Bot…").clicked() {
self.edit_bot = Some((b.clone(), false));
ui.close();
}
// 討論串: put this conversation aside and start fresh; earlier ones can come back.
if menu_ui::item(ui, p, Icon::NewChat, "開始新對話").clicked() {
self.stop(id);
if self.mir.on {
self.mirror_act(serde_json::json!({"bot": id, "action": "new"}));
} else if let Some(b) = self.state.bot_mut(id) {
if !b.messages.is_empty() {
let title = b.messages.iter().find(|m| m.role == Role::User).map(|m| first_line(&m.text, 24)).unwrap_or_else(|| "對話".into());
let msgs = std::mem::take(&mut b.messages);
b.threads.insert(0, (title, msgs));
b.threads.truncate(50);
}
}
self.mark();
ui.close();
}
let threads: Vec<String> = b.threads.iter().map(|t| t.0.clone()).collect();
if !threads.is_empty() {
menu_ui::sub(ui, p, Icon::History, "過去的對話", |ui| {
for (i, title) in threads.iter().enumerate() {
if menu_ui::item_text(ui, p, None, title).clicked() {
self.stop(id);
if self.mir.on {
self.mirror_act(serde_json::json!({"bot": id, "action": "restore", "index": i}));
} else if let Some(b) = self.state.bot_mut(id) {
let (_, msgs) = b.threads.remove(i);
if !b.messages.is_empty() {
let cur_title = b.messages.iter().find(|m| m.role == Role::User).map(|m| first_line(&m.text, 24)).unwrap_or_else(|| "對話".into());
let cur = std::mem::take(&mut b.messages);
b.threads.insert(0, (cur_title, cur));
}
b.messages = msgs;
}
self.mark();
ui.close();
}
}
});
}
if menu_ui::item(ui, p, Icon::Record, "錄會議").on_hover_text(t("用麥克風錄下會議,結束後轉成文字記錄交給這個 Bot 整理")).clicked() {
let ctx = ui.ctx().clone();
self.state.selected = id;
self.start_meeting(&ctx, id);
ui.close();
}
menu_ui::sep(ui, p);
// 分享: as a file (look, model, instructions, routines, demonstrations; no chat or keys).
menu_ui::sub(ui, p, Icon::Share, "分享與匯出", |ui| {
if menu_ui::item(ui, p, Icon::Share, "匯出 Bot(分享給別人)…").clicked() {
if let Some(b) = self.state.bot(id) {
let (text, name) = (crate::share::export(b), format!("{}.anybot", b.name));
let note = self.ch.note.clone();
std::thread::spawn(move || {
if let Some(path) = crate::share::choose_save(&name) {
let path = if path.extension().is_none() { path.with_extension("anybot") } else { path };
*note.lock().unwrap() = match std::fs::write(&path, text) {
Ok(()) => format!("已匯出到 {}", path.display()),
Err(e) => format!("匯出失敗:{e}"),
};
}
});
self.notice = Some(t("選好位置後就會存成 .anybot 檔,可以傳給別人匯入。聊天記錄、金鑰和 Token 不會帶出去。"));
}
ui.close();
}
if menu_ui::item(ui, p, Icon::Download, "匯出文字記錄…").clicked() {
if let Some(b) = self.state.bot(id) {
let (md, name) = (transcript_markdown(b), format!("{} 對話.md", b.name));
let note = self.ch.note.clone();
std::thread::spawn(move || {
if let Some(path) = crate::share::choose_save(&name) {
*note.lock().unwrap() = match std::fs::write(&path, md) {
Ok(()) => format!("已匯出到 {}", path.display()),
Err(e) => format!("匯出失敗:{e}"),
};
}
});
}
ui.close();
}
if menu_ui::item(ui, p, Icon::Upload, "匯入 Bot…").clicked() {
self.import_bot_dialog();
ui.close();
}
if menu_ui::item(ui, p, Icon::Copy, "複製對話 ID").clicked() {
ui.ctx().copy_text(format!("{id}"));
ui.close();
}
});
menu_ui::sep(ui, p);
if menu_ui::item(ui, p, Icon::Refresh, "重新啟動這個 Bot").on_hover_text(t("卡住時用:停止它目前的工作和背景指令,聊天記錄和檔案都會保留")).clicked() {
self.restart_bot(id);
ui.close();
}
if menu_ui::item(ui, p, Icon::Eraser, "清除聊天記錄").clicked() {
if self.mir.on {
// The chat lives on the brain: clearing only this copy left the brain sure this
// screen still had it, and its next changes landed in the wrong place.
self.mirror_act(serde_json::json!({"bot": id, "action": "clear"}));
} else {
self.stop(id);
if let Some(b) = self.state.bot_mut(id) {
b.messages.clear();
}
self.mark();
}
ui.close();
}
if menu_ui::danger(ui, p, Icon::Trash, "刪除").clicked() {
self.confirm_delete = Some(id);
ui.close();
}
}
// ---------- main pane ----------
fn main_pane(&mut self, root: &mut egui::Ui) {
let p = self.pal;
let ctx = &root.ctx().clone();
if let Some(bot) = self.state.bot(self.state.selected).cloned() {
self.bot_panel(root, &bot);
}
egui::CentralPanel::default().frame(Frame::new().fill(p.pane)).show(root, |ui| {
let id = self.state.selected;
if self.state.bots.is_empty() {
self.mirror_banner(ui);
self.welcome(ui);
return;
}
let Some(bot) = self.state.bot(id).cloned() else {
ui.centered_and_justified(|ui| ui.label(RichText::new(t("未選取 Bot")).color(p.text3)));
return;
};
self.header(ui, &bot);
self.mirror_banner(ui);
// The composer grows with a long draft: keep room for last frame's height.
let h_id = egui::Id::unique("composer-height");
let composer_h: f32 = ui.data(|d| d.get_temp(h_id)).unwrap_or(116.0);
let avail = ui.available_height() - composer_h;
let w = ui.available_width();
let col_w = (w - 48.0).min(TRANSCRIPT_MAX_WIDTH);
let pad = ((w - col_w) / 2.0).max(0.0);
egui::ScrollArea::vertical()
.id_salt(("transcript", id))
.auto_shrink([false, false])
.max_height(avail)
.stick_to_bottom(self.stick_bottom)
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.add_space(pad);
ui.vertical(|ui| {
ui.set_width(col_w);
ui.add_space(18.0);
if bot.messages.is_empty() {
self.empty_state(ui, &bot);
}
let n = bot.messages.len();
for (i, m) in bot.messages.iter().enumerate() {
let last = i + 1 == n;
self.msg_index = i;
self.chat_message(ui, ctx, &bot, m, last);
}
// While it works: the ring and the shimmering phrase.
self.working_row(ui, &bot);
self.approval_card(ui, ctx, &bot);
ui.add_space(16.0);
});
});
});
let top = ui.cursor().top();
ui.horizontal(|ui| {
ui.add_space(pad);
ui.vertical(|ui| {
ui.set_width(col_w);
self.composer(ui, ctx, &bot);
});
});
let used = ui.cursor().top() - top;
if (used - composer_h).abs() > 0.5 {
ui.data_mut(|d| d.insert_temp(h_id, used));
ui.ctx().request_repaint();
}
});
}
/// The top of the chat, like the original: no bar — the bot's name in a rounded pill in the
/// middle (it opens 對話詳細資訊), and 分享 on the right (pills: 1 px border at 6 %, 36–41 px).
fn header(&mut self, ui: &mut egui::Ui, bot: &Bot) {
use panel_ui::PanelTab;
let p = self.pal;
let color = self.bot_color(bot);
let (bar, _) = ui.allocate_exact_size(vec2(ui.available_width(), 58.0), egui::Sense::hover());
// Right: request / working, 分享, ⋯ — laid out from the right edge.
let mut right = ui.new_child(egui::UiBuilder::new().max_rect(bar.shrink2(vec2(12.0, 10.0))).layout(Layout::right_to_left(Align::Center)));
right.spacing_mut().item_spacing.x = 8.0;
let more = pill_icon(&mut right, crate::icons::Icon::More, p).on_hover_text(t("更多"));
egui::Popup::menu(&more).show(|ui| self.bot_menu_items(ui, bot.id));
if pill_text(&mut right, &t("分享"), p).on_hover_text(t("匯出 Bot(分享給別人)…")).clicked() {
let (text, name) = (crate::share::export(bot), format!("{}.anybot", bot.name));
let note = self.ch.note.clone();
std::thread::spawn(move || {
if let Some(path) = crate::share::choose_save(&name) {
let path = if path.extension().is_none() { path.with_extension("anybot") } else { path };
*note.lock().unwrap() = match std::fs::write(&path, text) {
Ok(()) => format!("已匯出到 {}", path.display()),
Err(e) => format!("匯出失敗:{e}"),
};
}
});
}
if self.meeting_on() {
let red = Color32::from_rgb(0xfc, 0x2c, 0x3e);
if crate::icons::pill(&mut right, crate::icons::Icon::Record, &t("正在錄會議 · 結束"), red, Color32::WHITE, 36.0).on_hover_text(t("停止錄音,轉成文字記錄")).clicked() {
let ctx = right.ctx().clone();
self.voice_toggle(&ctx, bot.id);
}
}
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();
// Middle: the name pill (face + name), centred, never under the buttons on the right.
let font = egui::FontId::proportional(14.0);
let name_w = ui.painter().layout_no_wrap(bot.name.clone(), font.clone(), p.text).size().x;
let max_pill = (bar.width() - 2.0 * right_w - 24.0).max(120.0);
let pill_w = (name_w + 52.0).min(max_pill);
let mut cx = bar.center().x;
if cx + pill_w / 2.0 > bar.right() - right_w - 8.0 {
cx = bar.right() - right_w - 8.0 - pill_w / 2.0;
}
let rect = egui::Rect::from_center_size(egui::pos2(cx.max(bar.left() + pill_w / 2.0 + 8.0), bar.center().y), vec2(pill_w, 40.0));
let r = ui.interact(rect, egui::Id::unique(("name-pill", bot.id)), egui::Sense::click());
ui.painter().rect_filled(rect, 20, if r.hovered() { p.hover } else { p.elevated });
ui.painter().rect_stroke(rect, 20, Stroke::new(1.0, p.border), egui::StrokeKind::Inside);
let mut inner = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(8.0, 0.0))).layout(Layout::left_to_right(Align::Center)));
inner.spacing_mut().item_spacing.x = 8.0;
let (face, _) = inner.allocate_exact_size(vec2(26.0, 26.0), egui::Sense::hover());
let _ = color;
self.live_face(&mut inner, bot.id, face, true);
inner.add(egui::Label::new(RichText::new(&bot.name).size(14.0).color(p.text)).truncate().selectable(false));
let open = self.panel.is_some();
if r.on_hover_text(t(if open { "關閉對話詳細資訊" } else { "查看對話詳細資訊" })).on_hover_cursor(egui::CursorIcon::PointingHand).clicked() {
self.panel = if open { None } else { Some(PanelTab::Activity) };
}
}
fn empty_state(&mut self, ui: &mut egui::Ui, bot: &Bot) {
let p = self.pal;
ui.add_space(70.0);
ui.vertical_centered(|ui| {
avatar::avatar(ui, 72.0, &bot.shape, self.bot_color(bot));
ui.add_space(10.0);
ui.label(RichText::new(&bot.name).size(22.0).strong());
if !bot.description.is_empty() {
ui.label(RichText::new(&bot.description).color(p.text2));
}
ui.add_space(6.0);
ui.label(RichText::new(format!("使用 {}·{}", bot.provider.label(), bot.model)).size(12.0).color(p.text3));
if bot.tools_enabled {
ui.label(RichText::new(format!("可以在「{}」裡查看、修改檔案和執行指令,動手前會先問你", bot.workdir)).size(12.0).color(p.text3));
}
let ps = self.state.provider(bot.provider);
if bot.provider.needs_key() && providers::resolve_key(bot.provider, &ps.api_key).is_empty() {
ui.add_space(10.0);
if ui.button(format!("設定 {} 的 API 金鑰", bot.provider.label())).clicked() {
self.show_settings = true;
self.settings_tab = SettingsTab::Providers;
self.settings_provider = bot.provider;
}
}
});
}
/// "The bot wants to …": allow once, always allow this kind, or say no.
/// The question, the details and the buttons, shared by the chat card and the pop-up.
fn approval_body(&self, ui: &mut egui::Ui, bot: &Bot, big: bool) -> Option<ApprovalAnswer> {
let p = self.pal;
let a = self.approvals.get(&bot.id)?;
let (tool, reason, grant, kind) = (a.tool.clone(), a.reason.clone(), a.grant.clone(), a.kind);
// "允許 Grok Bot 在 {targetMachineDisplay} 上使用 {app} 嗎?" (eQViQE): name the machine.
let (reason, machine) = match reason.rsplit_once("\n(在 ") {
Some((r, m)) => (r.to_string(), m.trim_end_matches(" 上)").to_string()),
None => (reason.clone(), String::new()),
};
let what = if appctl::is_app_tool(&tool) { appctl::label(&tool) } else if crate::macapps::is_mac_tool(&tool) { crate::macapps::label(&tool) } else if crate::computer::is_computer_tool(&tool) { crate::computer::label(&tool) } else if web::is_web_tool(&tool) { web::label(&tool) } else { tools::label(&tool) };
let title = match kind {
ApprovalKind::EnableTools => format!("允許 {} 使用電腦工具嗎?", bot.name),
// Hd1KB+: 允許…在這台電腦上執行任務,而不只是在它自己的電腦上
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 | ApprovalKind::Handoff => None,
};
let mut answer = None;
ui.label(RichText::new(title).strong().size(if big { 17.0 } else { 14.0 }));
ui.add_space(2.0);
let (head, detail) = reason.split_once(':').unwrap_or(("", reason.as_str()));
match note {
Some(n) => ui.label(RichText::new(n).size(12.5).color(p.text2)),
None if !head.is_empty() => ui.label(RichText::new(head).size(12.0).color(p.text3)),
None => ui.label(""),
};
Frame::new().fill(p.hover).corner_radius(8).inner_margin(Margin::symmetric(10, 6)).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.add(egui::Label::new(RichText::new(detail).monospace().size(12.5).color(p.text)).wrap());
});
if !machine.is_empty() && kind != ApprovalKind::Action {
ui.label(RichText::new(format!("在:{machine}")).size(12.0).color(p.text3));
}
ui.add_space(6.0);
ui.horizontal(|ui| {
let once = if kind == ApprovalKind::Action { "允許" } else { "允許這一次" };
if ui.add(egui::Button::new(RichText::new(once).color(p.on_primary)).fill(p.primary).corner_radius(8).min_size(vec2(0.0, if big { 30.0 } else { 0.0 }))).clicked() {
answer = Some(ApprovalAnswer::Once);
}
let always = match kind {
ApprovalKind::EnableTools => Some("一律允許這個 Bot 使用電腦".to_string()),
ApprovalKind::EnableThisComputer => Some("一律允許(打開「在這台電腦上執行」)".to_string()),
ApprovalKind::Action if !grant.is_empty() => Some(format!("一律允許{}", grant_label(&grant))),
ApprovalKind::Action | ApprovalKind::Handoff => None,
};
if let Some(label) = always {
if ui.button(label).clicked() {
answer = Some(ApprovalAnswer::Always(grant.clone()));
}
}
if ui.button(t("拒絕")).clicked() {
answer = Some(ApprovalAnswer::Reject);
}
});
// One click to work like Grok Bot: allow this and stop asking from now on.
if !self.direct_on() && self.managed.direct.allows() && self.managed.local.allows() {
ui.add_space(4.0);
let b = crate::icons::label(ui, crate::icons::Icon::Zap, &t("全部允許,之後直接操作(像 Grok Bot 一樣)"), 13.5, p.accent).interact(egui::Sense::click()).on_hover_cursor(egui::CursorIcon::PointingHand);
if b.on_hover_text(t("打開「直接操作」:Bot 可以直接使用這台電腦和瀏覽器,不再一個個問你。危險指令、密碼檔和你的「一律詢問」規則仍會擋下來。可在設定 → 電腦關掉。")).clicked() {
self.want_direct.set(true);
answer = Some(ApprovalAnswer::Always(grant.clone()));
}
}
answer
}
fn approval_card(&mut self, ui: &mut egui::Ui, ctx: &egui::Context, bot: &Bot) {
let p = self.pal;
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;
let width = ui.available_width() - 32.0 - 28.0;
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);
answer = self.approval_body(ui, bot, false);
});
});
});
ui.add_space(10.0);
if let Some(ans) = answer {
self.answer(ctx, bot.id, ans);
}
}
/// 需要處理 (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) {
self.popup_hidden.remove(&bot_id);
if !self.demo {
ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Critical));
// Away from AnyBot (other app in front, or idle 90 s+): also a macOS banner.
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(&title, &what);
}
crate::remote::notify(&title, &what, bot_id);
}
}
}
}
/// The person isn't looking at AnyBot: window not in front, or no input for 90 s
/// (the original's presence rule `K4`, idle `Z_e=9e4`).
fn away(&self) -> bool {
if self.headless {
return true;
}
notify::should_notify(self.window_focused, self.idle_secs)
}
/// Re-read focus every frame and system idle time every 10 s (`uBt=1e4`).
fn refresh_presence(&mut self, ctx: &egui::Context) {
self.window_focused = ctx.input(|i| i.viewport().focused.unwrap_or(true));
if !self.demo && self.last_presence.elapsed().as_secs_f64() >= notify::PRESENCE_POLL_SECS {
self.last_presence = Instant::now();
self.idle_secs = notify::system_idle_secs();
}
}
/// 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;
// (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 };
let p = self.pal;
let screen = ctx.content_rect();
egui::Area::new(egui::Id::unique("approval-dim")).order(egui::Order::Foreground).fixed_pos(screen.min).interactable(true).show(ctx, |ui| {
let (rect, _) = ui.allocate_exact_size(screen.size(), egui::Sense::click());
ui.painter().rect_filled(rect, 0, Color32::from_black_alpha(if p.pane == LIGHT.pane { 70 } else { 120 }));
});
let mut answer = None;
let mut later = false;
let mut go = false;
egui::Window::new("需要你的允許")
.id(egui::Id::unique("approval-popup"))
.order(egui::Order::Foreground)
.collapsible(false)
.resizable(false)
.title_bar(false)
.fixed_size(vec2(440.0, 0.0))
.anchor(egui::Align2::CENTER_CENTER, vec2(0.0, -20.0))
.frame(Frame::new().fill(p.elevated).stroke(Stroke::new(2.0, p.accent)).corner_radius(16).inner_margin(Margin::same(18)).shadow(egui::epaint::Shadow { offset: [0, 8], blur: 28, spread: 0, color: Color32::from_black_alpha(60) }))
.show(ctx, |ui| {
ui.horizontal(|ui| {
avatar::avatar(ui, 28.0, &bot.shape, avatar::color(&bot.color));
ui.label(RichText::new(format!("{} 正在等你決定", bot.name)).size(12.5).color(p.text3));
if ids.len() > 1 {
ui.label(RichText::new(format!("(還有 {} 個請求)", ids.len() - 1)).size(12.0).color(p.text3));
}
});
ui.add_space(6.0);
answer = self.approval_body(ui, &bot, true);
ui.add_space(4.0);
ui.horizontal(|ui| {
if bot_id != selected && small_button(ui, "前往聊天", p).clicked() {
go = true;
}
if small_button(ui, "稍後再決定", p).on_hover_text(t("先關掉這個視窗;聊天裡的卡片還在,Bot 會一直等你")).clicked() {
later = true;
}
});
});
if let Some(ans) = answer {
self.answer(ctx, bot_id, ans);
} else if later {
self.popup_hidden.insert(bot_id);
} else if go {
self.state.selected = bot_id;
if let Some(b) = self.state.bot_mut(bot_id) {
b.unread = false;
}
self.stick_bottom = true;
}
}
fn composer(&mut self, ui: &mut egui::Ui, ctx: &egui::Context, bot: &Bot) {
let p = self.pal;
let streaming = self.streams.contains_key(&bot.id);
let composer_id = egui::Id::unique(("composer", bot.id));
let had_focus = ui.memory(|m| m.has_focus(composer_id));
let mut send_now = false;
// Enter sends, Shift+Enter is a new line, and Enter while an input method is
// composing (e.g. picking Chinese characters) is left alone: index.eager-app `mwt` (@2002346).
let composing = ui.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Ime(egui::ImeEvent::Preedit { text, .. }) if !text.is_empty())));
if had_focus && !composing && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Enter)) {
send_now = true;
}
self.attachments_poll(ctx, bot.id, had_focus);
self.voice_poll();
let mut draft = self.drafts.remove(&bot.id).unwrap_or_default();
ui.add_space(4.0);
// Quote and attachments sit just above the pill.
if let Some(q) = self.quoting.get(&bot.id).cloned() {
ui.horizontal(|ui| {
ui.add_space(14.0);
let (r, _) = ui.allocate_exact_size(vec2(3.0, 18.0), egui::Sense::hover());
ui.painter().rect_filled(r, 1, p.accent);
let short: String = q.lines().next().unwrap_or("").chars().take(60).collect();
ui.label(RichText::new(format!("{}{short}…", t("引用:"))).size(FONT_XS).color(p.text2));
if ui.add(egui::Button::new(RichText::new("×").size(13.0).color(p.text3)).frame(false)).clicked() {
self.quoting.remove(&bot.id);
}
});
ui.add_space(4.0);
}
if self.has_pending_attachments(bot.id) {
ui.horizontal(|ui| {
ui.add_space(10.0);
self.pending_chips(ui, bot.id);
});
ui.add_space(4.0);
}
// The original's input: one 44 px pill — +, the words, the mic, and a round button in the
// bot's colour (voice chat while empty, send ↑ once there is something to send).
Frame::new()
.fill(p.elevated)
.stroke(Stroke::new(1.0, if had_focus { p.border.gamma_multiply(1.8) } else { p.border }))
.corner_radius(22)
.inner_margin(Margin { left: 8, right: 8, top: 8, bottom: 8 })
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 8.0;
// +: 附加檔案 / 教它一項任務 / the model.
let plus = round_tool(ui, crate::icons::Icon::Plus, p.hover, p.text2).on_hover_text(t("輸入框操作"));
egui::Popup::menu(&plus).show(|ui| {
ui.set_min_width(menu_ui::MENU_W);
if menu_ui::item(ui, p, crate::icons::Icon::Paperclip, "附加檔案").clicked() {
self.pick_attachments(ctx, bot.id);
ui.close();
}
if crate::computer::demo_supported() && menu_ui::item(ui, p, crate::icons::Icon::Record, "教它一項任務").clicked() {
self.teach_quick(bot.id);
ui.close();
}
menu_ui::sep(ui, p);
if bot.members.is_empty() {
self.model_picker(ui, bot);
} else {
let names: Vec<String> = bot.members.iter().filter_map(|m| self.state.bot(*m)).map(|b| b.name.clone()).collect();
ui.label(RichText::new(format!("{}{}", t("成員:"), names.join("、"))).size(FONT_XS).color(p.text2));
}
});
let right_w = 28.0 * 2.0 + 8.0 + 4.0;
let text_w = (ui.available_width() - right_w).max(60.0);
let hint = if self.mir.on && !self.mir.online { t("連不到大腦,現在只能看") } else { t("傳訊息給 {name}").replace("{name}", &bot.name) };
ui.allocate_ui_with_layout(vec2(text_w, 28.0), Layout::top_down(Align::Min), |ui| {
egui::ScrollArea::vertical().id_salt(("composer_scroll", bot.id)).max_height(160.0).show(ui, |ui| {
ui.add_space(4.0);
ui.add(egui::TextEdit::multiline(&mut draft).id(composer_id).hint_text(RichText::new(hint).color(p.text3)).frame(Frame::NONE).margin(Margin::ZERO).desired_rows(1).desired_width(text_w).lock_focus(true));
});
});
ui.with_layout(Layout::right_to_left(Align::Max), |ui| {
ui.spacing_mut().item_spacing.x = 8.0;
let has_text = !draft.trim().is_empty() || self.has_pending_attachments(bot.id);
let call = self.call_state();
let in_call = call.map(|c| c.0 == bot.id).unwrap_or(false);
let colour = self.bot_color(bot);
let colour = if colour.r() as u32 + colour.g() as u32 + colour.b() as u32 <= 0x30 * 3 { p.primary } else { colour };
let ink = if colour == p.primary { p.on_primary } else { Color32::WHITE };
if has_text {
let tip = if streaming { "補充給正在進行的工作(Enter)" } else { "傳送訊息" };
if round_tool(ui, crate::icons::Icon::ArrowUp, colour, ink).on_hover_text(t(tip)).clicked() {
send_now = true;
}
} else if streaming {
if round_tool(ui, crate::icons::Icon::Stop, colour, ink).on_hover_text(t("停止")).clicked() {
self.stop(bot.id);
}
} else {
let fill = if in_call { Color32::from_rgb(0xfc, 0x2c, 0x3e) } else { colour };
let tip = match call {
Some((id, phase)) if id == bot.id => format!("{} · {}", t(phase.label()), t("結束通話")),
_ => t("發起語音聊天"),
};
if round_tool(ui, if in_call { crate::icons::Icon::Stop } else { crate::icons::Icon::Wave }, fill, if in_call { Color32::WHITE } else { ink }).on_hover_text(tip).clicked() {
if in_call {
self.call_end();
} else if call.is_none() {
self.call_start(bot.id);
}
}
}
// 語音輸入: click to record, click again to turn it into text.
if let Some(started) = self.voice_recording(bot.id) {
let secs = started.elapsed().as_secs();
let tt = ui.input(|i| i.time) as f32;
let red = Color32::from_rgb(0xfc, 0x2c, 0x3e);
let fill = red.gamma_multiply(0.75 + 0.25 * (tt * 3.0).sin().abs());
if crate::icons::pill(ui, crate::icons::Icon::Record, &format!("{:02}:{:02}", secs / 60, secs % 60), fill, Color32::WHITE, 28.0).on_hover_text(crate::i18n::t("停止錄音並轉成文字")).clicked() {
self.voice_toggle(ctx, bot.id);
}
ctx.request_repaint_after(std::time::Duration::from_millis(100));
} else if self.voice_busy() {
chat_ui::spinner(ui, 18.0, p.text3);
} else if round_tool(ui, crate::icons::Icon::Mic, p.hover, p.text2).on_hover_text(t("開始語音輸入")).clicked() {
self.voice_toggle(ctx, bot.id);
}
if let Some((id, phase)) = call {
if id == bot.id {
ui.label(RichText::new(t(phase.label())).size(FONT_XS).color(p.text3));
}
}
});
});
});
ui.add_space(4.0);
let queued: Vec<String> = self.streams.get(&bot.id).map(|l| l.steer.lock().unwrap().clone()).unwrap_or_default();
let notice = self.notice.clone();
ui.vertical_centered(|ui| {
if let Some(n) = notice.filter(|_| queued.is_empty()) {
ui.horizontal(|ui| {
ui.label(RichText::new(n).size(11.0).color(p.accent));
if small_button(ui, "知道了", p).clicked() {
self.notice = None;
}
});
} else if queued.is_empty() {
// Like the original: nothing under the input.
} else {
let first: String = queued.last().unwrap().chars().take(30).collect();
let more = if queued.len() > 1 { format!(" 等 {} 則", queued.len()) } else { String::new() };
ui.label(RichText::new(format!("已排入「{first}」{more},Bot 會在下一步看到")).size(11.0).color(p.accent));
}
});
ui.add_space(10.0);
self.drafts.insert(bot.id, draft);
if send_now {
self.send(ctx, bot.id);
ui.memory_mut(|m| m.request_focus(composer_id));
}
}
/// Settings → 電腦: AnyBot browser profiles (each with its own logins), switch / open / add.
fn browser_profiles_section(&mut self, ui: &mut egui::Ui, p: Palette) {
setting_title(ui, "AnyBot 瀏覽器設定檔", "每個設定檔是一個獨立的瀏覽器視窗,有自己的登入(例如工作和私人帳號)。Bot 會用「使用中」的那個,你也可以在聊天裡叫它換。", p);
if !crate::botbrowser::available() {
ui.label(RichText::new(t("需要 Chrome、Brave、Edge 或 Chromium 才能用 AnyBot 自己的瀏覽器。")).size(11.5).color(p.text3));
return;
}
let current = crate::botbrowser::current();
for name in crate::botbrowser::choices() {
ui.horizontal(|ui| {
let mut on = name == current;
if ui.radio_value(&mut on, true, RichText::new(&name).strong()).clicked() && name != current {
let _ = crate::botbrowser::set_current(&name);
}
if name == current {
ui.label(RichText::new(t("使用中")).size(11.5).color(p.accent));
}
if name == crate::botbrowser::PERSON_PROFILE {
ui.label(RichText::new(t("你平常的 Chrome,切到哪個設定檔就讀哪個")).size(11.5).color(p.text2));
if crate::axread::trusted(false) {
crate::icons::label(ui, crate::icons::Icon::Check, &t("輔助使用已打開"), 11.5, p.text3);
} else if ui.small_button(t("打開輔助使用")).on_hover_text(t("macOS 要你允許一次,AnyBot 才看得到你的瀏覽器視窗")).clicked() {
if !crate::axread::trusted(true) {
let _ = crate::computer::spawn_reaped(&mut std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"));
}
}
return;
}
if ui.small_button(t("開啟視窗")).on_hover_text(t("打開這個設定檔的瀏覽器視窗,在裡面登入網站")).clicked() {
let note = self.browser_note.clone();
let name = name.clone();
*note.lock().unwrap() = format!("正在打開「{name}」…");
std::thread::spawn(move || {
let r = crate::botbrowser::show(&name);
*note.lock().unwrap() = match r {
Ok(()) => format!("已打開「{name}」的視窗。在裡面登入一次,之後會記住。"),
Err(e) => e,
};
});
}
if name != crate::botbrowser::DEFAULT_PROFILE {
if self.confirm_remove_profile.as_deref() == Some(name.as_str()) {
if ui.small_button(RichText::new(t("確定移除(登入也會刪掉)")).color(p.danger)).clicked() {
*self.browser_note.lock().unwrap() = match crate::botbrowser::remove(&name) {
Ok(()) => format!("已移除「{name}」。"),
Err(e) => e,
};
self.confirm_remove_profile = None;
}
if ui.small_button(crate::i18n::t("取消")).clicked() {
self.confirm_remove_profile = None;
}
} else if ui.small_button(t("移除")).clicked() {
self.confirm_remove_profile = Some(name.clone());
}
}
});
}
ui.horizontal(|ui| {
ui.add(egui::TextEdit::singleline(&mut self.new_browser_profile).hint_text(t("新設定檔名稱,例如 工作")).desired_width(180.0));
let ok = crate::botbrowser::check_name(&self.new_browser_profile).is_ok();
if ui.add_enabled(ok, egui::Button::new("新增設定檔")).clicked() {
let r = crate::botbrowser::add(&self.new_browser_profile).and_then(|n| crate::botbrowser::set_current(&n));
*self.browser_note.lock().unwrap() = match r {
Ok(n) => format!("已新增「{n}」並設為使用中。按「開啟視窗」登入你要的網站。"),
Err(e) => e,
};
self.new_browser_profile.clear();
}
});
let note = self.browser_note.lock().unwrap().clone();
if !note.is_empty() {
ui.label(RichText::new(note).size(11.5).color(p.text2));
}
ui.label(RichText::new(t("選「你的瀏覽器」:Bot 像 Grok Bot 一樣從畫面讀你平常的 Chrome(macOS 會請你打開一次「輔助使用」),你在 Chrome 切到哪個設定檔,它就看哪個;讀之前會先問你。其他的是 AnyBot 自己的視窗,各自登入一次就好。")).size(11.0).color(p.text3));
}
/// The bot's routines: what repeats, when it runs next, pause / run now / delete.
fn routines_window(&mut self, ctx: &egui::Context) {
let Some(bot_id) = self.show_routines else { return };
let Some(bot) = self.state.bot(bot_id).cloned() else {
self.show_routines = None;
return;
};
let p = self.pal;
let mut action: Option<(&str, u64)> = None;
let body_h = dialog_max_height(ctx) - 48.0;
let dismissed = dialog(ctx, "routines", &format!("{} {}", bot.name, t("的例行任務")), 500.0, true, p, |ui| {
dialog_body(ui, "routines-body", body_h, |ui| self.routines_body(ui, &bot, &mut action));
});
let open = !dismissed;
self.routine_action(ctx, bot_id, action);
if !open {
self.show_routines = None;
}
}
/// The routines of one bot with 暫停/立即執行/刪除 (the window and the side panel share it).
fn routines_body(&self, ui: &mut egui::Ui, bot: &Bot, action: &mut Option<(&'static str, u64)>) {
let p = self.pal;
if bot.routines.is_empty() {
ui.label(RichText::new(t("例行任務是這個 Bot 依排程重複執行的任務。請在聊天中請它設定一個。")).color(p.text2));
ui.add_space(4.0);
hint(ui, "例如:「每個工作日早上 9 點整理新聞給我」", p);
return;
}
for r in &bot.routines {
setting_card(ui, p, |ui| {
ui.horizontal(|ui| {
ui.label(RichText::new(&r.title).strong().size(FONT_LG));
if r.paused {
ui.label(RichText::new(t("已暫停")).size(11.5).color(p.text3));
}
});
let next = match (r.paused, r.next_run) {
(false, Some(t)) => format!(" · 下次 {}", crate::routines::describe_when(t, &r.schedule.timezone)),
_ => String::new(),
};
match &r.trigger {
Some(t) => {
crate::icons::label(ui, crate::icons::Icon::Zap, &t.describe(), 12.5, p.text2);
if let Some(st) = crate::triggers::statuses().lock().unwrap().get(&r.id).cloned() {
ui.label(RichText::new(st).size(11.5).color(p.text3));
}
}
None => {
ui.label(RichText::new(format!("{}{next}", crate::routines::describe(&r.schedule))).size(12.5).color(p.text2));
}
}
hint(ui, r.prompt.clone(), p);
ui.add_space(4.0);
ui.horizontal(|ui| {
if ui.add(secondary_button(if r.paused { "繼續" } else { "暫停" })).clicked() {
*action = Some(("toggle", r.id));
}
if ui.add(secondary_button("立即執行")).clicked() {
*action = Some(("run", r.id));
}
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.add(egui::Button::new(RichText::new(t("刪除")).color(p.danger)).frame(false)).clicked() {
*action = Some(("delete", r.id));
}
});
});
});
}
hint(ui, "要新增或修改,直接在聊天裡告訴 Bot。", p);
}
fn routine_action(&mut self, ctx: &egui::Context, bot_id: u64, action: Option<(&str, u64)>) {
if let Some((what, id)) = action {
let now = crate::routines::now();
let mut wake = None;
if let Some(b) = self.state.bot_mut(bot_id) {
match what {
"toggle" => {
if let Some(r) = b.routines.iter_mut().find(|r| r.id == id) {
r.paused = !r.paused;
r.next_run = crate::routines::next_run(&r.schedule, r.created_at, now);
}
}
"run" => {
if let Some(r) = b.routines.iter_mut().find(|r| r.id == id) {
r.last_run = Some(now);
wake = Some(appctl::wake_message(r));
}
}
_ => self.confirm_delete_routine = Some((bot_id, id)),
}
}
if let Some(m) = wake {
self.state.selected = bot_id;
self.wake(ctx, bot_id, m);
}
self.dirty = true;
}
}
fn model_picker(&mut self, ui: &mut egui::Ui, bot: &Bot) {
let p = self.pal;
let label = format!("{} · {}", short_provider(bot.provider), bot.model);
let mut choice: Option<(ProviderKind, String)> = None;
let mut open_editor = false;
let models: Vec<(ProviderKind, bool, Vec<String>)> = ALL_PROVIDERS
.iter()
.map(|&kind| {
let ps = self.state.provider(kind);
(kind, !kind.needs_key() || !providers::resolve_key(kind, &ps.api_key).is_empty(), self.models_for(kind))
})
.collect();
let row = menu_ui::item_text(ui, p, Some(crate::icons::Icon::Sparkle), &label).on_hover_text(t("選擇這個 Bot 用來思考的模型"));
egui::containers::menu::SubMenu::new().show(ui, &row, |ui| {
ui.set_min_width(menu_ui::MENU_W);
for (kind, ready, list) in &models {
let title = if *ready { kind.label().to_string() } else { format!("{}({})", kind.label(), t("未設定金鑰")) };
let icon = if *kind == bot.provider { crate::icons::Icon::Check } else { crate::icons::Icon::Sparkle };
let r = menu_ui::item_text(ui, p, Some(icon), &title);
egui::containers::menu::SubMenu::new().show(ui, &r, |ui| {
ui.set_min_width(220.0);
for m in list {
let sel = *kind == bot.provider && *m == bot.model;
if menu_ui::item_text(ui, p, Some(if sel { crate::icons::Icon::Check } else { crate::icons::Icon::Sparkle }), m).clicked() {
choice = Some((*kind, m.clone()));
ui.close();
}
}
menu_ui::sep(ui, p);
if menu_ui::item(ui, p, crate::icons::Icon::Pencil, "其他模型…").clicked() {
choice = Some((*kind, self.default_model(*kind)));
open_editor = true;
ui.close();
}
});
}
});
if let Some((k, m)) = choice {
if let Some(b) = self.state.bot_mut(bot.id) {
b.provider = k;
b.model = m;
if open_editor {
self.edit_bot = Some((b.clone(), false));
}
}
self.mark();
}
}
fn default_model(&self, kind: ProviderKind) -> String {
let ps = self.state.provider(kind);
if ps.default_model.is_empty() { kind.suggested_model().to_string() } else { ps.default_model }
}
// ---------- dialogs ----------
fn bot_editor(&mut self, ctx: &egui::Context) {
let Some((mut b, is_new)) = self.edit_bot.take() else { return };
let p = self.pal;
let mut save = false;
let mut cancel = false;
// Title bar ~48 px and footer ~56 px; the form scrolls in between.
let body_h = dialog_max_height(ctx) - 104.0;
let title = if is_new { "建立 Bot" } else { "Bot 設定" };
let dismissed = dialog(ctx, "bot-editor", title, EDITOR_WIDTH, false, p, |ui| {
dialog_body(ui, "bot-editor-body", body_h, |ui| {
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = 16.0;
avatar::avatar(ui, 64.0, &b.shape, avatar::color(&b.color));
ui.vertical(|ui| {
let w = ui.available_width();
ui.label(RichText::new(t("Bot 名稱")).size(FONT_XS).color(p.text2));
ui.add(egui::TextEdit::singleline(&mut b.name).desired_width(w));
field_label(ui, "Bot 描述", p);
ui.add(egui::TextEdit::singleline(&mut b.description).hint_text(t("它負責做什麼?")).desired_width(w));
});
});
ui.add_space(16.0);
setting_card(ui, p, |ui| {
setting_title(ui, "頭像", "", p);
let inactive_grey = if p.pane == LIGHT.pane { Color32::from_gray(185) } else { Color32::from_gray(90) };
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
for s in avatar::SHAPES {
let r = avatar::avatar(ui, 28.0, s, if b.shape == s { avatar::color(&b.color) } else { inactive_grey }).on_hover_text(avatar::shape_label(s));
if r.clicked() {
b.shape = s.into();
}
}
});
ui.add_space(4.0);
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
for (key, name, _) in avatar::COLORS {
let (rect, r) = ui.allocate_exact_size(vec2(28.0, 28.0), egui::Sense::click());
ui.painter().circle_filled(rect.center(), 10.0, avatar::color(key));
if b.color == key {
ui.painter().circle_stroke(rect.center(), 13.0, Stroke::new(2.0, p.focus));
}
if r.on_hover_text(name).clicked() {
b.color = key.into();
}
}
});
});
setting_card(ui, p, |ui| {
setting_title(ui, "AI 供應商與模型", "", p);
ui.horizontal(|ui| {
let before = b.provider;
egui::ComboBox::from_id_salt("bot_provider").selected_text(b.provider.label()).width(170.0).show_ui(ui, |ui| {
for k in ALL_PROVIDERS {
ui.selectable_value(&mut b.provider, k, k.label());
}
});
if b.provider != before {
b.model = self.default_model(b.provider);
}
let models = self.models_for(b.provider);
let w = ui.available_width() - 80.0;
ui.add(egui::TextEdit::singleline(&mut b.model).hint_text(t("模型名稱")).desired_width(w));
egui::ComboBox::from_id_salt("bot_model").selected_text("選擇").width(60.0).show_ui(ui, |ui| {
for m in models {
let sel = m == b.model;
if ui.selectable_label(sel, &m).clicked() {
b.model = m;
}
}
});
});
let ps = self.state.provider(b.provider);
if b.provider.needs_key() && providers::resolve_key(b.provider, &ps.api_key).is_empty() {
ui.horizontal(|ui| {
ui.label(RichText::new(format!("還沒有設定 {} 的 API 金鑰", b.provider.label())).size(12.0).color(p.danger));
if ui.add(egui::Button::new(RichText::new(t("前往設定")).size(12.0).color(p.accent)).frame(false)).clicked() {
self.show_settings = true;
self.settings_tab = SettingsTab::Providers;
self.settings_provider = b.provider;
}
});
}
});
setting_card(ui, p, |ui| {
setting_title(ui, "電腦操作", "", p);
ui.checkbox(&mut b.tools_enabled, t("讓這個 Bot 查看、修改檔案和執行指令"));
if b.tools_enabled {
ui.add_space(6.0);
form_row(ui, "做事的電腦", p, |ui| {
let (choices, current) = self.computer_choices(&b);
let mut pick = None;
egui::ComboBox::from_id_salt("default_computer").selected_text(current.clone()).width(190.0).show_ui(ui, |ui| {
for (n, kind) in choices {
let label = if kind == "this" && !self.state.local_execution { format!("{n}(要先在設定裡允許)") } else { n.clone() };
if ui.selectable_label(n == current, label).clicked() {
pick = Some(n);
}
}
});
if let Some(n) = pick {
b.default_computer = Self::computer_to_keep(&b, &n);
}
ui.label(RichText::new(t("聊天時指名別台就會換")).size(11.5).color(p.text3));
});
form_row(ui, "起始資料夾", p, |ui| {
ui.add(egui::TextEdit::singleline(&mut b.workdir).hint_text(t("在你的電腦上,例如 ~/Documents/專案")).desired_width(ui.available_width()));
});
form_row(ui, "模型可讀長度", p, |ui| {
egui::ComboBox::from_id_salt("ctx_tokens").selected_text(format!("{}K tokens", b.context_tokens / 1000)).width(150.0).show_ui(ui, |ui| {
for t in [32_000u64, 128_000, 200_000, 400_000, 1_000_000] {
ui.selectable_value(&mut b.context_tokens, t, format!("{}K tokens", t / 1000));
}
});
ui.label(RichText::new(t("用到 70% 時會把舊對話整理成摘要")).size(11.5).color(p.text3));
});
ui.add_space(2.0);
hint(ui, "在它自己的電腦上可以自由做事(看不到你的檔案);在你的電腦上,改檔案和會改變東西的指令會先問你。密碼和金鑰檔一律不碰。", p);
if !b.allow_rules.is_empty() {
ui.add_space(6.0);
ui.label(RichText::new(t("已設定「一律允許」")).size(FONT_XS).color(p.text2));
let mut remove = None;
ui.horizontal_wrapped(|ui| {
for (i, g) in b.allow_rules.iter().enumerate() {
let chip = egui::Button::new(RichText::new(format!("{} ×", grant_label(g).trim_matches(['「', '」']))).size(12.5)).fill(p.elevated).stroke(Stroke::new(1.0, p.border)).corner_radius(14);
if ui.add(chip).on_hover_text(t("移除這個允許")).clicked() {
remove = Some(i);
}
}
});
if let Some(i) = remove {
b.allow_rules.remove(i);
}
}
}
});
setting_card(ui, p, |ui| {
setting_title(ui, "指示", "Bot 應該記得的事、說話風格…", p);
ui.add(egui::TextEdit::multiline(&mut b.instructions).hint_text(t("例如:用繁體中文回答,簡短一點。")).desired_rows(4).desired_width(f32::INFINITY));
});
if !b.members.is_empty() {
let others: Vec<(u64, String)> = self.state.bots.iter().filter(|x| x.id != b.id && x.members.is_empty()).map(|x| (x.id, x.name.clone())).collect();
setting_card(ui, p, |ui| {
setting_title(ui, "群組成員", "你傳訊息後,勾選的 Bot 依序回答(每個用自己的模型和設定),後面的會看到前面的回答。這個群組自己的模型不會用到。", p);
for (id, name) in others {
let mut on = b.members.contains(&id);
if ui.checkbox(&mut on, name).changed() {
if on {
b.members.push(id);
} else if b.members.len() > 1 {
b.members.retain(|m| *m != id);
}
}
}
});
}
setting_card(ui, p, |ui| {
setting_title(ui, "記得的事", "你在聊天裡請 Bot 記住的事(「記住…」),每次聊天都會帶著。", p);
if b.memories.is_empty() {
hint(ui, "還沒有。跟 Bot 說「記住我喜歡靠窗的座位」試試看。", p);
}
let mut remove = None;
for (i, m) in b.memories.iter().enumerate() {
ui.horizontal(|ui| {
ui.add(egui::Label::new(RichText::new(format!("{}. {m}", i + 1)).size(FONT_SM)).wrap());
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.add(egui::Button::new(RichText::new(t("忘記")).size(12.0).color(p.text3)).frame(false)).clicked() {
remove = Some(i);
}
});
});
}
if let Some(i) = remove {
b.memories.remove(i);
}
});
});
dialog_footer(ui, p, |ui| {
ui.checkbox(&mut b.pinned, t("釘選到側邊欄"));
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.add_enabled(!b.name.trim().is_empty() && !b.model.trim().is_empty(), primary_button(if is_new { "建立 Bot" } else { "儲存" }, p)).clicked() {
save = true;
}
if ui.add(secondary_button("取消")).clicked() {
cancel = true;
}
});
});
});
let open = !dismissed;
if save {
b.name = b.name.trim().to_string();
b.model = b.model.trim().to_string();
if is_new {
self.state.next_id += 1;
self.state.selected = b.id;
self.state.bots.push(b);
} else if let Some(slot) = self.state.bot_mut(b.id) {
// Keep messages that arrived while the editor was open.
let msgs = std::mem::take(&mut slot.messages);
*slot = b;
slot.messages = msgs;
}
self.mark();
} else if open && !cancel {
self.edit_bot = Some((b, is_new));
}
}
fn settings(&mut self, ctx: &egui::Context) {
if !self.show_settings {
self.settings_was_open = false;
self.vault.dialog = None;
return;
}
self.vault_dialogs(ctx);
// Re-read admin locks each time Settings opens.
if !self.settings_was_open && !self.demo {
self.managed = policy::read();
}
self.settings_was_open = true;
let p = self.pal;
let mut fetch: Option<ProviderKind> = None;
let mut theme_changed = false;
let mut relaunch = false;
let max_h = dialog_max_height(ctx).min(SETTINGS_HEIGHT);
// The title bar is about 48 px; the rest is the page list and the page.
let page_h = max_h - 48.0;
let close = dialog(ctx, "settings", "設定", SETTINGS_WIDTH, true, p, |ui| {
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
Frame::new().fill(p.sidebar).corner_radius(CornerRadius { sw: 16, ..Default::default() }).inner_margin(Margin::symmetric(10, 12)).show(ui, |ui| {
// The page list narrows with a small window so the page keeps its room.
let nav_w = if ctx.content_rect().width() < SETTINGS_WIDTH + 40.0 { 118.0 } else { SETTINGS_NAV_WIDTH };
ui.set_width(nav_w - 20.0);
ui.set_min_height(page_h - 24.0);
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 2.0;
use crate::icons::Icon;
for (tab, name, icon) in [(SettingsTab::General, "一般", Icon::Gear), (SettingsTab::Providers, "模型", Icon::Sparkle), (SettingsTab::Channels, "整合", Icon::Chat), (SettingsTab::Computers, "電腦", Icon::Monitor), (SettingsTab::Review, "權限", Icon::Shield), (SettingsTab::Vault, "保險庫", Icon::Vault), (SettingsTab::Updates, "更新", Icon::CloudDownload)] {
let (rect, r) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), egui::Sense::click());
let on = self.settings_tab == tab || (tab == SettingsTab::General && self.settings_tab == SettingsTab::Remote);
if on || r.hovered() {
ui.painter().rect_filled(rect, 8, if on { p.active } else { p.hover });
}
let col = if on { p.text } else { p.text2 };
crate::icons::paint(ui.painter(), egui::Rect::from_center_size(rect.left_center() + vec2(18.0, 0.0), vec2(16.0, 16.0)), icon, col);
ui.painter().text(rect.left_center() + vec2(34.0, 0.0), egui::Align2::LEFT_CENTER, t(name), egui::FontId::proportional(FONT_BASE), col);
if r.on_hover_cursor(egui::CursorIcon::PointingHand).clicked() {
self.settings_tab = tab;
}
}
});
});
let r = ui.min_rect();
ui.painter().vline(r.right(), r.y_range(), Stroke::new(1.0, p.border));
ui.vertical(|ui| {
ui.spacing_mut().item_spacing = vec2(8.0, 6.0);
dialog_body(ui, ("settings-page", self.settings_tab as u8), page_h, |ui| match self.settings_tab {
SettingsTab::General => {
section(ui, "帳戶", p);
let acct = if self.state.remote.host_token.is_empty() { t("尚未登入帳號 · 手機、帳號、團隊、備份") } else { format!("{} · {}", self.state.remote.relay_user, self.state.remote.relay_url) };
setting_row(ui, p, "帳戶", &acct, |ui| {
if ui.add(secondary_button("管理")).clicked() {
self.settings_tab = SettingsTab::Remote;
}
});
section(ui, "外觀", p);
setting_row(ui, p, "主題", "為 AnyBot 選擇淺色或深色外觀", |ui| {
for (th, n) in [(Theme::System, "跟隨系統"), (Theme::Dark, "深色"), (Theme::Light, "淺色")] {
if ui.selectable_value(&mut self.state.theme, th, t(n)).clicked() {
theme_changed = true;
}
}
});
setting_row(ui, p, "強調色", "用 Bot 的顏色為 1:1 Bot 訊息和卡片上色,或保持黑色", |ui| {
for (a, n) in [(Accent::Black, "黑色"), (Accent::BotColor, "Bot 顏色")] {
if ui.selectable_value(&mut self.state.accent, a, t(n)).clicked() {
self.dirty = true;
}
}
});
setting_row(ui, p, "字型", "AnyBot 畫面使用的字型", |ui| {
let cur = crate::font_choice();
for (v, n) in [("system", "系統字型"), ("huninn", "粉圓體")] {
if ui.selectable_label(cur == v, t(n)).clicked() && cur != v {
if let Some(d) = State::path().parent() {
let _ = std::fs::create_dir_all(d);
let _ = std::fs::write(d.join("font.txt"), v);
}
crate::load_fonts(ctx);
}
}
});
setting_row(ui, p, "語言", "選擇 AnyBot 選單和 Bot 回答使用的語言", |ui| {
let cur = self.state.language.clone();
let shown = if cur == "auto" { format!("{}({})", t("自動偵測"), crate::i18n::endonym(&crate::i18n::system())) } else { crate::i18n::endonym(&cur).to_string() };
egui::ComboBox::from_id_salt("language").selected_text(shown).height(360.0).show_ui(ui, |ui| {
let mut pick = |ui: &mut egui::Ui, v: &str, label: String| {
if ui.selectable_label(cur == v, label).clicked() {
self.state.language = v.to_string();
crate::i18n::set(v);
self.dirty = true;
}
};
pick(ui, "auto", t("自動偵測"));
let mut rows: Vec<_> = crate::i18n::LANGS.to_vec();
rows.sort_by(|a, b| a.1.cmp(b.1));
for (tag, endo, eng) in rows {
pick(ui, tag, if endo == eng { endo.to_string() } else { format!("{endo} · {eng}") });
}
});
});
if !matches!(crate::i18n::lang().as_str(), "zh-TW" | "zh-CN" | "en" | "ja") {
hint(ui, "選單會用英文顯示;Bot 會用你選的語言回答。", p);
}
section(ui, "通知", p);
setting_row(ui, p, "通知", "Bot 完成任務或等你處理時通知你", |ui| {
if ui.add(egui::Checkbox::without_text(&mut self.state.notify)).changed() {
self.dirty = true;
}
});
hint(ui, "視窗不在最前面,或 90 秒沒有動鍵盤滑鼠,就會通知。登入帳號後也會推播到手機。", p);
section(ui, "Bot", p);
setting_row(ui, p, "時區", "設定 Bot 用於排程和提醒的時區", |ui| {
if ui.add(egui::TextEdit::singleline(&mut self.state.timezone).desired_width(150.0)).changed() {
self.dirty = true;
}
if ui.add(secondary_button("用這台電腦的")).clicked() {
self.state.timezone = crate::routines::local_timezone();
self.dirty = true;
}
});
if self.state.timezone.parse::<chrono_tz::Tz>().is_err() {
ui.label(RichText::new(t("看不懂這個時區,請用像 Asia/Taipei 這樣的名稱")).size(11.5).color(p.danger));
}
section(ui, "系統", p);
setting_row(ui, p, "資料", "聊天記錄和金鑰只存在這台電腦上", |ui| {
if ui.add(secondary_button(if cfg!(target_os = "macos") { "在 Finder 中顯示" } else { "打開所在資料夾" })).clicked() {
crate::preview::reveal(&State::path());
}
});
}
SettingsTab::Updates => {
section(ui, "AnyBot 更新", p);
setting_row(ui, p, "版本", &version_label(self.exe_built), |ui| {
if let Some(tm) = self.newer_build {
ui.label(RichText::new(format!("{}{}", t("有更新的版本:"), version_label(Some(tm)))).size(12.0).color(p.accent));
} else {
ui.label(RichText::new(t("已是最新版本")).size(12.0).color(p.text3));
}
});
setting_row(ui, p, "自動更新", "在你離開時自動換成新版本,你的 Bot 和工作都會保留", |ui| {
if ui.add(egui::Checkbox::without_text(&mut self.state.auto_update_when_away)).changed() {
self.dirty = true;
}
});
hint(ui, "螢幕鎖定或螢幕保護程式開著、10 分鐘沒動作、而且沒有 Bot 在工作時,才會自己換成新版本。", p);
setting_row(ui, p, "重新啟動", "App 不太對勁時用:進行中的工作會停下,聊天記錄都會保留", |ui| {
let label = if self.relaunching { "正在重新啟動…" } else { "重新啟動 AnyBot" };
if ui.add_enabled(!self.relaunching, secondary_button(label)).clicked() {
relaunch = true;
}
});
}
SettingsTab::Vault => {
self.vault_page(ui);
}
SettingsTab::Computers => {
self.computers_page(ui);
}
SettingsTab::Remote => {
if ui.add(egui::Button::new(RichText::new(format!("‹ {}", t("一般"))).size(FONT_SM).color(p.text2)).frame(false)).clicked() {
self.settings_tab = SettingsTab::General;
}
self.remote_page(ui, ctx);
}
SettingsTab::Channels => {
section(ui, "訊息管道", p);
self.channels_page(ui, ctx);
section(ui, "連接器", p);
self.connectors_page(ui, ctx);
}
SettingsTab::Review => {
Frame::new().fill(p.accent.gamma_multiply(0.07)).stroke(Stroke::new(1.0, p.accent.gamma_multiply(0.45))).corner_radius(12).inner_margin(Margin::symmetric(16, 14)).show(ui, |ui| {
ui.set_width(ui.available_width());
crate::icons::label(ui, crate::icons::Icon::Zap, &t("直接操作(像 Grok Bot 一樣)"), FONT_BASE, p.text);
hint(ui, "一個開關:讓 Bot 直接使用這台電腦和你的瀏覽器,不再一個個問你", p);
ui.add_space(4.0);
let allowed = self.managed.direct.allows() && self.managed.local.allows();
let mut on = self.direct_on();
if ui.add_enabled(allowed, egui::Checkbox::new(&mut on, t("打開直接操作"))).changed() {
if on {
self.enable_direct_mode();
} else {
self.disable_direct_mode();
}
}
if !allowed {
crate::icons::label(ui, crate::icons::Icon::Lock, &t("由你的管理員設定,無法變更"), 12.0, p.danger);
}
hint(ui, "仍會擋下:危險指令(sudo、格式化…)、密碼和金鑰檔、AnyBot 自己的資料,以及你在「自動審核」寫的「一律詢問」規則(預設:刪除檔案、付款、用你的帳號發文或傳訊息)。macOS 第一次讓 AnyBot 控制瀏覽器時還是會自己問一次,按「好」就行。", p);
});
ui.add_space(12.0);
setting_card(ui, p, |ui| {
setting_title(ui, "自動審核", "依照自訂規則審核 Bot 的操作", p);
if ui.checkbox(&mut self.state.auto_review, t("開啟自動審核")).changed() {
self.dirty = true;
}
hint(ui, "為每種操作寫一條簡短的自然語言規則。規則衝突時,「先詢問」優先。這些規則只對你生效。內建的安全檢查一律有效。", p);
if !self.state.auto_review {
hint(ui, "自動審核關閉時不會使用規則", p);
}
});
for (title, desc, which) in [("「無需詢問直接允許」規則", "操作符合這些規則時,自動繼續", 0), ("「一律詢問」規則", "操作符合這些規則時,停下來詢問", 1)] {
setting_card(ui, p, |ui| {
setting_title(ui, title, desc, p);
let rules = if which == 0 { &mut self.state.review_allow_rules } else { &mut self.state.review_ask_rules };
let mut remove = None;
for (i, r) in rules.iter().enumerate() {
Frame::new().fill(p.elevated).corner_radius(8).inner_margin(Margin::symmetric(10, 4)).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
ui.label(r);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.add(egui::Button::new(RichText::new(t("刪除")).size(12.0).color(p.text3)).frame(false)).clicked() {
remove = Some(i);
}
});
});
});
}
if let Some(i) = remove {
rules.remove(i);
self.dirty = true;
}
let draft = if which == 0 { &mut self.new_allow_rule } else { &mut self.new_ask_rule };
ui.add_space(2.0);
ui.horizontal(|ui| {
let w = ui.available_width() - 96.0;
ui.add(egui::TextEdit::singleline(draft).hint_text(if which == 0 { "例如:在工作資料夾裡建立文字檔" } else { "例如:刪除任何檔案" }).desired_width(w));
if ui.add_enabled(!draft.trim().is_empty(), secondary_button("新增規則")).clicked() {
rules.push(draft.trim().to_string());
draft.clear();
self.dirty = true;
}
});
});
}
}
SettingsTab::Providers => {
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
ui.set_width(PROVIDER_LIST_WIDTH);
ui.spacing_mut().item_spacing.y = 2.0;
for k in ALL_PROVIDERS {
let ps = self.state.provider(k);
let ready = !k.needs_key() || !providers::resolve_key(k, &ps.api_key).is_empty();
let (rect, r) = ui.allocate_exact_size(vec2(PROVIDER_LIST_WIDTH, 32.0), egui::Sense::click());
let on = self.settings_provider == k;
if on || r.hovered() {
ui.painter().rect_filled(rect, 8, if on { p.active } else { p.hover });
}
let dot = rect.left_center() + vec2(14.0, 0.0);
if ready {
ui.painter().circle_filled(dot, 4.0, Color32::from_rgb(0x2e, 0xa0, 0x43));
} else {
ui.painter().circle_stroke(dot, 3.5, Stroke::new(1.2, p.text3));
}
ui.painter().text(rect.left_center() + vec2(28.0, 0.0), egui::Align2::LEFT_CENTER, k.label(), egui::FontId::proportional(FONT_BASE), if on { p.text } else { p.text2 });
let tip = if ready { "已可使用" } else { "還沒設定金鑰" };
if r.on_hover_text(tip).clicked() {
self.settings_provider = k;
}
}
});
ui.add_space(16.0);
ui.vertical(|ui| {
let k = self.settings_provider;
let w = ui.available_width();
ui.label(RichText::new(k.label()).strong().size(FONT_XL));
hint(ui, k.key_help(), p);
ui.add_space(6.0);
let ps = self.state.provider_mut(k);
let mut changed = false;
if k.needs_key() || k == ProviderKind::Custom {
field_label(ui, "API 金鑰", p);
changed |= ui.add(egui::TextEdit::singleline(&mut ps.api_key).password(true).hint_text(k.env_var().map(|v| format!("貼上金鑰,或設定環境變數 {v}")).unwrap_or_else(|| "(選填)".into())).desired_width(w)).changed();
}
field_label(ui, "服務網址", p);
changed |= ui.add(egui::TextEdit::singleline(&mut ps.base_url).hint_text(k.default_base_url()).desired_width(w)).changed();
field_label(ui, "新 Bot 預設模型", p);
changed |= ui.add(egui::TextEdit::singleline(&mut ps.default_model).hint_text(k.suggested_model()).desired_width(w)).changed();
let count = ps.known_models.len();
if changed {
self.dirty = true;
}
ui.add_space(12.0);
ui.horizontal(|ui| {
let busy = self.model_fetch.contains_key(&k);
if ui.add_enabled(!busy, secondary_button("測試連線並取得模型清單")).clicked() {
fetch = Some(k);
}
if busy {
ui.spinner();
}
});
if let Some(s) = self.fetch_status.get(&k) {
hint(ui, s.clone(), p);
} else if count > 0 {
hint(ui, format!("已記住 {count} 個模型"), p);
}
});
});
}
});
});
});
});
let open = !close;
if let Some(k) = fetch {
self.fetch_models(k);
}
if theme_changed {
self.apply_theme(ctx);
self.dirty = true;
}
self.show_settings = open;
if relaunch {
self.relaunch(ctx);
}
}
/// Every 10 s, see whether a newer build replaced the program file (then the banner shows).
fn check_for_newer_build(&mut self) {
if self.demo {
return;
}
// Noticing a new build is a cheap file check, so the banner keeps up quickly (estimated 10 s).
if self.last_build_check.elapsed().as_secs() >= 10 {
self.last_build_check = Instant::now();
self.newer_build = match (exe_modified(), self.exe_built) {
(Some(now), Some(started)) if now > started => Some(now),
_ => None,
};
}
// Switching to it by itself follows the original's schedule and away rule.
if Instant::now() >= self.next_update_apply {
let jitter = (chrono::Utc::now().timestamp_subsec_nanos() as f64) / 1e9;
self.next_update_apply = Instant::now() + std::time::Duration::from_secs(notify::next_update_check_secs(jitter));
let bots_idle = self.streams.is_empty() && self.approvals.is_empty();
let ready = self.newer_build.is_some();
if ready
&& notify::should_apply_update_now(
self.state.auto_update_when_away,
ready,
notify::screen_locked(),
notify::screensaver_active(),
notify::system_idle_secs(),
bots_idle,
)
{
self.relaunch_requested = true;
}
}
}
/// Save, start a fresh copy of the app, and close this one.
fn relaunch(&mut self, ctx: &egui::Context) {
let ids: Vec<u64> = self.streams.keys().copied().collect();
for id in ids {
self.stop(id);
}
tools::stop_all();
crate::cloudbox::shutdown();
crate::mcp::shutdown();
if !self.demo {
self.state.save();
}
match std::env::current_exe().and_then(|exe| relaunch_command(std::process::id(), &exe).spawn()) {
Ok(_) => {
self.relaunching = true;
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
Err(e) => self.notice = Some(format!("沒辦法重新啟動:{e}。請手動關掉再打開 AnyBot。")),
}
}
fn delete_dialog(&mut self, ctx: &egui::Context) {
let Some(id) = self.confirm_delete else { return };
let name = self.state.bot(id).map(|b| b.name.clone()).unwrap_or_default();
let mut close = false;
let p = self.pal;
let dismissed = dialog(ctx, "delete-bot", "刪除 Bot?", 380.0, true, p, |ui| {
dialog_body(ui, "delete-bot-body", 200.0, |ui| {
ui.label(RichText::new(format!("這會刪除「{name}」和它的聊天記錄。此操作無法復原。")).color(p.text2));
});
dialog_footer(ui, p, |ui| { ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.add(danger_button("刪除", p)).clicked() {
self.stop(id);
self.state.bots.retain(|b| b.id != id);
// The last one gone: back to the getting-started guide.
self.state.fix_primary();
self.dirty = true;
close = true;
}
if ui.add(secondary_button("取消")).clicked() {
close = true;
}
}); });
});
if close || dismissed {
self.confirm_delete = None;
}
}
fn handle_screenshot(&mut self, ctx: &egui::Context) {
let Some(path) = self.screenshot.clone() else { return };
self.frames += 1;
let need = std::env::var("ANYBOT_SHOT_FRAMES").ok().and_then(|v| v.parse().ok()).unwrap_or(SCREENSHOT_FRAMES);
// ANYBOT_SHOT_RESIZE=WxH: halfway through, resize the open window (the same path as the
// person dragging its edge), so the picture shows the layout after a live resize.
if self.frames == need / 2 {
if let Some((w, h)) = std::env::var("ANYBOT_SHOT_RESIZE").ok().and_then(|v| {
let (w, h) = v.split_once('x')?;
Some((w.parse::<f32>().ok()?, h.parse::<f32>().ok()?))
}) {
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(vec2(w, h)));
}
}
// 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()) {
self.page_view = Some((id, None));
}
}
if self.frames >= need && !self.shot_requested {
self.shot_requested = true;
let slot = self.shot.clone();
let repaint = ctx.clone();
ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::ScreenshotCallback::new(move |img| {
*slot.lock().unwrap() = Some(img);
repaint.request_repaint();
})));
}
let shot = self.shot.lock().unwrap().take();
if let Some(img) = shot {
let [w, h] = img.size;
let bytes: Vec<u8> = img.pixels.iter().flat_map(|c| c.to_array()).collect();
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
match image::RgbaImage::from_raw(w as u32, h as u32, bytes) {
Some(buf) => {
if let Err(e) = buf.save(&path) {
eprintln!("could not save screenshot: {e}");
}
}
None => eprintln!("screenshot had an unexpected size"),
}
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
ctx.request_repaint();
}
}
impl App {
/// Everything that keeps bots working, without drawing (the window runs it every frame;
/// `anybot --serve` runs it on its own).
fn background(&mut self, ctx: &egui::Context) {
self.remote_start(ctx);
self.mirror_tick();
// A screen of another AnyBot's brain: its bots work there, not here.
if self.mir.on {
self.remote_tick(ctx);
self.poll_fetches();
return;
}
self.drain_events(ctx);
if !self.demo {
self.channels_poll(ctx);
}
self.remote_tick(ctx);
self.tick_routines(ctx);
self.watchdog();
self.poll_fetches();
self.poll_import();
}
fn save_if_dirty(&mut self) {
if self.dirty && !self.demo && (self.streams.is_empty() || self.last_save.elapsed().as_secs() >= 2) {
if self.mir.on {
self.mirror_save();
} else {
self.state.save();
}
self.dirty = false;
self.last_save = Instant::now();
}
}
/// `anybot --serve` with ANYBOT_ACCOUNT=url|user and ANYBOT_ACCOUNT_PASSWORD: join the account
/// (and, with ANYBOT_RESTORE_FROM_ACCOUNT=1, start once from the newest backup).
fn serve_join(&mut self) {
let (Ok(acct), Ok(pw)) = (std::env::var("ANYBOT_ACCOUNT"), std::env::var("ANYBOT_ACCOUNT_PASSWORD")) else { return };
if !self.state.remote.host_token.is_empty() {
return;
}
let Some((url, user)) = acct.rsplit_once('|') else { return };
// ANYBOT_ACCOUNT_CREATE=1 (the server's own brain, first start): make the account if it isn't there.
let mut r = crate::remote::relay_join(&self.state.remote, url, user, &pw, false, "");
if r.is_err() && std::env::var("ANYBOT_ACCOUNT_CREATE").map(|v| v == "1").unwrap_or(false) {
let invite = std::env::var("ANYBOT_RELAY_INVITE").unwrap_or_default();
if let Ok(s) = crate::remote::relay_join(&self.state.remote, url, user, &pw, true, &invite) {
eprintln!("AnyBot:已建立帳號 {user}");
r = Ok(s);
}
}
match r {
Ok(s) => {
self.state.remote = s;
self.dirty = true;
eprintln!("AnyBot:已加入帳號 {user}({url})");
}
Err(e) => {
eprintln!("AnyBot:登入帳號失敗(30 秒後再試):{e}");
return;
}
}
let marker = State::path().with_file_name("restored-from-account");
if std::env::var("ANYBOT_RESTORE_FROM_ACCOUNT").map(|v| v == "1").unwrap_or(false) && !marker.exists() {
match crate::remote::restore(url, user, &pw).and_then(|b| serde_json::from_slice::<State>(&b).map_err(|e| e.to_string())) {
Ok(mut st) => {
st.remote = self.state.remote.clone();
eprintln!("AnyBot:已從帳號備份接手 {} 個 Bot", st.bots.len());
self.state = st;
self.dirty = true;
let _ = std::fs::write(&marker, chrono::Utc::now().to_rfc3339());
}
Err(e) => eprintln!("AnyBot:沒有接手備份:{e}"),
}
}
}
/// `anybot --serve`: no window. The bots, routines, triggers, channels and the phone page keep
/// running (e.g. on an always-on Mac mini or a server); the person uses the phone.
/// For testing a bot's tool use: `anybot --ask <message>` sends one message to the primary bot
/// with no window, refuses every approval (and says so), and prints the turn's steps. Run it
/// with ANYBOT_DATA_DIR pointing at a copy of the data folder. It doesn't connect to the
/// account, channels or routines.
pub fn ask_cli(ctx: &egui::Context, text: &str) -> ! {
let mut app = App::new(ctx, None);
app.headless = true;
let id = app.state.primary_bot;
let start = app.state.bot(id).map(|b| b.messages.len()).unwrap_or(0);
app.drafts.insert(id, text.to_string());
app.send(ctx, id);
let t0 = Instant::now();
let limit = std::env::var("ANYBOT_ASK_SECS").ok().and_then(|v| v.parse().ok()).unwrap_or(900u64);
while app.streams.contains_key(&id) && t0.elapsed().as_secs() < limit {
if let Some(a) = app.approvals.remove(&id) {
println!(" ⚠️ 要求核准(自動拒絕):{} — {}", a.tool, a.reason);
let _ = a.reply.send(ApprovalAnswer::Reject);
}
app.drain_events(ctx);
app.watchdog();
ctx.run_ui(Default::default(), |_| {}).textures_delta.clear();
std::thread::sleep(std::time::Duration::from_millis(100));
}
let secs = t0.elapsed().as_secs();
// The chat can shrink (compaction) between the snapshot and now: slice safely.
let msgs = app.state.bot(id).and_then(|b| b.messages.get(start..)).map(|m| m.to_vec()).unwrap_or_default();
let clip = |s: &str, n: usize| -> String {
let one = s.replace('\n', " ⏎ ");
if one.chars().count() > n { format!("{}…", one.chars().take(n).collect::<String>()) } else { one }
};
let full = std::env::var("ANYBOT_ASK_FULL").is_ok();
let mut steps = 0;
for m in &msgs {
match m.role {
Role::User => println!("👤 {}", m.text),
Role::Assistant => {
if !m.text.trim().is_empty() {
println!("🤖 {}", if full || m.tool_calls.is_empty() { m.text.clone() } else { clip(&m.text, 300) });
}
for c in &m.tool_calls {
steps += 1;
println!(" 🔧 {} {}", c.name, clip(&c.args, 200));
}
}
Role::Tool => println!(" ↳ {}{}", if m.images.is_empty() { "" } else { "[附截圖] " }, clip(&m.text, if full { 4000 } else { 400 })),
}
}
println!("—— {steps} 個工具步驟,{secs} 秒");
app.state.save();
std::process::exit(0)
}
pub fn serve(ctx: &egui::Context) -> ! {
let mut app = App::new(ctx, None);
app.headless = true;
if let Ok(pw) = std::env::var("ANYBOT_PHONE_PASSWORD") {
if pw.chars().count() >= 8 && !crate::httpd::check_password(&pw, &app.state.remote.password) {
app.state.remote.password = crate::httpd::hash_password(&pw);
app.state.remote.devices.clear();
}
app.state.remote.lan = true;
app.dirty = true;
}
if let Ok(name) = std::env::var("ANYBOT_HOST_NAME") {
if !name.trim().is_empty() && app.state.remote.host_name != name {
app.state.remote.host_name = name;
app.dirty = true;
}
}
// 手腳: a computer the brain's bots may operate (screen, files, browser).
if std::env::var("ANYBOT_DEVICE_LINK").map(|v| v == "1").unwrap_or(false) && !app.state.remote.device_link {
app.state.remote.device_link = true;
app.dirty = true;
}
app.serve_join();
app.background(ctx);
if let Some(port) = crate::remote::lan_port() {
for a in crate::remote::addresses(port) {
eprintln!("AnyBot:手機可以打開 {a}");
}
}
if !app.state.remote.relay_url.is_empty() {
eprintln!("AnyBot:透過帳號 {} 連線({})", app.state.remote.relay_user, app.state.remote.relay_url);
}
if app.state.remote.password.is_empty() && app.state.remote.host_token.is_empty() && std::env::var("ANYBOT_ACCOUNT").is_err() {
eprintln!("AnyBot:還沒有設定手機密碼或帳號。用 ANYBOT_PHONE_PASSWORD=… 或 ANYBOT_ACCOUNT=網址|帳號 + ANYBOT_ACCOUNT_PASSWORD=… 啟動。");
}
let mut last_try = Instant::now();
let mut last_brain = Instant::now() - std::time::Duration::from_secs(60);
loop {
// The account server may not be up yet (or the account not made yet): keep trying.
if app.state.remote.host_token.is_empty() && last_try.elapsed().as_secs() >= 30 {
last_try = Instant::now();
app.serve_join();
}
// ANYBOT_BRAIN=名稱: show that AnyBot's bots instead of running its own.
if let Ok(want) = std::env::var("ANYBOT_BRAIN") {
if !want.trim().is_empty() && !app.state.remote.host_token.is_empty() && app.state.remote.brain_name != want && last_brain.elapsed().as_secs() >= 20 {
last_brain = Instant::now();
if let Some((id, name, _)) = crate::remote::devices_online(&app.state.remote).into_iter().find(|(_, n, _)| n == want.trim()) {
eprintln!("AnyBot:用「{name}」當大腦,這台當手腳");
app.mir.want = Some((id, name));
app.state.remote.device_link = true;
}
}
}
app.background(ctx);
app.save_if_dirty();
// egui wants a frame now and then to forget old state; nothing is drawn.
ctx.run_ui(Default::default(), |_| {}).textures_delta.clear();
std::thread::sleep(std::time::Duration::from_millis(if app.streams.is_empty() { 250 } else { 60 }));
}
}
}
impl eframe::App for App {
/// Screenshot mode only: ANYBOT_SHOT_CLICK="x,y@frame[;x,y@frame…][:right]" clicks there at that
/// frame (to check buttons really respond).
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
if self.screenshot.is_none() {
return;
}
// ANYBOT_SHOT_DRAG="x1,y1>x2,y2@frame": press, move over 12 frames, release.
if let Ok(d) = std::env::var("ANYBOT_SHOT_DRAG") {
if let Some((path, at)) = d.split_once('@') {
let at: u32 = at.trim().parse().unwrap_or(30);
let pts: Vec<f32> = path.split(['>', ',']).filter_map(|v| v.trim().parse().ok()).collect();
if pts.len() == 4 {
let (a, b) = (egui::pos2(pts[0], pts[1]), egui::pos2(pts[2], pts[3]));
let f = self.frames + 1;
if f == at {
raw.events.push(egui::Event::PointerMoved(a));
} else if f == at + 1 {
raw.events.push(egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
} else if f > at + 1 && f <= at + 13 {
let k = (f - at - 1) as f32 / 12.0;
raw.events.push(egui::Event::PointerMoved(a + (b - a) * k));
} else if f == at + 15 {
raw.events.push(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
}
}
}
}
// ANYBOT_SHOT_HOVER="x,y@frame": just move the pointer there (tooltips).
if let Ok(h) = std::env::var("ANYBOT_SHOT_HOVER") {
if let Some((xy, at)) = h.split_once('@') {
if let (Some((x, y)), Ok(at)) = (xy.split_once(','), at.trim().parse::<u32>()) {
if self.frames + 1 == at {
if let (Ok(x), Ok(y)) = (x.trim().parse::<f32>(), y.trim().parse::<f32>()) {
raw.events.push(egui::Event::PointerMoved(egui::pos2(x, y)));
}
}
}
}
}
let Ok(spec) = std::env::var("ANYBOT_SHOT_CLICK") else { return };
for one in spec.split(';') {
let (pos, right) = match one.strip_suffix(":right") { Some(p) => (p, true), None => (one, false) };
let Some((xy, at)) = pos.split_once('@') else { continue };
let Some((x, y)) = xy.split_once(',') else { continue };
let (Ok(x), Ok(y), Ok(at)) = (x.trim().parse::<f32>(), y.trim().parse::<f32>(), at.trim().parse::<u32>()) else { continue };
let p = egui::pos2(x, y);
let button = if right { egui::PointerButton::Secondary } else { egui::PointerButton::Primary };
if self.frames + 1 == at {
raw.events.push(egui::Event::PointerMoved(p));
}
if self.frames + 1 == at + 1 {
raw.events.push(egui::Event::PointerButton { pos: p, button, pressed: true, modifiers: Default::default() });
}
if self.frames + 1 == at + 2 {
raw.events.push(egui::Event::PointerButton { pos: p, button, pressed: false, modifiers: Default::default() });
}
}
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
let ctx = &ui.ctx().clone();
self.handle_screenshot(ctx);
self.state.fix_primary();
self.md_viewer(ctx);
self.page_viewer(ctx);
self.refresh_presence(ctx);
self.check_for_newer_build();
if std::mem::take(&mut self.relaunch_requested) {
self.relaunch(ctx);
}
self.background(ctx);
self.call_poll(ctx);
if self.state.theme == Theme::System {
self.apply_theme(ctx);
}
if ctx.input(|i| i.modifiers.command && i.key_pressed(Key::N)) {
self.new_bot();
}
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);
self.settings(ctx);
self.delete_dialog(ctx);
self.routines_window(ctx);
self.rebuild_dialog(ctx);
self.local_control_bar(ctx);
self.teach_bar(ctx);
self.approval_popup(ctx);
self.file_viewer(ctx);
self.splash(ctx);
self.drop_overlay(ctx);
// Keep the routine clock ticking while the window sits idle.
if !self.demo {
ctx.request_repaint_after(std::time::Duration::from_secs(15));
}
if !self.streams.is_empty() || !self.model_fetch.is_empty() {
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
self.save_if_dirty();
}
fn on_exit(&mut self) {
// Closing mid-reply ends that turn as cancelled, so it isn't left looking stuck.
// (On a screen of a brain the bots keep working there.)
if self.mir.on {
self.streams.clear();
}
let ids: Vec<u64> = self.streams.keys().copied().collect();
for id in ids {
self.stop(id);
}
tools::stop_all();
crate::cloudbox::shutdown();
if !self.demo {
if self.mir.on {
self.mirror_save();
} else {
self.state.save();
}
}
}
}
// ---------- helpers ----------
/// When the program file was last written (= when this build was made).
fn exe_modified() -> Option<std::time::SystemTime> {
std::env::current_exe().ok().and_then(|p| std::fs::metadata(p).ok()).and_then(|m| m.modified().ok())
}
/// "2026-11-03 20:57" in local time, or "?" when unknown.
fn version_label(t: Option<std::time::SystemTime>) -> String {
t.map(|t| chrono::DateTime::<chrono::Local>::from(t).format("%Y-%m-%d %H:%M").to_string()).unwrap_or_else(|| "?".into())
}
/// How often the relaunch helper checks whether the old copy has exited (main-app.cjs @3195964,
/// `PBt`: `while kill -0 "$pid" …; do sleep 0.2; done; exec "$app"`).
pub const RELAUNCH_POLL_SECS: f32 = 0.2;
/// A detached helper that waits for this copy (`pid`) to exit, then starts `exe`, so two copies
/// never run at once and the old one's last save lands first. Like the original's `Pur`
/// (`spawn(shell, …, {detached:true, stdio:"ignore"})`), its input and output go nowhere;
/// the main program also reopens closed std streams on /dev/null at start (`sub_1000004c0`,
/// 0x1000004c0). Start-up switches are cleared so the new copy opens normally (the original's
/// `entry`, 0x100000460, changes how it starts when ELECTRON_RUN_AS_NODE is set).
pub fn relaunch_command(pid: u32, exe: &std::path::Path) -> std::process::Command {
let script = format!("while kill -0 \"$1\" 2>/dev/null; do sleep {RELAUNCH_POLL_SECS}; done; exec \"$2\"");
let mut c = std::process::Command::new("/bin/sh");
c.arg("-c").arg(script).arg("anybot-relaunch").arg(pid.to_string()).arg(exe);
c.stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null());
for k in ["ANYBOT_SHOT_VIEW", tools::ENV_CONVERSATION_ID, tools::ENV_REQUEST_ID, "ELECTRON_RUN_AS_NODE"] {
c.env_remove(k);
}
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
c.process_group(0);
}
c
}
/// A reply that is still coming in.
struct Live {
cancel: Arc<AtomicBool>,
started: Instant,
last: Instant,
produced: bool,
status: Option<String>,
/// Waiting for an approval or a running tool: the stall watchdog stays quiet.
paused: bool,
/// What the person typed during this turn, waiting for the next step.
steer: Arc<Mutex<Vec<String>>>,
turn_id: String,
/// 直接操作 for this turn (switched on mid-turn by "全部允許").
direct: Arc<AtomicBool>,
}
/// How an "always allow" grant reads in the UI.
fn grant_label(g: &str) -> String {
match g {
"edit_file" => "「修改檔案」".into(),
"browser:read" => "「讀取瀏覽器分頁」".into(),
"read:anywhere" => "「讀取工作資料夾以外」".into(),
"computer:see" => "「看你的螢幕」".into(),
g if g.starts_with("app:") => format!("操作「{}」", &g[4..]),
g if g.starts_with("mcp:") => format!("使用連接器「{}」", &g[4..]),
g if g.starts_with("login:") => format!("登入 {}", &g[6..]),
g if g.starts_with("macapp-write:") => format!("修改「{}」", &g[13..]),
g if g.starts_with("macapp:") => format!("讀取「{}」", &g[7..]),
other => format!("「{}」", other.trim_start_matches("shell:")),
}
}
/// Padding that makes a one-line bubble 36 px tall around 15 px text (egui row ≈ 18 px).
fn bubble_margin() -> Margin {
// index CSS `message`: padding-block var(--sand-transcript-prose-block-padding) = 7px,
// padding-inline 12px (7 + 22 + 7 = the 36 px one-line bubble).
debug_assert_eq!(7.0 * 2.0 + TRANSCRIPT_LINE_HEIGHT, BUBBLE_ONE_LINE_HEIGHT);
Margin::symmetric(12, 7)
}
/// index.eager-app `messageColumnOfBlock`: max-width min(80cqi, 560px, 100cqi - 82px).
pub const MESSAGE_MAX_WIDTH: f32 = 560.0;
pub fn message_max_width(container: f32) -> f32 {
(container * 0.8).min(MESSAGE_MAX_WIDTH).min(container - 82.0).max(120.0)
}
/// What the model sees: attachments read in, 引用 and 表情回應 noted, and in a group chat the other
/// members' answers as labelled words (their tool steps and private blocks left out).
fn group_history(messages: &[Message], speaker: Option<u64>, st: &State) -> Vec<Message> {
let mut out = vec![];
let mut skip_tool_ids: std::collections::HashSet<String> = Default::default();
for m in messages {
let mut m = m.clone();
if !m.attachments.is_empty() {
let (extra, images) = crate::attach::for_model(&m.attachments);
m.text = format!("{}{extra}", m.text);
m.images = images;
}
if m.role == Role::Assistant && !m.reactions.is_empty() {
m.text = format!("{}\n(使用者對這則回應:{})", m.text, m.reactions.join(" "));
}
if let Some(me) = speaker {
if m.role == Role::Assistant && m.author != me && !m.summary {
for c in &m.tool_calls {
skip_tool_ids.insert(c.id.clone());
}
if m.text.trim().is_empty() {
continue;
}
let who = st.bot(m.author).map(|b| b.name.clone()).unwrap_or_else(|| "其他成員".into());
out.push(Message::user(&format!("【{who}】{}", m.text)));
continue;
}
if m.role == Role::Tool && skip_tool_ids.contains(&m.tool_call_id) {
continue;
}
}
out.push(m);
}
out
}
/// 生活卡片: a flight, place, weather, product or event the bot found, drawn as a card.
fn card(ui: &mut egui::Ui, args: &str, p: Palette) {
let v = tools::args_of(args);
use crate::icons::Icon;
let icon = match v["kind"].as_str().unwrap_or("") {
"flight" => Icon::Plane,
"place" => Icon::MapPin,
"weather" => Icon::CloudSun,
"product" => Icon::ShoppingBag,
"event" => Icon::Calendar,
_ => Icon::Folder,
};
ui.add_space(4.0);
Frame::new()
.fill(p.elevated)
.stroke(Stroke::new(1.0, p.border))
.corner_radius(14)
.inner_margin(Margin::symmetric(14, 12))
.shadow(egui::epaint::Shadow { offset: [0, 2], blur: 8, spread: 0, color: Color32::from_black_alpha(14) })
.show(ui, |ui| {
ui.set_max_width(message_max_width(ui.available_width() + 32.0));
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(26.0, 26.0), egui::Sense::hover());
crate::icons::paint(ui.painter(), r, icon, p.text2);
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 1.0;
ui.label(RichText::new(v["title"].as_str().unwrap_or("")).strong().size(FONT_LG));
if let Some(st) = v["subtitle"].as_str().filter(|x| !x.is_empty()) {
ui.label(RichText::new(st).size(FONT_XS).color(p.text3));
}
});
});
let rows = v["rows"].as_array().cloned().unwrap_or_default();
if !rows.is_empty() {
ui.add_space(6.0);
egui::Grid::new(("card", args.len(), v["title"].as_str().unwrap_or(""))).num_columns(2).spacing([14.0, 4.0]).show(ui, |ui| {
for r in rows.iter().take(12) {
ui.label(RichText::new(r["label"].as_str().unwrap_or("")).size(FONT_SM).color(p.text3));
ui.add(egui::Label::new(RichText::new(r["value"].as_str().unwrap_or("")).size(FONT_SM)).wrap());
ui.end_row();
}
});
}
if let Some(link) = v["link"].as_str().filter(|l| l.starts_with("http")) {
ui.add_space(4.0);
ui.hyperlink_to(RichText::new(t("查看來源 ↗")).size(FONT_XS).color(p.accent), link);
}
});
ui.add_space(4.0);
}
/// 匯出文字記錄: the chat as Markdown (tool steps folded in as quotes).
fn transcript_markdown(b: &Bot) -> String {
let mut s = format!("# {} 的對話\n\n匯出時間:{}\n\n", b.name, chrono::Local::now().format("%Y-%m-%d %H:%M"));
for m in &b.messages {
match m.role {
Role::User => {
s.push_str(&format!("**你**:{}\n", m.text));
for a in &m.attachments {
s.push_str(&format!(" [附件] {}\n", a.name));
}
s.push('\n');
}
Role::Assistant if m.summary => s.push_str(&format!("> (較早的對話摘要){}\n\n", m.text.replace('\n', " "))),
Role::Assistant => {
if !m.text.trim().is_empty() {
s.push_str(&format!("**{}**:{}\n\n", b.name, m.text));
}
for c in &m.tool_calls {
s.push_str(&format!("> [工具]{} {}\n", c.name, c.args.chars().take(200).collect::<String>()));
}
if !m.tool_calls.is_empty() {
s.push('\n');
}
}
Role::Tool => {}
}
}
s
}
/// What the bot is told about itself, the app it lives in and the computers it can use.
#[allow(clippy::too_many_arguments)]
fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_computer: &str, roles: &tools::Roles, missing: Option<&str>, lines: &[String], mac_apps: bool) -> String {
let mut s = format!("你是「{}」。", b.name);
if !b.description.trim().is_empty() {
s.push_str(&format!("你的角色:{}。", b.description.trim()));
}
if b.id == st.primary_bot {
s.push_str("你是使用者的主要 Bot:主要聯絡窗口,可以主動幫忙,也能用 message_bot 把工作交給其他 Bot。");
}
let now = crate::routines::now_in(&st.timezone);
// Which screens can really be operated, each by its computer's name.
let mut screens: Vec<String> = Vec::new();
for t in computers {
match &t.place {
tools::Place::Box { .. } if crate::cloudbox::available() => screens.push(format!("「{}」", t.name)),
tools::Place::Local { .. } if crate::computer::screen_supported() => screens.push(format!("「{}」", t.name)),
tools::Place::Device { .. } => screens.push(format!("「{}」", t.name)),
_ => {}
}
}
let screens = if screens.is_empty() { "目前沒有任何可操作的螢幕".to_string() } else { format!("可操作的螢幕:{}", screens.join("、")) };
s.push_str(&format!(
"\n\n現在是 {}({},時區 {})。\n\
你在 AnyBot 這個 App 裡,可以直接操作 App:\n\
- 使用者想定期或在某個時間做事(每天、每週一、每月一號、提醒我…),就用 create_routine 設成例行任務;時間照使用者的時區。查看、修改、暫停、刪除用 list_routines / update_routine / delete_routine。\n\
- 換模型或供應商、改名字、建立新 Bot:update_bot / create_bot;看有哪些 Bot:list_bots。\n\
- 主題、強調色、時區:update_settings。\n\
- 使用者說「交給某某 Bot」或需要別的 Bot 專長時,用 message_bot。\n\
做完 App 操作後,用一句話告訴使用者結果(例如排程的時間)。\n\n\
你也可以上網和用使用者的瀏覽器:\n\
- 公開網頁:web_fetch。\n\
Whole control flow checked against real chats: what the bot sees is what is there, every step lands, nothing done twice Web - web_fetch kept pages with a <header> only up to it (taken for <head>): articles came back as their menus. - browser_open waits for the page to load and returns it like browser_read (one step fewer per page); actions wait for the page to hold still instead of fixed 1-2.5 s sleeps; one way to reach the tab. The bot's computer (box v6) - Numbers only on what can be seen: clipped to the window and the page's visible area, none under another app's window (a link scrolled under the toolbar was numbered on Back). - press_key with a typed character no longer swallows the keys after it (xdotool type --args 1). - The person's input through noVNC/TigerVNC is noticed again (fence, continuous updates, QEMU keys). - "Nothing changed" only after clicks, scrolls and drags; long text is pasted; one screen step at a time; one docker inspect per step when the box is known ready; poppler-utils for long PDFs. - One typing path and fewer round trips (stdin variant, unused picture path, extra batch execs gone). The person's computers - Linux: the window system's front app when it has one, and the reason when there is none (was 「」); hidden and off-screen controls get no number; wording no longer names a tool that doesn't exist. The loop - Without Docker, the bot's own computer runs no commands where there is no sandbox (Linux, Windows): it was a plain shell on the brain itself, keys in reach, asked nothing. - The bot's own computer is re-checked each step; unreachable computers are failures, not safety blocks; no direct-mode note on every result; an empty reply is asked again; scrolling and keys aren't counted as going in circles; steps aren't moved to a computer that can't do them. - One finish() for every step's result, keep_grant(), dead appctl list_computers/SERVING and the impossible server branches removed; duplicated prompt instructions merged. Tests: 162 (Mac) / 166 (Linux) unit; real box drills 6/6 on box v6; a brain without Chrome browses on its box; remote E2E 28/28. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
2026-11-06 09:44:59 +00:00
- 要登入才看得到的(Threads、Instagram、Facebook、Gmail、銀行…):用 browser_open 在 AnyBot 自己的瀏覽器視窗打開:它會等頁面載入,直接回傳頁面內容和可以點的編號(不用再 browser_read;之後頁面變了才用 browser_read 重讀)。例如看 Threads 帳號:browser_open(\"https://www.threads.net\")。\n\
Bots click by number: every control on the screen is boxed and numbered, on every computer A bot asked to open a Threads profile clicked an empty strip ten times: it guessed coordinates from a picture, and the web tools' description told it they didn't work on its own computer. - marks.rs: the picture a bot gets has every button, link, field and menu item boxed and numbered (a tiny built-in digit font, pure Rust), with the same numbers in a list. click / typing / scroll / mouse_move take the number (ref) and land on the control's centre. - Where the controls come from: macOS AX and Linux AT-SPI through agent-desktop on a person's computer (desktop::elements); AT-SPI in the bots' own computer (box/a11y, after XBot's box-a11y), web pages included. On a Mac a control whose spot another app's window covers gets no number (topmost window at its centre must be the app's; the Dock's invisible full-screen window doesn't count). open_app waits until the app is really in front. - No going round in circles: a step that changed nothing says so; the same step (clicks within 16 px) a third time in a turn comes with a warning, from the fourth time it isn't run. - The box (version 4): one TigerVNC server instead of Xvfb + x11vnc, the AT-SPI bus and registry at boot, one browser launcher (box/browser, after XBot's box-chrome: same profile and CDP, renderer accessibility, a launch lock, stale profile locks cleared). One docker exec per look (list + picture, ~0.4 s). A brain updates an older box by itself when it has been idle a minute (files and logins stay); a new box is never made from an older image. - Gone: computer_use_read_app (the list is in every picture), computer_use_mouse_button, the per-call picture for the phone (it gets a plain one), the stale browser_read note; the prompt says web work goes through browser_* by number, and never to read browser cookie or history files. Tests: marks (numbering, drawing, fingerprints), box_numbers_are_exact on a box from the new image (a Threads-like page: the icon links get numbers, Profile clicked by number, Chinese typed into the numbered field, a click on nothing called out), box_screen_is_exact, VNC e2e 11/11 on TigerVNC, Calculator 7×8=56 by numbers through the signed app on this Mac, remote_e2e 28/28, 153 unit tests; Linux build + 157 tests + x11_live. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 16:47:01 +00:00
- 網頁上的事(任何電腦、任何網站)一律用 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、瀏覽紀錄、密碼或設定檔資料庫。
Whole control flow checked against real chats: what the bot sees is what is there, every step lands, nothing done twice Web - web_fetch kept pages with a <header> only up to it (taken for <head>): articles came back as their menus. - browser_open waits for the page to load and returns it like browser_read (one step fewer per page); actions wait for the page to hold still instead of fixed 1-2.5 s sleeps; one way to reach the tab. The bot's computer (box v6) - Numbers only on what can be seen: clipped to the window and the page's visible area, none under another app's window (a link scrolled under the toolbar was numbered on Back). - press_key with a typed character no longer swallows the keys after it (xdotool type --args 1). - The person's input through noVNC/TigerVNC is noticed again (fence, continuous updates, QEMU keys). - "Nothing changed" only after clicks, scrolls and drags; long text is pasted; one screen step at a time; one docker inspect per step when the box is known ready; poppler-utils for long PDFs. - One typing path and fewer round trips (stdin variant, unused picture path, extra batch execs gone). The person's computers - Linux: the window system's front app when it has one, and the reason when there is none (was 「」); hidden and off-screen controls get no number; wording no longer names a tool that doesn't exist. The loop - Without Docker, the bot's own computer runs no commands where there is no sandbox (Linux, Windows): it was a plain shell on the brain itself, keys in reach, asked nothing. - The bot's own computer is re-checked each step; unreachable computers are failures, not safety blocks; no direct-mode note on every result; an empty reply is asked again; scrolling and keys aren't counted as going in circles; steps aren't moved to a computer that can't do them. - One finish() for every step's result, keep_grant(), dead appctl list_computers/SERVING and the impossible server branches removed; duplicated prompt instructions merged. Tests: 162 (Mac) / 166 (Linux) unit; real box drills 6/6 on box v6; a brain without Chrome browses on its box; remote E2E 28/28. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
2026-11-06 09:44:59 +00:00
- 需要使用者親手做的步驟(讀到登入頁、輸入密碼、驗證碼、拼圖、簡訊或 email 驗證、確認付款):先在那台電腦上把那一頁打開、點到要輸入的地方,再呼叫 hand_over_computer,instruction 寫清楚要他做什麼。聊天裡會出現那台電腦的即時畫面,他點「接管」做完後按「我完成了」,你再確認、接著做(登入一次之後就會記住)。不要只用文字叫他自己去找視窗,絕對不要替他打密碼,也不要叫他改瀏覽器的設定或選單、或說要從 Chrome 複製登入或 Cookie(都不需要,也沒有這個功能)。\n\
- AnyBot 瀏覽器有設定檔(profile),每個有自己的登入。使用者說要換 profile/設定檔/另一個帳號時,用 browser_profiles:list 看有哪些,use 切換,add 新增(新的要登入一次)。也可以在 browser_open / browser_read 帶 profile 參數。使用者想用自己平常的 Chrome(自己的 Chrome 設定檔、已經登入的帳號)時,用 profile「你的瀏覽器」:請使用者在 Chrome 切到想要的設定檔,再 browser_read。\n\
Whole control flow checked against real chats: what the bot sees is what is there, every step lands, nothing done twice Web - web_fetch kept pages with a <header> only up to it (taken for <head>): articles came back as their menus. - browser_open waits for the page to load and returns it like browser_read (one step fewer per page); actions wait for the page to hold still instead of fixed 1-2.5 s sleeps; one way to reach the tab. The bot's computer (box v6) - Numbers only on what can be seen: clipped to the window and the page's visible area, none under another app's window (a link scrolled under the toolbar was numbered on Back). - press_key with a typed character no longer swallows the keys after it (xdotool type --args 1). - The person's input through noVNC/TigerVNC is noticed again (fence, continuous updates, QEMU keys). - "Nothing changed" only after clicks, scrolls and drags; long text is pasted; one screen step at a time; one docker inspect per step when the box is known ready; poppler-utils for long PDFs. - One typing path and fewer round trips (stdin variant, unused picture path, extra batch execs gone). The person's computers - Linux: the window system's front app when it has one, and the reason when there is none (was 「」); hidden and off-screen controls get no number; wording no longer names a tool that doesn't exist. The loop - Without Docker, the bot's own computer runs no commands where there is no sandbox (Linux, Windows): it was a plain shell on the brain itself, keys in reach, asked nothing. - The bot's own computer is re-checked each step; unreachable computers are failures, not safety blocks; no direct-mode note on every result; an empty reply is asked again; scrolling and keys aren't counted as going in circles; steps aren't moved to a computer that can't do them. - One finish() for every step's result, keep_grant(), dead appctl list_computers/SERVING and the impossible server branches removed; duplicated prompt instructions merged. Tests: 162 (Mac) / 166 (Linux) unit; real box drills 6/6 on box v6; a brain without Chrome browses on its box; remote E2E 28/28. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
2026-11-06 09:44:59 +00:00
- 你也能像人一樣操作螢幕({screens}),用在檔案、指令和網頁工具做不到的事(例如操作其他 App)。照截圖上的編號操作(工具說明裡有怎麼用);只有沒有編號的東西才用 x、y。結果說「畫面和上一步完全一樣」就是沒點到:換一個編號或換方法,絕對不要重複同一步。已經從畫面知道接下來幾步時(填一張表、一組捷徑),用 computer_use_batch 一次做完;要看上一步結果才知道下一步的,就一步一步來。每次看截圖用一句話說畫面顯示什麼、這步成功與否。\n\
{}\
- 不要說「我沒有瀏覽器或工具」:先試這些工具。需要權限時 App 會自己跳出視窗問使用者;工具回報要開什麼設定時,照實轉告。",
now.format("%Y-%m-%d %H:%M"),
["週一", "週二", "週三", "週四", "週五", "週六", "週日"][now.weekday().num_days_from_monday() as usize],
st.timezone,
if mac_apps { " - 使用者 Mac 上的「提醒事項、行事曆、聯絡人、備忘錄、郵件、訊息」有專門的工具(ios_read_reminders、mobile_read_calendar、mobile_read_contacts、mac_read_notes、mac_read_mail 等),比操作畫面快又準,會在使用者正在用的那台 Mac 上做;寄信或傳訊息每次都會先請使用者確認,起草郵件(mac_draft_mail)則讓使用者自己按寄出。\n" } else { "" }
));
if !b.routines.is_empty() {
s.push_str("\n\n你目前的例行任務:\n");
s.push_str(&b.routines.iter().map(appctl::routine_line).collect::<Vec<_>>().join("\n"));
}
// 頁面: what this bot already keeps, so a routine or a follow-up updates the same page.
let mine: Vec<String> = st.pages.iter().filter(|p| p.bot == b.id).map(|p| format!("#{} 「{}」(第 {} 版,更新於 {})", p.id, p.title, p.revision, crate::pages::when(p.updated))).collect();
if !mine.is_empty() {
s.push_str("\n\n你做過的頁面(更新時用 write_page 帶 id,先 read_page 看現在的內容):\n");
s.push_str(&mine.join("\n"));
}
if !computers.is_empty() {
let list: Vec<String> = lines.iter().map(|l| format!("- {l}")).collect();
let mine = match &roles.person {
Some(p) => format!("使用者說「我的電腦」「這台」「我的 Mac」時,指的是「{p}」(他正在用的那台)。"),
None => "現在不知道使用者在用哪台電腦:他說「我的電腦」「這台」時,先問他是哪一台。".to_string(),
};
let gone = match missing {
Some(m) => format!("\n- 使用者選的「{m}」現在沒有連線(那台的 AnyBot 沒開或沒登入)。要在那台做事時,告訴使用者先打開它;要改用別台,先問使用者。"),
None => String::new(),
};
s.push_str(&format!(
"\n\n你可以用工具在電腦上做事。每台電腦都用它自己的名字稱呼,工具的 computer 參數就填那個名字:\n{}\n\
- 使用者替你選的電腦是「{default_computer}」:檔案、指令、看畫面、點擊和瀏覽器都在這台做,不要自己換到別台。只有使用者在訊息裡指名另一台,才把 computer 設成那台。{gone}\n\
- 「{own}」是你自己的電腦;使用者說「你的電腦」「Bot 的電腦」指的就是它。{mine}\n\
- 回覆使用者時,說到電腦一律用上面的名字(例如「我在「{own}」打開了 Threads」),不要說「你的電腦」「Bot 的電腦」這種會混淆的說法。\n\
- 不確定使用者指的是哪台電腦時(他說的那台不在清單上、有幾台都說得通、或他的說法和你手上的預設不一致),先用一句話問他要哪台,問清楚再動手;不要擅作主張選一台就開始做,也不要把一台說成另一台。\n\
- 先用 list_dir、search、read_file 看清楚,再動手。\n\
- 一回合的步數有限,每一步都要做多一點:互不相依的工具在同一步一起呼叫(例如同時讀幾個檔案、同時查幾件事);查東西的指令合成一條(用 ; 或 && 串起來,一次看完要的資料),不要一條指令一步。要等東西跑完時,把等待寫在同一條指令裡(迴圈等到好了為止),不要一步一步 sleep。\n\
- 需要權限時(電腦工具還沒打開、要用使用者的電腦、會改變東西的操作),直接呼叫工具,App 會自己跳出視窗請使用者允許;不要叫使用者自己去改設定。被拒絕就換個方式或問使用者。\n\
- 跑很久的指令會移到背景,用 read_terminal 追蹤,不要重複執行;它在等輸入時(例如問 y/n),用 write_terminal 回答。\n\
- 只有工具結果證明做完了,才說做完;失敗就照實說。\n\
- 連續執行多步驟工具時不要每步都發訊息(先說「好,我先…」,做完一個工具播一段,最後再總結——使用者打一句就看到三四則回覆)。執行時就安靜地連續呼叫工具,全部做完後用一段話講結果;只有卡住、需要使用者動手(例如要他輸入密碼)、或要先問清哪台電腦時,才中途說話。\n\
- 回覆使用者時,用簡單的話說明你做了什麼、在哪台電腦、結果如何。",
list.join("\n"),
own = roles.own,
));
}
let chans: Vec<String> = st.channels.iter().filter(|c| c.bot_id == b.id && c.enabled).map(|c| format!("{}「{}」", c.kind.label(), c.name)).collect();
if !chans.is_empty() {
s.push_str(&format!("\n\n你也在這些訊息管道回覆別人:{}。以「[訊息管道]」開頭的訊息來自那裡,你這一回合最後的回答會自動傳回給對方,所以要簡潔、直接回答對方,不要用表格,也不要提到 AnyBot 的內部操作。對方不是使用者本人:不要透露使用者的私人資料,要動到使用者的電腦或帳號前先確認。", chans.join("、")));
}
s.push_str(&crate::memory::prompt_section(&b.memories, &st.skills));
let mcp_notes = crate::mcp::instructions(&st.mcp_servers);
let mcp_tools = crate::mcp::status();
if !mcp_tools.is_empty() {
s.push_str(&format!("\n\n你也可以用使用者接上的連接器(MCP,工具名稱以 mcp__ 開頭):{}。第一次用某個連接器時 App 會問使用者。{}", mcp_tools.keys().cloned().collect::<Vec<_>>().join("、"), if mcp_notes.is_empty() { String::new() } else { format!("\n連接器的使用說明:\n{mcp_notes}") }));
}
s.push_str(&crate::teach::prompt_section(&b.demos));
if !b.instructions.trim().is_empty() {
s.push_str("\n\n");
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做好的檔案(試算表、文件、Markdown、網頁、圖片)用 send_file 傳進聊天,使用者點一下就能預覽;不要只說檔案放在哪裡。適合的時候可以用 react 對使用者的訊息按個表情(例如謝謝你時按 ❤️),不用每次都按。");
s.push_str("\n\n");
s.push_str(&crate::i18n::reply_instruction(&st.language));
s
}
fn short_provider(k: ProviderKind) -> &'static str {
match k {
ProviderKind::Xai => "Grok",
ProviderKind::OpenAi => "OpenAI",
ProviderKind::Anthropic => "Claude",
ProviderKind::Gemini => "Gemini",
ProviderKind::OpenRouter => "OpenRouter",
ProviderKind::OpenCodeGo => "OpenCode Go",
ProviderKind::Ollama => "Ollama",
ProviderKind::Custom => "自訂",
}
}
/// The Chinese font's "·" already carries space on both sides; spaces around it doubled the gap.
fn tidy_dots(s: &str) -> String {
s.replace(" · ", "·")
}
fn first_line(s: &str, max: usize) -> String {
let line = s.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or("");
let line = line.trim_start_matches(['#', '-', '*', ' ']);
let mut out: String = line.chars().take(max).collect();
if line.chars().count() > max {
out.push('…');
}
out
}
fn icon_button(ui: &mut egui::Ui, icon: &str, tip: &str, p: Palette) -> egui::Response {
if let Some(ic) = crate::icons::Icon::from_name(icon) {
return crate::icons::button(ui, ic, 28.0, p.text2, p.hover).on_hover_text(t(tip));
}
ui.add(egui::Button::new(RichText::new(icon).size(15.0).color(p.text2)).frame(false).min_size(vec2(26.0, 26.0))).on_hover_text(t(tip))
}
fn small_button(ui: &mut egui::Ui, text: &str, p: Palette) -> egui::Response {
ui.add(egui::Button::new(RichText::new(t(text)).size(11.5).color(p.text3)).frame_when_inactive(false).corner_radius(6).min_size(vec2(0.0, 22.0)))
}
/// The input's 28 px round buttons (+, mic, voice chat / send).
fn round_tool(ui: &mut egui::Ui, icon: crate::icons::Icon, fill: Color32, ink: Color32) -> egui::Response {
let (rect, r) = ui.allocate_exact_size(vec2(28.0, 28.0), egui::Sense::click());
let f = if r.hovered() { fill.gamma_multiply(0.82).to_opaque().lerp_to_gamma(fill, 0.3) } else { fill };
ui.painter().circle_filled(rect.center(), 14.0, f);
crate::icons::paint(ui.painter(), egui::Rect::from_center_size(rect.center(), vec2(15.0, 15.0)), icon, ink);
r.on_hover_cursor(egui::CursorIcon::PointingHand)
}
/// A 36 px rounded button with a hairline border (the original's 分享 / ⋯).
fn pill_text(ui: &mut egui::Ui, text: &str, p: Palette) -> egui::Response {
ui.add(egui::Button::new(RichText::new(text).size(14.0).color(p.text)).fill(p.elevated).stroke(Stroke::new(1.0, p.border)).corner_radius(18).min_size(vec2(0.0, 36.0)))
}
fn pill_icon(ui: &mut egui::Ui, icon: crate::icons::Icon, p: Palette) -> egui::Response {
let (rect, r) = ui.allocate_exact_size(vec2(36.0, 36.0), egui::Sense::click());
ui.painter().circle_filled(rect.center(), 18.0, if r.hovered() { p.hover } else { p.elevated });
ui.painter().circle_stroke(rect.center(), 17.5, Stroke::new(1.0, p.border));
crate::icons::paint(ui.painter(), egui::Rect::from_center_size(rect.center(), vec2(16.0, 16.0)), icon, p.text2);
r.on_hover_cursor(egui::CursorIcon::PointingHand)
}
/// A group heading inside a settings page (like the original's section titles).
fn section(ui: &mut egui::Ui, title: &str, p: Palette) {
ui.add_space(14.0);
ui.label(RichText::new(t(title)).size(FONT_SM).strong().color(p.text3));
ui.add_space(2.0);
}
/// A small「?」after a setting's name. Its explanation shows as soon as the pointer is on it (no
/// waiting for the pointer to be still), and a click keeps it open until the next click.
fn help_mark(ui: &mut egui::Ui, text: &str, p: Palette) {
if text.trim().is_empty() {
return;
}
let (rect, r) = ui.allocate_exact_size(vec2(18.0, 18.0), egui::Sense::click());
let pinned_id = r.id.with("pinned");
let mut pinned = ui.ctx().data(|d| d.get_temp::<bool>(pinned_id)).unwrap_or(false);
if r.clicked() {
pinned = !pinned;
} else if pinned && ui.input(|i| i.pointer.any_pressed()) && !r.hovered() {
pinned = false;
}
ui.ctx().data_mut(|d| d.insert_temp(pinned_id, pinned));
let show = r.hovered() || pinned;
let col = if show { p.text } else { p.text3 };
ui.painter().circle_stroke(rect.center(), 6.5, Stroke::new(1.1, col));
ui.painter().text(rect.center() + vec2(0.0, 0.5), egui::Align2::CENTER_CENTER, "?", egui::FontId::proportional(10.0), col);
if show {
let text = t(text);
egui::Area::new(r.id.with("help"))
.order(egui::Order::Tooltip)
.fixed_pos(rect.left_bottom() + vec2(-4.0, 6.0))
.constrain(true)
.interactable(false)
.show(ui.ctx(), |ui| {
Frame::popup(ui.style()).corner_radius(10).inner_margin(Margin::symmetric(12, 10)).show(ui, |ui| {
ui.set_max_width(300.0);
ui.add(egui::Label::new(RichText::new(text).size(FONT_SM).color(p.text)).wrap());
});
});
}
r.on_hover_cursor(egui::CursorIcon::Help);
}
/// One setting: its name (with「?」for the explanation) on the left, its control on the right.
fn setting_row(ui: &mut egui::Ui, p: Palette, label: &str, desc: &str, control: impl FnOnce(&mut egui::Ui)) {
Frame::new().fill(p.elevated).corner_radius(10).inner_margin(Margin::symmetric(14, 10)).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
let w = (ui.available_width() * 0.5).max(180.0);
ui.horizontal(|ui| {
ui.set_width(w);
ui.spacing_mut().item_spacing.x = 4.0;
ui.label(RichText::new(t(label)).size(FONT_BASE).color(p.text));
help_mark(ui, desc, p);
});
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
ui.horizontal(control);
});
});
});
}
fn setting_title(ui: &mut egui::Ui, title: &str, desc: &str, p: Palette) {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
ui.label(RichText::new(t(title)).strong().size(FONT_BASE));
help_mark(ui, desc, p);
});
ui.add_space(4.0);
}
/// Small grey text under a setting.
fn hint(ui: &mut egui::Ui, text: impl Into<String>, p: Palette) {
ui.add(egui::Label::new(RichText::new(t(&text.into())).size(11.5).color(p.text3)).wrap());
}
/// A rounded group of related settings, like a macOS settings card.
fn setting_card<R>(ui: &mut egui::Ui, p: Palette, body: impl FnOnce(&mut egui::Ui) -> R) -> R {
let r = Frame::new()
.fill(p.hover.gamma_multiply(0.6))
.stroke(Stroke::new(1.0, p.border.gamma_multiply(0.6)))
.corner_radius(12)
.inner_margin(Margin::symmetric(16, 14))
.show(ui, |ui| {
ui.set_width(ui.available_width());
body(ui)
})
.inner;
ui.add_space(12.0);
r
}
/// A label column so form rows line up.
fn form_row(ui: &mut egui::Ui, label: &str, p: Palette, body: impl FnOnce(&mut egui::Ui)) {
ui.horizontal(|ui| {
let (rect, _) = ui.allocate_exact_size(vec2(FORM_LABEL_WIDTH, 28.0), egui::Sense::hover());
ui.painter().text(rect.left_center(), egui::Align2::LEFT_CENTER, t(label), egui::FontId::proportional(FONT_SM), p.text2);
body(ui);
});
}
fn field_label(ui: &mut egui::Ui, text: &str, p: Palette) {
ui.add_space(4.0);
ui.label(RichText::new(t(text)).size(FONT_XS).color(p.text2));
}
fn primary_button(text: &str, p: Palette) -> egui::Button<'static> {
egui::Button::new(RichText::new(t(text)).color(p.on_primary).strong()).fill(p.primary).corner_radius(8).min_size(vec2(72.0, 30.0))
}
fn secondary_button(text: &str) -> egui::Button<'static> {
egui::Button::new(t(text)).corner_radius(8).min_size(vec2(64.0, 30.0))
}
fn danger_button(text: &str, p: Palette) -> egui::Button<'static> {
egui::Button::new(RichText::new(t(text)).color(Color32::WHITE).strong()).fill(p.danger).corner_radius(8).min_size(vec2(72.0, 30.0))
}
/// Tallest a dialog may be: the window minus a margin above and below.
fn dialog_max_height(ctx: &egui::Context) -> f32 {
(ctx.content_rect().height() - WINDOW_SCREEN_MARGIN).max(240.0)
}
/// A centred dialog over a dimmed window: title and × on top, then the caller's body.
/// Returns true when the person asked to close it (×, Esc, or a click outside when `outside_closes`).
fn dialog(ctx: &egui::Context, id: &str, title: &str, width: f32, outside_closes: bool, p: Palette, content: impl FnOnce(&mut egui::Ui)) -> bool {
let dark = p.pane != LIGHT.pane;
let mut close = false;
let resp = egui::Modal::new(egui::Id::unique(("dialog", id)))
.backdrop_color(Color32::from_black_alpha(if dark { 130 } else { 70 }))
.frame(
Frame::new()
.fill(p.elevated)
.stroke(Stroke::new(1.0, p.border))
.corner_radius(16)
.shadow(egui::epaint::Shadow { offset: [0, 12], blur: 36, spread: 0, color: Color32::from_black_alpha(if dark { 110 } else { 45 }) }),
)
.show(ctx, |ui| {
// Never wider than the window (a narrow window keeps the whole dialog on screen).
ui.set_width(width.min(ctx.content_rect().width() - 32.0).max(280.0));
// Header, body and footer sit flush; each sets its own inner spacing.
ui.spacing_mut().item_spacing.y = 0.0;
Frame::new().inner_margin(Margin { left: 20, right: 12, top: 12, bottom: 12 }).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
ui.label(RichText::new(t(title)).strong().size(FONT_XL));
ui.add_space((ui.available_width() - 28.0).max(0.0));
// Drawn, not a glyph: not every Chinese font has "✕".
let (rect, r) = ui.allocate_exact_size(vec2(28.0, 28.0), egui::Sense::click());
if r.hovered() {
ui.painter().rect_filled(rect, 8, p.hover);
}
let c = rect.center();
let stroke = Stroke::new(1.5, if r.hovered() { p.text } else { p.text2 });
ui.painter().line_segment([c + vec2(-5.0, -5.0), c + vec2(5.0, 5.0)], stroke);
ui.painter().line_segment([c + vec2(-5.0, 5.0), c + vec2(5.0, -5.0)], stroke);
if r.on_hover_text(t("關閉(Esc)")).clicked() {
close = true;
}
});
});
let r = ui.min_rect();
ui.painter().hline(r.x_range(), r.bottom(), Stroke::new(1.0, p.border));
content(ui);
});
let escape = ctx.input(|i| i.key_pressed(Key::Escape));
close || escape || (outside_closes && resp.backdrop_response.clicked())
}
/// The scrolling middle of a dialog, `reserve` px kept free for its footer.
fn dialog_body<R>(ui: &mut egui::Ui, id: impl std::hash::Hash + std::fmt::Debug, max_height: f32, body: impl FnOnce(&mut egui::Ui) -> R) -> R {
egui::ScrollArea::vertical()
.id_salt(id)
.max_height(max_height)
.auto_shrink([false, true])
.show(ui, |ui| {
Frame::new().inner_margin(Margin { left: 20, right: 20, top: 16, bottom: 16 }).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.spacing_mut().item_spacing.y = 6.0;
body(ui)
})
.inner
})
.inner
}
/// The bottom bar of a dialog: a line above, buttons laid out right to left.
fn dialog_footer(ui: &mut egui::Ui, p: Palette, body: impl FnOnce(&mut egui::Ui)) {
let top = ui.cursor().top();
ui.painter().hline(ui.max_rect().x_range(), top, Stroke::new(1.0, p.border));
Frame::new().inner_margin(Margin::symmetric(20, 12)).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.spacing_mut().item_spacing.y = 6.0;
ui.horizontal(|ui| body(ui));
});
}
#[cfg(test)]
mod tests {
#[test]
fn group_members_see_each_other_as_labelled_words() {
let st = State::demo();
let (a, b) = (st.bots[1].id, st.bots[2].id);
let mut tool_step = Message { author: a, ..Message::assistant("", "x") };
tool_step.tool_calls.push(crate::model::ToolCall { id: "t1".into(), name: "shell".into(), args: "{}".into(), status: Default::default() });
let msgs = vec![
Message::user("大家覺得呢?"),
tool_step,
Message::tool_result("t1", "shell", "secret output"),
Message { author: a, reactions: vec!["👍".into()], ..Message::assistant("我覺得可以", "x") },
];
let h = super::group_history(&msgs, Some(b), &st);
assert_eq!(h.len(), 2, "{h:?}");
assert_eq!(h[1].role, Role::User);
assert!(h[1].text.starts_with(&format!("【{}】我覺得可以", st.bots[1].name)) && h[1].text.contains("👍"));
// The speaker's own steps stay as they are.
let own = super::group_history(&msgs, Some(a), &st);
assert_eq!(own.len(), 4);
}
#[test]
fn transcript_export_reads_like_a_chat() {
let st = State::demo();
let b = &st.bots[0];
let md = super::transcript_markdown(b);
assert!(md.starts_with(&format!("# {} 的對話", b.name)));
assert!(md.contains("**你**:"));
}
use super::*;
/// The relaunch helper waits for the old copy to exit before starting the new one.
#[test]
fn relaunch_waits_for_the_old_copy() {
let dir = std::env::temp_dir().join(format!("anybot-relaunch-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let marker = dir.join("started");
let fake_app = dir.join("fake-app.sh");
std::fs::write(&fake_app, format!("#!/bin/sh\necho \"${{ANYBOT_SHOT_VIEW:-clean}}\" > '{}'\n", marker.display())).unwrap();
std::process::Command::new("chmod").arg("+x").arg(&fake_app).status().unwrap();
let mut old = std::process::Command::new("sleep").arg("1").spawn().unwrap();
// A leftover start-up switch in this copy's environment must not reach the new copy.
std::env::set_var("ANYBOT_SHOT_VIEW", "leftover");
relaunch_command(old.id(), &fake_app).spawn().unwrap();
std::env::remove_var("ANYBOT_SHOT_VIEW");
std::thread::sleep(std::time::Duration::from_millis(500));
assert!(!marker.exists(), "started before the old copy exited");
old.wait().unwrap();
std::thread::sleep(std::time::Duration::from_millis(700));
assert_eq!(std::fs::read_to_string(&marker).unwrap().trim(), "clean");
}
}