From f8d00df0171980d1cc8a7f0e0162f1e5db94f8ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Sun, 6 Sep 2026 11:43:38 +0800 Subject: [PATCH 1/3] add voice --- .env.example | 13 + Cargo.lock | 15 + Cargo.toml | 3 +- README.md | 9 +- apps/web/index.html | 2 +- apps/web/src/App.tsx | 76 +- apps/web/src/call-audio.ts | 136 ++++ apps/web/src/call.css | 20 + apps/web/src/call.tsx | 174 +++++ apps/web/src/chat.css | 8 + apps/web/src/computer.css | 13 + apps/web/src/locales/zh-TW.ts | 16 +- apps/web/src/main.tsx | 1 + apps/web/src/types.ts | 17 + apps/web/src/voice-settings.tsx | 144 ++++ apps/web/vite.config.ts | 2 +- crates/api/src/computer.rs | 8 +- crates/api/src/db.rs | 53 +- crates/api/src/file_skills.rs | 96 +++ crates/api/src/main.rs | 14 +- crates/api/src/retention.rs | 84 ++ crates/api/src/retention/checkpoints.sql | 6 + crates/api/src/retention/deleted_memories.sql | 4 + crates/api/src/retention/events.sql | 4 + crates/api/src/retention/leases.sql | 5 + crates/api/src/retention/profile_locks.sql | 6 + crates/api/src/retention/recordings.sql | 5 + crates/api/src/retention/revisions.sql | 7 + crates/api/src/retention/runs.sql | 5 + crates/api/src/routes.rs | 12 +- crates/api/src/runs.rs | 174 ++++- crates/api/src/state.rs | 27 + crates/api/src/tools.rs | 1 + crates/api/src/voice.rs | 297 +++++++ crates/api/src/voice_call.rs | 616 +++++++++++++++ crates/api/src/web_static.rs | 111 +++ crates/contracts/src/lib.rs | 2 + crates/contracts/src/voice.rs | 200 +++++ crates/harness/Cargo.toml | 9 + crates/harness/src/execution.rs | 64 ++ crates/harness/src/lib.rs | 3 + crates/harness/src/voice.rs | 731 ++++++++++++++++++ crates/supervisor/src/docker.rs | 65 +- docker-compose.yml | 12 + image/api/Dockerfile | 1 + image/computer/Dockerfile | 8 +- image/computer/entrypoint.sh | 11 + image/computer/lazyboy-screen | 20 +- image/computer/rotate-logs.py | 50 ++ image/computer/start.sh | 4 +- migrations/012_voice.sql | 5 + migrations/013_voice_enabled.sql | 2 + migrations/014_retention.sql | 12 + tests/frontend.test.mjs | 28 + tests/log-rotation.test.py | 38 + tests/retention.test.py | 88 +++ 56 files changed, 3460 insertions(+), 77 deletions(-) create mode 100644 apps/web/src/call-audio.ts create mode 100644 apps/web/src/call.css create mode 100644 apps/web/src/call.tsx create mode 100644 apps/web/src/voice-settings.tsx create mode 100644 crates/api/src/file_skills.rs create mode 100644 crates/api/src/retention.rs create mode 100644 crates/api/src/retention/checkpoints.sql create mode 100644 crates/api/src/retention/deleted_memories.sql create mode 100644 crates/api/src/retention/events.sql create mode 100644 crates/api/src/retention/leases.sql create mode 100644 crates/api/src/retention/profile_locks.sql create mode 100644 crates/api/src/retention/recordings.sql create mode 100644 crates/api/src/retention/revisions.sql create mode 100644 crates/api/src/retention/runs.sql create mode 100644 crates/api/src/voice.rs create mode 100644 crates/api/src/voice_call.rs create mode 100644 crates/api/src/web_static.rs create mode 100644 crates/contracts/src/voice.rs create mode 100644 crates/harness/src/execution.rs create mode 100644 crates/harness/src/voice.rs create mode 100644 image/computer/entrypoint.sh create mode 100644 image/computer/rotate-logs.py create mode 100644 migrations/012_voice.sql create mode 100644 migrations/013_voice_enabled.sql create mode 100644 migrations/014_retention.sql create mode 100644 tests/log-rotation.test.py create mode 100644 tests/retention.test.py diff --git a/.env.example b/.env.example index 8edce9a..25d462c 100644 --- a/.env.example +++ b/.env.example @@ -17,7 +17,20 @@ LAZYBOY_SECURE_COOKIE=false LAZYBOY_COMPUTER_MEMORY_MB=2048 LAZYBOY_COMPUTER_CPUS=2 LAZYBOY_COMPUTER_PIDS=2048 +# Only affects the Agent desktop container. Disabled by default. +LAZYBOY_COMPUTER_SUDO=false +# Linux only (optional): point this at the host's LXCFS root to make htop/free +# report the per-Agent cgroup quota. Leave the default empty directory on macOS. +LAZYBOY_LXCFS_ROOT=./data/lxcfs LAZYBOY_MEMORY_ENABLED=true LAZYBOY_MEMORY_MODEL_CACHE=./data/fastembed LAZYBOY_MEMORY_TOP_K=8 LAZYBOY_MEMORY_BYTE_BUDGET=6000 + +# Hourly cleanup of diagnostics; conversations and current memories are retained. +LAZYBOY_EVENT_RETENTION_DAYS=30 +LAZYBOY_CHECKPOINT_RETENTION_DAYS=7 +LAZYBOY_RUN_RETENTION_DAYS=90 +LAZYBOY_RECORDING_RETENTION_DAYS=30 +LAZYBOY_MEMORY_HISTORY_RETENTION_DAYS=90 +LAZYBOY_DB_WARN_MB=1024 diff --git a/Cargo.lock b/Cargo.lock index e9a590b..f809f11 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2251,11 +2251,20 @@ dependencies = [ name = "lazyboy-harness" version = "0.1.0" dependencies = [ + "async-trait", + "base64 0.22.1", + "futures-util", + "http", "lazyboy-contracts", "reqwest 0.12.28", "rig-core", + "rustls", + "rustls-native-certs", "serde", + "serde_json", "thiserror", + "tokio", + "tokio-tungstenite 0.26.2", ] [[package]] @@ -4741,7 +4750,11 @@ checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" dependencies = [ "futures-util", "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", "tokio", + "tokio-rustls", "tungstenite 0.26.2", ] @@ -4953,6 +4966,8 @@ dependencies = [ "httparse", "log", "rand 0.9.5", + "rustls", + "rustls-pki-types", "sha1", "thiserror", "utf-8", diff --git a/Cargo.toml b/Cargo.toml index 182524a..0947876 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,7 +41,8 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } uuid = { version = "1", features = ["v4", "serde"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } -tokio-tungstenite = { version = "0.26", features = ["connect"] } +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } +tokio-tungstenite = { version = "0.26", features = ["connect", "rustls-tls-native-roots"] } futures-util = "0.3" bollard = "0.18" hmac = "0.12" diff --git a/README.md b/README.md index 5a1aa59..e78176d 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ LazyBoy 是本機系統,不是雲端沙盒。機器要跑四件事:**Postgre | GPU | 不需要 | 模型走網路 API,畫面是 CPU 上的 Xvfb | | 網路 | 第一次建映像、拉套件需要 | 之後離線也能開 UI;聊天要模型金鑰能連外 | +每個 Agent 桌面都會套用 Docker 的 CPU、記憶體與 PID 上限,預設是 2 CPU、2 GB、2048 個 PID。可在 `.env` 調整 `LAZYBOY_COMPUTER_CPUS`、`LAZYBOY_COMPUTER_MEMORY_MB`、`LAZYBOY_COMPUTER_PIDS`。Linux 若安裝 LXCFS,將 `LAZYBOY_LXCFS_ROOT` 指到它的 `/var/lib/lxcfs`,容器內的 `htop`、`free` 等也會顯示 cgroup 配額;macOS 仍會套用配額,但 Docker Desktop 不提供這個 `/proc` 虛擬化。桌面容器不掛主機 Docker socket,也不使用 `privileged`;需要一般管理命令時才在 `.env` 設定 `LAZYBOY_COMPUTER_SUDO=true`,權限只在該 Agent 容器內生效。 + 預設一個 Team 電腦容器可同時掛最多 **8** 個螢幕(`TEAM_SCREEN_LIMIT`)。再開私人電腦就是再一個容器、再 2 GB。分頁開著時心跳會讓桌面保持熱機;關掉分頁約 10 分鐘後凍結(記憶體還在),約 6 小時後才真正停機。 --- @@ -111,6 +113,9 @@ Team:工作區共用一個家目錄,每個 bot 有自己的 `DISPLAY`(`:1` **教技能** 你示範,容器內 CDP 錄「點了哪個控制項、填了什麼、去了哪一頁」,再抽幾個關鍵畫面。停下來後模型整理成意圖級 playbook,之後用普通工具在**當下畫面**找控制項,不是重播座標。密碼欄不錄。技能可匯出 JSON。 +**Slash 指令與長目標** +在 `data/skills//SKILL.md` 放工作區共用的唯讀技能,就能在輸入框打 `/name 參數` 執行;輸入 `/` 會顯示可用技能。`/goal` 是 harness 的持續執行模式,和錄製示範產生的 playbook 分開。選單支援方向鍵、Enter/Tab 選取、Esc 關閉。`/goal 目標` 會先規劃、執行並檢查結果,直到模型回報已驗證完成;只有需要登入、驗證碼、接管畫面或缺少必要資訊時才會停下來請你處理。缺少必要資訊時會記為等待輸入,補充訊息後接續原目標;停止按鈕可以取消。一般訊息仍維持 40 回合上限,教學技能 80 回合。 + **記憶** `pgvector` + MiniLM(384 維)。只有你叫它記住、或它呼叫 `remember` 的內容會進長期記憶。密碼與 token 會被拒。清除對話不會清記憶。 @@ -148,7 +153,7 @@ Compose 裡 supervisor **不**對主機開埠。API 在容器網路連 `supervis 同一 bot 已有進行中的工作時,新訊息會排隊(`queuedBehindActive`)。人正在接管時,後面的話只排隊,思考轉圈不會假裝它還在動。問候路徑會把工具表清空,從源頭避免「哈囉」去開電腦。 -`execute_run` 每一輪:續租約 → 寫步驟文字 → 問模型 → 沒有工具就結束(技能沒過會再把畫面塞回去)→ 有工具且需要沙盒才 boot → 畫面沒變就不重複塞圖。回合上限:聊天 4、一般 40、技能 80。 +`execute_run` 每一輪:續租約 → 寫步驟文字 → 問模型 → 沒有工具就結束(技能沒過會再把畫面塞回去)→ 有工具且需要沙盒才 boot → 畫面沒變就不重複塞圖。回合上限:聊天 4、一般 40、教學技能 80;`/goal` 會持續到完成或明確需要人介入。目標執行期間,同一對話送進來的新訊息會作為下一輪的補充指示。 --- @@ -242,6 +247,7 @@ LazyBoy/ │ ├── computer.rs boot / 凍結 / 心跳 / 螢幕租約 │ ├── sessions.rs 對話 CRUD、送訊息、SSE │ ├── skills.rs 示範錄製與蒸馏 +│ ├── file_skills.rs 讀取 data/skills 下的唯讀 SKILL.md │ ├── schedules.rs cron │ ├── vault.rs 登入保險箱 │ ├── memory.rs pgvector 記憶 @@ -257,6 +263,7 @@ LazyBoy/ │ └── lazyboy-screen Team 額外 DISPLAY ├── migrations/ sqlx,檔名流水號;API 啟動時自動 migrate ├── data/homes/ 每個電腦的家目錄(bind 進容器 /home/lazyboy) +├── data/skills//SKILL.md 工作區共用的 slash 技能(執行時掛載) ├── tests/ 跨語言的小測試(node:test、Python) ├── scripts/ init-env、build-computer-image、dev ├── docker-compose.yml 正式堆疊(Postgres + supervisor + API) diff --git a/apps/web/index.html b/apps/web/index.html index c7eb2ad..1f6d4be 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -1 +1 @@ -LazyBoy
+LazyBoy
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 531c0c9..5737c44 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -17,16 +17,18 @@ import searchToX from "react-useanimations/lib/searchToX"; import { api, ApiError } from "./api"; import { Avatar, AvatarLookProvider, AvatarStack, BLOBATAR_BACKGROUNDS, BLOBATAR_EXPRESSIONS, BLOBATAR_SHAPES, DEFAULT_LOOK, persistBlobatarShape, readAvatarLooks, resolveBlobatarShape, writeAvatarLook, type AvatarBackground, type AvatarExpression, type AvatarLook } from "./avatar"; import { t, type MessageKey } from "./i18n"; -import type { AvatarShape, Bot, ComputerMode, ComputerStatus, McpCatalogEntry, McpServer, McpTransport, MemoryItem, Message, MessageFile, ModelProviderId, Playbook, PlaybookInput, PlaybookStep, Room, RoomMember, Session, TaughtSkill, WorkspaceSettings } from "./types"; +import type { AvatarShape, Bot, ComputerMode, ComputerStatus, FileSkill, McpCatalogEntry, McpServer, McpTransport, MemoryItem, Message, MessageFile, ModelProviderId, Playbook, PlaybookInput, PlaybookStep, Room, RoomMember, Session, TaughtSkill, VoiceSettings, WorkspaceSettings } from "./types"; import { ChatMarkdown, CopyMessageButton } from "./markdown"; import { ScheduleEditor, ScheduleList, cronFromPreset, defaultCronPreset, presetFromCron, type CronPreset, type ScheduleItem } from "./schedule"; +import { CallOverlay, PhoneIcon } from "./call"; +import { VoiceSettingsDialog } from "./voice-settings"; const blankComputer:ComputerStatus={botId:"",mode:"team",state:"stopped",controlHolder:"none",takeoverRequested:false,busyBotName:null,busySessionId:null,busyRunId:null,busyStep:null,usingComputer:false,waitingRunId:null,waitingSessionId:null,queuedRuns:0,display:null,profileMode:"per-bot",screenAvailable:false}; const SESSION_STORE="lazyboy.sessionByBot"; const PANE_STORE="lazyboy.rightPane"; const WORKSPACE_STORE="lazyboy.workspace"; type RightPart="computer"|"memory"|"settings"|"plugins"|"accounts"; -type AccountDialog="phone"|"settings"|"model"|"about"|"help"|"feedback"|null; +type AccountDialog="phone"|"settings"|"model"|"voice"|"about"|"help"|"feedback"|null; function readSessionStore():Record{try{const raw=localStorage.getItem(SESSION_STORE);return raw?JSON.parse(raw) as Record:{}}catch{return {}}} function writeSessionStore(botId:string,sessionId:string){const store=readSessionStore();store[botId]=sessionId;localStorage.setItem(SESSION_STORE,JSON.stringify(store))} function readPaneStore():{collapsed:boolean;part:RightPart}{try{const raw=localStorage.getItem(PANE_STORE);if(!raw)return{collapsed:false,part:"computer"};const value=JSON.parse(raw) as {collapsed?:boolean;part?:string};return{collapsed:Boolean(value.collapsed),part:value.part==="memory"||value.part==="settings"||value.part==="plugins"||value.part==="accounts"?value.part:"computer"}}catch{return{collapsed:false,part:"computer"}}} @@ -89,7 +91,8 @@ export function App(){ const [roomContext,setRoomContext]=useState<{room:Room;x:number;y:number}|null>(null); const [roomToDelete,setRoomToDelete]=useState(null); const [mcpServers,setMcpServers]=useState([]); const [accountOpen,setAccountOpen]=useState(false); const [accountDialog,setAccountDialog]=useState(null); - const [skills,setSkills]=useState([]); const [plusOpen,setPlusOpen]=useState(false); const [skillQuery,setSkillQuery]=useState(""); const [teachOpen,setTeachOpen]=useState(false); const [editingSkillId,setEditingSkillId]=useState(null); + const [voiceSettings,setVoiceSettings]=useState(null); const [callOpen,setCallOpen]=useState(false); + const [skills,setSkills]=useState([]); const [fileSkills,setFileSkills]=useState([]); const [plusOpen,setPlusOpen]=useState(false); const [skillQuery,setSkillQuery]=useState(""); const [teachOpen,setTeachOpen]=useState(false); const [editingSkillId,setEditingSkillId]=useState(null); const [schedules,setSchedules]=useState([]); const [scheduleDraft,setScheduleDraft]=useState<{name:string;instructions:string;enabled:boolean;preset:CronPreset;id?:string;timezone?:string;threadId?:string|null}|null>(null); const [scheduleError,setScheduleError]=useState(null); const [scheduleSaving,setScheduleSaving]=useState(false); const [runningScheduleId,setRunningScheduleId]=useState(null); const [workspaceName,setWorkspaceName]=useState(workspaceStart.name); @@ -124,6 +127,13 @@ export function App(){ const savedSkills=skills.filter(skill=>skill.status==="saved"); const skillNeedle=skillQuery.trim().toLowerCase(); const listedSkills=skillNeedle?savedSkills.filter(skill=>skill.name.toLowerCase().includes(skillNeedle)||(skill.playbook.whenToUse||"").toLowerCase().includes(skillNeedle)||skill.goal.toLowerCase().includes(skillNeedle)):savedSkills; + const [slashIndex,setSlashIndex]=useState(0); + const [slashDismissed,setSlashDismissed]=useState(false); + useEffect(()=>{setSlashIndex(0);setSlashDismissed(false)},[draft]); + const slashToken=draft.trimStart().split(/\s/,1)[0].slice(1).toLowerCase(); + const slashSuggestions=!slashDismissed&&/^\/[^\s]*$/.test(draft.trimStart()) + ? [{name:"goal",description:t("goalCommandHint"),kind:"執行模式"},...fileSkills.map(skill=>({...skill,kind:"檔案技能"}))].filter(skill=>!slashToken||skill.name.startsWith(slashToken)).slice(0,8) + : []; const loadMcp=useCallback(async()=>{setMcpServers(await api("/api/mcp-servers").catch(()=>[] as McpServer[]))},[]); const loadBots=useCallback(async()=>{const [next,nextRooms]=await Promise.all([api("/api/bots"),api("/api/rooms").catch(()=>[] as Room[])]);setBots(next);setRooms(nextRooms);setActiveRoomId(id=>id&&nextRooms.some(room=>room.id===id)?id:null);setActiveId(id=>id&&next.some(b=>b.id===id)?id:null);await loadMcp()},[loadMcp]); @@ -144,6 +154,9 @@ export function App(){ setBusyMembers(nextBusy);setComputer(status);setMessages(nextMessages);setSkills(nextSkills);setScreenUrl(status.botId===computerBot?screen.url:null) },[activeId,activeRoomId,activeSessionId]); useEffect(()=>{loadBots().catch(e=>{if(e instanceof ApiError&&e.status===401)setAuthRequired(true);else setError(e.message)})},[loadBots]); + useEffect(()=>{if(!voiceSettings?.enabled)setCallOpen(false)},[voiceSettings?.enabled]); + useEffect(()=>{api("/api/voice/settings").then(setVoiceSettings).catch(()=>setVoiceSettings(null))},[]); + useEffect(()=>{if(authRequired)return;api("/api/file-skills").then(setFileSkills).catch(()=>setFileSkills([]))},[authRequired]); useEffect(()=>{if(activeId||activeRoomId||bots.length===0)return;setActiveId(bots[0].id)},[bots,activeId,activeRoomId]); useEffect(()=>{setMessages([]);loadSessions().catch(e=>setError(e.message))},[loadSessions]); useEffect(()=>{historyIndexRef.current=null;historyDraftRef.current="";setPendingFiles(current=>{current.forEach(file=>file.preview&&URL.revokeObjectURL(file.preview));return []})},[activeSessionId]); @@ -205,7 +218,7 @@ export function App(){ function addPendingFiles(list:FileList|File[]){const incoming=[...list];if(!incoming.length)return;setError(null);setPendingFiles(current=>{const next=[...current];for(const file of incoming){if(next.length>=ATTACH_MAX){setError(t("attachTooMany"));break}if(file.size>ATTACH_MAX_BYTES){setError(t("attachTooLarge"));continue}if(!attachAllowed(file)){setError(t("attachType"));continue}next.push({id:clientNonce(),file,preview:file.type.startsWith("image/")?URL.createObjectURL(file):null})}return next})} function removePendingFile(id:string){setPendingFiles(current=>current.filter(item=>{if(item.id===id&&item.preview)URL.revokeObjectURL(item.preview);return item.id!==id}))} async function send(event:FormEvent){event.preventDefault();if(sendingRef.current||busy)return;const text=draft.trim();const files=pendingFiles;if((!active&&!activeRoom)||!activeSessionId||(!text&&files.length===0))return;sendingRef.current=true;setDraft("");setPendingFiles([]);historyIndexRef.current=null;historyDraftRef.current="";try{await action(async()=>{try{const attachments=await Promise.all(files.map(async item=>({name:item.file.name,mimeType:item.file.type,content:await readAsBase64(item.file)})));await api(`/api/sessions/${activeSessionId}/messages`,{method:"POST",body:JSON.stringify({text,clientNonce:clientNonce(),attachments})});sentHistoryRef.current.push(text||files[0]?.file.name||"");if(sentHistoryRef.current.length>100)sentHistoryRef.current.shift();files.forEach(item=>item.preview&&URL.revokeObjectURL(item.preview));await loadSessions()}catch(error){setPendingFiles(files);throw error}})}finally{sendingRef.current=false}} - function composerKeyDown(event:ReactKeyboardEvent){if(event.nativeEvent.isComposing||event.key==="Process")return;const history=sentHistoryRef.current;if(event.key==="ArrowUp"&&history.length>0&&(!event.currentTarget.value.includes("\n")||event.currentTarget.selectionStart===0)){event.preventDefault();if(historyIndexRef.current===null){historyDraftRef.current=draft;historyIndexRef.current=history.length-1}else historyIndexRef.current=Math.max(0,historyIndexRef.current-1);setDraft(history[historyIndexRef.current]);return}if(event.key==="ArrowDown"&&historyIndexRef.current!==null&&(!event.currentTarget.value.includes("\n")||event.currentTarget.selectionEnd===event.currentTarget.value.length)){event.preventDefault();if(historyIndexRef.current){if(event.nativeEvent.isComposing||event.key==="Process")return;if(slashSuggestions.length){if(event.key==="Escape"){event.preventDefault();setSlashDismissed(true);return}if(event.key==="ArrowDown"||event.key==="ArrowUp"){event.preventDefault();setSlashIndex(index=>(index+(event.key==="ArrowDown"?1:slashSuggestions.length-1))%slashSuggestions.length);return}if(event.key==="Tab"||(event.key==="Enter"&&!event.shiftKey)){event.preventDefault();setDraft(`/${slashSuggestions[slashIndex%slashSuggestions.length].name} `);return}}const history=sentHistoryRef.current;if(event.key==="ArrowUp"&&history.length>0&&(!event.currentTarget.value.includes("\n")||event.currentTarget.selectionStart===0)){event.preventDefault();if(historyIndexRef.current===null){historyDraftRef.current=draft;historyIndexRef.current=history.length-1}else historyIndexRef.current=Math.max(0,historyIndexRef.current-1);setDraft(history[historyIndexRef.current]);return}if(event.key==="ArrowDown"&&historyIndexRef.current!==null&&(!event.currentTarget.value.includes("\n")||event.currentTarget.selectionEnd===event.currentTarget.value.length)){event.preventDefault();if(historyIndexRef.current(sessionsPath,{method:"POST",body:JSON.stringify({title:t("newConversation")})});writeSessionStore(sessionStoreKey,session.id);const next=await api(sessionsPath);setSessions(next);setActiveSessionId(session.id);setMessages([])}catch(e){setError(e instanceof Error?e.message:t("operationFailed"))}finally{setBusy(false)}} async function clearSession(){if(!activeSessionId)return;setClearOpen(false);setSessionMenuOpen(false);await action(async()=>{await api(`/api/sessions/${activeSessionId}/messages`,{method:"DELETE"});setMessages([]);await loadSessions()})} @@ -230,16 +243,16 @@ export function App(){ async function pasteClipboard(){try{pasteText(await navigator.clipboard.readText())}catch{setClipboardOpen(true)}} async function copyClipboard(){try{await navigator.clipboard.writeText(desktopClipboard)}catch{setError(t("clipboardWriteBlocked"))}} async function inbox(bot:Bot,actionName:string,groupName?:string|null){await api(`/api/bots/${bot.id}/inbox`,{method:"POST",body:JSON.stringify({action:actionName,groupName})});await loadBots()} - function openBot(bot:Bot){setMobileNav(false);setSessionMenuOpen(false);if(bot.unreadCount>0)void inbox(bot,"read");if(bot.id===activeId&&!activeRoomId){if(!activeSessionId){const stored=readSessionStore()[bot.id];if(stored)setActiveSessionId(stored);else void loadSessions()}return}setMessages([]);setActiveSessionId(null);setActiveRoomId(null);setBusyMembers([]);setActiveId(bot.id)} - function openRoom(room:Room){setMobileNav(false);setSessionMenuOpen(false);if(rightPart==="settings")setRightPart("computer");if(room.id===activeRoomId){if(!activeSessionId){const stored=readSessionStore()[`room:${room.id}`];if(stored)setActiveSessionId(stored);else void loadSessions()}return}setMessages([]);setActiveSessionId(null);setActiveId(null);setBusyMembers([]);setActiveRoomId(room.id)} + function openBot(bot:Bot){setCallOpen(false);setMobileNav(false);setSessionMenuOpen(false);if(bot.unreadCount>0)void inbox(bot,"read");if(bot.id===activeId&&!activeRoomId){if(!activeSessionId){const stored=readSessionStore()[bot.id];if(stored)setActiveSessionId(stored);else void loadSessions()}return}setMessages([]);setActiveSessionId(null);setActiveRoomId(null);setBusyMembers([]);setActiveId(bot.id)} + function openRoom(room:Room){setCallOpen(false);setMobileNav(false);setSessionMenuOpen(false);if(rightPart==="settings")setRightPart("computer");if(room.id===activeRoomId){if(!activeSessionId){const stored=readSessionStore()[`room:${room.id}`];if(stored)setActiveSessionId(stored);else void loadSessions()}return}setMessages([]);setActiveSessionId(null);setActiveId(null);setBusyMembers([]);setActiveRoomId(room.id)} async function deleteRoom(room:Room){setRoomToDelete(null);await action(async()=>{await api(`/api/rooms/${room.id}`,{method:"DELETE"});if(activeRoomId===room.id){setActiveRoomId(null);setActiveSessionId(null);setMessages([]);setBusyMembers([])}await loadBots()})} function openAccount(dialog:AccountDialog){setAccountOpen(false);setAccountDialog(dialog)} async function logout(){setAccountOpen(false);await api("/api/session",{method:"DELETE",body:"{}"}).catch(()=>{});setBots([]);setRooms([]);setMcpServers([]);setActiveId(null);setActiveRoomId(null);setAuthRequired(true)} - async function changeComputer(operation:"boot"|"restart"){ + async function changeComputer(operation:"boot"|"restart"|"stop"){ const botId=paneBotId;if(!botId)return; const previous=computer;setScreenUrl(null);setDesktopReady(false); - setComputer(current=>({...current,state:operation==="boot"&¤t.state==="suspended"?"suspended":"booting"})); - try{await api(`/api/computer/${botId}/${operation}`,{method:"POST",body:"{}",signal:AbortSignal.timeout(120_000)})} + setComputer(current=>({...current,state:operation==="stop"?current.state:operation==="boot"&¤t.state==="suspended"?"suspended":"booting"})); + try{const status=await api(`/api/computer/${botId}/${operation}`,{method:"POST",body:"{}",signal:AbortSignal.timeout(120_000)});if(currentPaneRef.current===botId)setComputer(status)} catch(error){ const status=await api(`/api/computer/${botId}/status`,{signal:AbortSignal.timeout(5_000)}).catch(()=>previous); if(currentPaneRef.current===botId)setComputer(status); @@ -247,6 +260,7 @@ export function App(){ } } const startBoot=()=>changeComputer("boot"); + const stopComputer=()=>changeComputer("stop"); const restartComputer=()=>changeComputer("restart"); const connecting=computer.state==="running"&&Boolean(screenUrl)&&!desktopReady; const overlayLabel=hudLabel(computer,connecting,handingOff); @@ -254,6 +268,7 @@ export function App(){ const hud=paneBot&&overlayLabel?:null; const statusMembers=workingMembers; const topTools=