AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
// 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 ;
2026-11-05 06:25:01 +00:00
mod welcome_ui ;
2026-11-04 09:46:43 +00:00
mod mirror_ui ;
頁面: pages a bot makes and keeps up to date (like the original's tracking pages)
- write_page / read_page / list_pages: Markdown pages with versions (last 10 kept), per bot;
the reply gets a card (title, fading preview, page icon, 頁面 · 更新時間, 最近更新), and
[標題](page:ID) links open the page
- The page opens large: summary, what changed, the version picker, 複製 / 存成檔案 / 刪除;
listed under 資料庫 in the right panel
- Page blocks for richer pages, in the chat's Markdown too: ```stats tiles, ```steps progress,
> [!NOTE]/[!TIP]/[!WARNING]/[!IMPORTANT] call-outs (with tables and Mermaid charts)
- Phone: the card in chats, page links, a page view with the same blocks; charts drawn by the
AnyBot as SVG (GET /api/pages/<id>[/chart/<n>]); members may read pages
- Bots are told to make a page for things to track and to update it from a routine only when
there is real news
- Screenshot views: ANYBOT_SHOT_VIEW=pagecard / page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 17:41:41 +00:00
mod pages_ui ;
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
// 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 < PathBuf > ,
frames : u32 ,
shot_requested : bool ,
shot : Arc < Mutex < Option < Arc < egui ::ColorImage > > > > ,
drafts : HashMap < u64 , String > ,
search : String ,
streams : HashMap < u64 , Live > ,
/// A tool waiting for the person's yes/no, per bot.
approvals : HashMap < u64 , Approval > ,
/// App actions (e.g. deleting a routine) waiting for that yes/no.
app_pending : HashMap < u64 , AppCall > ,
/// Messages that arrived as a turn ended: they start the next turn.
pending_turns : Vec < ( u64 , Vec < String > ) > ,
last_tick : Instant ,
/// The routines list for this bot is open.
show_routines : Option < u64 > ,
new_remote : ( String , String ) ,
new_browser_profile : String ,
browser_note : std ::sync ::Arc < std ::sync ::Mutex < String > > ,
confirm_remove_profile : Option < String > ,
confirm_delete_routine : Option < ( u64 , u64 ) > ,
/// A short message shown above the message box (e.g. after a restart).
notice : Option < String > ,
/// Requests whose pop-up the person put off ("稍後再決定"); the chat card stays.
popup_hidden : std ::collections ::HashSet < u64 > ,
/// "全部允許,之後直接操作" was clicked in a request.
want_direct : std ::cell ::Cell < bool > ,
/// Admin locks (policy.rs), read at start and when Settings opens.
managed : policy ::Managed ,
settings_was_open : bool ,
/// When the program file this copy started from was built (its version).
exe_built : Option < std ::time ::SystemTime > ,
/// A newer build has replaced the program file since this copy started.
newer_build : Option < std ::time ::SystemTime > ,
last_build_check : Instant ,
/// Next time to consider switching to a newer build by itself (30 s after start, then hourly).
next_update_apply : Instant ,
window_focused : bool ,
idle_secs : Option < f64 > ,
last_presence : Instant ,
relaunching : bool ,
/// The sidebar's "new version" banner was clicked.
relaunch_requested : bool ,
new_allow_rule : String ,
new_ask_rule : String ,
side_drag : Option < f32 > ,
tx : Sender < ( u64 , StreamEvent ) > ,
rx : Receiver < ( u64 , StreamEvent ) > ,
show_settings : bool ,
settings_tab : SettingsTab ,
settings_provider : ProviderKind ,
edit_bot : Option < ( Bot , bool ) > ,
confirm_delete : Option < u64 > ,
model_fetch : HashMap < ProviderKind , Receiver < Result < Vec < String > , String > > > ,
fetch_status : HashMap < ProviderKind , String > ,
dirty : bool ,
last_save : Instant ,
stick_bottom : bool ,
/// Bot 的電腦 viewer, rebuild dialog and the computers page.
cu : computers_ui ::ComputerUi ,
vault : vault_ui ::VaultUi ,
md : markdown_ui ::MdState ,
頁面: pages a bot makes and keeps up to date (like the original's tracking pages)
- write_page / read_page / list_pages: Markdown pages with versions (last 10 kept), per bot;
the reply gets a card (title, fading preview, page icon, 頁面 · 更新時間, 最近更新), and
[標題](page:ID) links open the page
- The page opens large: summary, what changed, the version picker, 複製 / 存成檔案 / 刪除;
listed under 資料庫 in the right panel
- Page blocks for richer pages, in the chat's Markdown too: ```stats tiles, ```steps progress,
> [!NOTE]/[!TIP]/[!WARNING]/[!IMPORTANT] call-outs (with tables and Mermaid charts)
- Phone: the card in chats, page links, a page view with the same blocks; charts drawn by the
AnyBot as SVG (GET /api/pages/<id>[/chart/<n>]); members may read pages
- Bots are told to make a page for things to track and to update it from a routine only when
there is real news
- Screenshot views: ANYBOT_SHOT_VIEW=pagecard / page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 17:41:41 +00:00
/// The page open large (頁面): its id, and an older revision when looking at one.
page_view : Option < ( u64 , Option < u32 > ) > ,
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
/// Files and pictures waiting to go with the next message.
att : attach_ui ::AttachUi ,
/// 訊息管道 (Slack / Discord / Telegram).
ch : channels_ui ::ChannelUi ,
/// A shared bot file being picked.
share_rx : Option < Receiver < Result < String , String > > > ,
/// 群組聊天: members still to answer in this round, and groups whose next member should start.
group_queue : HashMap < u64 , std ::collections ::VecDeque < u64 > > ,
group_next : Vec < u64 > ,
/// 引用: the message being quoted in the next message, per bot.
quoting : HashMap < u64 , String > ,
/// Index of the message being drawn (for reactions).
msg_index : usize ,
/// 手機與帳號.
rm : remote_ui ::RemoteUi ,
/// Running as `anybot --serve` (no window; the phone is the screen).
headless : bool ,
/// 右側面板 and its tab (None = closed).
panel : Option < panel_ui ::PanelTab > ,
/// When each emoji reaction appeared (for its pop) and when each message first showed (fade-in).
popped : HashMap < ( u64 , usize , String ) , Instant > ,
appeared : HashMap < ( u64 , usize ) , Instant > ,
/// Messages whose steps / thinking are unfolded (⋯ › 顯示步驟).
steps_open : std ::collections ::HashSet < ( u64 , usize ) > ,
/// The file being previewed.
file_view : Option < files_ui ::FileView > ,
started_ms : i64 ,
/// The message whose hover menu (表情 / ⋯) is open: its buttons stay while it is.
menu_for : Option < ( u64 , usize ) > ,
/// Each face's motion (gaze, blink, pokes).
faces : HashMap < u64 , sidebar_ui ::Face > ,
search_open : bool ,
confirm_section_delete : Option < String > ,
/// A group being renamed: (old name, what is typed).
renaming : Option < ( String , String ) > ,
/// The group whose ⋯ menu is open (its button stays while it is).
group_menu_for : Option < String > ,
/// When the launch greeting started.
splash_at : Option < f64 > ,
2026-11-04 09:46:43 +00:00
/// 大腦: this window shows another AnyBot's bots (a screen), or answers screens (the brain).
mir : mirror_ui ::Mirror ,
scr : mirror_ui ::Screens ,
ctx_handle : Option < egui ::Context > ,
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
}
impl App {
pub fn new ( ctx : & egui ::Context , screenshot : Option < PathBuf > ) -> Self {
2026-11-04 09:46:43 +00:00
// 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 ( ) ;
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
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 ( ) ,
頁面: pages a bot makes and keeps up to date (like the original's tracking pages)
- write_page / read_page / list_pages: Markdown pages with versions (last 10 kept), per bot;
the reply gets a card (title, fading preview, page icon, 頁面 · 更新時間, 最近更新), and
[標題](page:ID) links open the page
- The page opens large: summary, what changed, the version picker, 複製 / 存成檔案 / 刪除;
listed under 資料庫 in the right panel
- Page blocks for richer pages, in the chat's Markdown too: ```stats tiles, ```steps progress,
> [!NOTE]/[!TIP]/[!WARNING]/[!IMPORTANT] call-outs (with tables and Mermaid charts)
- Phone: the card in chats, page links, a page view with the same blocks; charts drawn by the
AnyBot as SVG (GET /api/pages/<id>[/chart/<n>]); members may read pages
- Bots are told to make a page for things to track and to update it from a routine only when
there is real news
- Screenshot views: ANYBOT_SHOT_VIEW=pagecard / page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 17:41:41 +00:00
page_view : None ,
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
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 ,
2026-11-04 09:46:43 +00:00
mir : Default ::default ( ) ,
scr : Default ::default ( ) ,
ctx_handle : None ,
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
} ;
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 ,
頁面: pages a bot makes and keeps up to date (like the original's tracking pages)
- write_page / read_page / list_pages: Markdown pages with versions (last 10 kept), per bot;
the reply gets a card (title, fading preview, page icon, 頁面 · 更新時間, 最近更新), and
[標題](page:ID) links open the page
- The page opens large: summary, what changed, the version picker, 複製 / 存成檔案 / 刪除;
listed under 資料庫 in the right panel
- Page blocks for richer pages, in the chat's Markdown too: ```stats tiles, ```steps progress,
> [!NOTE]/[!TIP]/[!WARNING]/[!IMPORTANT] call-outs (with tables and Mermaid charts)
- Phone: the card in chats, page links, a page view with the same blocks; charts drawn by the
AnyBot as SVG (GET /api/pages/<id>[/chart/<n>]); members may read pages
- Bots are told to make a page for things to track and to update it from a routine only when
there is real news
- Screenshot views: ANYBOT_SHOT_VIEW=pagecard / page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 17:41:41 +00:00
// 頁面: 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 \n NOWnews 今天專訪交通部監理司專門委員。他說 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 ) ) ;
}
}
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
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 , " <html><body><h1>報價</h1><p>總計 NT$ 12,800</p></body></html> " ) ;
let png = dir . join ( " 行程地圖.png " ) ;
let mut pic = image ::RgbImage ::from_pixel ( 800 , 500 , image ::Rgb ( [ 0xe8 , 0xf0 , 0xfb ] ) ) ;
for x in 100 .. 700 { for y in 230 .. 270 { pic . put_pixel ( x , y , image ::Rgb ( [ 0x16 , 0x80 , 0xf3 ] ) ) ; } }
let _ = pic . save ( & png ) ;
let now = crate ::model ::now_ms ( ) ;
let mut atts = vec! [ ] ;
for f in [ & csv , & md , & html , & png ] {
if let Ok ( a ) = crate ::attach ::store ( 1 , f ) {
atts . push ( a ) ;
}
}
let mut u = Message ::user ( " 我有多了兩項訂單,請幫我更新,順便把行程整理成文件給我 " ) ;
u . at = now - 26 * 3_600_000 ;
u . reactions = vec! [ " ❤️ " . into ( ) ] ;
let mut a1 = Message ::assistant ( " 好,我去把新的兩張訂單抓下來,補進表格。 " , " " ) ;
a1 . at = u . at + 4_000 ;
let mut a2 = Message ::assistant ( " 更新好了,兩張 10/31 的新訂單已經補在最上面。 \n \n - 特友之家的車門垃圾桶( 2 個裝)買 1 組 \n - 特友黑洛第二代油門剎車踏板買 2 個 \n \n 行程和報價也整理好了: " , " " ) ;
a2 . at = u . at + 60_000 ;
a2 . attachments = atts ;
a2 . reactions = vec! [ " 👍 " . into ( ) ] ;
let mut u2 = Message ::user ( " 太好了,謝謝! " ) ;
u2 . at = now - 60_000 ;
let mut b = app . state . bots [ 0 ] . clone ( ) ;
b . messages = vec! [ u , a1 , a2 , u2 ] ;
app . state . bots [ 0 ] = b ;
app . state . selected = app . state . bots [ 0 ] . id ;
if std ::env ::var ( " ANYBOT_SHOT_VIEW " ) . as_deref ( ) = = Ok ( " fileview " ) {
// ANYBOT_SHOT_FILE: preview that file instead of the sample spreadsheet.
match std ::env ::var ( " ANYBOT_SHOT_FILE " ) {
Ok ( f ) = > {
let name = std ::path ::Path ::new ( & f ) . file_name ( ) . map ( | n | n . to_string_lossy ( ) . to_string ( ) ) . unwrap_or_default ( ) ;
app . open_file ( & name , & f ) ;
}
Err ( _ ) = > app . open_file ( " orders_20261102_packages.csv " , & csv . display ( ) . to_string ( ) ) ,
}
}
}
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 ### 步驟 \n 1. 填出差單 \n 2. 主管核准 \n - 金額超過 3 萬要*總經理*簽 \n - ~~紙本~~ 改用線上表單 \n 3. 訂機票和住宿 \n \n - [x] 護照效期確認 \n - [ ] 換外幣 \n \n > 提醒:發票抬頭要寫公司全名。 \n \n | 項目 | 上限 | 備註 | \n |---|--:|---| \n | 住宿 | 3,500 | 每晚 | \n | 交通 | 實報實銷 | 留收據 | \n | 餐費 | 800 | `每日` | \n \n ```python \n # 算總額 \n def total(days, rate=3500): \n return days * rate + 800 * days \n ``` \n \n ```mermaid \n flowchart 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 ) ;
2026-11-04 11:30:32 +00:00
// 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 ;
}
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
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 ) ;
2026-11-04 09:46:43 +00:00
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 ( ) ) ;
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
// 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 ) {
2026-11-04 09:46:43 +00:00
if self . mir . on {
self . mirror_act ( serde_json ::json! ( { " bot " : bot_id , " action " : " reply " } ) ) ;
return ;
}
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
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 < String > = 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 ) ) ;
2026-11-04 09:46:43 +00:00
let devices : Vec < String > = computers . iter ( ) . filter ( | t | matches! ( t . place , tools ::Place ::Device { .. } ) ) . map ( | t | t . name . clone ( ) ) . collect ( ) ;
tool_specs . extend ( web ::specs_for ( & devices ) ) ;
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
// 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 . push ( crate ::model ::THIS_COMPUTER_NAME . to_string ( ) ) ;
}
2026-11-04 23:56:19 +00:00
// …and the account's other computers (手腳): their own AnyBot looks and clicks there.
screens . extend ( devices . iter ( ) . cloned ( ) ) ;
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
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 ) ) ;
}
2026-11-04 13:52:06 +00:00
// 看圖小幫手: the person's models that may see pictures, for a bot whose model can't.
providers ::set_eyes ( self . eyes ( ) ) ;
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
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 < String > = conv . members . iter ( ) . filter_map ( | m | self . state . bot ( * m ) ) . map ( | x | x . name . clone ( ) ) . collect ( ) ;
sys . push_str ( & format! ( " \n \n 你在群組聊天「 {} 」裡,成員依序回答: {} 。其他成員的發言以【名字】標示。只回答你擅長的部分,補充不同的觀點,不要重複別人說過的;沒有要補充就簡短說一句。 " , conv . name , names . join ( " 、 " ) ) ) ;
}
sys
} ,
history : group_history ( & conv . messages , speaker , & self . state ) ,
tools : tool_specs ,
session : format ! ( " anybot-bot-{bot_id} " ) ,
} ;
let via = if speaker . is_some ( ) { format! ( " {} · {} · {} " , b . name , b . provider . label ( ) , b . model ) } else { format! ( " {} · {} " , b . provider . label ( ) , b . model ) } ;
if let Some ( c ) = self . state . bot_mut ( bot_id ) {
c . messages . push ( Message { author : speaker . unwrap_or ( 0 ) , .. Message ::assistant ( " " , & via ) } ) ;
}
let cancel = Arc ::new ( AtomicBool ::new ( false ) ) ;
let steer = Arc ::new ( Mutex ::new ( Vec ::new ( ) ) ) ;
let turn_id = format! ( " turn- {bot_id} - {} " , chrono ::Utc ::now ( ) . timestamp_millis ( ) ) ;
let direct = Arc ::new ( AtomicBool ::new ( self . direct_on ( ) ) ) ;
self . streams . insert ( bot_id , Live { cancel : cancel . clone ( ) , started : Instant ::now ( ) , last : Instant ::now ( ) , produced : false , status : None , paused : false , steer : steer . clone ( ) , turn_id , direct : direct . clone ( ) } ) ;
self . stick_bottom = true ;
let review = self . state . auto_review . then ( | | agent ::Review { allow : self . state . review_allow_rules . clone ( ) , ask : self . state . review_ask_rules . clone ( ) } ) ;
let job = agent ::Job {
req ,
computers ,
default_computer ,
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 < tools ::Target > {
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 ::< Vec < _ > > ( ) ) ;
v
}
/// Record how a turn ended (success / cancelled / error) and clear its live state.
fn settle ( & mut self , bot_id : u64 , outcome : TurnOutcome ) {
// 群組聊天: the next member answers after this one finishes well.
if self . group_queue . get ( & bot_id ) . map ( | q | ! q . is_empty ( ) ) . unwrap_or ( false ) {
if outcome = = TurnOutcome ::Success {
self . group_next . push ( bot_id ) ;
} else {
self . group_queue . remove ( & bot_id ) ;
}
}
let group_continues = self . group_next . contains ( & bot_id ) ;
if ! group_continues {
self . channels_settled ( bot_id , outcome ) ;
}
if outcome = = TurnOutcome ::Success & & ! self . demo {
let in_call = self . call_state ( ) . map ( | c | c . 0 = = bot_id ) . unwrap_or ( false ) ;
if let Some ( b ) = self . state . bot ( bot_id ) . filter ( | b | b . speak_replies & & ! in_call ) {
if let Some ( m ) = b . messages . iter ( ) . rev ( ) . find ( | m | m . role = = Role ::Assistant & & ! m . text . trim ( ) . is_empty ( ) & & ! m . error ) {
crate ::voice ::speak_as ( & m . text , & b . voice , b . voice_rate ) ;
}
}
}
if outcome = = TurnOutcome ::Success {
let now = self . faces . get ( & bot_id ) . map ( | f | f . now ( ) ) . unwrap_or ( 0.0 ) ;
self . poke ( bot_id , sidebar_ui ::Poke ::Nod , now ) ;
}
let away = self . away ( ) & & self . state . notify ;
if let Some ( l ) = self . streams . remove ( & bot_id ) {
if let Some ( b ) = self . state . bot_mut ( bot_id ) {
b . last_turn = Some ( TurnSettlement { turn_id : l . turn_id , outcome , settled_at_ms : chrono ::Utc ::now ( ) . timestamp_millis ( ) } ) ;
// 「Bot 完成任務…時通知你」: a banner when it finishes while you're away.
if outcome = = TurnOutcome ::Success & & away & & ! self . demo {
let last = b . messages . iter ( ) . rev ( ) . find ( | m | m . role = = Role ::Assistant & & ! m . text . is_empty ( ) ) . map ( | m | first_line ( & m . text , 80 ) ) . unwrap_or_default ( ) ;
if ! self . headless {
notify ::banner ( & format! ( " {} 完成了 " , b . name ) , & last ) ;
}
crate ::remote ::notify ( & format! ( " {} 完成了 " , b . name ) , & last , bot_id ) ;
}
}
// Messages that arrived too late to steer start the next turn.
let leftover : Vec < String > = std ::mem ::take ( & mut * l . steer . lock ( ) . unwrap ( ) ) ;
if ! leftover . is_empty ( ) {
self . pending_turns . push ( ( bot_id , leftover ) ) ;
}
}
self . approvals . remove ( & bot_id ) ;
self . app_pending . remove ( & bot_id ) ;
}
/// Wake a bot with a message: a new turn when idle, steering when it is already working.
fn wake ( & mut self , ctx : & egui ::Context , bot_id : u64 , msg : Message ) {
if let Some ( l ) = self . streams . get ( & bot_id ) {
l . steer . lock ( ) . unwrap ( ) . push ( msg . text ) ;
return ;
}
let selected = self . state . selected ;
if let Some ( b ) = self . state . bot_mut ( bot_id ) {
b . messages . push ( msg ) ;
if bot_id ! = selected {
b . unread = true ;
}
}
self . start_reply ( ctx , bot_id ) ;
}
/// Run due routines (a routine firing is a "wake" turn).
fn tick_routines ( & mut self , ctx : & egui ::Context ) {
if self . demo | | self . last_tick . elapsed ( ) < std ::time ::Duration ::from_secs ( 1 ) {
return ;
}
self . last_tick = Instant ::now ( ) ;
let now = crate ::routines ::now ( ) ;
let mut fire = vec! [ ] ;
for b in self . state . bots . iter_mut ( ) {
if self . streams . contains_key ( & b . id ) {
continue ; // fires once the bot is idle
}
for r in b . routines . iter_mut ( ) . filter ( | r | ! r . paused ) {
if r . next_run . map ( | t | t < = now ) . unwrap_or ( false ) {
r . last_run = Some ( now ) ;
r . next_run = crate ::routines ::next_run ( & r . schedule , r . created_at , now ) ;
fire . push ( ( b . id , appctl ::wake_message ( r ) ) ) ;
break ;
}
}
}
for ( id , msg ) in fire {
self . wake ( ctx , id , msg ) ;
self . dirty = true ;
}
for id in std ::mem ::take ( & mut self . group_next ) {
if ! self . streams . contains_key ( & id ) {
self . start_reply ( ctx , id ) ;
}
}
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 ) ) ;
}
}
self . start_reply ( ctx , id ) ;
}
}
fn run_app_action ( & mut self , ctx : & egui ::Context , bot_id : u64 , call : AppCall ) {
let out = appctl ::apply ( & mut self . state , bot_id , & call . name , & call . args ) ;
頁面: pages a bot makes and keeps up to date (like the original's tracking pages)
- write_page / read_page / list_pages: Markdown pages with versions (last 10 kept), per bot;
the reply gets a card (title, fading preview, page icon, 頁面 · 更新時間, 最近更新), and
[標題](page:ID) links open the page
- The page opens large: summary, what changed, the version picker, 複製 / 存成檔案 / 刪除;
listed under 資料庫 in the right panel
- Page blocks for richer pages, in the chat's Markdown too: ```stats tiles, ```steps progress,
> [!NOTE]/[!TIP]/[!WARNING]/[!IMPORTANT] call-outs (with tables and Mermaid charts)
- Phone: the card in chats, page links, a page view with the same blocks; charts drawn by the
AnyBot as SVG (GET /api/pages/<id>[/chart/<n>]); members may read pages
- Bots are told to make a page for things to track and to update it from a routine only when
there is real news
- Screenshot views: ANYBOT_SHOT_VIEW=pagecard / page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 17:41:41 +00:00
// 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 ( ) ;
}
}
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
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 ) {
2026-11-04 09:46:43 +00:00
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 ;
}
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
self . approvals . remove ( & bot_id ) ;
if let Some ( call ) = self . app_pending . remove ( & bot_id ) {
let _ = call . reply . send ( ( " 已停止 " . into ( ) , false ) ) ;
}
if let Some ( live ) = self . streams . get ( & bot_id ) {
live . cancel . store ( true , Ordering ::Relaxed ) ;
// Stopping keeps what was typed but not yet read: it becomes the next turn only if sent again.
live . steer . lock ( ) . unwrap ( ) . clear ( ) ;
self . settle ( bot_id , TurnOutcome ::Cancelled ) ;
if let Some ( b ) = self . state . bot_mut ( bot_id ) {
for c in b . messages . iter_mut ( ) . rev ( ) . take ( 6 ) . flat_map ( | m | m . tool_calls . iter_mut ( ) ) {
if matches! ( c . status , ToolStatus ::Waiting | ToolStatus ::Running ) {
c . status = ToolStatus ::Denied ;
}
}
if let Some ( m ) = b . messages . last_mut ( ) {
if m . role = = Role ::Assistant & & m . text . is_empty ( ) & & m . tool_calls . is_empty ( ) {
m . text = " (已停止) " . into ( ) ;
}
}
}
self . mark ( ) ;
}
}
/// "Restart" a stuck bot: stop its turn, approvals and background commands, and mark any
/// half-finished reply as interrupted so it can be retried. Chats and files stay.
fn restart_bot ( & mut self , bot_id : u64 ) {
self . stop ( bot_id ) ;
let stopped = tools ::stop_for ( & bot_id . to_string ( ) ) ;
if let Some ( b ) = self . state . bot_mut ( bot_id ) {
b . repair_interrupted ( ) ;
let name = b . name . clone ( ) ;
self . notice = Some ( if stopped > 0 { format! ( " 已重新啟動「 {name} 」,也停止了 {stopped} 個背景指令。 " ) } else { format! ( " 已重新啟動「 {name} 」。 " ) } ) ;
}
self . dirty = true ;
}
/// 直接操作: one switch instead of many. Turns on this Mac, every bot's tools and auto-review,
/// starts the "always ask" rules with a few safe defaults if there are none, and tells running
/// turns to stop asking.
/// The switches as they actually apply: an admin lock (policy) wins over the saved setting.
fn direct_on ( & self ) -> bool {
self . state . direct_mode & & self . managed . direct . allows ( ) & & self . managed . local . allows ( )
}
fn local_on ( & self ) -> bool {
( self . state . local_execution | | self . state . direct_mode & & self . managed . direct . allows ( ) ) & & self . managed . local . allows ( )
}
fn enable_direct_mode ( & mut self ) {
if ! self . managed . direct . allows ( ) | | ! self . managed . local . allows ( ) {
self . notice = Some ( " 「直接操作」已由管理員關閉,沒辦法打開。 " . into ( ) ) ;
return ;
}
let st = & mut self . state ;
st . direct_mode = true ;
st . local_execution = true ;
st . auto_review = true ;
for b in st . bots . iter_mut ( ) {
b . tools_enabled = true ;
}
if st . review_ask_rules . is_empty ( ) {
st . review_ask_rules = crate ::model ::DEFAULT_ASK_RULES . iter ( ) . map ( | s | s . to_string ( ) ) . collect ( ) ;
}
for l in self . streams . values ( ) {
l . direct . store ( true , Ordering ::Relaxed ) ;
}
// Other bots waiting on a "may I use the computer?" question can go on too.
let waiting : Vec < u64 > = self . approvals . iter ( ) . filter ( | ( _ , a ) | a . kind ! = ApprovalKind ::Action ) . map ( | ( id , _ ) | * id ) . collect ( ) ;
for id in waiting {
if let Some ( a ) = self . approvals . remove ( & id ) {
let _ = a . reply . send ( ApprovalAnswer ::Always ( a . grant . clone ( ) ) ) ;
}
}
self . notice = Some ( " 已打開「直接操作」: Bot 會直接做事,只有危險操作和你的「一律詢問」規則會停下來問你。 " . into ( ) ) ;
self . dirty = true ;
}
fn disable_direct_mode ( & mut self ) {
self . state . direct_mode = false ;
for l in self . streams . values ( ) {
l . direct . store ( false , Ordering ::Relaxed ) ;
}
self . dirty = true ;
}
fn answer ( & mut self , ctx : & egui ::Context , bot_id : u64 , answer : ApprovalAnswer ) {
2026-11-04 09:46:43 +00:00
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 ;
}
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
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 ) {
2026-11-04 09:46:43 +00:00
if self . mir . on {
self . mirror_act ( serde_json ::json! ( { " bot " : bot_id , " action " : " regenerate " } ) ) ;
return ;
}
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
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 ) = > {
let n = n . min ( b . messages . len ( ) ) ;
b . messages . splice ( .. n , [ 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 ( & 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 ) {
2026-11-04 11:30:32 +00:00
// 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 ;
}
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
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 ) ;
}
}
}
}
2026-11-04 13:52:06 +00:00
/// Models to describe pictures with, best first: each provider with a key, its own default
/// model, then models of it known to see pictures.
fn eyes ( & self ) -> Vec < providers ::Eyes > {
use ProviderKind as K ;
let mut v = vec! [ ] ;
for kind in [ K ::Xai , K ::Anthropic , K ::OpenAi , K ::Gemini , K ::OpenRouter , K ::OpenCodeGo , K ::Custom , K ::Ollama ] {
let ps = self . state . provider ( kind ) ;
let key = providers ::resolve_key ( kind , & ps . api_key ) ;
if kind . needs_key ( ) & & key . is_empty ( ) {
continue ;
}
if matches! ( kind , K ::Custom | K ::Ollama ) & & ps . base_url . trim ( ) . is_empty ( ) {
continue ;
}
let base = providers ::resolve_base ( kind , & ps . base_url ) ;
let mut models : Vec < String > = vec! [ ] ;
if ! ps . default_model . trim ( ) . is_empty ( ) {
models . push ( ps . default_model . trim ( ) . to_string ( ) ) ;
}
models . extend ( providers ::seeing_models ( kind ) . iter ( ) . map ( | m | m . to_string ( ) ) ) ;
models . dedup ( ) ;
for m in models {
v . push ( providers ::Eyes { kind , base_url : base . clone ( ) , api_key : key . clone ( ) , model : m } ) ;
}
}
v
}
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
fn models_for ( & self , kind : ProviderKind ) -> Vec < String > {
let ps = self . state . provider ( kind ) ;
let mut v : Vec < String > = if ps . known_models . is_empty ( ) {
kind . starter_models ( ) . iter ( ) . map ( | s | s . to_string ( ) ) . collect ( )
} else {
ps . known_models . clone ( )
} ;
if ! ps . default_model . is_empty ( ) & & ! v . contains ( & ps . default_model ) {
v . insert ( 0 , ps . default_model . clone ( ) ) ;
}
v
}
fn import_bot_dialog ( & mut self ) {
let ( tx , rx ) = channel ( ) ;
self . share_rx = Some ( rx ) ;
std ::thread ::spawn ( move | | {
if let Some ( p ) = crate ::share ::choose_open ( ) {
let _ = tx . send ( std ::fs ::read_to_string ( & p ) . map_err ( | e | e . to_string ( ) ) ) ;
}
} ) ;
}
fn poll_import ( & mut self ) {
let Some ( rx ) = & self . share_rx else { return } ;
let Ok ( r ) = rx . try_recv ( ) else { return } ;
self . share_rx = None ;
let id = self . state . next_id ;
let mut next = self . state . next_routine_id ;
match r . and_then ( | t | crate ::share ::import ( & t , id , & mut next , crate ::routines ::now ( ) ) ) {
Ok ( b ) = > {
let paused = b . routines . iter ( ) . filter ( | r | r . trigger . is_some ( ) ) . count ( ) ;
self . state . next_id + = 1 ;
self . state . next_routine_id = next ;
self . notice = Some ( format! ( " 已匯入「 {} 」。 {} " , b . name , if paused > 0 { format! ( " 有 {paused} 個事件觸發的例行任務先暫停了:請跟 Bot 說要用的 Token, 再請它繼續。 " ) } else { String ::new ( ) } ) ) ;
self . state . bots . push ( b ) ;
self . state . selected = id ;
self . mark ( ) ;
}
Err ( e ) = > self . notice = Some ( format! ( " 匯入失敗: {e} " ) ) ,
}
}
fn new_bot ( & mut self ) {
let id = self . state . next_id ;
let shape = avatar ::SHAPES [ ( id as usize ) % avatar ::SHAPES . len ( ) ] ;
let color = avatar ::COLORS [ ( id as usize ) % avatar ::COLORS . len ( ) ] . 0 ;
let base = self . state . bot ( self . state . selected ) . map ( | b | ( b . provider , b . model . clone ( ) ) ) ;
2026-11-05 06:25:01 +00:00
// 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 ) ) ) ;
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
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 < u64 > = vec! [ self . state . primary_bot ] ;
order . extend ( self . pinned_ids ( ) ) ;
order . extend ( self . state . bots . iter ( ) . filter ( | b | ! b . pinned & & b . id ! = self . state . primary_bot ) . map ( | b | b . id ) ) ;
egui ::ScrollArea ::vertical ( ) . auto_shrink ( [ false , false ] ) . max_height ( ui . available_height ( ) - 40.0 ) . show ( ui , | ui | {
for id in order {
let Some ( b ) = self . state . bot ( id ) . cloned ( ) else { continue } ;
let ( rect , resp ) = ui . allocate_exact_size ( vec2 ( 56.0 , 48.0 ) , egui ::Sense ::click ( ) ) ;
if self . state . selected = = id | | resp . hovered ( ) {
ui . painter ( ) . rect_filled ( rect , 10 , if self . state . selected = = id { p . active } else { p . hover } ) ;
}
avatar ::paint ( ui . painter ( ) , egui ::Rect ::from_center_size ( rect . center ( ) , vec2 ( 32.0 , 32.0 ) ) , & b . shape , self . bot_color ( & b ) ) ;
if b . unread | | self . streams . contains_key ( & id ) {
ui . painter ( ) . circle_filled ( rect . right_top ( ) + vec2 ( - 8.0 , 8.0 ) , 4.0 , if b . unread { p . accent } else { p . text3 } ) ;
}
let resp = resp . on_hover_text ( & b . name ) ;
if resp . clicked ( ) {
self . state . selected = id ;
if let Some ( b ) = self . state . bot_mut ( id ) {
b . unread = false ;
}
self . mark ( ) ;
}
resp . context_menu ( | ui | self . bot_menu_items ( ui , id ) ) ;
}
} ) ;
ui . with_layout ( Layout ::bottom_up ( Align ::Center ) , | ui | {
if icon_button ( ui , " ⚙ " , " 設定 " , p ) . clicked ( ) {
self . show_settings = true ;
}
} ) ;
} ) ;
}
/// A bot's menu (right-click, and ⋯ at the top), grouped like the original's.
fn bot_menu_items ( & mut self , ui : & mut egui ::Ui , id : u64 ) {
use crate ::icons ::Icon ;
let p = self . pal ;
let Some ( b ) = self . state . bot ( id ) . cloned ( ) else { return } ;
ui . set_min_width ( menu_ui ::MENU_W ) ;
if id ! = self . state . primary_bot & & menu_ui ::item ( ui , p , Icon ::Pin , if b . pinned { " 取消釘選 " } else { " 釘選 " } ) . clicked ( ) {
self . set_pinned ( id , ! b . pinned , None ) ;
ui . close ( ) ;
}
self . folder_menu ( ui , id ) ;
if menu_ui ::item ( ui , p , Icon ::Bell , if b . unread { " 標示為已讀 " } else { " 標示為未讀 " } ) . clicked ( ) {
if let Some ( b ) = self . state . bot_mut ( id ) {
b . unread = ! b . unread ;
}
self . mark ( ) ;
ui . close ( ) ;
}
if id ! = self . state . primary_bot & & menu_ui ::item ( ui , p , Icon ::Star , " 設為主要 Bot " ) . clicked ( ) {
self . state . primary_bot = id ;
self . mark ( ) ;
ui . close ( ) ;
}
menu_ui ::sep ( ui , p ) ;
if menu_ui ::item ( ui , p , Icon ::Pencil , " 編輯 Bot… " ) . clicked ( ) {
self . edit_bot = Some ( ( b . clone ( ) , false ) ) ;
ui . close ( ) ;
}
// 討論串: put this conversation aside and start fresh; earlier ones can come back.
if menu_ui ::item ( ui , p , Icon ::NewChat , " 開始新對話 " ) . clicked ( ) {
self . stop ( id ) ;
2026-11-04 09:46:43 +00:00
if self . mir . on {
self . mirror_act ( serde_json ::json! ( { " bot " : id , " action " : " new " } ) ) ;
} else if let Some ( b ) = self . state . bot_mut ( id ) {
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
if ! b . messages . is_empty ( ) {
let title = b . messages . iter ( ) . find ( | m | m . role = = Role ::User ) . map ( | m | first_line ( & m . text , 24 ) ) . unwrap_or_else ( | | " 對話 " . into ( ) ) ;
let msgs = std ::mem ::take ( & mut b . messages ) ;
b . threads . insert ( 0 , ( title , msgs ) ) ;
b . threads . truncate ( 50 ) ;
}
}
self . mark ( ) ;
ui . close ( ) ;
}
let threads : Vec < String > = b . threads . iter ( ) . map ( | t | t . 0. clone ( ) ) . collect ( ) ;
if ! threads . is_empty ( ) {
menu_ui ::sub ( ui , p , Icon ::History , " 過去的對話 " , | ui | {
for ( i , title ) in threads . iter ( ) . enumerate ( ) {
if menu_ui ::item_text ( ui , p , None , title ) . clicked ( ) {
self . stop ( id ) ;
2026-11-04 09:46:43 +00:00
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 ) {
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
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 ;
2026-11-05 06:25:01 +00:00
if self . state . bots . is_empty ( ) {
self . mirror_banner ( ui ) ;
self . welcome ( ui ) ;
return ;
}
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
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 ) ;
2026-11-04 09:46:43 +00:00
self . mirror_banner ( ui ) ;
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
// 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 < ApprovalAnswer > {
let p = self . pal ;
let a = self . approvals . get ( & bot . id ) ? ;
let ( tool , reason , grant , kind ) = ( a . tool . clone ( ) , a . reason . clone ( ) , a . grant . clone ( ) , a . kind ) ;
// "允許 Grok Bot 在 {targetMachineDisplay} 上使用 {app} 嗎?" (eQViQE): name the machine.
let ( reason , machine ) = match reason . rsplit_once ( " \n (在 " ) {
Some ( ( r , m ) ) = > ( r . to_string ( ) , m . trim_end_matches ( " 上) " ) . to_string ( ) ) ,
None = > ( reason . clone ( ) , String ::new ( ) ) ,
} ;
let what = if appctl ::is_app_tool ( & tool ) { appctl ::label ( & tool ) } else if crate ::macapps ::is_mac_tool ( & tool ) { crate ::macapps ::label ( & tool ) } else if crate ::computer ::is_computer_tool ( & tool ) { crate ::computer ::label ( & tool ) } else if web ::is_web_tool ( & tool ) { web ::label ( & tool ) } else { tools ::label ( & tool ) } ;
let title = match kind {
ApprovalKind ::EnableTools = > format! ( " 允許 {} 使用電腦工具嗎? " , bot . name ) ,
// Hd1KB+: 允許…在這台電腦上執行任務,而不只是在它自己的電腦上
ApprovalKind ::EnableThisComputer = > format! ( " 允許 {} 在你的電腦上執行任務嗎? " , bot . name ) ,
ApprovalKind ::Action if machine . is_empty ( ) = > format! ( " 允許 {} {} 嗎? " , bot . name , what ) ,
ApprovalKind ::Action = > format! ( " 允許 {} 在 {machine} 上 {what} 嗎? " , bot . name ) ,
} ;
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 < u64 > = 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 < String > = bot . members . iter ( ) . filter_map ( | m | self . state . bot ( * m ) ) . map ( | b | b . name . clone ( ) ) . collect ( ) ;
ui . label ( RichText ::new ( format! ( " {} {} " , t ( " 成員: " ) , names . join ( " 、 " ) ) ) . size ( FONT_XS ) . color ( p . text2 ) ) ;
}
} ) ;
let right_w = 28.0 * 2.0 + 8.0 + 4.0 ;
let text_w = ( ui . available_width ( ) - right_w ) . max ( 60.0 ) ;
2026-11-04 09:46:43 +00:00
let hint = if self . mir . on & & ! self . mir . online { t ( " 連不到大腦,現在只能看 " ) } else { t ( " 傳訊息給 {name} " ) . replace ( " {name} " , & bot . name ) } ;
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
ui . allocate_ui_with_layout ( vec2 ( text_w , 28.0 ) , Layout ::top_down ( Align ::Min ) , | ui | {
egui ::ScrollArea ::vertical ( ) . id_salt ( ( " composer_scroll " , bot . id ) ) . max_height ( 160.0 ) . show ( ui , | ui | {
ui . add_space ( 4.0 ) ;
ui . add ( egui ::TextEdit ::multiline ( & mut draft ) . id ( composer_id ) . hint_text ( RichText ::new ( hint ) . color ( p . text3 ) ) . frame ( Frame ::NONE ) . margin ( Margin ::ZERO ) . desired_rows ( 1 ) . desired_width ( text_w ) . lock_focus ( true ) ) ;
} ) ;
} ) ;
ui . with_layout ( Layout ::right_to_left ( Align ::Max ) , | ui | {
ui . spacing_mut ( ) . item_spacing . x = 8.0 ;
let has_text = ! draft . trim ( ) . is_empty ( ) | | self . has_pending_attachments ( bot . id ) ;
let call = self . call_state ( ) ;
let in_call = call . map ( | c | c . 0 = = bot . id ) . unwrap_or ( false ) ;
let colour = self . bot_color ( bot ) ;
let colour = if colour . r ( ) as u32 + colour . g ( ) as u32 + colour . b ( ) as u32 < = 0x30 * 3 { p . primary } else { colour } ;
let ink = if colour = = p . primary { p . on_primary } else { Color32 ::WHITE } ;
if has_text {
let tip = if streaming { " 補充給正在進行的工作( Enter) " } else { " 傳送訊息 " } ;
if round_tool ( ui , crate ::icons ::Icon ::ArrowUp , colour , ink ) . on_hover_text ( t ( tip ) ) . clicked ( ) {
send_now = true ;
}
} else if streaming {
if round_tool ( ui , crate ::icons ::Icon ::Stop , colour , ink ) . on_hover_text ( t ( " 停止 " ) ) . clicked ( ) {
self . stop ( bot . id ) ;
}
} else {
let fill = if in_call { Color32 ::from_rgb ( 0xfc , 0x2c , 0x3e ) } else { colour } ;
let tip = match call {
Some ( ( id , phase ) ) if id = = bot . id = > format! ( " {} · {} " , t ( phase . label ( ) ) , t ( " 結束通話 " ) ) ,
_ = > t ( " 發起語音聊天 " ) ,
} ;
if round_tool ( ui , if in_call { crate ::icons ::Icon ::Stop } else { crate ::icons ::Icon ::Wave } , fill , if in_call { Color32 ::WHITE } else { ink } ) . on_hover_text ( tip ) . clicked ( ) {
if in_call {
self . call_end ( ) ;
} else if call . is_none ( ) {
self . call_start ( bot . id ) ;
}
}
}
// 語音輸入: click to record, click again to turn it into text.
if let Some ( started ) = self . voice_recording ( bot . id ) {
let secs = started . elapsed ( ) . as_secs ( ) ;
let tt = ui . input ( | i | i . time ) as f32 ;
let red = Color32 ::from_rgb ( 0xfc , 0x2c , 0x3e ) ;
let fill = red . gamma_multiply ( 0.75 + 0.25 * ( tt * 3.0 ) . sin ( ) . abs ( ) ) ;
if crate ::icons ::pill ( ui , crate ::icons ::Icon ::Record , & format! ( " {:02} : {:02} " , secs / 60 , secs % 60 ) , fill , Color32 ::WHITE , 28.0 ) . on_hover_text ( crate ::i18n ::t ( " 停止錄音並轉成文字 " ) ) . clicked ( ) {
self . voice_toggle ( ctx , bot . id ) ;
}
ctx . request_repaint_after ( std ::time ::Duration ::from_millis ( 100 ) ) ;
} else if self . voice_busy ( ) {
chat_ui ::spinner ( ui , 18.0 , p . text3 ) ;
} else if round_tool ( ui , crate ::icons ::Icon ::Mic , p . hover , p . text2 ) . on_hover_text ( t ( " 開始語音輸入 " ) ) . clicked ( ) {
self . voice_toggle ( ctx , bot . id ) ;
}
if let Some ( ( id , phase ) ) = call {
if id = = bot . id {
ui . label ( RichText ::new ( t ( phase . label ( ) ) ) . size ( FONT_XS ) . color ( p . text3 ) ) ;
}
}
} ) ;
} ) ;
} ) ;
ui . add_space ( 4.0 ) ;
let queued : Vec < String > = self . streams . get ( & bot . id ) . map ( | l | l . steer . lock ( ) . unwrap ( ) . clone ( ) ) . unwrap_or_default ( ) ;
let notice = self . notice . clone ( ) ;
ui . vertical_centered ( | ui | {
if let Some ( n ) = notice . filter ( | _ | queued . is_empty ( ) ) {
ui . horizontal ( | ui | {
ui . label ( RichText ::new ( n ) . size ( 11.0 ) . color ( p . accent ) ) ;
if small_button ( ui , " 知道了 " , p ) . clicked ( ) {
self . notice = None ;
}
} ) ;
} else if queued . is_empty ( ) {
// Like the original: nothing under the input.
} else {
let first : String = queued . last ( ) . unwrap ( ) . chars ( ) . take ( 30 ) . collect ( ) ;
let more = if queued . len ( ) > 1 { format! ( " 等 {} 則 " , queued . len ( ) ) } else { String ::new ( ) } ;
ui . label ( RichText ::new ( format! ( " 已排入「 {first} 」 {more} , Bot 會在下一步看到" ) ) . size ( 11.0 ) . color ( p . accent ) ) ;
}
} ) ;
ui . add_space ( 10.0 ) ;
self . drafts . insert ( bot . id , draft ) ;
if send_now {
self . send ( ctx , bot . id ) ;
ui . memory_mut ( | m | m . request_focus ( composer_id ) ) ;
}
}
/// Settings → 電腦: AnyBot browser profiles (each with its own logins), switch / open / add.
fn browser_profiles_section ( & mut self , ui : & mut egui ::Ui , p : Palette ) {
setting_title ( ui , " AnyBot 瀏覽器設定檔 " , " 每個設定檔是一個獨立的瀏覽器視窗, 有自己的登入( 例如工作和私人帳號) 。Bot 會用「使用中」的那個,你也可以在聊天裡叫它換。 " , p ) ;
if ! crate ::botbrowser ::available ( ) {
ui . label ( RichText ::new ( t ( " 需要 Chrome、Brave、Edge 或 Chromium 才能用 AnyBot 自己的瀏覽器。 " ) ) . size ( 11.5 ) . color ( p . text3 ) ) ;
return ;
}
let current = crate ::botbrowser ::current ( ) ;
for name in crate ::botbrowser ::choices ( ) {
ui . horizontal ( | ui | {
let mut on = name = = current ;
if ui . radio_value ( & mut on , true , RichText ::new ( & name ) . strong ( ) ) . clicked ( ) & & name ! = current {
let _ = crate ::botbrowser ::set_current ( & name ) ;
}
if name = = current {
ui . label ( RichText ::new ( t ( " 使用中 " ) ) . size ( 11.5 ) . color ( p . accent ) ) ;
}
if name = = crate ::botbrowser ::PERSON_PROFILE {
ui . label ( RichText ::new ( t ( " 你平常的 Chrome, 切到哪個設定檔就讀哪個 " ) ) . size ( 11.5 ) . color ( p . text2 ) ) ;
if crate ::axread ::trusted ( false ) {
crate ::icons ::label ( ui , crate ::icons ::Icon ::Check , & t ( " 輔助使用已打開 " ) , 11.5 , p . text3 ) ;
} else if ui . small_button ( t ( " 打開輔助使用 " ) ) . on_hover_text ( t ( " macOS 要你允許一次, AnyBot 才看得到你的瀏覽器視窗 " ) ) . clicked ( ) {
if ! crate ::axread ::trusted ( true ) {
let _ = std ::process ::Command ::new ( " open " ) . arg ( " x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility " ) . spawn ( ) ;
}
}
return ;
}
if ui . small_button ( t ( " 開啟視窗 " ) ) . on_hover_text ( t ( " 打開這個設定檔的瀏覽器視窗,在裡面登入網站 " ) ) . clicked ( ) {
let note = self . browser_note . clone ( ) ;
let name = name . clone ( ) ;
* note . lock ( ) . unwrap ( ) = format! ( " 正在打開「 {name} 」… " ) ;
std ::thread ::spawn ( move | | {
let r = crate ::botbrowser ::show ( & name ) ;
* note . lock ( ) . unwrap ( ) = match r {
Ok ( ( ) ) = > format! ( " 已打開「 {name} 」的視窗。在裡面登入一次,之後會記住。 " ) ,
Err ( e ) = > e ,
} ;
} ) ;
}
if name ! = crate ::botbrowser ::DEFAULT_PROFILE {
if self . confirm_remove_profile . as_deref ( ) = = Some ( name . as_str ( ) ) {
if ui . small_button ( RichText ::new ( t ( " 確定移除(登入也會刪掉) " ) ) . color ( p . danger ) ) . clicked ( ) {
* self . browser_note . lock ( ) . unwrap ( ) = match crate ::botbrowser ::remove ( & name ) {
Ok ( ( ) ) = > format! ( " 已移除「 {name} 」。 " ) ,
Err ( e ) = > e ,
} ;
self . confirm_remove_profile = None ;
}
if ui . small_button ( crate ::i18n ::t ( " 取消 " ) ) . clicked ( ) {
self . confirm_remove_profile = None ;
}
} else if ui . small_button ( t ( " 移除 " ) ) . clicked ( ) {
self . confirm_remove_profile = Some ( name . clone ( ) ) ;
}
}
} ) ;
}
ui . horizontal ( | ui | {
ui . add ( egui ::TextEdit ::singleline ( & mut self . new_browser_profile ) . hint_text ( t ( " 新設定檔名稱,例如 工作 " ) ) . desired_width ( 180.0 ) ) ;
let ok = crate ::botbrowser ::check_name ( & self . new_browser_profile ) . is_ok ( ) ;
if ui . add_enabled ( ok , egui ::Button ::new ( " 新增設定檔 " ) ) . clicked ( ) {
let r = crate ::botbrowser ::add ( & self . new_browser_profile ) . and_then ( | n | crate ::botbrowser ::set_current ( & n ) ) ;
* self . browser_note . lock ( ) . unwrap ( ) = match r {
Ok ( n ) = > format! ( " 已新增「 {n} 」並設為使用中。按「開啟視窗」登入你要的網站。 " ) ,
Err ( e ) = > e ,
} ;
self . new_browser_profile . clear ( ) ;
}
} ) ;
let note = self . browser_note . lock ( ) . unwrap ( ) . clone ( ) ;
if ! note . is_empty ( ) {
ui . label ( RichText ::new ( note ) . size ( 11.5 ) . color ( p . text2 ) ) ;
}
ui . label ( RichText ::new ( t ( " 選「你的瀏覽器」: Bot 像 Grok Bot 一樣從畫面讀你平常的 Chrome( macOS 會請你打開一次「輔助使用」),你在 Chrome 切到哪個設定檔,它就看哪個;讀之前會先問你。其他的是 AnyBot 自己的視窗,各自登入一次就好。 " ) ) . size ( 11.0 ) . color ( p . text3 ) ) ;
}
/// The bot's routines: what repeats, when it runs next, pause / run now / delete.
fn routines_window ( & mut self , ctx : & egui ::Context ) {
let Some ( bot_id ) = self . show_routines else { return } ;
let Some ( bot ) = self . state . bot ( bot_id ) . cloned ( ) else {
self . show_routines = None ;
return ;
} ;
let p = self . pal ;
let mut action : Option < ( & str , u64 ) > = None ;
let body_h = dialog_max_height ( ctx ) - 48.0 ;
let dismissed = dialog ( ctx , " routines " , & format! ( " {} {} " , bot . name , t ( " 的例行任務 " ) ) , 500.0 , true , p , | ui | {
dialog_body ( ui , " routines-body " , body_h , | ui | self . routines_body ( ui , & bot , & mut action ) ) ;
} ) ;
let open = ! dismissed ;
self . routine_action ( ctx , bot_id , action ) ;
if ! open {
self . show_routines = None ;
}
}
/// The routines of one bot with 暫停/立即執行/刪除 (the window and the side panel share it).
fn routines_body ( & self , ui : & mut egui ::Ui , bot : & Bot , action : & mut Option < ( & 'static str , u64 ) > ) {
let p = self . pal ;
if bot . routines . is_empty ( ) {
ui . label ( RichText ::new ( t ( " 例行任務是這個 Bot 依排程重複執行的任務。請在聊天中請它設定一個。 " ) ) . color ( p . text2 ) ) ;
ui . add_space ( 4.0 ) ;
hint ( ui , " 例如:「每個工作日早上 9 點整理新聞給我」 " , p ) ;
return ;
}
for r in & bot . routines {
setting_card ( ui , p , | ui | {
ui . horizontal ( | ui | {
ui . label ( RichText ::new ( & r . title ) . strong ( ) . size ( FONT_LG ) ) ;
if r . paused {
ui . label ( RichText ::new ( t ( " 已暫停 " ) ) . size ( 11.5 ) . color ( p . text3 ) ) ;
}
} ) ;
let next = match ( r . paused , r . next_run ) {
( false , Some ( t ) ) = > format! ( " · 下次 {} " , crate ::routines ::describe_when ( t , & r . schedule . timezone ) ) ,
_ = > String ::new ( ) ,
} ;
match & r . trigger {
Some ( t ) = > {
crate ::icons ::label ( ui , crate ::icons ::Icon ::Zap , & t . describe ( ) , 12.5 , p . text2 ) ;
if let Some ( st ) = crate ::triggers ::statuses ( ) . lock ( ) . unwrap ( ) . get ( & r . id ) . cloned ( ) {
ui . label ( RichText ::new ( st ) . size ( 11.5 ) . color ( p . text3 ) ) ;
}
}
None = > {
ui . label ( RichText ::new ( format! ( " {} {next} " , crate ::routines ::describe ( & r . schedule ) ) ) . size ( 12.5 ) . color ( p . text2 ) ) ;
}
}
hint ( ui , r . prompt . clone ( ) , p ) ;
ui . add_space ( 4.0 ) ;
ui . horizontal ( | ui | {
if ui . add ( secondary_button ( if r . paused { " 繼續 " } else { " 暫停 " } ) ) . clicked ( ) {
* action = Some ( ( " toggle " , r . id ) ) ;
}
if ui . add ( secondary_button ( " 立即執行 " ) ) . clicked ( ) {
* action = Some ( ( " run " , r . id ) ) ;
}
ui . with_layout ( Layout ::right_to_left ( Align ::Center ) , | ui | {
if ui . add ( egui ::Button ::new ( RichText ::new ( t ( " 刪除 " ) ) . color ( p . danger ) ) . frame ( false ) ) . clicked ( ) {
* action = Some ( ( " delete " , r . id ) ) ;
}
} ) ;
} ) ;
} ) ;
}
hint ( ui , " 要新增或修改,直接在聊天裡告訴 Bot。 " , p ) ;
}
fn routine_action ( & mut self , ctx : & egui ::Context , bot_id : u64 , action : Option < ( & str , u64 ) > ) {
if let Some ( ( what , id ) ) = action {
let now = crate ::routines ::now ( ) ;
let mut wake = None ;
if let Some ( b ) = self . state . bot_mut ( bot_id ) {
match what {
" toggle " = > {
if let Some ( r ) = b . routines . iter_mut ( ) . find ( | r | r . id = = id ) {
r . paused = ! r . paused ;
r . next_run = crate ::routines ::next_run ( & r . schedule , r . created_at , now ) ;
}
}
" run " = > {
if let Some ( r ) = b . routines . iter_mut ( ) . find ( | r | r . id = = id ) {
r . last_run = Some ( now ) ;
wake = Some ( appctl ::wake_message ( r ) ) ;
}
}
_ = > self . confirm_delete_routine = Some ( ( bot_id , id ) ) ,
}
}
if let Some ( m ) = wake {
self . state . selected = bot_id ;
self . wake ( ctx , bot_id , m ) ;
}
self . dirty = true ;
}
}
fn model_picker ( & mut self , ui : & mut egui ::Ui , bot : & Bot ) {
let p = self . pal ;
let label = format! ( " {} · {} " , short_provider ( bot . provider ) , bot . model ) ;
let mut choice : Option < ( ProviderKind , String ) > = None ;
let mut open_editor = false ;
let models : Vec < ( ProviderKind , bool , Vec < String > ) > = ALL_PROVIDERS
. iter ( )
. map ( | & kind | {
let ps = self . state . provider ( kind ) ;
( kind , ! kind . needs_key ( ) | | ! providers ::resolve_key ( kind , & ps . api_key ) . is_empty ( ) , self . models_for ( kind ) )
} )
. collect ( ) ;
let row = menu_ui ::item_text ( ui , p , Some ( crate ::icons ::Icon ::Sparkle ) , & label ) . on_hover_text ( t ( " 選擇這個 Bot 用來思考的模型 " ) ) ;
egui ::containers ::menu ::SubMenu ::new ( ) . show ( ui , & row , | ui | {
ui . set_min_width ( menu_ui ::MENU_W ) ;
for ( kind , ready , list ) in & models {
let title = if * ready { kind . label ( ) . to_string ( ) } else { format! ( " {} ( {} ) " , kind . label ( ) , t ( " 未設定金鑰 " ) ) } ;
let icon = if * kind = = bot . provider { crate ::icons ::Icon ::Check } else { crate ::icons ::Icon ::Sparkle } ;
let r = menu_ui ::item_text ( ui , p , Some ( icon ) , & title ) ;
egui ::containers ::menu ::SubMenu ::new ( ) . show ( ui , & r , | ui | {
ui . set_min_width ( 220.0 ) ;
for m in list {
let sel = * kind = = bot . provider & & * m = = bot . model ;
if menu_ui ::item_text ( ui , p , Some ( if sel { crate ::icons ::Icon ::Check } else { crate ::icons ::Icon ::Sparkle } ) , m ) . clicked ( ) {
choice = Some ( ( * kind , m . clone ( ) ) ) ;
ui . close ( ) ;
}
}
menu_ui ::sep ( ui , p ) ;
if menu_ui ::item ( ui , p , crate ::icons ::Icon ::Pencil , " 其他模型… " ) . clicked ( ) {
choice = Some ( ( * kind , self . default_model ( * kind ) ) ) ;
open_editor = true ;
ui . close ( ) ;
}
} ) ;
}
} ) ;
if let Some ( ( k , m ) ) = choice {
if let Some ( b ) = self . state . bot_mut ( bot . id ) {
b . provider = k ;
b . model = m ;
if open_editor {
self . edit_bot = Some ( ( b . clone ( ) , false ) ) ;
}
}
self . mark ( ) ;
}
}
fn default_model ( & self , kind : ProviderKind ) -> String {
let ps = self . state . provider ( kind ) ;
if ps . default_model . is_empty ( ) { kind . suggested_model ( ) . to_string ( ) } else { ps . default_model }
}
// ---------- dialogs ----------
fn bot_editor ( & mut self , ctx : & egui ::Context ) {
let Some ( ( mut b , is_new ) ) = self . edit_bot . take ( ) else { return } ;
let p = self . pal ;
let mut save = false ;
let mut cancel = false ;
// Title bar ~48 px and footer ~56 px; the form scrolls in between.
let body_h = dialog_max_height ( ctx ) - 104.0 ;
let title = if is_new { " 建立 Bot " } else { " Bot 設定 " } ;
let dismissed = dialog ( ctx , " bot-editor " , title , EDITOR_WIDTH , false , p , | ui | {
dialog_body ( ui , " bot-editor-body " , body_h , | ui | {
ui . horizontal_top ( | ui | {
ui . spacing_mut ( ) . item_spacing . x = 16.0 ;
avatar ::avatar ( ui , 64.0 , & b . shape , avatar ::color ( & b . color ) ) ;
ui . vertical ( | ui | {
let w = ui . available_width ( ) ;
ui . label ( RichText ::new ( t ( " Bot 名稱 " ) ) . size ( FONT_XS ) . color ( p . text2 ) ) ;
ui . add ( egui ::TextEdit ::singleline ( & mut b . name ) . desired_width ( w ) ) ;
field_label ( ui , " Bot 描述 " , p ) ;
ui . add ( egui ::TextEdit ::singleline ( & mut b . description ) . hint_text ( t ( " 它負責做什麼? " ) ) . desired_width ( w ) ) ;
} ) ;
} ) ;
ui . add_space ( 16.0 ) ;
setting_card ( ui , p , | ui | {
setting_title ( ui , " 頭像 " , " " , p ) ;
let inactive_grey = if p . pane = = LIGHT . pane { Color32 ::from_gray ( 185 ) } else { Color32 ::from_gray ( 90 ) } ;
ui . horizontal_wrapped ( | ui | {
ui . spacing_mut ( ) . item_spacing . x = 6.0 ;
for s in avatar ::SHAPES {
let r = avatar ::avatar ( ui , 28.0 , s , if b . shape = = s { avatar ::color ( & b . color ) } else { inactive_grey } ) . on_hover_text ( avatar ::shape_label ( s ) ) ;
if r . clicked ( ) {
b . shape = s . into ( ) ;
}
}
} ) ;
ui . add_space ( 4.0 ) ;
ui . horizontal_wrapped ( | ui | {
ui . spacing_mut ( ) . item_spacing . x = 6.0 ;
for ( key , name , _ ) in avatar ::COLORS {
let ( rect , r ) = ui . allocate_exact_size ( vec2 ( 28.0 , 28.0 ) , egui ::Sense ::click ( ) ) ;
ui . painter ( ) . circle_filled ( rect . center ( ) , 10.0 , avatar ::color ( key ) ) ;
if b . color = = key {
ui . painter ( ) . circle_stroke ( rect . center ( ) , 13.0 , Stroke ::new ( 2.0 , p . focus ) ) ;
}
if r . on_hover_text ( name ) . clicked ( ) {
b . color = key . into ( ) ;
}
}
} ) ;
} ) ;
setting_card ( ui , p , | ui | {
setting_title ( ui , " AI 供應商與模型 " , " " , p ) ;
ui . horizontal ( | ui | {
let before = b . provider ;
egui ::ComboBox ::from_id_salt ( " bot_provider " ) . selected_text ( b . provider . label ( ) ) . width ( 170.0 ) . show_ui ( ui , | ui | {
for k in ALL_PROVIDERS {
ui . selectable_value ( & mut b . provider , k , k . label ( ) ) ;
}
} ) ;
if b . provider ! = before {
b . model = self . default_model ( b . provider ) ;
}
let models = self . models_for ( b . provider ) ;
let w = ui . available_width ( ) - 80.0 ;
ui . add ( egui ::TextEdit ::singleline ( & mut b . model ) . hint_text ( t ( " 模型名稱 " ) ) . desired_width ( w ) ) ;
egui ::ComboBox ::from_id_salt ( " bot_model " ) . selected_text ( " 選擇 " ) . width ( 60.0 ) . show_ui ( ui , | ui | {
for m in models {
let sel = m = = b . model ;
if ui . selectable_label ( sel , & m ) . clicked ( ) {
b . model = m ;
}
}
} ) ;
} ) ;
let ps = self . state . provider ( b . provider ) ;
if b . provider . needs_key ( ) & & providers ::resolve_key ( b . provider , & ps . api_key ) . is_empty ( ) {
ui . horizontal ( | ui | {
ui . label ( RichText ::new ( format! ( " 還沒有設定 {} 的 API 金鑰 " , b . provider . label ( ) ) ) . size ( 12.0 ) . color ( p . danger ) ) ;
if ui . add ( egui ::Button ::new ( RichText ::new ( t ( " 前往設定 " ) ) . size ( 12.0 ) . color ( p . accent ) ) . frame ( false ) ) . clicked ( ) {
self . show_settings = true ;
self . settings_tab = SettingsTab ::Providers ;
self . settings_provider = b . provider ;
}
} ) ;
}
} ) ;
setting_card ( ui , p , | ui | {
setting_title ( ui , " 電腦操作 " , " " , p ) ;
ui . checkbox ( & mut b . tools_enabled , t ( " 讓這個 Bot 查看、修改檔案和執行指令 " ) ) ;
if b . tools_enabled {
ui . add_space ( 6.0 ) ;
form_row ( ui , " 預設電腦 " , p , | ui | {
let 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_000 u64 , 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 < ProviderKind > = None ;
let mut theme_changed = false ;
let mut relaunch = false ;
let max_h = dialog_max_height ( ctx ) . min ( SETTINGS_HEIGHT ) ;
// The title bar is about 48 px; the rest is the page list and the page.
let page_h = max_h - 48.0 ;
let close = dialog ( ctx , " settings " , " 設定 " , SETTINGS_WIDTH , true , p , | ui | {
ui . horizontal_top ( | ui | {
ui . spacing_mut ( ) . item_spacing . x = 0.0 ;
Frame ::new ( ) . fill ( p . sidebar ) . corner_radius ( CornerRadius { sw : 16 , .. Default ::default ( ) } ) . inner_margin ( Margin ::symmetric ( 10 , 12 ) ) . show ( ui , | ui | {
// The page list narrows with a small window so the page keeps its room.
let nav_w = if ctx . content_rect ( ) . width ( ) < SETTINGS_WIDTH + 40.0 { 118.0 } else { SETTINGS_NAV_WIDTH } ;
ui . set_width ( nav_w - 20.0 ) ;
ui . set_min_height ( page_h - 24.0 ) ;
ui . vertical ( | ui | {
ui . spacing_mut ( ) . item_spacing . y = 2.0 ;
use crate ::icons ::Icon ;
for ( tab , name , icon ) in [ ( SettingsTab ::General , " 一般 " , Icon ::Gear ) , ( SettingsTab ::Providers , " 模型 " , Icon ::Sparkle ) , ( SettingsTab ::Channels , " 整合 " , Icon ::Chat ) , ( SettingsTab ::Computers , " 電腦 " , Icon ::Monitor ) , ( SettingsTab ::Review , " 權限 " , Icon ::Shield ) , ( SettingsTab ::Vault , " 保險庫 " , Icon ::Vault ) , ( SettingsTab ::Updates , " 更新 " , Icon ::CloudDownload ) ] {
let ( rect , r ) = ui . allocate_exact_size ( vec2 ( ui . available_width ( ) , 32.0 ) , egui ::Sense ::click ( ) ) ;
let on = self . settings_tab = = tab | | ( tab = = SettingsTab ::General & & self . settings_tab = = SettingsTab ::Remote ) ;
if on | | r . hovered ( ) {
ui . painter ( ) . rect_filled ( rect , 8 , if on { p . active } else { p . hover } ) ;
}
let col = if on { p . text } else { p . text2 } ;
crate ::icons ::paint ( ui . painter ( ) , egui ::Rect ::from_center_size ( rect . left_center ( ) + vec2 ( 18.0 , 0.0 ) , vec2 ( 16.0 , 16.0 ) ) , icon , col ) ;
ui . painter ( ) . text ( rect . left_center ( ) + vec2 ( 34.0 , 0.0 ) , egui ::Align2 ::LEFT_CENTER , t ( name ) , egui ::FontId ::proportional ( FONT_BASE ) , col ) ;
if r . on_hover_cursor ( egui ::CursorIcon ::PointingHand ) . clicked ( ) {
self . settings_tab = tab ;
}
}
} ) ;
} ) ;
let r = ui . min_rect ( ) ;
ui . painter ( ) . vline ( r . right ( ) , r . y_range ( ) , Stroke ::new ( 1.0 , p . border ) ) ;
ui . vertical ( | ui | {
ui . spacing_mut ( ) . item_spacing = vec2 ( 8.0 , 6.0 ) ;
dialog_body ( ui , ( " settings-page " , self . settings_tab as u8 ) , page_h , | ui | match self . settings_tab {
SettingsTab ::General = > {
section ( ui , " 帳戶 " , p ) ;
let acct = if self . state . remote . host_token . is_empty ( ) { t ( " 尚未登入帳號 · 手機、帳號、團隊、備份 " ) } else { format! ( " {} · {} " , self . state . remote . relay_user , self . state . remote . relay_url ) } ;
setting_row ( ui , p , " 帳戶 " , & acct , | ui | {
if ui . add ( secondary_button ( " 管理 " ) ) . clicked ( ) {
self . settings_tab = SettingsTab ::Remote ;
}
} ) ;
section ( ui , " 外觀 " , p ) ;
setting_row ( ui , p , " 主題 " , " 為 AnyBot 選擇淺色或深色外觀 " , | ui | {
for ( th , n ) in [ ( Theme ::System , " 跟隨系統 " ) , ( Theme ::Dark , " 深色 " ) , ( Theme ::Light , " 淺色 " ) ] {
if ui . selectable_value ( & mut self . state . theme , th , t ( n ) ) . clicked ( ) {
theme_changed = true ;
}
}
} ) ;
setting_row ( ui , p , " 強調色 " , " 用 Bot 的顏色為 1:1 Bot 訊息和卡片上色,或保持黑色 " , | ui | {
for ( a , n ) in [ ( Accent ::Black , " 黑色 " ) , ( Accent ::BotColor , " Bot 顏色 " ) ] {
if ui . selectable_value ( & mut self . state . accent , a , t ( n ) ) . clicked ( ) {
self . dirty = true ;
}
}
} ) ;
setting_row ( ui , p , " 字型 " , " AnyBot 畫面使用的字型 " , | ui | {
let cur = crate ::font_choice ( ) ;
for ( v , n ) in [ ( " system " , " 系統字型 " ) , ( " huninn " , " 粉圓體 " ) ] {
if ui . selectable_label ( cur = = v , t ( n ) ) . clicked ( ) & & cur ! = v {
if let Some ( d ) = State ::path ( ) . parent ( ) {
let _ = std ::fs ::create_dir_all ( d ) ;
let _ = std ::fs ::write ( d . join ( " font.txt " ) , v ) ;
}
crate ::load_fonts ( ctx ) ;
}
}
} ) ;
setting_row ( ui , p , " 語言 " , " 選擇 AnyBot 選單和 Bot 回答使用的語言 " , | ui | {
let cur = self . state . language . clone ( ) ;
let shown = if cur = = " auto " { format! ( " {} ( {} ) " , t ( " 自動偵測 " ) , crate ::i18n ::endonym ( & crate ::i18n ::system ( ) ) ) } else { crate ::i18n ::endonym ( & cur ) . to_string ( ) } ;
egui ::ComboBox ::from_id_salt ( " language " ) . selected_text ( shown ) . height ( 360.0 ) . show_ui ( ui , | ui | {
let mut pick = | ui : & mut egui ::Ui , v : & str , label : String | {
if ui . selectable_label ( cur = = v , label ) . clicked ( ) {
self . state . language = v . to_string ( ) ;
crate ::i18n ::set ( v ) ;
self . dirty = true ;
}
} ;
pick ( ui , " auto " , t ( " 自動偵測 " ) ) ;
let mut rows : Vec < _ > = crate ::i18n ::LANGS . to_vec ( ) ;
rows . sort_by ( | a , b | a . 1. cmp ( b . 1 ) ) ;
for ( tag , endo , eng ) in rows {
pick ( ui , tag , if endo = = eng { endo . to_string ( ) } else { format! ( " {endo} · {eng} " ) } ) ;
}
} ) ;
} ) ;
if ! matches! ( crate ::i18n ::lang ( ) . as_str ( ) , " zh-TW " | " zh-CN " | " en " | " ja " ) {
hint ( ui , " 選單會用英文顯示; Bot 會用你選的語言回答。 " , p ) ;
}
section ( ui , " 通知 " , p ) ;
setting_row ( ui , p , " 通知 " , " Bot 完成任務或等你處理時通知你 " , | ui | {
if ui . add ( egui ::Checkbox ::without_text ( & mut self . state . notify ) ) . changed ( ) {
self . dirty = true ;
}
} ) ;
hint ( ui , " 視窗不在最前面,或 90 秒沒有動鍵盤滑鼠,就會通知。登入帳號後也會推播到手機。 " , p ) ;
section ( ui , " Bot " , p ) ;
setting_row ( ui , p , " 時區 " , " 設定 Bot 用於排程和提醒的時區 " , | ui | {
if ui . add ( egui ::TextEdit ::singleline ( & mut self . state . timezone ) . desired_width ( 150.0 ) ) . changed ( ) {
self . dirty = true ;
}
if ui . add ( secondary_button ( " 用這台電腦的 " ) ) . clicked ( ) {
self . state . timezone = crate ::routines ::local_timezone ( ) ;
self . dirty = true ;
}
} ) ;
if self . state . timezone . parse ::< chrono_tz ::Tz > ( ) . is_err ( ) {
ui . label ( RichText ::new ( t ( " 看不懂這個時區,請用像 Asia/Taipei 這樣的名稱 " ) ) . size ( 11.5 ) . color ( p . danger ) ) ;
}
section ( ui , " 系統 " , p ) ;
setting_row ( ui , p , " 資料 " , " 聊天記錄和金鑰只存在這台電腦上 " , | ui | {
if ui . add ( secondary_button ( if cfg! ( target_os = " macos " ) { " 在 Finder 中顯示 " } else { " 打開所在資料夾 " } ) ) . clicked ( ) {
crate ::preview ::reveal ( & State ::path ( ) ) ;
}
} ) ;
}
SettingsTab ::Updates = > {
section ( ui , " AnyBot 更新 " , p ) ;
setting_row ( ui , p , " 版本 " , & version_label ( self . exe_built ) , | ui | {
if let Some ( tm ) = self . newer_build {
ui . label ( RichText ::new ( format! ( " {} {} " , t ( " 有更新的版本: " ) , version_label ( Some ( tm ) ) ) ) . size ( 12.0 ) . color ( p . accent ) ) ;
} else {
ui . label ( RichText ::new ( t ( " 已是最新版本 " ) ) . size ( 12.0 ) . color ( p . text3 ) ) ;
}
} ) ;
setting_row ( ui , p , " 自動更新 " , " 在你離開時自動換成新版本,你的 Bot 和工作都會保留 " , | ui | {
if ui . add ( egui ::Checkbox ::without_text ( & mut self . state . auto_update_when_away ) ) . changed ( ) {
self . dirty = true ;
}
} ) ;
hint ( ui , " 螢幕鎖定或螢幕保護程式開著、10 分鐘沒動作、而且沒有 Bot 在工作時,才會自己換成新版本。 " , p ) ;
setting_row ( ui , p , " 重新啟動 " , " App 不太對勁時用:進行中的工作會停下,聊天記錄都會保留 " , | ui | {
let label = if self . relaunching { " 正在重新啟動… " } else { " 重新啟動 AnyBot " } ;
if ui . add_enabled ( ! self . relaunching , secondary_button ( label ) ) . clicked ( ) {
relaunch = true ;
}
} ) ;
}
SettingsTab ::Vault = > {
self . vault_page ( ui ) ;
}
SettingsTab ::Computers = > {
self . computers_page ( ui ) ;
}
SettingsTab ::Remote = > {
if ui . add ( egui ::Button ::new ( RichText ::new ( format! ( " ‹ {} " , t ( " 一般 " ) ) ) . size ( FONT_SM ) . color ( p . text2 ) ) . frame ( false ) ) . clicked ( ) {
self . settings_tab = SettingsTab ::General ;
}
self . remote_page ( ui , ctx ) ;
}
SettingsTab ::Channels = > {
section ( ui , " 訊息管道 " , p ) ;
self . channels_page ( ui , ctx ) ;
section ( ui , " 連接器 " , p ) ;
self . connectors_page ( ui , ctx ) ;
}
SettingsTab ::Review = > {
Frame ::new ( ) . fill ( p . accent . gamma_multiply ( 0.07 ) ) . stroke ( Stroke ::new ( 1.0 , p . accent . gamma_multiply ( 0.45 ) ) ) . corner_radius ( 12 ) . inner_margin ( Margin ::symmetric ( 16 , 14 ) ) . show ( ui , | ui | {
ui . set_width ( ui . available_width ( ) ) ;
crate ::icons ::label ( ui , crate ::icons ::Icon ::Zap , & t ( " 直接操作(像 Grok Bot 一樣) " ) , FONT_BASE , p . text ) ;
hint ( ui , " 一個開關:讓 Bot 直接使用這台電腦和你的瀏覽器,不再一個個問你 " , p ) ;
ui . add_space ( 4.0 ) ;
let allowed = self . managed . direct . allows ( ) & & self . managed . local . allows ( ) ;
let mut on = self . direct_on ( ) ;
if ui . add_enabled ( allowed , egui ::Checkbox ::new ( & mut on , t ( " 打開直接操作 " ) ) ) . changed ( ) {
if on {
self . enable_direct_mode ( ) ;
} else {
self . disable_direct_mode ( ) ;
}
}
if ! allowed {
crate ::icons ::label ( ui , crate ::icons ::Icon ::Lock , & t ( " 由你的管理員設定,無法變更 " ) , 12.0 , p . danger ) ;
}
hint ( ui , " 仍會擋下: 危險指令( sudo、格式化…) 、密碼和金鑰檔、AnyBot 自己的資料, 以及你在「自動審核」寫的「一律詢問」規則( 預設: 刪除檔案、付款、用你的帳號發文或傳訊息) 。macOS 第一次讓 AnyBot 控制瀏覽器時還是會自己問一次,按「好」就行。 " , p ) ;
} ) ;
ui . add_space ( 12.0 ) ;
setting_card ( ui , p , | ui | {
setting_title ( ui , " 自動審核 " , " 依照自訂規則審核 Bot 的操作 " , p ) ;
if ui . checkbox ( & mut self . state . auto_review , t ( " 開啟自動審核 " ) ) . changed ( ) {
self . dirty = true ;
}
hint ( ui , " 為每種操作寫一條簡短的自然語言規則。規則衝突時,「先詢問」優先。這些規則只對你生效。內建的安全檢查一律有效。 " , p ) ;
if ! self . state . auto_review {
hint ( ui , " 自動審核關閉時不會使用規則 " , p ) ;
}
} ) ;
for ( title , desc , which ) in [ ( " 「無需詢問直接允許」規則 " , " 操作符合這些規則時,自動繼續 " , 0 ) , ( " 「一律詢問」規則 " , " 操作符合這些規則時,停下來詢問 " , 1 ) ] {
setting_card ( ui , p , | ui | {
setting_title ( ui , title , desc , p ) ;
let rules = if which = = 0 { & mut self . state . review_allow_rules } else { & mut self . state . review_ask_rules } ;
let mut remove = None ;
for ( i , r ) in rules . iter ( ) . enumerate ( ) {
Frame ::new ( ) . fill ( p . elevated ) . corner_radius ( 8 ) . inner_margin ( Margin ::symmetric ( 10 , 4 ) ) . show ( ui , | ui | {
ui . set_width ( ui . available_width ( ) ) ;
ui . horizontal ( | ui | {
ui . label ( r ) ;
ui . with_layout ( Layout ::right_to_left ( Align ::Center ) , | ui | {
if ui . add ( egui ::Button ::new ( RichText ::new ( t ( " 刪除 " ) ) . size ( 12.0 ) . color ( p . text3 ) ) . frame ( false ) ) . clicked ( ) {
remove = Some ( i ) ;
}
} ) ;
} ) ;
} ) ;
}
if let Some ( i ) = remove {
rules . remove ( i ) ;
self . dirty = true ;
}
let draft = if which = = 0 { & mut self . new_allow_rule } else { & mut self . new_ask_rule } ;
ui . add_space ( 2.0 ) ;
ui . horizontal ( | ui | {
let w = ui . available_width ( ) - 96.0 ;
ui . add ( egui ::TextEdit ::singleline ( draft ) . hint_text ( if which = = 0 { " 例如:在工作資料夾裡建立文字檔 " } else { " 例如:刪除任何檔案 " } ) . desired_width ( w ) ) ;
if ui . add_enabled ( ! draft . trim ( ) . is_empty ( ) , secondary_button ( " 新增規則 " ) ) . clicked ( ) {
rules . push ( draft . trim ( ) . to_string ( ) ) ;
draft . clear ( ) ;
self . dirty = true ;
}
} ) ;
} ) ;
}
}
SettingsTab ::Providers = > {
ui . horizontal_top ( | ui | {
ui . vertical ( | ui | {
ui . set_width ( PROVIDER_LIST_WIDTH ) ;
ui . spacing_mut ( ) . item_spacing . y = 2.0 ;
for k in ALL_PROVIDERS {
let ps = self . state . provider ( k ) ;
let ready = ! k . needs_key ( ) | | ! providers ::resolve_key ( k , & ps . api_key ) . is_empty ( ) ;
let ( rect , r ) = ui . allocate_exact_size ( vec2 ( PROVIDER_LIST_WIDTH , 32.0 ) , egui ::Sense ::click ( ) ) ;
let on = self . settings_provider = = k ;
if on | | r . hovered ( ) {
ui . painter ( ) . rect_filled ( rect , 8 , if on { p . active } else { p . hover } ) ;
}
let dot = rect . left_center ( ) + vec2 ( 14.0 , 0.0 ) ;
if ready {
ui . painter ( ) . circle_filled ( dot , 4.0 , Color32 ::from_rgb ( 0x2e , 0xa0 , 0x43 ) ) ;
} else {
ui . painter ( ) . circle_stroke ( dot , 3.5 , Stroke ::new ( 1.2 , p . text3 ) ) ;
}
ui . painter ( ) . text ( rect . left_center ( ) + vec2 ( 28.0 , 0.0 ) , egui ::Align2 ::LEFT_CENTER , k . label ( ) , egui ::FontId ::proportional ( FONT_BASE ) , if on { p . text } else { p . text2 } ) ;
let tip = if ready { " 已可使用 " } else { " 還沒設定金鑰 " } ;
if r . on_hover_text ( tip ) . clicked ( ) {
self . settings_provider = k ;
}
}
} ) ;
ui . add_space ( 16.0 ) ;
ui . vertical ( | ui | {
let k = self . settings_provider ;
let w = ui . available_width ( ) ;
ui . label ( RichText ::new ( k . label ( ) ) . strong ( ) . size ( FONT_XL ) ) ;
hint ( ui , k . key_help ( ) , p ) ;
ui . add_space ( 6.0 ) ;
let ps = self . state . provider_mut ( k ) ;
let mut changed = false ;
if k . needs_key ( ) | | k = = ProviderKind ::Custom {
field_label ( ui , " API 金鑰 " , p ) ;
changed | = ui . add ( egui ::TextEdit ::singleline ( & mut ps . api_key ) . password ( true ) . hint_text ( k . env_var ( ) . map ( | v | format! ( " 貼上金鑰,或設定環境變數 {v} " ) ) . unwrap_or_else ( | | " (選填) " . into ( ) ) ) . desired_width ( w ) ) . changed ( ) ;
}
field_label ( ui , " 服務網址 " , p ) ;
changed | = ui . add ( egui ::TextEdit ::singleline ( & mut ps . base_url ) . hint_text ( k . default_base_url ( ) ) . desired_width ( w ) ) . changed ( ) ;
field_label ( ui , " 新 Bot 預設模型 " , p ) ;
changed | = ui . add ( egui ::TextEdit ::singleline ( & mut ps . default_model ) . hint_text ( k . suggested_model ( ) ) . desired_width ( w ) ) . changed ( ) ;
let count = ps . known_models . len ( ) ;
if changed {
self . dirty = true ;
}
ui . add_space ( 12.0 ) ;
ui . horizontal ( | ui | {
let busy = self . model_fetch . contains_key ( & k ) ;
if ui . add_enabled ( ! busy , secondary_button ( " 測試連線並取得模型清單 " ) ) . clicked ( ) {
fetch = Some ( k ) ;
}
if busy {
ui . spinner ( ) ;
}
} ) ;
if let Some ( s ) = self . fetch_status . get ( & k ) {
hint ( ui , s . clone ( ) , p ) ;
} else if count > 0 {
hint ( ui , format! ( " 已記住 {count} 個模型 " ) , p ) ;
}
} ) ;
} ) ;
}
} ) ;
} ) ;
} ) ;
} ) ;
let open = ! close ;
if let Some ( k ) = fetch {
self . fetch_models ( k ) ;
}
if theme_changed {
self . apply_theme ( ctx ) ;
self . dirty = true ;
}
self . show_settings = open ;
if relaunch {
self . relaunch ( ctx ) ;
}
}
/// Every 10 s, see whether a newer build replaced the program file (then the banner shows).
fn check_for_newer_build ( & mut self ) {
if self . demo {
return ;
}
// Noticing a new build is a cheap file check, so the banner keeps up quickly (estimated 10 s).
if self . last_build_check . elapsed ( ) . as_secs ( ) > = 10 {
self . last_build_check = Instant ::now ( ) ;
self . newer_build = match ( exe_modified ( ) , self . exe_built ) {
( Some ( now ) , Some ( started ) ) if now > started = > Some ( now ) ,
_ = > None ,
} ;
}
// Switching to it by itself follows the original's schedule and away rule.
if Instant ::now ( ) > = self . next_update_apply {
let jitter = ( chrono ::Utc ::now ( ) . timestamp_subsec_nanos ( ) as f64 ) / 1e9 ;
self . next_update_apply = Instant ::now ( ) + std ::time ::Duration ::from_secs ( notify ::next_update_check_secs ( jitter ) ) ;
let bots_idle = self . streams . is_empty ( ) & & self . approvals . is_empty ( ) ;
let ready = self . newer_build . is_some ( ) ;
if ready
& & notify ::should_apply_update_now (
self . state . auto_update_when_away ,
ready ,
notify ::screen_locked ( ) ,
notify ::screensaver_active ( ) ,
notify ::system_idle_secs ( ) ,
bots_idle ,
)
{
self . relaunch_requested = true ;
}
}
}
/// Save, start a fresh copy of the app, and close this one.
fn relaunch ( & mut self , ctx : & egui ::Context ) {
let ids : Vec < u64 > = self . streams . keys ( ) . copied ( ) . collect ( ) ;
for id in ids {
self . stop ( id ) ;
}
tools ::stop_all ( ) ;
crate ::cloudbox ::shutdown ( ) ;
crate ::mcp ::shutdown ( ) ;
if ! self . demo {
self . state . save ( ) ;
}
match std ::env ::current_exe ( ) . and_then ( | exe | relaunch_command ( std ::process ::id ( ) , & exe ) . spawn ( ) ) {
Ok ( _ ) = > {
self . relaunching = true ;
ctx . send_viewport_cmd ( egui ::ViewportCommand ::Close ) ;
}
Err ( e ) = > self . notice = Some ( format! ( " 沒辦法重新啟動: {e} 。請手動關掉再打開 AnyBot。 " ) ) ,
}
}
fn delete_dialog ( & mut self , ctx : & egui ::Context ) {
let Some ( id ) = self . confirm_delete else { return } ;
let name = self . state . bot ( id ) . map ( | b | b . name . clone ( ) ) . unwrap_or_default ( ) ;
let mut close = false ;
let p = self . pal ;
let dismissed = dialog ( ctx , " delete-bot " , " 刪除 Bot? " , 380.0 , true , p , | ui | {
dialog_body ( ui , " delete-bot-body " , 200.0 , | ui | {
ui . label ( RichText ::new ( format! ( " 這會刪除「 {name} 」和它的聊天記錄。此操作無法復原。 " ) ) . color ( p . text2 ) ) ;
} ) ;
dialog_footer ( ui , p , | ui | { ui . with_layout ( Layout ::right_to_left ( Align ::Center ) , | ui | {
if ui . add ( danger_button ( " 刪除 " , p ) ) . clicked ( ) {
self . stop ( id ) ;
self . state . bots . retain ( | b | b . id ! = id ) ;
2026-11-05 06:25:01 +00:00
// The last one gone: back to the getting-started guide.
self . state . fix_primary ( ) ;
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
self . dirty = true ;
close = true ;
}
if ui . add ( secondary_button ( " 取消 " ) ) . clicked ( ) {
close = true ;
}
} ) ; } ) ;
} ) ;
if close | | dismissed {
self . confirm_delete = None ;
}
}
fn handle_screenshot ( & mut self , ctx : & egui ::Context ) {
let Some ( path ) = self . screenshot . clone ( ) else { return } ;
self . frames + = 1 ;
let need = std ::env ::var ( " ANYBOT_SHOT_FRAMES " ) . ok ( ) . and_then ( | v | v . parse ( ) . ok ( ) ) . unwrap_or ( SCREENSHOT_FRAMES ) ;
// ANYBOT_SHOT_RESIZE=WxH: halfway through, resize the open window (the same path as the
// person dragging its edge), so the picture shows the layout after a live resize.
if self . frames = = need / 2 {
if let Some ( ( w , h ) ) = std ::env ::var ( " ANYBOT_SHOT_RESIZE " ) . ok ( ) . and_then ( | v | {
let ( w , h ) = v . split_once ( 'x' ) ? ;
Some ( ( w . parse ::< f32 > ( ) . ok ( ) ? , h . parse ::< f32 > ( ) . ok ( ) ? ) )
} ) {
ctx . send_viewport_cmd ( egui ::ViewportCommand ::InnerSize ( vec2 ( w , h ) ) ) ;
}
}
2026-11-05 00:33:23 +00:00
// 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 ) ) ;
}
}
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
if self . frames > = need & & ! self . shot_requested {
self . shot_requested = true ;
let slot = self . shot . clone ( ) ;
let repaint = ctx . clone ( ) ;
ctx . send_viewport_cmd ( egui ::ViewportCommand ::Screenshot ( egui ::ScreenshotCallback ::new ( move | img | {
* slot . lock ( ) . unwrap ( ) = Some ( img ) ;
repaint . request_repaint ( ) ;
} ) ) ) ;
}
let shot = self . shot . lock ( ) . unwrap ( ) . take ( ) ;
if let Some ( img ) = shot {
let [ w , h ] = img . size ;
let bytes : Vec < u8 > = img . pixels . iter ( ) . flat_map ( | c | c . to_array ( ) ) . collect ( ) ;
if let Some ( dir ) = path . parent ( ) {
let _ = std ::fs ::create_dir_all ( dir ) ;
}
match image ::RgbaImage ::from_raw ( w as u32 , h as u32 , bytes ) {
Some ( buf ) = > {
if let Err ( e ) = buf . save ( & path ) {
eprintln! ( " could not save screenshot: {e} " ) ;
}
}
None = > eprintln! ( " screenshot had an unexpected size " ) ,
}
ctx . send_viewport_cmd ( egui ::ViewportCommand ::Close ) ;
}
ctx . request_repaint ( ) ;
}
}
impl App {
/// Everything that keeps bots working, without drawing (the window runs it every frame;
/// `anybot --serve` runs it on its own).
fn background ( & mut self , ctx : & egui ::Context ) {
2026-11-04 09:46:43 +00:00
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 ) ;
2026-11-04 11:30:32 +00:00
self . poll_fetches ( ) ;
2026-11-04 09:46:43 +00:00
return ;
}
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
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 ) {
2026-11-04 09:46:43 +00:00
if self . mir . on {
self . mirror_save ( ) ;
} else {
self . state . save ( ) ;
}
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
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 } ;
2026-11-04 12:38:45 +00:00
// 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 {
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
Ok ( s ) = > {
self . state . remote = s ;
self . dirty = true ;
eprintln! ( " AnyBot: 已加入帳號 {user} ( {url} ) " ) ;
}
Err ( e ) = > {
eprintln! ( " AnyBot: 登入帳號失敗( 30 秒後再試): {e} " ) ;
return ;
}
}
let marker = State ::path ( ) . with_file_name ( " restored-from-account " ) ;
if std ::env ::var ( " ANYBOT_RESTORE_FROM_ACCOUNT " ) . map ( | v | v = = " 1 " ) . unwrap_or ( false ) & & ! marker . exists ( ) {
match crate ::remote ::restore ( url , user , & pw ) . and_then ( | b | serde_json ::from_slice ::< State > ( & b ) . map_err ( | e | e . to_string ( ) ) ) {
Ok ( mut st ) = > {
st . remote = self . state . remote . clone ( ) ;
eprintln! ( " AnyBot: 已從帳號備份接手 {} 個 Bot " , st . bots . len ( ) ) ;
self . state = st ;
self . dirty = true ;
let _ = std ::fs ::write ( & marker , chrono ::Utc ::now ( ) . to_rfc3339 ( ) ) ;
}
Err ( e ) = > eprintln! ( " AnyBot: 沒有接手備份: {e} " ) ,
}
}
}
/// `anybot --serve`: no window. The bots, routines, triggers, channels and the phone page keep
/// running (e.g. on an always-on Mac mini or a server); the person uses the phone.
/// For testing a bot's tool use: `anybot --ask <message>` sends one message to the primary bot
/// with no window, refuses every approval (and says so), and prints the turn's steps. Run it
/// with ANYBOT_DATA_DIR pointing at a copy of the data folder. It doesn't connect to the
/// account, channels or routines.
pub fn ask_cli ( ctx : & egui ::Context , text : & str ) -> ! {
let mut app = App ::new ( ctx , None ) ;
app . headless = true ;
let id = app . state . primary_bot ;
let start = app . state . bot ( id ) . map ( | b | b . messages . len ( ) ) . unwrap_or ( 0 ) ;
app . drafts . insert ( id , text . to_string ( ) ) ;
app . send ( ctx , id ) ;
let t0 = Instant ::now ( ) ;
let limit = std ::env ::var ( " ANYBOT_ASK_SECS " ) . ok ( ) . and_then ( | v | v . parse ( ) . ok ( ) ) . unwrap_or ( 900 u64 ) ;
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 ( ) ;
2026-11-04 09:46:43 +00:00
ctx . run_ui ( Default ::default ( ) , | _ | { } ) . textures_delta . clear ( ) ;
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
std ::thread ::sleep ( std ::time ::Duration ::from_millis ( 100 ) ) ;
}
let secs = t0 . elapsed ( ) . as_secs ( ) ;
let msgs = app . state . bot ( id ) . map ( | b | b . messages [ start .. ] . to_vec ( ) ) . unwrap_or_default ( ) ;
let clip = | s : & str , n : usize | -> String {
let one = s . replace ( '\n' , " ⏎ " ) ;
if one . chars ( ) . count ( ) > n { format! ( " {} … " , one . chars ( ) . take ( n ) . collect ::< String > ( ) ) } else { one }
} ;
let full = std ::env ::var ( " ANYBOT_ASK_FULL " ) . is_ok ( ) ;
let mut steps = 0 ;
for m in & msgs {
match m . role {
Role ::User = > println! ( " 👤 {} " , m . text ) ,
Role ::Assistant = > {
if ! m . text . trim ( ) . is_empty ( ) {
println! ( " 🤖 {} " , if full | | m . tool_calls . is_empty ( ) { m . text . clone ( ) } else { clip ( & m . text , 300 ) } ) ;
}
for c in & m . tool_calls {
steps + = 1 ;
println! ( " 🔧 {} {} " , c . name , clip ( & c . args , 200 ) ) ;
}
}
Role ::Tool = > println! ( " ↳ {} {} " , if m . images . is_empty ( ) { " " } else { " [附截圖] " } , clip ( & m . text , if full { 4000 } else { 400 } ) ) ,
}
}
println! ( " —— {steps} 個工具步驟, {secs} 秒 " ) ;
app . state . save ( ) ;
std ::process ::exit ( 0 )
}
pub fn serve ( ctx : & egui ::Context ) -> ! {
let mut app = App ::new ( ctx , None ) ;
app . headless = true ;
if let Ok ( pw ) = std ::env ::var ( " ANYBOT_PHONE_PASSWORD " ) {
if pw . chars ( ) . count ( ) > = 8 & & ! crate ::httpd ::check_password ( & pw , & app . state . remote . password ) {
app . state . remote . password = crate ::httpd ::hash_password ( & pw ) ;
app . state . remote . devices . clear ( ) ;
}
app . state . remote . lan = true ;
app . dirty = true ;
}
if let Ok ( name ) = std ::env ::var ( " ANYBOT_HOST_NAME " ) {
if ! name . trim ( ) . is_empty ( ) & & app . state . remote . host_name ! = name {
app . state . remote . host_name = name ;
app . dirty = true ;
}
}
2026-11-04 09:46:43 +00:00
// 手腳: 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 ;
}
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
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 ( ) ;
2026-11-04 09:46:43 +00:00
let mut last_brain = Instant ::now ( ) - std ::time ::Duration ::from_secs ( 60 ) ;
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
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 ( ) ;
}
2026-11-04 09:46:43 +00:00
// 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 ;
}
}
}
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
app . background ( ctx ) ;
app . save_if_dirty ( ) ;
// egui wants a frame now and then to forget old state; nothing is drawn.
2026-11-04 09:46:43 +00:00
ctx . run_ui ( Default ::default ( ) , | _ | { } ) . textures_delta . clear ( ) ;
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
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 ) {
2026-11-04 11:30:32 +00:00
if self . screenshot . is_none ( ) {
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
return ;
}
// ANYBOT_SHOT_DRAG="x1,y1>x2,y2@frame": press, move over 12 frames, release.
if let Ok ( d ) = std ::env ::var ( " ANYBOT_SHOT_DRAG " ) {
if let Some ( ( path , at ) ) = d . split_once ( '@' ) {
let at : u32 = at . trim ( ) . parse ( ) . unwrap_or ( 30 ) ;
let pts : Vec < f32 > = path . split ( [ '>' , ',' ] ) . filter_map ( | v | v . trim ( ) . parse ( ) . ok ( ) ) . collect ( ) ;
if pts . len ( ) = = 4 {
let ( a , b ) = ( egui ::pos2 ( pts [ 0 ] , pts [ 1 ] ) , egui ::pos2 ( pts [ 2 ] , pts [ 3 ] ) ) ;
let f = self . frames + 1 ;
if f = = at {
raw . events . push ( egui ::Event ::PointerMoved ( a ) ) ;
} else if f = = at + 1 {
raw . events . push ( egui ::Event ::PointerButton { pos : a , button : egui ::PointerButton ::Primary , pressed : true , modifiers : Default ::default ( ) } ) ;
} else if f > at + 1 & & f < = at + 13 {
let k = ( f - at - 1 ) as f32 / 12.0 ;
raw . events . push ( egui ::Event ::PointerMoved ( a + ( b - a ) * k ) ) ;
} else if f = = at + 15 {
raw . events . push ( egui ::Event ::PointerButton { pos : b , button : egui ::PointerButton ::Primary , pressed : false , modifiers : Default ::default ( ) } ) ;
}
}
}
}
// ANYBOT_SHOT_HOVER="x,y@frame": just move the pointer there (tooltips).
if let Ok ( h ) = std ::env ::var ( " ANYBOT_SHOT_HOVER " ) {
if let Some ( ( xy , at ) ) = h . split_once ( '@' ) {
if let ( Some ( ( x , y ) ) , Ok ( at ) ) = ( xy . split_once ( ',' ) , at . trim ( ) . parse ::< u32 > ( ) ) {
if self . frames + 1 = = at {
if let ( Ok ( x ) , Ok ( y ) ) = ( x . trim ( ) . parse ::< f32 > ( ) , y . trim ( ) . parse ::< f32 > ( ) ) {
raw . events . push ( egui ::Event ::PointerMoved ( egui ::pos2 ( x , y ) ) ) ;
}
}
}
}
}
let Ok ( spec ) = std ::env ::var ( " ANYBOT_SHOT_CLICK " ) else { return } ;
for one in spec . split ( ';' ) {
let ( pos , right ) = match one . strip_suffix ( " :right " ) { Some ( p ) = > ( p , true ) , None = > ( one , false ) } ;
let Some ( ( xy , at ) ) = pos . split_once ( '@' ) else { continue } ;
let Some ( ( x , y ) ) = xy . split_once ( ',' ) else { continue } ;
let ( Ok ( x ) , Ok ( y ) , Ok ( at ) ) = ( x . trim ( ) . parse ::< f32 > ( ) , y . trim ( ) . parse ::< f32 > ( ) , at . trim ( ) . parse ::< u32 > ( ) ) else { continue } ;
let p = egui ::pos2 ( x , y ) ;
let button = if right { egui ::PointerButton ::Secondary } else { egui ::PointerButton ::Primary } ;
if self . frames + 1 = = at {
raw . events . push ( egui ::Event ::PointerMoved ( p ) ) ;
}
if self . frames + 1 = = at + 1 {
raw . events . push ( egui ::Event ::PointerButton { pos : p , button , pressed : true , modifiers : Default ::default ( ) } ) ;
}
if self . frames + 1 = = at + 2 {
raw . events . push ( egui ::Event ::PointerButton { pos : p , button , pressed : false , modifiers : Default ::default ( ) } ) ;
}
}
}
fn ui ( & mut self , ui : & mut egui ::Ui , _frame : & mut eframe ::Frame ) {
let ctx = & ui . ctx ( ) . clone ( ) ;
self . handle_screenshot ( ctx ) ;
2026-11-05 06:25:01 +00:00
self . state . fix_primary ( ) ;
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
self . md_viewer ( ctx ) ;
頁面: pages a bot makes and keeps up to date (like the original's tracking pages)
- write_page / read_page / list_pages: Markdown pages with versions (last 10 kept), per bot;
the reply gets a card (title, fading preview, page icon, 頁面 · 更新時間, 最近更新), and
[標題](page:ID) links open the page
- The page opens large: summary, what changed, the version picker, 複製 / 存成檔案 / 刪除;
listed under 資料庫 in the right panel
- Page blocks for richer pages, in the chat's Markdown too: ```stats tiles, ```steps progress,
> [!NOTE]/[!TIP]/[!WARNING]/[!IMPORTANT] call-outs (with tables and Mermaid charts)
- Phone: the card in chats, page links, a page view with the same blocks; charts drawn by the
AnyBot as SVG (GET /api/pages/<id>[/chart/<n>]); members may read pages
- Bots are told to make a page for things to track and to update it from a routine only when
there is real news
- Screenshot views: ANYBOT_SHOT_VIEW=pagecard / page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 17:41:41 +00:00
self . page_viewer ( ctx ) ;
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
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.
2026-11-04 09:46:43 +00:00
// (On a screen of a brain the bots keep working there.)
if self . mir . on {
self . streams . clear ( ) ;
}
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
let ids : Vec < u64 > = self . streams . keys ( ) . copied ( ) . collect ( ) ;
for id in ids {
self . stop ( id ) ;
}
tools ::stop_all ( ) ;
crate ::cloudbox ::shutdown ( ) ;
if ! self . demo {
2026-11-04 09:46:43 +00:00
if self . mir . on {
self . mirror_save ( ) ;
} else {
self . state . save ( ) ;
}
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
}
}
}
// ---------- helpers ----------
/// When the program file was last written (= when this build was made).
fn exe_modified ( ) -> Option < std ::time ::SystemTime > {
std ::env ::current_exe ( ) . ok ( ) . and_then ( | p | std ::fs ::metadata ( p ) . ok ( ) ) . and_then ( | m | m . modified ( ) . ok ( ) )
}
/// "2026-11-03 20:57" in local time, or "?" when unknown.
fn version_label ( t : Option < std ::time ::SystemTime > ) -> String {
t . map ( | t | chrono ::DateTime ::< chrono ::Local > ::from ( t ) . format ( " %Y-%m-%d %H:%M " ) . to_string ( ) ) . unwrap_or_else ( | | " ? " . into ( ) )
}
/// How often the relaunch helper checks whether the old copy has exited (main-app.cjs @3195964,
/// `PBt`: `while kill -0 "$pid" …; do sleep 0.2; done; exec "$app"`).
pub const RELAUNCH_POLL_SECS : f32 = 0.2 ;
/// A detached helper that waits for this copy (`pid`) to exit, then starts `exe`, so two copies
/// never run at once and the old one's last save lands first. Like the original's `Pur`
/// (`spawn(shell, …, {detached:true, stdio:"ignore"})`), its input and output go nowhere;
/// the main program also reopens closed std streams on /dev/null at start (`sub_1000004c0`,
/// 0x1000004c0). Start-up switches are cleared so the new copy opens normally (the original's
/// `entry`, 0x100000460, changes how it starts when ELECTRON_RUN_AS_NODE is set).
pub fn relaunch_command ( pid : u32 , exe : & std ::path ::Path ) -> std ::process ::Command {
let script = format! ( " while kill -0 \" $1 \" 2>/dev/null; do sleep {RELAUNCH_POLL_SECS} ; done; exec \" $2 \" " ) ;
let mut c = std ::process ::Command ::new ( " /bin/sh " ) ;
c . arg ( " -c " ) . arg ( script ) . arg ( " anybot-relaunch " ) . arg ( pid . to_string ( ) ) . arg ( exe ) ;
c . stdin ( std ::process ::Stdio ::null ( ) ) . stdout ( std ::process ::Stdio ::null ( ) ) . stderr ( std ::process ::Stdio ::null ( ) ) ;
for k in [ " ANYBOT_SHOT_VIEW " , tools ::ENV_CONVERSATION_ID , tools ::ENV_REQUEST_ID , " ELECTRON_RUN_AS_NODE " ] {
c . env_remove ( k ) ;
}
#[ cfg(unix) ]
{
use std ::os ::unix ::process ::CommandExt ;
c . process_group ( 0 ) ;
}
c
}
/// A reply that is still coming in.
struct Live {
cancel : Arc < AtomicBool > ,
started : Instant ,
last : Instant ,
produced : bool ,
status : Option < String > ,
/// Waiting for an approval or a running tool: the stall watchdog stays quiet.
paused : bool ,
/// What the person typed during this turn, waiting for the next step.
steer : Arc < Mutex < Vec < String > > > ,
turn_id : String ,
/// 直接操作 for this turn (switched on mid-turn by "全部允許").
direct : Arc < AtomicBool > ,
}
/// How an "always allow" grant reads in the UI.
fn grant_label ( g : & str ) -> String {
match g {
" edit_file " = > " 「修改檔案」 " . into ( ) ,
" browser:read " = > " 「讀取瀏覽器分頁」 " . into ( ) ,
" read:anywhere " = > " 「讀取工作資料夾以外」 " . into ( ) ,
" computer:see " = > " 「看你的螢幕」 " . into ( ) ,
g if g . starts_with ( " app: " ) = > format! ( " 操作「 {} 」 " , & g [ 4 .. ] ) ,
g if g . starts_with ( " mcp: " ) = > format! ( " 使用連接器「 {} 」 " , & g [ 4 .. ] ) ,
g if g . starts_with ( " login: " ) = > format! ( " 登入 {} " , & g [ 6 .. ] ) ,
g if g . starts_with ( " macapp-write: " ) = > format! ( " 修改「 {} 」 " , & g [ 13 .. ] ) ,
g if g . starts_with ( " macapp: " ) = > format! ( " 讀取「 {} 」 " , & g [ 7 .. ] ) ,
other = > format! ( " 「 {} 」 " , other . trim_start_matches ( " shell: " ) ) ,
}
}
/// Padding that makes a one-line bubble 36 px tall around 15 px text (egui row ≈ 18 px).
fn bubble_margin ( ) -> Margin {
// index CSS `message`: padding-block var(--sand-transcript-prose-block-padding) = 7px,
// padding-inline 12px (7 + 22 + 7 = the 36 px one-line bubble).
debug_assert_eq! ( 7.0 * 2.0 + TRANSCRIPT_LINE_HEIGHT , BUBBLE_ONE_LINE_HEIGHT ) ;
Margin ::symmetric ( 12 , 7 )
}
/// index.eager-app `messageColumnOfBlock`: max-width min(80cqi, 560px, 100cqi - 82px).
pub const MESSAGE_MAX_WIDTH : f32 = 560.0 ;
pub fn message_max_width ( container : f32 ) -> f32 {
( container * 0.8 ) . min ( MESSAGE_MAX_WIDTH ) . min ( container - 82.0 ) . max ( 120.0 )
}
/// What the model sees: attachments read in, 引用 and 表情回應 noted, and in a group chat the other
/// members' answers as labelled words (their tool steps and private blocks left out).
fn group_history ( messages : & [ Message ] , speaker : Option < u64 > , st : & State ) -> Vec < Message > {
let mut out = vec! [ ] ;
let mut skip_tool_ids : std ::collections ::HashSet < String > = Default ::default ( ) ;
for m in messages {
let mut m = m . clone ( ) ;
if ! m . attachments . is_empty ( ) {
let ( extra , images ) = crate ::attach ::for_model ( & m . attachments ) ;
m . text = format! ( " {} {extra} " , m . text ) ;
m . images = images ;
}
if m . role = = Role ::Assistant & & ! m . reactions . is_empty ( ) {
m . text = format! ( " {} \n (使用者對這則回應: {} ) " , m . text , m . reactions . join ( " " ) ) ;
}
if let Some ( me ) = speaker {
if m . role = = Role ::Assistant & & m . author ! = me & & ! m . summary {
for c in & m . tool_calls {
skip_tool_ids . insert ( c . id . clone ( ) ) ;
}
if m . text . trim ( ) . is_empty ( ) {
continue ;
}
let who = st . bot ( m . author ) . map ( | b | b . name . clone ( ) ) . unwrap_or_else ( | | " 其他成員 " . into ( ) ) ;
out . push ( Message ::user ( & format! ( " 【 {who} 】 {} " , m . text ) ) ) ;
continue ;
}
if m . role = = Role ::Tool & & skip_tool_ids . contains ( & m . tool_call_id ) {
continue ;
}
}
out . push ( m ) ;
}
out
}
/// 生活卡片: a flight, place, weather, product or event the bot found, drawn as a card.
fn card ( ui : & mut egui ::Ui , args : & str , p : Palette ) {
let v = tools ::args_of ( args ) ;
use crate ::icons ::Icon ;
let icon = match v [ " kind " ] . as_str ( ) . unwrap_or ( " " ) {
" flight " = > Icon ::Plane ,
" place " = > Icon ::MapPin ,
" weather " = > Icon ::CloudSun ,
" product " = > Icon ::ShoppingBag ,
" event " = > Icon ::Calendar ,
_ = > Icon ::Folder ,
} ;
ui . add_space ( 4.0 ) ;
Frame ::new ( )
. fill ( p . elevated )
. stroke ( Stroke ::new ( 1.0 , p . border ) )
. corner_radius ( 14 )
. inner_margin ( Margin ::symmetric ( 14 , 12 ) )
. shadow ( egui ::epaint ::Shadow { offset : [ 0 , 2 ] , blur : 8 , spread : 0 , color : Color32 ::from_black_alpha ( 14 ) } )
. show ( ui , | ui | {
ui . set_max_width ( message_max_width ( ui . available_width ( ) + 32.0 ) ) ;
ui . horizontal ( | ui | {
let ( r , _ ) = ui . allocate_exact_size ( vec2 ( 26.0 , 26.0 ) , egui ::Sense ::hover ( ) ) ;
crate ::icons ::paint ( ui . painter ( ) , r , icon , p . text2 ) ;
ui . vertical ( | ui | {
ui . spacing_mut ( ) . item_spacing . y = 1.0 ;
ui . label ( RichText ::new ( v [ " title " ] . as_str ( ) . unwrap_or ( " " ) ) . strong ( ) . size ( FONT_LG ) ) ;
if let Some ( st ) = v [ " subtitle " ] . as_str ( ) . filter ( | x | ! x . is_empty ( ) ) {
ui . label ( RichText ::new ( st ) . size ( FONT_XS ) . color ( p . text3 ) ) ;
}
} ) ;
} ) ;
let rows = v [ " rows " ] . as_array ( ) . cloned ( ) . unwrap_or_default ( ) ;
if ! rows . is_empty ( ) {
ui . add_space ( 6.0 ) ;
egui ::Grid ::new ( ( " card " , args . len ( ) , v [ " title " ] . as_str ( ) . unwrap_or ( " " ) ) ) . num_columns ( 2 ) . spacing ( [ 14.0 , 4.0 ] ) . show ( ui , | ui | {
for r in rows . iter ( ) . take ( 12 ) {
ui . label ( RichText ::new ( r [ " label " ] . as_str ( ) . unwrap_or ( " " ) ) . size ( FONT_SM ) . color ( p . text3 ) ) ;
ui . add ( egui ::Label ::new ( RichText ::new ( r [ " value " ] . as_str ( ) . unwrap_or ( " " ) ) . size ( FONT_SM ) ) . wrap ( ) ) ;
ui . end_row ( ) ;
}
} ) ;
}
if let Some ( link ) = v [ " link " ] . as_str ( ) . filter ( | l | l . starts_with ( " http " ) ) {
ui . add_space ( 4.0 ) ;
ui . hyperlink_to ( RichText ::new ( t ( " 查看來源 ↗ " ) ) . size ( FONT_XS ) . color ( p . accent ) , link ) ;
}
} ) ;
ui . add_space ( 4.0 ) ;
}
/// 匯出文字記錄: the chat as Markdown (tool steps folded in as quotes).
fn transcript_markdown ( b : & Bot ) -> String {
let mut s = format! ( " # {} 的對話 \n \n 匯出時間: {} \n \n " , b . name , chrono ::Local ::now ( ) . format ( " %Y-%m-%d %H:%M " ) ) ;
for m in & b . messages {
match m . role {
Role ::User = > {
s . push_str ( & format! ( " **你**: {} \n " , m . text ) ) ;
for a in & m . attachments {
s . push_str ( & format! ( " [附件] {} \n " , a . name ) ) ;
}
s . push ( '\n' ) ;
}
Role ::Assistant if m . summary = > s . push_str ( & format! ( " > (較早的對話摘要) {} \n \n " , m . text . replace ( '\n' , " " ) ) ) ,
Role ::Assistant = > {
if ! m . text . trim ( ) . is_empty ( ) {
s . push_str ( & format! ( " ** {} **: {} \n \n " , b . name , m . text ) ) ;
}
for c in & m . tool_calls {
s . push_str ( & format! ( " > [工具] {} {} \n " , c . name , c . args . chars ( ) . take ( 200 ) . collect ::< String > ( ) ) ) ;
}
if ! m . tool_calls . is_empty ( ) {
s . push ( '\n' ) ;
}
}
Role ::Tool = > { }
}
}
s
}
/// What the bot is told about itself, the app it lives in and the computers it can use.
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 ) ;
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 \
- 你 也 能 像 人 一 樣 操 作 螢 幕 : 你 自 己 的 電 腦 ( Bot 的 電 腦 , Linux 桌 面 ) 或 使 用 者 的 Mac ( 你 的 電 腦 , 用 在 使 用 者 自 己 的 App ) 。 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 ::< Vec < _ > > ( ) . join ( " \n " ) ) ;
}
頁面: pages a bot makes and keeps up to date (like the original's tracking pages)
- write_page / read_page / list_pages: Markdown pages with versions (last 10 kept), per bot;
the reply gets a card (title, fading preview, page icon, 頁面 · 更新時間, 最近更新), and
[標題](page:ID) links open the page
- The page opens large: summary, what changed, the version picker, 複製 / 存成檔案 / 刪除;
listed under 資料庫 in the right panel
- Page blocks for richer pages, in the chat's Markdown too: ```stats tiles, ```steps progress,
> [!NOTE]/[!TIP]/[!WARNING]/[!IMPORTANT] call-outs (with tables and Mermaid charts)
- Phone: the card in chats, page links, a page view with the same blocks; charts drawn by the
AnyBot as SVG (GET /api/pages/<id>[/chart/<n>]); members may read pages
- Bots are told to make a page for things to track and to update it from a routine only when
there is real news
- Screenshot views: ANYBOT_SHOT_VIEW=pagecard / page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 17:41:41 +00:00
// 頁面: what this bot already keeps, so a routine or a follow-up updates the same page.
let mine : Vec < String > = st . pages . iter ( ) . filter ( | p | p . bot = = b . id ) . map ( | p | format! ( " # {} 「 {} 」(第 {} 版,更新於 {} ) " , p . id , p . title , p . revision , crate ::pages ::when ( p . updated ) ) ) . collect ( ) ;
if ! mine . is_empty ( ) {
s . push_str ( " \n \n 你做過的頁面(更新時用 write_page 帶 id, 先 read_page 看現在的內容): \n " ) ;
s . push_str ( & mine . join ( " \n " ) ) ;
}
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
if ! computers . is_empty ( ) {
let list : Vec < String > = 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 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 { .. } = > format! ( " - {} :使用者帳號裡的另一台電腦(裝置連結;可以用檔案、指令和 computer_use_* 工具, computer=「 {} 」) " , t . name , t . name ) ,
} )
. collect ( ) ;
s . push_str ( & format! (
" \n \n 你可以用工具在電腦上做事。每個工具都有 computer 參數,預設是「{default_computer}」。 \n {} \n \
- 使 用 者 說 在 哪 台 電 腦 上 做 ( 「 在 我 的 Mac 上 」 「 在 工 作 站 上 」 ) , 就 把 computer 設 成 那 台 ; 沒 說 就 用 預 設 。 \ n \
- 先 用 list_dir 、 search 、 read_file 看 清 楚 , 再 動 手 。 \ n \
- 需 要 權 限 時 ( 電 腦 工 具 還 沒 打 開 、 要 用 使 用 者 的 電 腦 、 會 改 變 東 西 的 操 作 ) , 直 接 呼 叫 工 具 , App 會 自 己 跳 出 視 窗 請 使 用 者 允 許 ; 不 要 叫 使 用 者 自 己 去 改 設 定 。 被 拒 絕 就 換 個 方 式 或 問 使 用 者 。 \ n \
- 跑 很 久 的 指 令 會 移 到 背 景 , 用 read_terminal 追 蹤 , 不 要 重 複 執 行 ; 它 在 等 輸 入 時 ( 例 如 問 y / n ) , 用 write_terminal 回 答 。 \ n \
- 只 有 工 具 結 果 證 明 做 完 了 , 才 說 做 完 ; 失 敗 就 照 實 說 。 \ n \
- 回 覆 使 用 者 時 , 用 簡 單 的 話 說 明 你 做 了 什 麼 、 在 哪 台 電 腦 、 結 果 如 何 。 " ,
list . join ( " \n " )
) ) ;
}
let chans : Vec < String > = st . channels . iter ( ) . filter ( | c | c . bot_id = = b . id & & c . enabled ) . map ( | c | format! ( " {} 「 {} 」 " , c . kind . label ( ) , c . name ) ) . collect ( ) ;
if ! chans . is_empty ( ) {
s . push_str ( & format! ( " \n \n 你也在這些訊息管道回覆別人: {} 。以「[訊息管道]」開頭的訊息來自那裡,你這一回合最後的回答會自動傳回給對方,所以要簡潔、直接回答對方,不要用表格,也不要提到 AnyBot 的內部操作。對方不是使用者本人:不要透露使用者的私人資料,要動到使用者的電腦或帳號前先確認。 " , chans . join ( " 、 " ) ) ) ;
}
s . push_str ( & crate ::memory ::prompt_section ( & b . memories , & st . skills ) ) ;
let mcp_notes = crate ::mcp ::instructions ( & st . mcp_servers ) ;
let mcp_tools = crate ::mcp ::status ( ) ;
if ! mcp_tools . is_empty ( ) {
s . push_str ( & format! ( " \n \n 你也可以用使用者接上的連接器( MCP, 工具名稱以 mcp__ 開頭): {} 。第一次用某個連接器時 App 會問使用者。 {} " , mcp_tools . keys ( ) . cloned ( ) . collect ::< Vec < _ > > ( ) . join ( " 、 " ) , if mcp_notes . is_empty ( ) { String ::new ( ) } else { format! ( " \n 連接器的使用說明: \n {mcp_notes} " ) } ) ) ;
}
s . push_str ( & crate ::teach ::prompt_section ( & b . demos ) ) ;
if ! b . instructions . trim ( ) . is_empty ( ) {
s . push_str ( " \n \n " ) ;
s . push_str ( b . instructions . trim ( ) ) ;
}
頁面: pages a bot makes and keeps up to date (like the original's tracking pages)
- write_page / read_page / list_pages: Markdown pages with versions (last 10 kept), per bot;
the reply gets a card (title, fading preview, page icon, 頁面 · 更新時間, 最近更新), and
[標題](page:ID) links open the page
- The page opens large: summary, what changed, the version picker, 複製 / 存成檔案 / 刪除;
listed under 資料庫 in the right panel
- Page blocks for richer pages, in the chat's Markdown too: ```stats tiles, ```steps progress,
> [!NOTE]/[!TIP]/[!WARNING]/[!IMPORTANT] call-outs (with tables and Mermaid charts)
- Phone: the card in chats, page links, a page view with the same blocks; charts drawn by the
AnyBot as SVG (GET /api/pages/<id>[/chart/<n>]); members may read pages
- Bots are told to make a page for things to track and to update it from a routine only when
there is real news
- Screenshot views: ANYBOT_SHOT_VIEW=pagecard / page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 17:41:41 +00:00
s . push_str ( " \n \n 使用者要你追蹤、整理、比較、研究一件之後還會回來看的事(例如「幫我追蹤台灣 FSD 狀況」),就用 write_page 做成一頁:先寫結論和現況,再寫細節和來源(附日期),適合時用表格、```stats、```steps、圖表。回答時簡短說重點, 並用 [頁面標題](page:ID) 連到它。要定期追蹤時,再用 create_routine 設例行任務, 每次執行: read_page、查新消息, 有實質進展才用 write_page 更新( note 寫改了什麼)並在聊天裡說;沒有新消息就簡短回答「沒有新消息」,不要更新頁面。 " ) ;
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
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 ::< bool > ( pinned_id ) ) . unwrap_or ( false ) ;
if r . clicked ( ) {
pinned = ! pinned ;
} else if pinned & & ui . input ( | i | i . pointer . any_pressed ( ) ) & & ! r . hovered ( ) {
pinned = false ;
}
ui . ctx ( ) . data_mut ( | d | d . insert_temp ( pinned_id , pinned ) ) ;
let show = r . hovered ( ) | | pinned ;
let col = if show { p . text } else { p . text3 } ;
ui . painter ( ) . circle_stroke ( rect . center ( ) , 6.5 , Stroke ::new ( 1.1 , col ) ) ;
ui . painter ( ) . text ( rect . center ( ) + vec2 ( 0.0 , 0.5 ) , egui ::Align2 ::CENTER_CENTER , " ? " , egui ::FontId ::proportional ( 10.0 ) , col ) ;
if show {
let text = t ( text ) ;
egui ::Area ::new ( r . id . with ( " help " ) )
. order ( egui ::Order ::Tooltip )
. fixed_pos ( rect . left_bottom ( ) + vec2 ( - 4.0 , 6.0 ) )
. constrain ( true )
. interactable ( false )
. show ( ui . ctx ( ) , | ui | {
Frame ::popup ( ui . style ( ) ) . corner_radius ( 10 ) . inner_margin ( Margin ::symmetric ( 12 , 10 ) ) . show ( ui , | ui | {
ui . set_max_width ( 300.0 ) ;
ui . add ( egui ::Label ::new ( RichText ::new ( text ) . size ( FONT_SM ) . color ( p . text ) ) . wrap ( ) ) ;
} ) ;
} ) ;
}
r . on_hover_cursor ( egui ::CursorIcon ::Help ) ;
}
/// One setting: its name (with「?」for the explanation) on the left, its control on the right.
fn setting_row ( ui : & mut egui ::Ui , p : Palette , label : & str , desc : & str , control : impl FnOnce ( & mut egui ::Ui ) ) {
Frame ::new ( ) . fill ( p . elevated ) . corner_radius ( 10 ) . inner_margin ( Margin ::symmetric ( 14 , 10 ) ) . show ( ui , | ui | {
ui . set_width ( ui . available_width ( ) ) ;
ui . horizontal ( | ui | {
let w = ( ui . available_width ( ) * 0.5 ) . max ( 180.0 ) ;
ui . horizontal ( | ui | {
ui . set_width ( w ) ;
ui . spacing_mut ( ) . item_spacing . x = 4.0 ;
ui . label ( RichText ::new ( t ( label ) ) . size ( FONT_BASE ) . color ( p . text ) ) ;
help_mark ( ui , desc , p ) ;
} ) ;
ui . with_layout ( Layout ::right_to_left ( Align ::Center ) , | ui | {
ui . horizontal ( control ) ;
} ) ;
} ) ;
} ) ;
}
fn setting_title ( ui : & mut egui ::Ui , title : & str , desc : & str , p : Palette ) {
ui . horizontal ( | ui | {
ui . spacing_mut ( ) . item_spacing . x = 4.0 ;
ui . label ( RichText ::new ( t ( title ) ) . strong ( ) . size ( FONT_BASE ) ) ;
help_mark ( ui , desc , p ) ;
} ) ;
ui . add_space ( 4.0 ) ;
}
/// Small grey text under a setting.
fn hint ( ui : & mut egui ::Ui , text : impl Into < String > , p : Palette ) {
ui . add ( egui ::Label ::new ( RichText ::new ( t ( & text . into ( ) ) ) . size ( 11.5 ) . color ( p . text3 ) ) . wrap ( ) ) ;
}
/// A rounded group of related settings, like a macOS settings card.
fn setting_card < R > ( ui : & mut egui ::Ui , p : Palette , body : impl FnOnce ( & mut egui ::Ui ) -> R ) -> R {
let r = Frame ::new ( )
. fill ( p . hover . gamma_multiply ( 0.6 ) )
. stroke ( Stroke ::new ( 1.0 , p . border . gamma_multiply ( 0.6 ) ) )
. corner_radius ( 12 )
. inner_margin ( Margin ::symmetric ( 16 , 14 ) )
. show ( ui , | ui | {
ui . set_width ( ui . available_width ( ) ) ;
body ( ui )
} )
. inner ;
ui . add_space ( 12.0 ) ;
r
}
/// A label column so form rows line up.
fn form_row ( ui : & mut egui ::Ui , label : & str , p : Palette , body : impl FnOnce ( & mut egui ::Ui ) ) {
ui . horizontal ( | ui | {
let ( rect , _ ) = ui . allocate_exact_size ( vec2 ( FORM_LABEL_WIDTH , 28.0 ) , egui ::Sense ::hover ( ) ) ;
ui . painter ( ) . text ( rect . left_center ( ) , egui ::Align2 ::LEFT_CENTER , t ( label ) , egui ::FontId ::proportional ( FONT_SM ) , p . text2 ) ;
body ( ui ) ;
} ) ;
}
fn field_label ( ui : & mut egui ::Ui , text : & str , p : Palette ) {
ui . add_space ( 4.0 ) ;
ui . label ( RichText ::new ( t ( text ) ) . size ( FONT_XS ) . color ( p . text2 ) ) ;
}
fn primary_button ( text : & str , p : Palette ) -> egui ::Button < 'static > {
egui ::Button ::new ( RichText ::new ( t ( text ) ) . color ( p . on_primary ) . strong ( ) ) . fill ( p . primary ) . corner_radius ( 8 ) . min_size ( vec2 ( 72.0 , 30.0 ) )
}
fn secondary_button ( text : & str ) -> egui ::Button < 'static > {
egui ::Button ::new ( t ( text ) ) . corner_radius ( 8 ) . min_size ( vec2 ( 64.0 , 30.0 ) )
}
fn danger_button ( text : & str , p : Palette ) -> egui ::Button < 'static > {
egui ::Button ::new ( RichText ::new ( t ( text ) ) . color ( Color32 ::WHITE ) . strong ( ) ) . fill ( p . danger ) . corner_radius ( 8 ) . min_size ( vec2 ( 72.0 , 30.0 ) )
}
/// Tallest a dialog may be: the window minus a margin above and below.
fn dialog_max_height ( ctx : & egui ::Context ) -> f32 {
( ctx . content_rect ( ) . height ( ) - WINDOW_SCREEN_MARGIN ) . max ( 240.0 )
}
/// A centred dialog over a dimmed window: title and × on top, then the caller's body.
/// Returns true when the person asked to close it (× , Esc, or a click outside when `outside_closes`).
fn dialog ( ctx : & egui ::Context , id : & str , title : & str , width : f32 , outside_closes : bool , p : Palette , content : impl FnOnce ( & mut egui ::Ui ) ) -> bool {
let dark = p . pane ! = LIGHT . pane ;
let mut close = false ;
let resp = egui ::Modal ::new ( egui ::Id ::unique ( ( " dialog " , id ) ) )
. backdrop_color ( Color32 ::from_black_alpha ( if dark { 130 } else { 70 } ) )
. frame (
Frame ::new ( )
. fill ( p . elevated )
. stroke ( Stroke ::new ( 1.0 , p . border ) )
. corner_radius ( 16 )
. shadow ( egui ::epaint ::Shadow { offset : [ 0 , 12 ] , blur : 36 , spread : 0 , color : Color32 ::from_black_alpha ( if dark { 110 } else { 45 } ) } ) ,
)
. show ( ctx , | ui | {
// Never wider than the window (a narrow window keeps the whole dialog on screen).
ui . set_width ( width . min ( ctx . content_rect ( ) . width ( ) - 32.0 ) . max ( 280.0 ) ) ;
// Header, body and footer sit flush; each sets its own inner spacing.
ui . spacing_mut ( ) . item_spacing . y = 0.0 ;
Frame ::new ( ) . inner_margin ( Margin { left : 20 , right : 12 , top : 12 , bottom : 12 } ) . show ( ui , | ui | {
ui . set_width ( ui . available_width ( ) ) ;
ui . horizontal ( | ui | {
ui . label ( RichText ::new ( t ( title ) ) . strong ( ) . size ( FONT_XL ) ) ;
ui . add_space ( ( ui . available_width ( ) - 28.0 ) . max ( 0.0 ) ) ;
// Drawn, not a glyph: not every Chinese font has "✕".
let ( rect , r ) = ui . allocate_exact_size ( vec2 ( 28.0 , 28.0 ) , egui ::Sense ::click ( ) ) ;
if r . hovered ( ) {
ui . painter ( ) . rect_filled ( rect , 8 , p . hover ) ;
}
let c = rect . center ( ) ;
let stroke = Stroke ::new ( 1.5 , if r . hovered ( ) { p . text } else { p . text2 } ) ;
ui . painter ( ) . line_segment ( [ c + vec2 ( - 5.0 , - 5.0 ) , c + vec2 ( 5.0 , 5.0 ) ] , stroke ) ;
ui . painter ( ) . line_segment ( [ c + vec2 ( - 5.0 , 5.0 ) , c + vec2 ( 5.0 , - 5.0 ) ] , stroke ) ;
if r . on_hover_text ( t ( " 關閉( Esc) " ) ) . clicked ( ) {
close = true ;
}
} ) ;
} ) ;
let r = ui . min_rect ( ) ;
ui . painter ( ) . hline ( r . x_range ( ) , r . bottom ( ) , Stroke ::new ( 1.0 , p . border ) ) ;
content ( ui ) ;
} ) ;
let escape = ctx . input ( | i | i . key_pressed ( Key ::Escape ) ) ;
close | | escape | | ( outside_closes & & resp . backdrop_response . clicked ( ) )
}
/// The scrolling middle of a dialog, `reserve` px kept free for its footer.
fn dialog_body < R > ( ui : & mut egui ::Ui , id : impl std ::hash ::Hash + std ::fmt ::Debug , max_height : f32 , body : impl FnOnce ( & mut egui ::Ui ) -> R ) -> R {
egui ::ScrollArea ::vertical ( )
. id_salt ( id )
. max_height ( max_height )
. auto_shrink ( [ false , true ] )
. show ( ui , | ui | {
Frame ::new ( ) . inner_margin ( Margin { left : 20 , right : 20 , top : 16 , bottom : 16 } ) . show ( ui , | ui | {
ui . set_width ( ui . available_width ( ) ) ;
ui . spacing_mut ( ) . item_spacing . y = 6.0 ;
body ( ui )
} )
. inner
} )
. inner
}
/// The bottom bar of a dialog: a line above, buttons laid out right to left.
fn dialog_footer ( ui : & mut egui ::Ui , p : Palette , body : impl FnOnce ( & mut egui ::Ui ) ) {
let top = ui . cursor ( ) . top ( ) ;
ui . painter ( ) . hline ( ui . max_rect ( ) . x_range ( ) , top , Stroke ::new ( 1.0 , p . border ) ) ;
Frame ::new ( ) . inner_margin ( Margin ::symmetric ( 20 , 12 ) ) . show ( ui , | ui | {
ui . set_width ( ui . available_width ( ) ) ;
ui . spacing_mut ( ) . item_spacing . y = 6.0 ;
ui . horizontal ( | ui | body ( ui ) ) ;
} ) ;
}
#[ cfg(test) ]
mod tests {
#[ test ]
fn group_members_see_each_other_as_labelled_words ( ) {
let st = State ::demo ( ) ;
let ( a , b ) = ( st . bots [ 1 ] . id , st . bots [ 2 ] . id ) ;
let mut tool_step = Message { author : a , .. Message ::assistant ( " " , " x " ) } ;
tool_step . tool_calls . push ( crate ::model ::ToolCall { id : " t1 " . into ( ) , name : " shell " . into ( ) , args : " {} " . into ( ) , status : Default ::default ( ) } ) ;
let msgs = vec! [
Message ::user ( " 大家覺得呢? " ) ,
tool_step ,
Message ::tool_result ( " t1 " , " shell " , " secret output " ) ,
Message { author : a , reactions : vec ! [ " 👍 " . into ( ) ] , .. Message ::assistant ( " 我覺得可以 " , " x " ) } ,
] ;
let h = super ::group_history ( & msgs , Some ( b ) , & st ) ;
assert_eq! ( h . len ( ) , 2 , " {h:?} " ) ;
assert_eq! ( h [ 1 ] . role , Role ::User ) ;
assert! ( h [ 1 ] . text . starts_with ( & format! ( " 【 {} 】我覺得可以 " , st . bots [ 1 ] . name ) ) & & h [ 1 ] . text . contains ( " 👍 " ) ) ;
// The speaker's own steps stay as they are.
let own = super ::group_history ( & msgs , Some ( a ) , & st ) ;
assert_eq! ( own . len ( ) , 4 ) ;
}
#[ test ]
fn transcript_export_reads_like_a_chat ( ) {
let st = State ::demo ( ) ;
let b = & st . bots [ 0 ] ;
let md = super ::transcript_markdown ( b ) ;
assert! ( md . starts_with ( & format! ( " # {} 的對話 " , b . name ) ) ) ;
assert! ( md . contains ( " **你**: " ) ) ;
}
use super ::* ;
/// The relaunch helper waits for the old copy to exit before starting the new one.
#[ test ]
fn relaunch_waits_for_the_old_copy ( ) {
let dir = std ::env ::temp_dir ( ) . join ( format! ( " anybot-relaunch- {} " , std ::process ::id ( ) ) ) ;
std ::fs ::create_dir_all ( & dir ) . unwrap ( ) ;
let marker = dir . join ( " started " ) ;
let fake_app = dir . join ( " fake-app.sh " ) ;
std ::fs ::write ( & fake_app , format! ( " #!/bin/sh \n echo \" $ {{ 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 " ) ;
}
}