AnyBot/src/app/remote_ui.rs

702 lines
37 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 手機與帳號 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;
self.ctx_handle = Some(ctx.clone());
let c = ctx.clone();
remote::hub().set_repaint(Arc::new(move || c.request_repaint()));
self.remote_apply_settings();
self.mirror_boot();
}
/// 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() {
// 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;
}
self.remote_cmd(ctx, c);
}
self.brain_serve();
self.brain_watch();
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;
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) {
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;
}
});
}
if !self.mir.on && !self.rm.last_publish.map(|t| t.elapsed().as_millis() < 120).unwrap_or(false) {
self.remote_publish();
}
}
/// 加一台電腦: one line to run on the new computer (a Linux box, a server); it joins as hands for
/// the brain, with no password and nothing to answer.
fn pair_section(&mut self, ui: &mut egui::Ui) {
let p = self.pal;
ui.add_space(6.0);
ui.separator();
ui.horizontal(|ui| {
ui.label(RichText::new(t("加一台電腦")).size(13.5).strong());
help_mark(ui, "讓另一台電腦(例如沒有螢幕的 Linux)也當大腦的手腳:在那台的終端機貼上這一行就好,不用帳號密碼、不用回答問題。它會設成開機自動執行,只讓這個大腦使用。", p);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.add(secondary_button("產生配對碼")).clicked() {
let (s, slot) = (self.state.remote.clone(), self.mir.pair.clone());
let for_host = if self.mir.on { s.brain_id.clone() } else { s.host_id.clone() };
*slot.lock().unwrap() = (t("正在產生…"), false, None);
std::thread::spawn(move || {
*slot.lock().unwrap() = match crate::remote::pair_code(&s, &for_host) {
Ok(code) => (format!("anybot setup {} {code}", s.relay_url), true, Some(Instant::now())),
Err(e) => (e, false, None),
};
});
}
});
});
let (line, ok, at) = self.mir.pair.lock().unwrap().clone();
if line.is_empty() {
return;
}
let left = at.map(|t| 600u64.saturating_sub(t.elapsed().as_secs())).unwrap_or(0);
if ok && left == 0 {
hint(ui, "配對碼過期了,再按一次「產生配對碼」。", p);
return;
}
if !ok {
let c = if line.ends_with('…') { p.text2 } else { p.danger };
ui.label(RichText::new(line).size(12.5).color(c));
return;
}
Frame::new().fill(p.hover).corner_radius(8).inner_margin(Margin::symmetric(10, 8)).show(ui, |ui| {
ui.horizontal(|ui| {
ui.add(egui::Label::new(RichText::new(&line).monospace().size(13.0)).selectable(true).wrap());
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.add(secondary_button("複製")).clicked() {
ui.ctx().copy_text(line.clone());
self.notice = Some(t("已複製,貼到那台電腦的終端機執行"));
}
});
});
});
ui.label(RichText::new(format!("{}(還有 {} 分 {:02} 秒,只能用一次)", t("在那台電腦的終端機貼上執行"), left / 60, left % 60)).size(12.0).color(p.text3));
// Just made: bring it into view.
if at.map(|t| t.elapsed().as_millis() < 800).unwrap_or(false) {
ui.scroll_to_cursor(Some(Align::Max));
}
ui.ctx().request_repaint_after(std::time::Duration::from_secs(1));
}
fn remote_signature(&self) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
self.state.remote.host_name.hash(&mut h);
self.scr.cfg_hash.hash(&mut h);
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.thinking.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),
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();
}
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));
}
});
// ── 大腦 ──
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 && !remote::is_hands(id)).cloned().collect();
if peers.is_empty() {
hint(ui, "帳號裡還沒有別台可以當大腦的 AnyBot。", 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;
}
});
});
}
}
self.pair_section(ui);
});
}
// ── 同一個網路 ──
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()
}
}