The phone page does what the desktop does, laid out like it
A new mobile/index.html in the desktop's (and Grok Bot's) shape: the sidebar with its sections and folders, the chat with steps, thinking, reactions, quotes and the approval card, 對話詳細資訊, Bot 設定, routines, pages, computers and 設定. Each action goes to the desktop's own code on the app's thread (api/act/<what> -> App::phone_act): appctl, set_pinned, restart_bot, toggle_reaction, box_task, channels_restart and the rest. No new rules. Keys, channel tokens and the vault never reach the phone; a model key can only be written. Admins keep what the phone always let them do; making, changing or deleting bots, routines, memories, pages, folders and settings stays the owner's. The state the phone polls now carries the sidebar's section order. Checked by using it on a preview brain: routines, memories, templates, folders, keys, signing another phone out, approvals and a chat with a fake model. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
This commit is contained in:
parent
81e87545cd
commit
f3ccd129d6
54
FEATURES.md
54
FEATURES.md
|
|
@ -1681,32 +1681,44 @@ computer_use_screenshot、click、mouse_move、mouse_button(按住/放開)
|
|||
- 打開方式:
|
||||
- 用帳號伺服器時,打開帳號伺服器的網址,用帳號登入;
|
||||
- 在同一個網路或 Tailscale 時,打開 `http://這台電腦:8793`,用手機密碼登入。
|
||||
- 可以「加入主畫面」,像 App 一樣全螢幕打開。顏色跟系統的淺色、深色走。
|
||||
- Bot 清單:
|
||||
- 每個 Bot 的頭像顏色、最後一句話;
|
||||
- 工作中顯示狀態;
|
||||
- 等你允許時顯示橘色的「等你決定」;
|
||||
- 有新訊息時顯示藍點。
|
||||
- 聊天:
|
||||
- Bot 的回覆用 Markdown 顯示,包含程式碼、清單、連結;
|
||||
- 工具步驟每步一行,有狀態符號;
|
||||
- 生活卡片;
|
||||
- 群組裡每則回覆標出是誰說的。
|
||||
- 允許請求:卡片固定在輸入框上方,有「允許一次」「一律允許」「拒絕」三個按鈕,跟電腦版同一套規則。
|
||||
- 輸入:
|
||||
- Bot 工作中再傳訊息算插話;
|
||||
- 輸入框空白時,送出鍵會變成停止鍵;
|
||||
- 可以附加照片或檔案,最大 30 MB;
|
||||
- 右上角「+」開始新對話。
|
||||
- 可以「加入主畫面」,像 App 一樣全螢幕打開。顏色、強調色跟電腦版的主題走(淺色、深色)。
|
||||
- 排版照電腦版的側欄和 Grok Bot 的手機版:一層一層往下點,手機的返回手勢回上一層;選單和表單都從下方彈出。
|
||||
- 首頁(電腦版的側欄):
|
||||
- 上面一排捷徑:例行任務、頁面、電腦(線上幾台)、設定;可以搜尋 Bot;
|
||||
- 分區照電腦版:主要 Bot、已釘選的 Bot、你的分區(照電腦版的順序,空的也顯示)、未分區;分區可以收合;
|
||||
- 每個 Bot 顯示頭像、最後一句話、工作中/等你決定/排隊中、藍點;
|
||||
- Bot 的「⋯」(或長按):開啟對話、對話詳細資訊、標示已讀/未讀、釘選、移至分區、設為主要 Bot、重新啟動、刪除;
|
||||
- 分區的「⋯」:重新命名、刪除分區(裡面的 Bot 回到未分區,不會刪 Bot);
|
||||
- 右上角「+」:建立 Bot、從範本建立、建立群組聊天、新增分區。
|
||||
- 聊天(電腦版的對話):
|
||||
- Markdown、程式碼、表格、生活卡片、頁面卡片;日期分隔;群組標出是誰說的;
|
||||
- 思考過程和工具步驟可以展開,看得到每步的結果;一輪做完會收起來;
|
||||
- 訊息下方:複製、重新產生;長按:複製、引用回覆、重新產生、重新送出、表情回應;
|
||||
- 允許請求卡片:允許一次、一律允許、拒絕,以及「全部允許,之後直接操作」;Bot 需要你接手時有電腦畫面縮圖;
|
||||
- 輸入:工作中再傳是插話(顯示排隊)、空白時送出鍵變停止、附加照片/檔案(最大 30 MB)、拍照、語音輸入、建議問題;
|
||||
- 上方「⋯」:新對話、過去的對話、清除對話、對話詳細資訊、Bot 設定。
|
||||
- 對話詳細資訊:動態、例行任務、資料庫(頁面、記憶、檔案)、電腦、成員(群組)。
|
||||
- Bot 設定:名稱、說明、指示、外型和顏色、模型、電腦、電腦工具開關、上下文長度、允許規則、釘選、主要 Bot、分區、範例、重新啟動、清除對話、刪除。
|
||||
- 例行任務:新增、編輯(每天/每週幾/每月、時間)、暫停、立刻執行、刪除。
|
||||
- 頁面:完整頁面(圖表、Mermaid)、舊版本、複製 Markdown、刪除。
|
||||
- 設定(擁有者):
|
||||
- 一般(主題、強調色、時區、通知、語言)、權限(直接操作、自動審核、允許/詢問規則)、整合(訊息管道、MCP 開關和移除);
|
||||
- 模型:每個服務是否可以用;可以設定或換 API 金鑰、清除金鑰、新 Bot 預設模型。**金鑰只能寫入**,手機永遠看不到原本的金鑰;
|
||||
- 電腦:Bot 的電腦的狀態和縮圖、開啟電腦、接管、喚醒、儲存快照、檢查用量、更新、自動更新時間、容器隔離、從快照復原/重設(會先確認)、「請 Bot 提出清理方案」(帶到主要 Bot 的對話,先列出不刪除);
|
||||
- 保險庫:只說明,不傳任何密碼到手機;
|
||||
- 手機與帳號:直接連線的手機清單,可以登出別支手機;
|
||||
- 更新:版本和有沒有新版。
|
||||
- 看電腦畫面:
|
||||
- 「Bot 的電腦」每秒更新一次畫面;在休眠時按「叫醒」。
|
||||
- 「這台 Mac」要先在設定裡打開才會出現。
|
||||
- 操作方式:點一下是左鍵;長按是右鍵;點兩下是雙擊;「右鍵:開」之後每次點都是右鍵。
|
||||
- 「⌨︎ 打字」可以打中文,會打進目前的視窗;還有 Enter、Esc、Tab、⌫ 和捲動按鈕。
|
||||
- 選單(右上角「⋯」):
|
||||
- 開啟通知(只有用帳號登入時才有;iPhone 要先加入主畫面);
|
||||
- 切換到別台 AnyBot;
|
||||
- 登出這支手機。
|
||||
- 右上角頭像:開啟通知(只有用帳號登入時才有;iPhone 要先加入主畫面)、切換到別台 AnyBot、登出這支手機。
|
||||
- 手機做的事都交給電腦版原本的功能處理(同一套規則、同一份資料),不是另外寫一套。
|
||||
- 團隊(用帳號伺服器分享時)權限跟以前一樣:
|
||||
- 成員只能聊天和附檔;
|
||||
- 管理員另外可以核准、停止、看和操作電腦畫面、換模型和電腦、新對話、重新產生,也能看對話詳細資訊、例行任務和頁面;
|
||||
- 建立、修改、刪除 Bot/例行任務/記憶/頁面/分區,以及設定、金鑰、訊息管道、MCP、登出別支手機、快照和重設電腦,只有擁有者能做(手機上也不會顯示這些按鈕)。
|
||||
- 畫面更新:手機問「有沒有新東西」會等最多 20 秒,有變化就立刻回來,所以不用一直刷新。
|
||||
|
||||
### 設定 › 手機與帳號
|
||||
|
|
|
|||
2257
mobile/index.html
2257
mobile/index.html
File diff suppressed because it is too large
Load Diff
|
|
@ -25,7 +25,7 @@ pub struct ChannelUi {
|
|||
/// Teams sign-in: the code to show, and the refresh token when done.
|
||||
ms_login: Arc<Mutex<(String, Option<String>)>>,
|
||||
mcp_sig: String,
|
||||
mcp_errors: Arc<Mutex<Vec<(String, String)>>>,
|
||||
pub(super) mcp_errors: Arc<Mutex<Vec<(String, String)>>>,
|
||||
mcp_busy: Arc<AtomicBool>,
|
||||
new_mcp: (String, String, String, String),
|
||||
/// 連接器市集 was asked for (scroll it into view once).
|
||||
|
|
|
|||
|
|
@ -484,7 +484,7 @@ impl App {
|
|||
ui.add_space(10.0);
|
||||
}
|
||||
|
||||
fn toggle_reaction(&mut self, bot_id: u64, idx: usize, e: &str) {
|
||||
pub(super) fn toggle_reaction(&mut self, bot_id: u64, idx: usize, e: &str) {
|
||||
if let Some(b) = self.state.bot_mut(bot_id) {
|
||||
if let Some(m) = b.messages.get_mut(idx) {
|
||||
if let Some(i) = m.reactions.iter().position(|x| x == e) {
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ pub struct ComputerUi {
|
|||
pub control: bool,
|
||||
pub confirm: Option<Rebuild>,
|
||||
pub note: Arc<Mutex<String>>,
|
||||
disk: Arc<Mutex<Option<String>>>,
|
||||
pub(super) disk: Arc<Mutex<Option<String>>>,
|
||||
new_secret: (String, String),
|
||||
replace_secret: Option<String>,
|
||||
secret_error: String,
|
||||
|
|
@ -863,7 +863,7 @@ impl App {
|
|||
}
|
||||
}
|
||||
|
||||
fn box_task(&mut self, f: impl FnOnce() -> Result<String, String> + Send + 'static) {
|
||||
pub(super) 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 || {
|
||||
|
|
|
|||
|
|
@ -429,13 +429,21 @@ fn cache_path() -> PathBuf {
|
|||
|
||||
impl App {
|
||||
/// The brain: answer the screens' requests (on the app's thread, where the state is).
|
||||
pub(super) fn brain_serve(&mut self) {
|
||||
pub(super) fn brain_serve(&mut self, ctx: &egui::Context) {
|
||||
let reqs = remote::hub().take_sync();
|
||||
if reqs.is_empty() {
|
||||
return;
|
||||
}
|
||||
for r in reqs {
|
||||
let v = match r.kind {
|
||||
// 手機 (not a screen): what the desktop does, done the same way.
|
||||
SyncKind::Phone { action, body, role } => {
|
||||
if self.mir.on {
|
||||
json!({"error": "這台 AnyBot 連著別的大腦,請在大腦上操作"})
|
||||
} else {
|
||||
self.phone_act(ctx, &action, &body, &role)
|
||||
}
|
||||
}
|
||||
SyncKind::Pull { client, since } => self.brain_pull(&client, since),
|
||||
SyncKind::Models(name) => {
|
||||
let kind = serde_json::from_value::<crate::providers::ProviderKind>(json!(name)).ok();
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ impl App {
|
|||
}
|
||||
self.remote_cmd(ctx, c);
|
||||
}
|
||||
self.brain_serve();
|
||||
self.brain_serve(ctx);
|
||||
self.brain_watch();
|
||||
if let Some(rx) = &self.rm.join_rx {
|
||||
if let Ok(r) = rx.try_recv() {
|
||||
|
|
@ -209,8 +209,12 @@ impl App {
|
|||
self.state.remote.host_name.hash(&mut h);
|
||||
self.state.timezone.hash(&mut h);
|
||||
self.scr.cfg_hash.hash(&mut h);
|
||||
(&self.state.folders, self.state.loose_index).hash(&mut h);
|
||||
for b in &self.state.bots {
|
||||
(b.id, &b.name, &b.color, b.unread, b.messages.len()).hash(&mut h);
|
||||
// (What the phone's home, editor and reactions show.)
|
||||
(&b.description, &b.shape, b.pinned, &b.folder, b.members.len(), b.instructions.len(), b.memories.len(), b.tools_enabled).hash(&mut h);
|
||||
b.messages.iter().rev().take(remote::PHONE_TAIL).map(|m| m.reactions.len()).sum::<usize>().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 {
|
||||
|
|
@ -218,7 +222,7 @@ impl App {
|
|||
}
|
||||
}
|
||||
if let Some(l) = self.streams.get(&b.id) {
|
||||
(1u8, &l.status, l.paused).hash(&mut h);
|
||||
(1u8, &l.status, l.paused, l.steer.lock().unwrap().len()).hash(&mut h);
|
||||
}
|
||||
self.approvals.get(&b.id).map(|a| a.call_id.clone()).hash(&mut h);
|
||||
// The phone's home and bot sheet: its model, computer, routines and past chats.
|
||||
|
|
@ -249,7 +253,7 @@ impl App {
|
|||
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() };
|
||||
let mut view = remote::View { host: self.state.remote.host_name.clone(), sections: self.section_order(), ..Default::default() };
|
||||
let peers = self.rm.peers.lock().unwrap().clone();
|
||||
for b in bots {
|
||||
// Its computers, each as the phone shows it: what it is and whether it is there now.
|
||||
|
|
@ -282,9 +286,37 @@ impl App {
|
|||
"computer": computer, "computers": computers,
|
||||
"routines": b.routines.iter().map(|r| json!({"id": r.id, "title": r.title, "paused": r.paused, "when": crate::appctl::routine_when(r)})).collect::<Vec<_>>(),
|
||||
"threads": b.threads.iter().enumerate().rev().take(20).map(|(i, (title, msgs))| json!({"index": i, "title": title, "count": msgs.len(), "at": msgs.last().map(|m| m.at).unwrap_or(0)})).collect::<Vec<_>>(),
|
||||
// The sidebar's sections, the colour by name (the editor), what is queued while it works.
|
||||
"folder": b.folder, "color_key": b.color,
|
||||
"queued": live.map(|l| l.steer.lock().unwrap().clone()).unwrap_or_default(),
|
||||
}));
|
||||
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, &self.state.pages)).collect());
|
||||
// Each message with its place in the chat (表情回應), its thinking and, on each step,
|
||||
// what came back (the desktop's 顯示步驟 / 思考過程), in short.
|
||||
let results: HashMap<&str, &str> = b.messages[start..].iter().filter(|m| m.role == Role::Tool).map(|m| (m.tool_call_id.as_str(), m.text.as_str())).collect();
|
||||
let msgs: Vec<serde_json::Value> = b.messages[start..]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(k, m)| {
|
||||
let mut v = remote::message_json(m, &names, &self.state.pages)?;
|
||||
v["index"] = json!(start + k);
|
||||
if !m.thinking.trim().is_empty() {
|
||||
v["thinking"] = json!(clip(m.thinking.trim(), 1500));
|
||||
}
|
||||
if let Some(tools) = v["tools"].as_array_mut() {
|
||||
for (t, c) in tools.iter_mut().zip(m.tool_calls.iter()) {
|
||||
if t.get("label").is_some() {
|
||||
t["name"] = json!(c.name);
|
||||
if let Some(r) = results.get(c.id.as_str()) {
|
||||
t["result"] = json!(clip(r.trim(), 600));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(v)
|
||||
})
|
||||
.collect();
|
||||
view.messages.insert(b.id, msgs);
|
||||
if let Some(a) = self.approvals.get(&b.id) {
|
||||
view.approvals.push(approval_json(b, a));
|
||||
}
|
||||
|
|
@ -858,3 +890,576 @@ mod phone_tests {
|
|||
assert!(!super::chats("grok-imagine-video-1.5") && !super::chats("text-embedding-3-large") && !super::chats("gpt-4o-mini-tts"));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 手機: everything the desktop does, from the phone ----------
|
||||
// Each action calls what the desktop's own button calls (appctl for the actions a bot can take
|
||||
// too, set_pinned, restart_bot, toggle_reaction, enable_direct_mode, box_task…), so the phone
|
||||
// adds no ways of its own. Members never get here (remote::role_allows); some things are the
|
||||
// owner's alone: deleting a bot, permissions and rules, channels, connectors and the box's
|
||||
// runtime, update and schedule. Keys, tokens and the vault's secrets are never sent.
|
||||
|
||||
/// Docker facts for the phone's 電腦 settings, refreshed in the background (asking Docker on the
|
||||
/// app's thread would stall the window): (when, update available, gVisor here, runs on).
|
||||
static BOX_FACTS: std::sync::Mutex<Option<(Instant, serde_json::Value)>> = std::sync::Mutex::new(None);
|
||||
static BOX_FACTS_BUSY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
fn box_facts() -> serde_json::Value {
|
||||
let (stale, v) = {
|
||||
let g = BOX_FACTS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
(g.as_ref().map(|(t, _)| t.elapsed().as_secs() > 20).unwrap_or(true), g.as_ref().map(|(_, v)| v.clone()).unwrap_or_else(|| json!({})))
|
||||
};
|
||||
// (Unit tests don't ask Docker.)
|
||||
if stale && !cfg!(test) && !BOX_FACTS_BUSY.swap(true, std::sync::atomic::Ordering::SeqCst) {
|
||||
std::thread::spawn(|| {
|
||||
let (n, exec_ms, total_ms) = crate::cloudbox::step_stats();
|
||||
let v = json!({
|
||||
"update": crate::cloudbox::update_available(),
|
||||
"gvisor": crate::cloudbox::host_has_gvisor(),
|
||||
"running": crate::cloudbox::running_runtime(),
|
||||
"steps": {"n": n, "exec_ms": exec_ms, "total_ms": total_ms},
|
||||
});
|
||||
*BOX_FACTS.lock().unwrap_or_else(|e| e.into_inner()) = Some((Instant::now(), v));
|
||||
BOX_FACTS_BUSY.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// At most `n` characters (the phone shows excerpts).
|
||||
fn clip(s: &str, n: usize) -> String {
|
||||
if s.chars().count() <= n {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}…", s.chars().take(n).collect::<String>())
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(super) fn phone_act(&mut self, ctx: &egui::Context, action: &str, b: &serde_json::Value, role: &str) -> serde_json::Value {
|
||||
let owner = role == "owner";
|
||||
let bot = b["bot"].as_u64().unwrap_or(0);
|
||||
let s = |k: &str| b[k].as_str().unwrap_or("").trim().to_string();
|
||||
let fail = |e: &str| json!({"error": e});
|
||||
let done = |t: &str| json!({"ok": true, "text": t});
|
||||
let owner_only = || json!({"error": "這件事只有這台 AnyBot 的擁有者能做"});
|
||||
// 團隊: an admin keeps what the phone always let them do (approve, stop, the screens, the
|
||||
// model and computer, a new chat, regenerate) plus looking and small chat things; making,
|
||||
// changing or deleting bots, routines, memories, pages and settings stays the owner's.
|
||||
let admin_ok = match action {
|
||||
"detail" | "settings" | "pages" | "restart_bot" | "react" | "reply" => true,
|
||||
"bot" => b["key"].as_str() == Some("unread"),
|
||||
"box" => matches!(b["op"].as_str(), Some("wake" | "disk")),
|
||||
_ => false,
|
||||
};
|
||||
if !owner && !admin_ok {
|
||||
return owner_only();
|
||||
}
|
||||
match action {
|
||||
"detail" => match self.state.bot(bot) {
|
||||
Some(x) => self.phone_detail(x),
|
||||
None => fail("找不到這個 Bot"),
|
||||
},
|
||||
"settings" => self.phone_settings(owner),
|
||||
// Every page, newest first (the state the phone polls carries only the latest few).
|
||||
"pages" => {
|
||||
let now = crate::model::now_ms();
|
||||
let mut list: Vec<&crate::pages::Page> = self.state.pages.iter().collect();
|
||||
list.sort_by_key(|p| std::cmp::Reverse(p.updated));
|
||||
let pages: Vec<serde_json::Value> = list
|
||||
.into_iter()
|
||||
.map(|p| {
|
||||
let mut c = crate::pages::card_json(p, now);
|
||||
c["bot"] = json!(self.state.bot(p.bot).map(|x| x.name.clone()).unwrap_or_default());
|
||||
c
|
||||
})
|
||||
.collect();
|
||||
json!({"ok": true, "pages": pages})
|
||||
}
|
||||
// The actions a bot can take on the app (the same appctl path as its tools).
|
||||
"app" => {
|
||||
const ALLOWED: [&str; 15] = [
|
||||
"create_bot", "update_bot", "remember", "forget", "save_skill", "delete_skill", "create_routine", "update_routine",
|
||||
"delete_routine", "run_routine_now", "create_group", "move_to_group", "rename_group", "delete_group", "update_settings",
|
||||
];
|
||||
let name = s("name");
|
||||
if !ALLOWED.contains(&name.as_str()) {
|
||||
return fail("手機不能做這件事");
|
||||
}
|
||||
let args = match &b["args"] {
|
||||
serde_json::Value::String(t) => t.clone(),
|
||||
v if v.is_object() => v.to_string(),
|
||||
_ => "{}".to_string(),
|
||||
};
|
||||
// (The bot it is about; the app-wide ones act as the main bot, as from its chat.)
|
||||
let id = [bot, self.state.primary_bot].into_iter().find(|i| self.state.bot(*i).is_some()).or_else(|| self.state.bots.first().map(|x| x.id)).unwrap_or(0);
|
||||
let next = self.state.next_id;
|
||||
let out = if name == "update_bot" && serde_json::from_str::<serde_json::Value>(&args).map(|v| v["default_computer"].is_string()).unwrap_or(false) {
|
||||
self.update_bot_computer(id, &args)
|
||||
} else {
|
||||
appctl::apply(&mut self.state, id, &name, &args)
|
||||
};
|
||||
if out.theme_changed {
|
||||
self.apply_theme(ctx);
|
||||
}
|
||||
for (target, msg) in out.wake {
|
||||
self.wake(ctx, target, msg);
|
||||
}
|
||||
self.dirty = true;
|
||||
if !out.ok {
|
||||
return fail(&out.text);
|
||||
}
|
||||
let made = (self.state.next_id > next).then_some(next);
|
||||
json!({"ok": true, "text": out.text, "bot": made})
|
||||
}
|
||||
// A bot's switches from its menu and editor.
|
||||
"bot" => {
|
||||
if self.state.bot(bot).is_none() {
|
||||
return fail("找不到這個 Bot");
|
||||
}
|
||||
let v = &b["value"];
|
||||
let key = s("key");
|
||||
if matches!(key.as_str(), "tools" | "allow_remove") && !owner {
|
||||
return owner_only();
|
||||
}
|
||||
match key.as_str() {
|
||||
"pinned" => self.set_pinned(bot, v.as_bool().unwrap_or(false), None),
|
||||
"primary" => self.state.primary_bot = bot,
|
||||
"members" => {
|
||||
let ids: Vec<u64> = v.as_array().map(|a| a.iter().filter_map(|x| x.as_u64()).collect()).unwrap_or_default();
|
||||
let ok: Vec<u64> = ids.into_iter().filter(|i| *i != bot && self.state.bot(*i).map(|x| x.members.is_empty()).unwrap_or(false)).collect();
|
||||
if ok.is_empty() {
|
||||
return fail("群組至少要有一個成員");
|
||||
}
|
||||
if let Some(x) = self.state.bot_mut(bot) {
|
||||
x.members = ok;
|
||||
}
|
||||
}
|
||||
k => {
|
||||
let Some(x) = self.state.bot_mut(bot) else { return fail("找不到這個 Bot") };
|
||||
match k {
|
||||
"unread" => x.unread = v.as_bool().unwrap_or(true),
|
||||
"tools" => x.tools_enabled = v.as_bool().unwrap_or(true),
|
||||
"shape" if v.as_str().map(|t| crate::avatar::SHAPES.contains(&t)).unwrap_or(false) => x.shape = v.as_str().unwrap_or("").to_string(),
|
||||
"color" if v.as_str().map(|t| crate::avatar::COLORS.iter().any(|c| c.0 == t)).unwrap_or(false) => x.color = v.as_str().unwrap_or("").to_string(),
|
||||
"context" => x.context_tokens = v.as_u64().unwrap_or(x.context_tokens).clamp(4_000, 2_000_000),
|
||||
"allow_remove" => x.allow_rules.retain(|r| Some(r.as_str()) != v.as_str()),
|
||||
_ => return fail("不認得這個設定"),
|
||||
}
|
||||
}
|
||||
}
|
||||
self.dirty = true;
|
||||
done("好了")
|
||||
}
|
||||
"group_chat" => {
|
||||
// 群組聊天, as the desktop's new-bot menu makes it.
|
||||
let ids: Vec<u64> = b["members"].as_array().map(|a| a.iter().filter_map(|x| x.as_u64()).collect()).unwrap_or_default();
|
||||
let members: Vec<u64> = ids.into_iter().filter(|i| self.state.bot(*i).map(|x| x.members.is_empty()).unwrap_or(false)).collect();
|
||||
if members.is_empty() {
|
||||
return fail("選至少一個 Bot 當成員");
|
||||
}
|
||||
let id = self.state.next_id;
|
||||
let name = Some(s("name")).filter(|n| !n.is_empty()).unwrap_or_else(|| "群組聊天".into());
|
||||
let sel = self.state.selected;
|
||||
let out = appctl::apply(&mut self.state, sel, "create_bot", &json!({"name": name, "description": "成員依序回答"}).to_string());
|
||||
if !out.ok {
|
||||
return fail(&out.text);
|
||||
}
|
||||
if let Some(x) = self.state.bot_mut(id) {
|
||||
x.members = members;
|
||||
x.shape = "cloud".into();
|
||||
}
|
||||
self.dirty = true;
|
||||
json!({"ok": true, "text": out.text, "bot": id})
|
||||
}
|
||||
"delete_bot" => {
|
||||
if !owner {
|
||||
return owner_only();
|
||||
}
|
||||
if self.state.bot(bot).is_none() {
|
||||
return fail("找不到這個 Bot");
|
||||
}
|
||||
// As the desktop's 刪除 Bot dialog.
|
||||
self.stop(bot);
|
||||
self.state.bots.retain(|x| x.id != bot);
|
||||
self.state.fix_primary();
|
||||
self.dirty = true;
|
||||
done("已刪除")
|
||||
}
|
||||
"restart_bot" => {
|
||||
if self.state.bot(bot).is_none() {
|
||||
return fail("找不到這個 Bot");
|
||||
}
|
||||
self.restart_bot(bot);
|
||||
done("已重新啟動")
|
||||
}
|
||||
"react" => {
|
||||
let idx = b["index"].as_u64().unwrap_or(u64::MAX) as usize;
|
||||
let emoji = s("emoji");
|
||||
if emoji.is_empty() || emoji.chars().count() > 8 || self.state.bot(bot).map(|x| idx >= x.messages.len()).unwrap_or(true) {
|
||||
return fail("找不到這則訊息");
|
||||
}
|
||||
self.toggle_reaction(bot, idx, &emoji);
|
||||
done("好了")
|
||||
}
|
||||
"reply" => {
|
||||
self.remote_cmd(ctx, Cmd::Reply { bot });
|
||||
done("重新傳送中…")
|
||||
}
|
||||
"clear" => {
|
||||
self.remote_cmd(ctx, Cmd::Clear { bot });
|
||||
done("已清除聊天記錄")
|
||||
}
|
||||
"delete_page" => {
|
||||
let id = b["id"].as_u64().unwrap_or(0);
|
||||
let before = self.state.pages.len();
|
||||
self.state.pages.retain(|x| x.id != id);
|
||||
if self.state.pages.len() == before {
|
||||
return fail("這個頁面已經刪除了");
|
||||
}
|
||||
self.mark();
|
||||
done("已刪除頁面")
|
||||
}
|
||||
// 設定 › 一般 / 權限 (the owner's).
|
||||
"setting" => {
|
||||
if !owner {
|
||||
return owner_only();
|
||||
}
|
||||
let v = &b["value"];
|
||||
match s("key").as_str() {
|
||||
"direct_mode" => {
|
||||
if v.as_bool().unwrap_or(false) {
|
||||
self.enable_direct_mode()
|
||||
} else {
|
||||
self.disable_direct_mode()
|
||||
}
|
||||
}
|
||||
"auto_review" => self.state.auto_review = v.as_bool().unwrap_or(false),
|
||||
"notify" => self.state.notify = v.as_bool().unwrap_or(true),
|
||||
"rule_add" | "rule_remove" => {
|
||||
let text = v.as_str().unwrap_or("").trim().to_string();
|
||||
let list = if s("list") == "ask" { &mut self.state.review_ask_rules } else { &mut self.state.review_allow_rules };
|
||||
if s("key") == "rule_add" {
|
||||
if text.is_empty() {
|
||||
return fail("規則是空的");
|
||||
}
|
||||
if !list.contains(&text) {
|
||||
list.push(text);
|
||||
}
|
||||
} else {
|
||||
list.retain(|r| *r != text);
|
||||
}
|
||||
}
|
||||
_ => return fail("不認得這個設定"),
|
||||
}
|
||||
self.dirty = true;
|
||||
done("好了")
|
||||
}
|
||||
// 設定 › 模型: a provider's key (written, never read back) and new bots' model.
|
||||
"provider" => {
|
||||
if !owner {
|
||||
return owner_only();
|
||||
}
|
||||
let Some(kind) = crate::providers::ALL_PROVIDERS.iter().copied().find(|k| serde_json::to_value(k).ok().as_ref() == Some(&b["provider"])) else { return fail("不認得這個模型服務") };
|
||||
let ps = self.state.providers.entry(kind.key().to_string()).or_default();
|
||||
if let Some(k) = b["key"].as_str() {
|
||||
ps.api_key = k.trim().to_string();
|
||||
}
|
||||
if let Some(m) = b["default_model"].as_str() {
|
||||
ps.default_model = m.trim().to_string();
|
||||
}
|
||||
self.dirty = true;
|
||||
done("好了")
|
||||
}
|
||||
// 手機與帳號: sign a direct-connection phone out (as the desktop's list does).
|
||||
"device_remove" => {
|
||||
if !owner {
|
||||
return owner_only();
|
||||
}
|
||||
let (name, created) = (s("name"), b["created"].as_i64().unwrap_or(0));
|
||||
let before = self.state.remote.devices.len();
|
||||
self.state.remote.devices.retain(|d| !(d.name == name && d.created == created));
|
||||
if self.state.remote.devices.len() == before {
|
||||
return fail("找不到這支手機");
|
||||
}
|
||||
self.dirty = true;
|
||||
done("已登出那支手機")
|
||||
}
|
||||
"channel" => {
|
||||
if !owner {
|
||||
return owner_only();
|
||||
}
|
||||
let id = b["id"].as_u64().unwrap_or(0);
|
||||
if !self.state.channels.iter().any(|c| c.id == id) {
|
||||
return fail("找不到這個管道");
|
||||
}
|
||||
if b["remove"].as_bool().unwrap_or(false) {
|
||||
self.state.channels.retain(|c| c.id != id);
|
||||
} else if let Some(c) = self.state.channels.iter_mut().find(|c| c.id == id) {
|
||||
c.enabled = b["enabled"].as_bool().unwrap_or(c.enabled);
|
||||
}
|
||||
self.dirty = true;
|
||||
self.channels_restart(ctx);
|
||||
done("好了")
|
||||
}
|
||||
"mcp" => {
|
||||
if !owner {
|
||||
return owner_only();
|
||||
}
|
||||
let name = s("name");
|
||||
if !self.state.mcp_servers.iter().any(|m| m.name == name) {
|
||||
return fail("找不到這個連接器");
|
||||
}
|
||||
// (mcp_poll connects or drops it when the list changes.)
|
||||
if b["remove"].as_bool().unwrap_or(false) {
|
||||
self.state.mcp_servers.retain(|m| m.name != name);
|
||||
} else if let Some(m) = self.state.mcp_servers.iter_mut().find(|m| m.name == name) {
|
||||
m.enabled = b["enabled"].as_bool().unwrap_or(m.enabled);
|
||||
}
|
||||
self.dirty = true;
|
||||
done("好了")
|
||||
}
|
||||
// 設定 › 電腦's buttons for Bot 的電腦.
|
||||
"box" => {
|
||||
let op = s("op");
|
||||
if matches!(op.as_str(), "update" | "reset" | "runtime" | "auto_update") && !owner {
|
||||
return owner_only();
|
||||
}
|
||||
match op.as_str() {
|
||||
"wake" => self.box_task(|| crate::cloudbox::ensure_running().map(|_| "Bot 的電腦已經開好了。".to_string())),
|
||||
"snapshot" => self.box_task(|| crate::cloudbox::save_snapshot().map(|n| format!("已儲存快照({})。", crate::cloudbox::human(n)))),
|
||||
"disk" => {
|
||||
let slot = self.cu.disk.clone();
|
||||
*slot.lock().unwrap() = Some("檢查中…".into());
|
||||
std::thread::spawn(move || {
|
||||
let r = crate::cloudbox::disk_report().unwrap_or_else(|e| e);
|
||||
*slot.lock().unwrap() = Some(r);
|
||||
});
|
||||
}
|
||||
"update" => {
|
||||
// As the desktop's 更新: the bots stop first, the files and logins stay.
|
||||
let ids: Vec<u64> = self.streams.keys().copied().collect();
|
||||
for id in ids {
|
||||
self.stop(id);
|
||||
}
|
||||
*self.cu.note.lock().unwrap() = match crate::cloudbox::rebuild(crate::cloudbox::Rebuild::Update) {
|
||||
Ok(()) => t("更新已開始。"),
|
||||
Err(e) => e,
|
||||
};
|
||||
}
|
||||
"reset" => {
|
||||
// As the desktop's 重設: back to the last snapshot (the current box saved
|
||||
// first), or a brand-new computer when there is none.
|
||||
let ids: Vec<u64> = self.streams.keys().copied().collect();
|
||||
for id in ids {
|
||||
self.stop(id);
|
||||
}
|
||||
let kind = if crate::cloudbox::snapshot_time().is_some() { crate::cloudbox::Rebuild::Restore } else { crate::cloudbox::Rebuild::Reset };
|
||||
*self.cu.note.lock().unwrap() = match crate::cloudbox::rebuild(kind) {
|
||||
Ok(()) => t("已開始。"),
|
||||
Err(e) => e,
|
||||
};
|
||||
}
|
||||
"runtime" => crate::cloudbox::set_box_runtime(&s("value")),
|
||||
"auto_update" => {
|
||||
let at = s("value");
|
||||
if !at.is_empty() && chrono::NaiveTime::parse_from_str(&at, "%H:%M").is_err() {
|
||||
return fail("時間要寫成 HH:MM");
|
||||
}
|
||||
crate::cloudbox::set_auto_update_at((!at.is_empty()).then_some(at));
|
||||
}
|
||||
_ => return fail("不認得這個動作"),
|
||||
}
|
||||
*BOX_FACTS.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
done("好了")
|
||||
}
|
||||
_ => fail("沒有這個動作"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A bot as its editor and the panel beside the chat show it.
|
||||
fn phone_detail(&self, b: &Bot) -> serde_json::Value {
|
||||
let names: HashMap<u64, String> = self.state.bots.iter().map(|x| (x.id, x.name.clone())).collect();
|
||||
let (choices, chosen) = self.computer_choices(b);
|
||||
let trig = crate::triggers::statuses().lock().unwrap().clone();
|
||||
// 資料庫: the chat's files, newest first (pictures and documents the person or the bot sent).
|
||||
let mut files: Vec<serde_json::Value> = vec![];
|
||||
for m in b.messages.iter().rev() {
|
||||
for a in &m.attachments {
|
||||
if files.len() < 80 {
|
||||
files.push(json!({"name": a.name, "path": a.path, "image": a.image, "at": m.at, "mine": m.role == Role::User}));
|
||||
}
|
||||
}
|
||||
}
|
||||
json!({
|
||||
"id": b.id, "name": b.name, "description": b.description, "instructions": b.instructions,
|
||||
"shape": b.shape, "color": b.color, "provider": serde_json::to_value(b.provider).unwrap_or_default(), "model": b.model,
|
||||
"computer": chosen, "computers": choices.iter().map(|(name, note)| json!({"name": name, "note": note})).collect::<Vec<_>>(),
|
||||
"tools_enabled": b.tools_enabled, "context_tokens": b.context_tokens, "folder": b.folder, "pinned": b.pinned,
|
||||
"primary": b.id == self.state.primary_bot, "unread": b.unread,
|
||||
"members": b.members.iter().map(|i| json!({"id": i, "name": names.get(i).cloned().unwrap_or_default()})).collect::<Vec<_>>(),
|
||||
"memories": b.memories, "allow_rules": b.allow_rules,
|
||||
"demos": b.demos.iter().map(|d| json!({"name": d.name, "created": d.created, "steps": d.steps.len()})).collect::<Vec<_>>(),
|
||||
"routines": b.routines.iter().map(|r| json!({
|
||||
"id": r.id, "title": r.title, "prompt": r.prompt, "paused": r.paused, "when": appctl::routine_when(r),
|
||||
"schedule": serde_json::to_value(&r.schedule).unwrap_or_default(), "repeat": crate::routines::describe(&r.schedule),
|
||||
"trigger": r.trigger.as_ref().map(|t| t.describe()), "status": trig.get(&r.id).cloned(),
|
||||
"last": r.last_run.map(|t| crate::routines::describe_when(t, &r.schedule.timezone)), "computer": r.computer,
|
||||
})).collect::<Vec<_>>(),
|
||||
"threads": b.threads.iter().enumerate().rev().map(|(i, (title, msgs))| json!({"index": i, "title": title, "count": msgs.len(), "at": msgs.last().map(|m| m.at).unwrap_or(0)})).collect::<Vec<_>>(),
|
||||
"files": files,
|
||||
})
|
||||
}
|
||||
|
||||
/// 設定 as the phone shows it (nothing secret: keys only as "set or not").
|
||||
fn phone_settings(&self, owner: bool) -> serde_json::Value {
|
||||
let st = &self.state;
|
||||
let ch_status = crate::channels::statuses().lock().unwrap().iter().map(|(k, v)| (*k, (v.ok, v.note.clone(), v.received))).collect::<HashMap<_, _>>();
|
||||
let mcp = crate::mcp::status();
|
||||
let mcp_err = self.ch.mcp_errors.lock().unwrap().clone();
|
||||
let bot_name = |id: u64| st.bot(id).map(|b| b.name.clone()).unwrap_or_else(|| "(Bot 已刪除)".into());
|
||||
let log = crate::cloudbox::log_lines();
|
||||
let mut out = json!({
|
||||
"role": if owner { "owner" } else { "admin" },
|
||||
"host": st.remote.host_name, "version": version_label(self.exe_built), "newer": self.newer_build.is_some(),
|
||||
"theme": match st.theme { Theme::Light => "light", Theme::Dark => "dark", Theme::System => "system" },
|
||||
"accent": serde_json::to_value(st.accent).unwrap_or_default(),
|
||||
"timezone": st.timezone, "notify": st.notify, "language": st.language,
|
||||
"direct_mode": st.direct_mode, "direct_locked": !self.managed.direct.allows() || !self.managed.local.allows(),
|
||||
"auto_review": st.auto_review, "allow_rules": st.review_allow_rules, "ask_rules": st.review_ask_rules,
|
||||
"providers": crate::providers::ALL_PROVIDERS.iter().map(|&k| {
|
||||
let ps = st.provider(k);
|
||||
json!({"key": serde_json::to_value(k).unwrap_or_default(), "label": k.label(), "needs_key": k.needs_key(),
|
||||
"ready": !k.needs_key() || !crate::providers::resolve_key(k, &ps.api_key).is_empty(),
|
||||
"default_model": ps.default_model, "custom_url": !ps.base_url.is_empty()})
|
||||
}).collect::<Vec<_>>(),
|
||||
"skills": st.skills.iter().map(|k| json!({"name": k.name, "description": k.description, "instructions": k.instructions})).collect::<Vec<_>>(),
|
||||
"templates": crate::memory::TEMPLATES.iter().map(|t| {
|
||||
let c = crate::avatar::color(t.color);
|
||||
json!({"name": t.name, "description": t.description, "shape": t.shape, "color": format!("#{:02x}{:02x}{:02x}", c.r(), c.g(), c.b())})
|
||||
}).collect::<Vec<_>>(),
|
||||
"shapes": crate::avatar::SHAPES,
|
||||
"colors": crate::avatar::COLORS.iter().map(|(k, n, c)| json!({"key": k, "name": n, "hex": format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2])})).collect::<Vec<_>>(),
|
||||
"folders": st.folders,
|
||||
"channels": st.channels.iter().map(|c| {
|
||||
let (ok, note, received) = ch_status.get(&c.id).cloned().unwrap_or((false, String::new(), 0));
|
||||
json!({"id": c.id, "kind": c.kind.label(), "name": c.name, "bot": bot_name(c.bot_id), "target": c.target, "enabled": c.enabled, "ok": ok, "note": note, "received": received})
|
||||
}).collect::<Vec<_>>(),
|
||||
"mcp": st.mcp_servers.iter().map(|m| json!({
|
||||
"name": m.name, "enabled": m.enabled, "tools": mcp.get(&m.name), "error": mcp_err.iter().find(|e| e.0 == m.name).map(|e| e.1.clone()),
|
||||
"what": if m.url.is_empty() { m.command.clone() } else { m.url.clone() },
|
||||
})).collect::<Vec<_>>(),
|
||||
"box": {
|
||||
"state": crate::cloudbox::run_state().label(), "available": crate::cloudbox::available(),
|
||||
"version": crate::cloudbox::wanted_version(), "runtime": crate::cloudbox::box_runtime(),
|
||||
"snapshot": crate::cloudbox::snapshot_time().map(|t| t.format("%m/%d %H:%M").to_string()),
|
||||
"auto_update": crate::cloudbox::auto_update_at(),
|
||||
"note": self.cu.note.lock().unwrap().clone(), "disk": self.cu.disk.lock().unwrap().clone(),
|
||||
"log": log.iter().rev().take(40).rev().cloned().collect::<Vec<_>>(),
|
||||
"facts": box_facts(),
|
||||
},
|
||||
});
|
||||
if owner {
|
||||
out["devices"] = json!(st.remote.devices.iter().map(|d| json!({"name": d.name, "created": d.created, "last_seen": d.last_seen})).collect::<Vec<_>>());
|
||||
out["docker_host"] = json!(crate::cloudbox::docker_host());
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod phone_act_tests {
|
||||
use super::*;
|
||||
|
||||
/// The demo app (sample bots, nothing read from or saved to disk).
|
||||
fn demo() -> (egui::Context, App) {
|
||||
let ctx = egui::Context::default();
|
||||
let app = App::new(&ctx, Some(std::env::temp_dir().join("anybot-phone-act-test.png")));
|
||||
(ctx, app)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_phone_does_what_the_desktop_does() {
|
||||
let (ctx, mut app) = demo();
|
||||
assert!(app.demo && app.state.bots.len() >= 2);
|
||||
let b = app.state.bots[0].id;
|
||||
let act = |app: &mut App, what: &str, body: serde_json::Value, role: &str| app.phone_act(&ctx, what, &body, role);
|
||||
// The actions a bot can take, by the same appctl path.
|
||||
let v = act(&mut app, "app", json!({"bot": b, "name": "create_routine", "args": {"title": "早報", "prompt": "整理新聞", "schedule": {"frequency": "daily", "time": "08:00"}}}), "owner");
|
||||
assert_eq!(v["ok"], true, "{v}");
|
||||
let rid = app.state.bot(b).unwrap().routines.iter().find(|r| r.title == "早報").map(|r| r.id).expect("routine made");
|
||||
assert_eq!(act(&mut app, "app", json!({"bot": b, "name": "update_routine", "args": {"id": rid, "paused": true}}), "owner")["ok"], true);
|
||||
assert!(app.state.bot(b).unwrap().routines.iter().any(|r| r.id == rid && r.paused));
|
||||
assert_eq!(act(&mut app, "app", json!({"bot": b, "name": "remember", "args": {"fact": "週末不排工作"}}), "owner")["ok"], true);
|
||||
assert!(app.state.bot(b).unwrap().memories.contains(&"週末不排工作".to_string()));
|
||||
// Only the app actions a bot has; nothing else through this door.
|
||||
assert!(act(&mut app, "app", json!({"bot": b, "name": "write_page", "args": {}}), "owner")["error"].is_string());
|
||||
assert!(act(&mut app, "nothing", json!({}), "owner")["error"].is_string());
|
||||
// A bot's switches, checked like the editor checks them.
|
||||
assert_eq!(act(&mut app, "bot", json!({"bot": b, "key": "shape", "value": "star"}), "owner")["ok"], true);
|
||||
assert_eq!(app.state.bot(b).unwrap().shape, "star");
|
||||
assert!(act(&mut app, "bot", json!({"bot": b, "key": "shape", "value": "<script>"}), "owner")["error"].is_string());
|
||||
assert_eq!(act(&mut app, "bot", json!({"bot": b, "key": "pinned", "value": true}), "owner")["ok"], true);
|
||||
assert!(app.state.bot(b).unwrap().pinned);
|
||||
// 團隊: an admin keeps what the phone always let them do, and small chat things…
|
||||
assert_eq!(act(&mut app, "bot", json!({"bot": b, "key": "unread", "value": true}), "admin")["ok"], true);
|
||||
assert!(act(&mut app, "detail", json!({"bot": b}), "admin")["id"].is_u64());
|
||||
// …but making, changing or deleting bots, routines, memories, pages and folders is the owner's.
|
||||
for (what, body) in [
|
||||
("app", json!({"bot": b, "name": "update_routine", "args": {"id": rid, "paused": false}})),
|
||||
("app", json!({"bot": b, "name": "create_bot", "args": {"name": "x"}})),
|
||||
("app", json!({"bot": b, "name": "remember", "args": {"fact": "x"}})),
|
||||
("bot", json!({"bot": b, "key": "pinned", "value": false})),
|
||||
("bot", json!({"bot": b, "key": "shape", "value": "cloud"})),
|
||||
("group_chat", json!({"members": [b]})),
|
||||
("clear", json!({"bot": b})),
|
||||
("delete_page", json!({"id": 1})),
|
||||
("box", json!({"op": "snapshot"})),
|
||||
] {
|
||||
assert!(act(&mut app, what, body.clone(), "admin")["error"].is_string(), "admin did {what} {body}");
|
||||
}
|
||||
assert!(app.state.bot(b).unwrap().routines.iter().any(|r| r.id == rid && r.paused));
|
||||
assert!(app.state.bot(b).unwrap().pinned && app.state.bot(b).unwrap().shape == "star");
|
||||
// The owner's alone: permissions, a bot's computer tools, deleting a bot.
|
||||
assert!(act(&mut app, "setting", json!({"key": "auto_review", "value": true}), "admin")["error"].is_string());
|
||||
assert!(act(&mut app, "bot", json!({"bot": b, "key": "tools", "value": true}), "admin")["error"].is_string());
|
||||
assert!(act(&mut app, "delete_bot", json!({"bot": b}), "admin")["error"].is_string());
|
||||
assert_eq!(act(&mut app, "setting", json!({"key": "rule_add", "list": "ask", "value": "付款前先問我"}), "owner")["ok"], true);
|
||||
assert!(app.state.review_ask_rules.contains(&"付款前先問我".to_string()));
|
||||
assert_eq!(act(&mut app, "setting", json!({"key": "rule_remove", "list": "ask", "value": "付款前先問我"}), "owner")["ok"], true);
|
||||
assert!(!app.state.review_ask_rules.contains(&"付款前先問我".to_string()));
|
||||
// 群組聊天 as the desktop's menu makes it.
|
||||
let others: Vec<u64> = app.state.bots.iter().filter(|x| x.members.is_empty()).map(|x| x.id).take(2).collect();
|
||||
let g = act(&mut app, "group_chat", json!({"members": others}), "owner");
|
||||
let gid = g["bot"].as_u64().expect("group made");
|
||||
assert_eq!(app.state.bot(gid).unwrap().members, others);
|
||||
// Deleting (the owner): gone, and there is still a main bot.
|
||||
assert_eq!(act(&mut app, "delete_bot", json!({"bot": gid}), "owner")["ok"], true);
|
||||
assert!(app.state.bot(gid).is_none() && app.state.bot(app.state.primary_bot).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_phone_never_gets_keys_or_secrets() {
|
||||
let (ctx, mut app) = demo();
|
||||
let key = "sk-test-very-secret-1234567890";
|
||||
app.state.providers.entry("openai".into()).or_default().api_key = key.into();
|
||||
app.state.channels.push(crate::channels::Channel { id: 9, kind: crate::channels::Kind::Telegram, name: "t".into(), bot_id: app.state.bots[0].id, token: "tg-secret-token".into(), target: String::new(), enabled: true, client_id: String::new(), tenant: String::new() });
|
||||
for role in ["owner", "admin"] {
|
||||
let s = app.phone_act(&ctx, "settings", &json!({}), role).to_string();
|
||||
assert!(!s.contains(key) && !s.contains("tg-secret-token"), "{role}: {s}");
|
||||
assert!(s.contains("\"ready\":true"), "{s}");
|
||||
}
|
||||
// A key goes in (the owner's), and still never comes out.
|
||||
let openai = serde_json::to_value(crate::providers::ProviderKind::OpenAi).unwrap();
|
||||
assert!(app.phone_act(&ctx, "provider", &json!({"provider": openai, "key": "sk-new-secret-999"}), "admin")["error"].is_string());
|
||||
assert_eq!(app.phone_act(&ctx, "provider", &json!({"provider": openai, "key": "sk-new-secret-999", "default_model": "gpt-5"}), "owner")["ok"], true);
|
||||
assert_eq!(app.state.provider(crate::providers::ProviderKind::OpenAi).api_key, "sk-new-secret-999");
|
||||
assert!(!app.phone_act(&ctx, "settings", &json!({}), "owner").to_string().contains("sk-new-secret-999"));
|
||||
assert!(app.phone_act(&ctx, "box", &json!({"op": "reset"}), "admin")["error"].is_string());
|
||||
// The owner's own lists only to the owner.
|
||||
assert!(app.phone_act(&ctx, "settings", &json!({}), "admin").get("devices").is_none());
|
||||
assert!(app.phone_act(&ctx, "settings", &json!({}), "owner").get("devices").is_some());
|
||||
let b = app.state.bots[0].id;
|
||||
let d = app.phone_act(&ctx, "detail", &json!({"bot": b}), "owner").to_string();
|
||||
assert!(!d.contains(key) && d.contains("\"memories\""), "{d}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -148,6 +148,8 @@ pub struct View {
|
|||
pub pages: Vec<crate::pages::Page>,
|
||||
/// The models the phone can switch a bot to: each ready provider with its known models.
|
||||
pub models: Vec<Value>,
|
||||
/// The sidebar's 分區 in order (None = 未分區), empty ones too, as the desktop shows them.
|
||||
pub sections: Vec<Option<String>>,
|
||||
pub version: u64,
|
||||
}
|
||||
|
||||
|
|
@ -165,6 +167,9 @@ pub struct Hub {
|
|||
|
||||
/// 大腦: another AnyBot of this account (a screen) asks for what changed, or sends its edits.
|
||||
pub enum SyncKind {
|
||||
/// 手機: something the desktop does with a bot, a page, a routine or a setting, or one of the
|
||||
/// views it shows (a bot's details, 設定), done on the app's thread; `role` is who asks.
|
||||
Phone { action: String, body: Value, role: String },
|
||||
Pull { client: String, since: u64 },
|
||||
Edit(Value),
|
||||
/// A provider's model list, fetched with the brain's own key (screens don't have it).
|
||||
|
|
@ -384,6 +389,7 @@ pub fn handle(req: Req, via_relay: bool) -> Resp {
|
|||
"role": role,
|
||||
"pages": pages,
|
||||
"models": if role == "member" { vec![] } else { v.models.clone() },
|
||||
"sections": v.sections,
|
||||
}))
|
||||
}
|
||||
// 頁面: the whole page (or an older version with ?rev=N), and its charts drawn as SVG.
|
||||
|
|
@ -420,6 +426,12 @@ pub fn handle(req: Req, via_relay: bool) -> Resp {
|
|||
None => Resp::error(404, "找不到這個 Bot"),
|
||||
}
|
||||
}
|
||||
// What the desktop shows beside the chat and in 設定, made when asked (never keys, tokens
|
||||
// or the vault's secrets), and everything else it can do (api/act/<what>).
|
||||
("GET", ["api", "bots", id, "detail"]) => phone_act("detail", json!({"bot": id.parse::<u64>().unwrap_or(0)}), &role),
|
||||
("GET", ["api", "settings"]) => phone_act("settings", json!({}), &role),
|
||||
("GET", ["api", "box", "thumb"]) => box_thumb(),
|
||||
("POST", ["api", "act", action]) => phone_act(action, req.json(), &role),
|
||||
("POST", ["api", "bots", id, action]) => {
|
||||
let bot: u64 = id.parse().unwrap_or(0);
|
||||
let b = req.json();
|
||||
|
|
@ -555,6 +567,25 @@ pub fn handle(req: Req, via_relay: bool) -> Resp {
|
|||
/// 團隊角色: what each role may do. "member" chats and attaches files; "admin" also approves,
|
||||
/// stops, starts new chats and uses the screens; only "device" (another AnyBot of the owner's
|
||||
/// account) may run tools here.
|
||||
/// A phone action carried out on the app's thread (where the state is), with its answer.
|
||||
fn phone_act(action: &str, body: Value, role: &str) -> Resp {
|
||||
match hub().ask_sync_for(SyncKind::Phone { action: action.to_string(), body, role: role.to_string() }, 20) {
|
||||
Ok(v) if v.get("error").is_some() => Resp::error(400, v["error"].as_str().unwrap_or("做不到")),
|
||||
Ok(v) => Resp::json(&v),
|
||||
Err(e) => Resp::error(503, &e),
|
||||
}
|
||||
}
|
||||
|
||||
/// The bots' computer's screen, small (the phone's 電腦 card and a hand-off), as JPEG.
|
||||
fn box_thumb() -> Resp {
|
||||
let Some(img) = crate::cloudbox::thumbnail(480) else { return Resp::error(404, "Bot 的電腦沒有開著") };
|
||||
let mut out = std::io::Cursor::new(vec![]);
|
||||
match image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 72).encode_image(&image::DynamicImage::ImageRgba8(img).to_rgb8()) {
|
||||
Ok(()) => Resp::bytes("image/jpeg", out.into_inner()),
|
||||
Err(e) => Resp::error(500, &e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn role_allows(role: &str, method: &str, path: &[&str]) -> bool {
|
||||
let device = matches!(path, ["api", "device", "run"]);
|
||||
let sync = matches!(path, ["api", "sync", ..]);
|
||||
|
|
|
|||
Loading…
Reference in New Issue