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
|
|
|
|
// 手機與帳號 in the window: what the phone sees (published when something changes), carrying out
|
|
|
|
|
|
// what the phone asks, and the settings page (phone password, account at the relay, backups,
|
|
|
|
|
|
// 裝置連結).
|
|
|
|
|
|
|
|
|
|
|
|
use super::*;
|
|
|
|
|
|
use crate::remote::{self, Cmd, Settings};
|
|
|
|
|
|
use serde_json::json;
|
|
|
|
|
|
use std::hash::{Hash, Hasher};
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Default)]
|
|
|
|
|
|
pub struct RemoteUi {
|
|
|
|
|
|
sig: u64,
|
|
|
|
|
|
last_publish: Option<Instant>,
|
|
|
|
|
|
/// The settings the server threads were last given.
|
|
|
|
|
|
pushed: Option<Settings>,
|
|
|
|
|
|
pw1: String,
|
|
|
|
|
|
pw2: String,
|
|
|
|
|
|
relay_url: String,
|
|
|
|
|
|
user: String,
|
|
|
|
|
|
pass: String,
|
|
|
|
|
|
invite: String,
|
|
|
|
|
|
create: bool,
|
|
|
|
|
|
note: Arc<Mutex<String>>,
|
|
|
|
|
|
join_rx: Option<Receiver<Result<Settings, String>>>,
|
|
|
|
|
|
restore_rx: Option<Receiver<Result<Vec<u8>, String>>>,
|
|
|
|
|
|
busy: Arc<AtomicBool>,
|
|
|
|
|
|
peers: Arc<Mutex<Vec<(String, String, bool)>>>,
|
|
|
|
|
|
last_seen_mark: Option<Instant>,
|
|
|
|
|
|
peers_at: Option<Instant>,
|
|
|
|
|
|
/// 團隊: (user, role) shared with, loaded from the relay; the form to add one.
|
|
|
|
|
|
members: Arc<Mutex<Option<Result<Vec<(String, String)>, String>>>>,
|
|
|
|
|
|
new_member: String,
|
|
|
|
|
|
new_admin: bool,
|
|
|
|
|
|
started: bool,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Approvals as the phone shows them.
|
|
|
|
|
|
fn approval_json(bot: &Bot, a: &Approval) -> serde_json::Value {
|
|
|
|
|
|
let what = remote::tool_label(&a.tool);
|
|
|
|
|
|
let title = match a.kind {
|
|
|
|
|
|
ApprovalKind::EnableTools => format!("允許 {} 使用電腦工具嗎?", bot.name),
|
|
|
|
|
|
ApprovalKind::EnableThisComputer => format!("允許 {} 在你的電腦上執行任務嗎?", bot.name),
|
|
|
|
|
|
ApprovalKind::Action => format!("{} 想要{what}", bot.name),
|
|
|
|
|
|
};
|
|
|
|
|
|
json!({"bot": bot.id, "title": title, "reason": a.reason, "tool": a.tool, "can_always": a.kind != ApprovalKind::Action || !a.grant.is_empty()})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl App {
|
|
|
|
|
|
pub(super) fn remote_start(&mut self, ctx: &egui::Context) {
|
|
|
|
|
|
if self.demo || self.rm.started {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
self.rm.started = true;
|
2026-11-04 09:46:43 +00:00
|
|
|
|
self.ctx_handle = Some(ctx.clone());
|
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 c = ctx.clone();
|
|
|
|
|
|
remote::hub().set_repaint(Arc::new(move || c.request_repaint()));
|
|
|
|
|
|
self.remote_apply_settings();
|
2026-11-04 09:46:43 +00:00
|
|
|
|
self.mirror_boot();
|
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
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Hand changed settings to the server threads (and restart what depends on them).
|
|
|
|
|
|
fn remote_apply_settings(&mut self) {
|
|
|
|
|
|
let s = self.state.remote.clone();
|
|
|
|
|
|
if self.rm.pushed.as_ref() == Some(&s) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
let old = self.rm.pushed.replace(s.clone());
|
|
|
|
|
|
remote::hub().set_settings(&s);
|
|
|
|
|
|
let lan_changed = old.as_ref().map(|o| o.lan) != Some(s.lan);
|
|
|
|
|
|
if lan_changed {
|
|
|
|
|
|
match remote::set_lan(s.lan) {
|
|
|
|
|
|
Ok(_) => {}
|
|
|
|
|
|
Err(e) => *self.rm.note.lock().unwrap() = e,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
let relay_changed = old.as_ref().map(|o| (o.relay_url.clone(), o.host_token.clone())) != Some((s.relay_url.clone(), s.host_token.clone()));
|
|
|
|
|
|
if relay_changed {
|
|
|
|
|
|
remote::relay_restart(&s);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Every frame: run what the phone asked and refresh what it sees.
|
|
|
|
|
|
pub(super) fn remote_tick(&mut self, ctx: &egui::Context) {
|
|
|
|
|
|
if self.demo {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
self.remote_apply_settings();
|
|
|
|
|
|
for c in remote::hub().take_cmds() {
|
2026-11-04 09:46:43 +00:00
|
|
|
|
// A screen of another brain has no bots of its own to run phone requests on.
|
|
|
|
|
|
if self.mir.on && !matches!(c, Cmd::DeviceAdded(_) | Cmd::DeviceSeen(_)) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
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.remote_cmd(ctx, c);
|
|
|
|
|
|
}
|
2026-11-04 09:46:43 +00:00
|
|
|
|
self.brain_serve();
|
|
|
|
|
|
self.brain_watch();
|
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(rx) = &self.rm.join_rx {
|
|
|
|
|
|
if let Ok(r) = rx.try_recv() {
|
|
|
|
|
|
self.rm.join_rx = None;
|
|
|
|
|
|
match r {
|
|
|
|
|
|
Ok(s) => {
|
|
|
|
|
|
self.state.remote = s;
|
|
|
|
|
|
self.rm.pass.clear();
|
|
|
|
|
|
*self.rm.note.lock().unwrap() = "已登入,這台 AnyBot 已加入帳號".into();
|
|
|
|
|
|
self.mark();
|
|
|
|
|
|
}
|
|
|
|
|
|
Err(e) => *self.rm.note.lock().unwrap() = e,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(rx) = &self.rm.restore_rx {
|
|
|
|
|
|
if let Ok(r) = rx.try_recv() {
|
|
|
|
|
|
self.rm.restore_rx = None;
|
|
|
|
|
|
match r.and_then(|bytes| serde_json::from_slice::<State>(&bytes).map_err(|e| format!("備份內容讀不出來:{e}"))) {
|
|
|
|
|
|
Ok(mut st) => {
|
|
|
|
|
|
// Keep this computer's own phone and account settings.
|
|
|
|
|
|
st.remote = self.state.remote.clone();
|
|
|
|
|
|
let n = st.bots.len();
|
|
|
|
|
|
self.state = st;
|
|
|
|
|
|
self.state.save();
|
|
|
|
|
|
*self.rm.note.lock().unwrap() = format!("已從備份還原 {n} 個 Bot");
|
|
|
|
|
|
}
|
|
|
|
|
|
Err(e) => *self.rm.note.lock().unwrap() = e,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// 自動備份: once a day while signed in.
|
|
|
|
|
|
let s = &self.state.remote;
|
2026-11-04 09:46:43 +00:00
|
|
|
|
if !self.mir.on && s.auto_backup && !s.backup_key.is_empty() && !s.relay_url.is_empty() && chrono::Utc::now().timestamp() - s.last_backup > 86_400 && !self.rm.busy.load(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
|
|
|
|
self.remote_backup();
|
|
|
|
|
|
}
|
|
|
|
|
|
// 裝置連結: keep the list of this account's computers fresh (once a minute).
|
|
|
|
|
|
if !self.state.remote.host_token.is_empty() && !self.rm.peers_at.map(|t| t.elapsed().as_secs() < 60).unwrap_or(false) {
|
|
|
|
|
|
self.rm.peers_at = Some(Instant::now());
|
|
|
|
|
|
let (peers, s) = (self.rm.peers.clone(), self.state.remote.clone());
|
|
|
|
|
|
std::thread::spawn(move || {
|
|
|
|
|
|
let v = remote::devices_online(&s);
|
|
|
|
|
|
if !v.is_empty() {
|
|
|
|
|
|
*peers.lock().unwrap() = v;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-11-04 09:46:43 +00:00
|
|
|
|
if !self.mir.on && !self.rm.last_publish.map(|t| t.elapsed().as_millis() < 120).unwrap_or(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
|
|
|
|
self.remote_publish();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn remote_signature(&self) -> u64 {
|
|
|
|
|
|
let mut h = std::collections::hash_map::DefaultHasher::new();
|
|
|
|
|
|
self.state.remote.host_name.hash(&mut h);
|
2026-11-04 09:46:43 +00:00
|
|
|
|
self.scr.cfg_hash.hash(&mut h);
|
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
|
|
|
|
for b in &self.state.bots {
|
|
|
|
|
|
(b.id, &b.name, &b.color, b.unread, b.messages.len()).hash(&mut h);
|
|
|
|
|
|
if let Some(m) = b.messages.last() {
|
|
|
|
|
|
(m.text.len(), m.tool_calls.len(), m.error).hash(&mut h);
|
|
|
|
|
|
for c in &m.tool_calls {
|
|
|
|
|
|
(c.status as u8).hash(&mut h);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(l) = self.streams.get(&b.id) {
|
|
|
|
|
|
(1u8, &l.status, l.paused).hash(&mut h);
|
|
|
|
|
|
}
|
|
|
|
|
|
self.approvals.get(&b.id).map(|a| a.call_id.clone()).hash(&mut h);
|
|
|
|
|
|
}
|
|
|
|
|
|
h.finish()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn remote_publish(&mut self) {
|
|
|
|
|
|
let sig = self.remote_signature();
|
|
|
|
|
|
if sig == self.rm.sig {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
self.rm.sig = sig;
|
|
|
|
|
|
self.rm.last_publish = Some(Instant::now());
|
|
|
|
|
|
let names: HashMap<u64, String> = self.state.bots.iter().map(|b| (b.id, b.name.clone())).collect();
|
|
|
|
|
|
let mut bots: Vec<&Bot> = self.state.bots.iter().collect();
|
|
|
|
|
|
// Pinned first, then the most recently active.
|
|
|
|
|
|
bots.sort_by_key(|b| (!b.pinned, -(b.last_turn.as_ref().map(|t| t.settled_at_ms).unwrap_or(0))));
|
|
|
|
|
|
let mut view = remote::View { host: self.state.remote.host_name.clone(), ..Default::default() };
|
|
|
|
|
|
for b in bots {
|
|
|
|
|
|
let live = self.streams.get(&b.id);
|
|
|
|
|
|
let last = b.messages.iter().rev().find(|m| !m.text.trim().is_empty() && m.role != Role::Tool).map(|m| first_line(&m.text, 60)).unwrap_or_default();
|
|
|
|
|
|
let c = crate::avatar::color(&b.color);
|
|
|
|
|
|
let hex = format!("#{:02x}{:02x}{:02x}", c.r(), c.g(), c.b());
|
|
|
|
|
|
view.bots.push(json!({
|
|
|
|
|
|
"id": b.id, "name": b.name, "description": b.description, "color": hex, "shape": b.shape,
|
|
|
|
|
|
"busy": live.is_some(), "status": live.and_then(|l| l.status.clone()).unwrap_or_default(),
|
|
|
|
|
|
"unread": b.unread, "last": last, "members": !b.members.is_empty(), "pinned": b.pinned,
|
|
|
|
|
|
}));
|
|
|
|
|
|
let start = b.messages.len().saturating_sub(remote::PHONE_TAIL);
|
|
|
|
|
|
view.messages.insert(b.id, b.messages[start..].iter().filter_map(|m| remote::message_json(m, &names)).collect());
|
|
|
|
|
|
if let Some(a) = self.approvals.get(&b.id) {
|
|
|
|
|
|
view.approvals.push(approval_json(b, a));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
remote::hub().publish(view);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn remote_cmd(&mut self, ctx: &egui::Context, c: Cmd) {
|
|
|
|
|
|
match c {
|
|
|
|
|
|
Cmd::Send { bot, text, attachments } => {
|
|
|
|
|
|
if self.state.bot(bot).is_none() {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
let atts: Vec<crate::attach::Attachment> = attachments
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.filter(|p| std::path::Path::new(p).starts_with(crate::model::State::path().parent().unwrap_or(std::path::Path::new("/"))))
|
|
|
|
|
|
.filter_map(|p| {
|
|
|
|
|
|
let meta = std::fs::metadata(p).ok()?;
|
|
|
|
|
|
let file = std::path::Path::new(p).file_name()?.to_string_lossy().to_string();
|
|
|
|
|
|
let name = file.split_once('-').map(|x| x.1.to_string()).unwrap_or(file);
|
|
|
|
|
|
Some(crate::attach::Attachment { image: crate::attach::is_image_name(&name), name, path: p.clone(), bytes: meta.len(), on_box: String::new() })
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
if let Some(l) = self.streams.get(&bot) {
|
|
|
|
|
|
let (extra, _) = crate::attach::for_model(&atts);
|
|
|
|
|
|
l.steer.lock().unwrap().push(format!("{text}{extra}"));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(b) = self.state.bot_mut(bot) {
|
|
|
|
|
|
b.messages.push(Message { attachments: atts, ..Message::user(&text) });
|
|
|
|
|
|
}
|
|
|
|
|
|
self.start_reply(ctx, bot);
|
|
|
|
|
|
}
|
|
|
|
|
|
Cmd::Stop { bot } => self.stop(bot),
|
2026-11-04 09:46:43 +00:00
|
|
|
|
Cmd::Regenerate { bot } => self.regenerate(ctx, bot),
|
|
|
|
|
|
Cmd::Reply { bot } => {
|
|
|
|
|
|
if !self.streams.contains_key(&bot) && self.state.bot(bot).and_then(|b| b.messages.last()).map(|m| m.role == Role::User).unwrap_or(false) {
|
|
|
|
|
|
self.start_reply(ctx, bot);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Cmd::RestoreThread { bot, index } => {
|
|
|
|
|
|
self.stop(bot);
|
|
|
|
|
|
if let Some(b) = self.state.bot_mut(bot) {
|
|
|
|
|
|
if index < b.threads.len() {
|
|
|
|
|
|
let (_, msgs) = b.threads.remove(index);
|
|
|
|
|
|
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 cur = std::mem::take(&mut b.messages);
|
|
|
|
|
|
b.threads.insert(0, (title, cur));
|
|
|
|
|
|
}
|
|
|
|
|
|
b.messages = msgs;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
self.mark();
|
|
|
|
|
|
}
|
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
|
|
|
|
Cmd::Answer { bot, answer } => {
|
|
|
|
|
|
let grant = self.approvals.get(&bot).map(|a| a.grant.clone()).unwrap_or_default();
|
|
|
|
|
|
let a = match answer.as_str() {
|
|
|
|
|
|
"once" => ApprovalAnswer::Once,
|
|
|
|
|
|
"always" => ApprovalAnswer::Always(grant),
|
|
|
|
|
|
_ => ApprovalAnswer::Reject,
|
|
|
|
|
|
};
|
|
|
|
|
|
self.answer(ctx, bot, a);
|
|
|
|
|
|
}
|
|
|
|
|
|
Cmd::Read { bot } => {
|
|
|
|
|
|
if let Some(b) = self.state.bot_mut(bot) {
|
|
|
|
|
|
if b.unread {
|
|
|
|
|
|
b.unread = false;
|
|
|
|
|
|
self.mark();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Cmd::NewThread { bot } => {
|
|
|
|
|
|
self.stop(bot);
|
|
|
|
|
|
if let Some(b) = self.state.bot_mut(bot) {
|
|
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
Cmd::DeviceAdded(d) => {
|
|
|
|
|
|
self.state.remote.devices.push(d);
|
|
|
|
|
|
self.mark();
|
|
|
|
|
|
}
|
|
|
|
|
|
Cmd::DeviceSeen(h) => {
|
|
|
|
|
|
if let Some(gone) = h.strip_prefix('-') {
|
|
|
|
|
|
self.state.remote.devices.retain(|d| d.token_hash != gone);
|
|
|
|
|
|
self.mark();
|
|
|
|
|
|
} else if !self.rm.last_seen_mark.map(|t| t.elapsed().as_secs() < 60).unwrap_or(false) {
|
|
|
|
|
|
self.rm.last_seen_mark = Some(Instant::now());
|
|
|
|
|
|
if let Some(d) = self.state.remote.devices.iter_mut().find(|d| d.token_hash == h) {
|
|
|
|
|
|
d.last_seen = chrono::Utc::now().timestamp();
|
|
|
|
|
|
self.mark();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn remote_backup(&mut self) {
|
|
|
|
|
|
if self.rm.busy.swap(true, Ordering::SeqCst) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
self.state.remote.last_backup = chrono::Utc::now().timestamp();
|
|
|
|
|
|
self.mark();
|
|
|
|
|
|
let s = self.state.remote.clone();
|
|
|
|
|
|
let mut st = self.state.clone();
|
|
|
|
|
|
// The backup is for the bots; this computer's own keys to the relay and phones stay here.
|
|
|
|
|
|
st.remote = Default::default();
|
|
|
|
|
|
let bytes = serde_json::to_vec(&st).unwrap_or_default();
|
|
|
|
|
|
let (note, busy) = (self.rm.note.clone(), self.rm.busy.clone());
|
|
|
|
|
|
std::thread::spawn(move || {
|
|
|
|
|
|
*note.lock().unwrap() = match remote::backup_now(&s, &bytes) {
|
|
|
|
|
|
Ok(n) => format!("已備份({},已加密)", crate::cloudbox::human(n as u64)),
|
|
|
|
|
|
Err(e) => e,
|
|
|
|
|
|
};
|
|
|
|
|
|
busy.store(false, Ordering::SeqCst);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub(super) fn remote_page(&mut self, ui: &mut egui::Ui, _ctx: &egui::Context) {
|
|
|
|
|
|
let p = self.pal;
|
|
|
|
|
|
let mut changed = false;
|
|
|
|
|
|
setting_card(ui, p, |ui| {
|
|
|
|
|
|
setting_title(ui, "手機與帳號", "在手機上和 Bot 聊天、核准它要做的事、看著它的電腦。", p);
|
|
|
|
|
|
hint(ui, "兩種連法:同一個網路(或 Tailscale)直接連這台電腦;或登入帳號,透過你的帳號伺服器從任何地方連,這台電腦不用開放任何連接埠。", p);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ── 帳號(薄後端) ──
|
|
|
|
|
|
setting_card(ui, p, |ui| {
|
|
|
|
|
|
setting_title(ui, "AnyBot 帳號", "帳號伺服器保管帳號、裝置清單和加密備份,手機從外面連進來也靠它轉送。", p);
|
|
|
|
|
|
let s = &self.state.remote;
|
|
|
|
|
|
if !s.host_token.is_empty() {
|
|
|
|
|
|
let status = remote::hub().relay_status();
|
|
|
|
|
|
ui.horizontal(|ui| {
|
|
|
|
|
|
let ok = status == "已連線";
|
|
|
|
|
|
let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), egui::Sense::hover());
|
|
|
|
|
|
ui.painter().circle_filled(r.center(), 4.0, if ok { Color32::from_rgb(0x14, 0xc3, 0x70) } else { p.text3 });
|
|
|
|
|
|
ui.label(RichText::new(format!("{} · {}", s.relay_user, s.relay_url)).size(FONT_BASE));
|
|
|
|
|
|
ui.label(RichText::new(if status.is_empty() { "—".to_string() } else { status }).size(12.0).color(p.text3));
|
|
|
|
|
|
});
|
|
|
|
|
|
hint(ui, format!("手機打開 {} 用同一個帳號登入就能用。可以「加入主畫面」當成 App。", s.relay_url), p);
|
|
|
|
|
|
ui.add_space(4.0);
|
|
|
|
|
|
ui.horizontal(|ui| {
|
|
|
|
|
|
ui.label(RichText::new(t("這台電腦在手機上叫")).size(13.0).color(p.text2));
|
|
|
|
|
|
if ui.add(egui::TextEdit::singleline(&mut self.state.remote.host_name).desired_width(180.0)).lost_focus() {
|
|
|
|
|
|
changed = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
if ui.checkbox(&mut self.state.remote.auto_backup, t("每天自動備份到帳號(加密,金鑰由你的密碼產生,伺服器打不開)")).changed() {
|
|
|
|
|
|
changed = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
ui.horizontal(|ui| {
|
|
|
|
|
|
let busy = self.rm.busy.load(Ordering::SeqCst);
|
|
|
|
|
|
if ui.add_enabled(!busy, secondary_button(if busy { "備份中…" } else { "立刻備份" })).clicked() {
|
|
|
|
|
|
self.remote_backup();
|
|
|
|
|
|
}
|
|
|
|
|
|
if self.state.remote.last_backup > 0 {
|
|
|
|
|
|
let t = chrono::DateTime::from_timestamp(self.state.remote.last_backup, 0).map(|d| d.with_timezone(&chrono::Local).format("%m/%d %H:%M").to_string()).unwrap_or_default();
|
|
|
|
|
|
ui.label(RichText::new(format!("上次備份 {t}")).size(12.0).color(p.text3));
|
|
|
|
|
|
}
|
|
|
|
|
|
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
|
|
|
|
|
if ui.add(egui::Button::new(RichText::new(t("登出帳號")).size(12.5).color(p.danger)).frame(false)).clicked() {
|
|
|
|
|
|
let keep_name = self.state.remote.host_name.clone();
|
|
|
|
|
|
let s = &mut self.state.remote;
|
|
|
|
|
|
s.host_id.clear();
|
|
|
|
|
|
s.host_token.clear();
|
|
|
|
|
|
s.backup_key.clear();
|
|
|
|
|
|
s.host_name = keep_name;
|
|
|
|
|
|
changed = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
} else {
|
|
|
|
|
|
let busy = self.rm.join_rx.is_some();
|
|
|
|
|
|
form_row(ui, "帳號伺服器", p, |ui| {
|
|
|
|
|
|
ui.add(egui::TextEdit::singleline(&mut self.rm.relay_url).hint_text(t("https://anybot.你的網域")).desired_width(f32::INFINITY));
|
|
|
|
|
|
});
|
|
|
|
|
|
form_row(ui, "帳號", p, |ui| {
|
|
|
|
|
|
ui.add(egui::TextEdit::singleline(&mut self.rm.user).desired_width(f32::INFINITY));
|
|
|
|
|
|
});
|
|
|
|
|
|
form_row(ui, "密碼", p, |ui| {
|
|
|
|
|
|
ui.add(egui::TextEdit::singleline(&mut self.rm.pass).password(true).desired_width(f32::INFINITY));
|
|
|
|
|
|
});
|
|
|
|
|
|
ui.checkbox(&mut self.rm.create, t("建立新帳號"));
|
|
|
|
|
|
if self.rm.create {
|
|
|
|
|
|
form_row(ui, "邀請碼", p, |ui| {
|
|
|
|
|
|
ui.add(egui::TextEdit::singleline(&mut self.rm.invite).hint_text(t("伺服器設定了才需要")).desired_width(f32::INFINITY));
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
ui.horizontal(|ui| {
|
|
|
|
|
|
let ready = !self.rm.relay_url.trim().is_empty() && !self.rm.user.trim().is_empty() && self.rm.pass.len() >= 8;
|
|
|
|
|
|
if ui.add_enabled(ready && !busy, primary_button(if busy { "連線中…" } else if self.rm.create { "建立並登入" } else { "登入" }, p)).clicked() {
|
|
|
|
|
|
let (tx, rx) = channel();
|
|
|
|
|
|
self.rm.join_rx = Some(rx);
|
|
|
|
|
|
let (url, user, pass, create, invite, base) = (self.rm.relay_url.clone(), self.rm.user.trim().to_string(), self.rm.pass.clone(), self.rm.create, self.rm.invite.clone(), self.state.remote.clone());
|
|
|
|
|
|
std::thread::spawn(move || {
|
|
|
|
|
|
let _ = tx.send(remote::relay_join(&base, &url, &user, &pass, create, &invite));
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
if ui.add_enabled(ready && self.rm.restore_rx.is_none(), secondary_button("從帳號備份還原")).on_hover_text(t("把這台電腦的 Bot 換成帳號裡最新的備份")).clicked() {
|
|
|
|
|
|
let (tx, rx) = channel();
|
|
|
|
|
|
self.rm.restore_rx = Some(rx);
|
|
|
|
|
|
let (url, user, pass) = (self.rm.relay_url.clone(), self.rm.user.trim().to_string(), self.rm.pass.clone());
|
|
|
|
|
|
std::thread::spawn(move || {
|
|
|
|
|
|
let _ = tx.send(remote::restore(&url, &user, &pass));
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
hint(ui, "密碼至少 8 個字。帳號伺服器在 relay/ 資料夾,可以放在任何一台小主機(說明見 relay/README.md)。", p);
|
|
|
|
|
|
}
|
|
|
|
|
|
let note = self.rm.note.lock().unwrap().clone();
|
|
|
|
|
|
if !note.is_empty() {
|
|
|
|
|
|
ui.label(RichText::new(note).size(12.5).color(p.text2));
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-11-04 09:46:43 +00:00
|
|
|
|
// ── 大腦 ──
|
|
|
|
|
|
if !self.state.remote.host_token.is_empty() {
|
|
|
|
|
|
setting_card(ui, p, |ui| {
|
|
|
|
|
|
ui.horizontal(|ui| {
|
|
|
|
|
|
ui.label(RichText::new(t("大腦")).size(FONT_BASE).strong());
|
|
|
|
|
|
help_mark(ui, "帳號裡選一台 AnyBot 當大腦(通常是跟帳號伺服器放在一起、一直開著的那台)。所有 Bot、聊天、記憶和例行任務都放在大腦上,由它思考和排程。其他電腦打開 AnyBot 看到的都是同一份,同時也是大腦的手腳:大腦的 Bot 可以透過裝置連結操作它們。大腦連不到時,這裡還能看之前的對話,但不能傳訊息,連回來就恢復。", p);
|
|
|
|
|
|
});
|
|
|
|
|
|
let s = &self.state.remote;
|
|
|
|
|
|
let status = self.mir.status.lock().unwrap().clone();
|
|
|
|
|
|
if self.mir.on {
|
|
|
|
|
|
ui.horizontal(|ui| {
|
|
|
|
|
|
let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), egui::Sense::hover());
|
|
|
|
|
|
ui.painter().circle_filled(r.center(), 4.0, if self.mir.online { Color32::from_rgb(0x14, 0xc3, 0x70) } else { p.text3 });
|
|
|
|
|
|
ui.label(RichText::new(status).size(13.0).color(p.text2));
|
|
|
|
|
|
});
|
|
|
|
|
|
ui.horizontal(|ui| {
|
|
|
|
|
|
let busy = self.mir.moving.load(Ordering::SeqCst);
|
|
|
|
|
|
if ui.add_enabled(self.mir.online && !busy, secondary_button("把這台的 Bot 搬過去")).on_hover_text(t("把這台電腦原本的 Bot(連同聊天)、模型金鑰、技能和連接器複製到大腦。這台的原始資料不會刪掉。")).clicked() {
|
|
|
|
|
|
self.mirror_move_local();
|
|
|
|
|
|
}
|
|
|
|
|
|
if ui.add(secondary_button("改回用這台自己的 Bot")).clicked() {
|
|
|
|
|
|
self.mir.leave = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
let note = self.mir.move_note.lock().unwrap().clone();
|
|
|
|
|
|
if !note.is_empty() {
|
|
|
|
|
|
ui.label(RichText::new(note).size(12.5).color(p.text2));
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
ui.label(RichText::new(t("這台就是大腦:Bot 在這台電腦上思考和工作。")).size(13.0).color(p.text2));
|
|
|
|
|
|
let me = s.host_id.clone();
|
|
|
|
|
|
let peers: Vec<(String, String, bool)> = self.rm.peers.lock().unwrap().iter().filter(|(id, _, _)| !id.is_empty() && *id != me).cloned().collect();
|
|
|
|
|
|
if peers.is_empty() {
|
|
|
|
|
|
hint(ui, "帳號裡還沒有其他 AnyBot。在伺服器上啟動 anybot --serve 並登入同一個帳號,它就會出現在這裡。", p);
|
|
|
|
|
|
}
|
|
|
|
|
|
for (id, name, online) in peers {
|
|
|
|
|
|
ui.horizontal(|ui| {
|
|
|
|
|
|
let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), egui::Sense::hover());
|
|
|
|
|
|
ui.painter().circle_filled(r.center(), 4.0, if online { Color32::from_rgb(0x14, 0xc3, 0x70) } else { p.text3 });
|
|
|
|
|
|
ui.label(RichText::new(&name).size(13.5));
|
|
|
|
|
|
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
|
|
|
|
|
if ui.add_enabled(online, secondary_button("改用它當大腦")).clicked() {
|
|
|
|
|
|
self.mir.want = Some((id.clone(), name.clone()));
|
|
|
|
|
|
// The brain's bots may use this computer (each action still asks).
|
|
|
|
|
|
self.state.remote.device_link = true;
|
|
|
|
|
|
changed = 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
|
|
|
|
// ── 同一個網路 ──
|
|
|
|
|
|
setting_card(ui, p, |ui| {
|
|
|
|
|
|
setting_title(ui, "同一個網路直接連", "手機和這台電腦在同一個 Wi-Fi,或都裝了 Tailscale 時使用。", p);
|
|
|
|
|
|
let has_pw = !self.state.remote.password.is_empty();
|
|
|
|
|
|
if ui.add_enabled(has_pw, egui::Checkbox::new(&mut self.state.remote.lan, t("讓手機直接連這台電腦"))).changed() {
|
|
|
|
|
|
changed = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
if self.state.remote.lan {
|
|
|
|
|
|
if let Some(port) = remote::lan_port() {
|
|
|
|
|
|
for a in remote::addresses(port) {
|
|
|
|
|
|
ui.horizontal(|ui| {
|
|
|
|
|
|
ui.label(RichText::new(&a).monospace().size(13.0));
|
|
|
|
|
|
if ui.small_button(t("複製")).clicked() {
|
|
|
|
|
|
ui.ctx().copy_text(a.clone());
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
ui.add_space(4.0);
|
|
|
|
|
|
form_row(ui, if has_pw { "更改手機密碼" } else { "設定手機密碼" }, p, |ui| {
|
|
|
|
|
|
ui.add(egui::TextEdit::singleline(&mut self.rm.pw1).password(true).hint_text(t("至少 8 個字")).desired_width(f32::INFINITY));
|
|
|
|
|
|
});
|
|
|
|
|
|
form_row(ui, "再輸入一次", p, |ui| {
|
|
|
|
|
|
ui.add(egui::TextEdit::singleline(&mut self.rm.pw2).password(true).desired_width(f32::INFINITY));
|
|
|
|
|
|
});
|
|
|
|
|
|
let ok = self.rm.pw1.chars().count() >= 8 && self.rm.pw1 == self.rm.pw2;
|
|
|
|
|
|
if ui.add_enabled(ok, secondary_button("儲存密碼")).clicked() {
|
|
|
|
|
|
self.state.remote.password = crate::httpd::hash_password(&self.rm.pw1);
|
|
|
|
|
|
// A new password signs every phone out.
|
|
|
|
|
|
self.state.remote.devices.clear();
|
|
|
|
|
|
self.rm.pw1.clear();
|
|
|
|
|
|
self.rm.pw2.clear();
|
|
|
|
|
|
changed = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
if !self.rm.pw2.is_empty() && self.rm.pw1 != self.rm.pw2 {
|
|
|
|
|
|
hint(ui, "兩次輸入的密碼不一樣", p);
|
|
|
|
|
|
}
|
|
|
|
|
|
hint(ui, "不要把這個連接埠開放到網際網路上;從外面連請用上面的帳號,或 Tailscale。", p);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ── 允許手機做什麼 ──
|
|
|
|
|
|
setting_card(ui, p, |ui| {
|
|
|
|
|
|
setting_title(ui, "手機可以做什麼", "聊天、核准、看 Bot 的電腦一律可以。下面這些會碰到這台電腦本身,預設關閉。", p);
|
|
|
|
|
|
if ui.checkbox(&mut self.state.remote.mac_from_phone, t("從手機看這台電腦的螢幕並操作(點、打字、按鍵)")).changed() {
|
|
|
|
|
|
changed = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
if ui.checkbox(&mut self.state.remote.device_link, t("裝置連結:讓帳號裡其他的 AnyBot(例如放在伺服器上的)操作這台電腦")).changed() {
|
|
|
|
|
|
changed = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
hint(ui, "裝置連結開著時,伺服器上的 Bot 可以把「你的電腦」的螢幕、檔案、指令和 Mac App 工具交給這台電腦執行。每個動作仍照那邊的權限規則詢問你(手機上也能核准)。", p);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ── 已登入的裝置 ──
|
|
|
|
|
|
setting_card(ui, p, |ui| {
|
|
|
|
|
|
setting_title(ui, "直接連線的裝置", "用手機密碼登入過的手機和瀏覽器", p);
|
|
|
|
|
|
if self.state.remote.devices.is_empty() {
|
|
|
|
|
|
hint(ui, "還沒有", p);
|
|
|
|
|
|
}
|
|
|
|
|
|
let mut remove = None;
|
|
|
|
|
|
for (i, d) in self.state.remote.devices.iter().enumerate() {
|
|
|
|
|
|
ui.horizontal(|ui| {
|
|
|
|
|
|
ui.label(RichText::new(&d.name).size(FONT_BASE));
|
|
|
|
|
|
let seen = chrono::DateTime::from_timestamp(d.last_seen.max(d.created), 0).map(|t| t.with_timezone(&chrono::Local).format("最近 %m/%d %H:%M").to_string()).unwrap_or_default();
|
|
|
|
|
|
ui.label(RichText::new(seen).size(12.0).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(i);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(i) = remove {
|
|
|
|
|
|
self.state.remote.devices.remove(i);
|
|
|
|
|
|
changed = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ── 團隊 ──
|
|
|
|
|
|
if !self.state.remote.host_token.is_empty() {
|
|
|
|
|
|
setting_card(ui, p, |ui| {
|
|
|
|
|
|
setting_title(ui, "分享給團隊", "讓帳號伺服器上的其他帳號也能用這台 AnyBot 的 Bot。", p);
|
|
|
|
|
|
hint(ui, "成員:可以和 Bot 聊天、附加檔案;Bot 會知道是誰在說話。管理員:另外可以核准 Bot 要做的事、停止、看和操作電腦畫面。需要你允許的事只通知你和管理員。", p);
|
|
|
|
|
|
let loaded = self.rm.members.lock().unwrap().clone();
|
|
|
|
|
|
if loaded.is_none() {
|
|
|
|
|
|
*self.rm.members.lock().unwrap() = Some(Ok(vec![]));
|
|
|
|
|
|
let (m, s) = (self.rm.members.clone(), self.state.remote.clone());
|
|
|
|
|
|
std::thread::spawn(move || *m.lock().unwrap() = Some(remote::members(&s)));
|
|
|
|
|
|
}
|
|
|
|
|
|
let mut change: Option<(String, &str)> = None;
|
|
|
|
|
|
match loaded {
|
|
|
|
|
|
Some(Ok(list)) => {
|
|
|
|
|
|
for (user, role) in list {
|
|
|
|
|
|
ui.horizontal(|ui| {
|
|
|
|
|
|
ui.label(RichText::new(&user).size(FONT_BASE));
|
|
|
|
|
|
ui.label(RichText::new(if role == "admin" { "管理員" } else { "成員" }).size(12.0).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() {
|
|
|
|
|
|
change = Some((user.clone(), "remove"));
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Some(Err(e)) => hint(ui, format!("讀不到分享清單:{e}"), p),
|
|
|
|
|
|
None => {}
|
|
|
|
|
|
}
|
|
|
|
|
|
ui.horizontal(|ui| {
|
|
|
|
|
|
ui.add(egui::TextEdit::singleline(&mut self.rm.new_member).hint_text(t("對方的帳號")).desired_width(160.0));
|
|
|
|
|
|
ui.checkbox(&mut self.rm.new_admin, t("管理員"));
|
|
|
|
|
|
if ui.add_enabled(!self.rm.new_member.trim().is_empty(), secondary_button("分享")).clicked() {
|
|
|
|
|
|
change = Some((self.rm.new_member.trim().to_string(), if self.rm.new_admin { "admin" } else { "member" }));
|
|
|
|
|
|
self.rm.new_member.clear();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
// Talk to the relay off the window's thread, then show the new list.
|
|
|
|
|
|
if let Some((user, role)) = change {
|
|
|
|
|
|
let (m, note, s, role) = (self.rm.members.clone(), self.rm.note.clone(), self.state.remote.clone(), role.to_string());
|
|
|
|
|
|
std::thread::spawn(move || {
|
|
|
|
|
|
let r = remote::set_member(&s, &user, &role);
|
|
|
|
|
|
*note.lock().unwrap() = match r {
|
|
|
|
|
|
Ok(()) if role == "remove" => format!("已停止分享給 {user}"),
|
|
|
|
|
|
Ok(()) => format!("已分享給 {user}"),
|
|
|
|
|
|
Err(e) => e,
|
|
|
|
|
|
};
|
|
|
|
|
|
*m.lock().unwrap() = Some(remote::members(&s));
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── 帳號裡的其他電腦 ──
|
|
|
|
|
|
if !self.state.remote.host_token.is_empty() {
|
|
|
|
|
|
setting_card(ui, p, |ui| {
|
|
|
|
|
|
setting_title(ui, "帳號裡的電腦", "登入同一個帳號的 AnyBot。開了裝置連結的電腦,Bot 可以操作它。", p);
|
|
|
|
|
|
if ui.small_button(t("重新整理")).clicked() || (self.rm.peers.lock().unwrap().is_empty() && !self.rm.busy.load(Ordering::SeqCst)) {
|
|
|
|
|
|
let (peers, s) = (self.rm.peers.clone(), self.state.remote.clone());
|
|
|
|
|
|
let first = peers.lock().unwrap().is_empty();
|
|
|
|
|
|
if first {
|
|
|
|
|
|
peers.lock().unwrap().push((String::new(), "讀取中…".into(), false));
|
|
|
|
|
|
}
|
|
|
|
|
|
std::thread::spawn(move || {
|
|
|
|
|
|
let v = remote::devices_online(&s);
|
|
|
|
|
|
*peers.lock().unwrap() = if v.is_empty() { vec![(String::new(), "(沒有)".into(), false)] } else { v };
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
for (id, name, online) in self.rm.peers.lock().unwrap().iter() {
|
|
|
|
|
|
ui.horizontal(|ui| {
|
|
|
|
|
|
let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), egui::Sense::hover());
|
|
|
|
|
|
if !id.is_empty() {
|
|
|
|
|
|
ui.painter().circle_filled(r.center(), 4.0, if *online { Color32::from_rgb(0x14, 0xc3, 0x70) } else { p.text3 });
|
|
|
|
|
|
}
|
|
|
|
|
|
let me = *id == self.state.remote.host_id;
|
|
|
|
|
|
ui.label(RichText::new(format!("{name}{}", if me { "(這台)" } else { "" })).size(13.5));
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
if changed {
|
|
|
|
|
|
self.mark();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Other AnyBots of this account that let bots operate them (裝置連結), as computers.
|
|
|
|
|
|
pub(super) fn linked_devices(&self) -> Vec<tools::Target> {
|
|
|
|
|
|
if self.state.remote.host_token.is_empty() {
|
|
|
|
|
|
return vec![];
|
|
|
|
|
|
}
|
|
|
|
|
|
self.rm
|
|
|
|
|
|
.peers
|
|
|
|
|
|
.lock()
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.filter(|(id, _, online)| !id.is_empty() && *online && *id != self.state.remote.host_id)
|
|
|
|
|
|
.map(|(id, name, _)| tools::Target { name: name.clone(), place: tools::Place::Device { host: id.clone() } })
|
|
|
|
|
|
.collect()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|