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 computers part of the window: 「Bot 的電腦」 (status, live screen, take control, rebuilds,
// snapshots, secrets, where it runs), 「你的電腦」 (macOS permissions, the per-app grants) and
// the floating bar shown while a bot drives this Mac.
//
// Wording follows the original's strings where it has them (ORIGINAL_FEATURES.md §9– §11).
use super ::* ;
use crate ::cloudbox ::{ self , Rebuild , Run } ;
/// How often the viewer asks the box for a new picture (estimated).
const VIEWER_FRAME_MS : u64 = 450 ;
2026-11-05 08:47:28 +00:00
/// The brain's computer comes over the account server: a picture about every 0.7 s.
const REMOTE_FRAME_MS : u64 = 700 ;
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 floating bar stays this long after the bot's last mouse/keyboard action on this Mac.
const LOCAL_BAR_SECS : u64 = 8 ;
#[ derive(Default) ]
pub struct ComputerUi {
/// 其他電腦 is expanded.
pub others_open : bool ,
/// The account's other AnyBots (id, name, online), fetched from the relay now and then.
pub peers : Arc < Mutex < Option < Vec < ( String , String , bool ) > > > > ,
pub peers_at : Option < Instant > ,
/// 你的電腦's name being edited.
pub renaming_machine : Option < String > ,
/// 更新: the first click arms it for 3 s (「再點選一次以確認」).
pub arm_update : Option < Instant > ,
/// 要移除「…」嗎? for another computer (its index).
pub confirm_remove : Option < usize > ,
/// 要重設 AnyBot 的電腦嗎?
pub confirm_reset : bool ,
/// 一律允許的應用程式 is expanded.
pub apps_open : bool ,
pub viewer_open : bool ,
/// 取得控制權: the person's clicks and keys go to the box.
pub control : bool ,
pub fit : bool ,
texture : Option < egui ::TextureHandle > ,
frame : Arc < Mutex < Option < ( u64 , egui ::ColorImage ) > > > ,
shown_seq : u64 ,
frame_size : [ usize ; 2 ] ,
worker : Option < Arc < AtomicBool > > ,
input_tx : Option < Sender < Input > > ,
pub confirm : Option < Rebuild > ,
pub note : Arc < Mutex < String > > ,
disk : Arc < Mutex < Option < String > > > ,
new_secret : ( String , String ) ,
replace_secret : Option < String > ,
secret_error : String ,
docker_host : Option < String > ,
show_log : bool ,
teach_name : String ,
teach_for : Option < u64 > ,
teach_error : String ,
/// Screenshot mode: show the real box in the viewer.
pub demo_live : bool ,
schedule_edit : String ,
pub update_ready : bool ,
update_checked : Option < Instant > ,
update_flag : Arc < AtomicBool > ,
/// The 電腦 card's small live picture of the box's screen.
thumb : Option < egui ::TextureHandle > ,
thumb_img : Arc < Mutex < Option < egui ::ColorImage > > > ,
thumb_busy : Arc < AtomicBool > ,
thumb_at : Option < Instant > ,
2026-11-05 08:47:28 +00:00
/// The viewer shows the brain's computer (this window is a screen of a brain): how to reach it.
remote : Option < crate ::remote ::Settings > ,
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
}
enum Input {
Click ( i64 , i64 , & 'static str , u32 ) ,
Move ( i64 , i64 ) ,
Scroll ( i64 , i64 , bool , u32 ) ,
Key ( String ) ,
Text ( String ) ,
}
fn status_color ( r : & Run , p : Palette ) -> Color32 {
match r {
Run ::Running = > Color32 ::from_rgb ( 0x14 , 0xc3 , 0x70 ) ,
Run ::Starting ( _ ) | Run ::Rebuilding ( .. ) = > Color32 ::from_rgb ( 0xf6 , 0x94 , 0x12 ) ,
Run ::Failed ( _ ) = > p . danger ,
_ = > p . text3 ,
}
}
/// A rounded status pill: coloured dot + label.
fn status_pill ( ui : & mut egui ::Ui , r : & Run , p : Palette ) {
let label = r . label ( ) ;
let font = egui ::FontId ::proportional ( FONT_XS ) ;
let galley = ui . painter ( ) . layout_no_wrap ( label , font , p . text2 ) ;
let size = vec2 ( galley . size ( ) . x + 26.0 , 22.0 ) ;
let ( rect , _ ) = ui . allocate_exact_size ( size , egui ::Sense ::hover ( ) ) ;
ui . painter ( ) . rect_filled ( rect , 11 , p . hover ) ;
let c = rect . left_center ( ) + vec2 ( 11.0 , 0.0 ) ;
let col = status_color ( r , p ) ;
if matches! ( r , Run ::Starting ( _ ) | Run ::Rebuilding ( .. ) ) {
let t = ui . input ( | i | i . time ) as f32 ;
ui . painter ( ) . circle_filled ( c , 4.0 + ( t * 4.0 ) . sin ( ) . abs ( ) * 2.0 , col . gamma_multiply ( 0.35 ) ) ;
ui . ctx ( ) . request_repaint_after ( std ::time ::Duration ::from_millis ( 60 ) ) ;
}
ui . painter ( ) . circle_filled ( c , 3.5 , col ) ;
ui . painter ( ) . galley ( rect . left_center ( ) + vec2 ( 20.0 , - galley . size ( ) . y / 2.0 ) , galley , p . text2 ) ;
}
/// A little monitor drawn with lines (no emoji font needed).
fn monitor_icon ( ui : & mut egui ::Ui , size : f32 , col : Color32 ) {
let ( rect , _ ) = ui . allocate_exact_size ( vec2 ( size , size ) , egui ::Sense ::hover ( ) ) ;
let s = size / 20.0 ;
let screen = egui ::Rect ::from_min_size ( rect . min + vec2 ( 2.0 * s , 3.0 * s ) , vec2 ( 16.0 * s , 11.0 * s ) ) ;
ui . painter ( ) . rect_stroke ( screen , 2.5 * s , Stroke ::new ( 1.5 , col ) , egui ::StrokeKind ::Inside ) ;
ui . painter ( ) . line_segment ( [ rect . min + vec2 ( 10.0 * s , 14.0 * s ) , rect . min + vec2 ( 10.0 * s , 17.0 * s ) ] , Stroke ::new ( 1.5 , col ) ) ;
ui . painter ( ) . line_segment ( [ rect . min + vec2 ( 6.5 * s , 17.5 * s ) , rect . min + vec2 ( 13.5 * s , 17.5 * s ) ] , Stroke ::new ( 1.5 , col ) ) ;
}
impl App {
/// Called once at start.
pub ( super ) fn computers_start ( & mut self ) {
if ! self . demo {
cloudbox ::start_background ( ) ;
}
}
// ---------- settings page ----------
pub ( super ) fn computers_page ( & mut self , ui : & mut egui ::Ui ) {
let p = self . pal ;
let run = cloudbox ::run_state ( ) ;
// Check now and then whether this AnyBot carries a newer computer.
if ! self . demo & & self . cu . update_checked . map_or ( true , | t | t . elapsed ( ) . as_secs ( ) > 30 ) {
self . cu . update_checked = Some ( Instant ::now ( ) ) ;
let flag = self . cu . update_flag . clone ( ) ;
std ::thread ::spawn ( move | | flag . store ( cloudbox ::update_available ( ) , Ordering ::SeqCst ) ) ;
}
self . cu . update_ready = self . cu . update_flag . load ( Ordering ::SeqCst ) ;
ui . spacing_mut ( ) . item_spacing . y = 0.0 ;
// ── 你的電腦 ── (title: this computer's name, with a pencil to rename it)
let label = if self . state . machine_label . trim ( ) . is_empty ( ) { crate ::remote ::default_host_name ( ) } else { self . state . machine_label . clone ( ) } ;
let renaming = self . cu . renaming_machine . clone ( ) ;
let mut rename_to : Option < Option < String > > = None ;
let mut keep_buf : Option < String > = None ;
let mut start_rename = false ;
sec_title ( ui , " 你的電腦 " , p , | ui | match renaming {
Some ( mut buf ) = > {
let r = ui . add ( egui ::TextEdit ::singleline ( & mut buf ) . desired_width ( 180.0 ) . char_limit ( 100 ) ) ;
if ! r . has_focus ( ) & & ! r . lost_focus ( ) {
r . request_focus ( ) ;
}
if r . lost_focus ( ) {
let cancel = ui . input ( | i | i . key_pressed ( Key ::Escape ) ) ;
let v = buf . trim ( ) . to_string ( ) ;
rename_to = Some ( if ! cancel & & ! v . is_empty ( ) { Some ( v ) } else { None } ) ;
} else {
keep_buf = Some ( buf ) ;
}
}
None = > {
if crate ::icons ::button ( ui , crate ::icons ::Icon ::Pencil , 14.0 , p . text3 , p . hover ) . on_hover_text ( format! ( " {} , {label} " , t ( " 重新命名這台電腦 " ) ) ) . clicked ( ) {
start_rename = true ;
}
ui . label ( RichText ::new ( & label ) . size ( FONT_SM ) . color ( p . text2 ) ) ;
}
} ) ;
if let Some ( buf ) = keep_buf {
self . cu . renaming_machine = Some ( buf ) ;
}
match rename_to {
Some ( Some ( v ) ) = > {
self . state . machine_label = v ;
self . cu . renaming_machine = None ;
self . mark ( ) ;
}
Some ( None ) = > self . cu . renaming_machine = None ,
None = > { }
}
if start_rename {
self . cu . renaming_machine = Some ( label . clone ( ) ) ;
}
sec_card ( ui , p , | ui | {
// 在這台電腦上執行: 一律允許 / 每次詢問 / 永不允許 (default 每次詢問).
let locked = ! self . managed . local . allows ( ) ;
let current = if self . state . local_never | | locked { " never " } else if self . state . local_execution { " always " } else { " ask " } ;
let name = | v : & str | {
t ( match v {
" always " = > " 一律允許 " ,
" never " = > " 永不允許 " ,
_ = > " 每次詢問 " ,
} )
} ;
let mut pick : Option < & str > = None ;
sec_row ( ui , p , " 在這台電腦上執行 " , " 讓 Bot 在你的電腦上開啟檔案並執行任務。自動審核仍會先檢查所有操作。 " , | ui | {
ui . add_enabled_ui ( ! locked , | ui | {
egui ::ComboBox ::from_id_salt ( " local-permission " ) . selected_text ( name ( current ) ) . width ( 110.0 ) . show_ui ( ui , | ui | {
for v in [ " always " , " ask " , " never " ] {
if ui . selectable_label ( current = = v , name ( v ) ) . clicked ( ) {
pick = Some ( v ) ;
}
}
} ) ;
} ) ;
} ) ;
if let Some ( v ) = pick {
self . state . local_execution = v = = " always " ;
self . state . local_never = v = = " never " ;
self . mark ( ) ;
}
if locked {
sec_note ( ui , p , " 由你的管理員設定,無法變更 " ) ;
}
// 經這台電腦轉送流量 (the box's new connections leave through this computer).
let allowed = self . managed . egress . allows ( ) & & cloudbox ::docker_host ( ) . is_empty ( ) ;
let mut via = cloudbox ::egress_via_mac ( ) & & allowed ;
let help = " 將 Bot 的電腦的網路流量改經這台電腦而非雲端轉送,例如要連公司內網或 VPN 才看得到的服務。僅對新連線生效。 " ;
let desc = if ! self . managed . egress . allows ( ) {
" 已由管理員停用 " . to_string ( )
} else if ! cloudbox ::docker_host ( ) . is_empty ( ) {
" Bot 的電腦在別的主機上時不能用 " . to_string ( )
} else if via {
match ( crate ::egress ::port ( ) , & run ) {
( _ , Run ::Asleep ) = > " 電腦休眠中,喚醒後會重新連線 " . to_string ( ) ,
( Some ( _ ) , Run ::Running ) = > format! ( " 本次已轉送 {} 個連線 " , crate ::egress ::CONNECTIONS . load ( Ordering ::Relaxed ) ) ,
_ = > " 等待 Bot 的電腦連線 " . to_string ( ) ,
}
} else {
String ::new ( )
} ;
let mut egress_err = None ;
sec_row_s ( ui , p , " 經這台電腦轉送流量 " , & desc , help , | ui | {
if ui . add_enabled ( allowed , toggle ( & mut via ) ) . changed ( ) {
if let Err ( e ) = cloudbox ::set_egress_via_mac ( via ) {
egress_err = Some ( e ) ;
}
}
} ) ;
if let Some ( e ) = egress_err {
* self . cu . note . lock ( ) . unwrap ( ) = e ;
}
// 其他電腦: every AnyBot signed in to this account (from the relay), then SSH computers.
let signed_in = ! self . state . remote . relay_url . is_empty ( ) & & ! self . state . remote . host_token . is_empty ( ) ;
if signed_in & & ! self . demo & & self . cu . peers_at . map_or ( true , | t | t . elapsed ( ) . as_secs ( ) > 20 ) {
self . cu . peers_at = Some ( Instant ::now ( ) ) ;
let ( slot , settings ) = ( self . cu . peers . clone ( ) , self . state . remote . clone ( ) ) ;
let ctx = ui . ctx ( ) . clone ( ) ;
std ::thread ::spawn ( move | | {
let me = settings . host_id . clone ( ) ;
let list : Vec < _ > = crate ::remote ::devices_online ( & settings ) . into_iter ( ) . filter ( | ( id , _ , _ ) | * id ! = me ) . collect ( ) ;
* slot . lock ( ) . unwrap ( ) = Some ( list ) ;
ctx . request_repaint ( ) ;
} ) ;
}
let peers : Vec < ( String , String , bool ) > = if self . demo { vec! [ ( " d1 " . into ( ) , " 辦公室的 Mac mini " . into ( ) , true ) , ( " d2 " . into ( ) , " 家裡的筆電 " . into ( ) , false ) ] } else { self . cu . peers . lock ( ) . unwrap ( ) . clone ( ) . unwrap_or_default ( ) } ;
let ssh = self . state . remote_computers . clone ( ) ;
let n = peers . len ( ) + ssh . len ( ) ;
let mut names : Vec < String > = peers . iter ( ) . map ( | p | p . 1. clone ( ) ) . collect ( ) ;
names . extend ( ssh . iter ( ) . map ( | c | c . name . clone ( ) ) ) ;
let others_help = if signed_in { " 在其他電腦安裝 AnyBot 並登入同一個帳號,就會自動出現在這裡。也可以用 SSH 加入。 " } else { " 登入 AnyBot 帳號(設定 → 一般 → 手機與帳號)後,同一個帳號裡的電腦會自動出現在這裡。也可以用 SSH 加入。 " } ;
let desc = if n = = 0 {
String ::new ( )
} else if n = = 1 {
match ( peers . first ( ) , ssh . first ( ) ) {
( Some ( p ) , _ ) = > format! ( " {} · {} " , p . 1 , t ( if p . 2 { " 目前在線上 " } else { " 離線 " } ) ) ,
( _ , Some ( c ) ) = > format! ( " {} · SSH " , c . name ) ,
_ = > String ::new ( ) ,
}
} else {
names . join ( " 、 " )
} ;
let open = self . cu . others_open ;
let mut flip = false ;
sec_row_s ( ui , p , " 其他電腦 " , & desc , others_help , | ui | {
let b = egui ::Button ::new ( RichText ::new ( format! ( " {n} {} " , if open { " ⌄ " } else { " › " } ) ) . size ( FONT_SM ) ) . corner_radius ( 8 ) ;
if ui . add ( b ) . clicked ( ) {
flip = true ;
}
} ) ;
if flip {
self . cu . others_open = ! open ;
}
if open {
for ( _ , name , online ) in & peers {
let status = t ( if * online { " 目前在線上 " } else { " 離線 " } ) ;
sec_row_s ( ui , p , name , & format! ( " {} · {status} " , t ( " 同一個帳號 " ) ) , " " , | ui | {
let ( r , _ ) = ui . allocate_exact_size ( vec2 ( 10.0 , 10.0 ) , egui ::Sense ::hover ( ) ) ;
ui . painter ( ) . circle_filled ( r . center ( ) , 3.0 , if * online { Color32 ::from_rgb ( 0x22 , 0xa0 , 0x55 ) } else { p . text3 } ) ;
} ) ;
}
for ( i , c ) in ssh . iter ( ) . enumerate ( ) {
let mut remove = false ;
sec_row_s ( ui , p , & c . name , & format! ( " SSH · {} " , c . ssh_target ) , " " , | ui | {
if ui . add ( egui ::Button ::new ( RichText ::new ( t ( " 移除 " ) ) . color ( p . danger ) ) . corner_radius ( 8 ) ) . on_hover_text ( format! ( " {} {} " , t ( " 移除 " ) , c . name ) ) . clicked ( ) {
remove = true ;
}
} ) ;
if remove {
self . cu . confirm_remove = Some ( i ) ;
}
}
let mut add = None ;
let taken = self . state . computer_names ( ) ;
Frame ::new ( ) . inner_margin ( Margin ::symmetric ( 14 , 8 ) ) . show ( ui , | ui | {
ui . horizontal ( | ui | {
let w = ( ui . available_width ( ) - 80.0 ) / 2.0 ;
ui . add ( egui ::TextEdit ::singleline ( & mut self . new_remote . 0 ) . hint_text ( t ( " SSH 電腦名稱,例如 工作站 " ) ) . desired_width ( w - 8.0 ) ) ;
ui . add ( egui ::TextEdit ::singleline ( & mut self . new_remote . 1 ) . hint_text ( " me@studio.local " ) . desired_width ( w - 8.0 ) ) ;
let ok = ! self . new_remote . 0. trim ( ) . is_empty ( ) & & ! self . new_remote . 1. trim ( ) . is_empty ( ) & & ! taken . contains ( & self . new_remote . 0. trim ( ) . to_string ( ) ) ;
if ui . add_enabled ( ok , secondary_button ( " 新增 " ) ) . clicked ( ) {
add = Some ( crate ::model ::RemoteComputer { name : self . new_remote . 0. trim ( ) . into ( ) , ssh_target : self . new_remote . 1. trim ( ) . into ( ) , dir : String ::new ( ) } ) ;
}
} ) ;
} ) ;
if let Some ( c ) = add {
self . state . remote_computers . push ( c ) ;
self . new_remote = Default ::default ( ) ;
self . mark ( ) ;
}
}
// This computer for the account's other AnyBots (裝置連結).
if signed_in {
let mut link = self . state . remote . device_link ;
sec_row ( ui , p , " 讓帳號裡的其他電腦使用這台 " , " 同一個帳號的其他 AnyBot( 例如放在伺服器上的) 可以看這台的畫面、在這台上做事。每個動作還是會先問你。要讓 Bot 操作別台,就在那台打開這個。要移除一台電腦,在那台登出帳號即可。 " , | ui | {
ui . add ( toggle ( & mut link ) ) ;
} ) ;
if link ! = self . state . remote . device_link {
self . state . remote . device_link = link ;
self . mark ( ) ;
}
}
} ) ;
// ── 訊息 (Mac) ──
if cfg! ( target_os = " macos " ) {
sec_title ( ui , " 訊息 " , p , | _ | { } ) ;
let mut send = self . state . messages_send ;
let mut no_ask = self . state . messages_no_ask ;
sec_card ( ui , p , | ui | {
sec_row ( ui , p , " 傳送訊息 " , " 允許 Bot 以你的身分從這台 Mac 上的「訊息」傳送訊息 " , | ui | {
ui . add ( toggle ( & mut send ) ) ;
} ) ;
if send {
sec_row ( ui , p , " 無需詢問直接傳送 " , " 每則訊息都跳過核准卡片 " , | ui | {
ui . add ( toggle ( & mut no_ask ) ) ;
} ) ;
}
} ) ;
if send ! = self . state . messages_send | | no_ask ! = self . state . messages_no_ask {
self . state . messages_send = send ;
self . state . messages_no_ask = no_ask ;
self . mark ( ) ;
}
}
// ── AnyBot Computer Use: seeing and using this computer's screen ──
self . mac_permissions_card ( ui ) ;
2026-11-04 11:30:32 +00:00
// ── Bot 的電腦 ── (on a screen of a brain, it is the brain's)
if self . mir . on {
sec_title ( ui , " Bot 的電腦 " , p , | _ | { } ) ;
sec_card ( ui , p , | ui | {
let name = self . state . remote . brain_name . clone ( ) ;
2026-11-05 08:47:28 +00:00
let mut view = false ;
sec_row ( ui , p , " 在大腦上 " , & format! ( " 每個 Bot 自己的電腦( 例如「Master 的電腦」)在大腦「 {name} 」上,要在那台管理;這裡可以看它的畫面。這台電腦是大腦的手腳:上面的權限決定 Bot 能不能看這台的畫面、操作這台。 " ) , | ui | {
view = ui . add ( primary_button ( " 開啟電腦 " , p ) ) . clicked ( ) ;
} ) ;
if view {
self . open_viewer ( ) ;
}
2026-11-04 11:30:32 +00:00
} ) ;
} else {
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
sec_title ( ui , " Bot 的電腦 " , p , | ui | status_pill ( ui , & run , p ) ) ;
#[ derive(PartialEq) ]
enum Act {
None ,
Recheck ,
Download ,
Docker ,
Browser ,
Sleep ,
Wake ,
View ,
UpdateClick ,
Reset ,
Snapshot ,
AskClean ,
Disk ,
}
let mut act = Act ::None ;
let armed = self . cu . arm_update . filter ( | t | t . elapsed ( ) . as_secs_f32 ( ) < 3.0 ) . is_some ( ) ;
if armed {
ui . ctx ( ) . request_repaint_after ( std ::time ::Duration ::from_millis ( 300 ) ) ;
}
let busy_rebuild = matches! ( run , Run ::Rebuilding ( .. ) ) ;
let update_ready = self . cu . update_ready ;
let working : Vec < String > = self . streams . keys ( ) . filter_map ( | id | self . state . bot ( * id ) . map ( | b | b . name . clone ( ) ) ) . collect ( ) ;
let mut on = cloudbox ::auto_update_at ( ) . is_some ( ) ;
let mut at = cloudbox ::auto_update_at ( ) . unwrap_or_else ( | | self . cu . schedule_edit . clone ( ) ) ;
if at . is_empty ( ) {
at = " 03:00 " . into ( ) ;
}
let mut schedule_changed = false ;
let disk = self . cu . disk . lock ( ) . unwrap ( ) . clone ( ) ;
let note = self . cu . note . lock ( ) . unwrap ( ) . clone ( ) ;
sec_card ( ui , p , | ui | {
match & run {
Run ::NoDocker ( why ) = > {
sec_row_s ( ui , p , " Bot 的電腦 " , " 還沒有 Docker " , & format! ( " {why} \n 在那之前, Bot 的電腦是一個封閉的資料夾(沒有桌面)。 " ) , | ui | {
if ui . add ( secondary_button ( " 重新檢查 " ) ) . clicked ( ) {
act = Act ::Recheck ;
}
if cfg! ( target_os = " macos " ) {
if ui . add ( primary_button ( " 下載 OrbStack " , p ) ) . clicked ( ) {
act = Act ::Download ;
}
} else if ui . add ( primary_button ( " 安裝 Docker 的說明 " , p ) ) . clicked ( ) {
act = Act ::Docker ;
}
} ) ;
return ;
}
Run ::Rebuilding ( phase , detail ) = > {
let steps : & [ & str ] = if matches! ( phase . as_str ( ) , " backing-up " | " moving " ) { & [ " backing-up " , " creating " , " moving " ] } else { & [ " cleaning-up " , " wiping " , " creating " , " restoring " ] } ;
let at = steps . iter ( ) . position ( | s | s = = phase ) . unwrap_or ( 0 ) ;
Frame ::new ( ) . inner_margin ( Margin ::symmetric ( 14 , 10 ) ) . show ( ui , | ui | {
ui . set_width ( ui . available_width ( ) ) ;
ui . horizontal ( | ui | {
for ( i , s ) in steps . iter ( ) . enumerate ( ) {
let done = i < at ;
let on = i = = at ;
let col = if done | | on { p . accent } else { p . text3 } ;
let icon = if done { crate ::icons ::Icon ::CircleCheck } else if on { crate ::icons ::Icon ::Loader } else { crate ::icons ::Icon ::Minus } ;
crate ::icons ::label ( ui , icon , cloudbox ::phase_label ( s ) , FONT_XS , col ) ;
if i + 1 < steps . len ( ) {
ui . label ( RichText ::new ( " — " ) . size ( FONT_XS ) . color ( p . text3 ) ) ;
}
}
} ) ;
ui . add_space ( 6.0 ) ;
ui . add ( egui ::ProgressBar ::new ( ( at as f32 + 0.5 ) / steps . len ( ) as f32 ) . desired_height ( 6.0 ) . fill ( p . accent ) ) ;
ui . add_space ( 4.0 ) ;
hint ( ui , format! ( " {detail} …(開始後就不能取消) " ) , p ) ;
} ) ;
}
_ = > {
// The screen: AnyBot has no per-bot overview, so it opens from here.
sec_row ( ui , p , " Bot 的電腦 " , " 一台 Linux 電腦,你所有的 Bot 共用,每個 Bot 有自己的資料夾。閒置一陣子會自動休眠,需要時自動喚醒。 " , | ui | {
if ui . add ( primary_button ( " 開啟電腦 " , p ) ) . on_hover_text ( t ( " 看 Bot 的電腦的螢幕,也可以取得控制權自己操作 " ) ) . clicked ( ) {
act = Act ::View ;
}
match run {
Run ::Running = > {
if ui . add ( secondary_button ( " 休眠 " ) ) . clicked ( ) {
act = Act ::Sleep ;
}
}
_ = > {
if ui . add ( secondary_button ( " 喚醒電腦 " ) ) . clicked ( ) {
act = Act ::Wake ;
}
}
}
if ui . add ( secondary_button ( " 在瀏覽器開啟 " ) ) . on_hover_text ( t ( " 用 noVNC 全螢幕觀看 " ) ) . clicked ( ) {
act = Act ::Browser ;
}
} ) ;
}
}
// 更新 Bot 的電腦: two clicks (the first arms it for 3 s).
sec_row ( ui , p , " 更新 Bot 的電腦 " , " 更新你的 Bot 共用的電腦。你的檔案和登入資訊會保留,但已安裝的應用程式和套件會被移除。所有 Bot 會一起更新。 " , | ui | {
let b = if armed {
egui ::Button ::new ( RichText ::new ( t ( " 再點選一次以確認 " ) ) . color ( p . danger ) ) . fill ( p . danger . gamma_multiply ( 0.14 ) ) . corner_radius ( 8 ) . min_size ( vec2 ( 64.0 , 30.0 ) )
} else {
secondary_button ( " 更新 " )
} ;
if ui . add_enabled ( update_ready & & ! busy_rebuild , b ) . clicked ( ) {
act = Act ::UpdateClick ;
}
} ) ;
// The status line under it.
let ( icon , text , accent ) = if ! update_ready {
( crate ::icons ::Icon ::CircleCheck , t ( " 你的電腦已是最新版本 " ) , true )
} else if ! working . is_empty ( ) {
let names = if working . len ( ) > 3 { format! ( " {} 、另外 {} 位 " , working [ .. 3 ] . join ( " 、 " ) , working . len ( ) - 3 ) } else { working . join ( " 、 " ) } ;
( crate ::icons ::Icon ::Clock , format! ( " 更新會停止 {names} " ) , false )
} else {
( crate ::icons ::Icon ::Update , t ( " 有新版的 Bot 的電腦可以更新 " ) , true )
} ;
Frame ::new ( ) . inner_margin ( Margin { left : 14 , right : 14 , top : 0 , bottom : 8 } ) . show ( ui , | ui | {
let fill = if accent { p . accent . gamma_multiply ( 0.09 ) } else { p . hover } ;
let stroke = if accent { Stroke ::new ( 0.5 , p . accent . gamma_multiply ( 0.6 ) ) } else { Stroke ::NONE } ;
Frame ::new ( ) . fill ( fill ) . stroke ( stroke ) . corner_radius ( 8 ) . inner_margin ( Margin { left : 12 , right : 8 , top : 8 , bottom : 8 } ) . show ( ui , | ui | {
ui . set_width ( ui . available_width ( ) ) ;
crate ::icons ::label ( ui , icon , & text , FONT_SM , if accent { p . accent } else { p . text2 } ) ;
} ) ;
} ) ;
// 自動更新 (at a local time each day, an hour to catch up).
let desc = if on { format! ( " 每天 {} " , at . trim ( ) ) } else { String ::new ( ) } ;
sec_row_s ( ui , p , " 自動更新 " , & desc , " 有新映像檔可用時,電腦會在你選的時間自動更新;錯過的話一小時內還會補做。開始後不能取消;有 Bot 在用電腦時會等它做完。 " , | ui | {
let changed = ui . add ( toggle ( & mut on ) ) . changed ( ) ;
let r = ui . add ( egui ::TextEdit ::singleline ( & mut at ) . desired_width ( 56.0 ) . char_limit ( 5 ) . font ( egui ::TextStyle ::Monospace ) ) ;
if changed | | r . lost_focus ( ) {
schedule_changed = true ;
}
} ) ;
// 重設 Bot 的電腦: from the last snapshot (red, asks first).
let snap = cloudbox ::snapshot_time ( ) . map ( | t | t . format ( " %m/%d %H:%M " ) . to_string ( ) ) ;
let desc = match & snap { Some ( t ) = > format! ( " 上次快照: {t} " ) , None = > t ( " 還沒有快照 " ) } ;
sec_row_s ( ui , p , " 重設 Bot 的電腦 " , & desc , " 電腦卡住時可重新開始。它會從你上次儲存的快照重建,最近的變更可能會遺失;沒有快照時會換一台全新的電腦。「儲存快照」把 Bot 的檔案備份到這台電腦,休眠前也會自動存一份。 " , | ui | {
if ui . add_enabled ( ! busy_rebuild , danger_button ( " 重設 " , p ) ) . clicked ( ) {
act = Act ::Reset ;
}
if ui . add_enabled ( run . is_running ( ) , secondary_button ( " 儲存快照 " ) ) . on_hover_text ( t ( " 把 Bot 的檔案備份到這台電腦;休眠前也會自動存一份 " ) ) . clicked ( ) {
act = Act ::Snapshot ;
}
} ) ;
// 磁碟 (AnyBot).
sec_row ( ui , p , " 磁碟 " , " 查看 Bot 的電腦用了多少空間,或請主要 Bot 提出清理方案(等你核准才刪) " , | ui | {
if ui . add ( secondary_button ( " 請 Bot 提出清理方案 " ) ) . clicked ( ) {
act = Act ::AskClean ;
}
if ui . add ( secondary_button ( " 檢查用量 " ) ) . clicked ( ) {
act = Act ::Disk ;
}
} ) ;
if let Some ( d ) = & disk {
Frame ::new ( ) . inner_margin ( Margin ::symmetric ( 14 , 6 ) ) . show ( ui , | ui | {
Frame ::new ( ) . fill ( p . hover ) . corner_radius ( 8 ) . inner_margin ( Margin ::symmetric ( 10 , 8 ) ) . show ( ui , | ui | {
ui . set_width ( ui . available_width ( ) ) ;
ui . label ( RichText ::new ( d ) . monospace ( ) . size ( 11.5 ) . color ( p . text2 ) ) ;
} ) ;
} ) ;
}
if ! note . is_empty ( ) {
sec_note ( ui , p , & note ) ;
}
} ) ;
if schedule_changed & & chrono ::NaiveTime ::parse_from_str ( at . trim ( ) , " %H:%M " ) . is_ok ( ) {
cloudbox ::set_auto_update_at ( on . then ( | | at . trim ( ) . to_string ( ) ) ) ;
}
self . cu . schedule_edit = at ;
match act {
Act ::None = > { }
Act ::Recheck = > cloudbox ::refresh ( ) ,
Act ::Download = > crate ::preview ::open_with_system ( " https://orbstack.dev/download " ) ,
Act ::Docker = > crate ::preview ::open_with_system ( " https://docs.docker.com/engine/install/ " ) ,
Act ::View = > self . open_viewer ( ) ,
Act ::Sleep = > self . box_task ( | | cloudbox ::sleep_now ( ) . map ( | _ | " 已休眠。 " . to_string ( ) ) ) ,
Act ::Wake = > self . box_task ( | | cloudbox ::ensure_running ( ) . map ( | _ | " Bot 的電腦已經開好了。 " . to_string ( ) ) ) ,
Act ::Browser = > self . box_task ( | | {
let url = cloudbox ::viewer_url ( false ) ? ;
crate ::preview ::open_with_system ( & url ) ;
Ok ( String ::new ( ) )
} ) ,
Act ::UpdateClick = > {
if armed {
self . cu . arm_update = None ;
let ids : Vec < u64 > = self . streams . keys ( ) . copied ( ) . collect ( ) ;
for id in ids {
self . stop ( id ) ;
}
* self . cu . note . lock ( ) . unwrap ( ) = match cloudbox ::rebuild ( Rebuild ::Update ) {
Ok ( ( ) ) = > t ( " 更新已開始。 " ) ,
Err ( e ) = > e ,
} ;
} else {
self . cu . arm_update = Some ( Instant ::now ( ) ) ;
}
}
Act ::Reset = > self . cu . confirm_reset = true ,
Act ::Snapshot = > self . box_task ( | | cloudbox ::save_snapshot ( ) . map ( | n | format! ( " 已儲存快照( {} )。 " , cloudbox ::human ( n ) ) ) ) ,
Act ::AskClean = > self . ask_primary_bot ( " 檢查「Bot 的電腦」的磁碟使用情況,並提出安全的清理方案供我核准。先列出用量最大的地方,不要直接刪除任何東西。 " ) ,
Act ::Disk = > {
let slot = self . cu . disk . clone ( ) ;
* slot . lock ( ) . unwrap ( ) = Some ( " 檢查中… " . into ( ) ) ;
std ::thread ::spawn ( move | | {
let r = cloudbox ::disk_report ( ) . unwrap_or_else ( | e | e ) ;
* slot . lock ( ) . unwrap ( ) = Some ( r ) ;
} ) ;
}
}
// ── 終端機與環境密鑰 ──
if ! matches! ( run , Run ::NoDocker ( _ ) ) {
sec_title ( ui , " 終端機與環境密鑰 " , p , | _ | { } ) ;
sec_card ( ui , p , | ui | {
Frame ::new ( ) . inner_margin ( Margin ::symmetric ( 14 , 8 ) ) . show ( ui , | ui | {
ui . set_width ( ui . available_width ( ) ) ;
ui . spacing_mut ( ) . item_spacing . y = 4.0 ;
ui . horizontal ( | ui | {
ui . spacing_mut ( ) . item_spacing . x = 4.0 ;
ui . label ( RichText ::new ( t ( " 給指令用的帳密 " ) ) . size ( FONT_SM ) . color ( p . text2 ) ) ;
help_mark ( ui , " 例如 CRM_API_TOKEN。Bot 執行指令時會變成環境變數;值只能寫入,不能讀出。 " , p ) ;
} ) ;
let names = cloudbox ::secret_names ( ) ;
let mut remove = None ;
for n in & names {
ui . horizontal ( | ui | {
ui . label ( RichText ::new ( n ) . monospace ( ) . size ( FONT_SM ) ) ;
ui . label ( RichText ::new ( " •••••••• " ) . size ( FONT_SM ) . color ( p . text3 ) ) ;
ui . with_layout ( Layout ::right_to_left ( Align ::Center ) , | ui | {
if ui . add ( egui ::Button ::new ( RichText ::new ( t ( " 刪除 " ) ) . size ( 12.0 ) . color ( p . danger ) ) . frame ( false ) ) . clicked ( ) {
remove = Some ( n . clone ( ) ) ;
}
if ui . add ( egui ::Button ::new ( RichText ::new ( t ( " 取代 " ) ) . size ( 12.0 ) . color ( p . text2 ) ) . frame ( false ) ) . clicked ( ) {
self . cu . new_secret = ( n . clone ( ) , String ::new ( ) ) ;
self . cu . replace_secret = Some ( n . clone ( ) ) ;
}
} ) ;
} ) ;
}
if let Some ( n ) = remove {
cloudbox ::remove_secret ( & n ) ;
}
ui . horizontal ( | ui | {
let w = ( ui . available_width ( ) - 80.0 ) / 2.0 ;
let replacing = self . cu . replace_secret . is_some ( ) ;
ui . add_enabled ( ! replacing , egui ::TextEdit ::singleline ( & mut self . cu . new_secret . 0 ) . hint_text ( t ( " 名稱,例如 CRM_API_TOKEN " ) ) . desired_width ( w - 8.0 ) . font ( egui ::TextStyle ::Monospace ) ) ;
ui . add ( egui ::TextEdit ::singleline ( & mut self . cu . new_secret . 1 ) . password ( true ) . hint_text ( t ( " 值 " ) ) . desired_width ( w - 8.0 ) ) ;
let ok = ! self . cu . new_secret . 0. trim ( ) . is_empty ( ) & & ! self . cu . new_secret . 1. is_empty ( ) ;
if ui . add_enabled ( ok , primary_button ( if replacing { " 取代 " } else { " 新增 " } , p ) ) . clicked ( ) {
match cloudbox ::set_secret ( & self . cu . new_secret . 0 , & self . cu . new_secret . 1 ) {
Ok ( ( ) ) = > {
self . cu . new_secret = Default ::default ( ) ;
self . cu . replace_secret = None ;
self . cu . secret_error . clear ( ) ;
}
Err ( e ) = > self . cu . secret_error = e ,
}
}
} ) ;
let up = self . cu . new_secret . 0. to_uppercase ( ) ;
if up ! = self . cu . new_secret . 0 {
self . cu . new_secret . 0 = up ;
}
if ! self . cu . secret_error . is_empty ( ) {
ui . label ( RichText ::new ( & self . cu . secret_error ) . size ( FONT_XS ) . color ( p . danger ) ) ;
}
} ) ;
} ) ;
// ── 在哪裡執行 ──
sec_title ( ui , " 在哪裡執行 " , p , | _ | { } ) ;
let current = cloudbox ::docker_host ( ) ;
let mut edit = self . cu . docker_host . take ( ) . unwrap_or_else ( | | current . clone ( ) ) ;
let mut apply = false ;
sec_card ( ui , p , | ui | {
sec_row ( ui , p , " Docker 主機 " , " 預設在這台電腦的 Docker 裡。也可以放到雲端或公司伺服器,例如 ssh://me@server。換位置後會在新的地方重新建立, 舊檔案可以用快照搬過去。 " , | ui | {
if ui . add_enabled ( edit . trim ( ) ! = current , primary_button ( " 套用 " , p ) ) . clicked ( ) {
apply = true ;
}
ui . add ( egui ::TextEdit ::singleline ( & mut edit ) . hint_text ( t ( " 這台電腦(預設) " ) ) . desired_width ( 180.0 ) . font ( egui ::TextStyle ::Monospace ) ) ;
} ) ;
} ) ;
if apply {
cloudbox ::set_docker_host ( edit . trim ( ) ) ;
}
self . cu . docker_host = Some ( edit ) ;
if ui . add ( egui ::Button ::new ( RichText ::new ( t ( if self . cu . show_log { " 隱藏紀錄 " } else { " 顯示紀錄 " } ) ) . size ( FONT_XS ) . color ( p . text3 ) ) . frame ( false ) ) . clicked ( ) {
self . cu . show_log = ! self . cu . show_log ;
}
if self . cu . show_log {
let lines = cloudbox ::log_lines ( ) ;
Frame ::new ( ) . fill ( p . hover ) . corner_radius ( 8 ) . inner_margin ( Margin ::symmetric ( 10 , 8 ) ) . show ( ui , | ui | {
ui . set_width ( ui . available_width ( ) ) ;
if lines . is_empty ( ) {
ui . label ( RichText ::new ( t ( " (沒有紀錄) " ) ) . size ( 11.5 ) . color ( p . text3 ) ) ;
}
for l in lines . iter ( ) . rev ( ) . take ( 30 ) {
ui . label ( RichText ::new ( l ) . monospace ( ) . size ( 11.0 ) . color ( p . text2 ) ) ;
}
} ) ;
}
}
2026-11-04 11:30:32 +00:00
}
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 . add_space ( SEC_GAP * 0.5 ) ;
ui . spacing_mut ( ) . item_spacing . y = 6.0 ;
self . teach_card ( ui ) ;
setting_card ( ui , p , | ui | self . browser_profiles_section ( ui , p ) ) ;
self . computers_dialogs ( ui . ctx ( ) ) ;
}
/// 要移除「…」嗎? and 要重設 AnyBot 的電腦嗎?
fn computers_dialogs ( & mut self , ctx : & egui ::Context ) {
let p = self . pal ;
if let Some ( i ) = self . cu . confirm_remove {
let name = self . state . remote_computers . get ( i ) . map ( | c | c . name . clone ( ) ) . unwrap_or_default ( ) ;
let mut keep = true ;
let mut remove = false ;
let close = dialog ( ctx , " remove-computer " , & format! ( " 要移除「 {name} 」嗎? " ) , 420.0 , true , p , | ui | {
dialog_body ( ui , " remove-computer-body " , 200.0 , | ui | {
ui . label ( t ( " Bot 將無法再在這台電腦上執行任務。之後可以再用 SSH 加回來。 " ) ) ;
} ) ;
dialog_footer ( ui , p , | ui | {
ui . with_layout ( Layout ::right_to_left ( Align ::Center ) , | ui | {
if ui . add ( danger_button ( " 移除 " , p ) ) . clicked ( ) {
remove = true ;
keep = false ;
}
if ui . add ( secondary_button ( " 取消 " ) ) . clicked ( ) {
keep = false ;
}
} ) ;
} ) ;
} ) ;
if remove & & i < self . state . remote_computers . len ( ) {
self . state . remote_computers . remove ( i ) ;
self . mark ( ) ;
}
if ! keep | | close {
self . cu . confirm_remove = None ;
}
}
if self . cu . confirm_reset {
let working = self . streams . len ( ) ;
let has_snap = cloudbox ::snapshot_time ( ) . is_some ( ) ;
let mut keep = true ;
let mut go = false ;
let close = dialog ( ctx , " reset-computer " , " 要重設 AnyBot 的電腦嗎? " , 460.0 , true , p , | ui | {
dialog_body ( ui , " reset-computer-body " , 320.0 , | ui | {
let w = if working = = 0 { t ( " 所有執行中的工作都將被中斷。 " ) } else { format! ( " {working} 個 Bot 正在工作,將會被中斷。 " ) } ;
ui . label ( if has_snap { format! ( " 從上次儲存的快照還原。這會關閉目前的電腦,並以最新版本啟動一台全新的電腦。 {w} " ) } else { format! ( " 還沒有快照:這會換一台全新的電腦,裡面的檔案和登入資訊都會清除。 {w} " ) } ) ;
ui . add_space ( 8.0 ) ;
Frame ::new ( ) . fill ( p . danger . gamma_multiply ( 0.08 ) ) . corner_radius ( 8 ) . inner_margin ( Margin ::same ( 10 ) ) . show ( ui , | ui | {
ui . add ( egui ::Label ::new ( RichText ::new ( t ( " 警告:尚未同步到快照的工作(包括最近的檔案)將會遺失。已安裝的應用程式和套件會被移除。 " ) ) . size ( FONT_SM ) . color ( p . danger ) ) . wrap ( ) ) ;
} ) ;
} ) ;
dialog_footer ( ui , p , | ui | {
ui . with_layout ( Layout ::right_to_left ( Align ::Center ) , | ui | {
if ui . add ( danger_button ( " 重設 AnyBot 的電腦 " , p ) ) . clicked ( ) {
go = true ;
keep = false ;
}
if ui . add ( secondary_button ( " 取消 " ) ) . clicked ( ) {
keep = false ;
}
} ) ;
} ) ;
} ) ;
if go {
let ids : Vec < u64 > = self . streams . keys ( ) . copied ( ) . collect ( ) ;
for id in ids {
self . stop ( id ) ;
}
let kind = if has_snap { Rebuild ::Restore } else { Rebuild ::Reset } ;
* self . cu . note . lock ( ) . unwrap ( ) = match cloudbox ::rebuild ( kind ) {
Ok ( ( ) ) = > t ( " 重設已開始。 " ) ,
Err ( e ) = > e ,
} ;
}
if ! keep | | close {
self . cu . confirm_reset = false ;
}
}
}
/// 「AnyBot Computer Use」: what seeing and using this computer's screen needs.
pub ( super ) fn mac_permissions_card ( & mut self , ui : & mut egui ::Ui ) {
let p = self . pal ;
if cfg! ( target_os = " linux " ) {
#[ cfg(target_os = " linux " ) ]
{
let kind = crate ::desk ::kind ( ) ;
let ( state , sub ) = match kind {
crate ::desk ::Kind ::X11 = > ( " 已授予 " , " X11 桌面:可以截圖、點擊、打字,也能錄製示範 " ) ,
crate ::desk ::Kind ::Wlr = > ( " 已授予 " , " Wayland 桌面:可以截圖、點擊、打字(錄製示範需要 Xorg 工作階段) " ) ,
crate ::desk ::Kind ::Portal = > ( " 第一次使用時詢問 " , " Wayland 桌面:第一次使用時系統會跳出視窗,打開「允許遠端操作」再按「分享」(錄製示範需要 Xorg 工作階段) " ) ,
crate ::desk ::Kind ::None if crate ::desk ::wayland ( ) = > ( " 無法使用 " , " 找不到 xdg-desktop-portal 的遠端桌面功能,請安裝 xdg-desktop-portal-gnome 或 -kde " ) ,
crate ::desk ::Kind ::None = > ( " 無法使用 " , " 這台電腦沒有顯示器 " ) ,
} ;
sec_title ( ui , " AnyBot Computer Use " , p , | _ | { } ) ;
sec_card ( ui , p , | ui | {
sec_row ( ui , p , " 看畫面和操作 " , sub , | ui | {
ui . label ( RichText ::new ( t ( state ) ) . size ( FONT_SM ) . color ( p . text2 ) ) ;
} ) ;
} ) ;
}
self . allowed_apps_section ( ui ) ;
return ;
}
if ! cfg! ( target_os = " macos " ) {
return ;
}
let see = crate ::computer ::screen_capture_ok ( ) ;
let control = crate ::axread ::trusted ( false ) ;
sec_title ( ui , " AnyBot Computer Use " , p , | _ | { } ) ;
let mut open_pane = None ;
sec_card ( ui , p , | ui | {
let state = | ok : bool | t ( if ok { " 已授予 " } else { " 未授予 " } ) ;
sec_row ( ui , p , " 螢幕錄製 " , " 讓 Bot 看到你的螢幕 " , | ui | {
ui . label ( RichText ::new ( state ( see ) ) . size ( FONT_SM ) . color ( if see { p . text2 } else { p . danger } ) ) ;
} ) ;
sec_row ( ui , p , " 輔助使用 " , " 讓 Bot 點擊和打字,也用來錄製示範 " , | ui | {
ui . label ( RichText ::new ( state ( control ) ) . size ( FONT_SM ) . color ( if control { p . text2 } else { p . danger } ) ) ;
} ) ;
if ! see | | ! control {
sec_row ( ui , p , " 授予權限 " , " 打開系統設定的面板。把 AnyBot( 或「終端機」) 加到清單裡並打開。 " , | ui | {
if ui . add ( primary_button ( " 開啟設定 " , p ) ) . clicked ( ) {
open_pane = Some ( if ! see { " Privacy_ScreenCapture " } else { " Privacy_Accessibility " } ) ;
}
} ) ;
}
} ) ;
if let Some ( pane ) = open_pane {
let _ = std ::process ::Command ::new ( " open " ) . arg ( format! ( " x-apple.systempreferences:com.apple.preference.security? {pane} " ) ) . spawn ( ) ;
}
self . allowed_apps_section ( ui ) ;
}
/// 一律允許的應用程式: apps bots may use without asking (per bot), with a remove button.
fn allowed_apps_section ( & mut self , ui : & mut egui ::Ui ) {
let p = self . pal ;
let apps : Vec < ( u64 , String , String ) > = self
. state
. bots
. iter ( )
. flat_map ( | b | b . allow_rules . iter ( ) . filter_map ( | g | g . strip_prefix ( " app: " ) ) . map ( | a | ( b . id , b . name . clone ( ) , a . to_string ( ) ) ) . collect ::< Vec < _ > > ( ) )
. collect ( ) ;
if apps . is_empty ( ) {
return ;
}
let open = self . cu . apps_open ;
let mut remove = None ;
let mut flip = false ;
sec_card ( ui , p , | ui | {
sec_row ( ui , p , " 一律允許的應用程式 " , " Bot 無需事先詢問即可使用這些應用程式 " , | ui | {
let b = egui ::Button ::new ( RichText ::new ( format! ( " {} 個應用程式 {} " , apps . len ( ) , if open { " ⌄ " } else { " › " } ) ) . size ( FONT_SM ) ) . corner_radius ( 8 ) ;
if ui . add ( b ) . clicked ( ) {
flip = true ;
}
} ) ;
if open {
for ( id , bot , app ) in & apps {
sec_row_s ( ui , p , app , & format! ( " {bot} 可以使用 " ) , " " , | ui | {
if crate ::icons ::button ( ui , crate ::icons ::Icon ::Trash , 14.0 , p . text3 , p . hover ) . on_hover_text ( format! ( " {} {app} " , t ( " 移除 " ) ) ) . clicked ( ) {
remove = Some ( ( * id , format! ( " app: {app} " ) ) ) ;
}
} ) ;
}
}
} ) ;
if flip {
self . cu . apps_open = ! open ;
}
if let Some ( ( id , g ) ) = remove {
if let Some ( b ) = self . state . bot_mut ( id ) {
b . allow_rules . retain ( | x | * x ! = g ) ;
}
self . mark ( ) ;
}
}
fn box_task ( & mut self , f : impl FnOnce ( ) -> Result < String , String > + Send + 'static ) {
let note = self . cu . note . clone ( ) ;
* note . lock ( ) . unwrap ( ) = " 處理中… " . into ( ) ;
std ::thread ::spawn ( move | | {
let r = f ( ) ;
* note . lock ( ) . unwrap ( ) = match r {
Ok ( s ) = > s ,
Err ( e ) = > e ,
} ;
cloudbox ::refresh ( ) ;
} ) ;
}
fn ask_primary_bot ( & mut self , text : & str ) {
let id = self . state . primary_bot ;
self . drafts . insert ( id , text . to_string ( ) ) ;
self . state . selected = id ;
self . show_settings = false ;
}
/// The confirm dialog for a rebuild (the original's wording).
pub ( super ) fn rebuild_dialog ( & mut self , ctx : & egui ::Context ) {
let Some ( kind ) = self . cu . confirm else { return } ;
let p = self . pal ;
let mut go = false ;
let mut close = dialog ( ctx , " box-rebuild " , & format! ( " {} ? " , kind . title ( ) ) , 420.0 , true , p , | ui | {
dialog_body ( ui , " box-rebuild-body " , 300.0 , | ui | {
ui . add ( egui ::Label ::new ( RichText ::new ( kind . warning ( ) ) . size ( FONT_BASE ) ) . wrap ( ) ) ;
if kind = = Rebuild ::Update & & cloudbox ::is_busy ( ) {
ui . add_space ( 6.0 ) ;
ui . label ( RichText ::new ( t ( " 有 Bot 正在用這台電腦,等它做完再重建。 " ) ) . size ( FONT_XS ) . color ( p . danger ) ) ;
}
} ) ;
dialog_footer ( ui , p , | ui | {
ui . with_layout ( Layout ::right_to_left ( Align ::Center ) , | ui | {
let btn = if kind = = Rebuild ::Reset { danger_button ( kind . title ( ) , p ) } else { primary_button ( kind . title ( ) , p ) } ;
if ui . add ( btn ) . clicked ( ) {
go = true ;
}
if ui . add ( secondary_button ( " 取消 " ) ) . clicked ( ) {
go = false ;
self . cu . confirm = None ;
}
} ) ;
} ) ;
} ) ;
if go {
if kind = = Rebuild ::Update {
// 更新會先停掉正在工作的 Bot, 聊天會保留.
let ids : Vec < u64 > = self . streams . keys ( ) . copied ( ) . collect ( ) ;
for id in ids {
self . stop ( id ) ;
}
}
* self . cu . note . lock ( ) . unwrap ( ) = match cloudbox ::rebuild ( kind ) {
Ok ( ( ) ) = > format! ( " {} 已開始。 " , kind . title ( ) ) ,
Err ( e ) = > e ,
} ;
close = true ;
}
if close {
self . cu . confirm = None ;
}
}
// ---------- the live screen ----------
/// The box's screen as a small picture, refreshed every few seconds while it is shown (taking
/// it never wakes the box; asleep it shows the last picture).
pub ( super ) fn box_thumbnail ( & mut self , ctx : & egui ::Context ) -> Option < ( egui ::TextureId , egui ::Vec2 ) > {
if let Some ( img ) = self . cu . thumb_img . lock ( ) . unwrap ( ) . take ( ) {
match & mut self . cu . thumb {
Some ( t ) = > t . set ( img , egui ::TextureOptions ::LINEAR ) ,
None = > self . cu . thumb = Some ( ctx . load_texture ( " box-thumb " , img , egui ::TextureOptions ::LINEAR ) ) ,
}
}
2026-11-05 08:47:28 +00:00
// On a screen of a brain the bots' computer is the brain's: its picture comes from there.
let brain = self . mir . on . then ( | | self . state . remote . clone ( ) ) ;
let running = if brain . is_some ( ) { self . mir . box_state [ " running " ] . as_bool ( ) . unwrap_or ( false ) } else { cloudbox ::run_state ( ) . is_running ( ) } ;
let every = if running { 3 } else { 30 } ;
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 . cu . thumb_at . map ( | t | t . elapsed ( ) . as_secs ( ) < every ) . unwrap_or ( false ) & & ! self . cu . thumb_busy . swap ( true , Ordering ::SeqCst ) {
self . cu . thumb_at = Some ( Instant ::now ( ) ) ;
let ( slot , busy , c ) = ( self . cu . thumb_img . clone ( ) , self . cu . thumb_busy . clone ( ) , ctx . clone ( ) ) ;
std ::thread ::spawn ( move | | {
2026-11-05 08:47:28 +00:00
let img = match & brain {
Some ( s ) = > crate ::remote ::sync_screen ( s , true ) . ok ( ) . and_then ( | b | image ::load_from_memory ( & b ) . ok ( ) ) . map ( | i | i . to_rgba8 ( ) ) ,
None = > cloudbox ::thumbnail ( 320 ) ,
} ;
if let Some ( img ) = img {
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 size = [ img . width ( ) as usize , img . height ( ) as usize ] ;
* slot . lock ( ) . unwrap ( ) = Some ( egui ::ColorImage ::from_rgba_unmultiplied ( size , img . as_raw ( ) ) ) ;
c . request_repaint ( ) ;
}
busy . store ( false , Ordering ::SeqCst ) ;
} ) ;
}
ctx . request_repaint_after ( std ::time ::Duration ::from_secs ( every ) ) ;
self . cu . thumb . as_ref ( ) . map ( | t | ( t . id ( ) , t . size_vec2 ( ) ) )
}
pub ( super ) fn open_viewer ( & mut self ) {
self . cu . viewer_open = true ;
self . cu . fit = true ;
self . show_settings = false ;
2026-11-05 08:47:28 +00:00
// A screen of a brain watches the brain's computer (where its bots really work).
if self . mir . on {
let s = self . state . remote . clone ( ) ;
self . cu . remote = Some ( s . clone ( ) ) ;
if let Some ( stop ) = self . cu . worker . take ( ) {
stop . store ( true , Ordering ::SeqCst ) ;
}
self . cu . texture = None ;
std ::thread ::spawn ( move | | {
let _ = crate ::remote ::sync_post ( & s , " screen " , & serde_json ::json! ( { " kind " : " viewer " , " on " : true } ) ) ;
let _ = crate ::remote ::sync_post ( & s , " screen " , & serde_json ::json! ( { " kind " : " wake " } ) ) ;
} ) ;
return ;
}
if self . cu . remote . take ( ) . is_some ( ) {
if let Some ( stop ) = self . cu . worker . take ( ) {
stop . store ( true , Ordering ::SeqCst ) ;
}
self . cu . texture = None ;
}
cloudbox ::set_viewer_open ( 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
if ! cloudbox ::run_state ( ) . is_running ( ) & & ! self . demo {
self . box_task ( | | cloudbox ::ensure_running ( ) . map ( | _ | String ::new ( ) ) ) ;
}
}
fn close_viewer ( & mut self ) {
self . cu . viewer_open = false ;
self . cu . control = false ;
2026-11-05 08:47:28 +00:00
if let Some ( s ) = self . cu . remote . clone ( ) {
std ::thread ::spawn ( move | | {
let _ = crate ::remote ::sync_post ( & s , " screen " , & serde_json ::json! ( { " kind " : " hold " , " on " : false } ) ) ;
let _ = crate ::remote ::sync_post ( & s , " screen " , & serde_json ::json! ( { " kind " : " viewer " , " on " : false } ) ) ;
} ) ;
} else {
cloudbox ::HELD_BY_PERSON . store ( false , Ordering ::SeqCst ) ;
cloudbox ::set_viewer_open ( false ) ;
}
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 let Some ( stop ) = self . cu . worker . take ( ) {
stop . store ( true , Ordering ::SeqCst ) ;
}
self . cu . input_tx = None ;
}
fn ensure_viewer_threads ( & mut self , ctx : & egui ::Context ) {
if self . cu . worker . is_some ( ) | | ( self . demo & & ! self . cu . demo_live ) {
return ;
}
let stop = Arc ::new ( AtomicBool ::new ( false ) ) ;
self . cu . worker = Some ( stop . clone ( ) ) ;
let slot = self . cu . frame . clone ( ) ;
let c = ctx . clone ( ) ;
let s2 = stop . clone ( ) ;
2026-11-05 08:47:28 +00:00
let brain = self . cu . remote . clone ( ) ;
if let Some ( s ) = brain . clone ( ) {
// The brain's computer: pictures and the person's input go through the account server.
let s_frames = s . clone ( ) ;
std ::thread ::spawn ( move | | {
let s = s_frames ;
let mut seq = 0 u64 ;
while ! s2 . load ( Ordering ::SeqCst ) {
match crate ::remote ::sync_screen ( & s , false ) . ok ( ) . and_then ( | b | image ::load_from_memory ( & b ) . ok ( ) ) {
Some ( img ) = > {
let img = img . to_rgba8 ( ) ;
seq + = 1 ;
let size = [ img . width ( ) as usize , img . height ( ) as usize ] ;
* slot . lock ( ) . unwrap ( ) = Some ( ( seq , egui ::ColorImage ::from_rgba_unmultiplied ( size , img . as_raw ( ) ) ) ) ;
c . request_repaint ( ) ;
std ::thread ::sleep ( std ::time ::Duration ::from_millis ( REMOTE_FRAME_MS ) ) ;
}
None = > {
std ::thread ::sleep ( std ::time ::Duration ::from_millis ( 1500 ) ) ;
c . request_repaint ( ) ;
}
}
}
} ) ;
let ( tx , rx ) = channel ::< Input > ( ) ;
self . cu . input_tx = Some ( tx ) ;
std ::thread ::spawn ( move | | {
let mut last_move = Instant ::now ( ) ;
while let Ok ( i ) = rx . recv ( ) {
let v = match i {
Input ::Click ( x , y , b , n ) = > serde_json ::json! ( { " kind " : " click " , " x " : x , " y " : y , " button " : b , " count " : n } ) ,
// Hovering sends many moves; a few a second are enough over the network.
Input ::Move ( x , y ) = > {
if last_move . elapsed ( ) . as_millis ( ) < 250 {
continue ;
}
last_move = Instant ::now ( ) ;
serde_json ::json! ( { " kind " : " move " , " x " : x , " y " : y } )
}
Input ::Scroll ( x , y , down , n ) = > serde_json ::json! ( { " kind " : " scroll " , " x " : x , " y " : y , " down " : down , " steps " : n } ) ,
Input ::Key ( k ) = > serde_json ::json! ( { " kind " : " key " , " key " : k } ) ,
Input ::Text ( t ) = > serde_json ::json! ( { " kind " : " text " , " text " : t } ) ,
} ;
let _ = crate ::remote ::sync_post ( & s , " screen " , & v ) ;
}
} ) ;
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
std ::thread ::spawn ( move | | {
let mut seq = 0 u64 ;
while ! s2 . load ( Ordering ::SeqCst ) {
let got = cloudbox ::frame ( ) ;
if let Some ( img ) = got {
seq + = 1 ;
let size = [ img . width ( ) as usize , img . height ( ) as usize ] ;
let ci = egui ::ColorImage ::from_rgba_unmultiplied ( size , img . as_raw ( ) ) ;
* slot . lock ( ) . unwrap ( ) = Some ( ( seq , ci ) ) ;
c . request_repaint ( ) ;
std ::thread ::sleep ( std ::time ::Duration ::from_millis ( VIEWER_FRAME_MS ) ) ;
} else {
std ::thread ::sleep ( std ::time ::Duration ::from_millis ( 1000 ) ) ;
c . request_repaint ( ) ;
}
}
} ) ;
let ( tx , rx ) = channel ::< Input > ( ) ;
self . cu . input_tx = Some ( tx ) ;
std ::thread ::spawn ( move | | {
while let Ok ( i ) = rx . recv ( ) {
match i {
Input ::Click ( x , y , b , n ) = > cloudbox ::viewer_click ( x , y , b , n ) ,
Input ::Move ( x , y ) = > cloudbox ::viewer_move ( x , y ) ,
Input ::Scroll ( x , y , down , n ) = > cloudbox ::viewer_scroll ( x , y , down , n ) ,
Input ::Key ( k ) = > cloudbox ::viewer_key ( & k ) ,
Input ::Text ( t ) = > cloudbox ::viewer_text ( & t ) ,
}
}
} ) ;
}
pub ( super ) fn viewer_window ( & mut self , ctx : & egui ::Context ) {
if ! self . cu . viewer_open {
return ;
}
self . ensure_viewer_threads ( ctx ) ;
let p = self . pal ;
if let Some ( ( seq , img ) ) = self . cu . frame . lock ( ) . unwrap ( ) . take ( ) {
if seq ! = self . cu . shown_seq {
self . cu . shown_seq = seq ;
self . cu . frame_size = img . size ;
match & mut self . cu . texture {
Some ( t ) = > t . set ( img , egui ::TextureOptions ::LINEAR ) ,
None = > self . cu . texture = Some ( ctx . load_texture ( " box-screen " , img , egui ::TextureOptions ::LINEAR ) ) ,
}
}
}
2026-11-05 08:47:28 +00:00
let brain = self . cu . remote . is_some ( ) ;
// The brain's computer reports its state with the brain's answers.
let run = if brain {
match ( self . mir . box_state [ " running " ] . as_bool ( ) . unwrap_or ( false ) , self . mir . box_state [ " available " ] . as_bool ( ) . unwrap_or ( true ) ) {
( true , _ ) = > Run ::Running ,
( false , false ) = > Run ::NoDocker ( " 大腦那台沒有 Docker " . into ( ) ) ,
_ = > Run ::Asleep ,
}
} else {
cloudbox ::run_state ( )
} ;
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 screen = ctx . content_rect ( ) ;
let open = true ;
let default = vec2 ( ( screen . width ( ) * 0.62 ) . min ( 900.0 ) , ( screen . width ( ) * 0.62 ) . min ( 900.0 ) * 0.625 + 52.0 ) ;
2026-11-05 08:47:28 +00:00
let busy = if brain { self . mir . box_state [ " busy " ] . as_bool ( ) . unwrap_or ( false ) } else { cloudbox ::is_busy ( ) } ;
let title = self . state . bot ( self . state . selected ) . map ( | b | crate ::model ::own_computer_name ( & b . name ) ) . unwrap_or_else ( | | crate ::model ::BOT_COMPUTER_NAME . to_string ( ) ) ;
let title = if brain { format! ( " {title} (在大腦「 {} 」上) " , self . state . remote . brain_name ) } else { title } ;
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 mut close_clicked = false ;
2026-11-05 08:47:28 +00:00
egui ::Window ::new ( " box-viewer " )
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
. id ( egui ::Id ::unique ( " box-viewer " ) )
. title_bar ( false )
. default_size ( default )
. default_pos ( screen . center ( ) - default / 2.0 )
. min_size ( vec2 ( 420.0 , 300.0 ) )
. resizable ( true )
. collapsible ( false )
. frame (
Frame ::new ( )
. fill ( p . elevated )
. corner_radius ( 14 )
. inner_margin ( Margin ::symmetric ( 14 , 12 ) )
. stroke ( Stroke ::new ( 1.0 , p . border ) )
. shadow ( egui ::epaint ::Shadow { offset : [ 0 , 14 ] , blur : 40 , spread : 0 , color : Color32 ::from_black_alpha ( 60 ) } ) ,
)
. show ( ctx , | ui | {
ui . horizontal ( | ui | {
monitor_icon ( ui , 20.0 , p . text ) ;
2026-11-05 08:47:28 +00:00
ui . label ( RichText ::new ( & title ) . strong ( ) . size ( FONT_LG ) ) ;
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
status_pill ( ui , & run , p ) ;
if busy & & ! self . cu . control {
crate ::icons ::label ( ui , crate ::icons ::Icon ::Bot , & t ( " Bot 正在操作 " ) , FONT_XS , p . accent ) ;
}
if self . cu . control {
crate ::icons ::label ( ui , crate ::icons ::Icon ::Users , & t ( " 你正在操作, Bot 已暫停 " ) , FONT_XS , p . accent ) ;
}
ui . with_layout ( Layout ::right_to_left ( Align ::Center ) , | ui | {
let ( rect , r ) = ui . allocate_exact_size ( vec2 ( 28.0 , 28.0 ) , egui ::Sense ::click ( ) ) ;
if r . hovered ( ) {
ui . painter ( ) . rect_filled ( rect , 8 , p . hover ) ;
}
let c = rect . center ( ) ;
let st = 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 ) ] , st ) ;
ui . painter ( ) . line_segment ( [ c + vec2 ( - 5.0 , 5.0 ) , c + vec2 ( 5.0 , - 5.0 ) ] , st ) ;
if r . on_hover_text ( t ( " 關閉 " ) ) . clicked ( ) {
close_clicked = true ;
}
ui . add_space ( 4.0 ) ;
2026-11-05 08:47:28 +00:00
if ! brain & & ui . add ( secondary_button ( " 全螢幕 " ) ) . on_hover_text ( t ( " 在瀏覽器用 noVNC 全螢幕開啟 " ) ) . clicked ( ) {
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 control = self . cu . control ;
self . box_task ( move | | {
let url = cloudbox ::viewer_url ( control ) ? ;
crate ::preview ::open_with_system ( & url ) ;
Ok ( String ::new ( ) )
} ) ;
}
let fit_label = if self . cu . fit { " 實際大小 " } else { " 調整至視窗大小 " } ;
if ui . add ( secondary_button ( fit_label ) ) . clicked ( ) {
self . cu . fit = ! self . cu . fit ;
}
let ( label , fill , col ) = if self . cu . control { ( " 交還給 Bot " , p . accent , Color32 ::WHITE ) } else { ( " 取得控制權 " , p . primary , p . on_primary ) } ;
let btn = egui ::Button ::new ( RichText ::new ( label ) . strong ( ) . color ( col ) ) . fill ( fill ) . corner_radius ( 8 ) . min_size ( vec2 ( 96.0 , 30.0 ) ) ;
if ui . add_enabled ( run . is_running ( ) , btn ) . on_hover_text ( if self . cu . control { " Bot 可以繼續操作 " } else { " 你自己點擊和輸入; Bot 的操作會先暫停 " } ) . clicked ( ) {
self . cu . control = ! self . cu . control ;
2026-11-05 08:47:28 +00:00
match self . cu . remote . clone ( ) {
Some ( s ) = > {
let on = self . cu . control ;
std ::thread ::spawn ( move | | {
let _ = crate ::remote ::sync_post ( & s , " screen " , & serde_json ::json! ( { " kind " : " hold " , " on " : on } ) ) ;
} ) ;
}
None = > cloudbox ::HELD_BY_PERSON . store ( self . cu . control , Ordering ::SeqCst ) ,
}
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 . add_space ( 6.0 ) ;
let avail = ui . available_size ( ) ;
match ( self . cu . texture . as_ref ( ) . map ( | t | t . id ( ) ) , run . is_running ( ) ) {
( Some ( tex_id ) , true ) = > {
let [ fw , fh ] = self . cu . frame_size ;
let ( fw , fh ) = ( fw . max ( 1 ) as f32 , fh . max ( 1 ) as f32 ) ;
let size = if self . cu . fit {
let k = ( avail . x / fw ) . min ( ( avail . y . max ( 120.0 ) ) / fh ) ;
vec2 ( fw * k , fh * k )
} else {
vec2 ( fw , fh )
} ;
egui ::ScrollArea ::both ( ) . auto_shrink ( [ false , false ] ) . show ( ui , | ui | {
let sense = if self . cu . control { egui ::Sense ::click_and_drag ( ) } else { egui ::Sense ::hover ( ) } ;
let resp = ui . add ( egui ::Image ::new ( ( tex_id , size ) ) . corner_radius ( 8 ) . sense ( sense ) ) ;
if self . cu . control {
ui . painter ( ) . rect_stroke ( resp . rect , 8 , Stroke ::new ( 2.0 , p . accent ) , egui ::StrokeKind ::Outside ) ;
self . forward_input ( ui , & resp , ( fw , fh ) ) ;
}
} ) ;
}
_ = > {
let ( rect , _ ) = ui . allocate_exact_size ( vec2 ( avail . x , avail . y . max ( 200.0 ) ) , egui ::Sense ::hover ( ) ) ;
ui . painter ( ) . rect_filled ( rect , 8 , Color32 ::from_rgb ( 0x2b , 0x2f , 0x36 ) ) ;
let text = match & run {
Run ::Running = > " 正在連線… " . to_string ( ) ,
Run ::NoDocker ( why ) = > why . clone ( ) ,
Run ::Failed ( e ) = > format! ( " 發生問題: {e} " ) ,
other = > format! ( " {} … " , other . label ( ) ) ,
} ;
ui . painter ( ) . text ( rect . center ( ) , egui ::Align2 ::CENTER_CENTER , text , egui ::FontId ::proportional ( FONT_BASE ) , Color32 ::from_gray ( 220 ) ) ;
if matches! ( run , Run ::Asleep | Run ::Absent ) {
let b = egui ::Rect ::from_center_size ( rect . center ( ) + vec2 ( 0.0 , 34.0 ) , vec2 ( 90.0 , 30.0 ) ) ;
if ui . put ( b , primary_button ( " 喚醒 " , p ) ) . clicked ( ) {
2026-11-05 08:47:28 +00:00
match self . cu . remote . clone ( ) {
Some ( s ) = > {
std ::thread ::spawn ( move | | {
let _ = crate ::remote ::sync_post ( & s , " screen " , & serde_json ::json! ( { " kind " : " wake " } ) ) ;
} ) ;
}
None = > self . box_task ( | | cloudbox ::ensure_running ( ) . map ( | _ | 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
}
}
}
}
} ) ;
if close_clicked | | ! open {
self . close_viewer ( ) ;
}
ctx . request_repaint_after ( std ::time ::Duration ::from_millis ( VIEWER_FRAME_MS ) ) ;
}
/// Clicks, scrolls and keys on the picture go to the box while the person has control.
fn forward_input ( & mut self , ui : & mut egui ::Ui , resp : & egui ::Response , frame : ( f32 , f32 ) ) {
let Some ( tx ) = self . cu . input_tx . clone ( ) else { return } ;
let to_box = | pos : egui ::Pos2 | -> ( i64 , i64 ) {
let r = resp . rect ;
( ( ( pos . x - r . left ( ) ) / r . width ( ) * frame . 0 ) . round ( ) as i64 , ( ( pos . y - r . top ( ) ) / r . height ( ) * frame . 1 ) . round ( ) as i64 )
} ;
if resp . clicked ( ) | | resp . secondary_clicked ( ) | | resp . double_clicked ( ) {
resp . request_focus ( ) ;
}
if let Some ( pos ) = resp . interact_pointer_pos ( ) . or ( resp . hover_pos ( ) ) {
let ( x , y ) = to_box ( pos ) ;
if resp . double_clicked ( ) {
let _ = tx . send ( Input ::Click ( x , y , " left " , 2 ) ) ;
} else if resp . clicked ( ) {
let _ = tx . send ( Input ::Click ( x , y , " left " , 1 ) ) ;
} else if resp . secondary_clicked ( ) {
let _ = tx . send ( Input ::Click ( x , y , " right " , 1 ) ) ;
} else if resp . hovered ( ) {
let d = ui . input ( | i | i . smooth_scroll_delta . y ) ;
if d . abs ( ) > 0.5 {
let _ = tx . send ( Input ::Scroll ( x , y , d < 0.0 , ( ( d . abs ( ) / 40.0 ) . ceil ( ) as u32 ) . max ( 1 ) ) ) ;
} else if ui . input ( | i | i . pointer . delta ( ) . length ( ) > 3.0 ) {
let _ = tx . send ( Input ::Move ( x , y ) ) ;
}
}
}
if ! resp . has_focus ( ) {
return ;
}
ui . memory_mut ( | m | m . set_focus_lock_filter ( resp . id , egui ::EventFilter { tab : true , horizontal_arrows : true , vertical_arrows : true , escape : true } ) ) ;
let events = ui . input ( | i | i . events . clone ( ) ) ;
for e in events {
match e {
egui ::Event ::Text ( t ) = > {
let _ = tx . send ( Input ::Text ( t ) ) ;
}
egui ::Event ::Paste ( t ) = > {
let _ = tx . send ( Input ::Text ( t ) ) ;
}
egui ::Event ::Key { key , pressed : true , modifiers , .. } = > {
let name = match key {
Key ::Enter = > " Return " ,
Key ::Tab = > " Tab " ,
Key ::Backspace = > " BackSpace " ,
Key ::Delete = > " Delete " ,
Key ::Escape = > " Escape " ,
Key ::ArrowUp = > " Up " ,
Key ::ArrowDown = > " Down " ,
Key ::ArrowLeft = > " Left " ,
Key ::ArrowRight = > " Right " ,
Key ::Home = > " Home " ,
Key ::End = > " End " ,
Key ::PageUp = > " Prior " ,
Key ::PageDown = > " Next " ,
_ if modifiers . command | | modifiers . ctrl | | modifiers . alt = > {
let n = key . name ( ) ;
if n . chars ( ) . count ( ) = = 1 {
n
} else {
continue ;
}
}
_ = > continue ,
} ;
let mut combo = vec! [ ] ;
if modifiers . command | | modifiers . ctrl {
combo . push ( " ctrl " . to_string ( ) ) ;
}
if modifiers . alt {
combo . push ( " alt " . to_string ( ) ) ;
}
if modifiers . shift & & ( name . len ( ) > 1 | | ! combo . is_empty ( ) ) {
combo . push ( " shift " . to_string ( ) ) ;
}
combo . push ( if name . len ( ) = = 1 { name . to_lowercase ( ) } else { name . to_string ( ) } ) ;
let _ = tx . send ( Input ::Key ( combo . join ( " + " ) ) ) ;
}
_ = > { }
}
}
}
// ---------- the floating bar while a bot drives this Mac ----------
pub ( super ) fn local_control_bar ( & mut self , ctx : & egui ::Context ) {
if self . demo {
return ;
}
let held = crate ::computer ::HELD_BY_PERSON . load ( Ordering ::SeqCst ) ;
let recent = crate ::computer ::LAST_ACTION . lock ( ) . unwrap ( ) . clone ( ) . filter ( | ( t , _ ) | t . elapsed ( ) . as_secs ( ) < LOCAL_BAR_SECS ) ;
if ! held & & recent . is_none ( ) {
return ;
}
let who = recent . map ( | r | r . 1 ) . filter ( | w | ! w . is_empty ( ) ) . unwrap_or_else ( | | " Bot " . into ( ) ) ;
let p = self . pal ;
let mut stop_bot : Option < String > = None ;
let mut toggle = false ;
let monitor_w = ctx . input ( | i | i . viewport ( ) . monitor_size ) . map ( | s | s . x ) . unwrap_or ( 1440.0 ) ;
ctx . show_viewport_immediate (
egui ::ViewportId ::from_hash_of ( " anybot-local-control " ) ,
egui ::ViewportBuilder ::default ( )
. with_title ( " AnyBot 正在操作你的電腦 " )
. with_inner_size ( [ 440.0 , 46.0 ] )
. with_position ( [ ( monitor_w - 440.0 ) / 2.0 , 34.0 ] )
. with_decorations ( false )
. with_always_on_top ( )
. with_resizable ( false )
. with_transparent ( true ) ,
| ui , _ | {
Frame ::new ( ) . fill ( p . elevated ) . stroke ( Stroke ::new ( 1.0 , p . border ) ) . corner_radius ( 23 ) . inner_margin ( Margin ::symmetric ( 16 , 8 ) ) . show ( ui , | ui | {
ui . set_width ( ui . available_width ( ) ) ;
ui . horizontal ( | ui | {
let ( r , _ ) = ui . allocate_exact_size ( vec2 ( 10.0 , 10.0 ) , egui ::Sense ::hover ( ) ) ;
ui . painter ( ) . circle_filled ( r . center ( ) , 4.5 , if held { p . text3 } else { Color32 ::from_rgb ( 0xfc , 0x2c , 0x3e ) } ) ;
let text = if held { " 你正在操作, Bot 已暫停 " . to_string ( ) } else { format! ( " {who} 正在操作你的電腦 " ) } ;
ui . label ( RichText ::new ( text ) . size ( FONT_SM ) . strong ( ) ) ;
ui . with_layout ( Layout ::right_to_left ( Align ::Center ) , | ui | {
if ! held & & ui . add ( egui ::Button ::new ( RichText ::new ( t ( " 停止 " ) ) . size ( FONT_XS ) ) . corner_radius ( 14 ) ) . clicked ( ) {
stop_bot = Some ( who . clone ( ) ) ;
}
let label = if held { " 交還給 Bot " } else { " 取得控制權 " } ;
if ui . add ( egui ::Button ::new ( RichText ::new ( label ) . size ( FONT_XS ) . strong ( ) . color ( p . on_primary ) ) . fill ( p . primary ) . corner_radius ( 14 ) ) . clicked ( ) {
toggle = true ;
}
} ) ;
} ) ;
} ) ;
} ,
) ;
if toggle {
crate ::computer ::HELD_BY_PERSON . store ( ! held , Ordering ::SeqCst ) ;
if held {
* crate ::computer ::LAST_ACTION . lock ( ) . unwrap ( ) = None ;
}
}
if let Some ( name ) = stop_bot {
let ids : Vec < u64 > = self . streams . keys ( ) . copied ( ) . filter ( | id | self . state . bot ( * id ) . map ( | b | b . name = = name ) . unwrap_or ( false ) ) . collect ( ) ;
for id in ids {
self . stop ( id ) ;
}
* crate ::computer ::LAST_ACTION . lock ( ) . unwrap ( ) = None ;
}
ctx . request_repaint_after ( std ::time ::Duration ::from_millis ( 500 ) ) ;
}
}
// ---------- 示範教學 ----------
impl App {
/// 教它一項任務 from the + menu: start recording right away under a dated name.
pub ( super ) fn teach_quick ( & mut self , bot_id : u64 ) {
let Some ( bot ) = self . state . bot ( bot_id ) . cloned ( ) else { return } ;
if crate ::teach ::recording ( ) . is_some ( ) {
return ;
}
self . cu . teach_name = format! ( " {} {} " , t ( " 示範 " ) , chrono ::Local ::now ( ) . format ( " %m/%d %H:%M " ) ) ;
match crate ::teach ::start ( & bot . name ) {
Ok ( ( ) ) = > {
self . cu . teach_for = Some ( bot_id ) ;
self . cu . teach_error . clear ( ) ;
}
Err ( e ) = > self . att . error = Some ( e ) ,
}
}
pub ( super ) fn teach_card ( & mut self , ui : & mut egui ::Ui ) {
if ! crate ::computer ::demo_supported ( ) {
return ;
}
let p = self . pal ;
let bot_id = self . state . selected ;
let Some ( bot ) = self . state . bot ( bot_id ) . cloned ( ) else { return } ;
setting_card ( ui , p , | ui | {
setting_title ( ui , " 示範教學 " , & format! ( " 錄下你在這台 Mac 上執行任務的過程。 {} 會學習這些步驟,之後可以自行再次執行。 " , bot . name ) , p ) ;
ui . horizontal ( | ui | {
ui . add ( egui ::TextEdit ::singleline ( & mut self . cu . teach_name ) . hint_text ( t ( " 這個任務叫什麼,例如 匯出本月報表 " ) ) . desired_width ( ui . available_width ( ) - 110.0 ) ) ;
let ok = ! self . cu . teach_name . trim ( ) . is_empty ( ) & & crate ::teach ::recording ( ) . is_none ( ) ;
if ui . add_enabled ( ok , primary_button ( " 開始錄製 " , p ) ) . clicked ( ) {
match crate ::teach ::start ( & bot . name ) {
Ok ( ( ) ) = > {
self . cu . teach_for = Some ( bot_id ) ;
self . cu . teach_error . clear ( ) ;
self . show_settings = false ;
}
Err ( e ) = > self . cu . teach_error = e ,
}
}
} ) ;
if ! self . cu . teach_error . is_empty ( ) {
ui . add ( egui ::Label ::new ( RichText ::new ( & self . cu . teach_error ) . size ( FONT_XS ) . color ( p . danger ) ) . wrap ( ) ) ;
}
hint ( ui , " 按下開始後照平常的方式操作;做完按畫面上方的「完成」。會記錄點擊、打字和捲動,並且每 0.5 秒拍一張螢幕畫面(需要「螢幕與系統錄音」權限),完成後合成錄影;每一步會留一張當時的畫面給 Bot 看。不會擋住或改變你的操作。 " , p ) ;
let mut remove = None ;
for ( i , d ) in bot . demos . iter ( ) . enumerate ( ) {
ui . add_space ( 6.0 ) ;
Frame ::new ( ) . fill ( p . elevated ) . corner_radius ( 10 ) . inner_margin ( Margin ::symmetric ( 12 , 8 ) ) . show ( ui , | ui | {
ui . set_width ( ui . available_width ( ) ) ;
ui . horizontal ( | ui | {
ui . label ( RichText ::new ( & d . name ) . strong ( ) . size ( FONT_SM ) ) ;
ui . label ( RichText ::new ( format! ( " {} · {} 個步驟 " , d . created , d . steps . len ( ) ) ) . size ( 11.5 ) . color ( p . text3 ) ) ;
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 ! d . video . is_empty ( ) & & ui . add ( egui ::Button ::new ( RichText ::new ( t ( " 播放錄影 " ) ) . size ( 12.0 ) . color ( p . accent ) ) . frame ( false ) ) . clicked ( ) {
crate ::preview ::open_with_system ( & d . video . to_string ( ) ) ;
}
} ) ;
} ) ;
egui ::CollapsingHeader ::new ( RichText ::new ( t ( " 步驟 " ) ) . size ( FONT_XS ) . color ( p . text2 ) ) . id_salt ( ( " demo " , bot_id , i ) ) . show ( ui , | ui | {
for ( n , s ) in d . steps . iter ( ) . enumerate ( ) {
ui . horizontal ( | ui | {
ui . label ( RichText ::new ( format! ( " {} . {s} " , n + 1 ) ) . size ( FONT_XS ) . color ( p . text2 ) ) ;
if let Some ( shot ) = d . shots . get ( n ) . filter ( | x | ! x . is_empty ( ) ) {
if ui . add ( egui ::Button ::new ( RichText ::new ( t ( " 畫面 " ) ) . size ( 11.0 ) . color ( p . accent ) ) . frame ( false ) ) . on_hover_text ( t ( " 打開這一步當時的畫面 " ) ) . clicked ( ) {
crate ::preview ::open_with_system ( & shot . to_string ( ) ) ;
}
}
} ) ;
}
} ) ;
} ) ;
}
if let Some ( i ) = remove {
if let Some ( b ) = self . state . bot_mut ( bot_id ) {
b . demos . remove ( i ) ;
}
self . mark ( ) ;
}
} ) ;
}
/// 「{Bot} 正在觀看並學習」 with 完成 / 取消, on top of every window while recording.
pub ( super ) fn teach_bar ( & mut self , ctx : & egui ::Context ) {
let Some ( ( who , since ) ) = crate ::teach ::recording ( ) else { return } ;
let p = self . pal ;
let mut finish = false ;
let mut cancel = false ;
let monitor_w = ctx . input ( | i | i . viewport ( ) . monitor_size ) . map ( | s | s . x ) . unwrap_or ( 1440.0 ) ;
ctx . show_viewport_immediate (
egui ::ViewportId ::from_hash_of ( " anybot-teach " ) ,
egui ::ViewportBuilder ::default ( )
. with_title ( " AnyBot 示範教學 " )
. with_inner_size ( [ 420.0 , 46.0 ] )
. with_position ( [ ( monitor_w - 420.0 ) / 2.0 , 34.0 ] )
. with_decorations ( false )
. with_always_on_top ( )
. with_resizable ( false )
. with_transparent ( true ) ,
| ui , _ | {
Frame ::new ( ) . fill ( p . elevated ) . stroke ( Stroke ::new ( 1.0 , p . border ) ) . corner_radius ( 23 ) . inner_margin ( Margin ::symmetric ( 16 , 8 ) ) . show ( ui , | ui | {
ui . set_width ( ui . available_width ( ) ) ;
ui . horizontal ( | ui | {
let ( r , _ ) = ui . allocate_exact_size ( vec2 ( 10.0 , 10.0 ) , egui ::Sense ::hover ( ) ) ;
let t = ui . input ( | i | i . time ) as f32 ;
ui . painter ( ) . circle_filled ( r . center ( ) , 4.5 , Color32 ::from_rgb ( 0xfc , 0x2c , 0x3e ) . gamma_multiply ( 0.6 + 0.4 * ( t * 3.0 ) . sin ( ) . abs ( ) ) ) ;
let secs = since . elapsed ( ) . as_secs ( ) ;
ui . label ( RichText ::new ( format! ( " {who} 正在觀看並學習 {:02} : {:02} " , secs / 60 , secs % 60 ) ) . size ( FONT_SM ) . strong ( ) ) ;
ui . with_layout ( Layout ::right_to_left ( Align ::Center ) , | ui | {
if ui . add ( egui ::Button ::new ( RichText ::new ( crate ::i18n ::t ( " 取消 " ) ) . size ( FONT_XS ) ) . corner_radius ( 14 ) ) . clicked ( ) {
cancel = true ;
}
if ui . add ( egui ::Button ::new ( RichText ::new ( crate ::i18n ::t ( " 完成 " ) ) . size ( FONT_XS ) . strong ( ) . color ( p . on_primary ) ) . fill ( p . primary ) . corner_radius ( 14 ) ) . clicked ( ) {
finish = true ;
}
} ) ;
} ) ;
} ) ;
} ,
) ;
ctx . request_repaint_after ( std ::time ::Duration ::from_millis ( 250 ) ) ;
if cancel {
crate ::teach ::cancel ( ) ;
}
if finish {
let demo = crate ::teach ::finish ( & self . cu . teach_name ) ;
let n = demo . steps . len ( ) ;
if let Some ( id ) = self . cu . teach_for . take ( ) {
if let Some ( b ) = self . state . bot_mut ( id ) {
b . demos . retain ( | d | d . name ! = demo . name ) ;
b . demos . push ( demo ) ;
}
self . notice = Some ( format! ( " 已記下「 {} 」( {n} 個步驟)。之後跟 Bot 說「照示範做 {} 」就可以。 " , self . cu . teach_name . trim ( ) , self . cu . teach_name . trim ( ) ) ) ;
self . state . selected = id ;
}
self . cu . teach_name . clear ( ) ;
self . mark ( ) ;
}
}
}
/// An on/off switch (the original's toggle, size md).
fn toggle ( on : & mut bool ) -> impl egui ::Widget + '_ {
move | ui : & mut egui ::Ui | -> egui ::Response {
let size = vec2 ( 36.0 , 20.0 ) ;
let ( rect , mut r ) = ui . allocate_exact_size ( size , egui ::Sense ::click ( ) ) ;
if r . clicked ( ) {
* on = ! * on ;
r . mark_changed ( ) ;
}
let t = ui . ctx ( ) . animate_bool_responsive ( r . id , * on ) ;
let off_col = ui . visuals ( ) . widgets . inactive . bg_fill ;
let on_col = ui . visuals ( ) . selection . bg_fill ;
let fill = if ui . is_enabled ( ) { egui ::lerp ( egui ::Rgba ::from ( off_col ) ..= egui ::Rgba ::from ( on_col ) , t ) . into ( ) } else { off_col . gamma_multiply ( 0.6 ) } ;
ui . painter ( ) . rect_filled ( rect , 10 , fill ) ;
let x = egui ::lerp ( ( rect . left ( ) + 10.0 ) ..= ( rect . right ( ) - 10.0 ) , t ) ;
ui . painter ( ) . circle_filled ( egui ::pos2 ( x , rect . center ( ) . y ) , 8.0 , Color32 ::WHITE ) ;
r . on_hover_cursor ( egui ::CursorIcon ::PointingHand )
}
}
/// The original's settings layout: sections 28 px apart, a small grey title (and something on
/// its right), then a card of rows (12 px corners, a hairline, 8 px top and bottom).
const SEC_GAP : f32 = 28.0 ;
fn sec_title ( ui : & mut egui ::Ui , title : & str , p : Palette , accessory : impl FnOnce ( & mut egui ::Ui ) ) {
ui . add_space ( SEC_GAP * 0.5 ) ;
ui . horizontal ( | ui | {
ui . add_space ( 14.0 ) ;
ui . label ( RichText ::new ( t ( title ) ) . size ( FONT_SM ) . color ( p . text2 ) ) ;
ui . with_layout ( Layout ::right_to_left ( Align ::Center ) , | ui | {
ui . add_space ( 4.0 ) ;
accessory ( ui ) ;
} ) ;
} ) ;
ui . add_space ( 8.0 ) ;
}
fn sec_card ( ui : & mut egui ::Ui , p : Palette , body : impl FnOnce ( & mut egui ::Ui ) ) {
Frame ::new ( )
. fill ( p . hover . gamma_multiply ( 0.45 ) )
. stroke ( Stroke ::new ( 0.5 , p . border ) )
. corner_radius ( 12 )
. inner_margin ( Margin ::symmetric ( 0 , 8 ) )
. show ( ui , | ui | {
ui . set_width ( ui . available_width ( ) ) ;
ui . spacing_mut ( ) . item_spacing . y = 0.0 ;
body ( ui )
} ) ;
ui . add_space ( SEC_GAP * 0.5 ) ;
}
/// A row: the setting's name (「?」 explains on hover) on the left, its control on the right.
fn sec_row ( ui : & mut egui ::Ui , p : Palette , label : & str , help : & str , control : impl FnOnce ( & mut egui ::Ui ) ) {
sec_row_s ( ui , p , label , " " , help , control )
}
/// A row with a short status line under the name (目前在線上、上次快照…).
fn sec_row_s ( ui : & mut egui ::Ui , p : Palette , label : & str , status : & str , help : & str , control : impl FnOnce ( & mut egui ::Ui ) ) {
Frame ::new ( ) . inner_margin ( Margin ::symmetric ( 14 , 10 ) ) . show ( ui , | ui | {
ui . set_width ( ui . available_width ( ) ) ;
ui . horizontal ( | ui | {
let w = ( ui . available_width ( ) * 0.58 ) . max ( 200.0 ) ;
ui . vertical ( | ui | {
ui . set_width ( w ) ;
ui . spacing_mut ( ) . item_spacing . y = 2.0 ;
ui . horizontal ( | ui | {
ui . spacing_mut ( ) . item_spacing . x = 4.0 ;
ui . label ( RichText ::new ( t ( label ) ) . size ( FONT_BASE ) . color ( p . text ) ) ;
help_mark ( ui , help , p ) ;
} ) ;
if ! status . is_empty ( ) {
ui . add ( egui ::Label ::new ( RichText ::new ( t ( status ) ) . size ( FONT_SM ) . color ( p . text2 ) ) . truncate ( ) ) ;
}
} ) ;
ui . with_layout ( Layout ::right_to_left ( Align ::Center ) , | ui | {
ui . spacing_mut ( ) . item_spacing . x = 6.0 ;
control ( ui )
} ) ;
} ) ;
} ) ;
}
/// A small note inside a card.
fn sec_note ( ui : & mut egui ::Ui , p : Palette , text : & str ) {
Frame ::new ( ) . inner_margin ( Margin { left : 14 , right : 14 , top : 0 , bottom : 6 } ) . show ( ui , | ui | {
ui . add ( egui ::Label ::new ( RichText ::new ( t ( text ) ) . size ( FONT_XS ) . color ( p . text3 ) ) . wrap ( ) ) ;
} ) ;
}