add voice

This commit is contained in:
王性驊 2026-09-06 20:26:13 +08:00
parent a58a38a005
commit 158f90770b
18 changed files with 15204 additions and 1290 deletions

535
README.md
View File

@ -1,339 +1,334 @@
<div align="center">
# LazyBoy # LazyBoy
![給 Agent 一台真的電腦](./docs/readme-hero.png) ### 給 Agent 一台真的電腦
在瀏覽器裡開 Agent。每個都有自己的 Linux 桌面:開網頁、敲指令、學你示範過的流程。金鑰、模型、檔案都留在你這台機器上 在瀏覽器裡指派工作,讓 AI Agent 在隔離的 Linux 桌面中開網頁、操作應用程式、整理檔案,並在你需要時把控制權交回來
--- [![Self-hosted](https://img.shields.io/badge/deployment-self--hosted-3ec5a8?style=flat-square)](#快速開始)
[![Rust 2024](https://img.shields.io/badge/Rust-2024-000000?style=flat-square&logo=rust)](./Cargo.toml)
[![React 19](https://img.shields.io/badge/React-19-20232a?style=flat-square&logo=react)](./apps/web/package.json)
[![Docker Compose](https://img.shields.io/badge/Docker-Compose-2496ed?style=flat-square&logo=docker&logoColor=white)](./docker-compose.yml)
## 需要的硬體 [快速開始](#快速開始) · [功能](#核心能力) · [運作流程](#一個任務如何完成) · [系統架構](#系統架構) · [二次開發](#二次開發) · [安全](#安全模型)
LazyBoy 是本機系統,不是雲端沙盒。機器要跑四件事:**Postgres**、**API含網頁**、**supervisor**、以及每個 Agent 的 **Debian 桌面容器** </div>
| 項目 | 最低能跑 | 建議(一台桌面常開) | ![LazyBoy 三欄工作空間Agent、對話與即時電腦畫面](./docs/readme-hero.png)
| --- | --- | --- |
| 作業系統 | macOS / Linux已裝 Docker Engine + Compose | 同上,磁碟給 Docker 至少 30 GB |
| CPU | 4 核 | 8 核以上。每個桌面預設吃 2 核(`LAZYBOY_COMPUTER_CPUS` |
| 記憶體 | 8 GB只能開一台、還會卡 | **16 GB**。每個桌面預設 2 GB`LAZYBOY_COMPUTER_MEMORY_MB`)再加上 API、Postgres、嵌入模型 |
| 磁碟 | 約 15 GB桌面映像 + Postgres | 家目錄 `data/homes/` 會隨瀏覽器設定檔長大 |
| 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 容器內生效 LazyBoy 是一套本機優先local-first的多 Agent 工作空間。每個 Agent 都能使用瀏覽器、終端、檔案系統與桌面應用程式;執行畫面可即時觀看、可以人工接管,也能把示範整理成技能,之後透過排程重複執行。
預設一個 Team 電腦容器可同時掛最多 **8** 個螢幕(`TEAM_SCREEN_LIMIT`)。再開私人電腦就是再一個容器、再 2 GB。分頁開著時心跳會讓桌面保持熱機關掉分頁約 10 分鐘後凍結(記憶體還在),約 6 小時後才真正停機。 > [!IMPORTANT]
> LazyBoy 會把對話、記憶、憑證與瀏覽器設定檔持久化在自己的主機,但使用外部模型時,完成任務所需的提示詞、工具結果或畫面仍可能傳送給你設定的模型供應商。請依資料敏感度選擇供應商與部署方式。
--- ## 為什麼是 LazyBoy
## 怎麼快速啟動 | 一般聊天機器人 | LazyBoy |
| --- | --- |
| 告訴你怎麼做 | 在隔離桌面中實際操作 |
| 看不到執行過程 | 即時 noVNC 畫面與步驟狀態 |
| 人與 Agent 搶控制權 | 接管/釋放租約,安全交接 |
| 每次重新說明流程 | 示範一次,儲存為可攜技能 |
| 一次性對話 | Session、長期記憶、排程與群組 |
| 固定模型與工具 | xAI、OpenCode Go、OpenAI 相容端點與 MCP |
要有 Docker含 Compose 外掛)和 Make。第一次會編 `lazyboy/computer:local`Debian + XFCE + Chromium會比較久。 ## 快速開始
### 需求
- macOS 或 Linux
- Docker Engine / Docker Desktop含 Compose
- Make、Git
- 至少 4 核 CPU、8 GB RAM建議 8 核、16 GB RAM
- 一組支援的模型 API 金鑰
第一次啟動會建置 Debian、XFCE、Chromium 桌面映像,因此會比後續啟動久。
```bash ```bash
git clone <your-repository-url>
cd LazyBoy
make env make env
# 在 .env 填 XAI_API_KEY # 編輯 .env至少填入 XAI_API_KEY、OPENCODE_GO_API_KEY
# (也可以之後在「本機工作區 → 設定」接 xAI / OpenCode Go / OpenAI 相容端點) # 或 OPENAI_API_KEY 其中之一
make up make up
make health make health
``` ```
打開 [http://127.0.0.1:3101](http://127.0.0.1:3101),用 `.env` 裡的 `LAZYBOY_APP_TOKEN` 登入,建一個 Agent傳一句話。 [http://127.0.0.1:3101](http://127.0.0.1:3101)使用 `.env` 中的 `LAZYBOY_APP_TOKEN` 登入,建立第一個 Agent然後直接描述目標
沒有 Make ```text
打開指定的訓練網站,完成還沒看完的章節,最後整理進度。
```
常用指令:
| 指令 | 用途 |
| --- | --- |
| `make up` | 建置並啟動完整堆疊 |
| `make health` | 檢查 API 健康狀態 |
| `make logs` | 持續查看服務日誌 |
| `make ps` | 查看容器狀態 |
| `make down` | 停止服務,保留 PostgreSQL 資料 |
| `make purge` | 停止服務並刪除 PostgreSQL volume |
沒有 Make 時,可以從 `.env.example` 建立設定後執行:
```bash ```bash
cp .env.example .env cp .env.example .env
# openssl rand -hex 32 → LAZYBOY_APP_TOKEN # 為 LAZYBOY_APP_TOKEN、SANDBOX_SUPERVISOR_TOKEN、
# openssl rand -hex 32 → SANDBOX_SUPERVISOR_TOKEN # LAZYBOY_VAULT_KEY 各自執行一次 openssl rand -hex 32
# openssl rand -hex 32 → LAZYBOY_VAULT_KEY
docker compose up -d --build docker compose up -d --build
``` ```
前端熱重載:`apps/web` 裡 `npm install && npm run dev`,開 [http://127.0.0.1:5173](http://127.0.0.1:5173)。Vite 把 `/api``/view` 轉到 3101。 ## 核心能力
本機 Rust 開發Postgres 仍在 Docker | 能力 | 實際行為 |
| --- | --- |
| 可觀察的電腦操作 | 右側 noVNC 顯示 Agent 的 1280×800 桌面;模型觀察與人類畫面分流 |
| 瀏覽器與原生桌面控制 | 網頁透過 CDP 元素操作;原生 UI 使用 AT-SPI必要時才退回座標 |
| 人工接管 | 登入、2FA、驗證碼或敏感步驟可暫停 Agent由使用者接管後續跑 |
| 示範教學 | 記錄語意操作事件並整理成 playbook不依賴固定像素重播 |
| 長期記憶 | PostgreSQL + pgvector + MiniLM只保存明確要求記住的內容 |
| 安全登入 | 每個 Agent 的 AES-256-GCM 憑證庫;只對相符的 HTTPS 網域填入 |
| 排程 | 五欄 cron、時區支援、立即試跑到期任務進入一般 run 佇列 |
| 多 Agent 與群組 | Team 共用電腦可分配不同 DISPLAY私人模式使用獨立容器 |
| MCP 與檔案技能 | 支援 stdio、HTTP、SSE MCP以及 `data/skills/*/SKILL.md` |
| 語音通話 | 前端提供可設定的即時語音工作階段 |
| 中英介面 | `zh-TW` 與 English 文案共用完整 key 契約 |
```bash ## 一個任務如何完成
make dev # 準備 .env、Postgres、桌面映像
make dev-supervisor # 終端 1 ```mermaid
make dev-api # 終端 2 flowchart LR
U["你<br/>文字・附件・語音"] --> W["React 工作空間<br/>對話・電腦・記憶"]
W -->|HTTP / WebSocket| A["Rust API + Agent<br/>模型・工具・技能"]
A -->|受控工具呼叫| S["Supervisor<br/>資源與生命週期"]
S -->|啟動 / 暫停 / 恢復| C["隔離 Linux 電腦<br/>Chromium・XFCE・檔案"]
C -->|畫面與事件| W
Q["排程"] -. Cron .-> A
A <--> M[("PostgreSQL<br/>Session・記憶・事件")]
V["加密憑證庫"] -. 僅相符 HTTPS 網域 .-> C
classDef ui fill:#151517,stroke:#3ec5a8,color:#f1f1f2
classDef core fill:#101012,stroke:#85858a,color:#f1f1f2
classDef secure fill:#221c0e,stroke:#fcd68a,color:#f1f1f2
class U,W ui
class A,M,Q core
class S,C,V secure
``` ```
| 指令 | 做什麼 | <div align="center">
**[開啟可縮放、可搜尋、支援深色模式的互動流程圖 →](./docs/workflow.html)**
</div>
主流程之外還有兩個重要迴圈:
1. **接管迴圈**:使用者接管時,進行中的 run 進入等待;釋放後從目前畫面重新排隊執行。
2. **技能迴圈**:示範期間記錄控制項與頁面情境,模型整理成 playbook往後仍在當下畫面重新尋找元素不重播舊座標。
## 系統架構
LazyBoy 的公開入口只有 API。Supervisor 位於 Compose 內部 control network不直接對主機開埠Agent 桌面也不掛載主機 Docker socket。
```text
Browser
│ HTTP / WebSocket / authenticated screen proxy
lazyboy-api (:3101)
├── React 靜態前端
├── Session / Run / Memory / Schedule / Vault
├── Model provider / MCP client
└── PostgreSQL + pgvector
│ authenticated internal control API
lazyboy-supervisor (:7091, internal only)
├── provision / pause / resume / stop
├── CPU / memory / PID limits
└── isolated computer containers
├── Chromium + CDP
├── XFCE + AT-SPI
├── Xvfb + x11vnc + websockify
└── per-computer persisted home
```
### 主要元件
| 元件 | 職責 |
| --- | --- | | --- | --- |
| `make up` / `make down` / `make purge` | 啟動/停止(留資料)/連 Postgres 一起清 | | `apps/web` | Vite + React 19 三欄工作空間、noVNC、語音與雙語 UI |
| `make logs` `make ps` `make health` | 看狀態 | | `crates/api` | 對外 Axum API、Agent run、Session、排程、記憶、MCP、保險箱 |
| `make computer` | 只重建桌面映像 | | `crates/harness` | 模型供應商、憑證解析與語音契約 |
| `make postgres` | 只開資料庫 `127.0.0.1:5434` | | `crates/supervisor` | Docker 電腦生命週期、隔離與資源上限 |
| `crates/control` | CDP、AT-SPI、X11 與畫面觀察操作 |
| `crates/controld` | 電腦容器內部的 localhost 控制服務 |
| `crates/contracts` | 跨 crate 的 Bot、Run、Computer、Voice 資料契約 |
| `PostgreSQL` | 對話、run、記憶、排程、憑證與保留政策 |
--- ### 電腦生命週期
## 跟 Grok Bot 比,好在哪 ```mermaid
stateDiagram-v2
[*] --> Stopped
Stopped --> Booting: 第一個需要 GUI 的工具
Booting --> Running: ready
Running --> WaitingTakeover: 使用者接管 / 2FA
WaitingTakeover --> Running: 釋放控制權
Running --> Suspended: 閒置 10 分鐘
Suspended --> Running: 新任務或畫面心跳
Suspended --> Stopped: 休眠約 6 小時
Running --> Stopped: 手動停止
```
[Grok Bot](https://grok.com) 是 xAI 的雲端隊友對話、電腦、排程都在他們的機器上。LazyBoy 走同一類產品(本機開源實作對齊 Rakazo 那條線),差在**誰擁有執行環境**。 ## 安全模型
| | Grok Bot | LazyBoy | 目前程式碼包含下列防護:
- API session token 與 Supervisor token 分離。
- Supervisor 只在內部 Compose network並使用 `no-new-privileges`、唯讀 root filesystem 與 capability drop。
- 每台電腦有 CPU、RAM、PID 上限;預設 2 CPU、2 GB、2048 PID。
- API 預設只綁定 `127.0.0.1:3101`
- 憑證以獨立 `LAZYBOY_VAULT_KEY` 加密,輪替登入 token 時不應更換此 key。
- 已保存登入只接受 HTTPS、精確或合法子網域匹配不對相似惡意網域填入。
- Markdown 連結限制為 HTTP(S)、`mailto:`、`tel:` 與頁內錨點。
- 日誌有大小與檔案數上限;診斷資料有可設定的保留週期。
部署注意事項:
1. 對區網或網際網路開放前,先放在 HTTPS reverse proxy 後方,並設定 `LAZYBOY_SECURE_COOKIE=true`
2. 不要把 Supervisor `:7091` 對外發布,也不要將 Docker socket 掛進 Agent 電腦。
3. `LAZYBOY_APP_TOKEN`、`SANDBOX_SUPERVISOR_TOKEN`、`LAZYBOY_VAULT_KEY` 必須使用不同的高熵值。
4. 模型仍可能看見任務所需的網頁內容與截圖密碼、token 與高敏感資料不要放進提示詞。
5. CAPTCHA、2FA 與不確定的敏感操作應由使用者接管。
## 硬體與資源
| 項目 | 最低可執行 | 建議 |
| --- | --- | --- | | --- | --- | --- |
| 跑在哪 | xAI 雲端 | 你的 Docker | | CPU | 4 核 | 8 核以上 |
| 模型 | Grok | 你帶金鑰xAI、OpenCode Go、或任何 OpenAI 相容端點 | | RAM | 8 GB單台電腦 | 16 GB 以上 |
| 電腦 | 廠商提供的桌面 | 你映像裡的 DebianXFCEChromium家目錄在 `data/homes/` | | 磁碟 | 約 15 GB | Docker 至少保留 30 GB |
| 資料 | 在服務端 | 對話、記憶、保險箱、瀏覽器設定檔都在本機 Postgres + 磁碟 | | GPU | 不需要 | 模型預設走外部 API |
| 登入帳號 | 跟雲端工作流程走 | 每個 Agent 自己的保險箱AES-256-GCM模型只看到帳號 id | | 作業系統 | macOS / Linux | Linux 可選配 LXCFS 顯示容器內 cgroup 配額 |
| 客製 | 封閉 | 開源。工具、MCP、技能 JSON 可改可搬 |
| 費用形態 | 訂閱/用量 | 電費與硬體;模型金鑰另計 |
| 多 Agent 同桌 | 產品內建 | Team 電腦一個容器最多 8 螢幕;私人電腦一人一容器 |
| 教會它 | 看產品當下提供什麼 | 你示範一次CDP 記語意事件,模型整理成技能 |
適合 LazyBoy 的情況:資料不能出門、要自己選模型、要看它點了哪個控制項、或想把「看完訓練影片交測驗」這種流程做成可匯出的技能。 每台電腦的預設限制可由 `LAZYBOY_COMPUTER_CPUS`、`LAZYBOY_COMPUTER_MEMORY_MB`、`LAZYBOY_COMPUTER_PIDS` 調整。瀏覽器分頁的心跳會維持熱機;沒有工作且約 10 分鐘無人觀看時暫停,持續休眠約 6 小時後停止。
Grok Bot 適合的情況:不想養 Docker、要官方託管、機器不夠力。 ## 設定
--- `make env` 會以 `.env.example` 為基礎建立 `.env`,並保留既有金鑰。
## 每個功能簡介 | 變數 | 用途 | 預設 |
| --- | --- | --- |
| `XAI_API_KEY` | xAI 模型 | 空 |
| `OPENCODE_GO_API_KEY` | OpenCode Go 模型 | 空 |
| `OPENAI_API_KEY` | OpenAI 相容端點 | 空 |
| `LAZYBOY_APP_TOKEN` | Web 登入 token | 必填 |
| `SANDBOX_SUPERVISOR_TOKEN` | API ↔ Supervisor 驗證 | 必填 |
| `LAZYBOY_VAULT_KEY` | 憑證庫加密 key | 必填且必須保持穩定 |
| `LAZYBOY_BIND_IP` | 主機監聽位址 | `127.0.0.1` |
| `LAZYBOY_SECURE_COOKIE` | HTTPS-only cookie | `false` |
| `LAZYBOY_COMPUTER_CPUS` | 每台電腦 CPU | `2` |
| `LAZYBOY_COMPUTER_MEMORY_MB` | 每台電腦記憶體 | `2048` |
| `LAZYBOY_COMPUTER_PIDS` | 每台電腦 PID 上限 | `2048` |
| `LAZYBOY_MEMORY_ENABLED` | 長期記憶 | `true` |
**對話與 Session** 完整清單與保留政策請見 [`.env.example`](./.env.example)。
每個 Agent 多則對話。訊息進 Postgres同一則用 `clientNonce` 去重。問候、閒聊走純文字,**不會**為了「看一下螢幕」去開 Docker。
**Team / 私人電腦** ## 二次開發
Team工作區共用一個家目錄每個 bot 有自己的 `DISPLAY``:1`、`:2`…)和瀏覽器設定檔。私人:這個 bot 獨佔一個容器。
**即時畫面** ### 本機開發
右側預覽是 noVNC。瀏覽器連 `/view/{botId}/vnc.html`API 用已登入的 cookie 轉到容器裡的 websockify。模型看到的截圖另走 `computer_observe`,上面會蓋黃字編號;你盯著的 VNC **沒有**那些編號。
**接管 / 釋放** ```bash
人按接管就拿到控制租約(預設 15 分鐘,心跳續約)。進行中的 run 會進 `waiting_takeover`放開後從目前畫面接著做。模型遇到登入牆、2FA、驗證碼會呼叫 `request_takeover` make dev # 準備 .env、PostgreSQL、電腦映像
make dev-supervisor # 終端 1Supervisor :7091
make dev-api # 終端 2API :3101
```
**觀察與操作** 前端熱更新:
- Chromium 網頁:`browser`CDP點 element id
- 原生視窗對話框、檔案管理員、XFCE`computer_act`AT-SPI id不行再退 xdotool 座標)
- 檔案與指令:`list_files` / `read_file` / `write_file` / `shell`
點到 `[disabled]` 的控制項會最多等 45 秒等它亮。模型用文字回「我在等」會結束整段 run所以等待必須是 `wait` 工具。
**教技能** ```bash
你示範,容器內 CDP 錄「點了哪個控制項、填了什麼、去了哪一頁」,再抽幾個關鍵畫面。停下來後模型整理成意圖級 playbook之後用普通工具在**當下畫面**找控制項,不是重播座標。密碼欄不錄。技能可匯出 JSON。 cd apps/web
npm install
npm run dev # http://127.0.0.1:5173
```
**Slash 指令與長目標** ### 檢查與測試
`data/skills/<name>/SKILL.md` 放工作區共用的唯讀技能,就能在輸入框打 `/name 參數` 執行;輸入 `/` 會顯示可用技能。`/goal` 是 harness 的持續執行模式,和錄製示範產生的 playbook 分開。選單支援方向鍵、EnterTab 選取、Esc 關閉。`/goal 目標` 會先規劃、執行並檢查結果,直到模型回報已驗證完成;只有需要登入、驗證碼、接管畫面或缺少必要資訊時才會停下來請你處理。缺少必要資訊時會記為等待輸入,補充訊息後接續原目標;停止按鈕可以取消。一般訊息仍維持 40 回合上限,教學技能 80 回合。
**記憶** ```bash
`pgvector` + MiniLM384 維)。只有你叫它記住、或它呼叫 `remember` 的內容會進長期記憶。密碼與 token 會被拒。清除對話不會清記憶。 make fmt
make clippy
make test
**保險箱** cd apps/web
每個 bot 自己的站名/帳號/密碼。模型用 `list_accounts` 只看到站與使用者名稱,`use_saved_login` 在 Chromium 登入表單填入。金鑰用 `LAZYBOY_VAULT_KEY` 加密。 npm run typecheck
npm run build
**排程** cd ../..
五欄 cron預設 `Asia/Taipei`。對話裡講「以後每天九點」或側欄新增。tick 迴圈把到期列變成普通 queued run。 node --test tests/frontend.test.mjs
python3 tests/control.test.py
python3 tests/log-rotation.test.py
**MCP** # 需要已啟動的 PostgreSQL Compose service
工作區級外掛。市集或自訂 stdioHTTPSSE。stdio 跑在 API 容器裡。 docker compose up -d postgres
python3 tests/retention.test.py
```
**群組** ### 專案結構
多個 Agent 同一個 thread。Team 電腦上各用各的螢幕。同一 bot 同時只跑一個 run後面的訊息排隊。
**附件**
圖片給當則模型看,不進歷史二進位。要讓電腦開原檔會放 `inbox/`,兩小時後刪。
**頭像與狀態**
Blobatar 色塊+眼睛。啟動、喚醒、連線、換手時,預覽左上角與思考列會顯示對應文字。分頁開著時心跳保住容器,換手不拆 VNC。
---
## 系統怎麼轉起來
![其實只有三個角色你、LazyBoy、它的電腦](./docs/diagrams/map.png)
Compose 裡 supervisor **不**對主機開埠。API 在容器網路連 `supervisor:7091`。家目錄 `data/homes/<homeKey>` bind 進容器的 `/home/lazyboy`
---
## 你送一則訊息
![它先決定:聊就好,還是要動手](./docs/diagrams/chat.png)
同一 bot 已有進行中的工作時,新訊息會排隊(`queuedBehindActive`)。人正在接管時,後面的話只排隊,思考轉圈不會假裝它還在動。問候路徑會把工具表清空,從源頭避免「哈囉」去開電腦。
`execute_run` 每一輪:續租約 → 寫步驟文字 → 問模型 → 沒有工具就結束(技能沒過會再把畫面塞回去)→ 有工具且需要沙盒才 boot → 畫面沒變就不重複塞圖。回合上限:聊天 4、一般 40、教學技能 80`/goal` 會持續到完成或明確需要人介入。目標執行期間,同一對話送進來的新訊息會作為下一輪的補充指示。
---
## 電腦的作息
![開著、小睡、關機](./docs/diagrams/sleep.png)
閒置(`crates/api/src/computer.rs` `idle_loop`
1. 執行中、超過 10 分鐘沒人看、也沒有進行中的 run示範 → `docker pause`,狀態 `suspended`
2. 休眠超過 6 小時 → `docker stop`,狀態 `stopped`
3. 分頁還在就心跳,不會進 1
開機/喚醒:已在跑就直接回;凍結中就 `unpause`(約一秒);沒有容器才 `provision`,等 `/tmp/lazyboy/ready`。畫面走 `/view/{bot}/vnc.html`,已登入的 cookie 轉到 websockify。
---
## 它怎麼看、怎麼點
![你看乾淨的,它看有編號的](./docs/diagrams/look.png)
![能認控制項,就不要猜座標](./docs/diagrams/click.png)
模型從不直接連 VNC。它只打 API 工具;工具經 sandbox HTTP 進 supervisor`docker exec` 或打容器內 `controld`
編號只畫在給模型的 JPEG 上。VNC 是乾淨桌面。每次 navigationsnapshot 會重編號,舊 id 作廢。`computer_act` 點在瀏覽器視窗上會被拒,避免用像素點網頁。解析度契約是 **1280×800**
每個 bot 一個 `computer_screens`slot、DISPLAY、執行租約、控制租約。人接管寫 `control_holder=user`worker 在回合邊界停,不跟你搶滑鼠。`view_only` 用 postMessage 切,不重掛 iframe所以換手時預覽不會黑掉。
`lazyboy-controld``127.0.0.1:7070`。Team 多螢幕slot 0 = `:1`slot N = `:N+1`。`lazyboy-screen ensure` 在同一個容器裡再長一組 Xvfb。
---
## 模型金鑰從哪來
![由近到遠,找到第一把就用](./docs/diagrams/keys.png)
`crates/harness` 不管滑鼠,只決定這次 run 要用哪一家模型。真正的 agent 迴圈在 `crates/api/src/runs.rs`
金鑰:**這個機器人 → 工作區設定 → 環境變數**。API 跑在 Docker 時,迴圈位址 `127.0.0.1` 會被改成 `host.docker.internal`,才能打到你本機的相容端點。
---
## 教會它
![你做一次,它記住為什麼](./docs/diagrams/teach.png)
之後 run 若 prompt 對得上技能名,會把完整 playbook 塞進當則,並清掉舊聊天以免模型複誦上次的「還在倒數」。執行仍用 `browser``computer_act`,在**現在**的畫面上找「Next」不是記像素。
---
## 排程怎麼進工作
![到點以後,跟你傳訊息同一條路](./docs/diagrams/schedule.png)
Cron 五欄。時區寫在列上,預設台北。`立刻跑` 只是立刻插一筆 run不改下一拍時間。
---
## 專案目錄(二次開發從這裡找)
![把它當成幾間房間,不是分層蛋糕](./docs/diagrams/folders.png)
Cargo workspace。畫面在 `apps/web`,對話與工作在 `api`,怎麼點在 `control`,開機在 `supervisor` + `image/computer`,問哪一家模型在 `harness`。前端是獨立的 Vite app由 API 把 `apps/web/dist`(或開發時的 `apps/web`)端出去。
```text ```text
LazyBoy/ LazyBoy/
├── apps/web/ 瀏覽器 UIVite + React ├── apps/web/ React 前端與 noVNC 頁面
│ ├── src/App.tsx 幾乎全部畫面:側欄、聊天、電腦、設定
│ ├── src/schedule.tsx 排程面板
│ ├── src/avatar.tsx Blobatar 頭像
│ ├── src/api.ts fetch 包裝、401
│ ├── src/types.ts 跟 API JSON 對齊的型別
│ ├── src/locales/zh-TW.ts 所有使用者看得到的字
│ ├── src/*.css 樣式styles / chat / computer / refinements…
│ └── vnc.html 內嵌桌面noVNCAPI 的 /view 會讀這一檔
├── crates/ ├── crates/
│ ├── contracts/ 跨 crate 的型別Bot、Run、ComputerState、動作 JSON │ ├── api/ 對外 API 與 Agent 執行迴圈
│ ├── harness/ 模型後端CredentialChain、resolve_backend、connect_model │ ├── contracts/ 跨元件資料契約
│ ├── control/ 桌面契約:螢幕 slot、租約、CDP/AT-SPI/xdotool、overlay │ ├── control/ 瀏覽器與桌面控制
│ │ 含 a11y.py / cdp.py容器裡被 exec 的腳本) │ ├── controld/ 容器內控制服務
│ ├── sandbox/ API 打 supervisor 的 HTTP 客戶端fake 給測試 │ ├── harness/ 模型與語音後端
│ ├── supervisor/ Dockerprovision / pause / unpause / exec / observe / act │ ├── sandbox/ API 到 Supervisor 的抽象
│ ├── controld/ 打進容器的小 HTTP127.0.0.1:7070 │ └── supervisor/ Docker 生命週期管理
│ └── api/ 唯一對外程序路由、worker、idle、排程 tick、靜態網頁 ├── image/ API、Supervisor、電腦映像
│ └── src/ ├── migrations/ SQLx PostgreSQL migrations
│ ├── main.rs 啟動、三條背景迴圈 ├── tests/ Rust 以外的契約與回歸測試
│ ├── routes.rs 組 routerbot / computer HTTP 也在這 ├── scripts/ 環境初始化與執行工具
│ ├── runs.rs agent 迴圈租約、complete_once、nudge ├── docs/hero.html README 首頁視覺原稿
│ ├── tools.rs tool_definitions + dispatch加工具從這裡 ├── docs/workflow.html 可互動產品流程圖
│ ├── computer.rs boot / 凍結 / 心跳 / 螢幕租約 ├── docker-compose.yml 正式堆疊
│ ├── sessions.rs 對話 CRUD、送訊息、SSE └── Makefile 常用開發與部署指令
│ ├── skills.rs 示範錄製與蒸馏
│ ├── file_skills.rs 讀取 data/skills 下的唯讀 SKILL.md
│ ├── schedules.rs cron
│ ├── vault.rs 登入保險箱
│ ├── memory.rs pgvector 記憶
│ ├── mcp.rs MCP 連線
│ ├── screen_proxy.rs /view 反代
│ └── db.rs SQL 與列定義
├── image/
│ ├── api/Dockerfile
│ ├── supervisor/Dockerfile
│ └── computer/ 桌面映像
│ ├── Dockerfile
│ ├── start.sh PID 1controld + Xvfb/XFCE/VNC
│ └── lazyboy-screen Team 額外 DISPLAY
├── migrations/ sqlx檔名流水號API 啟動時自動 migrate
├── data/homes/ 每個電腦的家目錄bind 進容器 /home/lazyboy
├── data/skills/<name>/SKILL.md 工作區共用的 slash 技能(執行時掛載)
├── tests/ 跨語言的小測試node:test、Python
├── scripts/ init-env、build-computer-image、dev
├── docker-compose.yml 正式堆疊Postgres + supervisor + API
├── Makefile make up / dev / test
└── reference/rakazo/ 上游參考實作,不要當 runtime 依賴
``` ```
### 想改什麼,開哪個檔 ## 徽章與開源認證
| 你要做的事 | 先開 | README 頂端目前只顯示可以直接從原始碼驗證的資訊徽章,不把「尚未執行的檢查」包裝成通過。
| --- | --- |
| 加一個模型工具(例如 `screenshot_region` | `crates/api/src/tools.rs``tool_definitions` + `dispatch`);若要 GUI`runs.rs` 的 `tool_needs_sandbox` / `tool_needs_gui` |
| 工具對應的滑鼠鍵盤CDP | `crates/control/src/{actions,x11,cdp,a11y}.rs` 與同目錄 `.py` |
| 新的 HTTP 端點 | 功能模組自己的 `router()`(如 `schedules.rs`),在 `routes.rs` `.merge(...)`電腦bot 則直接寫在 `routes.rs` |
| 新狀態、動作 JSON、Run 狀態機 | `crates/contracts/src/`(改完 `api` / 前端 `types.ts` 一起對) |
| 換模型供應商或金鑰解析 | `crates/harness/src/resolve.rs`、`crates/contracts/src/model.rs`、`workspace.rs` |
| 容器怎麼開、凍結、等 ready | `crates/supervisor/src/docker.rs`、`crates/api/src/computer.rs` |
| 桌面裡多裝套件、改 XFCE、開機腳本 | `image/computer/`,然後 `make computer` |
| 對話 UI、電腦預覽、頭像小卡 | `apps/web/src/App.tsx` + 對應 css |
| 畫面上的中文 | `apps/web/src/locales/zh-TW.ts`key 加了 `tsc` 才會過) |
| 內嵌 VNC 行為(貼上、唯讀) | `apps/web/vnc.html` |
| 新資料表 | `migrations/0xx_....sql`;列定義補 `crates/api/src/db.rs` |
| 排程 UI | `apps/web/src/schedule.tsx` |
| MCP 市集清單 | `crates/api/src/mcp_catalog.rs` |
### 加一支工具的最短路徑 若要取得可公開查驗的安全與品質徽章,建議依序完成:
1. `tools.rs``tool_definitions``ToolDefinition`名稱、說明、JSON Schema。說明是寫給模型看的。 - [ ] 在公開 GitHub repository 建立正式 mirrorOpenSSF 的公開查驗以 GitHub 為主要整合目標。
2. 同一個檔的 `dispatch` 加 match arm`ToolOutcome { text, image, pause, blocks }` - [ ] 加入完整 `LICENSE`、`SECURITY.md`、`CONTRIBUTING.md` 與行為準則。
3. 若會動到桌面:`runs.rs` 裡 `tool_needs_sandbox` / `tool_needs_gui` 把名字加進去,否則不會 boot、也拿不到螢幕租約。 - [ ] 建立 CIRust format / Clippy / tests、前端 typecheck / build / tests。
4. 需要新的容器指令就放 `control`Rust 組 argvPython 做 CDP/AT-SPIsupervisor 的 `exec` 已經會把 `DISPLAY` 帶進去。 - [ ] 啟用 Dependabot 或 Renovate、CodeQL、secret scanning 與 branch protection。
5. 前端若要顯示步驟文字,`runs.rs` 的 `describe_step` 加一列。 - [ ] 執行並發布 [OpenSSF Scorecard](https://scorecard.dev/) 結果後,再加入 Scorecard 徽章。
6. `SANDBOX_PROVIDER=fake cargo test -p lazyboy-api` 先過,再對真容器看。 - [ ] 在 [OpenSSF Best Practices](https://www.bestpractices.dev/) 登記專案、誠實完成 Passing 問卷後,再加入認證徽章。
- [ ] 若提供容器映像,再加入 SBOM、簽章與可重現版本發布流程。
### 本機二次開發迴圈 > 不建議現在顯示 CI passing、coverage、OpenSSF 或 Best Practices 徽章:目前 repository 沒有對應的公開結果,徽章會失真或直接顯示 unknown。
```bash ## 專案狀態
make env && make postgres # 資料庫
make computer # 桌面映像有改才需要
make dev-supervisor # 終端 1:7091
make dev-api # 終端 2:3101會自動跑 migrations
# 前端另開:
cd apps/web && npm install && npm run dev # :5173
```
- 改 `apps/web/src`Vite 熱更新。 LazyBoy 目前版本為 `0.1.0`,仍屬早期階段。建議先在本機或受信任網路中使用;對外部署前請完成威脅模型、權限檢查、備份與還原演練。
- 改 `crates/api`:停掉 `dev-api``make dev-api`
- 改 `crates/supervisor`:同樣重跑 supervisor。
- 改 `crates/control``.py`:映像沒重建的話,執行中的容器還是舊腳本;要嘛 `make computer` 後重開電腦,要嘛確認 supervisor exec 讀的是映像內檔案。
- 改 `image/computer`:一定 `make computer`,再在 UI 重啟該台電腦。
- 契約改了:同時改 `contracts`、呼叫端、`apps/web/src/types.ts`。
檢查: Cargo workspace 的授權中繼資料宣告為 MIT。若要正式公開散布應先在 repository 根目錄補上完整 MIT `LICENSE` 文字,再把上方授權徽章改為連到該檔案。
```bash
make fmt && make clippy && make test
node --test tests/frontend.test.mjs # 排程 cron、VNC 貼上、登入填表防護
```
`SANDBOX_PROVIDER=fake` 時 API 不碰 Docker適合先測 run工具契約。
`reference/rakazo/` 是對齊用的上游,不要在 LazyBoy runtime import 它。
--- ---
## 安全(操作時要記得) <div align="center">
- Supervisor 只在 Compose 內網,不要對 LAN 開埠 用自然語言交代工作,保留看得見、接得回來的控制權。
- 畫面走已登入 APIVNC 密碼不進瀏覽器 URL
- 區網請走 HTTPS終端是 HTTPS 時設 `LAZYBOY_SECURE_COOKIE=true` </div>
- API 綁非本機時 `LAZYBOY_APP_TOKEN` 至少 32 字supervisor 拒絕空白、過短、`dev-token`
- 保險箱用 `LAZYBOY_VAULT_KEY`;換登入 token 時這把 key 要留著
- 模型看不到密碼本文2FACAPTCHA 一定要人在**它的**畫面上處理

View File

@ -1,30 +0,0 @@
# Bug 檢查與改善計劃
檢查日期2026-09-05。範圍為目前工作目錄的前端、排程與 VNC 剪貼簿整合;不是全專案完整稽核。保留原有未提交修改。
## 本次已修正
- 頭像:縮小聊天氣泡 span 選擇器範圍,固定頭像尺寸,群聊頂欄改以實際 32px 計算堆疊位置,移除額外縮放。
- 綠點:恢復頭像本身的 presence取消側欄額外偽元素限制綠點大小未讀仍以右上藍點區別。
- 排程補齊表單、checkbox、換行及按鈕配置由整個電腦側欄負責捲動避免列表與編輯器壓縮預覽。聊天排程卡片與文字採上下排列。
- 等待動畫:電腦採獨立薄荷色機器人、呼吸光環與琥珀軌道;聊天只依工作中的對話顯示思考,不再因電腦啟動/連線而出現。排除 null session ID 互相比較產生假忙碌。
- 剪貼簿:在 noVNC 接收前攔截 Ctrl/Cmd+V讀取本機文字後只送出一次遠端貼上權限不足時開啟手動貼上框。延遲貼上期間換連線或變唯讀就取消。父子訊息驗證來源視窗避免舊 iframe 更新目前狀態。
## 計劃項目完成狀態
| 優先 | 項目 | 實作與驗證 |
| --- | --- | --- |
| P1 | 固定間隔排程 | 改用 `@every Nm/Nh/Nd`,一天固定 24 小時跨月、DST、漏跑相位測試通過。舊 cron 保留原文 |
| P1 | Cron 編輯不失真 | 僅五欄及合法範圍轉換預設;未知格式保留 Advanced空值禁止儲存修正 UnixRust 星期編號差異 |
| P1 | 電腦啟動失敗恢復 | 失敗立即刷新狀態,刷新失敗回復前值;回應只更新原本的 bot |
| P1 | 遠端貼上同步 | 後端確認 X11 剪貼簿內容後才貼上;終端使用 Ctrl+Shift+V連續貼上依序處理 |
| P2 | 複製與權限提示 | 顯示同步結果、權限失敗退路macOS Cmd+C 交由後端判斷終端快捷鍵 |
| P2 | CSS 拆分 | Avatar、Chat、Computer、Schedule 分檔,響應式規則集中最後載入 |
| P2 | 主 JS 過大 | 動畫改為 lazy import主檔約 446 kB動畫 chunk 約 318 kB消除 500 kB chunk 警告 |
## 驗證與限制
- 前端 TypeScript、正式打包、5 個 Node 回歸測試、3 個 Python 控制測試通過Rust 驗證詳見[安全與 Harness 檢查](security-and-harness-review.md)。
- 瀏覽器工具回報沒有可用瀏覽器,尚未完成視覺截圖與真實 VNC 端到端驗收。這些項目已完成程式修正,不能等同所有畫面已實機驗收。
- 待實機驗收1440×900、1280×720、390×844群聊、長排程、多筆排程、開機與聊天各自獨立、權限拒絕、中文emoji多行與重連。
- 剪貼簿支援純文字圖片、檔案不在此次實作範圍。Lottie 上游 eval 提醒仍存在;未量測首屏時間。

View File

@ -1,925 +0,0 @@
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8"/>
<title>LazyBoy diagrams</title>
<style>
@font-face {
font-family: Huninn;
src: url("../apps/web/node_modules/@fontsource/huninn/files/huninn-latin-400-normal.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F;
}
@font-face {
font-family: Huninn;
src: url("../apps/web/node_modules/@fontsource/huninn/files/huninn-chinese-traditional-400-normal.woff2") format("woff2");
unicode-range: U+4E00-9FFF, U+3400-4DBF, U+F900-FAFF, U+3000-303F, U+FF00-FFEF;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
width: 1280px;
height: 720px;
overflow: hidden;
background: #f3f3f5;
color: #161618;
font-family: Huninn, "PingFang TC", sans-serif;
}
.slide {
width: 1280px;
height: 720px;
padding: 32px 48px 30px;
display: none;
flex-direction: column;
}
.slide.on { display: flex; }
.kicker {
font-size: 12px;
letter-spacing: .16em;
color: #0f766e;
margin-bottom: 6px;
}
h1 {
margin: 0;
font-size: 30px;
font-weight: 400;
letter-spacing: .01em;
}
.sub {
margin: 6px 0 20px;
color: #6b6b73;
font-size: 14px;
line-height: 1.5;
}
.row { display: flex; gap: 16px; align-items: stretch; flex: 1; min-height: 0; }
.col3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; flex: 1; min-height: 0; }
.col4 { display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 14px; flex: 1; min-height: 0; }
.col5 { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; flex: 1; min-height: 0; }
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; flex: 1; min-height: 0; }
.rooms {
display: grid;
grid-template-columns: 1.15fr 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 14px;
flex: 1;
min-height: 0;
}
.card {
background: #fff;
border-radius: 22px;
border: 1px solid #ececef;
box-shadow: 0 16px 40px rgba(20,20,24,.06);
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
.card.dark { background: #111113; color: #f1f1f2; border-color: #1c1c20; }
.viz {
flex: 1;
min-height: 0;
position: relative;
overflow: hidden;
}
.copy { padding: 14px 16px 16px; }
.copy h2 { margin: 0 0 4px; font-size: 17px; font-weight: 400; }
.copy p { margin: 0; color: #6b6b73; font-size: 13px; line-height: 1.45; }
.dark .copy p { color: #9a9aa2; }
.n {
width: 24px; height: 24px; border-radius: 50%;
display: grid; place-items: center;
background: #3ec5a8; color: #083f34; font-size: 12px;
margin-bottom: 8px;
}
.pill {
display: inline-flex; align-items: center;
height: 22px; padding: 0 9px; border-radius: 999px;
background: #eef8f5; color: #0f766e; font-size: 11px;
}
.dark .pill { background: #1a2e2a; color: #7ee0c8; }
.face { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; }
.blob { width: 36px; height: 36px; flex: 0 0 36px; }
.chev {
width: 22px; flex: 0 0 22px;
display: grid; place-items: center;
color: #3ec5a8; font-size: 22px;
align-self: center;
}
/* mini chat */
.chat-mini {
position: absolute; inset: 16px 14px 12px;
display: flex; flex-direction: column; gap: 8px;
justify-content: flex-end;
}
.b {
max-width: 86%;
padding: 8px 12px;
border-radius: 14px;
font-size: 12px;
line-height: 1.4;
}
.me { align-self: flex-end; background: #f1f1ef; color: #1a1a1a; }
.them { align-self: flex-start; background: #19191c; color: #eee; }
.think-row {
display: flex; align-items: center; gap: 8px;
color: #85858a; font-size: 11px;
}
/* xfce-ish desktop */
.desk {
position: absolute; inset: 0;
background:
radial-gradient(circle at 18% 0%, #1a2430 0%, transparent 46%),
linear-gradient(180deg, #15202b, #0d1218);
}
.desk.dim { filter: saturate(.4) brightness(.55); }
.desk.off { background: #09090b; }
.win {
position: absolute;
left: 16px; top: 14px; right: 16px; bottom: 26px;
border-radius: 8px;
background: #fff;
overflow: hidden;
box-shadow: 0 10px 24px rgba(0,0,0,.32);
display: grid;
grid-template-rows: 22px 26px 1fr;
}
.win-bar {
background: #e8e8ea;
display: flex; align-items: center; gap: 5px;
padding: 0 8px;
color: #666; font-size: 10px;
}
.win-bar i { width: 7px; height: 7px; border-radius: 50%; background: #c4c4c8; font-style: normal; }
.addr {
background: #f4f4f6;
display: flex; align-items: center;
padding: 0 10px;
color: #666; font-size: 10px;
border-bottom: 1px solid #ececf0;
}
.page-body {
padding: 12px 14px 14px;
color: #222;
display: flex;
flex-direction: column;
min-height: 0;
}
.page-body h3 { margin: 0 0 4px; font-size: 14px; font-weight: 400; }
.page-body p { margin: 0; color: #666; font-size: 11px; line-height: 1.4; }
.video {
flex: 1;
min-height: 40px;
margin: 10px 0 8px;
border-radius: 8px;
background:
linear-gradient(160deg, #1c2838 0%, #1a2c2a 55%, #2a8f7a 140%);
}
.lessons { margin: 8px 0 0; padding: 0; list-style: none; font-size: 11px; color: #555; }
.lessons li {
display: flex; justify-content: space-between;
padding: 5px 0;
border-bottom: 1px solid #f0f0f2;
}
.lessons li.on { color: #0f766e; }
.mail { margin-top: 8px; font-size: 11px; color: #444; }
.mail .m { padding: 7px 8px; border-radius: 8px; }
.mail .m.on { background: #eef8f5; }
.progress {
margin-top: 8px; height: 6px; border-radius: 99px; background: #ececf0; overflow: hidden;
}
.progress b { display: block; width: 62%; height: 100%; background: #3ec5a8; }
.next {
margin-top: 10px; height: 26px; width: 68px;
border-radius: 6px; background: #1a1a1c; color: #fff;
display: grid; place-items: center; font-size: 11px;
position: relative;
flex: 0 0 auto;
}
.btns { display: flex; gap: 8px; margin-top: auto; padding-top: 10px; }
.tag {
position: absolute;
right: -9px; top: -9px;
width: 20px; height: 20px; border-radius: 5px;
background: #ffe66d; color: #111;
display: grid; place-items: center; font-size: 11px;
box-shadow: 0 2px 6px rgba(0,0,0,.18);
}
.panelbar {
position: absolute; left: 0; right: 0; bottom: 0; height: 22px;
background: #1b242e; border-top: 1px solid #2a3540;
}
.zzz {
position: absolute; right: 18px; top: 14px;
color: #cdeae3; font-size: 22px; letter-spacing: 2px;
}
.house {
position: absolute; inset: 0;
display: grid; place-items: center;
color: #3ec5a8; font-size: 13px; text-align: center; line-height: 1.5;
}
/* queue / chips */
.stack {
position: absolute; inset: 18px 16px;
display: flex; flex-direction: column; gap: 8px; justify-content: center;
}
.job {
height: 36px; border-radius: 10px;
display: flex; align-items: center; padding: 0 12px;
font-size: 12px; gap: 8px;
}
.job.on { background: #111113; color: #fff; }
.job.wait { background: #f1f1ef; color: #666; }
.job .dot { width: 7px; height: 7px; border-radius: 50%; background: #3ec5a8; }
.fork {
position: absolute; inset: 16px 12px;
display: flex; flex-direction: column; gap: 10px; justify-content: center;
}
.lane {
border-radius: 12px; padding: 10px 12px;
font-size: 12px; line-height: 1.4;
}
.lane.a { background: #eef8f5; color: #0f766e; }
.lane.b { background: #111113; color: #f1f1f2; }
.keys-viz {
position: absolute; inset: 0;
display: grid; place-items: center;
}
.key {
width: 88px; height: 88px;
}
.clock {
width: 120px; height: 120px;
}
.playbook {
position: absolute; inset: 18px 16px;
background: #fff; border-radius: 10px;
padding: 12px; box-shadow: 0 8px 20px rgba(0,0,0,.08);
font-size: 11px; color: #333; line-height: 1.55;
}
.playbook b { display: block; font-weight: 400; font-size: 13px; margin-bottom: 6px; }
.events {
position: absolute; inset: 16px 14px;
display: flex; flex-direction: column; gap: 7px; justify-content: center;
}
.ev {
background: #fff; border-radius: 10px; padding: 8px 10px;
font-size: 11px; color: #333;
box-shadow: 0 4px 12px rgba(0,0,0,.06);
}
.ev em { color: #0f766e; font-style: normal; }
.dialog {
position: absolute; left: 18px; right: 18px; top: 28px; bottom: 28px;
background: #f6f6f4; border-radius: 10px;
box-shadow: 0 12px 28px rgba(0,0,0,.2);
padding: 12px;
color: #222; font-size: 12px;
}
.dialog h4 { margin: 0 0 10px; font-weight: 400; font-size: 13px; }
.file { padding: 6px 8px; border-radius: 6px; }
.file.on { background: #d7efe8; }
.dlg-btn {
position: absolute; right: 12px; bottom: 12px;
height: 26px; padding: 0 12px; border-radius: 6px;
background: #1a1a1c; color: #fff; display: grid; place-items: center; font-size: 11px;
}
.canvas {
position: absolute; inset: 16px;
background:
linear-gradient(#ececf0 1px, transparent 1px) 0 0 / 24px 24px,
linear-gradient(90deg, #ececf0 1px, transparent 1px) 0 0 / 24px 24px,
#fafafa;
border-radius: 10px;
}
.cross {
position: absolute; left: 52%; top: 46%;
width: 18px; height: 18px;
transform: translate(-50%,-50%);
}
.cross:before, .cross:after {
content: ""; position: absolute; background: #d23b3b;
}
.cross:before { left: 8px; top: 0; width: 2px; height: 18px; }
.cross:after { left: 0; top: 8px; width: 18px; height: 2px; }
.xy {
position: absolute; left: 54%; top: 52%;
font-size: 11px; color: #d23b3b;
}
.alarm {
position: absolute; inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
}
.cron {
margin-top: 8px; font-size: 12px; color: #0f766e;
background: #eef8f5; padding: 4px 10px; border-radius: 999px;
}
.room {
border-radius: 20px;
padding: 18px 18px 16px;
color: #fff;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 0;
}
.room small { opacity: .82; font-size: 13px; line-height: 1.45; }
.room h2 { margin: 0 0 4px; font-size: 20px; font-weight: 400; }
.room .path { font-size: 11px; opacity: .7; letter-spacing: .02em; }
.tiny-ui {
margin: 14px 0 10px;
background: #1a1a1d;
border-radius: 12px;
flex: 1;
min-height: 0;
overflow: hidden;
display: grid;
grid-template-columns: 54px 1fr 78px;
}
.tiny-ui > div { border-right: 1px solid #2a2a2e; }
.tiny-ui > div:last-child { border: 0; background: #121214; }
.form {
position: absolute; inset: 22px 18px;
background: #fff;
border-radius: 12px;
padding: 18px;
box-shadow: 0 8px 20px rgba(0,0,0,.06);
display: flex;
flex-direction: column;
justify-content: center;
gap: 10px;
}
.form.dark {
background: #19191c;
color: #eee;
}
.form label { display: block; font-size: 11px; color: #888; margin-bottom: 8px; }
.field {
height: 36px; border-radius: 10px;
display: flex; align-items: center; padding: 0 12px;
font-size: 13px; gap: 8px;
background: #f4f4f6;
}
.form.dark .field { background: #111113; }
.ok { margin-left: auto; color: #3ec5a8; font-size: 12px; }
.env-lines {
font-size: 12px; line-height: 1.8; color: #444;
font-family: ui-monospace, Menlo, monospace;
}
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.chip {
background: #f1f1ef; border-radius: 999px;
padding: 4px 10px; font-size: 11px; color: #333;
}
.dark .chip { background: #1c1c20; color: #ddd; }
</style>
</head>
<body>
<section class="slide" id="map">
<div class="kicker">LAZYBOY · 怎麼轉起來</div>
<h1>其實只有三個角色</h1>
<p class="sub">不是一疊伺服器名詞。你說話、中間有人幫忙想、右邊那台電腦去按。</p>
<div class="row">
<article class="card">
<div class="viz" style="background:#0d0d0e">
<div class="chat-mini">
<div class="b me">把這堂課看完</div>
<div class="b them">好,我去開電腦點 Next。</div>
<div class="think-row">右側還能看見它的桌面</div>
</div>
</div>
<div class="copy">
<div class="face">
<svg class="blob" viewBox="0 0 64 64"><circle cx="32" cy="32" r="28" fill="#f1f1ef"/><circle cx="32" cy="26" r="8" fill="#c8c8cc"/><path d="M18 46c3-8 9-12 14-12s11 4 14 12" fill="#c8c8cc"/></svg>
<div><h2></h2><span class="pill">瀏覽器</span></div>
</div>
<p>聊天、看桌面、必要時接手滑鼠。分頁開著,電腦就不會睡著。</p>
</div>
</article>
<div class="chev"></div>
<article class="card dark">
<div class="viz" style="background:#0a0a0c;display:grid;place-items:center">
<div style="text-align:center">
<svg width="72" height="72" viewBox="0 0 64 64"><path fill="#3ec5a8" d="M33 5c11.5 1 24 11 23 26-1 14-11 27-25 28S6 47 7 31 21 4 33 5z"/><ellipse cx="25.5" cy="29" rx="4.2" ry="5" fill="#1b1b22"/><ellipse cx="40.5" cy="29" rx="4.2" ry="5" fill="#1b1b22"/><circle cx="26.8" cy="27.2" r="1.3" fill="#f3f3f6"/><circle cx="41.8" cy="27.2" r="1.3" fill="#f3f3f6"/></svg>
<div class="chips" style="justify-content:center">
<span class="chip">記憶</span><span class="chip">技能</span><span class="chip">要不要開機</span>
</div>
</div>
</div>
<div class="copy">
<div class="face">
<div><h2>LazyBoy</h2><span class="pill">想、記、叫人做事</span></div>
</div>
<p>把話留給模型、把檔案留給磁碟。問候不會無故開機。</p>
</div>
</article>
<div class="chev"></div>
<article class="card">
<div class="viz">
<div class="desk">
<div class="win">
<div class="win-bar"><i></i><i></i><i></i> Chromium</div>
<div class="addr">star.example / course / 12</div>
<div class="page-body">
<h3>Workplace Safety</h3>
<p>單元 1218</p>
<div class="video"></div>
<div class="progress"><b></b></div>
<div class="next">Next</div>
</div>
</div>
<div class="panelbar"></div>
</div>
</div>
<div class="copy">
<div class="face">
<svg class="blob" viewBox="0 0 64 64"><rect x="8" y="12" width="48" height="36" rx="6" fill="#111113"/><rect x="14" y="18" width="36" height="20" fill="#3ec5a8" opacity=".35"/><rect x="20" y="50" width="24" height="4" rx="2" fill="#ccc"/></svg>
<div><h2>它的電腦</h2><span class="pill">真的 Linux 桌面</span></div>
</div>
<p>Debian、視窗、瀏覽器。家目錄在你硬碟上不在別人雲裡。</p>
</div>
</article>
</div>
</section>
<section class="slide" id="chat">
<div class="kicker">傳一句話之後</div>
<h1>它先決定:聊就好,還是要動手</h1>
<p class="sub">同一條路走完。差別只在第四步要不要打開那台電腦。</p>
<div class="col5">
<article class="card">
<div class="viz" style="background:#0d0d0e">
<div class="chat-mini">
<div class="b me">哈囉</div>
<div class="b me" style="opacity:.45">把課看完</div>
</div>
</div>
<div class="copy"><div class="n">1</div><h2>你送出</h2><p>訊息進這則對話。同一句話重送不會變兩則。</p></div>
</article>
<article class="card">
<div class="viz" style="background:#f7f7f8">
<div class="stack">
<div class="job on"><span class="dot"></span>現在這則</div>
<div class="job wait">下一則等著</div>
<div class="job wait">再下一則</div>
</div>
</div>
<div class="copy"><div class="n">2</div><h2>排隊</h2><p>這個 Agent 一次只做一件事。忙完才輪到下一則。</p></div>
</article>
<article class="card dark">
<div class="viz" style="background:#0a0a0c;display:grid;place-items:center">
<div style="text-align:center;color:#9a9aa2;font-size:12px;line-height:1.6">
<svg width="56" height="56" viewBox="0 0 64 64"><path fill="#3ec5a8" d="M33 5c11.5 1 24 11 23 26-1 14-11 27-25 28S6 47 7 31 21 4 33 5z"/><ellipse cx="25.5" cy="29" rx="4.2" ry="5" fill="#1b1b22"/><ellipse cx="40.5" cy="29" rx="4.2" ry="5" fill="#1b1b22"/><circle cx="26.8" cy="27.2" r="1.3" fill="#f3f3f6"/><circle cx="41.8" cy="27.2" r="1.3" fill="#f3f3f6"/></svg>
<div>記憶 · 技能 · 你剛說的</div>
</div>
</div>
<div class="copy"><div class="n">3</div><h2>問模型</h2><p>帶上記憶、技能、你剛說的話。金鑰用你自己的。</p></div>
</article>
<article class="card">
<div class="viz" style="background:#f7f7f8">
<div class="fork">
<div class="lane a">哈囉 → 直接回你</div>
<div class="lane b">「把課看完」→ 開電腦再點</div>
</div>
</div>
<div class="copy"><div class="n">4</div><h2>兩條岔路</h2><p>問候不開機。真的要動手,才叫醒那台 Debian。</p></div>
</article>
<article class="card">
<div class="viz">
<div class="desk">
<div class="win">
<div class="win-bar"><i></i><i></i><i></i> Chromium</div>
<div class="addr">star.example / course / 13</div>
<div class="page-body">
<h3>Quiz</h3>
<p>右側畫面跟著動</p>
<div class="video"></div>
<div class="progress"><b style="width:78%"></b></div>
</div>
</div>
<div class="panelbar"></div>
</div>
</div>
<div class="copy"><div class="n">5</div><h2>寫回來</h2><p>回覆出現在聊天裡。右側畫面跟著動。</p></div>
</article>
</div>
</section>
<section class="slide" id="sleep">
<div class="kicker">電腦的作息</div>
<h1>開著、小睡、關機</h1>
<p class="sub">分頁還在看,它就醒著。沒人看才睡,醒來幾乎不用等。</p>
<div class="col3">
<article class="card dark">
<div class="viz">
<div class="desk">
<div class="win">
<div class="win-bar"><i></i><i></i><i></i> Chromium</div>
<div class="addr">你還在這個分頁</div>
<div class="page-body">
<h3>熱機中</h3>
<p>每兩秒打一次招呼</p>
<div class="video"></div>
<div class="progress"><b></b></div>
</div>
</div>
<div class="panelbar"></div>
</div>
</div>
<div class="copy">
<div class="n">1</div>
<h2>開著</h2>
<p>記憶體佔著,畫面隨時能進。</p>
<div class="chips"><span class="chip">心跳</span><span class="chip">執行中</span></div>
</div>
</article>
<article class="card">
<div class="viz">
<div class="desk dim">
<div class="win">
<div class="win-bar"><i></i><i></i><i></i> Chromium</div>
<div class="addr">離開約 10 分鐘</div>
<div class="page-body">
<h3>凍結</h3>
<p>行程還在記憶體裡</p>
<div class="video"></div>
</div>
</div>
<div class="panelbar"></div>
</div>
<div class="zzz">Zzz</div>
</div>
<div class="copy">
<div class="n">2</div>
<h2>小睡</h2>
<p>像筆電合蓋。下次用,大約一秒內醒來。</p>
<div class="chips"><span class="chip">凍結</span><span class="chip">不要重開桌面</span></div>
</div>
</article>
<article class="card">
<div class="viz">
<div class="desk off">
<div class="house">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#3ec5a8" stroke-width="1.6"><path d="M4 11.5 12 5l8 6.5V20H4z"/><path d="M9 20v-6h6v6"/></svg>
<div>家目錄還在</div>
</div>
<div class="panelbar"></div>
</div>
</div>
<div class="copy">
<div class="n">3</div>
<h2>關機</h2>
<p>約六小時後才真正停掉,把那 2 GB 還你。</p>
<div class="chips"><span class="chip">停機</span><span class="chip">家目錄還在</span></div>
</div>
</article>
</div>
</section>
<section class="slide" id="look">
<div class="kicker">它怎麼「看」螢幕</div>
<h1>你看乾淨的,它看有編號的</h1>
<p class="sub">模型從來不連你的 VNC。它拿一張蓋了黃字的截圖點編號不是猜像素。</p>
<div class="split">
<article class="card">
<div class="copy" style="padding-bottom:8px"><h2>你看到的</h2><p>右側預覽,就是那台 Linux 桌面。沒有黃框、沒有編號。</p></div>
<div class="viz" style="min-height:360px">
<div class="desk">
<div class="win">
<div class="win-bar"><i></i><i></i><i></i> Chromium</div>
<div class="addr">star.example / course / 12</div>
<div class="page-body">
<h3>Workplace Safety</h3>
<p>單元 1218 · 看完按 Next</p>
<div class="video"></div>
<ul class="lessons">
<li>11. 防護用具</li>
<li class="on">12. 現場巡視</li>
<li>13. 測驗</li>
</ul>
<div class="progress"><b></b></div>
<div class="next">Next</div>
</div>
</div>
<div class="panelbar"></div>
</div>
</div>
</article>
<article class="card dark">
<div class="copy" style="padding-bottom:8px"><h2>模型看到的</h2><p>同一瞬間多一層黃字。它說「點 3」就是點那個按鈕。</p></div>
<div class="viz" style="min-height:360px">
<div class="desk">
<div class="win">
<div class="win-bar"><i></i><i></i><i></i> Chromium</div>
<div class="addr">star.example / course / 12</div>
<div class="page-body">
<h3>Workplace Safety</h3>
<p>單元 1218 · 看完按 Next</p>
<div class="video"></div>
<ul class="lessons">
<li>11. 防護用具</li>
<li class="on">12. 現場巡視</li>
<li>13. 測驗</li>
</ul>
<div class="progress"><b></b></div>
<div class="next">Next<span class="tag">3</span></div>
</div>
</div>
<div class="panelbar"></div>
</div>
</div>
</article>
</div>
</section>
<section class="slide" id="click">
<div class="kicker">動手的三種方式</div>
<h1>能認控制項,就不要猜座標</h1>
<p class="sub">網頁走瀏覽器、對話框走無障礙樹,實在沒編號才點像素。</p>
<div class="col3">
<article class="card dark">
<div class="viz">
<div class="desk">
<div class="win">
<div class="win-bar"><i></i><i></i><i></i> Chromium</div>
<div class="addr">mail.example / inbox</div>
<div class="page-body">
<h3>收件匣</h3>
<div class="mail">
<div class="m on">訓練系統 · 請完成單元 12</div>
<div class="m">HR · 本週排班</div>
<div class="m">IT · 密碼即將到期</div>
</div>
<div class="btns">
<div class="next" style="width:78px">Compose<span class="tag">1</span></div>
<div class="next" style="width:64px;background:#444">Reply<span class="tag">2</span></div>
</div>
</div>
</div>
<div class="panelbar"></div>
</div>
</div>
<div class="copy">
<div class="n">1</div>
<h2>網頁</h2>
<p>Chromium 裡的按鈕、輸入框。點 id會自己捲到看不見的地方。</p>
<div class="chips"><span class="chip">優先</span></div>
</div>
</article>
<article class="card">
<div class="viz" style="background:#2a3340">
<div class="dialog">
<h4>開啟檔案</h4>
<div class="file">report.pdf</div>
<div class="file on">syllabus.pdf</div>
<div class="file">notes.txt</div>
<div class="dlg-btn">開啟<span class="tag">4</span></div>
</div>
</div>
<div class="copy">
<div class="n">2</div>
<h2>桌面視窗</h2>
<p>檔案選取、系統對話框。用無障礙名稱,不是用視窗外框。</p>
<div class="chips"><span class="chip">原生 GUI</span></div>
</div>
</article>
<article class="card">
<div class="viz" style="background:#fff">
<div class="canvas">
<div class="cross"></div>
<div class="xy">640, 400</div>
</div>
</div>
<div class="copy">
<div class="n">3</div>
<h2>座標</h2>
<p>畫布、沒有名字的控制項。最後才用。畫面契約是 1280×800。</p>
<div class="chips"><span class="chip">不得已</span></div>
</div>
</article>
</div>
</section>
<section class="slide" id="teach">
<div class="kicker">教會它</div>
<h1>你做一次,它記住「為什麼」</h1>
<p class="sub">不是錄巨集。它記你點了哪個控制項、填了什麼、去了哪一頁,下次在活的畫面上自己找。</p>
<div class="col4">
<article class="card">
<div class="viz">
<div class="desk">
<div class="win">
<div class="win-bar"><i></i><i></i><i></i> 你在操作</div>
<div class="addr">star.example / course / 12</div>
<div class="page-body">
<h3>Workplace Safety</h3>
<p>你正在示範</p>
<div class="video"></div>
<div class="next">Next<span class="tag">3</span></div>
</div>
</div>
<div class="panelbar"></div>
</div>
</div>
<div class="copy"><div class="n">1</div><h2>你示範</h2><p>畫面交給你,用平常的方式做完。密碼欄不會被記。</p></div>
</article>
<article class="card dark">
<div class="viz" style="background:#0a0a0c">
<div class="events">
<div class="ev">click <em>Next</em></div>
<div class="ev">fill <em>姓名</em></div>
<div class="ev">goto <em>/quiz</em></div>
</div>
</div>
<div class="copy"><div class="n">2</div><h2>它在旁邊看</h2><p>記語意事件。畫面沒變就不存,避免一堆廢幀。</p></div>
</article>
<article class="card">
<div class="viz" style="background:#eef2ee">
<div class="playbook">
<b>技能草稿</b>
目標:看完課並交測驗<br/>
輸入:學員姓名<br/>
步驟:找到 Next → 填表 → 送出<br/>
完成:出現分數
<div style="margin-top:12px;color:#0f766e;font-size:11px">可改名 · 試跑 · 匯出 JSON</div>
</div>
</div>
<div class="copy"><div class="n">3</div><h2>整理成技能</h2><p>目標、可變輸入、步驟、怎麼算做完。你可以改名字再存。</p></div>
</article>
<article class="card">
<div class="viz">
<div class="desk">
<div class="win">
<div class="win-bar"><i></i><i></i><i></i> 下次自己做</div>
<div class="addr">star.example / course / 3</div>
<div class="page-body">
<h3>另一堂課</h3>
<p>在現在的畫面上找 Next</p>
<div class="video"></div>
<div class="next">Next<span class="tag">2</span></div>
</div>
</div>
<div class="panelbar"></div>
</div>
</div>
<div class="copy"><div class="n">4</div><h2>下次自己做</h2><p>找「Next」不是重播上次的座標。</p></div>
</article>
</div>
</section>
<section class="slide" id="schedule">
<div class="kicker">排程</div>
<h1>到點以後,跟你傳訊息同一條路</h1>
<p class="sub">不是另一套引擎。鬧鐘響了,就塞進同一個工作隊列。</p>
<div class="row">
<article class="card">
<div class="viz" style="background:#0d0d0e">
<div class="chat-mini">
<div class="b me">每天九點交報告</div>
<div class="think-row">對話裡講,或側欄自己建</div>
</div>
</div>
<div class="copy"><div class="n">1</div><h2>你說「每天九點」</h2><p>五欄時間,預設台北。</p></div>
</article>
<div class="chev"></div>
<article class="card">
<div class="viz" style="background:#f7f7f8">
<div class="alarm">
<svg class="clock" viewBox="0 0 120 120">
<circle cx="60" cy="60" r="52" fill="#fff" stroke="#ececef" stroke-width="4"/>
<circle cx="60" cy="60" r="4" fill="#111"/>
<line x1="60" y1="60" x2="60" y2="28" stroke="#111" stroke-width="4" stroke-linecap="round"/>
<line x1="60" y1="60" x2="88" y2="60" stroke="#3ec5a8" stroke-width="4" stroke-linecap="round"/>
</svg>
<div class="cron">每天 09:00</div>
</div>
</div>
<div class="copy"><div class="n">2</div><h2>鬧鐘記住</h2><p>到點前它只是一列時間。按「立刻跑」也不會把鬧鐘撥亂。</p></div>
</article>
<div class="chev"></div>
<article class="card dark">
<div class="viz" style="background:#0a0a0c">
<div class="stack">
<div class="job on"><span class="dot"></span>09:00 交報告</div>
<div class="job wait">你剛傳的那則</div>
<div class="job wait">下一則等著</div>
</div>
</div>
<div class="copy"><div class="n">3</div><h2>變成普通工作</h2><p>跟你剛傳的那則訊息一樣:排隊、問模型、必要時開電腦。</p></div>
</article>
</div>
</section>
<section class="slide" id="keys">
<div class="kicker">模型金鑰</div>
<h1>由近到遠,找到第一把就用</h1>
<p class="sub">不管滑鼠。只決定這次要問哪一家模型。</p>
<div class="col3">
<article class="card dark">
<div class="viz" style="background:#0a0a0c">
<div class="form dark">
<label>這個機器人 · 模型金鑰</label>
<div class="field">xai-••••••••••••<span class="ok">用這把</span></div>
</div>
</div>
<div class="copy"><div class="n">1</div><h2>這個機器人</h2><p>它自己若存了 key用它的。適合不同 Agent 接不同家。</p></div>
</article>
<article class="card">
<div class="viz" style="background:#f7f7f8">
<div class="form">
<label>本機工作區 · 設定</label>
<div class="field" style="color:#999">尚未填寫</div>
</div>
</div>
<div class="copy"><div class="n">2</div><h2>工作區設定</h2><p>本機工作區裡填的。畫面上存的會蓋過環境變數。</p></div>
</article>
<article class="card">
<div class="viz" style="background:#f7f7f8">
<div class="form">
<label>.env</label>
<div class="env-lines">XAI_API_KEY=…<br/>OPENAI_API_KEY=</div>
</div>
</div>
<div class="copy"><div class="n">3</div><h2>環境變數</h2><p>什麼都沒填才落到這裡。</p></div>
</article>
</div>
</section>
<section class="slide" id="folders">
<div class="kicker">想改程式時</div>
<h1>把它當成幾間房間,不是分層蛋糕</h1>
<p class="sub">從你看得見的畫面往裡走。不必先背 crate 依賴圖。</p>
<div class="rooms">
<div class="room" style="background:#111113;grid-row:1 / span 2">
<div class="path">apps/web</div>
<h2>畫面</h2>
<div class="tiny-ui">
<div style="padding:10px 8px">
<div style="height:8px;width:72%;background:#2a2a2e;border-radius:4px;margin-bottom:10px"></div>
<div style="height:28px;background:#222;border-radius:8px;margin-bottom:8px"></div>
<div style="height:28px;background:#1a1a1d;border-radius:8px"></div>
</div>
<div style="padding:12px 10px;display:flex;flex-direction:column;gap:8px">
<div style="height:8px;width:36%;background:#2a2a2e;border-radius:4px"></div>
<div style="height:26px;width:72%;background:#f1f1ef;border-radius:10px;margin-left:auto"></div>
<div style="height:26px;width:58%;background:#19191c;border-radius:10px"></div>
<div style="height:26px;width:64%;background:#f1f1ef;border-radius:10px;margin-left:auto"></div>
<div style="flex:1"></div>
<div style="height:28px;border:1px solid #2a2a2e;border-radius:14px"></div>
</div>
<div style="background:#101012"></div>
</div>
<small>聊天、電腦預覽、頭像、排程表單。中文在 locales。VNC 在 vnc.html。</small>
</div>
<div class="room" style="background:#0f766e">
<div>
<div class="path">crates/api</div>
<h2>對話與工作</h2>
</div>
<small>送訊息、工具、開機、技能、排程、保險箱。加工具從 tools.rs 開始。</small>
</div>
<div class="room" style="background:#6a6bf5">
<div>
<div class="path">crates/control</div>
<h2>怎麼點</h2>
</div>
<small>編號、CDP、無障礙樹、滑鼠。桌面上的手。</small>
</div>
<div class="room" style="background:#f5a03c;color:#1b1206">
<div>
<div class="path">supervisor + image/computer</div>
<h2>怎麼開機</h2>
</div>
<small>容器的生老病死,以及 XFCEChromium 長什麼樣。</small>
</div>
<div class="room" style="background:#d9508a">
<div>
<div class="path">crates/harness</div>
<h2>問哪一家模型</h2>
</div>
<small>金鑰從哪來、打哪一個網址。不管滑鼠。</small>
</div>
</div>
</section>
<script>
const id = new URLSearchParams(location.search).get("p") || "map";
document.querySelectorAll(".slide").forEach((el) => {
el.classList.toggle("on", el.id === id);
});
</script>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 427 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 455 KiB

View File

@ -360,11 +360,11 @@
</svg> </svg>
LazyBoy LazyBoy
</div> </div>
<div class="pill"><b>本機開源</b> 每個 Agent 有自己的 Linux 桌面</div> <div class="pill"><b>本機自託管</b> 每個 Agent 有自己的 Linux 桌面</div>
</div> </div>
<div class="copy"> <div class="copy">
<h1>給 Agent 一台真的電腦</h1> <h1>給 Agent 一台真的電腦</h1>
<p class="lead">在瀏覽器裡開機器人。它會自己開網頁、敲指令、學你示範過的流程。<br/>金鑰、模型、桌面都在你這台機器上</p> <p class="lead">在瀏覽器裡開機器人。它會自己開網頁、敲指令、學你示範過的流程。<br/>對話與桌面資料留在你的主機,模型連線由你選擇</p>
</div> </div>
<div class="window"> <div class="window">
<div class="chrome"><i class="dot r"></i><i class="dot y"></i><i class="dot g"></i></div> <div class="chrome"><i class="dot r"></i><i class="dot y"></i><i class="dot g"></i></div>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 263 KiB

After

Width:  |  Height:  |  Size: 268 KiB

View File

@ -1,62 +0,0 @@
# 安全、Harness 與 Docker 檢查
日期2026-09-05。檢查第一方 Rust workspace、前端、控制腳本及容器設定保留工作目錄原有修改。這是程式與依賴檢查不是完整滲透測試reference 第三方專案未逐行稽核。
## 已修正的安全問題
| 風險 | 問題與修正 | 主要位置 |
| --- | --- | --- |
| 高 | 桌面原本持有 supervisor 主 token改為依 home key 衍生的 HMAC token管理路由驗證容器歸屬 | crates/supervisor/src/docker.rs、main.rs |
| 高 | 可提供任意 home 路徑及透過 symlink 越界;限定 DATA_DIR/homes/key附件改用 cap-std 目錄能力讀寫 | supervisor/docker.rs、api/attachments.rs |
| 高 | 已儲存密碼可能填入不相符網站;現在強制指定 hostname 與 HTTPS不自動送出DOM 觀察遮蔽密碼類欄位 | api/tools.rs、control/cdp.py |
| 高 | 桌面提供的 JavaScript 可能在 API 同源執行noVNC 靜態程式改由 API 可信映像提供 | api/screen_proxy.rs、image/api/Dockerfile |
| 中 | 固定登入 cookie 無法個別撤銷;改為隨機 session、伺服器期限及登出撤銷加入 OriginFetch Metadata 與本機 Host 檢查 | api/auth.rs |
| 中 | MCP 子程序繼承服務秘密;改為環境白名單及明確設定 | api/mcp.rs |
| 中 | 工具輸出可能進入記錄;移除輸出摘錄;附件隨機儲存名稱避免覆寫 | api/runs.rs、attachments.rs |
Session 目前放在單一 API 記憶體,重啟失效;不適用多副本共用登入。已建立的 WebSocket 不會因 cookie 撤銷立即關閉。MCP 仍是同 UID 的可信子程序,環境白名單不等於作業系統沙箱。
## Harness 與電腦控制
- 修正 observe 對 POST-only 端點誤用 GET消除正常路徑每次失敗再 fallback。
- 移除有副作用操作失敗後的自動重播;執行前落盤 toolsStarted完成批次保存上下文 checkpoint。worker 在不確定操作是否執行的狀態中斷,會標記失敗而非重做。
- 模型暫時失敗採有限次重試與退避各工具及程序有期限與輸出上限。Checkpoint 移除截圖並限制 1 MiB超限保留不確定狀態以避免盲目重播。這不是 exactly-once 保證。
- 使用 websocket-client 處理 CDP framing、控制訊框與 timeout限制 Chromium 除錯介面為 loopback移除 wildcard Origin。
- DOM snapshot 使用每次唯一 selector降低舊觀察誤點新元素風險點擊前檢查可見、啟用、遮擋動作後等待有限畫面更新。
- 指標/視窗觀察並行;終端剪貼簿使用正確快捷鍵,確認 X11 文字一致後再貼上。
- 排程以交易與 SKIP LOCKED 避免多 worker 重複派發入列與下次時間一併提交。Unix 星期轉換已有回歸測試。DOM 與 DOW 同時受限的 cron 明確拒絕,避免不同 cron 引擎的 OR/AND 語意差異。
這些改動減少多餘往返與重播風險,尚未進行真實桌面延遲 benchmark沒有速度倍數保證。DOM 優先、需要時使用桌面操作仍是本專案適合的路徑;沒有為了換框架重寫整個控制層。
## Docker 改善與升級
已加入 cargo/npm 建置 cache、cargo --locked、npm ci、直接 MCP 套件版本固定、API 非 root、capabilities 限縮、健康檢查、restart/init、程序與日誌上限。資料庫控制網路獨立預設只在 127.0.0.1:3101 開放 APIPostgres 不對主機映射。開發資料庫使用 make postgres 的額外 Compose 設定。
新安裝執行 make env產生四組獨立秘密與隨機資料庫密碼檔案權限 0600。既有 .env 不會被覆寫。
既有安裝更新時:
1. 備份資料庫、data 與 .env。此次僅建置映像沒有重啟你的正式服務、旋轉秘密或刪除既有桌面。
2. 保留原本 vault key。若之前未設定 LAZYBOY_VAULT_KEY先把原本 LAZYBOY_APP_TOKEN 的值保存為 LAZYBOY_VAULT_KEY才能旋轉 app token否則舊密碼可能無法解密。
3. 舊桌面曾取得 supervisor 主 token更新時應更換 LAZYBOY_SUPERVISOR_TOKEN 並重建所有舊桌面容器(保留 home 資料)。新版 controlVersion 會使舊容器在重新 provision 時重建。
4. API 改為 UID 1000。檢查既有 API 資料與快取目錄是否可由 UID 1000 存取;只調整確定需要的目錄,勿遞迴改動所有使用者 home。HOST_DATA_DIR 必須對應正確的主機資料目錄。
5. 既有 Postgres volume 不會因改 .env 自動換密碼密碼輪替須同時更新資料庫角色與連線設定。API 重啟後重新登入。
Supervisor 仍掌握 Docker socket可控制 Docker 主機cap_drop 不能消除此權限。較強隔離方案是專用 Docker daemonVM。桌面 Chromium 既有 --no-sandbox 與可信 MCP 的執行邊界仍需納入威脅模型。基底映像尚未固定 digest未做完整 OS image CVE 掃描;直接套件固定版本不代表所有下載資產都可完全重現。
## 驗證與剩餘告警
- Rust workspace114 個測試通過,含隔離 PostgreSQL 測試;前端 Node 5 個、Python 3 個測試通過。
- 前端 typecheck、production build、程式碼 diff 空白檢查(不含 README 的 Markdown 換行空白、Compose config 驗證通過;三個 Docker 映像實際建置成功。
- 隔離 API smoke test未登入拒絕、跨站登入拒絕、合法登入可存取 API、可信 noVNC asset 可用、登出後舊 cookie 被拒絕;均使用最後建置的映像驗證。
- npm audit0。cargo-auditRUSTSEC-2023-0071rsa 0.9.10,無修補版本)仍在 lockfile但目前啟用依賴樹 cargo tree -i rsa 無結果;不能把未使用的 lockfile 告警說成已移除。
- paste 1.0.15 有停止維護告警 RUSTSEC-2024-0436由 fastembed影像相關上游引入仍需追蹤替代版本。
- 未完成瀏覽器視覺驗收、真實 VNC 剪貼簿端到端、故障注入負載測試。Lottie eval 提醒仍存在。
## 方法參考
- [Docker 安全與 daemon 信任邊界](https://docs.docker.com/engine/security/)、[Docker socket 保護](https://docs.docker.com/engine/security/protect-access/)、[建置快取](https://docs.docker.com/build/cache/optimize/)
- [Playwright actionability](https://playwright.dev/docs/actionability):參考動作前狀態檢查原則,並未導入 Playwright runtime。
- [長時間 agent harness](https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents):持久化進度與恢復的設計參考。
- [CDP 協定](https://chromedevtools.github.io/devtools-protocol/)、[websocket-client 用法](https://websocket-client.readthedocs.io/en/latest/examples.html)
- [RSA advisory](https://rustsec.org/advisories/RUSTSEC-2023-0071.html)、[paste advisory](https://rustsec.org/advisories/RUSTSEC-2024-0436.html)

14847
docs/workflow.html Normal file

File diff suppressed because one or more lines are too long

90
docs/workflow.json Normal file
View File

@ -0,0 +1,90 @@
{
"schema_version": 2,
"diagram_type": "workflow",
"meta": {
"title": "LazyBoy從一句話到完成任務",
"quality_profile": "showcase",
"views": [
{
"id": "task-flow",
"label": "任務主流程",
"focus": ["workspace", "api", "supervisor", "desktop", "result"],
"note": "沿著主路徑查看指令如何成為可觀察、可接管的桌面操作。"
},
{
"id": "memory-schedule",
"label": "記憶與排程",
"focus": ["api", "memory", "schedule"],
"note": "長期記憶與排程會在需要時補充情境並重新啟動任務。"
},
{
"id": "safety",
"label": "隔離與憑證",
"focus": ["vault", "supervisor", "desktop"],
"note": "登入資料經加密保存,桌面工作負載在獨立容器中執行。"
}
]
},
"lanes": [
{ "id": "flow", "label": "任務主流程" },
{ "id": "automation", "label": "持續自動化" },
{ "id": "data", "label": "資料與安全", "variant": "exception" }
],
"phases": [
{ "id": "intent", "label": "說明目標", "fromCol": 0, "toCol": 1 },
{ "id": "orchestrate", "label": "理解與編排", "fromCol": 2, "toCol": 3, "variant": "emphasis" },
{ "id": "operate", "label": "執行與回傳", "fromCol": 4, "toCol": 5, "variant": "dashed" }
],
"groups": [
{ "id": "agent_core", "label": "Rust Agent Runtime", "lane": "flow", "fromCol": 1, "toCol": 2, "variant": "emphasis" },
{ "id": "sandbox_boundary", "label": "Docker 隔離邊界", "lane": "flow", "fromCol": 2, "toCol": 3, "variant": "security" },
{ "id": "durable_context", "label": "排程、記憶與登入資料", "lane": "data", "fromCol": 1, "toCol": 3, "variant": "dashed" }
],
"mainPath": ["workspace", "api", "supervisor", "desktop", "result"],
"semanticChecks": {
"allowedRoots": ["workspace", "schedule", "vault"],
"allowedTerminals": ["result", "memory"],
"requiredPaths": [
{ "from": "workspace", "to": "result" },
{ "from": "schedule", "to": "desktop" },
{ "from": "vault", "to": "desktop" }
]
},
"nodes": [
{ "id": "workspace", "lane": "flow", "col": 0, "type": "frontend", "label": "React 工作空間", "sublabel": "對話・電腦・記憶", "tag": "zh-TW / EN", "width": 150 },
{ "id": "api", "lane": "flow", "col": 1, "type": "backend", "label": "API + Agent", "sublabel": "模型、工具與技能", "tag": "Rust / Axum", "width": 146 },
{ "id": "supervisor", "lane": "flow", "col": 2, "type": "security", "label": "Supervisor", "sublabel": "資源限制與生命週期", "width": 146 },
{ "id": "desktop", "lane": "flow", "col": 3, "type": "cloud", "label": "隔離電腦", "sublabel": "瀏覽器、檔案與輸入", "tag": "noVNC", "width": 146 },
{ "id": "result", "lane": "flow", "col": 4, "type": "frontend", "label": "即時結果", "sublabel": "畫面、訊息與狀態", "width": 142 },
{ "id": "schedule", "lane": "automation", "col": 1, "type": "messagebus", "label": "排程", "sublabel": "到點自動執行", "width": 130 },
{ "id": "memory", "lane": "data", "col": 1, "type": "database", "label": "記憶與事件", "sublabel": "PostgreSQL + pgvector", "width": 154 },
{ "id": "vault", "lane": "data", "col": 3, "type": "security", "label": "加密憑證庫", "sublabel": "網站登入資料", "width": 142 }
],
"edges": [
{ "id": "workspace-api", "from": "workspace", "to": "api", "label": "HTTP / WebSocket", "variant": "emphasis", "role": "main" },
{ "id": "api-supervisor", "from": "api", "to": "supervisor", "label": "受控工具呼叫", "variant": "security", "role": "main" },
{ "id": "supervisor-desktop", "from": "supervisor", "to": "desktop", "label": "啟動・暫停・恢復", "variant": "security", "role": "main" },
{ "id": "desktop-result", "from": "desktop", "to": "result", "label": "螢幕與事件", "variant": "emphasis", "role": "return" },
{ "id": "schedule-api", "from": "schedule", "to": "api", "label": "Cron 觸發", "variant": "dashed", "role": "async" },
{ "id": "api-memory", "from": "api", "to": "memory", "label": "檢索與寫入", "variant": "dashed", "role": "branch" },
{ "id": "vault-desktop", "from": "vault", "to": "desktop", "label": "僅匹配 HTTPS 網域", "variant": "security", "role": "branch" }
],
"cards": [
{
"dot": "emerald",
"title": "看得見,也接得回來",
"items": [
"即時觀看 Agent 桌面與目前步驟",
"需要時由使用者接管,再交還給 Agent"
]
},
{
"dot": "violet",
"title": "從一次任務到持續自動化",
"items": [
"示範操作可整理成可重用技能",
"排程、長期記憶與 MCP 延伸工作能力"
]
}
]
}

@ -1 +0,0 @@
Subproject commit 0f5c4cefd59cdbe440deb7e05fd3f503164a6068