// 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; 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, frames: u32, shot_requested: bool, shot: Arc>>>, drafts: HashMap, search: String, streams: HashMap, /// A tool waiting for the person's yes/no, per bot. approvals: HashMap, /// App actions (e.g. deleting a routine) waiting for that yes/no. app_pending: HashMap, /// Messages that arrived as a turn ended: they start the next turn. pending_turns: Vec<(u64, Vec)>, last_tick: Instant, /// The routines list for this bot is open. show_routines: Option, new_remote: (String, String), new_browser_profile: String, browser_note: std::sync::Arc>, confirm_remove_profile: Option, confirm_delete_routine: Option<(u64, u64)>, /// A short message shown above the message box (e.g. after a restart). notice: Option, /// Requests whose pop-up the person put off ("稍後再決定"); the chat card stays. popup_hidden: std::collections::HashSet, /// "全部允許,之後直接操作" was clicked in a request. want_direct: std::cell::Cell, /// 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, /// A newer build has replaced the program file since this copy started. newer_build: Option, 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, 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, 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, model_fetch: HashMap, String>>>, fetch_status: HashMap, 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)>, /// 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>>, /// 群組聊天: members still to answer in this round, and groups whose next member should start. group_queue: HashMap>, group_next: Vec, /// 引用: the message being quoted in the next message, per bot. quoting: HashMap, /// 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, /// 右側面板 and its tab (None = closed). panel: Option, /// 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, 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, search_open: bool, confirm_section_delete: Option, /// 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, /// When the launch greeting started. splash_at: Option, /// 大腦: 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, } impl App { pub fn new(ctx: &egui::Context, screenshot: Option) -> 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, 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(); 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("viewer") => { crate::cloudbox::start_background(); let _ = crate::cloudbox::ensure_running(); app.cu.demo_live = true; app.cu.viewer_open = true; app.cu.fit = true; } 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, "

報價

總計 NT$ 12,800

"); 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()), } } } Ok("panel") => { // The real box, so the 電腦 card shows its screen. crate::cloudbox::start_background(); let _ = crate::cloudbox::ensure_running(); app.cu.demo_live = true; 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; } 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()); // 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 names: Vec = computers.iter().map(|t| t.name.clone()).collect(); let default_computer = if names.contains(&b.default_computer) { b.default_computer.clone() } else { crate::model::BOT_COMPUTER_NAME.to_string() }; let mut tool_specs = appctl::specs(); tool_specs.extend(tools::specs_for(&names, &default_computer)); let devices: Vec = computers.iter().filter(|t| matches!(t.place, tools::Place::Device { .. })).map(|t| t.name.clone()).collect(); let here = computers.iter().find(|t| matches!(t.place, tools::Place::Local { .. })).map(|t| t.name.clone()).unwrap_or_else(|| crate::model::THIS_COMPUTER_NAME.into()); tool_specs.extend(web::specs_for(&here, &devices)); // Screen tools: the bot's own desktop (when it has one) and this Mac. let mut screens = vec![]; if crate::cloudbox::available() { screens.push(crate::model::BOT_COMPUTER_NAME.to_string()); } if crate::computer::screen_supported() { screens.extend(computers.iter().filter(|t| matches!(t.place, tools::Place::Local { .. })).map(|t| t.name.clone())); } // …and the account's other computers (手腳): their own AnyBot looks and clicks there. screens.extend(devices.iter().cloned()); if cfg!(target_os = "macos") { 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() }; tool_specs.extend(crate::computer::specs_for(&screens, &default_screen)); } // 看圖小幫手: 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); if speaker.is_some() { let names: Vec = 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, 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 (its own, this Mac, then SSH ones). fn targets_for(&self, b: &Bot) -> Vec { let mut v = vec![ tools::Target { name: crate::model::BOT_COMPUTER_NAME.into(), 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) } }, }, tools::Target { name: crate::model::THIS_COMPUTER_NAME.into(), place: tools::Place::Local { workdir: tools::expand_home(if b.workdir.trim().is_empty() { "~" } else { b.workdir.trim() }) }, }, ]; v.extend(self.state.remote_computers.iter().map(|c| 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. v.extend(self.linked_devices().into_iter().filter(|d| !v.iter().any(|t| t.name == d.name)).collect::>()); // A brain on a server whose hands are the person's computer: "你的電腦" is that computer // (what the person means by "my Mac"), and the server itself goes by its own name. if !cfg!(target_os = "macos") { if let Some(hand) = crate::remote::my_hands(&self.state.remote.host_id).into_iter().next() { if let Some(i) = v.iter().position(|t| matches!(&t.place, tools::Place::Device { host } if *host == hand)) { for t in v.iter_mut() { if matches!(t.place, tools::Place::Local { .. }) { t.name = crate::model::BRAIN_HOST_NAME.into(); } } v[i].name = crate::model::THIS_COMPUTER_NAME.into(); } } else if self.headless { // A server brain with no hands connected: the machine it runs on is a server, // not anyone's computer. Wearing the name 你的電腦 here made bots "work on your // computer" on the server while the person watched their own untouched desktop. for t in v.iter_mut() { if matches!(t.place, tools::Place::Local { .. }) { t.name = crate::model::BRAIN_HOST_NAME.into(); } } } } v } /// 查看電腦: the computers this bot really has, in plain words. fn computers_text(&self, b: &Bot) -> String { self.targets_for(b) .iter() .map(|t| match &t.place { tools::Place::Sealed { .. } | tools::Place::Box { .. } => format!("{}:這個 Bot 自己的 Linux 桌面(AI 的電腦,不是使用者的電腦),一律可用", t.name), tools::Place::Local { .. } if t.name == crate::model::BRAIN_HOST_NAME => format!("{}:AnyBot 大腦所在的伺服器「{}」(不是使用者的電腦)", t.name, self.state.remote.host_name), tools::Place::Local { .. } => format!( "{}:{}「{}」,{}", t.name, if cfg!(target_os = "macos") { "使用者的這台 Mac" } else { "使用者的這台電腦" }, self.state.machine_label, if self.state.local_execution { "已允許" } else { "還沒預先允許(直接用工具,App 會問使用者)" } ), tools::Place::Ssh { target, .. } => format!("{}:透過 SSH 連到 {target}", t.name), tools::Place::Device { host } => { let real = crate::remote::peer_name(host); if t.name == crate::model::THIS_COMPUTER_NAME { format!("{}:使用者的電腦「{real}」(大腦透過它的 AnyBot 看畫面、點擊、開瀏覽器和跑指令)", t.name) } else { format!("{}:帳號裡的另一台電腦(裝置連結)", t.name) } } }) .collect::>() .join("\n") } /// 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 = 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 { 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 = 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::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; } } 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 { 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 = 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 { let ps = self.state.provider(kind); let mut v: Vec = 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| { 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 = 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 = 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() { 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 self.approvals.contains_key(&bot.id) { if crate::icons::pill(&mut right, crate::icons::Icon::Shield, &t("需要你允許"), p.accent, Color32::WHITE, 36.0).on_hover_text(t("打開請求視窗")).clicked() { self.popup_hidden.remove(&bot.id); } } 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 { 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), }; let note = match kind { ApprovalKind::EnableTools => Some("這個 Bot 的電腦工具還沒打開。它想做的事:"), ApprovalKind::EnableThisComputer => Some("「在這台電腦上執行」還沒打開,Bot 平常只在它自己的電腦上做事。它想在你的電腦上:"), ApprovalKind::Action => 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 => 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; if !self.approvals.contains_key(&bot.id) { 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); } } /// 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(); if !self.headless { notify::banner(&format!("{} 需要你允許", b.name), &what); } crate::remote::notify(&format!("{} 需要你允許", b.name), &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; let mut ids: Vec = self.approvals.keys().copied().filter(|id| !self.popup_hidden.contains(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 = 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 = 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)> = 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 names = self.state.computer_names(); egui::ComboBox::from_id_salt("default_computer").selected_text(b.default_computer.clone()).width(150.0).show_ui(ui, |ui| { for n in names { let label = if n == crate::model::THIS_COMPUTER_NAME && !self.state.local_execution { format!("{n}(要先在設定裡允許)") } else { n.clone() }; ui.selectable_value(&mut b.default_computer, n, label); } }); ui.label(RichText::new(t("聊天時說「在我的 Mac 上」就會換")).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, "在 Bot 的電腦上可以自由做事(看不到你的檔案);在你的電腦上,改檔案和會改變東西的指令會先問你。密碼和金鑰檔一律不碰。", 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 = 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::().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 = 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::().ok()?, h.parse::().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 = 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::(&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 ` 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::()) } 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) -> ! { crate::appctl::SERVING.store(true, std::sync::atomic::Ordering::Relaxed); 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 = 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::()) { if self.frames + 1 == at { if let (Ok(x), Ok(y)) = (x.trim().parse::(), y.trim().parse::()) { 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::(), y.trim().parse::(), at.trim().parse::()) 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; } 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.viewer_window(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 = 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::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) -> String { t.map(|t| chrono::DateTime::::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, started: Instant, last: Instant, produced: bool, status: Option, /// 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>>, turn_id: String, /// 直接操作 for this turn (switched on mid-turn by "全部允許"). direct: Arc, } /// 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, st: &State) -> Vec { let mut out = vec![]; let mut skip_tool_ids: std::collections::HashSet = 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::())); } 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. fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_computer: &str) -> 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, named as the model must name them: the fixed // wording promised "你的電腦(使用者的 Mac)" on server brains that have no such computer, // and bots then "worked on your computer" on the server itself. let mut screens: Vec = Vec::new(); for t in computers { match &t.place { tools::Place::Box { .. } => screens.push(format!("{}(AI 自己的 Linux 桌面,不是使用者的電腦)", t.name)), tools::Place::Local { .. } => { if crate::computer::screen_supported() { screens.push(format!("{}(這台電腦自己的畫面)", t.name)); } } tools::Place::Device { host } if t.name == crate::model::THIS_COMPUTER_NAME => screens.push(format!("{}(使用者自己的電腦「{}」的畫面)", t.name, crate::remote::peer_name(host))), tools::Place::Device { .. } => screens.push(format!("{}(帳號裡另一台電腦的畫面)", t.name)), tools::Place::Ssh { .. } | tools::Place::Sealed { .. } => {} } } let screens = if screens.is_empty() { "目前沒有任何可操作的螢幕".to_string() } else { 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\ - 要登入才看得到的(Threads、Instagram、Facebook、Gmail、銀行…):用 browser_open 在 AnyBot 自己的瀏覽器視窗打開,再用 browser_read 讀分頁內容。例如看 Threads 帳號:browser_open(\"https://www.threads.net\") 然後 browser_read。\n\ - 在網頁上做事(後台、報表、表單)要像人一樣操作:先 browser_screenshot 看一眼(圖上每個可點的東西都有編號),再用 browser_click/browser_type/browser_select/browser_press 照編號操作;表格用 browser_table 讀,找字用 browser_find,要等載入用 browser_wait,匯出報表用 browser_download。不要猜網址;找不到想要的功能(例如排序、篩選),就看欄位標題、篩選器、下拉選單或分頁標籤,還是沒有就照頁面現有的資料自己整理,並說明網站沒有這個功能。 - 絕對不要直接呼叫網站的內部 API、下載網站的程式碼來研究,或用程式大量抓資料:網站會把帳號當成機器人(例如跳出拼圖驗證、封鎖帳號)。出現驗證碼、拼圖或簡訊驗證時,停下來請使用者在 AnyBot 瀏覽器視窗完成。 - 如果讀到的是登入頁,請使用者在跳出來的 AnyBot 瀏覽器視窗登入一次(之後會記住),登入好再讀。絕對不要叫使用者去改瀏覽器的設定或選單(例如「允許來自 Apple 事件的 JavaScript」),那不需要。也不要說要從 Chrome 複製登入或 Cookie,沒有這個功能。\n\ - AnyBot 瀏覽器有設定檔(profile),每個有自己的登入。使用者說要換 profile/設定檔/另一個帳號時,用 browser_profiles:list 看有哪些,use 切換,add 新增(新的要登入一次)。也可以在 browser_open / browser_read 帶 profile 參數。使用者想用自己平常的 Chrome(自己的 Chrome 設定檔、已經登入的帳號)時,用 profile「你的瀏覽器」:請使用者在 Chrome 切到想要的設定檔,再 browser_read。\n\ - 你也能像人一樣操作螢幕({screens})。computer_use_screenshot 看畫面,再用 computer_use_click / computer_use_typing / computer_use_press_key / computer_use_scroll / computer_use_drag 操作;座標用最新截圖上的像素。每做一步就再截圖確認。用在檔案和網頁工具做不到的事(例如操作其他 App、在 Chrome 切換設定檔)。\n\ - 使用者 Mac 上的「提醒事項、行事曆、聯絡人、備忘錄、郵件、訊息」有專門的工具(ios_read_reminders、mobile_read_calendar、mobile_read_contacts、mac_read_notes、mac_read_mail 等),比操作畫面快又準;寄信或傳訊息每次都會先請使用者確認,起草郵件(mac_draft_mail)則讓使用者自己按寄出。\n\ - 不要說「我沒有瀏覽器或工具」:先試這些工具。需要權限時 App 會自己跳出視窗問使用者;工具回報要開什麼設定時,照實轉告。", now.format("%Y-%m-%d %H:%M"), ["週一", "週二", "週三", "週四", "週五", "週六", "週日"][now.weekday().num_days_from_monday() as usize], st.timezone )); if !b.routines.is_empty() { s.push_str("\n\n你目前的例行任務:\n"); s.push_str(&b.routines.iter().map(appctl::routine_line).collect::>().join("\n")); } // 頁面: what this bot already keeps, so a routine or a follow-up updates the same page. let mine: Vec = 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 = computers .iter() .map(|t| match &t.place { tools::Place::Sealed { root } => format!("- {}:你自己的電腦(獨立資料夾 {},看不到使用者的檔案;沒有螢幕)", t.name, root.display()), tools::Place::Box { dir } => { let secrets = crate::cloudbox::secret_names(); format!( "- {}:你自己的 Linux 電腦(Debian,有桌面和 Chromium,所有 Bot 共用;你的資料夾是 {dir},可以 sudo 安裝套件)。需要時會自動喚醒。可以用 computer_use_* 工具(computer=「{}」)看它的螢幕和操作滑鼠鍵盤,例如開 chromium 上網、測網頁。{}", t.name, t.name, if secrets.is_empty() { String::new() } else { format!("使用者設好的環境密鑰(已是環境變數,值不要印出來):{}。", secrets.join("、")) } ) } tools::Place::Local { workdir } => format!( "- {}:{},起始資料夾 {}{}", t.name, if t.name == crate::model::BRAIN_HOST_NAME { "你(大腦)所在的伺服器,不是使用者的電腦;只有使用者明確要在伺服器上做事才用" } else if cfg!(target_os = "macos") { "使用者的這台 Mac" } else if crate::platform::has_desktop() { "使用者的這台 Linux 電腦(快捷鍵用 Ctrl,不是 ⌘)" } else { "AnyBot 所在的伺服器(不是使用者的 Mac;要用使用者的 Mac 請選帳號裡那台電腦的名字)" }, workdir.display(), if st.local_execution { "" } else { "(還沒預先允許:直接使用工具即可,App 會跳出視窗問使用者)" } ), tools::Place::Ssh { target, .. } => format!("- {}:使用者的另一台電腦(SSH {target})", t.name), tools::Place::Device { host } if t.name == crate::model::THIS_COMPUTER_NAME => format!( "- {}:使用者自己的電腦「{}」(你在伺服器上,透過它的 AnyBot 操作它:檔案、指令、瀏覽器和 computer_use_* 工具,computer=「{}」)。使用者說「我的電腦」「我的筆電」「這台」或提到 Mac,都是指它。", t.name, crate::remote::peer_name(host), t.name ), tools::Place::Device { .. } => format!("- {}:使用者帳號裡的另一台電腦(裝置連結;可以用檔案、指令和 computer_use_* 工具,computer=「{}」)", t.name, t.name), }) .collect(); s.push_str(&format!( "\n\n你可以用工具在電腦上做事。每個工具都有 computer 參數,預設是「{default_computer}」。\n{}\n\ - 使用者替你選的電腦是「{default_computer}」:檔案、指令、看畫面、點擊和瀏覽器都在這台做,不要自己換到別台。只有使用者在訊息裡指名另一台(「在我的 Mac 上」「在 Bot 的電腦上」),才把 computer 設成那台。\n\ - 不確定使用者指的是哪台電腦時(例如他說「我的電腦」而那台不在清單上、有幾台都說得通、或他的說法和你手上的預設不一致),先用一句話問他要哪台,問清楚再動手;不要擅作主張選一台就開始做,也不要把一台說成另一台。\n\ - 先用 list_dir、search、read_file 看清楚,再動手。\n\ - 需要權限時(電腦工具還沒打開、要用使用者的電腦、會改變東西的操作),直接呼叫工具,App 會自己跳出視窗請使用者允許;不要叫使用者自己去改設定。被拒絕就換個方式或問使用者。\n\ - 跑很久的指令會移到背景,用 read_terminal 追蹤,不要重複執行;它在等輸入時(例如問 y/n),用 write_terminal 回答。\n\ - 只有工具結果證明做完了,才說做完;失敗就照實說。\n\ - 回覆使用者時,用簡單的話說明你做了什麼、在哪台電腦、結果如何。", list.join("\n") )); } let chans: Vec = 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::>().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)) } /// A header action: text only, a soft fill on hover. #[allow(dead_code)] fn quiet_button(ui: &mut egui::Ui, text: &str, p: Palette) -> egui::Response { ui.add(egui::Button::new(RichText::new(t(text)).size(FONT_SM).color(p.text2)).frame_when_inactive(false).corner_radius(8).min_size(vec2(0.0, 28.0))) } 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::(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, 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(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(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"); } }