feat/schedule #3

Merged
daniel.w merged 3 commits from feat/schedule into main 2026-09-05 15:19:29 +00:00
74 changed files with 7816 additions and 916 deletions
Showing only changes of commit 2bd00a4716 - Show all commits

View File

@ -4,3 +4,13 @@ data
reference
**/*.md
apps/web/node_modules
.env
.env.*
!.env.example
**/*.pem
**/*.key
**/node_modules
apps/web/dist
**/__pycache__
.DS_Store

View File

@ -4,10 +4,14 @@ OPENAI_API_KEY=
# Generate independent values with: openssl rand -hex 32
LAZYBOY_APP_TOKEN=
SANDBOX_SUPERVISOR_TOKEN=
# Keep this key stable when rotating the app login token.
LAZYBOY_VAULT_KEY=
POSTGRES_PASSWORD=lazyboy
LAZYBOY_BIND_IP=127.0.0.1
SANDBOX_PROVIDER=docker
DATABASE_URL=postgres://lazyboy:lazyboy@127.0.0.1:5434/lazyboy
DATA_DIR=./data
API_BIND=0.0.0.0:3101
API_BIND=127.0.0.1:3101
SANDBOX_SUPERVISOR_URL=http://127.0.0.1:7091
LAZYBOY_SECURE_COOKIE=false
LAZYBOY_COMPUTER_MEMORY_MB=2048

3
.gitignore vendored
View File

@ -9,3 +9,6 @@ node_modules
dist
.DS_Store
.gstack/
__pycache__/
*.pyc

258
Cargo.lock generated
View File

@ -8,6 +8,41 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array",
]
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures 0.2.17",
]
[[package]]
name = "aes-gcm"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [
"aead",
"aes",
"cipher",
"ctr",
"ghash",
"subtle",
]
[[package]]
name = "ahash"
version = "0.8.12"
@ -55,6 +90,12 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "ambient-authority"
version = "0.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b"
[[package]]
name = "android_system_properties"
version = "0.1.6"
@ -435,6 +476,36 @@ version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cap-primitives"
version = "3.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e0bf07d379916947be6c4a07f43684153d710a2896c31f9e97781362895596c"
dependencies = [
"ambient-authority",
"fs-set-times",
"io-extras",
"io-lifetimes",
"ipnet",
"maybe-owned",
"rustix",
"rustix-linux-procfs",
"windows-sys 0.52.0",
"winx",
]
[[package]]
name = "cap-std"
version = "3.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a59e59fa26472d29680ece6a9f8ee8b0551a719a33df2f5240bde065ecbddfd7"
dependencies = [
"cap-primitives",
"io-extras",
"io-lifetimes",
"rustix",
]
[[package]]
name = "castaway"
version = "0.2.4"
@ -491,6 +562,26 @@ dependencies = [
"windows-link",
]
[[package]]
name = "chrono-tz"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
dependencies = [
"chrono",
"phf",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "cmake"
version = "0.1.58"
@ -655,6 +746,17 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "cron"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5877d3fbf742507b66bc2a1945106bd30dd8504019d596901ddd012a4dd01740"
dependencies = [
"chrono",
"once_cell",
"winnow 0.6.26",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.7"
@ -702,9 +804,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"typenum",
]
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]]
name = "darling"
version = "0.20.11"
@ -1141,6 +1253,17 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs-set-times"
version = "0.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a"
dependencies = [
"io-lifetimes",
"rustix",
"windows-sys 0.52.0",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
@ -1305,6 +1428,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "ghash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [
"opaque-debug",
"polyval",
]
[[package]]
name = "gif"
version = "0.14.2"
@ -1843,6 +1976,15 @@ dependencies = [
"web-time",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "interpolate_name"
version = "0.2.4"
@ -1854,6 +1996,22 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "io-extras"
version = "0.18.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2285ddfe3054097ef4b2fe909ef8c3bcd1ea52a8f0d274416caebeef39f04a65"
dependencies = [
"io-lifetimes",
"windows-sys 0.52.0",
]
[[package]]
name = "io-lifetimes"
version = "2.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983"
[[package]]
name = "ipnet"
version = "2.12.1"
@ -2011,10 +2169,14 @@ dependencies = [
name = "lazyboy-api"
version = "0.1.0"
dependencies = [
"aes-gcm",
"async-trait",
"axum",
"base64 0.22.1",
"cap-std",
"chrono",
"chrono-tz",
"cron",
"dotenvy",
"fastembed",
"futures-util",
@ -2026,6 +2188,7 @@ dependencies = [
"lazyboy-control",
"lazyboy-harness",
"lazyboy-sandbox",
"rand 0.8.8",
"reqwest 0.12.28",
"rig-core",
"rmcp",
@ -2089,6 +2252,7 @@ name = "lazyboy-harness"
version = "0.1.0"
dependencies = [
"lazyboy-contracts",
"reqwest 0.12.28",
"rig-core",
"serde",
"thiserror",
@ -2120,11 +2284,14 @@ dependencies = [
"base64 0.22.1",
"bollard",
"futures-util",
"hex",
"hmac",
"lazyboy-contracts",
"lazyboy-control",
"reqwest 0.12.28",
"serde",
"serde_json",
"sha2",
"thiserror",
"tokio",
"tracing",
@ -2277,6 +2444,12 @@ dependencies = [
"rawpointer",
]
[[package]]
name = "maybe-owned"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4"
[[package]]
name = "maybe-rayon"
version = "0.1.1"
@ -2602,6 +2775,12 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "openssl"
version = "0.10.81"
@ -2755,6 +2934,24 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "phf"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
dependencies = [
"phf_shared",
]
[[package]]
name = "phf_shared"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
dependencies = [
"siphasher",
]
[[package]]
name = "pin-project"
version = "1.1.13"
@ -2827,6 +3024,18 @@ dependencies = [
"miniz_oxide 0.8.9",
]
[[package]]
name = "polyval"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "portable-atomic"
version = "1.15.0"
@ -3536,6 +3745,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustix-linux-procfs"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056"
dependencies = [
"once_cell",
"rustix",
]
[[package]]
name = "rustls"
version = "0.23.43"
@ -3930,6 +4149,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "siphasher"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
[[package]]
name = "slab"
version = "0.4.12"
@ -4568,7 +4793,7 @@ dependencies = [
"indexmap 2.14.1",
"toml_datetime",
"toml_parser",
"winnow",
"winnow 1.0.4",
]
[[package]]
@ -4577,7 +4802,7 @@ version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
dependencies = [
"winnow",
"winnow 1.0.4",
]
[[package]]
@ -4823,6 +5048,16 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"subtle",
]
[[package]]
name = "untrusted"
version = "0.9.0"
@ -5420,6 +5655,15 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winnow"
version = "0.6.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e90edd2ac1aa278a5c4599b1d89cf03074b610800f866d4026dc199d7929a28"
dependencies = [
"memchr",
]
[[package]]
name = "winnow"
version = "1.0.4"
@ -5429,6 +5673,16 @@ dependencies = [
"memchr",
]
[[package]]
name = "winx"
version = "0.36.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d"
dependencies = [
"bitflags 2.13.1",
"windows-sys 0.52.0",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"

View File

@ -16,6 +16,11 @@ version = "0.1.0"
license = "MIT"
publish = false
[profile.release]
lto = true
codegen-units = 1
strip = "symbols"
[workspace.dependencies]
lazyboy-contracts = { path = "crates/contracts" }
lazyboy-control = { path = "crates/control" }

View File

@ -53,26 +53,12 @@ help: ## Show this help
# --- Environment -----------------------------------------------------------
env: ## Create .env from example with fresh random tokens (no-op if .env exists)
@if [ -f .env ]; then \
echo ".env already exists — leaving it untouched (use 'make env-force' to regenerate tokens)."; \
else \
cp .env.example .env; \
APP=$$(openssl rand -hex 32); SUP=$$(openssl rand -hex 32); \
sed "s|^LAZYBOY_APP_TOKEN=.*|LAZYBOY_APP_TOKEN=$$APP|" .env > .env.tmp; \
sed "s|^SANDBOX_SUPERVISOR_TOKEN=.*|SANDBOX_SUPERVISOR_TOKEN=$$SUP|" .env.tmp > .env.tmp2; \
mv .env.tmp2 .env; rm -f .env.tmp; \
echo "created .env with generated LAZYBOY_APP_TOKEN / SANDBOX_SUPERVISOR_TOKEN (64 hex chars)."; \
echo "next: set XAI_API_KEY in .env, then run 'make up'."; \
fi
env: ## Create .env with independent random secrets (preserve existing .env)
@python3 scripts/init-env.py
env-force: ## Regenerate .env (wipes XAI_API_KEY — you will set it again)
@cp .env.example .env; \
APP=$$(openssl rand -hex 32); SUP=$$(openssl rand -hex 32); \
sed "s|^LAZYBOY_APP_TOKEN=.*|LAZYBOY_APP_TOKEN=$$APP|" .env > .env.tmp; \
sed "s|^SANDBOX_SUPERVISOR_TOKEN=.*|SANDBOX_SUPERVISOR_TOKEN=$$SUP|" .env.tmp > .env.tmp2; \
mv .env.tmp2 .env; rm -f .env.tmp; \
echo "regenerated .env tokens (XAI_API_KEY reset — set it again)."
env-force: ## Refuse destructive key regeneration; existing vault keys must be preserved
@echo "Refusing to overwrite .env: this can orphan saved passwords. See docs/security-and-harness-review.md for safe rotation."
@exit 1
# --- Full Docker stack -----------------------------------------------------
@ -104,7 +90,7 @@ computer: ## Build the Debian desktop image used to spawn bot computers
docker build -f image/computer/Dockerfile -t $(COMPUTER_IMAGE) .
postgres: ## Start only postgres and wait until it is ready
$(COMPOSE) up -d postgres
$(COMPOSE) -f docker-compose.yml -f docker-compose.dev.yml up -d postgres
@echo "waiting for postgres (127.0.0.1:5434) to be ready..."
@for i in $$(seq 1 40); do if $(COMPOSE) exec -T postgres pg_isready -U lazyboy >/dev/null 2>&1; then echo "postgres ready"; break; fi; sleep 0.5; done

562
README.md
View File

@ -1,72 +1,528 @@
# LazyBoy
Create a bot in the browser, give it a Team or Private computer, and let it drive a Linux desktop.
![給 Agent 一台真的電腦](./docs/readme-hero.png)
## Run (full Docker, recommended)
在瀏覽器裡開 Agent。每個都有自己的 Linux 桌面:開網頁、敲指令、學你示範過的流程。金鑰、模型、檔案都留在你這台機器上。
Everything (postgres, desktop image build, supervisor, API + web UI) runs in
Docker Compose, driven by the Makefile:
---
## 需要的硬體
LazyBoy 是本機系統,不是雲端沙盒。機器要跑四件事:**Postgres**、**API含網頁**、**supervisor**、以及每個 Agent 的 **Debian 桌面容器**
| 項目 | 最低能跑 | 建議(一台桌面常開) |
| --- | --- | --- |
| 作業系統 | 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聊天要模型金鑰能連外 |
預設一個 Team 電腦容器可同時掛最多 **8** 個螢幕(`TEAM_SCREEN_LIMIT`)。再開私人電腦就是再一個容器、再 2 GB。分頁開著時心跳會讓桌面保持熱機關掉分頁約 10 分鐘後凍結(記憶體還在),約 6 小時後才真正停機。
---
## 怎麼快速啟動
要有 Docker含 Compose 外掛)和 Make。第一次會編 `lazyboy/computer:local`Debian + XFCE + Chromium會比較久。
```bash
make env # creates .env with two generated 64-hex tokens (no-op if .env exists)
# then set XAI_API_KEY in .env
make up # builds all images and starts the stack
make env
# 在 .env 填 XAI_API_KEY
# (也可以之後在「本機工作區 → 設定」接 xAI / OpenCode Go / OpenAI 相容端點)
make up
make health
```
Open `http://127.0.0.1:3101` and sign in with `LAZYBOY_APP_TOKEN`.
打開 [http://127.0.0.1:3101](http://127.0.0.1:3101),用 `.env` 裡的 `LAZYBOY_APP_TOKEN` 登入,建一個 Agent傳一句話。
Useful targets (see `make help`):
| target | what it does |
| --- | --- |
| `make up` / `make down` / `make purge` | start / stop (keep data) / stop + delete postgres volume |
| `make logs`, `make ps`, `make health` | observe the stack |
| `make computer` | build only the heavy desktop image `lazyboy/computer:local` |
| `make postgres`, `make postgres-down` | start/stop just the database on `127.0.0.1:5434` |
| `make build`, `make fmt`, `make clippy`, `make test` | cargo workspace tasks |
| `make web` | build `apps/web` with npm (needs node) |
| `make dev`, `make dev-supervisor`, `make dev-api` | local dev: postgres in Docker, Rust services on the host in two terminals |
Without make, the full-stack equivalent is:
```bash
cp .env.example .env # fill in the two tokens + XAI_API_KEY
docker compose up -d --build
```
## Run (local)
Postgres is on `127.0.0.1:5434` so it does not collide with other stacks.
沒有 Make
```bash
cp .env.example .env
# Set XAI_API_KEY, then generate two different secrets:
# openssl rand -hex 32
# openssl rand -hex 32
# Put them in LAZYBOY_APP_TOKEN and SANDBOX_SUPERVISOR_TOKEN.
docker compose up -d postgres
./scripts/build-computer-image.sh
SANDBOX_SUPERVISOR_TOKEN=<same-strong-supervisor-token> \
DATA_DIR=/root/LazyBoy/data cargo run -p lazyboy-supervisor
DATABASE_URL=postgres://lazyboy:lazyboy@127.0.0.1:5434/lazyboy \
SANDBOX_PROVIDER=docker \
SANDBOX_SUPERVISOR_URL=http://127.0.0.1:7091 \
SANDBOX_SUPERVISOR_TOKEN=<same-strong-supervisor-token> \
LAZYBOY_APP_TOKEN=<strong-app-token> \
DATA_DIR=/root/LazyBoy/data \
LAZYBOY_WEB_DIR=apps/web \
API_BIND=0.0.0.0:3101 \
cargo run -p lazyboy-api
# openssl rand -hex 32 → LAZYBOY_APP_TOKEN
# openssl rand -hex 32 → SANDBOX_SUPERVISOR_TOKEN
# openssl rand -hex 32 → LAZYBOY_VAULT_KEY
docker compose up -d --build
```
Open `http://<host>:3101` and sign in with `LAZYBOY_APP_TOKEN`. The computer is a real Debian container: fluxbox toolbar, Chromium with tabs and URL bar, and xterm. The display is proxied through the authenticated API; the supervisor is internal-only in Docker Compose and must not be published to the LAN.
前端熱重載:`apps/web` 裡 `npm install && npm run dev`,開 [http://127.0.0.1:5173](http://127.0.0.1:5173)。Vite 把 `/api``/view` 轉到 3101。
For LAN use, put LazyBoy behind HTTPS whenever possible. A shared token sent over plain HTTP can be observed by other devices on an untrusted network. Set `LAZYBOY_SECURE_COOKIE=true` when HTTPS terminates at LazyBoy or a trusted reverse proxy. The API refuses a non-loopback bind unless `LAZYBOY_APP_TOKEN` is at least 32 characters; the supervisor likewise rejects missing, short, or default tokens.
本機 Rust 開發Postgres 仍在 Docker
For frontend development, run `npm install && npm run dev` in `apps/web`, then open
`http://127.0.0.1:5173`. Vite proxies API and computer-screen traffic to the Rust API on port 3101.
```bash
make dev # 準備 .env、Postgres、桌面映像
make dev-supervisor # 終端 1
make dev-api # 終端 2
```
The standalone supervisor listens on `127.0.0.1:7091` by default. Docker Compose reaches it internally as `supervisor:7091`; there is intentionally no host port `7092`.
| 指令 | 做什麼 |
| --- | --- |
| `make up` / `make down` / `make purge` | 啟動/停止(留資料)/連 Postgres 一起清 |
| `make logs` `make ps` `make health` | 看狀態 |
| `make computer` | 只重建桌面映像 |
| `make postgres` | 只開資料庫 `127.0.0.1:5434` |
Model providers: set the workspace default in 本機工作區 → 設定. v1 supports xAI (`XAI_API_KEY`), OpenCode Go (`OPENCODE_GO_API_KEY` or a key saved in settings), and a self-hosted OpenAI-compatible endpoint (base URL + optional key). Keys saved in the UI are stored on the local workspace and take precedence over env vars.
---
## 跟 Grok Bot 比,好在哪
[Grok Bot](https://grok.com) 是 xAI 的雲端隊友對話、電腦、排程都在他們的機器上。LazyBoy 走同一類產品(本機開源實作對齊 Rakazo 那條線),差在**誰擁有執行環境**。
| | Grok Bot | LazyBoy |
| --- | --- | --- |
| 跑在哪 | xAI 雲端 | 你的 Docker |
| 模型 | Grok | 你帶金鑰xAI、OpenCode Go、或任何 OpenAI 相容端點 |
| 電腦 | 廠商提供的桌面 | 你映像裡的 DebianXFCEChromium家目錄在 `data/homes/` |
| 資料 | 在服務端 | 對話、記憶、保險箱、瀏覽器設定檔都在本機 Postgres + 磁碟 |
| 登入帳號 | 跟雲端工作流程走 | 每個 Agent 自己的保險箱AES-256-GCM模型只看到帳號 id |
| 客製 | 封閉 | 開源。工具、MCP、技能 JSON 可改可搬 |
| 費用形態 | 訂閱/用量 | 電費與硬體;模型金鑰另計 |
| 多 Agent 同桌 | 產品內建 | Team 電腦一個容器最多 8 螢幕;私人電腦一人一容器 |
| 教會它 | 看產品當下提供什麼 | 你示範一次CDP 記語意事件,模型整理成技能 |
適合 LazyBoy 的情況:資料不能出門、要自己選模型、要看它點了哪個控制項、或想把「看完訓練影片交測驗」這種流程做成可匯出的技能。
Grok Bot 適合的情況:不想養 Docker、要官方託管、機器不夠力。
---
## 每個功能簡介
**對話與 Session**
每個 Agent 多則對話。訊息進 Postgres同一則用 `clientNonce` 去重。問候、閒聊走純文字,**不會**為了「看一下螢幕」去開 Docker。
**Team / 私人電腦**
Team工作區共用一個家目錄每個 bot 有自己的 `DISPLAY``:1`、`:2`…)和瀏覽器設定檔。私人:這個 bot 獨佔一個容器。
**即時畫面**
右側預覽是 noVNC。瀏覽器連 `/view/{botId}/vnc.html`API 用已登入的 cookie 轉到容器裡的 websockify。模型看到的截圖另走 `computer_observe`,上面會蓋黃字編號;你盯著的 VNC **沒有**那些編號。
**接管 / 釋放**
人按接管就拿到控制租約(預設 15 分鐘,心跳續約)。進行中的 run 會進 `waiting_takeover`放開後從目前畫面接著做。模型遇到登入牆、2FA、驗證碼會呼叫 `request_takeover`
**觀察與操作**
- Chromium 網頁:`browser`CDP點 element id
- 原生視窗對話框、檔案管理員、XFCE`computer_act`AT-SPI id不行再退 xdotool 座標)
- 檔案與指令:`list_files` / `read_file` / `write_file` / `shell`
點到 `[disabled]` 的控制項會最多等 45 秒等它亮。模型用文字回「我在等」會結束整段 run所以等待必須是 `wait` 工具。
**教技能**
你示範,容器內 CDP 錄「點了哪個控制項、填了什麼、去了哪一頁」,再抽幾個關鍵畫面。停下來後模型整理成意圖級 playbook之後用普通工具在**當下畫面**找控制項,不是重播座標。密碼欄不錄。技能可匯出 JSON。
**記憶**
`pgvector` + MiniLM384 維)。只有你叫它記住、或它呼叫 `remember` 的內容會進長期記憶。密碼與 token 會被拒。清除對話不會清記憶。
**保險箱**
每個 bot 自己的站名/帳號/密碼。模型用 `list_accounts` 只看到站與使用者名稱,`use_saved_login` 在 Chromium 登入表單填入。金鑰用 `LAZYBOY_VAULT_KEY` 加密。
**排程**
五欄 cron預設 `Asia/Taipei`。對話裡講「以後每天九點」或側欄新增。tick 迴圈把到期列變成普通 queued run。
**MCP**
工作區級外掛。市集或自訂 stdioHTTPSSE。stdio 跑在 API 容器裡。
**群組**
多個 Agent 同一個 thread。Team 電腦上各用各的螢幕。同一 bot 同時只跑一個 run後面的訊息排隊。
**附件**
圖片給當則模型看,不進歷史二進位。要讓電腦開原檔會放 `inbox/`,兩小時後刪。
**頭像與狀態**
Blobatar 色塊+眼睛。啟動、喚醒、連線、換手時,預覽左上角與思考列會顯示對應文字。分頁開著時心跳保住容器,換手不拆 VNC。
---
## 系統怎麼疊起來
```text
瀏覽器
├─ React UIapps/web
└─ /view/{bot} → API 畫面代理cookie→ 容器 websockify → x11vnc → Xvfb
lazyboy-api
├─ Postgres + pgvector
├─ runs worker最多 16 條並行,每個 bot 同時一個)
├─ computer idle_loop熱機凍結停放
├─ schedules tick
├─ MCP hub
└─ harness選模型、接金鑰
▼ HTTPSANDBOX_SUPERVISOR_TOKEN
lazyboy-supervisor :7091
│ Docker socket
lazyboy/computer 容器
├─ start.sh → Xvfb :1 + XFCE + x11vnc + websockify
├─ lazyboy-screen 額外 Team 螢幕 :2…:8
└─ lazyboy-controld :7070 容器內觀察/動作
data/homes/<homeKey> bind 到 /home/lazyboy
```
Compose 裡 supervisor **不**對主機開埠。API 在容器網路連 `supervisor:7091`
---
## 時序:你送一則訊息
```mermaid
sequenceDiagram
participant U as 瀏覽器
participant A as API
participant DB as Postgres
participant W as run worker
participant H as harness
participant M as 模型
participant S as supervisor
participant C as 桌面容器
U->>A: POST /api/sessions/{id}/messages
A->>DB: 寫 user 訊息clientNonce 去重)
A->>DB: INSERT run status=queued
A-->>U: 202 + 輪詢 messages / computer status
loop 每 200ms
W->>DB: 租下一筆 queued同 bot 沒有別人在跑)
end
W->>H: resolve_backendbot → 工作區 → 環境變數)
H-->>W: provider + model + key
W->>M: 系統提示 + 歷史 + 記憶 + 工具定義
alt 純聊天(問候、閒聊、知識問答)
M-->>W: 純文字
W->>DB: 寫 assistant 訊息run=completed
else 要用電腦
M-->>W: tool_callbrowser / computer_act / shell…
W->>DB: busy_step電腦啟動中喚醒中實際動作
W->>A: boot() 若尚未 running
A->>S: provision 或 unpause
S->>C: 等 /tmp/lazyboy/ready
W->>C: 執行工具
C-->>W: 截圖DOMstdout
W->>M: tool result畫面變了才帶圖
M-->>W: 下一動或結束文字
end
W->>DB: completed放開螢幕租約
U->>A: GET statusmessages2 秒一次)
```
同一 bot 已有 `leased``running``waiting_takeover` 時,新訊息會 `queuedBehindActive`。人正在接管時,後面的話只排隊,思考轉圈不會假裝它還在動。
---
## 時序:電腦從開機到你看到畫面
```mermaid
sequenceDiagram
participant U as 瀏覽器
participant A as API
participant S as supervisor
participant D as Docker
participant C as 容器 PID 1
U->>A: POST /api/computer/{bot}/boot
alt 已是 running 且容器還在
A-->>U: state=running
else 休眠中docker pause
A->>S: POST /computers/{id}/unpause
S->>D: unpause
S->>C: 確認 /tmp/lazyboy/ready約一秒內
A-->>U: state=running
else 已關或沒有容器
A->>DB: state=booting
A->>S: POST /computers
S->>D: 找到可重用的就 start否則 create
D->>C: start.sh
C->>C: Xvfb :1、XFCE、x11vnc、websockify、controld
C->>C: touch /tmp/lazyboy/ready
A->>S: ensure_screenTeam 再掛 :2…
A-->>U: state=running
end
U->>A: GET /api/computer/{bot}/screen
A-->>U: /view/{bot}/vnc.html
U->>A: WebSocket /view/{bot}/websockify
A->>C: 轉到該 slot 的 6080+N
Note over U,C: 分頁每 2 秒 heartbeat更新 computers.updated_at
```
閒置(`crates/api/src/computer.rs` `idle_loop`
1. 執行中、超過 10 分鐘沒人看、也沒有進行中的 run示範 → `docker pause`,狀態 `suspended`
2. 休眠超過 6 小時 → `docker stop`,狀態 `stopped`
3. 分頁還在就心跳,不會進 1
---
## Harness 流程
`crates/harness` 不管滑鼠,只負責**這次 run 要用哪一家模型**。真正的 agent 迴圈在 `crates/api/src/runs.rs`
```mermaid
flowchart TD
A[execute_run] --> B[讀 bot 與工作區設定]
B --> C[CredentialChain]
C --> C1[bot 自己存的 key]
C --> C2[工作區設定的 key]
C --> C3[環境變數 XAI_API_KEY 等]
C1 --> D[resolve_backend]
C2 --> D
C3 --> D
D --> E{provider}
E -->|xai| F[rig xAI CompletionModel]
E -->|opencode-go 且 gpt/grok/muse| G[OpenAI Responses API]
E -->|其他 OpenAI 相容| H[OpenAI Chat Completions]
F --> I[connect_model]
G --> I
H --> I
I --> J[帶工具定義進 complete_once]
J --> K{回傳}
K -->|純文字| L[聊天結束或 skill 檢查沒過就 nudge]
K -->|tool_calls| M[dispatch]
M --> N[結果寫回 history]
N --> J
```
金鑰優先順序:**bot → 工作區 → 環境變數**。API 跑在 Docker 時,迴圈位址 `127.0.0.1` 會被改成 `host.docker.internal`,才能打到你本機的相容端點。
`execute_run` 每一輪:
1. 續租約5 分鐘),否則 halt
2. 寫 `busy_step`思考中電腦啟動中browser click…
3. `complete_once`system + 記憶 + 技能目錄 + 歷史
4. 沒有 tool call問候就結束技能 run 若檢查沒過,把現在畫面塞回去再逼一次(最多數次)
5. 有 tool call需要沙盒才 `prepare_run_computer`boot解凍、拿螢幕執行租約、瀏覽器 profile lock
6. `dispatch` 跑工具,畫面沒變就不重複塞圖
7. 直到文字結束、halt停止接管、或達到回合上限聊天 4、一般 40、技能 80
問候路徑會把工具表清空,從源頭避免「哈囉」去 `ls` 家目錄。
---
## 控制電腦的原理
模型**從不**直接連 VNC。它只打 API 工具;工具經 sandbox HTTP 進 supervisor`docker exec` 或打容器內 `controld`
```mermaid
flowchart LR
subgraph 模型側
T1[browser]
T2[computer_observe / computer_act]
T3[shell / files]
end
subgraph 容器內
CDP[Chromium CDP]
ATSPI[AT-SPI]
XD[xdotool / Xvfb]
VNC[x11vnc]
end
T1 --> CDP
T2 --> ATSPI
T2 --> XD
T3 --> XD
U[你的瀏覽器] --> VNC
```
**三層找得到什麼、點得了什麼**
1. **CDP網頁**
Chromium 開著時,`browser` 拿 DOM可點的控制項編成 1…N截圖上蓋黃字。`click {element:N}` 會捲到視窗外的節點。這是訓練系統、信箱、後台的主路徑。`computer_act` 點在瀏覽器視窗上會被拒,避免用像素點網頁。
2. **AT-SPI原生 GUI**
沒有 DOM 時檔案選取、XFCE 對話框),`computer_observe` 走無障礙樹。編號是控制項,不是視窗外框。
3. **座標(最後)**
畫布、無樹的 widget 才用 `computer_act` 的 x,y。解析度契約是 **1280×800**
**編號從哪來**
`overlay_elements` 只畫在給模型的 JPEG 上。VNC 是乾淨桌面。每次 navigationsnapshot 會重編號,舊 id 作廢。
**誰可以動滑鼠**
每個 bot 一個 `computer_screens`slot、DISPLAY、執行租約 `execution_run_id`、控制租約 `control_holder`。run 要 GUI 時 `take_screen_execution` 把 fence +1較新的 fence 贏。人接管寫 `control_holder=user`worker 在回合邊界停,不跟你搶滑鼠。
**你看到的畫面**
`screen_proxy` 只接受已登入的 GETWebSocket。頁面本體是 `apps/web/vnc.html`。`view_only` 用 postMessage 切,不重掛 iframe所以換手時預覽不會黑掉。
**容器內 controld**
`lazyboy-controld``127.0.0.1:7070`,要 `LAZYBOY_CONTROL_TOKEN`。supervisor 的觀察/動作能打通就走它,否則退回 `import``xdotool` 指令。
**Team 多螢幕**
slot 0 = `:1` / VNC 5900 / 畫面 6080。slot N = `:N+1` / 5900+N / 6080+N。`lazyboy-screen ensure` 在同一個容器裡再長一組 Xvfb。瀏覽器設定檔預設 per-bot兩個 bot 搶同一份 shared profile 會拿到「profile locked」檔案與 shell 仍可用。
---
## 教技能怎麼做
```mermaid
sequenceDiagram
participant H as 你
participant A as API
participant C as 桌面
participant M as 模型
H->>A: POST /skills/start {goal}
A->>C: 開電腦、把控制權給人
A->>C: 啟動 CDP recorder
loop 約 1.5s
A->>C: 視窗標題 + 粗略畫面簽名
Note over A: 簽名沒變就不存,避免閒置把 60 幀用完
end
H->>C: 正常操作(密碼欄不錄)
H->>A: POST /skills/stop
A->>C: 停 recorder
A->>M: 目標 + 語意事件 + 最多 8 張關鍵畫面
M-->>A: playbook意圖、輸入、步驟、怎麼驗收
A-->>H: 草稿,可改名、試跑、匯出 JSON
```
之後 run 若 prompt 對得上技能名,會把完整 playbook 塞進當則,並清掉舊聊天以免模型複誦上次的「還在倒數」。執行仍用 `browser``computer_act`,在**現在**的畫面上找「Next」不是記像素。
---
## 排程怎麼進 run
```mermaid
flowchart LR
A[對話 create_schedule 或側欄新增] --> B[(schedules 表)]
B --> C[tick_loop]
C -->|next_run_at 到了| D[INSERT runs queued]
D --> E[同一個 worker_loop]
E --> F[普通 execute_runprompt 是 instructions]
```
Cron 五欄。時區寫在列上,預設台北。`run now` 只是立刻插一筆 run不改下一拍時間。
---
## 專案目錄(二次開發從這裡找)
Cargo workspace。契約在 `contracts`,畫面邏輯在 `control`HTTP 與 agent 迴圈在 `api`Docker 生命週期在 `supervisor`。前端是獨立的 Vite app由 API 把 `apps/web/dist`(或開發時的 `apps/web`)端出去。
```text
LazyBoy/
├── apps/web/ 瀏覽器 UIVite + React
│ ├── 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/
│ ├── contracts/ 跨 crate 的型別Bot、Run、ComputerState、動作 JSON
│ ├── harness/ 模型後端CredentialChain、resolve_backend、connect_model
│ ├── control/ 桌面契約:螢幕 slot、租約、CDP/AT-SPI/xdotool、overlay
│ │ 含 a11y.py / cdp.py容器裡被 exec 的腳本)
│ ├── sandbox/ API 打 supervisor 的 HTTP 客戶端fake 給測試
│ ├── supervisor/ Dockerprovision / pause / unpause / exec / observe / act
│ ├── controld/ 打進容器的小 HTTP127.0.0.1:7070
│ └── api/ 唯一對外程序路由、worker、idle、排程 tick、靜態網頁
│ └── src/
│ ├── main.rs 啟動、三條背景迴圈
│ ├── routes.rs 組 routerbot / computer HTTP 也在這
│ ├── runs.rs agent 迴圈租約、complete_once、nudge
│ ├── tools.rs tool_definitions + dispatch加工具從這裡
│ ├── computer.rs boot / 凍結 / 心跳 / 螢幕租約
│ ├── sessions.rs 對話 CRUD、送訊息、SSE
│ ├── skills.rs 示範錄製與蒸馏
│ ├── 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
├── 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 依賴
```
crate 依賴方向(不要倒過來 import
```text
api → harness, control, sandbox, contracts
sandbox → control, contracts
supervisor → control, contracts
controld → control, contracts
harness → contracts
control → contracts
```
### 想改什麼,開哪個檔
| 你要做的事 | 先開 |
| --- | --- |
| 加一個模型工具(例如 `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。說明是寫給模型看的。
2. 同一個檔的 `dispatch` 加 match arm`ToolOutcome { text, image, pause, blocks }`
3. 若會動到桌面:`runs.rs` 裡 `tool_needs_sandbox` / `tool_needs_gui` 把名字加進去,否則不會 boot、也拿不到螢幕租約。
4. 需要新的容器指令就放 `control`Rust 組 argvPython 做 CDP/AT-SPIsupervisor 的 `exec` 已經會把 `DISPLAY` 帶進去。
5. 前端若要顯示步驟文字,`runs.rs` 的 `describe_step` 加一列。
6. `SANDBOX_PROVIDER=fake cargo test -p lazyboy-api` 先過,再對真容器看。
### 本機二次開發迴圈
```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 熱更新。
- 改 `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`。
檢查:
```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 它。
---
## 安全(操作時要記得)
- Supervisor 只在 Compose 內網,不要對 LAN 開埠
- 畫面走已登入 APIVNC 密碼不進瀏覽器 URL
- 區網請走 HTTPS終端是 HTTPS 時設 `LAZYBOY_SECURE_COOKIE=true`
- API 綁非本機時 `LAZYBOY_APP_TOKEN` 至少 32 字supervisor 拒絕空白、過短、`dev-token`
- 保險箱用 `LAZYBOY_VAULT_KEY`;換登入 token 時這把 key 要留著
- 模型看不到密碼本文2FACAPTCHA 一定要人在**它的**畫面上處理

51
a.txt
View File

@ -1,51 +0,0 @@
我這邊體感滑,是因為多數工作走 ShellAPI瀏覽器 DOM很少靠截圖猜 XY。你 repo 裡 瀏覽器這條其實已經對了;卡的是 桌面原生 App。
你已經有的(別拆)
能力 在哪
Docker 真實 Linux 桌面 + Screenleasetakeover supervisor / sandbox / control
shell 工具 crates/api/src/tools.rs
browserCDP DOM snapshotclick by idselector control/cdp.rs + cdp.py工具描述已寫「網頁優先用 browser」
編號元素、apply_element_targets contracts/action.rs、control/actions.rs
畫面沒變就不重傳圖frame signature control/observe.rs
原生動作執行xdotool / wmctrl control/x11.rs、image/computer/Dockerfile
這已經比純「截圖 computer-use」產品完整一截。
現在實際主路徑(為什麼不順)
computer_observe → 截圖 +瀏覽器時CDP 元素
→(否則)只有 wmctrl 視窗級元素
computer_act → element id 先換成中心點 XY → xdotool 點像素
關鍵缺口:
P0沒有 AT-SPI無障礙樹
Dockerfile 有 xdotool、wmctrl沒有 at-spi2python3-pyatspi 之類。
原生「elements」在 window_list_command 只是 整窗標題,不是按鈕/輸入框。模型對 Thunar、對話框、面板仍要看圖或瞎點。
P0element 最終還是 XY
apply_element_targets 填的是中心座標,執行仍走 ComputerAction::Pointer → xdotool。
視窗一動、動畫、縮放就歪。agent-desktopCua 是 對 element 做 AXAT-SPI action不是點中心像素。
P1ComputerAction 契約只有像素/鍵
action.rs 沒有 ActByRef { id }、SetValue、A11yPress 這類語意動作。擴 CDPa11y 都會卡在契約層。
P1控的是容器桌面不是你 Mac
對「操控我電腦」若指本機 Finder系統設定現在這條 stack 接不到 Mac AX。那是另一條 provideragent-desktopCua不是再調 xdotool。
P2模型路由依賴 prompt
tools 已寫「Chromium 用 browser」但若模型仍狂 computer_observe座標體感就跟我差一截。缺硬性策略例如有 CDP page 時禁止對 browser 窗做 computer_act click
跟「我為什麼滑」的對照
路徑 我Grok Bot LazyBoy feat/test
Shell檔案 預設 有
瀏覽器 DOMelement ref 有 CDP方向對
桌面原生 另有 a11y委派截圖是備援 截圖 + xdotool 仍是主幹
本機 Mac 受限/核准 Shell 尚未做 Host provider
所以:你換好幾套「控制」若不順,多半都還停在 視覺/座標層;你 repo 差的是把 AT-SPI容器內 接到 observeact並讓 browsershell 硬優先。
建議下一步(只做這 3 件)
容器加 AT-SPIimage 裝 at-spiobserve 回傳 buttontextfield 樹(精簡 YAMLJSON + stable idact 對 id 做 DoActionset text失敗才 fallback XY。
擴 ComputerActionUiElementkind: a11y|dom|windowref 語意動作computer_act 有 selectora11y ref 時不要先轉座標。
路由硬規則Chromium 前台 → 強制 browser能 shell 完成 → 禁止截圖迴圈;並加「連續同點無畫面變化 → 強制換策略」(你已有一點 stale-click 提示,可再硬一點)。
本機 Mac 若也要滑:另開 HostComputerProvideragent-desktopCua別硬塞進現在的 X11 xdotool 路徑。
要的話我可以直接幫你開一張 P0 AT-SPI observe/act 的實作規格(改哪些檔、契約長怎樣),或直接在 feat/test 上開工。

View File

@ -10,6 +10,7 @@
"dependencies": {
"@blobatar/react": "^2.7.0",
"@fontsource/huninn": "^5.3.0",
"@novnc/novnc": "1.7.0",
"blobatar": "^2.7.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@ -836,6 +837,12 @@
"node": "^22.20 || ^24.12 || >=25"
}
},
"node_modules/@novnc/novnc": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@novnc/novnc/-/novnc-1.7.0.tgz",
"integrity": "sha512-ucEJOx4T2avIRCleodk7YobZj5O2Ga2AeLfQ69A/yjG9HHba2+PDgwSkN3FttrmG+70ZGx21sElNFouK13RzyA==",
"license": "MPL-2.0"
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",

View File

@ -11,6 +11,7 @@
"dependencies": {
"@blobatar/react": "^2.7.0",
"@fontsource/huninn": "^5.3.0",
"@novnc/novnc": "1.7.0",
"blobatar": "^2.7.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

@ -1,8 +1,7 @@
import { FormEvent, KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { BotIcon, Brain, ChevronDown, ChevronsRight, CircleHelp, ClipboardPaste, Computer, Download, Ellipsis, Info, LogOut, Megaphone, Paperclip, Pencil, Pin, Plug, Plus, RefreshCw, Settings, Smartphone, Sparkle, Square, Upload, Users, X } from "./animated-icons";
import UseAnimations from "react-useanimations";
import UseAnimations from "./use-animations";
import loading from "react-useanimations/lib/loading";
import loading2 from "react-useanimations/lib/loading2";
import arrowUp from "react-useanimations/lib/arrowUp";
import bookmark from "react-useanimations/lib/bookmark";
import copy from "react-useanimations/lib/copy";
@ -19,22 +18,32 @@ 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 { ScheduleEditor, ScheduleList, cronFromPreset, defaultCronPreset, presetFromCron, type CronPreset, type ScheduleItem } from "./schedule";
const blankComputer:ComputerStatus={botId:"",mode:"team",state:"stopped",controlHolder:"none",takeoverRequested:false,busyBotName:null,busySessionId:null,busyRunId:null,busyStep:null,waitingRunId:null,waitingSessionId:null,queuedRuns:0,display:null,profileMode:"per-bot",screenAvailable:false};
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";
type RightPart="computer"|"memory"|"settings"|"plugins"|"accounts";
type AccountDialog="phone"|"settings"|"model"|"about"|"help"|"feedback"|null;
function readSessionStore():Record<string,string>{try{const raw=localStorage.getItem(SESSION_STORE);return raw?JSON.parse(raw) as Record<string,string>:{}}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:"computer"}}catch{return{collapsed:false,part:"computer"}}}
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"}}}
type WorkspacePrefs={name:string;showHidden:boolean};
function readWorkspace():WorkspacePrefs{try{const raw=localStorage.getItem(WORKSPACE_STORE);if(!raw)return{name:t("localWorkspace"),showHidden:false};const value=JSON.parse(raw) as {name?:string;showHidden?:boolean};const name=value.name?.trim();return{name:name&&name!=="Local workspace"?name:t("localWorkspace"),showHidden:Boolean(value.showHidden)}}catch{return{name:t("localWorkspace"),showHidden:false}}}
function WorkspaceAvatar({name}:{name:string}){const parts=name.trim().split(/\s+/).filter(Boolean);const initials=(parts.length>1?parts.map(part=>part[0]).join(""):parts[0]?.slice(0,2)||"LB").slice(0,2).toUpperCase();return <span className="workspace-avatar" aria-hidden="true">{initials}</span>}
function modeLabel(mode:ComputerMode){return mode==="team"?t("sharedComputer"):t("privateComputer")}
function stateLabel(state:ComputerStatus["state"]){return ({stopped:t("stopped"),booting:t("booting"),running:t("running"),suspended:t("suspended"),error:t("error")})[state]}
function isTransitionStep(step?:string|null){return step==="電腦啟動中"||step==="喚醒中"||step==="換手中"}
function hudLabel(computer:ComputerStatus,connecting:boolean,handingOff:boolean){
const step=computer.busyStep||"";
if(computer.state==="booting"||step==="電腦啟動中")return t("hudBooting");
if(computer.state==="suspended"||step==="喚醒中")return t("hudWaking");
if(handingOff||step==="換手中")return t("hudHandoff");
if(connecting)return t("hudConnecting");
return null;
}
function inboxTime(value:string|null){if(!value)return "";const date=new Date(value),now=new Date();if(date.toDateString()===now.toDateString())return new Intl.DateTimeFormat("zh-TW",{hour:"2-digit",minute:"2-digit",hour12:false}).format(date);const days=Math.floor((new Date(now.getFullYear(),now.getMonth(),now.getDate()).getTime()-new Date(date.getFullYear(),date.getMonth(),date.getDate()).getTime())/86400000);if(days<7)return new Intl.DateTimeFormat("zh-TW",{weekday:"long"}).format(date);return new Intl.DateTimeFormat("zh-TW",{month:"numeric",day:"numeric"}).format(date)}
const ATTACH_MAX=4;
const ATTACH_MAX_BYTES=10*1024*1024;
@ -45,6 +54,7 @@ function readAsBase64(file:File){return new Promise<string>((resolve,reject)=>{c
function formatBytes(size:number){if(size<1024)return `${size} B`;if(size<1024*1024)return `${Math.round(size/102.4)/10} KB`;return `${Math.round(size/104857.6)/10} MB`}
function fileExt(name:string){const dot=name.lastIndexOf(".");const ext=dot>=0?name.slice(dot+1).replace(/[^a-z0-9]/gi,""):"";return (ext||"FILE").slice(0,4).toUpperCase()}
function messageFiles(blocks:unknown):MessageFile[]{if(!Array.isArray(blocks))return [];return blocks.flatMap(block=>{if(!block||typeof block!=="object")return [];const value=block as {kind?:string;name?:string;mimeType?:string;size?:number};if(value.kind!=="file"&&value.kind!=="image")return [];return [{kind:value.kind,name:value.name||"file",mimeType:value.mimeType,size:value.size}]})}
function chipBlocks(blocks:unknown){if(!Array.isArray(blocks))return [] as {kind:string;site?:string;why?:string;name?:string;human?:string}[];return blocks.flatMap(block=>{if(!block||typeof block!=="object")return [];const value=block as {kind?:string;site?:string;why?:string;name?:string;human?:string};if(value.kind==="login"||value.kind==="schedule"||value.kind==="scheduleRun")return [value];return []})}
function isAutoAttachCaption(body:string,files:MessageFile[]){const text=body.trim();if(!files.length)return false;if(!text)return true;return files.some(file=>text===file.name||text===`附件 ${file.name}`||text===t("attachedFile",{name:file.name}))}
function FileCard({file,preview,onRemove}:{file:{name:string;size?:number};preview?:string|null;onRemove?:()=>void}){const ext=fileExt(file.name);return <div className={`file-card ${onRemove?"is-pending":""}`}>{preview?<img className="file-card-thumb" src={preview} alt=""/>:<div className="file-card-badge" aria-hidden="true">{ext}</div>}<div className="file-card-meta"><strong>{file.name}</strong><small>{typeof file.size==="number"?formatBytes(file.size):ext}</small></div>{onRemove&&<button type="button" className="file-card-remove" title={t("attachRemove",{name:file.name})} onClick={onRemove}><X/></button>}</div>}
function clientNonce(){
@ -79,9 +89,14 @@ export function App(){
const [roomToDelete,setRoomToDelete]=useState<Room|null>(null); const [mcpServers,setMcpServers]=useState<McpServer[]>([]);
const [accountOpen,setAccountOpen]=useState(false); const [accountDialog,setAccountDialog]=useState<AccountDialog>(null);
const [skills,setSkills]=useState<TaughtSkill[]>([]); const [plusOpen,setPlusOpen]=useState(false); const [skillQuery,setSkillQuery]=useState(""); const [teachOpen,setTeachOpen]=useState(false); const [editingSkillId,setEditingSkillId]=useState<string|null>(null);
const [schedules,setSchedules]=useState<ScheduleItem[]>([]); 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<string|null>(null); const [scheduleSaving,setScheduleSaving]=useState(false); const [runningScheduleId,setRunningScheduleId]=useState<string|null>(null);
const [workspaceName,setWorkspaceName]=useState(workspaceStart.name);
const [looks,setLooks]=useState(readAvatarLooks);
const sendingRef=useRef(false); const refreshSeqRef=useRef(0); const importRef=useRef<HTMLInputElement>(null); const attachRef=useRef<HTMLInputElement>(null);
const desktopFrameRef=useRef<HTMLIFrameElement>(null);
const holderRef=useRef(computer.controlHolder); const paneBotRef=useRef<string|null>(null); const skipHandoffRef=useRef(true);
const [desktopReady,setDesktopReady]=useState(false); const [handingOff,setHandingOff]=useState(false);
const [pendingFiles,setPendingFiles]=useState<PendingFile[]>([]);
const messageEndRef=useRef<HTMLDivElement|null>(null);
const sentHistoryRef=useRef<string[]>([]); const historyIndexRef=useRef<number|null>(null); const historyDraftRef=useRef("");
@ -92,7 +107,10 @@ export function App(){
const sections=useMemo(()=>{const map=new Map<string,Bot[]>();for(const bot of filtered){const key=bot.pinned?t("pinned"):bot.groupName||t("agentGroup");map.set(key,[...(map.get(key)||[]),bot])}return [...map.entries()]},[filtered]);
const paneBotId=busyMembers[0]?.id||activeRoom?.members[0]?.id||activeId;
const paneBot=bots.find(bot=>bot.id===paneBotId)||active;
const workingMembers=activeRoom?busyMembers:active&&computer.busySessionId===activeSessionId?[{id:active.id,name:active.name,avatarColor:active.avatarColor,avatarShape:active.avatarShape}]:[];
const currentPaneRef=useRef(paneBotId);currentPaneRef.current=paneBotId;
const pasteQueueRef=useRef<Promise<unknown>>(Promise.resolve());
const [clipboardStatus,setClipboardStatus]=useState("");
const workingMembers=activeRoom?busyMembers:active&&activeSessionId&&computer.busySessionId===activeSessionId?[{id:active.id,name:active.name,avatarColor:active.avatarColor,avatarShape:active.avatarShape}]:[];
const lastMessageId=messages[messages.length-1]?.id||"";
// The bot is parked in waiting_takeover: nothing moves (including queued
// messages) until the human releases the screen, so say so loudly.
@ -131,14 +149,40 @@ export function App(){
useEffect(()=>{if(!plusOpen)setSkillQuery("")},[plusOpen]);
useEffect(()=>{messageEndRef.current?.scrollIntoView({block:"end",behavior:"smooth"})},[activeSessionId,lastMessageId,workingMembers.length,pausedForUser]);
useEffect(()=>{if(!activeSessionId||(!activeId&&!activeRoomId)){refreshSeqRef.current+=1;setMessages([]);setScreenUrl(null);if(!activeId&&!activeRoomId)setComputer(blankComputer);return}setScreenUrl(null);refresh().catch(e=>setError(e.message));const timer=setInterval(()=>{refresh().catch(()=>{});const beat=roomsRef.current.find(room=>room.id===activeRoomId)?.members[0]?.id||activeId;if(beat)api(`/api/computer/${beat}/heartbeat`,{method:"POST",body:"{}"}).catch(()=>{})},2000);return()=>{clearInterval(timer);refreshSeqRef.current+=1}},[activeId,activeRoomId,activeSessionId,refresh]);
useEffect(()=>{const listener=(event:MessageEvent)=>{if(event.origin!==location.origin||!event.data)return;if(event.data.type==="lazyboy-desktop-clipboard"){const text=String(event.data.text||"");setDesktopClipboard(text);navigator.clipboard.writeText(text).catch(()=>{})}};window.addEventListener("message",listener);return()=>window.removeEventListener("message",listener)});
useEffect(()=>{const listener=(event:MessageEvent)=>{if(event.origin!==location.origin||event.data?.type!=="lazyboy-request-control"||!paneBotId)return;void action(()=>api(`/api/computer/${paneBotId}/takeover`,{method:"POST",body:"{}"}))};window.addEventListener("message",listener);return()=>window.removeEventListener("message",listener)},[paneBotId]);
useEffect(()=>{const listener=(event:MessageEvent)=>{if(event.origin!==location.origin||event.source!==desktopFrameRef.current?.contentWindow||!event.data)return;if(event.data.type==="lazyboy-desktop-clipboard"){const text=String(event.data.text||"");setDesktopClipboard(text);navigator.clipboard?.writeText(text).then(()=>setClipboardStatus("剪貼簿已同步")).catch(()=>setClipboardStatus("瀏覽器未允許同步,請按複製按鈕"))}if(event.data.type==="lazyboy-copy-request"&&computer.controlHolder==="user")void copySelection();if(event.data.type==="lazyboy-paste-text"&&typeof event.data.text==="string")pasteText(event.data.text);if(event.data.type==="lazyboy-paste-request"&&computer.controlHolder==="user")setClipboardOpen(true);if(event.data.type==="lazyboy-desktop-ready")setDesktopReady(true);if(event.data.type==="lazyboy-desktop-lost")setDesktopReady(false)};window.addEventListener("message",listener);return()=>window.removeEventListener("message",listener)});
useEffect(()=>{setDesktopReady(false)},[screenUrl,paneBotId]);
useEffect(()=>{if(skipHandoffRef.current){skipHandoffRef.current=false;holderRef.current=computer.controlHolder;paneBotRef.current=paneBotId;return}if((holderRef.current!==computer.controlHolder||paneBotRef.current!==paneBotId)&&computer.state==="running")setHandingOff(true);holderRef.current=computer.controlHolder;paneBotRef.current=paneBotId},[computer.controlHolder,paneBotId,computer.state]);
useEffect(()=>{if(!handingOff)return;const timer=setTimeout(()=>setHandingOff(false),1600);return()=>clearTimeout(timer)},[handingOff]);
useEffect(()=>{const frame=desktopFrameRef.current;if(!frame?.contentWindow||!screenUrl)return;frame.contentWindow.postMessage({type:"lazyboy-view-only",viewOnly:computer.controlHolder!=="user"},location.origin)},[computer.controlHolder,screenUrl,desktopReady]);
useEffect(()=>{const listener=(event:MessageEvent)=>{if(event.origin!==location.origin||event.source!==desktopFrameRef.current?.contentWindow||event.data?.type!=="lazyboy-request-control"||!paneBotId)return;void action(()=>api(`/api/computer/${paneBotId}/takeover`,{method:"POST",body:"{}"}))};window.addEventListener("message",listener);return()=>window.removeEventListener("message",listener)},[paneBotId]);
useEffect(()=>{const close=(event:MouseEvent)=>{const target=event.target;if(target instanceof Element&&target.closest(".create-menu-wrap,.account-wrap,.session-picker,.context-menu,.plus-menu-wrap"))return;setContext(null);setRoomContext(null);setSessionMenuOpen(false);setAccountOpen(false);setCreateMenuOpen(false);setPlusOpen(false)};window.addEventListener("click",close);return()=>window.removeEventListener("click",close)},[]);
useEffect(()=>{const onKey=(event:KeyboardEvent)=>{if(event.key!=="Escape")return;setAccountOpen(false);setAccountDialog(null);setCreateMenuOpen(false);setPlusOpen(false);setTeachOpen(false);setEditingSkillId(null);setSessionMenuOpen(false);setContext(null);setRoomContext(null)};window.addEventListener("keydown",onKey);return()=>window.removeEventListener("keydown",onKey)},[]);
useEffect(()=>{localStorage.setItem(WORKSPACE_STORE,JSON.stringify({name:workspaceName,showHidden}))},[workspaceName,showHidden]);
useEffect(()=>{if(!sessionStoreKey||!activeSessionId)return;if(!sessions.some(session=>session.id===activeSessionId))return;if(!activeRoomId&&!sessions.some(session=>session.id===activeSessionId&&session.botId===activeId))return;writeSessionStore(sessionStoreKey,activeSessionId)},[sessionStoreKey,activeId,activeRoomId,activeSessionId,sessions]);
useEffect(()=>{localStorage.setItem(PANE_STORE,JSON.stringify({collapsed:rightCollapsed,part:rightPart}))},[rightCollapsed,rightPart]);
useEffect(()=>{if(!paneBotId){setSchedules([]);return}api<ScheduleItem[]>(`/api/bots/${paneBotId}/schedules`).then(setSchedules).catch(()=>setSchedules([]))},[paneBotId]);
useEffect(()=>{if(computer.takeoverRequested){setRightPart("computer");setRightCollapsed(false)}},[computer.takeoverRequested]);
function openPane(part:RightPart){setRightPart(part);setRightCollapsed(false)}
async function openLoginScreen(){
const id=paneBot?.id||active?.id;if(!id)return;
setRightPart("computer");setRightCollapsed(false);setComputerOpen(true);
await action(async()=>{
try{await api(`/api/computer/${id}/boot`,{method:"POST",body:"{}"})}catch{/* already up */}
await api(`/api/computer/${id}/takeover`,{method:"POST",body:"{}"}).catch(()=>{});
});
}
async function reloadSchedules(){if(!paneBotId)return;setSchedules(await api<ScheduleItem[]>(`/api/bots/${paneBotId}/schedules`))}
async function saveScheduleDraft(){
if(!paneBot||!scheduleDraft)return;
setScheduleSaving(true);setScheduleError(null);
try{
const body={name:scheduleDraft.name.trim(),cron:cronFromPreset(scheduleDraft.preset),instructions:scheduleDraft.instructions.trim(),timezone:scheduleDraft.timezone||Intl.DateTimeFormat().resolvedOptions().timeZone||"Asia/Taipei",enabled:scheduleDraft.enabled,threadId:scheduleDraft.id?scheduleDraft.threadId:activeRoomId?undefined:activeSessionId};
if(scheduleDraft.id)await api(`/api/schedules/${scheduleDraft.id}`,{method:"PATCH",body:JSON.stringify(body)});
else await api(`/api/bots/${paneBot.id}/schedules`,{method:"POST",body:JSON.stringify(body)});
setScheduleDraft(null);await reloadSchedules();
}catch(e){setScheduleError(e instanceof Error?e.message:t("operationFailed"))}
finally{setScheduleSaving(false)}
}
const sessionBusy=workingMembers.length>0;
const otherSessionBusy=Boolean(computer.busyBotName&&!sessionBusy);
@ -166,7 +210,23 @@ export function App(){
async function clearSession(){if(!activeSessionId)return;setClearOpen(false);setSessionMenuOpen(false);await action(async()=>{await api(`/api/sessions/${activeSessionId}/messages`,{method:"DELETE"});setMessages([]);await loadSessions()})}
async function deleteSession(id:string){if(!sessionsPath||!sessionStoreKey)return;setSessionMenuOpen(false);setBusy(true);setError(null);try{await api(`/api/sessions/${id}`,{method:"DELETE"});const next=await api<Session[]>(sessionsPath);setSessions(next);const pick=id===activeSessionId||!next.some(session=>session.id===activeSessionId)?next[0]?.id||null:activeSessionId;setActiveSessionId(pick);if(pick)writeSessionStore(sessionStoreKey,pick)}catch(e){setError(e instanceof Error?e.message:t("operationFailed"))}finally{setBusy(false)}}
async function rememberMessage(message:Message){const botId=message.speakerBotId||active?.id||activeRoom?.members[0]?.id;if(!botId||!message.body.trim())return;try{await api(`/api/bots/${botId}/memories`,{method:"POST",body:JSON.stringify({content:message.body,sessionId:activeSessionId})});setRemembered(current=>({...current,[message.id]:true}))}catch(e){setError(e instanceof Error?e.message:t("rememberFailed"))}}
async function pasteClipboard(){try{const text=await navigator.clipboard.readText();document.querySelectorAll<HTMLIFrameElement>(".desktop-frame").forEach(frame=>frame.contentWindow?.postMessage({type:"lazyboy-host-clipboard",text},location.origin))}catch{setClipboardOpen(true)}}
function pasteText(text:string){
const botId=paneBotId;
if(!botId||computer.controlHolder!=="user")return;
pasteQueueRef.current=pasteQueueRef.current.catch(()=>{}).then(async()=>{
if(currentPaneRef.current!==botId)return;
await api(`/api/computer/${botId}/input`,{method:"POST",body:JSON.stringify({kind:"clipboard",text})});
if(currentPaneRef.current===botId)setClipboardStatus("已貼上文字");
}).catch(()=>setClipboardStatus("貼上失敗,請確認已接管電腦後重試"));
}
async function copySelection(){
const botId=paneBotId;if(!botId)return;
try{const result=await api<{text:string}>(`/api/computer/${botId}/input`,{method:"POST",body:JSON.stringify({kind:"copy"})});
if(currentPaneRef.current!==botId)return;
setDesktopClipboard(result.text);await navigator.clipboard.writeText(result.text);setClipboardStatus("已複製選取文字");
}catch{setClipboardStatus("複製未同步;請按複製按鈕,或在遠端使用 Ctrl+Shift+C");}
}
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)}
@ -174,13 +234,27 @@ export function App(){
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)}
// Re-mount noVNC after every status transition so a freshly booted Docker
// display cannot remain stuck on the previous disconnected iframe.
const startBoot=async()=>{setScreenUrl(null);setComputer(current=>({...current,state:"booting"}));await api(`/api/computer/${paneBot?.id||active?.id}/boot`,{method:"POST",body:"{}"})};
const restartComputer=async()=>{setScreenUrl(null);setComputer(current=>({...current,state:"booting"}));await api(`/api/computer/${paneBot?.id||active?.id}/restart`,{method:"POST",body:"{}"})};
const frame=screenUrl?<iframe key={`${screenUrl}:${computer.state}:${computer.botId}:${computer.controlHolder}`} className="desktop-frame" src={screenUrl} title={t("agentComputer")} allow="fullscreen; clipboard-read; clipboard-write"/>:<EmptyComputer state={computer.state}/>;
async function changeComputer(operation:"boot"|"restart"){
const botId=paneBotId;if(!botId)return;
const previous=computer;setScreenUrl(null);setDesktopReady(false);
setComputer(current=>({...current,state:operation==="boot"&&current.state==="suspended"?"suspended":"booting"}));
try{await api(`/api/computer/${botId}/${operation}`,{method:"POST",body:"{}",signal:AbortSignal.timeout(120_000)})}
catch(error){
const status=await api<ComputerStatus>(`/api/computer/${botId}/status`,{signal:AbortSignal.timeout(5_000)}).catch(()=>previous);
if(currentPaneRef.current===botId)setComputer(status);
throw error;
}
}
const startBoot=()=>changeComputer("boot");
const restartComputer=()=>changeComputer("restart");
const connecting=computer.state==="running"&&Boolean(screenUrl)&&!desktopReady;
const overlayLabel=hudLabel(computer,connecting,handingOff);
const frame=screenUrl?<iframe ref={desktopFrameRef} key={screenUrl} className="desktop-frame" src={screenUrl} title={t("agentComputer")} allow="fullscreen; clipboard-read; clipboard-write"/>:<EmptyComputer state={computer.state}/>;
const hud=paneBot&&overlayLabel?<ComputerHud bot={paneBot} label={overlayLabel}/>:null;
const statusMembers=workingMembers;
const topTools=<nav className="top-tools" aria-label={t("workTools")}>
<button type="button" className={`top-tool-button ${!rightCollapsed&&rightPart==="computer"?"active":""}`} title={t("computer")} aria-label={t("computer")} onClick={()=>openPane("computer")}><Computer/></button>
<button type="button" className={`top-tool-button ${!rightCollapsed&&rightPart==="accounts"?"active":""}`} title={t("accounts")} aria-label={t("accounts")} onClick={()=>openPane("accounts")} disabled={!paneBot}><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg></button>
<button type="button" className={`top-tool-button ${!rightCollapsed&&rightPart==="memory"?"active":""}`} title={t("memory")} aria-label={t("memory")} onClick={()=>openPane("memory")} disabled={!paneBot}><Brain/></button>
<button type="button" className={`top-tool-button ${!rightCollapsed&&rightPart==="plugins"?"active":""}`} title={t("plugins")} aria-label={t("plugins")} onClick={()=>openPane("plugins")}><Plug/></button>
<button type="button" className={`top-tool-button ${!rightCollapsed&&rightPart==="settings"?"active":""}`} title={active?t("botSettings"):t("settings")} aria-label={active?t("botSettings"):t("settings")} onClick={()=>active?openPane("settings"):setAccountDialog("settings")}><Settings/></button>
@ -222,26 +296,30 @@ export function App(){
</aside>
<main className="chat-panel">
<header className="topbar"><button className="icon-button mobile-menu" onClick={()=>setMobileNav(v=>!v)}><UseAnimations animation={menu} size={18} strokeColor="#dfdfe2"/></button>{activeRoom?<><AvatarStack members={activeRoom.members} online thinkingIds={busyMembers.map(member=>member.id)}/><strong>{activeRoom.name}</strong><SessionMenu sessions={sessions} activeSessionId={activeSessionId} open={sessionMenuOpen} setOpen={setSessionMenuOpen} busy={busy} onSelect={selectSession} onCreate={createSession} onDelete={id=>{setSessionMenuOpen(false);setSessionToDelete(id)}} onClear={()=>{setSessionMenuOpen(false);setClearOpen(true)}}/><span className="grow"/>{topTools}</>:active?<><Avatar lookId={active.id} name={active.name} color={active.avatarColor} shape={active.avatarShape} active online/><strong>{active.name}</strong><SessionMenu sessions={sessions} activeSessionId={activeSessionId} open={sessionMenuOpen} setOpen={setSessionMenuOpen} busy={busy} onSelect={selectSession} onCreate={createSession} onDelete={id=>{setSessionMenuOpen(false);setSessionToDelete(id)}} onClear={()=>{setSessionMenuOpen(false);setClearOpen(true)}}/><span className="grow"/>{topTools}</>:<><strong>{t("chooseBot")}</strong><span className="grow"/>{topTools}</>}</header>
<div className="messages">{(activeRoom||active)&&messages.length===0?<div className="welcome">{activeRoom?<AvatarStack members={activeRoom.members} size={56} online/>:<Avatar lookId={active!.id} name={active!.name} color={active!.avatarColor} shape={active!.avatarShape} active online size={64}/>}<h1>{activeRoom?t("startRoomDiscussion",{name:activeRoom.name}):t("startBotWork",{name:active!.name})}</h1><p>{activeRoom?t("roomWillReply",{names:activeRoom.members.map(member=>member.name).join("、")}):active!.description||t("botWelcome")}</p></div>:messages.map(message=>{const spoken=message.role!=="user"&&Boolean(activeRoom);const speakerName=message.speakerName||(spoken?paneBot?.name:undefined);const speakerShape=(message.speakerShape||paneBot?.avatarShape||"blob") as AvatarShape;const files=messageFiles(message.blocks);const hideBody=isAutoAttachCaption(message.body,files);return <div key={message.id} className={`message ${message.role} ${spoken?"spoken":""} ${files.length?"with-files":""}`}>{spoken&&<span className="msg-avatar"><Avatar lookId={message.speakerBotId||paneBot?.id||undefined} name={speakerName||"agent"} color={message.speakerColor||undefined} shape={speakerShape} size={22}/></span>}{spoken&&<b className="speaker" style={{color:message.speakerColor||undefined}}>{speakerName}</b>}{files.length>0&&<div className="msg-attachments">{files.map(file=><FileCard key={file.name} file={file}/>)}</div>}{!hideBody&&<span className="message-body">{message.body}</span>}{!hideBody&&message.body.trim()&&<button type="button" className={`remember-msg ${remembered[message.id]?"saved":""}`} title={remembered[message.id]?t("remembered"):t("remember")} disabled={!!remembered[message.id]} onClick={()=>void rememberMessage(message)}><UseAnimations animation={bookmark} size={14} strokeColor="var(--muted)"/></button>}</div>})}{workingMembers.map(member=><div className="thinking-row" key={member.id}><Avatar lookId={member.id} name={member.name} color={member.avatarColor} shape={member.avatarShape} thinking online/><span className="working-label">{computer.busyStep&&member.id===computer.botId?t("workingStep",{name:member.name,step:computer.busyStep}):t("working",{name:member.name})}</span></div>)}{pausedForUser&&paneBot&&<div className="pause-banner" role="status"><span>{computer.controlHolder==="user"?t("pausedUserControl",{name:paneBot.name}):t("pausedNeedsUser",{name:paneBot.name})}{(computer.queuedRuns||0)>0&&<> {t("queuedMessages",{count:computer.queuedRuns||0})}</>}</span>{computer.controlHolder==="user"?<button type="button" className="primary" disabled={busy} onClick={()=>void action(()=>api(`/api/computer/${paneBot.id}/release`,{method:"POST",body:"{}"}))}>{t("releaseAndContinue")}</button>:<button type="button" className="primary" disabled={busy} onClick={()=>void action(()=>api(`/api/computer/${paneBot.id}/takeover`,{method:"POST",body:"{}"}))}>{t("takeOverNow")}</button>}</div>}{teaching&&active&&<div className="teach-banner recording" role="status"><span><i className="record-dot live"/>{t("teachingLive",{goal:teaching.goal})}<small>{t("teachingHint")}{teaching.eventCount>0&&<> · {t("teachingCaptured",{count:teaching.eventCount})}</>}</small></span><button type="button" className="primary" disabled={busy} onClick={()=>void stopTeaching()}>{t("finishDemo")}</button><button type="button" className="outline" disabled={busy} onClick={()=>void cancelTeaching()}>{t("cancel")}</button></div>}{drafting&&<div className="teach-banner" role="status"><UseAnimations animation={loading} size={18} wrapperStyle={{display:"inline-block",verticalAlign:"middle"}}/><span>{t("distilling",{goal:drafting.goal})}</span></div>}{skillDraft&&active&&<SkillDraftCard key={skillDraft.id} skill={skillDraft} busy={busy} onSave={(name,playbook)=>void saveSkill(skillDraft,name,playbook)} onTest={(name,playbook)=>void testSkill(skillDraft,name,playbook)} onDiscard={()=>void discardSkill(skillDraft)} onEdit={()=>setEditingSkillId(skillDraft.id)} onExport={(name,playbook)=>downloadSkill(name,skillDraft.goal,playbook)}/>}<div ref={messageEndRef} aria-hidden="true"/></div>
<header className="topbar"><button className="icon-button mobile-menu" onClick={()=>setMobileNav(v=>!v)}><UseAnimations animation={menu} size={18} strokeColor="#dfdfe2"/></button>{activeRoom?<><AvatarStack members={activeRoom.members} size={32} online thinkingIds={busyMembers.map(member=>member.id)}/><strong>{activeRoom.name}</strong><SessionMenu sessions={sessions} activeSessionId={activeSessionId} open={sessionMenuOpen} setOpen={setSessionMenuOpen} busy={busy} onSelect={selectSession} onCreate={createSession} onDelete={id=>{setSessionMenuOpen(false);setSessionToDelete(id)}} onClear={()=>{setSessionMenuOpen(false);setClearOpen(true)}}/><span className="grow"/>{topTools}</>:active?<><Avatar lookId={active.id} name={active.name} color={active.avatarColor} shape={active.avatarShape} active online/><strong>{active.name}</strong><SessionMenu sessions={sessions} activeSessionId={activeSessionId} open={sessionMenuOpen} setOpen={setSessionMenuOpen} busy={busy} onSelect={selectSession} onCreate={createSession} onDelete={id=>{setSessionMenuOpen(false);setSessionToDelete(id)}} onClear={()=>{setSessionMenuOpen(false);setClearOpen(true)}}/><span className="grow"/>{topTools}</>:<><strong>{t("chooseBot")}</strong><span className="grow"/>{topTools}</>}</header>
<div className="messages">{(activeRoom||active)&&messages.length===0?<div className="welcome">{activeRoom?<AvatarStack members={activeRoom.members} size={56} online/>:<Avatar lookId={active!.id} name={active!.name} color={active!.avatarColor} shape={active!.avatarShape} active online size={64}/>}<h1>{activeRoom?t("startRoomDiscussion",{name:activeRoom.name}):t("startBotWork",{name:active!.name})}</h1><p>{activeRoom?t("roomWillReply",{names:activeRoom.members.map(member=>member.name).join("、")}):active!.description||t("botWelcome")}</p></div>:messages.map(message=>{const spoken=message.role!=="user"&&Boolean(activeRoom);const speakerName=message.speakerName||(spoken?paneBot?.name:undefined);const speakerShape=(message.speakerShape||paneBot?.avatarShape||"blob") as AvatarShape;const files=messageFiles(message.blocks);const chips=chipBlocks(message.blocks);const hideBody=isAutoAttachCaption(message.body,files);return <div key={message.id} className={`message ${message.role} ${spoken?"spoken":""} ${files.length?"with-files":""}`}>{spoken&&<span className="msg-avatar"><Avatar lookId={message.speakerBotId||paneBot?.id||undefined} name={speakerName||"agent"} color={message.speakerColor||undefined} shape={speakerShape} size={22}/></span>}{spoken&&<b className="speaker" style={{color:message.speakerColor||undefined}}>{speakerName}</b>}{files.length>0&&<div className="msg-attachments">{files.map(file=><FileCard key={file.name} file={file}/>)}</div>}{chips.map((chip,index)=>chip.kind==="login"?<div className="login-chip" key={`${message.id}-login-${index}`}><div className="login-label">{t("loginNeedsYou")}</div><div className="login-site">{chip.site||message.body}</div>{chip.why?<div className="login-why">{t("loginWhy",{why:chip.why})}</div>:null}<button type="button" className="primary" onClick={()=>void openLoginScreen()}>{t("loginOpenScreen")}</button></div>:chip.kind==="schedule"?<div className="sched-chip" key={`${message.id}-sched-${index}`}><div className="sched-label">{t("scheduleChip")}</div><strong>{chip.name}</strong><small>{chip.human}</small></div>:<div className="sched-chip" key={`${message.id}-run-${index}`}><div className="sched-label">{t("scheduleRunChip")}</div><strong>{chip.name}</strong><small>{chip.human}</small></div>)}{!hideBody&&<span className="message-body">{message.body}</span>}{!hideBody&&message.body.trim()&&<button type="button" className={`remember-msg ${remembered[message.id]?"saved":""}`} title={remembered[message.id]?t("remembered"):t("remember")} disabled={!!remembered[message.id]} onClick={()=>void rememberMessage(message)}><UseAnimations animation={bookmark} size={14} strokeColor="var(--muted)"/></button>}</div>})}{pausedForUser&&paneBot&&<div className="pause-banner" role="status"><span>{computer.controlHolder==="user"?t("pausedUserControl",{name:paneBot.name}):t("pausedNeedsUser",{name:paneBot.name})}{(computer.queuedRuns||0)>0&&<> {t("queuedMessages",{count:computer.queuedRuns||0})}</>}</span>{computer.controlHolder==="user"?<button type="button" className="primary" disabled={busy} onClick={()=>void action(()=>api(`/api/computer/${paneBot.id}/release`,{method:"POST",body:"{}"}))}>{t("releaseAndContinue")}</button>:<button type="button" className="primary" disabled={busy} onClick={()=>void action(()=>api(`/api/computer/${paneBot.id}/takeover`,{method:"POST",body:"{}"}))}>{t("takeOverNow")}</button>}</div>}{teaching&&active&&<div className="teach-banner recording" role="status"><span><i className="record-dot live"/>{t("teachingLive",{goal:teaching.goal})}<small>{t("teachingHint")}{teaching.eventCount>0&&<> · {t("teachingCaptured",{count:teaching.eventCount})}</>}</small></span><button type="button" className="primary" disabled={busy} onClick={()=>void stopTeaching()}>{t("finishDemo")}</button><button type="button" className="outline" disabled={busy} onClick={()=>void cancelTeaching()}>{t("cancel")}</button></div>}{drafting&&<div className="teach-banner" role="status"><UseAnimations animation={loading} size={18} wrapperStyle={{display:"inline-block",verticalAlign:"middle"}}/><span>{t("distilling",{goal:drafting.goal})}</span></div>}{skillDraft&&active&&<SkillDraftCard key={skillDraft.id} skill={skillDraft} busy={busy} onSave={(name,playbook)=>void saveSkill(skillDraft,name,playbook)} onTest={(name,playbook)=>void testSkill(skillDraft,name,playbook)} onDiscard={()=>void discardSkill(skillDraft)} onEdit={()=>setEditingSkillId(skillDraft.id)} onExport={(name,playbook)=>downloadSkill(name,skillDraft.goal,playbook)}/>}<div ref={messageEndRef} aria-hidden="true"/></div>
{error&&<div className="error-banner"><span>{error}</span><button onClick={()=>setError(null)}><X/></button></div>}
{otherSessionBusy&&<div className="queue-hint">{t("anotherConversationQueued")}</div>}
<div className={`composer-dock ${statusMembers.length?"has-status":""}`}>
{statusMembers.map(member=>{const step=computer.busyStep&&member.id===computer.botId?computer.busyStep:null;const transition=isTransitionStep(step);const label=t("working",{name:member.name});return <div className="thinking-row" key={member.id}><Avatar lookId={member.id} name={member.name} color={member.avatarColor} shape={member.avatarShape} thinking online/><span className="working-copy"><span className="working-label">{label}</span>{!transition&&step?<span className="working-step">{step}</span>:null}</span></div>})}
<form className={`composer ${pendingFiles.length?"has-files":""}`} onSubmit={send} onDragOver={event=>{event.preventDefault()}} onDrop={event=>{event.preventDefault();if(event.dataTransfer.files.length)addPendingFiles(event.dataTransfer.files)}}><div className="plus-menu-wrap" onClick={event=>event.stopPropagation()}><button type="button" className={`composer-plus ${plusOpen?"open":""}`} disabled={!activeSessionId} title={t("moreActions")} aria-label={t("moreActions")} aria-haspopup="menu" aria-expanded={plusOpen} onClick={()=>setPlusOpen(v=>!v)}><Plus/></button>{plusOpen&&<div className="plus-menu" role="menu"><button type="button" role="menuitem" disabled={!activeSessionId||Boolean(teaching)} title={t("attachFileHint")} onClick={()=>{setPlusOpen(false);attachRef.current?.click()}}><Paperclip/>{t("attachFile")}</button><button type="button" role="menuitem" disabled={!active||Boolean(teaching)||Boolean(drafting)} title={active?t("teachTaskHint"):t("teachNeedsBot")} onClick={()=>{setPlusOpen(false);setTeachOpen(true)}}><i className="record-dot"/>{t("teachTask")}</button><button type="button" role="menuitem" disabled={!active} title={t("importSkillHint")} onClick={()=>{setPlusOpen(false);importRef.current?.click()}}><Upload/>{t("importSkill")}</button>{savedSkills.length>0&&<><hr/><div className="plus-menu-skills"><small className="plus-menu-label">{t("taughtSkills")}{savedSkills.length>5?` · ${savedSkills.length}`:""}</small>{savedSkills.length>=6&&<input className="plus-menu-search" value={skillQuery} onChange={e=>setSkillQuery(e.target.value)} placeholder={t("searchSkills")} aria-label={t("searchSkills")} onClick={e=>e.stopPropagation()}/>}<div className="plus-menu-skill-list">{listedSkills.map(skill=><div className="plus-menu-skill" key={skill.id}><button type="button" role="menuitem" title={t("runSkillNamed",{name:skill.name})+(skill.playbook.whenToUse?`\n${skill.playbook.whenToUse}`:"")} onClick={()=>runSkill(skill)}><Sparkle/>{skill.name}</button><button type="button" className="skill-edit" title={t("exportSkillHint")} aria-label={t("exportSkill")} onClick={()=>downloadSkill(skill.name,skill.goal,skill.playbook)}><Download/></button><button type="button" className="skill-edit" title={t("editSkill")} aria-label={t("editSkill")} onClick={()=>{setPlusOpen(false);setEditingSkillId(skill.id)}}><Pencil/></button></div>)}{listedSkills.length===0&&<small className="plus-menu-empty">{t("noMatchingSkills")}</small>}</div></div></>}</div>}</div><input ref={importRef} className="skill-import-input" type="file" accept="application/json,.json" tabIndex={-1} aria-hidden="true" onChange={event=>{const file=event.target.files?.[0];event.currentTarget.value="";if(file)void importSkillFile(file)}}/><input ref={attachRef} className="skill-import-input attach-input" type="file" multiple accept={ATTACH_ACCEPT} tabIndex={-1} aria-hidden="true" onChange={event=>{const files=[...event.target.files||[]];event.currentTarget.value="";if(files.length)addPendingFiles(files)}}/>{pendingFiles.length>0&&<div className="composer-files">{pendingFiles.map(item=><FileCard key={item.id} file={{name:item.file.name,size:item.file.size}} preview={item.preview} onRemove={()=>removePendingFile(item.id)}/>)}</div>}<textarea rows={1} value={draft} onChange={e=>setDraft(e.target.value)} onKeyDown={composerKeyDown} onPaste={event=>{const files=event.clipboardData?.files;if(files&&files.length){event.preventDefault();addPendingFiles(files)}}} placeholder={teaching?t("teachingComposerHint"):activeSessionId&&chatName?t("messageTo",{name:chatName}):t("chooseConversationFirst")} disabled={!activeSessionId||Boolean(teaching)}/>{sessionBusy?<button type="button" className="send stop-send" title={t("stopConversation")} onClick={()=>void action(stopChat)}><Square/></button>:<button className="send" disabled={!activeSessionId||(!draft.trim()&&pendingFiles.length===0)||busy}><UseAnimations animation={arrowUp} size={20} strokeColor="#1b1b1c"/></button>}</form>
</div>
</main>
{!rightCollapsed&&<div className="side-card-backdrop" onClick={()=>setRightCollapsed(true)}/>}
{!rightCollapsed&&<aside className="side-card">
<>
<header className="side-card-head">
<span className="side-card-title">{rightPart==="computer"?t("computer"):rightPart==="memory"?t("memory"):rightPart==="plugins"?t("plugins"):t("settings")}</span>
<span className="side-card-title">{rightPart==="computer"?t("computer"):rightPart==="memory"?t("memory"):rightPart==="plugins"?t("plugins"):rightPart==="accounts"?t("accounts"):t("settings")}</span>
<button type="button" className="icon-button" title={t("collapseSidebar")} onClick={()=>setRightCollapsed(true)}><ChevronsRight/></button>
</header>
<div className="side-card-body">
<div className={`side-part computer-part ${rightPart==="computer"?"":"hidden-part"}`}>
<div className="computer-status-row">{paneBot?<span>{t("botComputer",{name:paneBot.name})}</span>:<span>{t("computer")}</span>}{computer.state==="booting"?<UseAnimations animation={loading} size={17} wrapperStyle={{display:"inline-block",verticalAlign:"middle"}}/>:<i className={`state-dot ${computer.state}`}/>}<small>{stateLabel(computer.state)}</small></div>
<div className="preview">{computerOpen?<EmptyComputer state={computer.state}/>:frame}</div>
{paneBot&&<><div className="computer-caption"><span>{t("dedicatedScreen")}</span><button className="outline" onClick={()=>setComputerOpen(true)}>{t("enlarge")}</button></div>{teaching?<div className="control-bar"><div className="computer-actions"><button className="primary" disabled={busy} onClick={()=>void stopTeaching()}>{t("finishDemo")}</button><button className="outline" disabled={busy} onClick={()=>void cancelTeaching()}>{t("cancel")}</button></div></div>:<ControlBar active={paneBot} computer={computer} busy={busy} action={action} paste={pasteClipboard} copy={copyClipboard} sessionId={activeSessionId} onBoot={startBoot} onRestart={restartComputer}/>}</>}
<div className="preview">{computerOpen?<EmptyComputer state={computer.state}/>:frame}{!computerOpen&&hud}</div>
{paneBot&&<><div className="computer-caption"><span>{t("dedicatedScreen")}</span><button className="outline" onClick={()=>setComputerOpen(true)}>{t("enlarge")}</button></div>{teaching?<div className="control-bar"><div className="computer-actions"><button className="primary" disabled={busy} onClick={()=>void stopTeaching()}>{t("finishDemo")}</button><button className="outline" disabled={busy} onClick={()=>void cancelTeaching()}>{t("cancel")}</button></div></div>:<ControlBar active={paneBot} computer={computer} busy={busy} action={action} paste={pasteClipboard} copy={copyClipboard} sessionId={activeSessionId} onBoot={startBoot} onRestart={restartComputer}/>}<p className="computer-login-hint">{t("computerLoginHint")}</p><p className="clipboard-status" role="status">{clipboardStatus}</p>{scheduleDraft?<ScheduleEditor draft={scheduleDraft} timezone={scheduleDraft.timezone||Intl.DateTimeFormat().resolvedOptions().timeZone||"Asia/Taipei"} saving={scheduleSaving} error={scheduleError} onChange={next=>setScheduleDraft(current=>current?{...current,...next}:next)} onBack={()=>setScheduleDraft(null)} onSave={()=>void saveScheduleDraft()} onDelete={scheduleDraft.id?()=>void action(async()=>{await api(`/api/schedules/${scheduleDraft.id}`,{method:"DELETE"});setScheduleDraft(null);await reloadSchedules()}):undefined}/>:<ScheduleList items={schedules} runningId={runningScheduleId} onCreate={()=>setScheduleDraft({name:"",instructions:"",enabled:true,preset:defaultCronPreset()})} onOpen={item=>setScheduleDraft({id:item.id,timezone:item.timezone,threadId:item.threadId,name:item.name,instructions:item.instructions,enabled:item.enabled,preset:presetFromCron(item.cron)})} onRun={item=>void action(async()=>{setRunningScheduleId(item.id);try{await api(`/api/schedules/${item.id}/run`,{method:"POST",body:"{}"});await loadSessions()}finally{setRunningScheduleId(null)}})}/>}</>}
</div>
{rightPart==="accounts"&&paneBot&&<VaultPane bot={paneBot}/>}
{rightPart==="memory"&&paneBot&&<MemoryPane bot={paneBot} changed={loadBots}/>}
{rightPart==="plugins"&&<McpPane servers={mcpServers} reload={loadMcp}/>}
{rightPart==="settings"&&active&&<BotSettingsPane bot={active} look={looks[active.id]||DEFAULT_LOOK} onLook={look=>{writeAvatarLook(active.id,look);setLooks(readAvatarLooks())}} saved={loadBots} onDelete={()=>setDeleteOpen(true)}/>}
@ -249,10 +327,10 @@ export function App(){
</>
</aside>}
{computerOpen&&paneBot&&<div className="computer-overlay"><header><div><Avatar lookId={paneBot.id} name={paneBot.name} color={paneBot.avatarColor} shape={paneBot.avatarShape} active online/><strong>{modeLabel(computer.mode)}</strong><span className={`control-badge ${teaching?"teaching":""}`}>{teaching?t("teachingBadge"):computer.controlHolder==="user"?t("userControlling"):computer.busyBotName?t("aiReadOnly"):t("readOnly")}</span></div><div>{teaching?<div className="computer-actions"><button className="primary" disabled={busy} onClick={()=>void stopTeaching()}>{t("finishDemo")}</button><button className="outline" disabled={busy} onClick={()=>void cancelTeaching()}>{t("cancel")}</button></div>:<ControlButtons computer={computer} busy={busy} action={action} active={paneBot} sessionId={activeSessionId} onBoot={startBoot} onRestart={restartComputer}/>}<button className="icon-button" onClick={pasteClipboard} disabled={computer.controlHolder!=="user"} title={t("pasteClipboard")}><ClipboardPaste/></button><button className="icon-button" onClick={copyClipboard} disabled={computer.controlHolder!=="user"||!desktopClipboard} title={t("copyDesktopClipboard")}><UseAnimations animation={copy} size={18} strokeColor="#dfdfe2"/></button><button className="icon-button" title={t("moreActions")}><Ellipsis/></button><button className="icon-button" onClick={()=>setComputerOpen(false)}><X/></button></div></header><div className="overlay-screen">{frame}</div>{error&&<div className="overlay-error">{error}</div>}</div>}
{computerOpen&&paneBot&&<div className="computer-overlay"><header><div><Avatar lookId={paneBot.id} name={paneBot.name} color={paneBot.avatarColor} shape={paneBot.avatarShape} active online/><strong>{modeLabel(computer.mode)}</strong><span className={`control-badge ${teaching?"teaching":""}`}>{teaching?t("teachingBadge"):computer.controlHolder==="user"?t("userControlling"):computer.usingComputer?t("aiReadOnly"):t("readOnly")}</span></div><div>{teaching?<div className="computer-actions"><button className="primary" disabled={busy} onClick={()=>void stopTeaching()}>{t("finishDemo")}</button><button className="outline" disabled={busy} onClick={()=>void cancelTeaching()}>{t("cancel")}</button></div>:<ControlButtons computer={computer} busy={busy} action={action} active={paneBot} sessionId={activeSessionId} onBoot={startBoot} onRestart={restartComputer}/>}<button className="icon-button" onClick={pasteClipboard} disabled={computer.controlHolder!=="user"} title={t("pasteClipboard")}><ClipboardPaste/></button><button className="icon-button" onClick={copyClipboard} disabled={computer.controlHolder!=="user"||!desktopClipboard} title={t("copyDesktopClipboard")}><UseAnimations animation={copy} size={18} strokeColor="#dfdfe2"/></button><button className="icon-button" title={t("moreActions")}><Ellipsis/></button><button className="icon-button" onClick={()=>setComputerOpen(false)}><X/></button></div></header><div className="overlay-screen"><div className="overlay-desktop">{frame}{hud}</div></div>{error&&<div className="overlay-error">{error}</div>}</div>}
{teachOpen&&active&&<TeachDialog bot={active} busy={busy} close={()=>setTeachOpen(false)} start={goal=>void startTeaching(goal)}/>}
{editingSkill&&<SkillEditDialog key={editingSkill.id} skill={editingSkill} busy={busy} close={()=>setEditingSkillId(null)} save={(name,playbook)=>void updateSkill(editingSkill,name,playbook)} test={(name,playbook)=>void testSkill(editingSkill,name,playbook)} remove={()=>void deleteSkill(editingSkill)} exportFile={(name,playbook)=>downloadSkill(name,editingSkill.goal,playbook)}/>}
{clipboardOpen&&<ClipboardDialog close={()=>setClipboardOpen(false)} paste={text=>{document.querySelectorAll<HTMLIFrameElement>(".desktop-frame").forEach(frame=>frame.contentWindow?.postMessage({type:"lazyboy-host-clipboard",text},location.origin));setClipboardOpen(false)}}/>}
{clipboardOpen&&<ClipboardDialog close={()=>setClipboardOpen(false)} paste={text=>{pasteText(text);setClipboardOpen(false)}}/>}
{createOpen&&<CreateDialog close={()=>setCreateOpen(false)} created={async bot=>{setCreateOpen(false);await loadBots();setActiveId(bot.id)}}/>}
{groupOpen&&<CreateGroupDialog bots={bots} close={()=>setGroupOpen(false)} created={async room=>{setGroupOpen(false);setRooms(current=>[room,...current.filter(item=>item.id!==room.id)]);setActiveId(null);setActiveSessionId(null);setBusyMembers([]);setMessages([]);setRightPart("computer");setActiveRoomId(room.id);await loadBots()}}/>}
@ -326,6 +404,26 @@ function BotSettingsPane({bot,look,onLook,saved,onDelete}:{bot:Bot;look:AvatarLo
</form>
}
function VaultPane({bot}:{bot:Bot}){
type Account={id:string;site:string;host:string;username:string;notes:string};
const[items,setItems]=useState<Account[]>([]);const[busy,setBusy]=useState(false);const[error,setError]=useState("");
const[form,setForm]=useState({site:"",host:"",username:"",password:"",notes:""});
const load=useCallback(()=>api<Account[]>(`/api/bots/${bot.id}/accounts`).then(setItems),[bot.id]);
useEffect(()=>{load().catch(e=>setError(e instanceof Error?e.message:t("loadFailed")))},[load]);
async function run(work:()=>Promise<unknown>){setBusy(true);setError("");try{await work();await load()}catch(e){setError(e instanceof Error?e.message:t("operationFailed"))}finally{setBusy(false)}}
return <div className="memory-pane">
<p className="memory-help">{t("accountsHelp")}</p>
<form onSubmit={e=>{e.preventDefault();if(!form.site.trim()||!form.username.trim()||!form.password)return;void run(async()=>{await api(`/api/bots/${bot.id}/accounts`,{method:"POST",body:JSON.stringify(form)});setForm({site:"",host:"",username:"",password:"",notes:""})})}}>
<label>{t("accountSite")}<input value={form.site} onChange={e=>setForm({...form,site:e.target.value})} placeholder={t("accountSitePlaceholder")}/></label>
<label>{t("accountHost")}<input value={form.host} onChange={e=>setForm({...form,host:e.target.value})} placeholder={t("accountHostPlaceholder")}/></label>
<label>{t("accountUsername")}<input value={form.username} onChange={e=>setForm({...form,username:e.target.value})}/></label>
<label>{t("accountPassword")}<input type="password" value={form.password} onChange={e=>setForm({...form,password:e.target.value})} autoComplete="new-password"/></label>
<button className="primary" disabled={busy||!form.site.trim()||!form.username.trim()||!form.password}>{t("addAccount")}</button>
</form>
<div className="account-list">{items.length===0?<p>{t("noAccounts")}</p>:items.map(item=><div className="account-row" key={item.id}><strong>{item.site}</strong><small>{item.username}{item.host?` · ${item.host}`:""}</small><button className="danger-ghost" disabled={busy} onClick={()=>void run(()=>api(`/api/bots/${bot.id}/accounts/${item.id}`,{method:"DELETE"}))}>{t("delete")}</button></div>)}</div>
{error&&<div className="pane-error">{error}</div>}
</div>
}
function MemoryPane({bot,changed}:{bot:Bot;changed:()=>Promise<void>}){
const[items,setItems]=useState<MemoryItem[]>([]);const[draft,setDraft]=useState("");const[query,setQuery]=useState("");
const[enabled,setEnabled]=useState(bot.memoryEnabled);const[busy,setBusy]=useState(false);const[error,setError]=useState("");
@ -460,8 +558,11 @@ function McpCustomForm({busy,error,onBack,onSubmit}:{busy:boolean;error:string;o
</form>
}
function EmptyComputer({state}:{state:ComputerStatus["state"]}){const loading=state==="booting";return <div className="empty-computer">{loading?<UseAnimations animation={loading2} size={38} wrapperStyle={{display:"block"}}/>:<Computer/>}<strong>{stateLabel(state)}</strong><span>{loading?t("preparingDesktop"):t("computerPreviewHint")}</span></div>}
function ControlButtons({computer,busy,action,active,sessionId,onBoot,onRestart}:{computer:ComputerStatus;busy:boolean;action:(w:()=>Promise<unknown>)=>Promise<void>;active:Bot;sessionId?:string|null;onBoot?:()=>Promise<void>;onRestart?:()=>Promise<void>}){const working=Boolean(computer.busyBotName);const restart=<button className="outline restart-computer" disabled={busy} title={t("restartDocker")} onClick={()=>void action(onRestart||(()=>api(`/api/computer/${active.id}/restart`,{method:"POST",body:"{}"})))}><RefreshCw/> {t("restartDocker")}</button>;if(computer.state!=="running")return <div className="computer-actions"><button className="primary" disabled={busy||computer.state==="booting"} onClick={()=>void action(onBoot||(()=>api(`/api/computer/${active.id}/boot`,{method:"POST",body:"{}"})))}>{(busy||computer.state==="booting")&&<UseAnimations animation={loading} size={17} wrapperStyle={{display:"inline-block",verticalAlign:"middle",marginRight:7}}/>}{computer.state==="booting"?t("bootingProgress"):t("openComputer")}</button>{(computer.state==="booting"||computer.state==="error")&&restart}</div>;if(working)return <div className="computer-actions"><button className="primary" disabled={busy} onClick={()=>action(()=>api(`/api/computer/${active.id}/takeover`,{method:"POST",body:"{}"}))}>{t("takeOverNow")}</button><button className="outline" disabled={busy} onClick={()=>action(async()=>{if(sessionId)await api(`/api/sessions/${sessionId}/stop`,{method:"POST",body:"{}"});else await api(`/api/bots/${active.id}/stop`,{method:"POST",body:"{}"});})}><Square/>{t("stopTask")}</button>{restart}</div>;if(computer.controlHolder==="user")return <div className="computer-actions"><button className="outline" disabled={busy} onClick={()=>action(()=>api(`/api/computer/${active.id}/release`,{method:"POST",body:"{}"}))}>{t("releaseControl")}</button>{restart}</div>;return <div className="computer-actions"><button className="primary" disabled={busy} onClick={()=>action(()=>api(`/api/computer/${active.id}/takeover`,{method:"POST",body:"{}"}))}>{t("takeControl")}</button>{restart}</div>}
function ComputerHud({bot,label}:{bot:{id:string;name:string;avatarColor?:string;avatarShape?:AvatarShape};label:string}){
return <div className="computer-hud" role="status" aria-label={`${bot.name}${label}`}><span className="computer-signal" aria-hidden="true"><span className="computer-signal-face"><i/><i/></span></span><span className="computer-hud-label">{label}</span></div>
}
function EmptyComputer({state}:{state:ComputerStatus["state"]}){if(state==="booting"||state==="suspended")return <div className="empty-computer is-waiting" aria-hidden="true"/>;return <div className="empty-computer"><Computer/><strong>{stateLabel(state)}</strong><span>{t("computerPreviewHint")}</span></div>}
function ControlButtons({computer,busy,action,active,sessionId,onBoot,onRestart}:{computer:ComputerStatus;busy:boolean;action:(w:()=>Promise<unknown>)=>Promise<void>;active:Bot;sessionId?:string|null;onBoot?:()=>Promise<void>;onRestart?:()=>Promise<void>}){const working=Boolean(computer.usingComputer);const restart=<button className="outline restart-computer" disabled={busy} title={t("restartDocker")} onClick={()=>void action(onRestart||(()=>api(`/api/computer/${active.id}/restart`,{method:"POST",body:"{}"})))}><RefreshCw/> {t("restartDocker")}</button>;if(computer.state!=="running")return <div className="computer-actions"><button className="primary" disabled={busy||computer.state==="booting"} onClick={()=>void action(onBoot||(()=>api(`/api/computer/${active.id}/boot`,{method:"POST",body:"{}"})))}>{(busy||computer.state==="booting")&&<UseAnimations animation={loading} size={17} wrapperStyle={{display:"inline-block",verticalAlign:"middle",marginRight:7}}/>}{computer.state==="booting"?t("bootingProgress"):t("openComputer")}</button>{(computer.state==="booting"||computer.state==="error")&&restart}</div>;if(working)return <div className="computer-actions"><button className="primary" disabled={busy} onClick={()=>action(()=>api(`/api/computer/${active.id}/takeover`,{method:"POST",body:"{}"}))}>{t("takeOverNow")}</button><button className="outline" disabled={busy} onClick={()=>action(async()=>{if(sessionId)await api(`/api/sessions/${sessionId}/stop`,{method:"POST",body:"{}"});else await api(`/api/bots/${active.id}/stop`,{method:"POST",body:"{}"});})}><Square/>{t("stopTask")}</button>{restart}</div>;if(computer.controlHolder==="user")return <div className="computer-actions"><button className="outline" disabled={busy} onClick={()=>action(()=>api(`/api/computer/${active.id}/release`,{method:"POST",body:"{}"}))}>{t("releaseControl")}</button>{restart}</div>;return <div className="computer-actions"><button className="primary" disabled={busy} onClick={()=>action(()=>api(`/api/computer/${active.id}/takeover`,{method:"POST",body:"{}"}))}>{t("takeControl")}</button>{restart}</div>}
function ControlBar(props:{active:Bot;computer:ComputerStatus;busy:boolean;action:(w:()=>Promise<unknown>)=>Promise<void>;paste:()=>void;copy:()=>void;sessionId?:string|null;onBoot?:()=>Promise<void>;onRestart?:()=>Promise<void>}){const interactive=props.computer.controlHolder==="user";return <div className="control-bar"><ControlButtons {...props}/><button className="icon-button" disabled={!interactive} onClick={props.paste}><ClipboardPaste/></button><button className="icon-button" disabled={!interactive} onClick={props.copy}><UseAnimations animation={copy} size={18} strokeColor="#dfdfe2"/></button></div>}
function TeachDialog({bot,busy,close,start}:{bot:Bot;busy:boolean;close:()=>void;start:(goal:string)=>void}){
const [goal,setGoal]=useState("");

View File

@ -1,4 +1,4 @@
import UseAnimations from "react-useanimations";
import UseAnimations from "./use-animations";
import type { Animation } from "react-useanimations/utils";
import activity from "react-useanimations/lib/activity";
import archive from "react-useanimations/lib/archive";

31
apps/web/src/avatar.css Normal file
View File

@ -0,0 +1,31 @@
.avatar{flex:0 0 auto;width:36px;height:36px;display:grid;place-items:center;border-radius:50%;background:#26262a;color:#c9c9ce;font-weight:650}
.avatar.online{background:var(--accent);color:#083f34;box-shadow:inset 0 0 0 8px rgba(0,0,0,.16)}
.topbar .avatar{width:32px;height:32px}
.avatar.blobatar,.avatar.blobatar.online{position:relative;display:inline-grid;place-items:center;flex:0 0 var(--avatar-size);width:var(--avatar-size);height:var(--avatar-size);overflow:visible;border:0;border-radius:0;background:transparent;box-shadow:none;color:unset}
.avatar.blobatar>svg,.avatar.blobatar>img{display:block;width:100%;height:100%}
.avatar.blobatar.thinking:before,.avatar.blobatar.thinking:after{content:"";position:absolute;z-index:4;inset:-6px;border-radius:50%;background:conic-gradient(from 0deg,transparent 0 8%,#ff4fd8 11%,#7c5cff 18%,transparent 25% 43%,#34d9ff 48%,#58f39a 55%,transparent 62% 78%,#ffe66d 82%,#ff7a59 89%,transparent 96%);-webkit-mask:radial-gradient(farthest-side,transparent calc(100% - 2px),#000 calc(100% - 1.5px));mask:radial-gradient(farthest-side,transparent calc(100% - 2px),#000 calc(100% - 1.5px));pointer-events:none;animation:magic-orbit 1.35s linear infinite}
.avatar.blobatar.thinking:after{inset:-8px;opacity:.45;filter:blur(3px);animation-duration:2.1s;animation-direction:reverse}
.avatar-wrap{position:relative;display:grid;flex:0 0 auto}
.avatar-editor{display:grid;justify-items:center;gap:5px;padding:18px 0 2px}
.avatar-editor strong{margin-top:7px}
.avatar-editor .avatar.blobatar{margin-bottom:2px}
.avatar .presence{position:absolute;z-index:6;right:-1px;bottom:-1px;width:clamp(8px,calc(var(--avatar-size)*0.25),11px);height:clamp(8px,calc(var(--avatar-size)*0.25),11px);min-width:8px;min-height:8px;border:2px solid var(--side,#0b0b0c);border-radius:50%;background:#22c55e;box-shadow:0 0 0 1px rgba(0,0,0,.4);pointer-events:none}
.bot-row .avatar-wrap::after{content:none;position:absolute;z-index:9;right:-2px;bottom:-2px;width:9px;height:9px;border:2px solid var(--side);border-radius:50%;background:#22c55e;box-shadow:0 0 0 1px rgba(0,0,0,.38);pointer-events:none}
.avatar.blobatar.thinking{animation:avatar-rainbow-glow 1.8s linear infinite}
.avatar-wrap{transition:transform .16s ease}
.avatar-stack{position:relative;display:block;flex:0 0 auto}
.avatar-stack .stack-item{position:absolute;top:0}
.avatar-stack .stack-item .avatar{box-shadow:none;filter:drop-shadow(2px 0 0 var(--side,#0b0b0c)) drop-shadow(-2px 0 0 var(--side,#0b0b0c)) drop-shadow(0 2px 0 var(--side,#0b0b0c)) drop-shadow(0 -2px 0 var(--side,#0b0b0c))}
.avatar-stack .stack-item .avatar.thinking{filter:none;animation:avatar-rainbow-glow 1.8s linear infinite}
.stack-extra{position:absolute;top:0;display:grid;place-items:center;border-radius:50%;background:#2a2a2e;color:var(--muted);font-size:11px;font-weight:650;box-shadow:0 0 0 2px var(--side,#0b0b0c)}
.topbar .avatar-stack{margin-right:2px}
.avatar-stack{isolation:isolate}
.avatar-stack .stack-item{transition:transform .16s ease}
.avatar-stack .stack-item:hover{transform:translateY(-2px);z-index:8!important}
.avatar-stack .stack-item .avatar{filter:drop-shadow(2px 0 0 var(--main,#0d0d0e)) drop-shadow(-2px 0 0 var(--main,#0d0d0e)) drop-shadow(0 2px 0 var(--main,#0d0d0e)) drop-shadow(0 -2px 0 var(--main,#0d0d0e))}
.avatar-stack .stack-extra{box-shadow:0 0 0 2px var(--main,#0d0d0e)}
.avatar-stack .stack-item .avatar.thinking{filter:none}
/* Keep avatars independent of message and toolbar dimensions. */
.avatar.blobatar{min-width:var(--avatar-size);max-width:var(--avatar-size);min-height:var(--avatar-size);padding:0;line-height:0;vertical-align:middle}
.avatar.blobatar>svg{max-width:none}
.avatar-stack .stack-item{line-height:0}

59
apps/web/src/chat.css Normal file
View File

@ -0,0 +1,59 @@
.messages{flex:1;overflow:auto;padding:34px max(34px,7vw) 150px;display:flex;flex-direction:column;gap:18px}
.message{display:flex}
.message>.message-body{max-width:76%;padding:13px 17px;border-radius:22px;white-space:pre-wrap;line-height:1.5}
.message.user{justify-content:flex-end}
.message.user>.message-body{background:var(--cream);color:#1a1a1a}
.message.assistant>.message-body{background:#19191c}
.composer{position:absolute;left:max(34px,5vw);right:max(34px,5vw);bottom:22px;display:flex;align-items:flex-end;gap:8px;border:1px solid var(--border);background:#121214;border-radius:26px;padding:8px;box-shadow:0 16px 50px rgba(0,0,0,.22)}
.composer textarea{flex:1;min-height:42px;max-height:140px;resize:none;border:0;outline:0;background:transparent;color:var(--ink);padding:11px 4px}
.composer-plus{background:transparent;color:var(--muted)}
.composer svg{width:18px}
.messages{min-width:0;padding-bottom:128px;padding-left:var(--chat-gutter);padding-right:var(--chat-gutter)}
.message{box-sizing:border-box;width:var(--chat-col);max-width:none;margin-inline:auto;overflow-wrap:anywhere}
.message>span{max-width:82%}
.thinking-row{display:flex;align-items:center;gap:10px;width:min(760px,100%);margin:0}
.thinking-dots{position:relative;display:flex;align-items:center;gap:4px;height:32px;padding:0 12px;border:1px solid var(--border);border-radius:14px;background:var(--surface);overflow:hidden}
.thinking-dots:after{content:"";position:absolute;left:12px;bottom:4px;width:24px;height:2px;border-radius:999px;background:linear-gradient(90deg,#7c5cff,#34d9ff,#58f39a,#ffe66d,#ff73dc,#7c5cff);background-size:200% 100%;animation:small-magic-line 1.25s linear infinite}
.thinking-dots i{width:5px;height:5px;border-radius:50%;background:var(--muted);animation:thinking-dot 1.15s ease-in-out infinite}
.thinking-dots i:nth-child(2){animation-delay:.16s}
.thinking-dots i:nth-child(3){animation-delay:.32s}
.composer-dock{position:absolute;left:0;right:0;bottom:0;z-index:6;display:flex;flex-direction:column;align-items:center;gap:20px;padding:28px var(--chat-gutter) 20px;background:var(--main);pointer-events:none}
.composer-dock:before{content:"";position:absolute;left:0;right:0;bottom:100%;height:32px;background:linear-gradient(180deg,transparent,var(--main));pointer-events:none}
.composer-dock>*{box-sizing:border-box;pointer-events:auto;width:var(--chat-col);max-width:760px;margin-inline:auto}
.composer{position:relative;left:auto;right:auto;bottom:auto;width:var(--chat-col);min-height:62px;align-items:center;padding:9px 10px;transform:none}
.composer.has-files{flex-wrap:wrap;align-items:flex-end;padding-top:10px}
.composer textarea{align-self:center;box-sizing:border-box;height:42px;min-height:42px;max-height:126px;padding:11px 4px;line-height:20px}
.composer-files{display:flex;flex-wrap:wrap;gap:8px;flex:1 0 100%;order:-1;padding:2px 8px 10px 46px}
.file-card{display:flex;align-items:center;gap:8px;max-width:min(240px,100%);height:48px;padding:2px 8px 2px 2px;border:1px solid #2a2a2a;border-radius:14px;background:#1a1a1a;color:var(--ink)}
.file-card-thumb,.file-card-badge{flex:0 0 44px;width:44px;height:44px;border-radius:11px;object-fit:cover}
.file-card-badge{display:grid;place-items:center;background:#2a2a2a;color:#c8c8c8;font-size:10px;font-weight:700;letter-spacing:.04em}
.file-card-meta{display:grid;min-width:0;flex:1}
.file-card-meta strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:600}
.file-card-meta small{color:var(--muted);font-size:11px}
.file-card-remove{flex:0 0 28px;width:28px;height:28px;display:grid;place-items:center;border:0;border-radius:8px;background:transparent;color:var(--muted);cursor:pointer}
.file-card-remove:hover{color:var(--ink);background:rgba(255,255,255,.08)}
.file-card-remove svg{width:14px;height:14px}
.message.with-files{align-items:flex-end;flex-direction:column;gap:8px}
.message.with-files .msg-attachments{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px;max-width:min(420px,82%)}
.message.with-files .message-body{max-width:82%}
.composer-plus,.composer .send{box-sizing:border-box;flex:0 0 42px;width:42px;height:42px;margin:0;align-self:center}
.stop-send{background:#f2f2f2;color:#151515}
.stop-send svg{width:14px;height:14px;fill:currentColor}
.composer-plus:disabled{opacity:.35;cursor:not-allowed}
.message{position:relative;padding-right:28px}
.messages .remember-msg{display:none}
.working-label{font-size:13px;letter-spacing:.02em;line-height:1.35;white-space:nowrap;background:linear-gradient(90deg,#7a8088 0%,#7a8088 28%,#fff 50%,#7a8088 72%,#7a8088 100%);background-size:220% 100%;-webkit-background-clip:text;background-clip:text;color:transparent;animation:working-shimmer 1.35s linear infinite}
.message.assistant.spoken{display:grid;grid-template-columns:28px minmax(0,1fr);column-gap:10px;row-gap:3px;justify-content:start;align-items:end}
.message.assistant.spoken .msg-avatar{grid-column:1;grid-row:2;align-self:start;max-width:none;margin:4px 0 0;padding:0;border-radius:0;background:transparent;line-height:normal}
.message.assistant .msg-avatar>.avatar.blobatar{max-width:none;padding:0;background:transparent;color:inherit;line-height:normal;white-space:normal}
.message.assistant.spoken .speaker{grid-column:2;font-size:12px;font-weight:650;line-height:1.2;padding:0 6px;color:var(--accent)}
.message.assistant.spoken>span{grid-column:2;max-width:82%;border-radius:6px 18px 18px 18px}
.thinking-row{width:100%;margin:0;padding-left:2px}
.working-copy{display:flex;flex-direction:column;gap:8px;min-width:0}
.working-step{color:var(--muted);font-size:12px;letter-spacing:.02em;line-height:1.35}
/* Speaker rows: the coloured speaker label must breathe away from the text. */
.message.assistant.spoken{column-gap:11px;row-gap:6px}
.message.assistant.spoken>.msg-avatar{max-width:none;padding:0;border-radius:0;background:transparent}
.message.assistant.spoken .speaker{padding:0 2px;letter-spacing:.01em}
.message.assistant.spoken>.message-body{grid-column:2;max-width:82%;line-height:1.52;border-radius:6px 18px 18px 18px}
.composer-plus.open{color:var(--ink);background:rgba(255,255,255,.08)}

57
apps/web/src/computer.css Normal file
View File

@ -0,0 +1,57 @@
.computer-panel{display:flex;flex-direction:column;background:var(--panel);border-right:0;padding:0 20px}
.state-dot{width:7px;height:7px;border-radius:50%;background:var(--faint)}
.state-dot.running{background:var(--success)}
.state-dot.booting{background:#f5a03c;animation:pulse 1s infinite}
.state-dot.error{background:var(--danger)}
.desktop-frame{display:block;width:100%;height:100%;border:0;background:#101012}
.empty-computer{height:100%;display:grid;place-content:center;justify-items:center;gap:7px;color:var(--muted);text-align:center}
.empty-computer svg{width:28px}
.empty-computer span{font-size:12px}
.computer-caption{display:flex;align-items:center;justify-content:space-between;padding:14px 0;color:var(--muted)}
.control-bar{display:flex;align-items:center;justify-content:flex-end;gap:8px;border-top:1px solid #171719;padding:16px 0}
.computer-overlay{position:fixed;inset:0;z-index:50;display:flex;flex-direction:column;background:rgba(4,4,5,.98)}
.computer-overlay>header{height:64px;display:flex;align-items:center;justify-content:space-between;padding:0 18px;border-bottom:1px solid var(--line)}
.computer-overlay>header>div{display:flex;align-items:center;gap:10px}
.computer-overlay .avatar{width:30px;height:30px}
.control-badge{padding:5px 10px;border-radius:999px;background:rgba(48,162,75,.14);color:var(--success);font-size:13px}
.overlay-screen{flex:1;min-height:0;padding:20px;display:flex;justify-content:center}
.overlay-screen>.desktop-frame,.overlay-screen>.empty-computer{width:min(100%,1440px);height:100%;border:1px solid var(--border);border-radius:14px;overflow:hidden;background:#0d0d0e}
.overlay-error{position:absolute;top:74px;left:50%;transform:translateX(-50%);background:#2a1717;color:#fca5a5;padding:9px 15px;border-radius:9px}
.computer-toggle{background:var(--surface)}
.computer-part{display:flex;flex-direction:column;min-height:0;flex:1;gap:10px;overflow:auto}
.computer-part.hidden-part{display:none}
.computer-status-row{display:flex;align-items:center;gap:8px;color:var(--muted)}
.computer-status-row>span{margin-right:auto;color:var(--ink);font-weight:600}
.computer-part .preview{flex:1;min-height:160px;aspect-ratio:auto}
/* Preserve the monitor's 16:10 shape as the right panel gets narrower. */
.computer-part .preview{position:relative;flex:0 1 auto;width:100%;height:auto;min-height:0;max-height:min(52vh,380px);aspect-ratio:16 / 10}
.computer-part .preview .desktop-frame{display:block;aspect-ratio:16 / 10}
.overlay-screen .overlay-desktop{position:relative;width:min(100%,1440px);height:100%}
.overlay-screen .overlay-desktop>.desktop-frame,.overlay-screen .overlay-desktop>.empty-computer{width:100%;height:100%;border:1px solid var(--border);border-radius:14px;overflow:hidden;background:#0d0d0e}
.computer-hud{position:absolute;top:10px;left:10px;z-index:3;display:grid;justify-items:center;gap:6px;padding:10px 12px 8px;border-radius:16px;background:rgba(10,10,12,.72);backdrop-filter:blur(8px);pointer-events:none;box-shadow:0 10px 30px rgba(0,0,0,.35)}
.computer-hud .avatar.blobatar.thinking{animation:computer-hud-bob 1.6s ease-in-out infinite,avatar-rainbow-glow 1.8s linear infinite}
.computer-hud-label{font-size:12px;letter-spacing:.02em;line-height:1.3;white-space:nowrap;background:linear-gradient(90deg,#7a8088 0%,#7a8088 28%,#fff 50%,#7a8088 72%,#7a8088 100%);background-size:220% 100%;-webkit-background-clip:text;background-clip:text;color:transparent;animation:working-shimmer 1.35s linear infinite}
.empty-computer.is-waiting{min-height:100%;background:#101012}
.computer-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;min-width:0;flex-wrap:wrap}
.computer-actions .restart-computer{font-size:12px;padding-inline:10px;color:var(--muted)}
.computer-actions .restart-computer svg{width:14px;height:14px}
.computer-actions .primary{min-width:0}
.computer-overlay header .computer-actions{justify-content:flex-end}
.control-badge.teaching{background:rgba(239,85,85,.16);color:#ff8a8a}
.computer-login-hint{margin:0;color:var(--faint);font-size:12px;line-height:1.45}
.computer-part{scrollbar-gutter:stable;overflow-x:hidden}
.computer-part>*{flex-shrink:0;min-width:0}
.computer-part .preview{flex:none;max-height:none}
.computer-status-row>span{min-width:0;overflow-wrap:anywhere}
.computer-status-row>small,.state-dot{flex-shrink:0}
.control-bar{flex-wrap:wrap}
.control-bar>.computer-actions{flex:1 1 180px}
/* Independent monitor mascot, with mint halo and amber orbit. */
.computer-hud{inset:0;align-content:center;border-radius:inherit;gap:14px;background:radial-gradient(ellipse at center,#142722e8,#101012ed);box-shadow:none}
.computer-signal{position:relative;display:grid;place-items:center;width:64px;height:64px;border:1px solid #65dcb34d;border-radius:24px;animation:monitor-breathe 2.8s ease-in-out infinite}
.computer-signal::before{content:"";position:absolute;inset:-8px;border:1px solid #65dcb326;border-top-color:#efbe72;border-radius:50%;animation:monitor-orbit 4s linear infinite}
.computer-signal-face{display:flex;align-items:center;justify-content:center;gap:12px;width:46px;height:38px;border-radius:15px;background:#85dfbb;color:#143429;transform:rotate(-6deg)}
.computer-signal-face i{width:5px;height:11px;border-radius:5px;background:currentColor;animation:monitor-blink 4.6s ease-in-out infinite}
.computer-hud-label{color:#c9ddd5;background:none;animation:none;white-space:normal;text-align:center;max-width:90%;line-height:1.5}
.clipboard-status{margin:0;min-height:1.4em;font-size:12px;line-height:1.4;color:var(--muted)}

View File

@ -63,6 +63,7 @@ export const zhTW = {
mcpConnectNamed: "接入 {name}", mcpKeyHint: "這個 MCP 需要憑證才能連。",
noMcp: "還沒有 MCP。點上面的「選擇 MCP」從市集接入。", toolsCount: "{count} 個工具", disabled: "已關閉", disconnected: "未連線", noTools: "沒有可用工具", reconnect: "重新連線", disable: "停用", enable: "啟用",
preparingDesktop: "正在準備 Agent 的獨立桌面…", computerPreviewHint: "開啟電腦後,畫面會顯示在這裡。", bootingProgress: "啟動中…", restartDocker: "重啟 Docker", stopAndTakeOver: "停止並接管",
hudBooting: "電腦啟動中…", hudWaking: "喚醒中…", hudConnecting: "連線中…", hudHandoff: "換手中…",
pasteToRemoteComputer: "貼到遠端電腦", pasteRemoteHelp: "把外面的文字貼在這裡,再送進 VNC。這個方式在區網 HTTP 也能使用。", pasteTextPlaceholder: "在此貼上文字…", pasteIntoVnc: "貼入 VNC",
botNamePlaceholder: "例如:研究助理", sharedComputerHint: "與其他機器人共用環境", privateComputerHint: "全新的獨立 Docker", create: "建立",
groupDescription: "拉進群組會開一個對話,選中的 Agent 都會在裡面發言。", groupName: "群組名稱", groupNamePlaceholder: "例如:產品研究", chooseBots: "選擇機器人(至少兩位)", creating: "建立中…", createGroup: "建立群組",
@ -96,4 +97,18 @@ export const zhTW = {
agentComputer: "Agent 電腦", url: "URL", stdio: "stdio", http: "HTTP", sse: "SSE",
argumentsPlaceholder: "-y @modelcontextprotocol/server-github", environmentVariablesPlaceholder: "GITHUB_TOKEN=…",
urlPlaceholder: "https://mcp.example.com/mcp", headersPlaceholder: "Authorization=Bearer …",
back: "返回", accounts: "帳號", accountsHelp: "存在這台機器人自己的保險箱。密碼只在這裡填,不會進聊天、也不會給模型看。排程碰到登入牆時會自動填表。",
accountSite: "站名", accountSitePlaceholder: "例如Gmail", accountHost: "網址或網域", accountHostPlaceholder: "mail.google.com",
accountUsername: "帳號", accountPassword: "密碼", accountPasswordKeep: "留空表示不改密碼", accountNotes: "備註",
addAccount: "新增帳號", noAccounts: "還沒有已存帳號。排程若要自動登入,先在這裡加一筆。",
loginNeedsYou: "需要你在鍵盤上登入", loginOpenScreen: "打開它的畫面", loginWhy: "登入之後:{why}",
scheduleChip: "已排程", scheduleRunChip: "排程執行",
schedules: "排程", schedule: "排程", schedCreate: "新增排程", schedEmpty: "還沒有排程。對話裡說「以後每天 9 點…」或按+。",
schedPaused: "已暫停", schedRunNow: "立刻跑", schedRunning: "執行中…", schedActive: "啟用",
schedNamePlaceholder: "例如:早報摘要", schedInstruction: "每次要做什麼",
schedInstructionPlaceholder: "例如:打開信箱,把未讀摘要寫回對話。",
schedWhen: "何時執行", schedEveryHour: "每小時", schedEveryDay: "每天", schedWeekdays: "工作日",
schedEveryWeek: "每週一", schedEveryMonth: "每月", schedInterval: "間隔", schedAdvanced: "進階 cron",
schedMinutes: "分鐘", schedHours: "小時", schedDays: "天",
computerLoginHint: "在這台機器人自己的瀏覽器登入。Session 留在它的電腦,不要把密碼打在聊天裡。",
} as const;

View File

@ -4,5 +4,10 @@ import "blobatar/motion.css";
import "blobatar/gaze.css";
import "./styles.css";
import "./refinements.css";
import "./avatar.css";
import "./chat.css";
import "./computer.css";
import "./schedule.css";
import "./responsive.css";
import { App } from "./App";
ReactDOM.createRoot(document.getElementById("root")!).render(<App />);

View File

@ -1,347 +1,879 @@
.memory-dialog{max-height:85vh;overflow:auto}.memory-dialog form{display:flex;gap:10px;align-items:end}.memory-dialog form label{flex:1}.memory-list{display:grid;gap:8px;margin:14px 0}.memory-row{display:grid;gap:5px;padding:10px;border:1px solid var(--border,#ddd);border-radius:10px}.memory-row small{opacity:.65}.memory-row>div{display:flex;gap:8px}.memory-row textarea{box-sizing:border-box;width:100%;resize:vertical;border:1px solid var(--border);border-radius:10px;background:#0c0c0d;color:var(--ink);padding:11px;font:inherit}
.memory-dialog{max-height:85vh;overflow:auto}
.memory-dialog form{display:flex;gap:10px;align-items:end}
.memory-dialog form label{flex:1}
.memory-list{display:grid;gap:8px;margin:14px 0}
.memory-row{display:grid;gap:5px;padding:10px;border:1px solid var(--border,#ddd);border-radius:10px}
.memory-row small{opacity:.65}
.memory-row>div{display:flex;gap:8px}
.memory-row textarea{box-sizing:border-box;width:100%;resize:vertical;border:1px solid var(--border);border-radius:10px;background:#0c0c0d;color:var(--ink);padding:11px;font:inherit}
.app-shell{grid-template-columns:clamp(220px,18vw,280px) minmax(0,1fr) clamp(320px,28vw,420px)}
.sidebar,.chat-panel,.computer-panel,.topbar,.panel-head,.control-bar{min-width:0}
.session-picker select{max-width:190px;min-width:90px;padding:7px 28px 7px 10px;border:1px solid var(--border);border-radius:9px;color:var(--ink);background:var(--surface)}
.avatar.blobatar,.avatar.blobatar.online{position:relative;display:inline-grid;place-items:center;flex:0 0 var(--avatar-size);width:var(--avatar-size);height:var(--avatar-size);overflow:visible;border:0;border-radius:0;background:transparent;box-shadow:none;color:unset}
.avatar.blobatar>svg,.avatar.blobatar>img{display:block;width:100%;height:100%}
.avatar.blobatar.thinking:before,.avatar.blobatar.thinking:after{content:"";position:absolute;z-index:4;inset:-6px;border-radius:50%;background:conic-gradient(from 0deg,transparent 0 8%,#ff4fd8 11%,#7c5cff 18%,transparent 25% 43%,#34d9ff 48%,#58f39a 55%,transparent 62% 78%,#ffe66d 82%,#ff7a59 89%,transparent 96%);-webkit-mask:radial-gradient(farthest-side,transparent calc(100% - 2px),#000 calc(100% - 1.5px));mask:radial-gradient(farthest-side,transparent calc(100% - 2px),#000 calc(100% - 1.5px));pointer-events:none;animation:magic-orbit 1.35s linear infinite}.avatar.blobatar.thinking:after{inset:-8px;opacity:.45;filter:blur(3px);animation-duration:2.1s;animation-direction:reverse}
.magic-particles{position:absolute;z-index:4;inset:-12px;pointer-events:none;animation:magic-orbit 2.4s linear infinite}.magic-particles i{position:absolute;width:4px;height:4px;border-radius:50%;background:#fff;box-shadow:0 0 7px 2px #6df,0 0 12px #d5f}.magic-particles i:nth-child(1){top:0;left:48%}.magic-particles i:nth-child(2){right:0;top:45%;background:#ffe66d}.magic-particles i:nth-child(3){bottom:1px;left:35%;background:#ff73dc}.magic-particles i:nth-child(4){left:0;top:30%;background:#79ffb0}
.messages{min-width:0}
.message{box-sizing:border-box;width:min(760px,100%);max-width:none;margin-inline:auto;overflow-wrap:anywhere}
.message>span{max-width:82%}
.thinking-row{display:flex;align-items:center;gap:10px;width:min(760px,100%);margin:4px auto 18px}
.thinking-dots{position:relative;display:flex;align-items:center;gap:4px;height:32px;padding:0 12px;border:1px solid var(--border);border-radius:14px;background:var(--surface);overflow:hidden}.thinking-dots:after{content:"";position:absolute;left:12px;bottom:4px;width:24px;height:2px;border-radius:999px;background:linear-gradient(90deg,#7c5cff,#34d9ff,#58f39a,#ffe66d,#ff73dc,#7c5cff);background-size:200% 100%;animation:small-magic-line 1.25s linear infinite}
.thinking-dots i{width:5px;height:5px;border-radius:50%;background:var(--muted);animation:thinking-dot 1.15s ease-in-out infinite}.thinking-dots i:nth-child(2){animation-delay:.16s}.thinking-dots i:nth-child(3){animation-delay:.32s}
.composer{left:50%;right:auto;width:min(760px,calc(100% - 48px));min-height:62px;align-items:center;padding:9px 10px;transform:translateX(-50%)}
.composer.has-files{flex-wrap:wrap;align-items:flex-end;padding-top:10px}
.composer textarea{align-self:center;box-sizing:border-box;height:42px;min-height:42px;max-height:126px;padding:11px 4px;line-height:20px}
.composer-files{display:flex;flex-wrap:wrap;gap:8px;flex:1 0 100%;order:-1;padding:2px 8px 10px 46px}
.file-card{display:flex;align-items:center;gap:8px;max-width:min(240px,100%);height:48px;padding:2px 8px 2px 2px;border:1px solid #2a2a2a;border-radius:14px;background:#1a1a1a;color:var(--ink)}
.file-card-thumb,.file-card-badge{flex:0 0 44px;width:44px;height:44px;border-radius:11px;object-fit:cover}
.file-card-badge{display:grid;place-items:center;background:#2a2a2a;color:#c8c8c8;font-size:10px;font-weight:700;letter-spacing:.04em}
.file-card-meta{display:grid;min-width:0;flex:1}
.file-card-meta strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:600}
.file-card-meta small{color:var(--muted);font-size:11px}
.file-card-remove{flex:0 0 28px;width:28px;height:28px;display:grid;place-items:center;border:0;border-radius:8px;background:transparent;color:var(--muted);cursor:pointer}
.file-card-remove:hover{color:var(--ink);background:rgba(255,255,255,.08)}
.file-card-remove svg{width:14px;height:14px}
.message.with-files{align-items:flex-end;flex-direction:column;gap:8px}
.message.with-files .msg-attachments{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px;max-width:min(420px,82%)}
.message.with-files .message-body{max-width:82%}
.composer-plus,.composer .send{box-sizing:border-box;flex:0 0 42px;width:42px;height:42px;margin:0;align-self:center}
.stop-send{background:#f2f2f2;color:#151515}.stop-send svg{width:14px;height:14px;fill:currentColor}
.spinner{width:17px;height:17px;animation:spin .8s linear infinite}.spinner.large{width:38px;height:38px;color:var(--accent)}.primary .spinner{margin-right:7px}.primary{display:inline-flex;align-items:center;justify-content:center}
.clipboard-text{box-sizing:border-box;width:100%;min-height:150px;resize:vertical;border:1px solid var(--border);border-radius:12px;background:#0c0c0d;color:var(--text);padding:12px;font:inherit;line-height:1.5;outline:none}.clipboard-text:focus{border-color:var(--accent)}
.magic-particles{position:absolute;z-index:4;inset:-12px;pointer-events:none;animation:magic-orbit 2.4s linear infinite}
.magic-particles i{position:absolute;width:4px;height:4px;border-radius:50%;background:#fff;box-shadow:0 0 7px 2px #6df,0 0 12px #d5f}
.magic-particles i:nth-child(1){top:0;left:48%}
.magic-particles i:nth-child(2){right:0;top:45%;background:#ffe66d}
.magic-particles i:nth-child(3){bottom:1px;left:35%;background:#ff73dc}
.magic-particles i:nth-child(4){left:0;top:30%;background:#79ffb0}
.chat-panel{--chat-gutter:max(34px,7vw);--chat-col:min(760px,100%)}
.chat-panel:has(.composer-dock.has-status) .messages{padding-bottom:188px}
.spinner{width:17px;height:17px;animation:spin .8s linear infinite}
.spinner.large{width:38px;height:38px;color:var(--accent)}
.primary .spinner{margin-right:7px}
.primary{display:inline-flex;align-items:center;justify-content:center}
.clipboard-text{box-sizing:border-box;width:100%;min-height:150px;resize:vertical;border:1px solid var(--border);border-radius:12px;background:#0c0c0d;color:var(--text);padding:12px;font:inherit;line-height:1.5;outline:none}
.clipboard-text:focus{border-color:var(--accent)}
.bot-tag{padding:3px 8px;border:1px solid var(--border);border-radius:999px;color:var(--muted);font-size:11px;white-space:nowrap}
.bot-row{gap:9px}.bot-copy{display:grid;min-width:0;flex:1}.bot-copy strong,.bot-copy small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.side-tag{max-width:64px;overflow:hidden;text-overflow:ellipsis;flex:0 1 auto;padding:2px 6px;color:var(--text);background:rgba(255,255,255,.055)}
.avatar-wrap{position:relative;display:grid;flex:0 0 auto}.unread-dot{position:absolute;z-index:8;top:-2px;right:-3px;width:9px;height:9px;border:2px solid var(--sidebar,#0b0b0c);border-radius:50%;background:#35d07f;box-shadow:0 0 8px rgba(53,208,127,.75)}.bot-group{display:grid;gap:3px}.group-label{padding:12px 11px 4px;color:var(--muted);font-size:11px;letter-spacing:.04em}.row-time{align-self:start;color:var(--muted);font-size:10px;white-space:nowrap}.row-pin{width:13px;height:13px;color:var(--muted);transform:rotate(-20deg)}.hidden-toggle{display:flex;align-items:center;gap:7px;margin:4px 12px;padding:8px;border:0;background:transparent;color:var(--muted);font:inherit;font-size:12px;cursor:pointer}.hidden-toggle svg{width:14px}
.context-menu{position:fixed;z-index:100;display:grid;width:210px;padding:6px;border:1px solid var(--border);border-radius:13px;background:#18181b;box-shadow:0 18px 60px rgba(0,0,0,.55)}.context-menu button{display:flex;align-items:center;gap:10px;width:100%;height:38px;padding:0 10px;border:0;border-radius:8px;background:transparent;color:var(--text);font:inherit;text-align:left;cursor:pointer}.context-menu button:hover{background:rgba(255,255,255,.07)}.context-menu button svg{width:16px;height:16px}.context-menu hr{width:100%;margin:5px 0;border:0;border-top:1px solid var(--border)}.context-menu .danger-item{color:#ff7777}.context-menu .context-close{display:none}
.settings-dialog{width:min(520px,calc(100vw - 28px));max-height:min(850px,calc(100dvh - 28px));overflow:auto}.avatar-editor{display:grid;justify-items:center;gap:5px;padding:18px 0 2px}.avatar-editor strong{margin-top:7px}.avatar-editor small,.settings-dialog label small{color:var(--muted)}.settings-dialog fieldset{margin:0;padding:0;border:0}.settings-dialog legend{margin-bottom:9px;color:var(--muted);font-size:13px}.color-grid,.shape-grid{display:flex;gap:10px;flex-wrap:wrap}.color-grid button,.custom-color{position:relative;box-sizing:border-box;width:34px;height:34px;border:2px solid transparent;border-radius:50%;cursor:pointer}.color-grid button.selected{outline:2px solid var(--text);outline-offset:2px}.custom-color{display:grid;place-items:center;overflow:hidden;border:1px dashed var(--muted)}.custom-color input{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer}.custom-color span{font-size:20px}.shape-grid button{display:grid;place-items:center;width:54px;height:54px;border:1px solid var(--border);border-radius:12px;background:transparent}.shape-grid button.selected{border-color:var(--accent);background:rgba(62,197,168,.08)}.settings-dialog textarea{box-sizing:border-box;width:100%;resize:vertical;border:1px solid var(--border);border-radius:10px;background:#0c0c0d;color:var(--text);padding:11px;font:inherit;outline:none}.settings-dialog textarea:focus{border-color:var(--accent)}
.bot-row{gap:9px}
.bot-copy{display:grid;min-width:0;flex:1}
.bot-copy strong,.bot-copy small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.side-tag{max-width:64px;overflow:hidden;text-overflow:ellipsis;flex:0 1 auto;padding:2px 6px;color:var(--text);background:rgba(255,255,255,.055)}
.unread-dot{position:absolute;z-index:8;top:-2px;right:-3px;width:9px;height:9px;border:2px solid var(--sidebar,#0b0b0c);border-radius:50%;background:#35d07f;box-shadow:0 0 8px rgba(53,208,127,.75)}
.bot-group{display:grid;gap:3px}
.group-label{padding:12px 11px 4px;color:var(--muted);font-size:11px;letter-spacing:.04em}
.row-time{align-self:start;color:var(--muted);font-size:10px;white-space:nowrap}
.row-pin{width:13px;height:13px;color:var(--muted);transform:rotate(-20deg)}
.hidden-toggle{display:flex;align-items:center;gap:7px;margin:4px 12px;padding:8px;border:0;background:transparent;color:var(--muted);font:inherit;font-size:12px;cursor:pointer}
.hidden-toggle svg{width:14px}
.context-menu{position:fixed;z-index:100;display:grid;width:210px;padding:6px;border:1px solid var(--border);border-radius:13px;background:#18181b;box-shadow:0 18px 60px rgba(0,0,0,.55)}
.context-menu button{display:flex;align-items:center;gap:10px;width:100%;height:38px;padding:0 10px;border:0;border-radius:8px;background:transparent;color:var(--text);font:inherit;text-align:left;cursor:pointer}
.context-menu button:hover{background:rgba(255,255,255,.07)}
.context-menu button svg{width:16px;height:16px}
.context-menu hr{width:100%;margin:5px 0;border:0;border-top:1px solid var(--border)}
.context-menu .danger-item{color:#ff7777}
.context-menu .context-close{display:none}
.settings-dialog{width:min(520px,calc(100vw - 28px));max-height:min(850px,calc(100dvh - 28px));overflow:auto}
.avatar-editor small,.settings-dialog label small{color:var(--muted)}
.settings-dialog fieldset{margin:0;padding:0;border:0}
.settings-dialog legend{margin-bottom:9px;color:var(--muted);font-size:13px}
.color-grid,.shape-grid{display:flex;gap:10px;flex-wrap:wrap}
.color-grid button,.custom-color{position:relative;box-sizing:border-box;width:34px;height:34px;border:2px solid transparent;border-radius:50%;cursor:pointer}
.color-grid button.selected{outline:2px solid var(--text);outline-offset:2px}
.custom-color{display:grid;place-items:center;overflow:hidden;border:1px dashed var(--muted)}
.custom-color input{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer}
.custom-color span{font-size:20px}
.shape-grid button{display:grid;place-items:center;width:54px;height:54px;border:1px solid var(--border);border-radius:12px;background:transparent}
.shape-grid button.selected{border-color:var(--accent);background:rgba(62,197,168,.08)}
.settings-dialog textarea{box-sizing:border-box;width:100%;resize:vertical;border:1px solid var(--border);border-radius:10px;background:#0c0c0d;color:var(--text);padding:11px;font:inherit;outline:none}
.settings-dialog textarea:focus{border-color:var(--accent)}
@keyframes thinking-dot{0%,60%,100%{opacity:.35;transform:translateY(0)}30%{opacity:1;transform:translateY(-3px)}}
@keyframes spin{to{transform:rotate(360deg)}}
@keyframes magic-orbit{to{transform:rotate(360deg)}}
@keyframes avatar-rainbow-glow{0%,100%{filter:drop-shadow(-3px -1px 2px #ff4fd8) drop-shadow(3px 1px 2px #34d9ff)}33%{filter:drop-shadow(1px -3px 2px #ffe66d) drop-shadow(-1px 3px 2px #58f39a)}66%{filter:drop-shadow(3px -1px 2px #ff7a59) drop-shadow(-3px 1px 2px #7c5cff)}}
@keyframes small-magic-line{to{background-position:200% 0}}
@media(max-width:1200px){.app-shell{grid-template-columns:230px minmax(0,1fr) 330px}}
@media(max-width:1050px){.app-shell{grid-template-columns:250px minmax(0,1fr)}}
@media(max-width:700px){.app-shell{display:block}.message{width:100%}.message>span{max-width:90%}.composer{left:50%;right:auto;width:calc(100% - 24px);min-height:60px}.composer-files{padding:2px 4px 8px 8px}.file-card{max-width:100%;height:52px}.file-card-remove{flex-basis:32px;width:32px;height:32px}.message.with-files .msg-attachments{max-width:100%}}
@media(prefers-reduced-motion:reduce){.avatar.blobatar.thinking:before,.avatar.blobatar.thinking:after,.thinking-dots i{animation:none!important}}
.shape-grid button{border-color:transparent;border-radius:50%}.shape-grid button:hover{background:rgba(255,255,255,.04)}.shape-grid button.selected{border-color:#626268;box-shadow:none;background:rgba(255,255,255,.025)}
.shape-grid button{border-color:transparent;border-radius:50%}
.shape-grid button:hover{background:rgba(255,255,255,.04)}
.shape-grid button.selected{border-color:#626268;box-shadow:none;background:rgba(255,255,255,.025)}
.expr-grid,.bg-grid{display:flex;gap:8px;flex-wrap:wrap}
.expr-grid button,.bg-grid button{display:grid;justify-items:center;gap:3px;min-width:54px;padding:6px 4px 7px;border:1px solid transparent;border-radius:12px;background:transparent;color:var(--muted);font:inherit;font-size:10px;cursor:pointer}
.expr-grid button:hover,.bg-grid button:hover{background:rgba(255,255,255,.04)}
.expr-grid button.selected,.bg-grid button.selected{border-color:#626268;color:var(--ink);background:rgba(255,255,255,.025)}
.avatar-editor .avatar.blobatar{margin-bottom:2px}
.bot-list{flex:1;min-height:0}
.sidebar-bottom{display:flex;flex-direction:column;align-items:stretch;gap:4px;padding-top:10px}.sidebar-bottom .account{min-width:0;flex:1}
.sidebar-bottom{display:flex;flex-direction:column;align-items:stretch;gap:4px;padding-top:10px}
.sidebar-bottom .account{min-width:0;flex:1}
.account-wrap{position:relative;width:100%}
.account{cursor:pointer}
.workspace-avatar{display:grid;width:32px;height:32px;flex:0 0 32px;place-items:center;border-radius:50%;background:var(--surface);color:var(--muted);font-size:11px;font-weight:650;letter-spacing:.02em;box-shadow:inset 0 0 0 1px rgba(255,255,255,.05)}
.account:hover,.account.open{background:#171719}
.account .chevron{width:16px;height:16px;color:var(--muted);margin-left:auto;flex:0 0 16px;transition:transform .15s ease}
.account.open .chevron{transform:rotate(180deg)}
.account-menu{position:absolute;z-index:50;left:0;right:0;bottom:calc(100% + 8px);display:grid;padding:6px;border:1px solid var(--border);border-radius:14px;background:#18181b;box-shadow:0 18px 50px rgba(0,0,0,.55)}
.account-menu button{display:flex;align-items:center;gap:10px;height:40px;padding:0 12px;border:0;border-radius:9px;background:transparent;color:var(--ink);font:inherit;text-align:left;cursor:pointer}
.account-menu button:hover{background:rgba(255,255,255,.07)}
.account-menu button:disabled{opacity:.35;cursor:not-allowed}
.account-menu button svg{width:16px;height:16px;color:var(--muted);flex:0 0 16px}
.account-menu hr{width:100%;margin:4px 0;border:0;border-top:1px solid var(--border)}
.share-url{display:block;padding:10px 12px;border-radius:10px;background:#0c0c0d;color:var(--ink);font-size:13px;word-break:break-all}
.about-brand{display:grid;justify-items:center;gap:6px;padding:8px 0 4px}
.about-brand small{color:var(--muted)}
.help-dialog{width:min(520px,100%);max-height:min(80dvh,720px)}
.help-body{display:grid;gap:14px;overflow:auto;max-height:min(56dvh,480px);padding-right:4px}
.help-body section h3{margin:0 0 4px;font-size:14px}
.help-body section p{margin:0;color:var(--muted);line-height:1.55}
.dialog label.memory-toggle{display:flex;align-items:center;gap:10px;color:var(--ink);font-size:14px}
.dialog label.memory-toggle input[type="checkbox"]{width:16px;height:16px;min-height:16px;padding:0;flex:0 0 16px;border-radius:4px;accent-color:var(--accent)}
.plugin-row,.plugin-server{display:flex;align-items:center;gap:10px;width:100%;padding:9px 10px;border:0;border-radius:12px;background:transparent;color:inherit;text-align:left;cursor:pointer}
.plugin-row:hover,.plugin-server:hover,.plugin-row.selected{background:#171719}
.plugin-icon{width:28px;height:28px;display:grid;place-items:center;border-radius:9px;background:rgba(255,255,255,.06);color:var(--muted);flex:0 0 28px}
.plugin-icon svg{width:15px;height:15px}
.plugin-row .bot-copy,.plugin-server .bot-copy{flex:1;min-width:0}
.plugin-server{padding:7px 10px 7px 12px}
.mcp-dot{width:8px;height:8px;border-radius:50%;background:var(--faint);flex:0 0 8px}
.mcp-dot.connected{background:#22c55e;box-shadow:0 0 8px rgba(34,197,94,.6)}
.mcp-dot.disconnected{background:#ef5555}
.mcp-dot.disabled{background:#626267}
.mcp-pane{display:grid;gap:14px;min-height:0;overflow:auto;padding-bottom:8px}
.mcp-add{display:grid;gap:10px}
.mcp-transport{display:flex;gap:6px}
.mcp-transport button{height:32px;padding:0 12px;border:1px solid var(--border);border-radius:9px;background:transparent;color:var(--muted);cursor:pointer}
.mcp-transport button.picked{border-color:var(--accent);color:var(--ink);background:rgba(62,197,168,.08)}
.mcp-list{display:grid;gap:8px}
.mcp-card{border:1px solid var(--border);border-radius:12px;background:var(--inset);overflow:hidden}
.mcp-card-head{display:flex;align-items:center;gap:8px;width:100%;padding:10px 12px;border:0;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer}
.mcp-card-head strong{flex:1}
.mcp-card-head small{color:var(--muted)}
.mcp-error{padding:0 12px 10px;color:#ff8585;font-size:12px;line-height:1.4}
.mcp-tools{display:grid;gap:6px;padding:0 12px 10px}
.mcp-tool{display:grid;gap:2px;padding:8px;border-radius:8px;background:rgba(255,255,255,.03)}
.mcp-tool code{font-size:12px;color:var(--accent)}
.mcp-tool span{color:var(--muted);font-size:12px}
.mcp-card-actions{display:flex;flex-wrap:wrap;gap:6px;padding:0 12px 12px}
.mcp-choose{display:inline-flex;align-items:center;gap:8px;width:fit-content}.mcp-choose svg{width:16px;height:16px}
.mcp-choose{display:inline-flex;align-items:center;gap:8px;width:fit-content}
.mcp-choose svg{width:16px;height:16px}
.mcp-picker{width:min(760px,calc(100vw - 28px));max-height:min(800px,calc(100dvh - 24px));display:flex;flex-direction:column;gap:12px;overflow:hidden;min-height:0}
.mcp-picker .dialog-title,.mcp-picker .dialog-lead,.mcp-picker .dialog-actions,.mcp-picker .pane-error{flex:0 0 auto}
.dialog label.mcp-picker-search,.mcp-picker-search{display:flex;align-items:center;gap:10px;height:44px;margin:0;padding:0 13px;border:1px solid var(--line);border-radius:14px;background:#121214;color:var(--muted);font-size:inherit;flex:0 0 44px}
.mcp-picker-search>div{flex:0 0 auto;display:grid;place-items:center;line-height:0}
.dialog .mcp-picker-search input,.mcp-picker-search input{width:100%;height:auto;min-height:0;padding:0;border:0;border-radius:0;background:transparent;color:var(--ink)}
.dialog .mcp-picker-search input:focus{border-color:transparent}
.mcp-picker-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));grid-auto-rows:min-content;gap:8px;flex:1 1 auto;min-height:0;overflow:auto;padding:2px 6px 8px 0;align-content:start;scrollbar-gutter:stable}
.mcp-picker-grid::-webkit-scrollbar{width:8px}
.mcp-picker-grid::-webkit-scrollbar-thumb{background:#2a2a2e;border-radius:999px}
.mcp-picker-empty{grid-column:1/-1;padding:28px 8px;color:var(--muted);text-align:center}
.mcp-pick-card{display:grid;gap:6px;align-content:start;min-height:min-content;padding:12px;border:1px solid var(--border);border-radius:14px;background:var(--inset);color:inherit;text-align:left;cursor:pointer}
.mcp-pick-card:hover:not(.added):not(:disabled){border-color:var(--accent)}
.mcp-pick-card.added,.mcp-pick-card:disabled{opacity:.45;cursor:default}
.mcp-pick-icon{width:32px;height:32px;display:grid;place-items:center;border-radius:9px;background:rgba(62,197,168,.12);color:var(--accent);font-weight:700}
.mcp-pick-card strong{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.mcp-pick-card small{color:var(--muted);font-size:12px;line-height:1.35;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.mcp-pick-meta{color:var(--faint);font-size:11px}
.mcp-secret-step,.mcp-picker .mcp-add{display:grid;gap:10px;flex:1 1 auto;min-height:0;overflow:auto;align-content:start}
.mcp-picker .mcp-back{width:fit-content;justify-self:start}
.mcp-card-actions svg{width:13px;height:13px}
.create-menu-wrap{position:relative;margin-left:auto}.brand .create-menu-wrap .icon-button{margin-left:0}.create-menu{position:absolute;z-index:40;top:42px;right:0;display:grid;width:180px;padding:6px;border:1px solid var(--border);border-radius:12px;background:#18181b;box-shadow:0 18px 50px rgba(0,0,0,.5)}.create-menu button{display:flex;align-items:center;gap:9px;height:38px;padding:0 10px;border:0;border-radius:8px;background:transparent;color:var(--text);cursor:pointer}.create-menu button:hover:not(:disabled){background:rgba(255,255,255,.07)}.create-menu button:disabled{opacity:.4;cursor:not-allowed}.create-menu svg{width:16px}
.group-picker{display:grid;gap:5px;max-height:280px;margin:0;padding:0;overflow:auto;border:0}.group-picker legend{margin-bottom:8px;color:var(--muted)}.group-picker label{display:flex;align-items:center;gap:10px;padding:7px 9px;border-radius:9px;background:var(--inset);cursor:pointer}.group-picker input{width:16px;height:16px;margin:0}.group-picker .avatar{--avatar-size:28px!important}
.composer-plus:disabled{opacity:.35;cursor:not-allowed}
.create-menu-wrap{position:relative;margin-left:auto}
.brand .create-menu-wrap .icon-button{margin-left:0}
.create-menu{position:absolute;z-index:40;top:42px;right:0;display:grid;width:180px;padding:6px;border:1px solid var(--border);border-radius:12px;background:#18181b;box-shadow:0 18px 50px rgba(0,0,0,.5)}
.create-menu button{display:flex;align-items:center;gap:9px;height:38px;padding:0 10px;border:0;border-radius:8px;background:transparent;color:var(--text);cursor:pointer}
.create-menu button:hover:not(:disabled){background:rgba(255,255,255,.07)}
.create-menu button:disabled{opacity:.4;cursor:not-allowed}
.create-menu svg{width:16px}
.group-picker{display:grid;gap:5px;max-height:280px;margin:0;padding:0;overflow:auto;border:0}
.group-picker legend{margin-bottom:8px;color:var(--muted)}
.group-picker label{display:flex;align-items:center;gap:10px;padding:7px 9px;border-radius:9px;background:var(--inset);cursor:pointer}
.group-picker input{width:16px;height:16px;margin:0}
.group-picker .avatar{--avatar-size:28px!important}
.session-picker{position:relative;display:flex;align-items:center;gap:4px;margin-left:10px;min-width:0}
.session-current{display:flex;align-items:center;gap:6px;min-width:0;max-width:220px;height:36px;padding:0 10px;border:1px solid var(--border);border-radius:9px;background:var(--surface);color:var(--ink);cursor:pointer}
.session-current span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.session-current svg{flex:0 0 14px;width:14px;height:14px;color:var(--muted)}
.session-menu{position:absolute;z-index:40;top:42px;left:0;display:grid;width:min(280px,calc(100vw - 24px));padding:6px;border:1px solid var(--border);border-radius:13px;background:#18181b;box-shadow:0 18px 60px rgba(0,0,0,.55)}
.session-menu>button{display:flex;align-items:center;gap:10px;width:100%;height:38px;padding:0 10px;border:0;border-radius:8px;background:transparent;color:var(--ink);font:inherit;text-align:left;cursor:pointer}
.session-menu>button:hover:not(:disabled){background:rgba(255,255,255,.07)}
.session-menu>button:disabled{opacity:.4;cursor:not-allowed}
.session-menu svg{width:16px;height:16px}
.session-menu hr{width:100%;margin:5px 0;border:0;border-top:1px solid var(--border)}
.session-menu-list{display:grid;max-height:240px;overflow:auto}
.session-item{display:flex;align-items:center;gap:2px;border-radius:8px}
.session-item.selected{background:rgba(255,255,255,.06)}
.session-item-main{display:grid;min-width:0;flex:1;height:42px;padding:0 10px;border:0;background:transparent;color:var(--ink);font:inherit;text-align:left;cursor:pointer}
.session-item-main strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px}
.session-item-main time{color:var(--muted);font-size:11px}
.session-item .icon-button{flex:0 0 32px;width:32px;height:32px;color:var(--muted)}
.session-menu .danger-item{color:#ff7777}
.queue-hint{position:absolute;bottom:92px;left:50%;transform:translateX(-50%);color:var(--muted);font-size:12px}
.queue-hint{position:absolute;bottom:118px;left:50%;z-index:7;transform:translateX(-50%);color:var(--muted);font-size:12px}
.error-banner{z-index:8;bottom:118px}
.pause-banner{display:flex;align-items:center;gap:14px;width:min(760px,100%);margin:4px auto 18px;padding:12px 14px;border:1px solid #5a4a1f;border-radius:12px;background:#221c0e;color:#fcd68a;font-size:13px;line-height:1.5}
.pause-banner span{flex:1}
.pause-banner .primary{flex:0 0 auto;white-space:nowrap}
.message{position:relative;padding-right:28px}
.remember-msg{position:absolute;top:8px;right:4px;width:26px;height:26px;display:grid;place-items:center;border:0;border-radius:8px;background:transparent;color:var(--muted);cursor:pointer}
.messages .remember-msg{display:none}
.remember-msg svg{width:14px;height:14px}
.remember-msg:hover:not(:disabled){background:rgba(255,255,255,.07);color:var(--ink)}
.remember-msg.saved,.remember-msg:disabled{color:var(--accent);opacity:.9;cursor:default}
.panel-open-btn{display:none}
.side-card-backdrop{display:none}
.app-shell.right-open{grid-template-columns:clamp(220px,18vw,280px) minmax(0,1fr) clamp(320px,30vw,440px)}
.app-shell.right-collapsed{grid-template-columns:clamp(220px,18vw,280px) minmax(0,1fr) 52px}
.side-card{display:flex;flex-direction:column;min-width:0;min-height:0;background:var(--panel);border-right:0}
.side-card.collapsed{padding:10px 6px}
.side-rail{display:flex;flex-direction:column;align-items:center;gap:6px;height:100%}
.side-rail button{width:40px;height:40px;display:grid;place-items:center;border:0;border-radius:11px;background:transparent;color:var(--muted);cursor:pointer}
.side-rail button svg{width:18px;height:18px}
.side-rail button:hover:not(:disabled){background:var(--surface);color:var(--ink)}
.side-rail button.active{background:var(--surface);color:var(--ink)}
.side-rail button:disabled{opacity:.35;cursor:not-allowed}
.side-card-head{display:flex;align-items:center;gap:6px;height:72px;flex:0 0 72px;padding:0 10px 0 12px;border-bottom:1px solid #171719}
.side-card-title{min-width:0;flex:1;overflow:hidden;color:var(--muted);font-size:13px;text-overflow:ellipsis;white-space:nowrap}
.side-tabs{display:flex;gap:4px;min-width:0;flex:1;flex-wrap:wrap}
.side-tabs button{display:inline-flex;align-items:center;gap:6px;height:36px;padding:0 10px;border:0;border-radius:10px;background:transparent;color:var(--muted);font:inherit;font-size:13px;cursor:pointer}
.side-tabs button svg{width:15px;height:15px}
.side-tabs button:hover:not(:disabled){background:var(--surface);color:var(--ink)}
.side-tabs button.active{background:var(--surface);color:var(--ink)}
.side-tabs button:disabled{opacity:.35;cursor:not-allowed}
.side-card-body{display:flex;flex-direction:column;min-height:0;flex:1;padding:14px 16px 16px;overflow:hidden}
.computer-part{display:flex;flex-direction:column;min-height:0;flex:1;gap:10px}
.computer-part.hidden-part{display:none}
.computer-status-row{display:flex;align-items:center;gap:8px;color:var(--muted)}
.computer-status-row>span{margin-right:auto;color:var(--ink);font-weight:600}
.computer-part .preview{flex:1;min-height:160px;aspect-ratio:auto}
.pane-form,.memory-pane{display:grid;gap:14px;min-height:0;overflow:auto;padding-bottom:8px}
.pane-form label,.memory-pane label,.mcp-add label{display:grid;gap:8px;color:var(--muted);font-size:13px}
.pane-form input:not([type="checkbox"]):not([type="color"]),.memory-pane input:not([type="checkbox"]),.mcp-add input{height:40px;border:1px solid var(--border);border-radius:10px;background:#0c0c0d;color:var(--ink);padding:0 12px;outline:0}
.pane-form textarea,.memory-pane textarea,.mcp-add textarea{box-sizing:border-box;width:100%;resize:vertical;border:1px solid var(--border);border-radius:10px;background:#0c0c0d;color:var(--ink);padding:11px;font:inherit;outline:none}
.pane-actions{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
.pane-error{color:#ff8585;font-size:13px}
.settings-pane fieldset{margin:0;padding:0;border:0}
.settings-pane legend{margin-bottom:9px;color:var(--muted);font-size:13px}
.memory-pane form{display:flex;gap:10px;align-items:end}
.memory-pane form label{flex:1}
@media(max-width:1200px){.app-shell.right-open{grid-template-columns:230px minmax(0,1fr) 340px}.app-shell.right-collapsed{grid-template-columns:230px minmax(0,1fr) 52px}}
@media(max-width:1050px){
.panel-open-btn{display:inline-flex}
.app-shell.right-collapsed{grid-template-columns:250px minmax(0,1fr)}
.app-shell.right-collapsed .side-card{display:none}
.app-shell.right-open{grid-template-columns:250px minmax(0,1fr)}
.app-shell.right-open .side-card-backdrop{display:block;position:fixed;inset:0;z-index:44;background:rgba(0,0,0,.5)}
.app-shell.right-open .side-card{position:fixed;z-index:45;inset:0 0 0 auto;width:min(420px,100vw);box-shadow:-20px 0 60px #000}
}
.memory-help{margin:0;color:var(--muted);line-height:1.5}
.memory-pane .memory-toggle,.memory-toggle{display:flex;align-items:center;gap:8px}
.memory-search{display:grid;gap:8px}
.memory-clear-confirm{display:flex;align-items:center;gap:8px;margin-right:auto;color:var(--muted);font-size:13px}
.login-screen{display:grid;min-height:100%;place-items:center;padding:20px;background:radial-gradient(circle at 50% 20%,#18201e 0,#080809 55%)}.login-dialog{display:grid;justify-items:stretch}.login-dialog>.avatar{justify-self:center}.login-dialog h1,.login-dialog p{text-align:center}.login-dialog p{margin-top:-8px;color:var(--muted)}.login-error{color:#ff8585;font-size:13px}
.working-label{font-size:13px;letter-spacing:.02em;white-space:nowrap;background:linear-gradient(90deg,#7a8088 0%,#7a8088 28%,#fff 50%,#7a8088 72%,#7a8088 100%);background-size:220% 100%;-webkit-background-clip:text;background-clip:text;color:transparent;animation:working-shimmer 1.35s linear infinite}
.login-screen{display:grid;min-height:100%;place-items:center;padding:20px;background:radial-gradient(circle at 50% 20%,#18201e 0,#080809 55%)}
.login-dialog{display:grid;justify-items:stretch}
.login-dialog>.avatar{justify-self:center}
.login-dialog h1,.login-dialog p{text-align:center}
.login-dialog p{margin-top:-8px;color:var(--muted)}
.login-error{color:#ff8585;font-size:13px}
@keyframes working-shimmer{to{background-position:-220% 0}}
@media(prefers-reduced-motion:reduce){.working-label{animation:none;color:var(--muted);background:none}}
.avatar .presence{position:absolute;z-index:6;right:-1px;bottom:-1px;width:calc(var(--avatar-size)*0.28);height:calc(var(--avatar-size)*0.28);min-width:8px;min-height:8px;border:2px solid var(--side,#0b0b0c);border-radius:50%;background:#22c55e;box-shadow:0 0 0 1px rgba(0,0,0,.4);pointer-events:none}
.avatar .presence{display:none}
.bot-row .avatar-wrap::after{content:"";position:absolute;z-index:9;right:-2px;bottom:-2px;width:9px;height:9px;border:2px solid var(--side);border-radius:50%;background:#22c55e;box-shadow:0 0 0 1px rgba(0,0,0,.38);pointer-events:none}
.bot-row .unread-dot{top:-2px;right:-3px;background:#3b82f6;box-shadow:0 0 8px rgba(59,130,246,.65)}
.avatar.blobatar.thinking{animation:avatar-rainbow-glow 1.8s linear infinite}
.avatar-wrap{transition:transform .16s ease}
.bot-row:hover .avatar-wrap{transform:scale(1.06)}
.avatar-stack{position:relative;display:block;flex:0 0 auto}
.avatar-stack .stack-item{position:absolute;top:0}
.avatar-stack .stack-item .avatar{box-shadow:none;filter:drop-shadow(2px 0 0 var(--side,#0b0b0c)) drop-shadow(-2px 0 0 var(--side,#0b0b0c)) drop-shadow(0 2px 0 var(--side,#0b0b0c)) drop-shadow(0 -2px 0 var(--side,#0b0b0c))}.avatar-stack .stack-item .avatar.thinking{filter:none;animation:avatar-rainbow-glow 1.8s linear infinite}
.stack-extra{position:absolute;top:0;display:grid;place-items:center;border-radius:50%;background:#2a2a2e;color:var(--muted);font-size:11px;font-weight:650;box-shadow:0 0 0 2px var(--side,#0b0b0c)}
.bot-row.room-row .bot-copy small{color:var(--muted)}
.message.assistant.spoken{display:grid;grid-template-columns:28px minmax(0,1fr);column-gap:10px;row-gap:3px;justify-content:start;align-items:end}
.message.assistant.spoken .msg-avatar{grid-column:1;grid-row:2;align-self:start;max-width:none;margin:4px 0 0;padding:0;border-radius:0;background:transparent;line-height:normal}
.message.assistant .msg-avatar>.avatar.blobatar{max-width:none;padding:0;background:transparent;color:inherit;line-height:normal;white-space:normal}
.message.assistant.spoken .speaker{grid-column:2;font-size:12px;font-weight:650;line-height:1.2;padding:0 6px;color:var(--accent)}
.message.assistant.spoken>span{grid-column:2;max-width:82%;border-radius:6px 18px 18px 18px}
.thinking-row{width:min(760px,100%);margin:2px auto 14px;padding-left:2px}
.dialog-lead{margin:0;color:var(--muted);line-height:1.5;font-size:13px}
.topbar .avatar-stack{width:26px!important;height:26px!important;margin-right:2px;transform:scale(.6842);transform-origin:center}
.avatar-stack{isolation:isolate}
.avatar-stack .stack-item{transition:transform .16s ease}
.avatar-stack .stack-item:hover{transform:translateY(-2px);z-index:8!important}
.avatar-stack .stack-item .avatar{filter:drop-shadow(2px 0 0 var(--main,#0d0d0e)) drop-shadow(-2px 0 0 var(--main,#0d0d0e)) drop-shadow(0 2px 0 var(--main,#0d0d0e)) drop-shadow(0 -2px 0 var(--main,#0d0d0e))}
.avatar-stack .stack-extra{box-shadow:0 0 0 2px var(--main,#0d0d0e)}
.avatar-stack .stack-item .avatar.thinking{filter:none}
/* Speaker rows: the coloured speaker label must breathe away from the text. */
.message.assistant.spoken{column-gap:11px;row-gap:6px}
.message.assistant.spoken>.msg-avatar{max-width:none;padding:0;border-radius:0;background:transparent}
.message.assistant.spoken .speaker{padding:0 2px;letter-spacing:.01em}
.message.assistant.spoken>.message-body{grid-column:2;max-width:82%;line-height:1.52;border-radius:6px 18px 18px 18px}
@keyframes computer-hud-bob{0%,100%{transform:translateY(0) rotate(-4deg)}50%{transform:translateY(-3px) rotate(5deg)}}
/* Preserve the monitor's 16:10 shape as the right panel gets narrower. */
.computer-part .preview{flex:0 1 auto;width:100%;height:auto;min-height:0;max-height:min(52vh,380px);aspect-ratio:16 / 10}
.computer-part .preview .desktop-frame{display:block;aspect-ratio:16 / 10}
/* Collapsed navigation is rendered in the top bar by App. Keep this fallback
for older markup and make both variants feel like one compact tool strip. */
.top-tools{display:flex;align-items:center;gap:4px;margin-left:auto;padding:4px;border:1px solid var(--border);border-radius:13px;background:rgba(18,18,20,.86);box-shadow:0 8px 24px rgba(0,0,0,.2)}
.top-tool-button{width:34px;height:34px;display:inline-flex;align-items:center;justify-content:center;border:0;border-radius:9px;background:transparent;color:var(--muted);cursor:pointer;transition:background .15s ease,color .15s ease,transform .15s ease}
.top-tool-button svg{width:17px;height:17px}
.top-tool-button:hover:not(:disabled){background:var(--surface);color:var(--ink);transform:translateY(-1px)}
.top-tool-button.active{background:var(--surface);color:var(--ink);box-shadow:inset 0 0 0 1px rgba(255,255,255,.06)}
.top-tool-button:disabled{opacity:.3;cursor:not-allowed}
.app-shell.right-collapsed{grid-template-columns:clamp(220px,18vw,280px) minmax(0,1fr)}
.side-rail{flex-direction:row;align-items:center;justify-content:flex-start;gap:4px;height:auto;padding:6px}
.side-rail .grow{display:none}
.side-rail button{width:34px;height:34px;border-radius:9px}
.side-rail button svg{width:17px;height:17px}
@media(max-width:700px){
.topbar{gap:8px}
.top-tools{gap:2px;padding:3px}
.top-tool-button{width:32px;height:32px}
.topbar>strong{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.session-picker{margin-left:2px}
.session-current{max-width:min(32vw,150px)}
.message.assistant.spoken{grid-template-columns:24px minmax(0,1fr);column-gap:8px}
.computer-part .preview{max-height:none}
}
.computer-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;min-width:0;flex-wrap:wrap}
.computer-actions .restart-computer{font-size:12px;padding-inline:10px;color:var(--muted)}
.computer-actions .restart-computer svg{width:14px;height:14px}
.computer-actions .primary{min-width:0}
.computer-overlay header .computer-actions{justify-content:flex-end}
.provider-fieldset{margin:0;padding:0;border:0}.provider-fieldset legend{margin-bottom:8px;color:var(--muted);font-size:13px}
.provider-fieldset{margin:0;padding:0;border:0}
.provider-fieldset legend{margin-bottom:8px;color:var(--muted);font-size:13px}
.provider-grid{display:flex;gap:6px;flex-wrap:wrap}
.provider-grid button{height:36px;padding:0 12px;border:1px solid var(--border);border-radius:10px;background:transparent;color:var(--muted);cursor:pointer}
.provider-grid button.picked{border-color:var(--accent);color:var(--ink);background:rgba(62,197,168,.08)}
.dialog.settings-dialog{width:min(520px,calc(100vw - 28px));max-height:min(850px,calc(100dvh - 28px));overflow:auto}
.dialog.settings-dialog select{width:100%;height:42px;padding:0 12px;border:1px solid var(--border);border-radius:10px;background:#0c0c0d;color:var(--ink)}
/* Teach-by-demonstration: composer menu, live banner, draft card */
.plus-menu-wrap{position:relative;align-self:center}
.composer-plus.open{color:var(--ink);background:rgba(255,255,255,.08)}
.plus-menu{position:absolute;z-index:40;bottom:50px;left:0;display:grid;width:268px;padding:6px;border:1px solid var(--border);border-radius:13px;background:#18181b;box-shadow:0 18px 60px rgba(0,0,0,.55)}
.plus-menu button{display:flex;align-items:center;gap:10px;width:100%;height:38px;padding:0 10px;border:0;border-radius:8px;background:transparent;color:var(--ink);font:inherit;text-align:left;cursor:pointer;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
.plus-menu button:hover:not(:disabled){background:rgba(255,255,255,.07)}
.plus-menu button:disabled{opacity:.4;cursor:not-allowed}
.plus-menu svg{width:16px;height:16px;flex:0 0 16px}
.plus-menu hr{width:100%;margin:5px 0;border:0;border-top:1px solid var(--border)}
.plus-menu-label{padding:4px 10px 2px;color:var(--faint);font-size:11px}
.plus-menu-skills{display:grid;min-height:0}
.plus-menu-search{box-sizing:border-box;width:calc(100% - 8px);height:32px;margin:4px 4px 6px;padding:0 10px;border:1px solid var(--border);border-radius:8px;background:#121214;color:var(--ink);font:inherit;font-size:13px;outline:0}
.plus-menu-search:focus{border-color:#4b4b50}
.plus-menu-skill-list{overflow-y:auto;max-height:min(228px,calc(100dvh - 280px));padding-bottom:2px}
.plus-menu-skill-list::-webkit-scrollbar{width:8px}
.plus-menu-skill-list::-webkit-scrollbar-thumb{border-radius:8px;background:#2f2f33}
.plus-menu-empty{display:block;padding:10px 12px 12px;color:var(--muted);font-size:12px}
.record-dot{display:inline-block;flex:0 0 12px;width:12px;height:12px;border-radius:50%;background:#ef5555;box-shadow:inset 0 0 0 2px #18181b,0 0 0 1.5px #ef5555}
.record-dot.live{animation:pulse 1.2s infinite;margin-right:8px;vertical-align:-1px}
.teach-dialog h2{display:flex;align-items:center;gap:10px}
.teach-goal{min-height:78px;resize:vertical;border:1px solid var(--border);border-radius:10px;background:#0c0c0d;color:var(--ink);padding:10px 12px;outline:0;line-height:1.5}
.teach-goal:focus{border-color:#4b4b50}
.teach-tips{margin:0;padding-left:18px;color:var(--muted);font-size:12.5px;line-height:1.6}
.teach-banner{display:flex;align-items:center;gap:12px;width:min(760px,100%);margin:4px auto 18px;padding:12px 14px;border:1px solid #3d2a2a;border-radius:12px;background:#1d1212;color:#f3c5c5;font-size:13px;line-height:1.5}
.teach-banner.recording{border-color:#6a2a2a;background:#241313}
.teach-banner>span{flex:1;display:grid;gap:2px}
.teach-banner small{color:var(--muted)}
.teach-banner .primary,.teach-banner .outline{flex:0 0 auto;white-space:nowrap}
.control-badge.teaching{background:rgba(239,85,85,.16);color:#ff8a8a}
.skill-draft{display:grid;gap:12px;width:min(760px,100%);margin:4px auto 18px;padding:16px 18px;border:1px solid #2f4a42;border-radius:14px;background:#0f1815;font-size:13.5px;line-height:1.55}
.skill-draft header{display:flex;align-items:center;gap:9px;color:var(--accent)}
.skill-draft header svg{width:16px;height:16px}
.skill-draft header small{margin-left:auto;color:var(--muted);font-weight:400}
.skill-draft label{display:grid;gap:6px;color:var(--muted);font-size:12.5px}
.skill-draft input,.skill-draft textarea{border:1px solid var(--border);border-radius:10px;background:#0c0c0d;color:var(--ink);padding:9px 12px;outline:0;font:inherit;line-height:1.5}
.skill-draft input:focus,.skill-draft textarea:focus{border-color:#4b4b50}
.skill-draft textarea{resize:vertical}
.skill-draft p{margin:0;color:var(--ink)}
.skill-intent{color:#d8d8dc}
.skill-inputs code{margin-right:6px;padding:1px 7px;border-radius:6px;background:rgba(62,197,168,.14);color:var(--accent);font-size:12px}
.skill-steps ol{margin:0;padding-left:22px;color:var(--ink);cursor:text}
.skill-steps li{margin:2px 0}
.skill-steps li.more,.skill-check{color:var(--muted)}
.skill-draft .link{width:fit-content;padding:0;border:0;background:none;color:var(--accent);font-size:12px;cursor:pointer}
.skill-error{color:#fca5a5;font-size:12.5px}
.skill-actions{display:flex;align-items:center;gap:8px}
.skill-actions .grow{flex:1}
.skill-actions .link{display:inline-flex;align-items:center;gap:5px}
.skill-actions .link svg{width:13px;height:13px}
/* Saved-skill rows in the composer menu: run on the left, pencil on the right */
.plus-menu-skill{display:flex;align-items:center;gap:2px}
.plus-menu-skill>button:first-child{flex:1;min-width:0}
.plus-menu-skill .skill-edit{flex:0 0 30px;width:30px;height:30px;padding:0;justify-content:center;color:var(--muted)}
.plus-menu-skill .skill-edit:hover{color:var(--ink)}
.plus-menu-skill .skill-edit svg{width:14px;height:14px;flex-basis:14px}
.skill-import-input{position:absolute;left:0;bottom:0;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0;opacity:0}
/* Full skill editor */
.dialog.skill-edit{width:min(600px,calc(100vw - 28px));max-height:calc(100dvh - 28px);overflow:auto}
.dialog.skill-edit h2{display:flex;align-items:center;gap:9px}
.dialog.skill-edit h2 svg{width:17px;height:17px;color:var(--accent)}
.dialog.skill-edit label{display:grid;gap:6px;color:var(--muted);font-size:12.5px}
.dialog.skill-edit input,.dialog.skill-edit textarea{width:100%;border:1px solid var(--border);border-radius:10px;background:#0c0c0d;color:var(--ink);padding:9px 12px;outline:0;font:inherit;font-size:13.5px;line-height:1.5;resize:vertical}
.dialog.skill-edit input:focus,.dialog.skill-edit textarea:focus{border-color:#4b4b50}
.dialog.skill-edit .dialog-actions{align-items:center;gap:8px;flex-wrap:wrap}
.dialog.skill-edit .dialog-actions .grow{flex:1}
.dialog.skill-edit .dialog-actions .outline svg{fill:none;stroke:currentColor;width:14px;height:14px}
.account-list{display:grid;gap:10px}
.account-row{display:grid;gap:4px;padding:10px;border:1px solid var(--border);border-radius:12px}
.account-row strong{font-size:14px}
.account-row small{color:var(--muted)}
@keyframes monitor-breathe{50%{transform:translateY(-4px);box-shadow:0 0 28px #65dcb325}}
@keyframes monitor-orbit{to{transform:rotate(360deg)}}
@keyframes monitor-blink{0%,43%,49%,100%{transform:scaleY(1)}46%{transform:scaleY(.15)}}

View File

@ -0,0 +1,28 @@
@media(max-width:1050px){.app-shell{grid-template-columns:250px 1fr}.computer-panel{display:none}}
@media(max-width:700px){.app-shell{display:block}.sidebar{position:fixed;z-index:40;inset:0 auto 0 0;width:min(300px,88vw);transform:translateX(-105%);transition:.2s;box-shadow:20px 0 60px #000}.sidebar.open{transform:none}.chat-panel{height:100%}.mobile-menu{display:inline-flex}.topbar{padding:0 12px}.messages{padding:24px 18px 130px}.composer{left:12px;right:12px}.computer-overlay>header{height:auto;min-height:64px;flex-wrap:wrap;padding:10px}.computer-overlay>header>div:last-child{flex-wrap:wrap;justify-content:flex-end}.overlay-screen{padding:8px}.mode-grid{grid-template-columns:1fr}}
@media(max-width:1200px){.app-shell{grid-template-columns:230px minmax(0,1fr) 330px}}
@media(max-width:1050px){.app-shell{grid-template-columns:250px minmax(0,1fr)}}
@media(max-width:700px){.chat-panel{--chat-gutter:18px}.app-shell{display:block}.message{width:100%}.message>span{max-width:90%}.composer-dock{padding:20px var(--chat-gutter) 16px}.composer{width:var(--chat-col);min-height:60px}.composer-files{padding:2px 4px 8px 8px}.file-card{max-width:100%;height:52px}.file-card-remove{flex-basis:32px;width:32px;height:32px}.message.with-files .msg-attachments{max-width:100%}.messages{padding-bottom:118px}.chat-panel:has(.composer-dock.has-status) .messages{padding-bottom:178px}}
@media(prefers-reduced-motion:reduce){.avatar.blobatar.thinking:before,.avatar.blobatar.thinking:after,.thinking-dots i{animation:none!important}}
@media(max-width:1200px){.app-shell.right-open{grid-template-columns:230px minmax(0,1fr) 340px}.app-shell.right-collapsed{grid-template-columns:230px minmax(0,1fr) 52px}}
@media(max-width:1050px){
.panel-open-btn{display:inline-flex}
.app-shell.right-collapsed{grid-template-columns:250px minmax(0,1fr)}
.app-shell.right-collapsed .side-card{display:none}
.app-shell.right-open{grid-template-columns:250px minmax(0,1fr)}
.app-shell.right-open .side-card-backdrop{display:block;position:fixed;inset:0;z-index:44;background:rgba(0,0,0,.5)}
.app-shell.right-open .side-card{position:fixed;z-index:45;inset:0 0 0 auto;width:min(420px,100vw);box-shadow:-20px 0 60px #000}
}
@media(prefers-reduced-motion:reduce){.working-label{animation:none;color:var(--muted);background:none}}
@media(prefers-reduced-motion:reduce){.computer-hud .avatar.blobatar,.computer-hud-label{animation:none!important}.computer-hud-label{color:var(--muted);background:none}}
@media(max-width:700px){
.topbar{gap:8px}
.top-tools{gap:2px;padding:3px}
.top-tool-button{width:32px;height:32px}
.topbar>strong{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.session-picker{margin-left:2px}
.session-current{max-width:min(32vw,150px)}
.message.assistant.spoken{grid-template-columns:24px minmax(0,1fr);column-gap:8px}
.computer-part .preview{max-height:none}
}
@media(prefers-reduced-motion:reduce){.computer-signal,.computer-signal::before,.computer-signal-face i,.avatar.blobatar.thinking,.avatar-stack .stack-item .avatar.thinking{animation:none!important}}

37
apps/web/src/schedule.css Normal file
View File

@ -0,0 +1,37 @@
.login-chip,.sched-chip{display:grid;gap:6px;margin:8px 0;padding:12px 14px;border:1px solid var(--border);border-radius:12px;background:#101113;max-width:min(var(--chat-col),100%)}
.login-chip .login-label,.sched-chip .sched-label{color:var(--muted);font-size:12px}
.login-chip .login-site{font-weight:600}
.login-chip .login-why{color:var(--muted);font-size:13px}
.sched-list{display:grid;gap:8px;min-height:0;overflow:auto}
.sched-list-head{display:flex;align-items:center;justify-content:space-between;color:var(--muted);font-size:13px}
.sched-empty{margin:0;color:var(--faint);font-size:13px}
.sched-row{display:flex;align-items:center;gap:8px}
.sched-row-main{display:flex;align-items:center;gap:10px;min-width:0;flex:1;padding:8px;border:0;border-radius:10px;background:transparent;color:inherit;text-align:left;cursor:pointer}
.sched-row-main:hover{background:var(--surface)}
.sched-row-main span{min-width:0;display:grid}
.sched-row-main strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.sched-row-main small{color:var(--muted);font-size:12px}
.sched-dot{width:8px;height:8px;border-radius:50%;background:var(--faint)}
.sched-dot.on{background:#4ade80}
.sched-editor{display:grid;gap:12px;min-height:0;overflow:auto}
.sched-editor-head{display:flex;align-items:center;gap:8px}
.sched-when{display:grid;gap:8px;color:var(--muted);font-size:13px}
.sched-card{display:flex;flex-wrap:wrap;gap:8px;padding:10px;border:1px solid var(--border);border-radius:12px;background:#0c0c0d}
.sched-card select,.sched-cron{height:36px;border:1px solid var(--border);border-radius:8px;background:#151517;color:var(--ink);padding:0 8px;font:inherit}
.sched-cron{flex:1;min-width:140px;font-family:ui-monospace,monospace}
.sched-list,.sched-editor{flex:none;overflow:visible;border-top:1px solid var(--line);padding-top:16px;margin-top:6px;min-width:0}
.sched-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px;padding:4px 0}
.sched-row>.outline{padding:0 10px;white-space:nowrap}
.sched-row-main small{overflow-wrap:anywhere;line-height:1.45}
.sched-dot{flex:0 0 8px}
.sched-editor>label{display:grid;gap:8px;color:var(--muted);font-size:13px;min-width:0}
.sched-editor>label.memory-toggle{display:flex;align-items:center}
.sched-editor input:not([type="checkbox"]),.sched-editor textarea{width:100%;min-width:0;border:1px solid var(--border);border-radius:10px;background:var(--inset);color:var(--ink);padding:10px 12px;font:inherit}
.sched-editor textarea{resize:vertical;min-height:100px;line-height:1.5}
.sched-editor input[type="checkbox"]{width:16px;height:16px;margin:0;accent-color:var(--accent)}
.sched-card>*{min-width:0;max-width:100%;flex:1 1 100px}
.sched-when>span{display:flex;flex-wrap:wrap;gap:6px;overflow-wrap:anywhere}
.sched-editor .dialog-actions{flex-wrap:wrap}
.login-chip,.sched-chip{width:100%;overflow-wrap:anywhere}
.message:has(>.sched-chip),.message:has(>.login-chip){flex-direction:column;gap:8px}
.message.spoken>.sched-chip,.message.spoken>.login-chip{grid-column:2}

198
apps/web/src/schedule.tsx Normal file
View File

@ -0,0 +1,198 @@
import { t } from "./i18n";
export type CronFreq = "Every hour" | "Every day" | "Weekdays" | "Every week" | "Every month" | "Interval" | "Advanced";
export type CronUnit = "minutes" | "hours" | "days";
export type CronPreset = { freq: CronFreq; n: number; unit: CronUnit; time: string; cron: string };
export interface ScheduleItem {
id: string;
botId: string;
threadId?: string | null;
name: string;
cron: string;
timezone: string;
instructions: string;
enabled: boolean;
human: string;
lastRunAt?: string | null;
nextRunAt?: string | null;
}
export const CRON_FREQS: CronFreq[] = ["Every hour", "Every day", "Weekdays", "Every week", "Every month", "Interval", "Advanced"];
const TIMES = ["6:00 AM", "7:00 AM", "8:00 AM", "9:00 AM", "12:00 PM", "3:00 PM", "6:00 PM", "9:00 PM"];
const NUMBERS = [1, 2, 3, 5, 10, 15, 30, 45];
const TIMED: CronFreq[] = ["Every day", "Weekdays", "Every week", "Every month"];
export function defaultCronPreset(): CronPreset {
return { freq: "Every day", n: 3, unit: "minutes", time: "9:00 AM", cron: "" };
}
export function cronFromPreset(input: CronPreset): string {
if (input.freq === "Advanced") return input.cron.trim();
if (input.freq === "Every hour") return "0 * * * *";
if (input.freq === "Interval") {
if (!Number.isInteger(input.n) || input.n < 1 || input.n > 365) throw new Error("間隔必須為 1365 的整數");
return `@every ${input.n}${({minutes:"m",hours:"h",days:"d"})[input.unit]}`;
}
const { hour, minute } = parseClock(input.time);
if (input.freq === "Weekdays") return `${minute} ${hour} * * 1-5`;
if (input.freq === "Every week") return `${minute} ${hour} * * 1`;
if (input.freq === "Every month") return `${minute} ${hour} 1 * *`;
return `${minute} ${hour} * * *`;
}
export function presetFromCron(cron: string): CronPreset {
const base = defaultCronPreset();
const interval = /^@every ([1-9]\d{0,2})([mhd])$/.exec(cron.trim());
if (interval && Number(interval[1]) <= 365) return {...base, freq:"Interval", n:Number(interval[1]), unit:({m:"minutes",h:"hours",d:"days"} as const)[interval[2] as "m"|"h"|"d"]};
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) return { ...base, freq: "Advanced", cron };
const [minute, hour, day, month, dow] = parts;
if (month !== "*") return { ...base, freq: "Advanced", cron };
if (minute === "0" && hour === "*" && day === "*" && dow === "*") return { ...base, freq: "Every hour" };
if (!/^\d+$/.test(minute) || !/^\d+$/.test(hour)) return { ...base, freq: "Advanced", cron };
if (Number(hour) > 23 || Number(minute) > 59) return {...base, freq:"Advanced", cron};
const time = formatClock(Number(hour), Number(minute));
if (day === "*" && dow === "1-5") return { ...base, freq: "Weekdays", time };
if (day === "*" && dow === "1") return { ...base, freq: "Every week", time };
if (day === "1" && dow === "*") return { ...base, freq: "Every month", time };
if (day === "*" && dow === "*") return { ...base, freq: "Every day", time };
return { ...base, freq: "Advanced", cron };
}
function parseClock(time: string): { hour: number; minute: number } {
const [rawH, rest] = time.split(":");
const minute = Number((rest ?? "00").slice(0, 2));
let hour = Number(rawH);
if (/pm/i.test(time) && hour < 12) hour += 12;
if (/am/i.test(time) && hour === 12) hour = 0;
return { hour, minute };
}
function formatClock(hour: number, minute: number): string {
const period = hour >= 12 ? "PM" : "AM";
const h12 = hour % 12 === 0 ? 12 : hour % 12;
return `${h12}:${String(minute).padStart(2, "0")} ${period}`;
}
function freqLabel(freq: CronFreq): string {
return ({
"Every hour": t("schedEveryHour"),
"Every day": t("schedEveryDay"),
"Weekdays": t("schedWeekdays"),
"Every week": t("schedEveryWeek"),
"Every month": t("schedEveryMonth"),
"Interval": t("schedInterval"),
"Advanced": t("schedAdvanced"),
})[freq];
}
export function ScheduleList({
items,
onCreate,
onOpen,
onRun,
runningId,
}: {
items: ScheduleItem[];
onCreate: () => void;
onOpen: (item: ScheduleItem) => void;
onRun: (item: ScheduleItem) => void;
runningId?: string | null;
}) {
return (
<div className="sched-list">
<div className="sched-list-head">
<span>{t("schedules")}</span>
<button type="button" className="icon-button" title={t("schedCreate")} onClick={onCreate}>+</button>
</div>
{items.length === 0 ? <p className="sched-empty">{t("schedEmpty")}</p> : items.map(item => (
<div className="sched-row" key={item.id}>
<button type="button" className="sched-row-main" onClick={() => onOpen(item)}>
<i className={`sched-dot ${item.enabled ? "on" : "off"}`} />
<span>
<strong>{item.name}</strong>
<small>{item.enabled ? item.human : t("schedPaused")}</small>
</span>
</button>
<button type="button" className="outline" disabled={runningId === item.id} onClick={() => onRun(item)}>
{runningId === item.id ? t("schedRunning") : t("schedRunNow")}
</button>
</div>
))}
</div>
);
}
export function ScheduleEditor({
draft,
timezone,
saving,
error,
onChange,
onBack,
onSave,
onDelete,
}: {
draft: { name: string; instructions: string; enabled: boolean; preset: CronPreset };
timezone: string;
saving: boolean;
error: string | null;
onChange: (next: { name: string; instructions: string; enabled: boolean; preset: CronPreset }) => void;
onBack: () => void;
onSave: () => void;
onDelete?: () => void;
}) {
const preset = draft.preset;
const times = TIMES.includes(preset.time) ? TIMES : [...TIMES, preset.time];
const numbers = NUMBERS.includes(preset.n) ? NUMBERS : [...NUMBERS, preset.n].sort((a, b) => a - b);
function patchPreset(partial: Partial<CronPreset>) {
onChange({ ...draft, preset: { ...preset, ...partial } });
}
return (
<div className="sched-editor">
<div className="sched-editor-head">
<button type="button" className="outline" onClick={onBack}>{t("back")}</button>
<strong>{t("schedule")}</strong>
</div>
<label className="memory-toggle">
<input type="checkbox" checked={draft.enabled} onChange={e => onChange({ ...draft, enabled: e.target.checked })} />
{t("schedActive")}
</label>
<label>{t("name")}<input value={draft.name} onChange={e => onChange({ ...draft, name: e.target.value })} placeholder={t("schedNamePlaceholder")} /></label>
<label>{t("schedInstruction")}<textarea rows={4} value={draft.instructions} onChange={e => onChange({ ...draft, instructions: e.target.value })} placeholder={t("schedInstructionPlaceholder")} /></label>
<div className="sched-when">
<span>{t("schedWhen")} <small>{timezone}</small></span>
<div className="sched-card">
<select value={preset.freq} onChange={e => patchPreset({ freq: e.target.value as CronFreq })} aria-label={t("schedWhen")}>
{CRON_FREQS.map(freq => <option key={freq} value={freq}>{freqLabel(freq)}</option>)}
</select>
{preset.freq === "Interval" && <>
<select value={String(preset.n)} onChange={e => patchPreset({ n: Number(e.target.value) })}>
{numbers.map(n => <option key={n} value={n}>{n}</option>)}
</select>
<select value={preset.unit} onChange={e => patchPreset({ unit: e.target.value as CronUnit })}>
<option value="minutes">{t("schedMinutes")}</option>
<option value="hours">{t("schedHours")}</option>
<option value="days">{t("schedDays")}</option>
</select>
</>}
{TIMED.includes(preset.freq) && (
<select value={preset.time} onChange={e => patchPreset({ time: e.target.value })}>
{times.map(time => <option key={time} value={time}>{time}</option>)}
</select>
)}
{preset.freq === "Advanced" && (
<input className="sched-cron" value={preset.cron} placeholder="0 9 * * 1-5" onChange={e => patchPreset({ cron: e.target.value })} />
)}
</div>
</div>
{preset.freq === "Interval" && <small className="memory-help"> 24 </small>}
{error && <div className="pane-error">{error}</div>}
<div className="pane-actions">
{onDelete && <button type="button" className="danger" disabled={saving} onClick={onDelete}>{t("delete")}</button>}
<button type="button" className="primary" disabled={saving || !draft.name.trim() || !draft.instructions.trim() || (preset.freq === "Advanced" && !preset.cron.trim())} onClick={onSave}>{saving ? t("saving") : t("save")}</button>
</div>
</div>
);
}

File diff suppressed because one or more lines are too long

View File

@ -9,7 +9,7 @@ export interface Message { id:string; sessionId?:string; seq?:number; role:strin
export interface MessageFile { kind:"image"|"file"; name:string; mimeType?:string; size?:number }
export interface RoomMember { id:string; name:string; avatarColor:string; avatarShape:AvatarShape }
export interface Room { id:string; name:string; members:RoomMember[]; lastMessageAt:string|null; lastPreview:string|null; unreadCount:number }
export interface ComputerStatus { botId:string; mode:ComputerMode; state:ComputerState; controlHolder:"none"|"bot"|"user"; takeoverRequested:boolean; busyBotName:string|null; busySessionId:string|null; busyRunId:string|null; busyStep?:string|null; waitingRunId?:string|null; waitingSessionId?:string|null; queuedRuns?:number; display:string|null; profileMode:string; screenAvailable:boolean }
export interface ComputerStatus { botId:string; mode:ComputerMode; state:ComputerState; controlHolder:"none"|"bot"|"user"; takeoverRequested:boolean; busyBotName:string|null; busySessionId:string|null; busyRunId:string|null; busyStep?:string|null; usingComputer?:boolean; waitingRunId?:string|null; waitingSessionId?:string|null; queuedRuns?:number; display:string|null; profileMode:string; screenAvailable:boolean }
export interface PlaybookStep { do:string; expect?:string; note?:string }
export interface PlaybookInput { name:string; description?:string; example?:string }
export interface Playbook { name?:string; whenToUse?:string; intent?:string; inputs?:PlaybookInput[]; preconditions?:string[]; steps?:(PlaybookStep|string)[]; howToCheck?:string; whatToReturn?:string; cautions?:string[] }

View File

@ -0,0 +1,7 @@
import { lazy, Suspense, type ComponentProps } from "react";
import type Animation from "react-useanimations";
const AnimationPlayer = lazy(() => import("react-useanimations"));
export default function UseAnimations(props: ComponentProps<typeof Animation>) {
const size=props.size ?? 24;
return <Suspense fallback={<span aria-hidden="true" style={{display:"inline-block",width:size,height:size,flexShrink:0}}/>}><AnimationPlayer {...props}/></Suspense>;
}

View File

@ -12,16 +12,7 @@
overflow: hidden;
}
#status {
position: absolute;
left: 8px;
top: 8px;
z-index: 2;
padding: 4px 8px;
border-radius: 6px;
background: rgba(0, 0, 0, 0.45);
color: #fff;
font: 12px/1.3 ui-sans-serif, system-ui, sans-serif;
pointer-events: none;
display: none;
}
#screen canvas { cursor: default; }
</style>
@ -60,17 +51,7 @@
function pasteIntoDesktop(text) {
if (!rfb || rfb.viewOnly || !text) return;
try { rfb.clipboardPasteFrom(text); } catch (_) {}
// x11vnc applies ClientCutText asynchronously. Give X11 a moment before
// sending Ctrl+V so pastes are reliable for Chromium and terminals.
setTimeout(() => {
try {
rfb.sendKey(0xffe3, "ControlLeft", true);
rfb.sendKey(0x0076, "KeyV", true);
rfb.sendKey(0x0076, "KeyV", false);
rfb.sendKey(0xffe3, "ControlLeft", false);
} catch (_) {}
}, 100);
window.parent.postMessage({type:"lazyboy-paste-text", text}, parentOrigin);
}
function pinTaskbar() {
@ -87,23 +68,27 @@
}
}
function applyViewOnly(value) {
if (!rfb) return;
rfb.viewOnly = Boolean(value);
}
function connect() {
setStatus("Connecting to desktop…");
statusEl.style.display = "block";
window.parent.postMessage({ type: "lazyboy-desktop-lost" }, parentOrigin);
rfb = new RFB(document.getElementById("screen"), url);
rfb.viewOnly = flag("view_only", false);
rfb.viewOnly = flag("view_only", true);
rfb.scaleViewport = true;
rfb.clipViewport = false;
rfb.background = "#0f172a";
pinTaskbar();
rfb.addEventListener("connect", () => {
pinTaskbar();
setStatus(rfb.viewOnly ? "View only — click to take control" : "Click the desktop");
try { rfb.focus(); } catch (_) {}
setTimeout(() => { statusEl.style.display = "none"; }, 2500);
window.parent.postMessage({ type: "lazyboy-desktop-ready" }, parentOrigin);
});
rfb.addEventListener("disconnect", (event) => {
statusEl.style.display = "block";
window.parent.postMessage({ type: "lazyboy-desktop-lost" }, parentOrigin);
const clean = event && event.detail && event.detail.clean;
setStatus(clean ? "Disconnected — retrying" : "Desktop connection lost — retrying");
if (reconnectTimer) clearTimeout(reconnectTimer);
@ -117,6 +102,24 @@
});
}
// Capture before noVNC forwards the shortcut; otherwise stale remote
// clipboard contents can be pasted once before the local text arrives.
window.addEventListener("keydown", async (event) => {
if (rfb && !rfb.viewOnly && event.metaKey && event.code === "KeyC") {
event.preventDefault(); event.stopImmediatePropagation();
window.parent.postMessage({type:"lazyboy-copy-request"},parentOrigin); return;
}
if (!rfb || rfb.viewOnly || !(event.ctrlKey || event.metaKey) || event.altKey || event.code !== "KeyV") return;
event.preventDefault();
event.stopImmediatePropagation();
const target = rfb;
try {
const text = await navigator.clipboard.readText();
if (rfb === target && !target.viewOnly) pasteIntoDesktop(text);
} catch (_) {
window.parent.postMessage({ type: "lazyboy-paste-request" }, parentOrigin);
}
}, true);
connect();
window.addEventListener("resize", pinTaskbar);
window.addEventListener("pointerdown", () => {
@ -133,14 +136,19 @@
pasteIntoDesktop(text);
});
window.addEventListener("message", (event) => {
if (event.origin !== parentOrigin) return;
if (!event.data || event.data.type !== "lazyboy-host-clipboard") return;
if (event.origin !== parentOrigin || event.source !== window.parent) return;
if (!event.data) return;
if (event.data.type === "lazyboy-view-only") {
applyViewOnly(event.data.viewOnly);
return;
}
if (event.data.type !== "lazyboy-host-clipboard") return;
pasteIntoDesktop(String(event.data.text || ""));
});
</script>
</head>
<body>
<div id="status">Loading desktop…</div>
<div id="status" hidden>Loading desktop…</div>
<div id="screen"></div>
</body>
</html>

View File

@ -35,3 +35,9 @@ dotenvy = "0.15"
fastembed = "6.0.2"
rmcp = { version = "3.2", default-features = false, features = ["client", "transport-child-process", "transport-streamable-http-client-reqwest"] }
http = "1"
aes-gcm = "0.10"
cron = "0.15"
chrono-tz = "0.10"
rand = "0.8"
cap-std = "3"

View File

@ -5,7 +5,7 @@
//! mounted home and delete anything older than [`INBOX_TTL`].
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use std::time::Duration;
use base64::Engine;
use lazyboy_contracts::SessionAttachment;
@ -13,7 +13,6 @@ use lazyboy_control::resolve_bot_workspace_path;
use rig_core::completion::message::{ImageDetail, ImageMediaType, UserContent};
use serde::Serialize;
use serde_json::{Value, json};
use tokio::fs;
use crate::computer;
use crate::db::{Actor, parse_mode};
@ -41,6 +40,7 @@ pub struct StoredAttachment {
#[derive(Debug, Clone)]
pub struct DecodedAttachment {
pub name: String,
pub stored_name: String,
pub mime_type: String,
pub bytes: Vec<u8>,
}
@ -68,8 +68,11 @@ pub fn decode_incoming(items: &[IncomingAttachment]) -> Result<Vec<DecodedAttach
if total > MAX_TOTAL_BYTES {
return Err("附件合計超過 20 MB".into());
}
let (stem, ext) = split_ext(&name);
let stored_name = format!("{stem}-{}{ext}", uuid::Uuid::new_v4());
out.push(DecodedAttachment {
name,
stored_name,
mime_type: mime,
bytes,
});
@ -86,7 +89,7 @@ pub fn stored_blocks(files: &[DecodedAttachment]) -> Vec<Value> {
"name": file.name,
"mimeType": file.mime_type,
"size": file.bytes.len(),
"path": format!("inbox/{}", file.name),
"path": format!("inbox/{}", file.stored_name),
})
})
.collect()
@ -117,15 +120,30 @@ pub async fn stage_for_bots(
let Some(dir) = inbox_dir_for_bot(state, actor, bot_id).await? else {
continue;
};
fs::create_dir_all(&dir)
.await
.map_err(|error| error.to_string())?;
for file in files {
let path = unique_path(&dir, &file.name).await;
fs::write(&path, &file.bytes)
.await
.map_err(|error| error.to_string())?;
}
let root = PathBuf::from(&state.data_dir).join("homes");
let relative = dir
.strip_prefix(&root)
.map_err(|_| "invalid inbox root")?
.to_path_buf();
let files = files.to_vec();
tokio::task::spawn_blocking(move || -> Result<(), String> {
use std::io::Write;
let root = cap_std::fs::Dir::open_ambient_dir(root, cap_std::ambient_authority())
.map_err(|e| e.to_string())?;
root.create_dir_all(&relative).map_err(|e| e.to_string())?;
let inbox = root.open_dir(&relative).map_err(|e| e.to_string())?;
for file in files {
let mut opts = cap_std::fs::OpenOptions::new();
opts.write(true).create_new(true);
let mut output = inbox
.open_with(&file.stored_name, &opts)
.map_err(|e| e.to_string())?;
output.write_all(&file.bytes).map_err(|e| e.to_string())?;
}
Ok(())
})
.await
.map_err(|e| e.to_string())??;
}
Ok(())
}
@ -151,8 +169,8 @@ pub async fn llm_parts(
}
for (stored, bytes) in files {
notes.push(format!(
"- {} ({}, {} bytes) at inbox/{}",
stored.name, stored.mime_type, stored.size, stored.name
"- {} ({}, {} bytes) at {}",
stored.name, stored.mime_type, stored.size, stored.path
));
if is_image(&stored.mime_type) {
if vision {
@ -189,21 +207,52 @@ pub async fn llm_parts(
pub async fn sweep_all_inboxes(data_dir: &str) {
let homes = PathBuf::from(data_dir).join("homes");
let Ok(mut spaces) = fs::read_dir(&homes).await else {
let _ = tokio::task::spawn_blocking(move || {
let Ok(root) = cap_std::fs::Dir::open_ambient_dir(homes, cap_std::ambient_authority())
else {
return;
};
let Ok(entries) = root.entries() else {
return;
};
for entry in entries.flatten() {
let Ok(home) = root.open_dir(entry.file_name()) else {
continue;
};
sweep_cap_inbox(&home, "inbox");
if let Ok(bots) = home.open_dir("bots") {
if let Ok(entries) = bots.entries() {
for bot in entries.flatten() {
if let Ok(dir) = bots.open_dir(bot.file_name()) {
sweep_cap_inbox(&dir, "inbox");
}
}
}
}
}
})
.await;
}
fn sweep_cap_inbox(root: &cap_std::fs::Dir, path: &str) {
let Ok(dir) = root.open_dir(path) else {
return;
};
while let Ok(Some(entry)) = spaces.next_entry().await {
let path = entry.path();
if !path.is_dir() {
continue;
}
sweep_dir(&path.join("inbox")).await;
let bots = path.join("bots");
let Ok(mut bots_dir) = fs::read_dir(&bots).await else {
let Ok(entries) = dir.entries() else {
return;
};
for entry in entries.flatten() {
let Ok(meta) = entry.metadata() else {
continue;
};
while let Ok(Some(bot)) = bots_dir.next_entry().await {
sweep_dir(&bot.path().join("inbox")).await;
if meta.is_file()
&& meta
.modified()
.ok()
.and_then(|t| t.into_std().elapsed().ok())
.is_some_and(|age| age > INBOX_TTL)
{
let _ = dir.remove_file(entry.file_name());
}
}
}
@ -256,8 +305,31 @@ async fn load_from_blocks(
let Some(file) = parse_stored(block) else {
continue;
};
let path = dir.join(&file.name);
let bytes = fs::read(&path).await.ok();
let root = PathBuf::from(&state.data_dir).join("homes");
let stored = file.path.strip_prefix("inbox/").unwrap_or(&file.name);
let relative = dir.strip_prefix(&root).ok().map(|p| p.join(stored));
let bytes = if let Some(relative) = relative {
tokio::task::spawn_blocking(move || {
use std::io::Read;
let root =
cap_std::fs::Dir::open_ambient_dir(root, cap_std::ambient_authority()).ok()?;
let input = root.open(relative).ok()?;
if !input.metadata().ok()?.is_file() {
return None;
}
let mut bytes = Vec::new();
input
.take(MAX_BYTES as u64 + 1)
.read_to_end(&mut bytes)
.ok()?;
(bytes.len() <= MAX_BYTES).then_some(bytes)
})
.await
.ok()
.flatten()
} else {
None
};
out.push((file, bytes));
}
out
@ -269,6 +341,23 @@ fn parse_stored(value: &Value) -> Option<StoredAttachment> {
return None;
}
let name = value.get("name").and_then(Value::as_str)?;
if name.is_empty()
|| name == "."
|| name == ".."
|| name.contains(['/', '\\'])
|| name.chars().any(char::is_control)
{
return None;
}
let path = value.get("path").and_then(Value::as_str).unwrap_or("");
if !path.is_empty() {
let Some(stored) = path.strip_prefix("inbox/") else {
return None;
};
if stored.is_empty() || stored == "." || stored == ".." || stored.contains(['/', '\\']) {
return None;
}
}
Some(StoredAttachment {
kind: if kind == "image" { "image" } else { "file" },
name: name.to_string(),
@ -286,41 +375,6 @@ fn parse_stored(value: &Value) -> Option<StoredAttachment> {
})
}
async fn sweep_dir(dir: &Path) {
let Ok(mut entries) = fs::read_dir(dir).await else {
return;
};
let cutoff = SystemTime::now() - INBOX_TTL;
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
let Ok(meta) = fs::metadata(&path).await else {
continue;
};
if !meta.is_file() {
continue;
}
let modified = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
if modified < cutoff {
let _ = fs::remove_file(&path).await;
}
}
}
async fn unique_path(dir: &Path, name: &str) -> PathBuf {
let candidate = dir.join(name);
if fs::metadata(&candidate).await.is_err() {
return candidate;
}
let (stem, ext) = split_ext(name);
for n in 2..1000 {
let next = dir.join(format!("{stem}-{n}{ext}"));
if fs::metadata(&next).await.is_err() {
return next;
}
}
dir.join(format!("{stem}-{}.bin", uuid::Uuid::new_v4()))
}
fn split_ext(name: &str) -> (String, String) {
match name.rfind('.') {
Some(index) if index > 0 => (name[..index].to_string(), name[index..].to_string()),
@ -459,6 +513,7 @@ mod tests {
fn stored_blocks_drop_bytes() {
let files = [DecodedAttachment {
name: "a.png".into(),
stored_name: "a.png".into(),
mime_type: "image/png".into(),
bytes: vec![1, 2, 3],
}];
@ -479,3 +534,30 @@ mod tests {
assert!(decode_incoming(&many).is_err());
}
}
#[cfg(test)]
mod boundary_tests {
use super::*;
#[test]
fn stored_metadata_cannot_escape_inbox() {
for name in ["../secret", "/etc/passwd", "..", "x\\secret"] {
assert!(parse_stored(&json!({"kind":"file","name":name})).is_none());
}
}
#[cfg(unix)]
#[test]
fn symlink_from_computer_home_cannot_read_host_secret() {
let base = std::env::temp_dir().join(format!("lazyboy-boundary-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(base.join("homes")).unwrap();
std::fs::write(base.join("secret"), b"private").unwrap();
std::os::unix::fs::symlink(base.join("secret"), base.join("homes/link")).unwrap();
let root =
cap_std::fs::Dir::open_ambient_dir(base.join("homes"), cap_std::ambient_authority())
.unwrap();
assert!(root.read("link").is_err());
assert!(root.write("link", b"overwritten").is_err());
assert_eq!(std::fs::read(base.join("secret")).unwrap(), b"private");
drop(root);
std::fs::remove_dir_all(base).unwrap();
}
}

View File

@ -6,6 +6,11 @@ use axum::{Json, Router};
use serde::Deserialize;
use serde_json::json;
use sha2::{Digest, Sha256};
use std::{
collections::HashMap,
sync::{Arc, Mutex},
time::{Duration, Instant},
};
use crate::state::AppState;
@ -14,7 +19,7 @@ const COOKIE_NAME: &str = "lazyboy_session";
#[derive(Clone)]
pub struct AuthConfig {
token: Option<String>,
session_value: Option<String>,
sessions: Arc<Mutex<HashMap<String, Instant>>>,
secure_cookie: bool,
}
@ -23,18 +28,12 @@ impl AuthConfig {
let token = std::env::var("LAZYBOY_APP_TOKEN")
.ok()
.filter(|value| !value.trim().is_empty());
let session_value = token.as_ref().map(|value| {
let mut hasher = Sha256::new();
hasher.update(b"lazyboy-session-v1:");
hasher.update(value.as_bytes());
hex::encode(hasher.finalize())
});
let secure_cookie = std::env::var("LAZYBOY_SECURE_COOKIE")
.map(|value| matches!(value.as_str(), "1" | "true" | "yes"))
.unwrap_or(false);
Self {
token,
session_value,
sessions: Arc::new(Mutex::new(HashMap::new())),
secure_cookie,
}
}
@ -58,21 +57,55 @@ impl AuthConfig {
}
fn valid_session(&self, headers: &HeaderMap) -> bool {
let Some(expected) = &self.session_value else {
if !self.enabled() {
return true;
}
let Some(value) = cookie_value(headers, COOKIE_NAME) else {
return false;
};
cookie_value(headers, COOKIE_NAME)
.map(|supplied| constant_time_eq(expected.as_bytes(), supplied.as_bytes()))
.unwrap_or(false)
let key = hex::encode(Sha256::digest(value.as_bytes()));
self.sessions
.lock()
.unwrap()
.get(&key)
.is_some_and(|expires| *expires > Instant::now())
}
fn session_cookie(&self) -> Option<String> {
self.session_value.as_ref().map(|value| {
format!(
"{COOKIE_NAME}={value}; Path=/; HttpOnly; SameSite=Strict; Max-Age=604800{}",
if self.secure_cookie { "; Secure" } else { "" }
)
})
if !self.enabled() {
return None;
}
let value = format!(
"{}{}",
uuid::Uuid::new_v4().simple(),
uuid::Uuid::new_v4().simple()
);
let key = hex::encode(Sha256::digest(value.as_bytes()));
let mut sessions = self.sessions.lock().unwrap();
sessions.retain(|_, expires| *expires > Instant::now());
if sessions.len() >= 4096 {
if let Some(oldest) = sessions
.iter()
.min_by_key(|(_, t)| **t)
.map(|(k, _)| k.clone())
{
sessions.remove(&oldest);
}
}
sessions.insert(key, Instant::now() + Duration::from_secs(604800));
Some(format!(
"{COOKIE_NAME}={value}; Path=/; HttpOnly; SameSite=Strict; Max-Age=604800{}",
if self.secure_cookie { "; Secure" } else { "" }
))
}
fn revoke_session(&self, headers: &HeaderMap) {
if let Some(value) = cookie_value(headers, COOKIE_NAME) {
self.sessions
.lock()
.unwrap()
.remove(&hex::encode(Sha256::digest(value.as_bytes())));
}
}
}
@ -143,7 +176,8 @@ async fn login(
Ok(response)
}
async fn logout() -> Response {
async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
state.auth.revoke_session(&headers);
let mut response = Json(json!({"ok": true})).into_response();
response.headers_mut().insert(
header::SET_COOKIE,
@ -175,3 +209,126 @@ mod tests {
assert!(!constant_time_eq(b"correct", b"correct-longer"));
}
}
/// SameSite cookies do not replace Origin checks (including WebSockets).
pub async fn browser_boundary(
State(state): State<AppState>,
request: Request,
next: Next,
) -> Response {
if !allowed_browser_request(request.headers(), state.auth.enabled()) {
return (
StatusCode::FORBIDDEN,
Json(json!({"message":"cross-origin request rejected"})),
)
.into_response();
}
let private =
request.uri().path().starts_with("/api/") || request.uri().path().starts_with("/view/");
let mut response = next.run(request).await;
let headers = response.headers_mut();
headers.insert(
header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
);
headers.insert(
header::X_FRAME_OPTIONS,
HeaderValue::from_static("SAMEORIGIN"),
);
headers.insert(
header::REFERRER_POLICY,
HeaderValue::from_static("same-origin"),
);
if private {
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
}
response
}
fn allowed_browser_request(headers: &HeaderMap, authenticated_mode: bool) -> bool {
if headers.get("sec-fetch-site").and_then(|v| v.to_str().ok()) == Some("cross-site") {
return false;
}
let Some(host) = headers.get(header::HOST).and_then(|v| v.to_str().ok()) else {
return false;
};
let Ok(destination) = reqwest::Url::parse(&format!("http://{host}")) else {
return false;
};
if !authenticated_mode
&& !matches!(
destination.host_str(),
Some("localhost" | "127.0.0.1" | "[::1]")
)
{
return false;
}
if let Some(origin) = headers.get(header::ORIGIN) {
let Ok(origin) = origin
.to_str()
.ok()
.and_then(|s| reqwest::Url::parse(s).ok())
.ok_or(())
else {
return false;
};
let Ok(expected) = reqwest::Url::parse(&format!("{}://{host}", origin.scheme())) else {
return false;
};
if !matches!(origin.scheme(), "http" | "https") || origin.origin() != expected.origin() {
return false;
}
}
true
}
#[cfg(test)]
mod origin_tests {
use super::*;
#[test]
fn blocks_cross_origin_and_dns_rebinding() {
let mut headers = HeaderMap::new();
headers.insert(header::HOST, "localhost:3101".parse().unwrap());
assert!(allowed_browser_request(&headers, false));
headers.insert(header::ORIGIN, "https://evil.example".parse().unwrap());
assert!(!allowed_browser_request(&headers, true));
headers.insert(header::ORIGIN, "http://localhost:3101".parse().unwrap());
assert!(allowed_browser_request(&headers, true));
headers.remove(header::ORIGIN);
headers.insert(header::HOST, "rebinding.example".parse().unwrap());
assert!(!allowed_browser_request(&headers, false));
}
}
#[cfg(test)]
mod session_tests {
use super::*;
#[test]
fn sessions_are_distinct_expire_and_are_revoked_on_logout() {
let config = AuthConfig {
token: Some("secret".into()),
sessions: Arc::new(Mutex::new(HashMap::new())),
secure_cookie: true,
};
let first = config.session_cookie().unwrap();
let second = config.session_cookie().unwrap();
assert_ne!(first, second);
let mut headers = HeaderMap::new();
headers.insert(
header::COOKIE,
first.split(';').next().unwrap().parse().unwrap(),
);
assert!(config.valid_session(&headers));
config.revoke_session(&headers);
assert!(!config.valid_session(&headers));
headers.insert(
header::COOKIE,
second.split(';').next().unwrap().parse().unwrap(),
);
assert!(config.valid_session(&headers));
for expiry in config.sessions.lock().unwrap().values_mut() {
*expiry = Instant::now() - Duration::from_secs(1);
}
assert!(!config.valid_session(&headers));
}
}

View File

@ -19,6 +19,10 @@ use crate::db::{
};
use crate::state::AppState;
pub const STEP_BOOTING: &str = "電腦啟動中";
pub const STEP_WAKING: &str = "喚醒中";
pub const STEP_HANDOFF: &str = "換手中";
pub fn status_from(
bot_id: &str,
computer: &ComputerRow,
@ -55,6 +59,7 @@ pub fn status_from(
busy_session_id: None,
busy_run_id: None,
busy_step: None,
using_computer: false,
waiting_run_id: None,
waiting_session_id: None,
queued_runs: 0,
@ -320,6 +325,7 @@ async fn probe_computer_container(
],
cwd: None,
timeout_ms: Some(5_000),
stdin: None,
},
&adapter_context(actor, bot_id, "probe"),
)
@ -500,6 +506,21 @@ pub async fn boot(state: &AppState, actor: &Actor, bot_id: &str) -> Result<Compu
.await
.map_err(|error| error.to_string())?;
}
if computer.state == "suspended" && computer.provider_ref.is_some() {
match resume_paused(state, actor, bot_id, &computer).await {
Ok(status) => return Ok(status),
Err(error) => {
tracing::warn!("computer {computer_id} resume failed: {error}");
let _ = sqlx::query(
"UPDATE computers SET state = 'stopped', updated_at = now()
WHERE id = $1 AND state = 'suspended'",
)
.bind(&computer_id)
.execute(state.pool())
.await;
}
}
}
let claimed = sqlx::query(
"UPDATE computers SET state = 'booting', updated_at = now()
WHERE id = $1 AND state IN ('stopped','suspended','error')",
@ -563,6 +584,7 @@ pub async fn boot(state: &AppState, actor: &Actor, bot_id: &str) -> Result<Compu
argv: vec!["mkdir".into(), "-p".into(), "shared".into(), folder],
cwd: None,
timeout_ms: Some(10_000),
stdin: None,
},
&ctx,
),
@ -604,6 +626,71 @@ pub async fn boot(state: &AppState, actor: &Actor, bot_id: &str) -> Result<Compu
Ok(status_from(bot_id, &computer, screen.as_ref(), None))
}
async fn resume_paused(
state: &AppState,
actor: &Actor,
bot_id: &str,
computer: &ComputerRow,
) -> Result<ComputerStatus, String> {
let ctx = adapter_context(actor, bot_id, "resume");
let home = home_path(&state.data_dir, &computer.home_key);
let resumed = match tokio::time::timeout(
Duration::from_secs(20),
state.sandbox.resume(
ProvisionRequest {
home_key: computer.home_key.clone(),
home_path: home.to_string_lossy().into_owned(),
provider_ref: computer.provider_ref.clone(),
},
&ctx,
),
)
.await
{
Ok(Ok(resumed)) => resumed,
Ok(Err(error)) => return Err(error.to_string()),
Err(_) => return Err("computer resume timed out after 20 seconds".into()),
};
let running = sqlx::query(
"UPDATE computers SET state = 'running', provider_ref = $2, kind = $3, updated_at = now()
WHERE id = $1 AND state = 'suspended'",
)
.bind(&computer.id)
.bind(&resumed.provider_ref)
.bind(resumed.kind.as_str())
.execute(state.pool())
.await
.map_err(|error| error.to_string())?;
if running.rows_affected() != 1 {
let current = state
.db
.get_computer(&computer.id)
.await
.map_err(|error| error.to_string())?
.ok_or_else(|| "computer not found".to_string())?;
if current.state == "running" {
let screen = ensure_bot_screen(state, actor, bot_id, &current, None)
.await
.ok()
.and_then(|bound| bound.row);
return Ok(status_from(bot_id, &current, screen.as_ref(), None));
}
return Err("computer resume was superseded".into());
}
let computer = state
.db
.get_computer(&computer.id)
.await
.map_err(|error| error.to_string())?
.ok_or_else(|| "computer not found".to_string())?;
let screen = ensure_bot_screen(state, actor, bot_id, &computer, None)
.await
.ok()
.and_then(|bound| bound.row);
restore_computer_screens(state, actor, &computer, bot_id).await;
Ok(status_from(bot_id, &computer, screen.as_ref(), None))
}
async fn mark_boot_error(state: &AppState, computer_id: &str, provider_ref: Option<&str>) {
if let Err(error) = sqlx::query(
"UPDATE computers SET state = 'error', provider_ref = COALESCE($2, provider_ref), updated_at = now()
@ -813,6 +900,7 @@ pub async fn takeover(
.await
.map_err(|error| error.to_string())?;
for (run_id, thread_id) in paused {
crate::runs::set_run_step(state, &run_id, STEP_HANDOFF).await;
let _ = crate::runs::append_bot_message(
state,
&thread_id,
@ -878,6 +966,11 @@ pub async fn heartbeat(state: &AppState, actor: &Actor, bot_id: &str) -> Result<
return Ok(());
};
let expires = Utc::now() + TimeDelta::minutes(15);
sqlx::query("UPDATE computers SET updated_at = now() WHERE id = $1")
.bind(&computer_id)
.execute(state.pool())
.await
.map_err(|error| error.to_string())?;
sqlx::query(
"UPDATE computer_screens SET control_lease_expires_at = $2, updated_at = now()
WHERE computer_id = $1 AND bot_id = $3 AND control_holder = 'user'",
@ -933,61 +1026,102 @@ pub async fn idle_loop(state: AppState) {
loop {
tokio::time::sleep(Duration::from_secs(60)).await;
crate::attachments::sweep_all_inboxes(&state.data_dir).await;
let cutoff = Utc::now() - TimeDelta::minutes(10);
let rows = sqlx::query_as::<_, ComputerRow>(
"SELECT id, space_id, user_id, scope, scope_key, home_key, home_revision, kind, provider_ref, state,
control_holder, control_lease_id, control_lease_expires_at, control_bot_id, control_run_id,
execution_run_id, execution_bot_id, execution_lease_expires_at, execution_fence,
browser_profile_mode
FROM computers WHERE state = 'running' AND updated_at < $1",
)
.bind(cutoff)
.fetch_all(state.pool())
.await;
let Ok(rows) = rows else { continue };
for computer in rows {
let active: Result<Option<(i64,)>, _> = sqlx::query_as(
"SELECT 1 FROM runs WHERE status IN ('queued','leased','running','waiting_input','waiting_takeover')
AND bot_id IN (SELECT id FROM bots WHERE computer_id = $1)
UNION ALL
SELECT 1 FROM taught_skills WHERE status IN ('recording','drafting')
AND bot_id IN (SELECT id FROM bots WHERE computer_id = $1)
LIMIT 1",
)
.bind(&computer.id)
.fetch_optional(state.pool())
.await;
if matches!(active, Ok(Some(_))) {
pause_idle_computers(&state).await;
stop_parked_computers(&state).await;
}
}
async fn computer_has_active_work(state: &AppState, computer_id: &str) -> bool {
let active: Result<Option<(i64,)>, _> = sqlx::query_as(
"SELECT 1 FROM runs WHERE status IN ('queued','leased','running','waiting_input','waiting_takeover')
AND bot_id IN (SELECT id FROM bots WHERE computer_id = $1)
UNION ALL
SELECT 1 FROM taught_skills WHERE status IN ('recording','drafting')
AND bot_id IN (SELECT id FROM bots WHERE computer_id = $1)
LIMIT 1",
)
.bind(computer_id)
.fetch_optional(state.pool())
.await;
matches!(active, Ok(Some(_)))
}
fn idle_adapter(computer: &ComputerRow, operation: &str) -> AdapterContext {
AdapterContext {
operation_id: operation.into(),
space_id: computer.space_id.clone(),
user_id: computer.user_id.clone(),
..Default::default()
}
}
async fn pause_idle_computers(state: &AppState) {
let cutoff = Utc::now() - TimeDelta::minutes(10);
let rows = sqlx::query_as::<_, ComputerRow>(
"SELECT id, space_id, user_id, scope, scope_key, home_key, home_revision, kind, provider_ref, state,
control_holder, control_lease_id, control_lease_expires_at, control_bot_id, control_run_id,
execution_run_id, execution_bot_id, execution_lease_expires_at, execution_fence,
browser_profile_mode
FROM computers WHERE state = 'running' AND updated_at < $1",
)
.bind(cutoff)
.fetch_all(state.pool())
.await;
let Ok(rows) = rows else { return };
for computer in rows {
if computer_has_active_work(state, &computer.id).await {
continue;
}
if let Some(computer_ref) = computer_ref(&computer) {
if state
.sandbox
.suspend(&computer_ref, &idle_adapter(&computer, "idle"))
.await
.is_err()
{
continue;
}
if let Some(provider_ref) = &computer.provider_ref {
let ctx = AdapterContext {
operation_id: "idle".into(),
space_id: computer.space_id.clone(),
user_id: computer.user_id.clone(),
..Default::default()
};
let _ = state
.sandbox
.stop(
&lazyboy_control::ComputerRef {
id: provider_ref.clone(),
home_key: computer.home_key.clone(),
kind: parse_kind(&computer.kind),
provider_ref: provider_ref.clone(),
fresh: false,
},
&ctx,
)
.await;
}
let _ = sqlx::query(
"UPDATE computers SET state = 'stopped', updated_at = now() WHERE id = $1",
)
.bind(&computer.id)
.execute(state.pool())
.await;
}
let _ = sqlx::query(
"UPDATE computers SET state = 'suspended', updated_at = now()
WHERE id = $1 AND state = 'running'",
)
.bind(&computer.id)
.execute(state.pool())
.await;
}
}
async fn stop_parked_computers(state: &AppState) {
let cutoff = Utc::now() - TimeDelta::hours(6);
let rows = sqlx::query_as::<_, ComputerRow>(
"SELECT id, space_id, user_id, scope, scope_key, home_key, home_revision, kind, provider_ref, state,
control_holder, control_lease_id, control_lease_expires_at, control_bot_id, control_run_id,
execution_run_id, execution_bot_id, execution_lease_expires_at, execution_fence,
browser_profile_mode
FROM computers WHERE state = 'suspended' AND updated_at < $1",
)
.bind(cutoff)
.fetch_all(state.pool())
.await;
let Ok(rows) = rows else { return };
for computer in rows {
if computer_has_active_work(state, &computer.id).await {
continue;
}
if let Some(computer_ref) = computer_ref(&computer) {
let _ = state
.sandbox
.stop(&computer_ref, &idle_adapter(&computer, "parked"))
.await;
}
let _ = sqlx::query(
"UPDATE computers SET state = 'stopped', updated_at = now()
WHERE id = $1 AND state = 'suspended'",
)
.bind(&computer.id)
.execute(state.pool())
.await;
}
}
@ -1069,6 +1203,11 @@ pub async fn current_status(
status.busy_run_id = Some(run.id.clone());
status.busy_session_id = Some(run.thread_id.clone());
status.busy_step = run.step.clone();
status.using_computer = screen
.as_ref()
.and_then(|row| row.execution_run_id.as_deref())
== Some(run.id.as_str())
|| computer.execution_run_id.as_deref() == Some(run.id.as_str());
}
if let Some(run) = waiting {
status.waiting_run_id = Some(run.id.clone());

View File

@ -8,11 +8,13 @@ mod memory;
mod rooms;
mod routes;
mod runs;
mod schedules;
mod screen_proxy;
mod sessions;
mod skills;
mod state;
mod tools;
mod vault;
mod workspace;
use std::net::SocketAddr;
@ -57,6 +59,10 @@ async fn main() {
tokio::spawn(async move {
computer::idle_loop(idle_state).await;
});
let schedule_state = state.clone();
tokio::spawn(async move {
schedules::tick_loop(schedule_state).await;
});
let bind = std::env::var("API_BIND").unwrap_or_else(|_| "127.0.0.1:3101".into());
let addr: SocketAddr = bind.parse().expect("API_BIND");
@ -66,16 +72,20 @@ async fn main() {
);
}
let web_dir = std::env::var("LAZYBOY_WEB_DIR").unwrap_or_else(|_| "apps/web".into());
let web_dir = std::env::var("LAZYBOY_WEB_DIR").unwrap_or_else(|_| "apps/web/dist".into());
let app = Router::new()
.route(
"/api/health",
axum::routing::get(|| async { axum::Json(serde_json::json!({"ok": true})) }),
)
.merge(auth::public_router(state.clone()))
.merge(routes::router(state))
.merge(routes::router(state.clone()))
.fallback_service(ServeDir::new(web_dir))
.layer(DefaultBodyLimit::max(24 * 1024 * 1024));
.layer(DefaultBodyLimit::max(28 * 1024 * 1024))
.layer(axum::middleware::from_fn_with_state(
state,
auth::browser_boundary,
));
tracing::info!("api listening on {addr}");
let listener = tokio::net::TcpListener::bind(addr).await.expect("bind");

View File

@ -254,6 +254,21 @@ async fn connect_client(row: &McpRow) -> Result<LiveClient, String> {
.filter(|value| !value.is_empty())
.ok_or_else(|| "stdio 需要 command".to_string())?;
let mut cmd = Command::new(command);
cmd.env_clear();
for key in [
"PATH",
"HOME",
"LANG",
"LC_ALL",
"TMPDIR",
"PYTHONPATH",
"NODE_EXTRA_CA_CERTS",
] {
if let Some(value) = std::env::var_os(key) {
cmd.env(key, value);
}
}
cmd.kill_on_drop(true);
cmd.args(&row.args);
cmd.stdin(std::process::Stdio::piped());
cmd.stdout(std::process::Stdio::piped());

View File

@ -19,6 +19,8 @@ pub fn router(state: AppState) -> Router {
.merge(crate::mcp::router())
.merge(crate::workspace::router())
.merge(crate::skills::router())
.merge(crate::vault::router())
.merge(crate::schedules::router())
.route("/api/bots", get(list_bots).post(create_bot))
.route(
"/api/bots/{id}",
@ -649,7 +651,7 @@ async fn screen_url(
.await
.map_err(|_| StatusCode::BAD_GATEWAY)?;
Ok(Json(json!({
"url": format!("/view/{id}/vnc.html?view_only={}", !interactive)
"url": format!("/view/{id}/vnc.html")
})))
}
@ -722,6 +724,39 @@ async fn input(
return Err(StatusCode::CONFLICT);
}
let computer_ref = computer::computer_ref(&computer).ok_or(StatusCode::BAD_REQUEST)?;
if body.kind == "clipboard" || body.kind == "copy" {
let text = body.text.unwrap_or_default();
if text.len() > 1024 * 1024 {
return Err(StatusCode::PAYLOAD_TOO_LARGE);
}
let context =
computer::adapter_context_for(&actor, &id, "clipboard", screen.as_ref(), None);
let mut argv =
lazyboy_control::paste_command_on(context.display.as_deref().unwrap_or(":1"));
if body.kind == "copy" {
argv.push("copy".into());
}
let result = state
.sandbox
.execute(
&computer_ref,
lazyboy_control::CommandRequest {
argv,
cwd: None,
timeout_ms: Some(10_000),
stdin: Some(text),
},
&context,
)
.await
.map_err(|_| StatusCode::BAD_GATEWAY)?;
if result.code != 0 {
return Err(StatusCode::BAD_GATEWAY);
}
return Ok(Json(
json!({"ok":true,"text":if body.kind=="copy" {Some(result.stdout)} else {None}}),
));
}
let action = match body.kind.as_str() {
"key" => lazyboy_contracts::ComputerAction::Key {
key: body.key.unwrap_or_default(),

File diff suppressed because it is too large Load Diff

794
crates/api/src/schedules.rs Normal file
View File

@ -0,0 +1,794 @@
//! Recurring bot runs. Chat or the computer panel can create them; a tick
//! loop turns due rows into ordinary queued runs.
use std::str::FromStr;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::routing::{get, post};
use axum::{Json, Router};
use chrono::{DateTime, Utc};
use chrono_tz::Tz;
use cron::Schedule;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sqlx::FromRow;
use uuid::Uuid;
use crate::db::Actor;
use crate::state::AppState;
#[derive(Debug, Clone, Serialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct ScheduleRow {
pub id: String,
pub bot_id: String,
pub thread_id: Option<String>,
pub name: String,
pub cron: String,
pub timezone: String,
pub instructions: String,
pub enabled: bool,
pub last_run_at: Option<DateTime<Utc>>,
pub next_run_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateSchedule {
pub name: String,
pub cron: String,
#[serde(default)]
pub timezone: String,
pub instructions: String,
#[serde(default)]
pub thread_id: Option<String>,
#[serde(default = "default_enabled")]
pub enabled: bool,
}
fn default_enabled() -> bool {
true
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateSchedule {
pub name: Option<String>,
pub cron: Option<String>,
pub timezone: Option<String>,
pub instructions: Option<String>,
pub enabled: Option<bool>,
pub thread_id: Option<String>,
}
pub fn router() -> Router<AppState> {
Router::new()
.route(
"/api/bots/{bot_id}/schedules",
get(list_http).post(create_http),
)
.route(
"/api/schedules/{id}",
axum::routing::patch(update_http).delete(delete_http),
)
.route("/api/schedules/{id}/run", post(run_now_http))
}
async fn scoped_actor(state: &AppState, bot_id: &str) -> Result<Actor, StatusCode> {
let actor = state
.bootstrap()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
state
.db
.get_bot(&actor, bot_id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
Ok(actor)
}
async fn list_http(
State(state): State<AppState>,
Path(bot_id): Path<String>,
) -> Result<Json<Vec<Value>>, StatusCode> {
let actor = scoped_actor(&state, &bot_id).await?;
let rows = list(&state, &actor, &bot_id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(rows.into_iter().map(public_json).collect()))
}
async fn create_http(
State(state): State<AppState>,
Path(bot_id): Path<String>,
Json(input): Json<CreateSchedule>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let actor = scoped_actor(&state, &bot_id)
.await
.map_err(|status| (status, Json(json!({"message":"bot not found"}))))?;
create(&state, &actor, &bot_id, input)
.await
.map(|row| Json(public_json(row)))
.map_err(|error| (StatusCode::BAD_REQUEST, Json(json!({"message": error}))))
}
async fn update_http(
State(state): State<AppState>,
Path(id): Path<String>,
Json(input): Json<UpdateSchedule>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let actor = state.bootstrap().await.map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message":"actor"})),
)
})?;
update(&state, &actor, &id, input)
.await
.map_err(|error| (StatusCode::BAD_REQUEST, Json(json!({"message": error}))))?
.map(|row| Json(public_json(row)))
.ok_or((
StatusCode::NOT_FOUND,
Json(json!({"message":"schedule not found"})),
))
}
async fn delete_http(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<StatusCode, StatusCode> {
let actor = state
.bootstrap()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let deleted = sqlx::query("DELETE FROM schedules WHERE id=$1 AND space_id=$2 AND user_id=$3")
.bind(&id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.execute(state.pool())
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if deleted.rows_affected() == 0 {
return Err(StatusCode::NOT_FOUND);
}
Ok(StatusCode::NO_CONTENT)
}
async fn run_now_http(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let actor = state.bootstrap().await.map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message":"actor"})),
)
})?;
let row = get_row(&state, &actor, &id)
.await
.map_err(|error| (StatusCode::BAD_REQUEST, Json(json!({"message": error}))))?
.ok_or((
StatusCode::NOT_FOUND,
Json(json!({"message":"schedule not found"})),
))?;
enqueue(&state, &row, false)
.await
.map(|run_id| Json(json!({"ok": true, "runId": run_id})))
.map_err(|error| (StatusCode::BAD_REQUEST, Json(json!({"message": error}))))
}
pub fn public_json(row: ScheduleRow) -> Value {
json!({
"id": row.id,
"botId": row.bot_id,
"threadId": row.thread_id,
"name": row.name,
"cron": row.cron,
"timezone": row.timezone,
"instructions": row.instructions,
"enabled": row.enabled,
"human": describe_cron(&row.cron),
"lastRunAt": row.last_run_at,
"nextRunAt": row.next_run_at,
"createdAt": row.created_at,
"updatedAt": row.updated_at,
})
}
pub async fn list(
state: &AppState,
actor: &Actor,
bot_id: &str,
) -> Result<Vec<ScheduleRow>, String> {
sqlx::query_as(
"SELECT id, bot_id, thread_id, name, cron, timezone, instructions, enabled,
last_run_at, next_run_at, created_at, updated_at
FROM schedules
WHERE bot_id=$1 AND space_id=$2 AND user_id=$3
ORDER BY enabled DESC, next_run_at ASC NULLS LAST, updated_at DESC",
)
.bind(bot_id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.fetch_all(state.pool())
.await
.map_err(|error| error.to_string())
}
pub async fn get_row(
state: &AppState,
actor: &Actor,
id: &str,
) -> Result<Option<ScheduleRow>, String> {
sqlx::query_as(
"SELECT id, bot_id, thread_id, name, cron, timezone, instructions, enabled,
last_run_at, next_run_at, created_at, updated_at
FROM schedules WHERE id=$1 AND space_id=$2 AND user_id=$3",
)
.bind(id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.fetch_optional(state.pool())
.await
.map_err(|error| error.to_string())
}
pub async fn create(
state: &AppState,
actor: &Actor,
bot_id: &str,
input: CreateSchedule,
) -> Result<ScheduleRow, String> {
validate_thread(state, actor, bot_id, input.thread_id.as_deref()).await?;
let name = clean_name(&input.name)?;
let instructions = clean_instructions(&input.instructions)?;
let cron = validate_cron(&input.cron)?;
let timezone = validate_timezone(&input.timezone)?;
let next = if input.enabled {
Some(next_fire(&cron, &timezone, Utc::now())?)
} else {
None
};
let id = Uuid::new_v4().to_string();
sqlx::query_as(
"INSERT INTO schedules
(id, space_id, user_id, bot_id, thread_id, name, cron, timezone, instructions, enabled, next_run_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
RETURNING id, bot_id, thread_id, name, cron, timezone, instructions, enabled,
last_run_at, next_run_at, created_at, updated_at",
)
.bind(&id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.bind(bot_id)
.bind(input.thread_id.as_deref())
.bind(name)
.bind(&cron)
.bind(&timezone)
.bind(instructions)
.bind(input.enabled)
.bind(next)
.fetch_one(state.pool())
.await
.map_err(|error| error.to_string())
}
async fn update(
state: &AppState,
actor: &Actor,
id: &str,
input: UpdateSchedule,
) -> Result<Option<ScheduleRow>, String> {
let existing = match get_row(state, actor, id).await? {
Some(row) => row,
None => return Ok(None),
};
let name = match input.name {
Some(value) => clean_name(&value)?,
None => existing.name.clone(),
};
let instructions = match input.instructions {
Some(value) => clean_instructions(&value)?,
None => existing.instructions.clone(),
};
let cron = match input.cron {
Some(value) => validate_cron(&value)?,
None => existing.cron.clone(),
};
let timezone = match input.timezone {
Some(value) => validate_timezone(&value)?,
None => existing.timezone.clone(),
};
let enabled = input.enabled.unwrap_or(existing.enabled);
let thread_id = input.thread_id.or(existing.thread_id);
validate_thread(state, actor, &existing.bot_id, thread_id.as_deref()).await?;
let next = if enabled {
Some(next_fire(&cron, &timezone, Utc::now())?)
} else {
None
};
sqlx::query_as(
"UPDATE schedules
SET name=$1, cron=$2, timezone=$3, instructions=$4, enabled=$5, thread_id=$6,
next_run_at=$7, updated_at=now()
WHERE id=$8 AND space_id=$9 AND user_id=$10
RETURNING id, bot_id, thread_id, name, cron, timezone, instructions, enabled,
last_run_at, next_run_at, created_at, updated_at",
)
.bind(name)
.bind(cron)
.bind(timezone)
.bind(instructions)
.bind(enabled)
.bind(thread_id)
.bind(next)
.bind(id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.fetch_optional(state.pool())
.await
.map_err(|error| error.to_string())
}
pub async fn tick_loop(state: AppState) {
loop {
tokio::time::sleep(std::time::Duration::from_secs(15)).await;
if let Err(error) = fire_due(&state).await {
tracing::warn!("schedule tick failed: {error}");
}
}
}
async fn fire_due(state: &AppState) -> Result<(), String> {
let due: Vec<ScheduleRow> = sqlx::query_as(
"SELECT id, bot_id, thread_id, name, cron, timezone, instructions, enabled,
last_run_at, next_run_at, created_at, updated_at
FROM schedules
WHERE enabled AND next_run_at IS NOT NULL AND next_run_at <= now()
ORDER BY next_run_at ASC
LIMIT 8",
)
.fetch_all(state.pool())
.await
.map_err(|error| error.to_string())?;
for row in due {
if let Err(error) = enqueue(state, &row, true).await {
tracing::warn!("schedule {} failed to enqueue: {error}", row.id);
}
}
Ok(())
}
async fn enqueue(state: &AppState, row: &ScheduleRow, from_tick: bool) -> Result<String, String> {
let mut tx = state.pool().begin().await.map_err(|e| e.to_string())?;
let locked: Option<ScheduleRow> = if from_tick {
sqlx::query_as("SELECT id,bot_id,thread_id,name,cron,timezone,instructions,enabled,last_run_at,next_run_at,created_at,updated_at FROM schedules WHERE id=$1 AND enabled AND next_run_at<=now() FOR UPDATE SKIP LOCKED")
.bind(&row.id).fetch_optional(&mut *tx).await.map_err(|e| e.to_string())?
} else {
Some(row.clone())
};
let Some(row) = locked.as_ref() else {
return Ok(String::new());
};
let bot = sqlx::query_as::<_, (String, String, String)>(
"SELECT space_id, user_id, id FROM bots WHERE id=$1",
)
.bind(&row.bot_id)
.fetch_optional(state.pool())
.await
.map_err(|error| error.to_string())?
.ok_or_else(|| "bot not found".to_string())?;
let thread_id = match row.thread_id.as_deref() {
Some(id) => id.to_string(),
None => latest_thread(state, &row.bot_id).await?,
};
validate_thread(
state,
&Actor {
space_id: bot.0.clone(),
user_id: bot.1.clone(),
},
&row.bot_id,
Some(&thread_id),
)
.await?;
let prompt = format!("Scheduled task 「{}」:\n{}", row.name, row.instructions);
let seq: i32 = sqlx::query_scalar(
"UPDATE threads SET next_message_seq=next_message_seq+1, updated_at=now()
WHERE id=$1 RETURNING next_message_seq-1",
)
.bind(&thread_id)
.fetch_one(&mut *tx)
.await
.map_err(|error| error.to_string())?;
let run_id = Uuid::new_v4().to_string();
let message_id = Uuid::new_v4().to_string();
let body = if from_tick {
format!("[排程] {}", row.name)
} else {
format!("[排程試跑] {}", row.name)
};
sqlx::query(
"INSERT INTO messages (id,thread_id,seq,role,body,blocks,run_id)
VALUES ($1,$2,$3,'user',$4,$5,$6)",
)
.bind(&message_id)
.bind(&thread_id)
.bind(seq)
.bind(&body)
.bind(json!([{"kind":"scheduleRun","scheduleId":row.id,"name":row.name,"human":describe_cron(&row.cron)}]))
.bind(&run_id)
.execute(&mut *tx)
.await
.map_err(|error| error.to_string())?;
sqlx::query(
"INSERT INTO runs (id,space_id,bot_id,thread_id,user_id,status,prompt,checkpoint)
VALUES ($1,$2,$3,$4,$5,'queued',$6,$7)",
)
.bind(&run_id)
.bind(&bot.0)
.bind(&row.bot_id)
.bind(&thread_id)
.bind(&bot.1)
.bind(&prompt)
.bind(json!({"messageSeq": seq, "scheduleId": row.id}))
.execute(&mut *tx)
.await
.map_err(|error| error.to_string())?;
if from_tick {
let next = next_after_due(
&row.cron,
&row.timezone,
row.next_run_at.unwrap_or_else(Utc::now),
Utc::now(),
)?;
sqlx::query(
"UPDATE schedules SET last_run_at=now(),next_run_at=$2,updated_at=now() WHERE id=$1",
)
.bind(&row.id)
.bind(next)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
}
tx.commit().await.map_err(|error| error.to_string())?;
Ok(run_id)
}
async fn latest_thread(state: &AppState, bot_id: &str) -> Result<String, String> {
sqlx::query_scalar(
"SELECT id FROM threads
WHERE bot_id=$1 AND status='active' AND room_id IS NULL
ORDER BY updated_at DESC LIMIT 1",
)
.bind(bot_id)
.fetch_optional(state.pool())
.await
.map_err(|error| error.to_string())?
.ok_or_else(|| "bot has no conversation to run this schedule in".into())
}
fn clean_name(name: &str) -> Result<String, String> {
let value = name.trim();
if value.is_empty() || value.chars().count() > 80 {
return Err("name must be 180 characters".into());
}
Ok(value.to_string())
}
fn clean_instructions(text: &str) -> Result<String, String> {
let value = text.trim();
if value.is_empty() || value.chars().count() > 8_000 {
return Err("instructions must be 18000 characters".into());
}
Ok(value.to_string())
}
pub fn validate_cron(expr: &str) -> Result<String, String> {
let trimmed = expr.trim();
if interval_seconds(trimmed)?.is_none() {
parse_schedule(trimmed)?;
}
Ok(trimmed.to_string())
}
fn validate_timezone(value: &str) -> Result<String, String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Ok("Asia/Taipei".into());
}
Tz::from_str(trimmed).map_err(|_| "invalid IANA timezone".to_string())?;
Ok(trimmed.to_string())
}
fn interval_seconds(expr: &str) -> Result<Option<i64>, String> {
let Some(raw) = expr.strip_prefix("@every ") else {
return Ok(None);
};
if !raw.is_ascii() || raw.len() < 2 {
return Err("invalid fixed interval".into());
}
let (number, unit) = raw.split_at(raw.len() - 1);
let n: i64 = number.parse().map_err(|_| "invalid interval")?;
if !(1..=365).contains(&n) {
return Err("interval must be 1365".into());
}
let scale = match unit {
"m" => 60,
"h" => 3600,
"d" => 86400,
_ => return Err("interval unit must be m, h, or d".into()),
};
Ok(Some(n * scale))
}
fn parse_schedule(expr: &str) -> Result<Schedule, String> {
let fields: Vec<&str> = expr.split_whitespace().collect();
if fields.len() != 5 {
return Err("cron must have exactly 5 fields".into());
}
if fields[2] != "*" && fields[4] != "*" {
return Err("use either day-of-month or day-of-week, not both".into());
}
let weekday = standard_weekdays(fields[4])?;
let six = format!(
"0 {} {} {} {} {}",
fields[0], fields[1], fields[2], fields[3], weekday
);
Schedule::from_str(&six).map_err(|error| format!("invalid cron: {error}"))
}
// UI/API use Unix weekdays (0/7=Sun, 1=Mon); cron crate uses 1=Sun.
fn standard_weekdays(field: &str) -> Result<String, String> {
if field == "*" {
return Ok("*".into());
}
fn value(raw: &str) -> Result<u32, String> {
match raw.to_ascii_uppercase().as_str() {
"SUN" => Ok(0),
"MON" => Ok(1),
"TUE" => Ok(2),
"WED" => Ok(3),
"THU" => Ok(4),
"FRI" => Ok(5),
"SAT" => Ok(6),
_ => raw
.parse::<u32>()
.ok()
.filter(|n| *n <= 7)
.ok_or_else(|| "invalid weekday".into()),
}
}
let mut days = std::collections::BTreeSet::new();
for part in field.split(',') {
let (base, step) = if let Some((base, n)) = part.split_once('/') {
(
base,
n.parse::<u32>()
.ok()
.filter(|n| (1..=7).contains(n))
.ok_or("invalid weekday step")?,
)
} else {
(part, 1)
};
let (start, end) = if base == "*" {
(0, 6)
} else if let Some((a, b)) = base.split_once('-') {
(value(a)?, value(b)?)
} else {
let n = value(base)?;
(n, n)
};
if start > end {
return Err("weekday range must be ascending".into());
}
for n in (start..=end).step_by(step as usize) {
days.insert(n % 7 + 1);
}
}
Ok(days
.into_iter()
.map(|n| n.to_string())
.collect::<Vec<_>>()
.join(","))
}
pub fn next_fire(expr: &str, timezone: &str, from: DateTime<Utc>) -> Result<DateTime<Utc>, String> {
if let Some(seconds) = interval_seconds(expr)? {
return Ok(from + chrono::TimeDelta::seconds(seconds));
}
let schedule = parse_schedule(expr)?;
let tz: Tz = Tz::from_str(timezone).map_err(|_| "invalid IANA timezone")?;
let local = from.with_timezone(&tz);
schedule
.after(&local)
.next()
.map(|when| when.with_timezone(&Utc))
.ok_or_else(|| "cron has no future run".into())
}
pub fn describe_cron(expr: &str) -> String {
if let Ok(Some(seconds)) = interval_seconds(expr) {
return format!("每隔 {} 分鐘(固定間隔)", seconds / 60);
}
let parts: Vec<&str> = expr.trim().split_whitespace().collect();
if parts.len() != 5 {
return expr.to_string();
}
let (min, hour, dom, month, dow) = (parts[0], parts[1], parts[2], parts[3], parts[4]);
if expr.trim() == "* * * * *" {
return "每分鐘".into();
}
if let Some(rest) = min.strip_prefix("*/") {
if hour == "*" && dom == "*" && month == "*" && dow == "*" {
return if rest
.parse::<u32>()
.ok()
.is_some_and(|n| n > 0 && 60 % n == 0)
{
format!("{rest} 分鐘")
} else {
format!("日曆排程:{expr}")
};
}
}
if min == "0" && hour == "*" && dom == "*" && month == "*" && dow == "*" {
return "每小時".into();
}
if min == "0" {
if let Some(rest) = hour.strip_prefix("*/") {
if dom == "*" && month == "*" && dow == "*" {
return if rest
.parse::<u32>()
.ok()
.is_some_and(|n| n > 0 && 24 % n == 0)
{
format!("{rest} 小時")
} else {
format!("日曆排程:{expr}")
};
}
}
}
if min.parse::<u32>().is_ok() && hour.parse::<u32>().is_ok() && month == "*" {
let at = format!("{hour:0>2}:{min:0>2}");
if dom == "*" && dow == "*" {
return format!("每天 {at}");
}
if dom == "*" && dow == "1-5" {
return format!("工作日 {at}");
}
if dom == "*" && dow == "1" {
return format!("每週一 {at}");
}
if dom == "1" && dow == "*" {
return format!("每月 1 日 {at}");
}
}
expr.to_string()
}
#[cfg(test)]
mod tests {
use super::{describe_cron, next_fire, validate_cron};
use chrono::TimeZone;
#[test]
fn weekday_nine_is_valid() {
assert_eq!(validate_cron("0 9 * * 1-5").unwrap(), "0 9 * * 1-5");
assert!(validate_cron("not cron").is_err());
}
#[test]
fn describes_common_patterns() {
assert_eq!(describe_cron("0 9 * * *"), "每天 09:00");
assert_eq!(describe_cron("0 9 * * 1-5"), "工作日 09:00");
assert_eq!(describe_cron("*/15 * * * *"), "每 15 分鐘");
}
#[test]
fn next_fire_is_in_the_future() {
let from = chrono::Utc.with_ymd_and_hms(2026, 9, 5, 0, 0, 0).unwrap();
let next = next_fire("0 9 * * *", "Asia/Taipei", from).unwrap();
assert!(next > from);
}
}
async fn validate_thread(
state: &AppState,
actor: &Actor,
bot: &str,
thread: Option<&str>,
) -> Result<(), String> {
let owns: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM bots WHERE id=$1 AND space_id=$2 AND user_id=$3)",
)
.bind(bot)
.bind(&actor.space_id)
.bind(&actor.user_id)
.fetch_one(state.pool())
.await
.map_err(|e| e.to_string())?;
if !owns {
return Err("bot not found".into());
}
if let Some(id) = thread {
let valid: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM threads WHERE id=$1 AND bot_id=$2 AND status='active' AND room_id IS NULL)")
.bind(id).bind(bot).fetch_one(state.pool()).await.map_err(|e| e.to_string())?;
if !valid {
return Err("schedule conversation must belong to this bot and be active".into());
}
}
Ok(())
}
fn next_after_due(
expr: &str,
timezone: &str,
due: DateTime<Utc>,
now: DateTime<Utc>,
) -> Result<DateTime<Utc>, String> {
if let Some(seconds) = interval_seconds(expr)? {
let elapsed = (now - due).num_seconds().max(0);
return Ok(due + chrono::TimeDelta::seconds((elapsed / seconds + 1) * seconds));
}
next_fire(expr, timezone, now)
}
#[cfg(test)]
mod regression_tests {
use super::*;
use chrono::{Datelike, TimeZone};
#[test]
fn intervals_cross_month_and_dst_without_reset() {
let from = Utc.with_ymd_and_hms(2026, 1, 31, 9, 0, 0).unwrap();
assert_eq!(
(next_fire("@every 3d", "America/New_York", from).unwrap() - from).num_hours(),
72
);
let dst = Utc.with_ymd_and_hms(2026, 3, 7, 9, 0, 0).unwrap();
assert_eq!(
(next_fire("@every 2d", "America/New_York", dst).unwrap() - dst).num_hours(),
48
);
}
#[test]
fn weekday_means_monday_through_friday() {
let friday = Utc.with_ymd_and_hms(2026, 9, 4, 2, 0, 0).unwrap();
let next = next_fire("0 9 * * 1-5", "Asia/Taipei", friday).unwrap();
assert_eq!(next.weekday(), chrono::Weekday::Mon);
}
#[test]
fn invalid_formats_and_timezones_fail_closed() {
for expr in [
"0 0 9 * * 1",
"99 25 * * *",
"@every 0d",
"@every 366h",
"@every 5z",
] {
assert!(validate_cron(expr).is_err(), "{expr}");
}
assert!(validate_timezone("Asia/Typo").is_err());
}
#[test]
fn missed_intervals_preserve_phase() {
let due = Utc.with_ymd_and_hms(2026, 9, 5, 0, 0, 0).unwrap();
assert_eq!(
next_after_due(
"@every 3m",
"UTC",
due,
due + chrono::TimeDelta::seconds(400)
)
.unwrap(),
due + chrono::TimeDelta::seconds(540)
);
}
}

View File

@ -31,6 +31,18 @@ async fn proxy(state: AppState, bot_id: String, rest: String, req: Request) -> R
.and_then(|value| value.to_str().ok())
.map(|value| value.eq_ignore_ascii_case("websocket"))
.unwrap_or(false);
if !upgrade {
if req.method() != axum::http::Method::GET && req.method() != axum::http::Method::HEAD {
return StatusCode::METHOD_NOT_ALLOWED.into_response();
}
if is_viewer_page(&rest) {
return viewer_page().await;
}
return trusted_asset(&rest).await;
}
if rest != "websockify" {
return StatusCode::NOT_FOUND.into_response();
}
let ensure = upgrade || is_viewer_page(&rest) || rest.contains("websockify");
let port = match upstream_port(&state, &bot_id, ensure).await {
Ok(port) => port,
@ -47,7 +59,7 @@ async fn proxy(state: AppState, bot_id: String, rest: String, req: Request) -> R
if is_viewer_page(&rest) {
return viewer_page().await;
}
http_proxy(port, &rest, req).await
StatusCode::NOT_FOUND.into_response()
}
fn is_viewer_page(rest: &str) -> bool {
@ -71,7 +83,6 @@ async fn viewer_page() -> Response {
"text/html; charset=utf-8".parse().unwrap(),
);
headers.insert(header::CACHE_CONTROL, "no-store".parse().unwrap());
headers.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*".parse().unwrap());
(StatusCode::OK, headers, html).into_response()
}
@ -129,49 +140,36 @@ fn rewrite_upstream(url: &str) -> String {
url.replace("127.0.0.1", &host).replace("localhost", &host)
}
async fn http_proxy(port: u16, rest: &str, req: Request) -> Response {
let host = std::env::var("LAZYBOY_SCREEN_UPSTREAM").unwrap_or_else(|_| "127.0.0.1".into());
let path = if rest.is_empty() {
"vnc_lite.html"
} else {
rest
};
let query = req.uri().query().unwrap_or_default();
let url = if query.is_empty() {
format!("http://{host}:{port}/{path}")
} else {
format!("http://{host}:{port}/{path}?{query}")
};
let client = reqwest::Client::new();
let method = reqwest::Method::from_bytes(req.method().as_str().as_bytes())
.unwrap_or(reqwest::Method::GET);
match client.request(method, url).send().await {
Ok(upstream) => {
let status =
StatusCode::from_u16(upstream.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
let content_type = upstream
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("application/octet-stream")
.to_string();
match upstream.bytes().await {
Ok(bytes) => {
let mut headers = HeaderMap::new();
if let Ok(value) = content_type.parse() {
headers.insert(header::CONTENT_TYPE, value);
}
headers.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*".parse().unwrap());
headers.insert(
header::HeaderName::from_static("cross-origin-resource-policy"),
"cross-origin".parse().unwrap(),
);
(status, headers, bytes).into_response()
}
Err(_) => StatusCode::BAD_GATEWAY.into_response(),
}
fn safe_asset(rest: &str) -> bool {
(rest.starts_with("core/") || rest.starts_with("vendor/"))
&& rest.ends_with(".js")
&& rest
.split('/')
.all(|p| !p.is_empty() && p != "." && p != "..")
&& rest
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"/._-".contains(&b))
}
async fn trusted_asset(rest: &str) -> Response {
if !safe_asset(rest) {
return StatusCode::NOT_FOUND.into_response();
}
let root = std::env::var("LAZYBOY_NOVNC_DIR").unwrap_or_else(|_| {
let web = std::env::var("LAZYBOY_WEB_DIR").unwrap_or_else(|_| "apps/web".into());
if std::path::Path::new(&web).join("novnc").is_dir() {
format!("{web}/novnc")
} else {
"apps/web/node_modules/@novnc/novnc".into()
}
Err(_) => StatusCode::BAD_GATEWAY.into_response(),
});
match tokio::fs::read(std::path::Path::new(&root).join(rest)).await {
Ok(bytes) => (
[(header::CONTENT_TYPE, "text/javascript; charset=utf-8")],
bytes,
)
.into_response(),
Err(_) => StatusCode::NOT_FOUND.into_response(),
}
}
@ -229,3 +227,21 @@ async fn proxy_socket(mut client: WebSocket, port: u16, rest: String) {
}
}
}
#[cfg(test)]
mod asset_tests {
use super::*;
#[test]
fn only_trusted_novnc_scripts_are_served() {
assert!(safe_asset("core/rfb.js"));
for path in [
"../secret.js",
"core/../../secret.js",
"core/%2e%2e/x.js",
"evil.html",
"/core/rfb.js",
] {
assert!(!safe_asset(path));
}
}
}

View File

@ -292,6 +292,7 @@ async fn start_skill(
argv,
cwd: None,
timeout_ms: Some(10_000),
stdin: None,
},
&ctx,
)
@ -902,6 +903,7 @@ async fn stop_recorder(
argv: cdp_record_stop_command(skill_id),
cwd: None,
timeout_ms: Some(5_000),
stdin: None,
},
ctx,
)
@ -928,6 +930,7 @@ async fn collect_browser_events(
],
cwd: None,
timeout_ms: Some(10_000),
stdin: None,
},
ctx,
)

File diff suppressed because it is too large Load Diff

383
crates/api/src/vault.rs Normal file
View File

@ -0,0 +1,383 @@
//! Per-bot login vault. Humans store credentials; the model only sees ids.
use aes_gcm::aead::{Aead, KeyInit};
use aes_gcm::{Aes256Gcm, Nonce};
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::routing::{delete, get, patch, post};
use axum::{Json, Router};
use chrono::{DateTime, Utc};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use sqlx::FromRow;
use uuid::Uuid;
use crate::db::Actor;
use crate::state::AppState;
#[derive(Debug, Clone, Serialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct VaultAccount {
pub id: String,
pub bot_id: String,
pub site: String,
pub host: String,
pub username: String,
pub notes: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpsertAccount {
pub site: String,
#[serde(default)]
pub host: String,
pub username: String,
#[serde(default)]
pub password: String,
#[serde(default)]
pub notes: String,
}
pub fn router() -> Router<AppState> {
Router::new()
.route(
"/api/bots/{bot_id}/accounts",
get(list_accounts).post(create_account),
)
.route(
"/api/bots/{bot_id}/accounts/{account_id}",
patch(update_account).delete(delete_account),
)
}
async fn scoped_actor(state: &AppState, bot_id: &str) -> Result<Actor, StatusCode> {
let actor = state
.bootstrap()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
state
.db
.get_bot(&actor, bot_id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
Ok(actor)
}
async fn list_accounts(
State(state): State<AppState>,
Path(bot_id): Path<String>,
) -> Result<Json<Vec<VaultAccount>>, StatusCode> {
let actor = scoped_actor(&state, &bot_id).await?;
list(&state, &actor, &bot_id)
.await
.map(Json)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
async fn create_account(
State(state): State<AppState>,
Path(bot_id): Path<String>,
Json(input): Json<UpsertAccount>,
) -> Result<Json<VaultAccount>, (StatusCode, Json<Value>)> {
let actor = scoped_actor(&state, &bot_id)
.await
.map_err(|status| (status, Json(json!({"message":"bot not found"}))))?;
insert(&state, &actor, &bot_id, input)
.await
.map(Json)
.map_err(|error| (StatusCode::BAD_REQUEST, Json(json!({"message": error}))))
}
async fn update_account(
State(state): State<AppState>,
Path((bot_id, account_id)): Path<(String, String)>,
Json(input): Json<UpsertAccount>,
) -> Result<Json<VaultAccount>, (StatusCode, Json<Value>)> {
let actor = scoped_actor(&state, &bot_id)
.await
.map_err(|status| (status, Json(json!({"message":"bot not found"}))))?;
update_row(&state, &actor, &bot_id, &account_id, input)
.await
.map_err(|error| (StatusCode::BAD_REQUEST, Json(json!({"message": error}))))?
.map(Json)
.ok_or((
StatusCode::NOT_FOUND,
Json(json!({"message":"account not found"})),
))
}
async fn delete_account(
State(state): State<AppState>,
Path((bot_id, account_id)): Path<(String, String)>,
) -> Result<StatusCode, StatusCode> {
let actor = scoped_actor(&state, &bot_id).await?;
let deleted = sqlx::query(
"DELETE FROM vault_accounts
WHERE id=$1 AND bot_id=$2 AND space_id=$3 AND user_id=$4",
)
.bind(&account_id)
.bind(&bot_id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.execute(state.pool())
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if deleted.rows_affected() == 0 {
return Err(StatusCode::NOT_FOUND);
}
Ok(StatusCode::NO_CONTENT)
}
pub async fn list(
state: &AppState,
actor: &Actor,
bot_id: &str,
) -> Result<Vec<VaultAccount>, String> {
list_on(state.pool(), actor, bot_id).await
}
pub async fn list_on(
pool: &sqlx::PgPool,
actor: &Actor,
bot_id: &str,
) -> Result<Vec<VaultAccount>, String> {
sqlx::query_as(
"SELECT id, bot_id, site, host, username, notes, created_at, updated_at
FROM vault_accounts
WHERE bot_id=$1 AND space_id=$2 AND user_id=$3
ORDER BY updated_at DESC",
)
.bind(bot_id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.fetch_all(pool)
.await
.map_err(|error| error.to_string())
}
pub async fn get_secret(
state: &AppState,
actor: &Actor,
bot_id: &str,
account_id: &str,
) -> Result<Option<(VaultAccount, String, String)>, String> {
get_secret_on(state.pool(), actor, bot_id, account_id).await
}
pub async fn get_secret_on(
pool: &sqlx::PgPool,
actor: &Actor,
bot_id: &str,
account_id: &str,
) -> Result<Option<(VaultAccount, String, String)>, String> {
let row: Option<(String, String, String, String, String, String, DateTime<Utc>, DateTime<Utc>, String)> =
sqlx::query_as(
"SELECT id, bot_id, site, host, username, notes, created_at, updated_at, password_ciphertext
FROM vault_accounts
WHERE id=$1 AND bot_id=$2 AND space_id=$3 AND user_id=$4",
)
.bind(account_id)
.bind(bot_id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.fetch_optional(pool)
.await
.map_err(|error| error.to_string())?;
let Some(row) = row else {
return Ok(None);
};
let password = decrypt(&row.8)?;
Ok(Some((
VaultAccount {
id: row.0,
bot_id: row.1,
site: row.2,
host: row.3,
username: row.4.clone(),
notes: row.5,
created_at: row.6,
updated_at: row.7,
},
row.4,
password,
)))
}
async fn insert(
state: &AppState,
actor: &Actor,
bot_id: &str,
input: UpsertAccount,
) -> Result<VaultAccount, String> {
let site = clean_site(&input.site)?;
let username = clean_username(&input.username)?;
if input.password.is_empty() {
return Err("password is required".into());
}
let host = normalize_host(&input.host, &site);
let id = Uuid::new_v4().to_string();
let ciphertext = encrypt(&input.password)?;
sqlx::query_as(
"INSERT INTO vault_accounts
(id, space_id, user_id, bot_id, site, host, username, password_ciphertext, notes)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
RETURNING id, bot_id, site, host, username, notes, created_at, updated_at",
)
.bind(&id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.bind(bot_id)
.bind(site)
.bind(host)
.bind(username)
.bind(ciphertext)
.bind(input.notes.trim())
.fetch_one(state.pool())
.await
.map_err(|error| error.to_string())
}
async fn update_row(
state: &AppState,
actor: &Actor,
bot_id: &str,
account_id: &str,
input: UpsertAccount,
) -> Result<Option<VaultAccount>, String> {
let site = clean_site(&input.site)?;
let username = clean_username(&input.username)?;
let host = normalize_host(&input.host, &site);
if input.password.is_empty() {
sqlx::query_as(
"UPDATE vault_accounts
SET site=$1, host=$2, username=$3, notes=$4, updated_at=now()
WHERE id=$5 AND bot_id=$6 AND space_id=$7 AND user_id=$8
RETURNING id, bot_id, site, host, username, notes, created_at, updated_at",
)
.bind(site)
.bind(host)
.bind(username)
.bind(input.notes.trim())
.bind(account_id)
.bind(bot_id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.fetch_optional(state.pool())
.await
.map_err(|error| error.to_string())
} else {
let ciphertext = encrypt(&input.password)?;
sqlx::query_as(
"UPDATE vault_accounts
SET site=$1, host=$2, username=$3, notes=$4, password_ciphertext=$5, updated_at=now()
WHERE id=$6 AND bot_id=$7 AND space_id=$8 AND user_id=$9
RETURNING id, bot_id, site, host, username, notes, created_at, updated_at",
)
.bind(site)
.bind(host)
.bind(username)
.bind(input.notes.trim())
.bind(ciphertext)
.bind(account_id)
.bind(bot_id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.fetch_optional(state.pool())
.await
.map_err(|error| error.to_string())
}
}
fn clean_site(site: &str) -> Result<String, String> {
let value = site.trim();
if value.is_empty() || value.chars().count() > 80 {
return Err("site name must be 180 characters".into());
}
Ok(value.to_string())
}
fn clean_username(username: &str) -> Result<String, String> {
let value = username.trim();
if value.is_empty() || value.chars().count() > 200 {
return Err("username must be 1200 characters".into());
}
Ok(value.to_string())
}
fn normalize_host(host: &str, site: &str) -> String {
let raw = host.trim();
if raw.is_empty() {
return site.to_lowercase();
}
raw.trim_start_matches("https://")
.trim_start_matches("http://")
.split('/')
.next()
.unwrap_or(raw)
.trim()
.to_lowercase()
}
fn vault_key() -> Result<[u8; 32], String> {
let material = std::env::var("LAZYBOY_VAULT_KEY")
.ok()
.filter(|value| !value.is_empty())
.or_else(|| std::env::var("LAZYBOY_APP_TOKEN").ok().filter(|v| !v.is_empty()))
.ok_or_else(|| "set LAZYBOY_VAULT_KEY or LAZYBOY_APP_TOKEN to encrypt saved passwords".to_string())?;
let digest = Sha256::digest(material.as_bytes());
let mut key = [0u8; 32];
key.copy_from_slice(&digest);
Ok(key)
}
fn encrypt(plaintext: &str) -> Result<String, String> {
let cipher = Aes256Gcm::new_from_slice(&vault_key()?).map_err(|error| error.to_string())?;
let mut nonce_bytes = [0u8; 12];
rand::thread_rng().fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let encrypted = cipher
.encrypt(nonce, plaintext.as_bytes())
.map_err(|error| error.to_string())?;
let mut packed = Vec::with_capacity(12 + encrypted.len());
packed.extend_from_slice(&nonce_bytes);
packed.extend_from_slice(&encrypted);
Ok(hex::encode(packed))
}
fn decrypt(packed: &str) -> Result<String, String> {
let bytes = hex::decode(packed).map_err(|error| error.to_string())?;
if bytes.len() < 13 {
return Err("corrupt vault entry".into());
}
let cipher = Aes256Gcm::new_from_slice(&vault_key()?).map_err(|error| error.to_string())?;
let nonce = Nonce::from_slice(&bytes[..12]);
let plain = cipher
.decrypt(nonce, &bytes[12..])
.map_err(|_| "could not decrypt vault entry".to_string())?;
String::from_utf8(plain).map_err(|error| error.to_string())
}
#[cfg(test)]
mod tests {
use super::{decrypt, encrypt, normalize_host};
#[test]
fn round_trips_a_password() {
unsafe { std::env::set_var("LAZYBOY_VAULT_KEY", "test-vault-key-for-unit-tests") };
let packed = encrypt("s3cret!").unwrap();
assert!(!packed.contains("s3cret"));
assert_eq!(decrypt(&packed).unwrap(), "s3cret!");
}
#[test]
fn host_strips_urls() {
assert_eq!(normalize_host("https://mail.google.com/inbox", "Gmail"), "mail.google.com");
assert_eq!(normalize_host("", "Gmail"), "gmail");
}
}

View File

@ -24,6 +24,14 @@ pub enum ScrollDirection {
Down,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RefVerb {
Click,
Focus,
SetValue,
}
/// Canonical actions the control plane understands.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
@ -36,6 +44,15 @@ pub enum ComputerAction {
#[serde(default)]
button: Option<PointerButton>,
},
/// Semantic target (DOM selector or AT-SPI path). Execute via CDP/a11y, not xdotool.
Ref {
verb: RefVerb,
target: String,
#[serde(default, rename = "refKind")]
ref_kind: String,
#[serde(default)]
text: Option<String>,
},
Clipboard {
text: String,
},
@ -86,15 +103,24 @@ pub struct UiElement {
pub y: u32,
pub w: u32,
pub h: u32,
/// CSS selector for in-page controls (Chromium CDP). Native windows leave this empty.
/// CSS selector (DOM) or AT-SPI path (a11y). Native windows leave this empty.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selector: Option<String>,
/// "dom" for page controls, "window" for native windows.
/// "dom" for page controls, "a11y" for AT-SPI widgets, "window" for native windows.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
}
impl UiElement {
pub fn has_ref(&self) -> bool {
self.selector
.as_deref()
.is_some_and(|value| !value.is_empty())
&& matches!(self.kind.as_deref(), Some("dom") | Some("a11y"))
}
pub fn center(&self) -> (u32, u32) {
(
self.x.saturating_add(self.w / 2),

View File

@ -189,6 +189,10 @@ pub struct ComputerStatus {
/// What the active run is doing right now ("思考中", "browser: click #12"…).
#[serde(default)]
pub busy_step: Option<String>,
/// True only while the active run holds the desktop screen lease.
/// Chat-only replies set busy_session_id but not this.
#[serde(default)]
pub using_computer: bool,
/// Run paused in `waiting_takeover` (bot asked for, or user forced, control).
#[serde(default)]
pub waiting_run_id: Option<String>,

353
crates/control/src/a11y.py Normal file
View File

@ -0,0 +1,353 @@
import json, os, sys, time
def fail(msg):
print(json.dumps({"ok": False, "error": msg, "elements": []}))
sys.exit(0)
def load_session(display):
display = display or os.environ.get("DISPLAY") or ":1"
if not display.startswith(":"):
display = ":" + display
os.environ["DISPLAY"] = display
number = display.lstrip(":")
dbus_file = "/tmp/lazyboy/screen-%s.dbus" % number
runtime_file = "/tmp/lazyboy/screen-%s.runtime" % number
try:
addr = open(dbus_file).read().strip()
if addr:
os.environ["DBUS_SESSION_BUS_ADDRESS"] = addr
except Exception:
pass
try:
runtime = open(runtime_file).read().strip()
if runtime:
os.environ["XDG_RUNTIME_DIR"] = runtime
except Exception:
if number == "1":
candidate = "/tmp/xfce-home/runtime"
else:
candidate = "/tmp/xfce-home-%s/runtime" % number
if os.path.isdir(candidate):
os.environ["XDG_RUNTIME_DIR"] = candidate
def atspi():
import gi
gi.require_version("Atspi", "2.0")
from gi.repository import Atspi
try:
Atspi.init()
except Exception:
pass
try:
Atspi.set_timeout(200, 200)
except Exception:
pass
return Atspi
INTERACTIVE = {
"push button", "toggle button", "check box", "radio button",
"combo box", "text", "password text", "menu item", "check menu item",
"radio menu item", "tab", "page tab", "slider", "spin button",
"link", "tree item", "entry", "password", "button", "menu",
"list item", "column header", "toggle",
}
BROWSER_APPS = ("chromium", "chrome", "google-chrome", "chromium-browser")
def is_browser_name(name):
n = (name or "").lower()
return any(token in n for token in BROWSER_APPS)
def child_count(acc):
try:
return int(acc.get_child_count())
except Exception:
return 0
def child_at(acc, i):
try:
return acc.get_child_at_index(i)
except Exception:
return None
def role_name(acc):
try:
return (acc.get_role_name() or "").lower()
except Exception:
return ""
def acc_name(acc):
try:
text = (acc.get_name() or "").strip()
if text:
return text
except Exception:
pass
try:
return (acc.get_description() or "").strip()
except Exception:
return ""
def state_names(Atspi, acc):
out = []
try:
ss = acc.get_state_set()
except Exception:
return out
for name in ("showing", "visible", "enabled", "sensitive", "checked",
"selected", "focused", "editable", "defunct", "expandable",
"expanded"):
try:
st = getattr(Atspi.StateType, name.upper())
if ss.contains(st):
out.append(name)
except Exception:
pass
return out
def extents(Atspi, acc):
try:
ext = acc.get_extents(Atspi.CoordType.SCREEN)
return int(ext.x), int(ext.y), int(ext.width), int(ext.height)
except Exception:
pass
try:
comp = acc.get_component_iface()
if comp is None:
return None
ext = comp.get_extents(Atspi.CoordType.SCREEN)
return int(ext.x), int(ext.y), int(ext.width), int(ext.height)
except Exception:
return None
def action_iface(acc):
for getter in ("get_action_iface", "queryAction", "get_action"):
fn = getattr(acc, getter, None)
if not fn:
continue
try:
iface = fn()
if iface is not None:
return iface
except Exception:
pass
if hasattr(acc, "get_n_actions") and hasattr(acc, "do_action"):
return acc
return None
def text_ifaces(acc):
edit = None
text = None
for getter in ("get_editable_text_iface", "queryEditableText"):
fn = getattr(acc, getter, None)
if not fn:
continue
try:
edit = fn()
if edit is not None:
break
except Exception:
pass
for getter in ("get_text_iface", "queryText"):
fn = getattr(acc, getter, None)
if not fn:
continue
try:
text = fn()
if text is not None:
break
except Exception:
pass
if edit is None and hasattr(acc, "insert_text"):
edit = acc
if text is None and hasattr(acc, "get_character_count"):
text = acc
return edit, text
def resolve(Atspi, path):
desktop = Atspi.get_desktop(0)
node = desktop
for part in str(path).split("/"):
if part == "":
continue
node = child_at(node, int(part))
if node is None:
return None
return node
def grab_focus(acc):
for getter in ("grab_focus",):
fn = getattr(acc, getter, None)
if fn:
try:
fn()
return True
except Exception:
pass
try:
comp = acc.get_component_iface()
if comp is not None:
comp.grab_focus()
return True
except Exception:
pass
return False
def do_click(acc):
action = action_iface(acc)
if action is None:
return grab_focus(acc) and False
try:
n = int(action.get_n_actions())
except Exception:
n = 0
idx = 0
prefer = ("click", "press", "activate", "jump", "open", "toggle", "select")
for i in range(n):
try:
name = (action.get_action_name(i) or "").lower()
except Exception:
name = ""
if name in prefer:
idx = i
break
if n <= 0:
return False
try:
return bool(action.do_action(idx))
except Exception:
return False
def set_text(acc, value):
grab_focus(acc)
edit, text = text_ifaces(acc)
if edit is None:
return False
n = 0
if text is not None:
try:
n = int(text.get_character_count())
except Exception:
n = 0
try:
edit.delete_text(0, n)
except Exception:
pass
try:
edit.insert_text(0, value, len(value))
return True
except Exception:
return False
def snapshot(Atspi, include_browser):
deadline = time.time() + 2.8
desktop = Atspi.get_desktop(0)
found = []
visited = 0
apps = child_count(desktop)
for app_i in range(apps):
if time.time() > deadline or len(found) >= 50:
break
app = child_at(desktop, app_i)
if app is None:
continue
app_label = acc_name(app) or role_name(app)
if not include_browser and is_browser_name(app_label):
continue
stack = [(app, str(app_i), 0)]
while stack:
if time.time() > deadline or len(found) >= 50 or visited > 400:
break
acc, path, depth = stack.pop()
visited += 1
states = state_names(Atspi, acc)
if "defunct" in states:
continue
role = role_name(acc)
name = acc_name(acc)
showing = ("showing" in states) or ("visible" in states) or not states
if role in INTERACTIVE and showing and name:
box = extents(Atspi, acc)
if box and box[2] >= 2 and box[3] >= 2:
x, y, w, h = box
if x + w > 0 and y + h > 0:
title = "%s %s" % (role, name.replace("\n", " ").strip())
if "checked" in states:
title += " (checked)"
if "expanded" in states:
title += " (expanded)"
if "enabled" in states and "sensitive" in states:
pass
elif states and "enabled" not in states:
title += " [disabled]"
title = title[:80]
found.append({
"id": len(found) + 1,
"title": title,
"role": role,
"kind": "a11y",
"selector": path,
"x": max(0, x),
"y": max(0, y),
"w": w,
"h": h,
})
if depth >= 12:
continue
n = child_count(acc)
# Walk children in reverse so index 0 is processed first with pop().
for i in range(n - 1, -1, -1):
child = child_at(acc, i)
if child is None:
continue
stack.append((child, "%s/%d" % (path, i), depth + 1))
return found
def main():
req = json.loads(sys.argv[1]) if len(sys.argv) > 1 else {}
action = req.get("action") or "snapshot"
display = req.get("display") or ":1"
load_session(display)
try:
Atspi = atspi()
except Exception as error:
fail("atspi unavailable: %s" % error)
return
if action == "snapshot":
include_browser = bool(req.get("includeBrowser"))
try:
elements = snapshot(Atspi, include_browser)
except Exception as error:
fail("atspi snapshot failed: %s" % error)
return
print(json.dumps({"ok": True, "elements": elements}))
return
selector = req.get("selector") or ""
if not selector:
fail("a11y action needs selector")
return
try:
acc = resolve(Atspi, selector)
except Exception as error:
fail("a11y resolve failed: %s" % error)
return
if acc is None:
fail("a11y element gone")
return
ok = False
if action == "click":
ok = do_click(acc)
elif action == "type":
ok = set_text(acc, req.get("text") or "")
elif action == "focus":
ok = grab_focus(acc)
else:
fail("unsupported a11y action")
return
print(json.dumps({"ok": bool(ok), "error": None if ok else "a11y %s failed" % action}))
if __name__ == "__main__":
try:
main()
except Exception as error:
fail(str(error))

201
crates/control/src/a11y.rs Normal file
View File

@ -0,0 +1,201 @@
use lazyboy_contracts::UiElement;
use serde_json::{Value, json};
use crate::x11::{is_browser_title, parse_ui_elements};
use crate::{PRIMARY_DISPLAY, normalize_display};
const A11Y_PY: &str = include_str!("a11y.py");
#[derive(Debug, Clone, PartialEq, Default)]
pub struct A11yPage {
pub ok: bool,
pub error: Option<String>,
pub elements: Vec<UiElement>,
}
pub fn a11y_command_on(display: &str, request: &Value) -> Vec<String> {
let mut body = request.clone();
if let Some(object) = body.as_object_mut() {
object
.entry("display")
.or_insert_with(|| json!(normalize_display(display)));
}
vec![
"env".into(),
format!("DISPLAY={}", normalize_display(display)),
"python3".into(),
"-c".into(),
A11Y_PY.into(),
body.to_string(),
]
}
pub fn a11y_command(request: &Value) -> Vec<String> {
a11y_command_on(PRIMARY_DISPLAY, request)
}
pub fn parse_a11y_page(raw: &str) -> A11yPage {
let value: Value = serde_json::from_str(raw.trim()).unwrap_or(Value::Null);
let ok = value.get("ok").and_then(Value::as_bool) == Some(true);
A11yPage {
ok,
error: if ok {
None
} else {
value
.get("error")
.and_then(Value::as_str)
.map(str::to_string)
.or_else(|| Some("atspi unavailable".into()))
},
elements: value
.get("elements")
.map(|items| parse_ui_elements(&items.to_string()))
.unwrap_or_default(),
}
}
/// Merge DOM (keep ids), then a11y, then native windows that are not already
/// covered by a control tree. Chromium's whole window is dropped when the
/// page snapshot returned elements.
pub fn merge_ui_elements(
windows: Vec<UiElement>,
page: &[UiElement],
a11y: &[UiElement],
) -> Vec<UiElement> {
let mut elements = page.to_vec();
let mut next = elements.len() as u32 + 1;
for mut control in a11y.iter().cloned() {
control.id = next;
next += 1;
elements.push(control);
}
for mut window in windows {
if !page.is_empty() && is_browser_title(&window.title) {
continue;
}
if a11y_covers_window(a11y, &window) {
continue;
}
window.id = next;
next += 1;
elements.push(window);
}
elements
}
fn a11y_covers_window(a11y: &[UiElement], window: &UiElement) -> bool {
if a11y.is_empty() || window.w == 0 || window.h == 0 {
return false;
}
a11y.iter().any(|control| center_inside(control, window))
}
fn center_inside(element: &UiElement, window: &UiElement) -> bool {
let (x, y) = element.center();
let area = u64::from(element.w.saturating_mul(element.h.max(1)));
let window_area = u64::from(window.w.saturating_mul(window.h.max(1)));
x >= window.x
&& y >= window.y
&& x < window.x.saturating_add(window.w)
&& y < window.y.saturating_add(window.h)
&& (window_area == 0 || area < window_area)
}
#[cfg(test)]
mod tests {
use super::*;
fn window(id: u32, title: &str, x: u32, y: u32, w: u32, h: u32) -> UiElement {
UiElement {
id,
title: title.into(),
x,
y,
w,
h,
kind: Some("window".into()),
..UiElement::default()
}
}
fn a11y(id: u32, title: &str, x: u32, y: u32, w: u32, h: u32) -> UiElement {
UiElement {
id,
title: title.into(),
x,
y,
w,
h,
kind: Some("a11y".into()),
selector: Some(format!("0/{id}")),
role: Some("push button".into()),
..UiElement::default()
}
}
#[test]
fn command_injects_display_and_script() {
let argv = a11y_command_on(":2", &json!({"action": "snapshot"}));
assert!(argv.contains(&"DISPLAY=:2".into()));
assert!(argv.iter().any(|item| item.contains("python3")));
assert!(argv.last().unwrap().contains("\"display\":\":2\""));
assert!(argv.iter().any(|item| item.contains("Atspi") || item.contains("atspi")));
}
#[test]
fn parses_snapshot_elements() {
let page = parse_a11y_page(
r#"{"ok":true,"elements":[{"id":1,"title":"push button Open","role":"push button","kind":"a11y","selector":"0/2/1","x":10,"y":20,"w":80,"h":24}]}"#,
);
assert!(page.ok);
assert_eq!(page.elements.len(), 1);
assert_eq!(page.elements[0].kind.as_deref(), Some("a11y"));
assert_eq!(page.elements[0].selector.as_deref(), Some("0/2/1"));
assert_eq!(page.elements[0].role.as_deref(), Some("push button"));
}
#[test]
fn merge_keeps_dom_ids_and_replaces_covered_windows() {
let windows = vec![
window(1, "Thunar", 0, 24, 800, 600),
window(2, "Terminal", 800, 24, 480, 600),
window(3, "Chromium", 0, 0, 1280, 800),
];
let page = vec![UiElement {
id: 1,
title: "Login".into(),
selector: Some("[data-lazyboy=\"1\"]".into()),
kind: Some("dom".into()),
x: 40,
y: 80,
w: 60,
h: 20,
..UiElement::default()
}];
let native = vec![a11y(1, "push button Open", 40, 40, 80, 24)];
let merged = merge_ui_elements(windows, &page, &native);
assert_eq!(merged[0].title, "Login");
assert_eq!(merged[0].id, 1);
assert_eq!(merged[1].id, 2);
assert_eq!(merged[1].title, "push button Open");
assert!(merged.iter().any(|element| element.title == "Terminal"));
assert!(!merged.iter().any(|element| element.title == "Thunar"));
assert!(!merged.iter().any(|element| element.title == "Chromium"));
}
#[test]
fn merge_without_trees_keeps_windows() {
let windows = vec![window(1, "Thunar", 0, 24, 800, 600)];
let merged = merge_ui_elements(windows, &[], &[]);
assert_eq!(merged.len(), 1);
assert_eq!(merged[0].title, "Thunar");
}
#[test]
fn unavailable_tree_is_not_ok() {
let page = parse_a11y_page(r#"{"ok":false,"error":"atspi unavailable"}"#);
assert!(!page.ok);
assert_eq!(page.error.as_deref(), Some("atspi unavailable"));
}
}

View File

@ -1,4 +1,7 @@
use lazyboy_contracts::{ComputerAction, PointerButton, PointerType, ScrollDirection, UiElement};
use crate::is_browser_title;
use lazyboy_contracts::{
ComputerAction, PointerButton, PointerType, RefVerb, ScrollDirection, UiElement,
};
use serde_json::{Value, json};
use thiserror::Error;
@ -43,7 +46,8 @@ pub fn element_id(value: Option<&Value>) -> Option<u64> {
}
}
/// Fill x/y from a numbered on-screen element so the model can click by id.
/// Resolve a numbered on-screen element. DOM/a11y refs stay semantic (no
/// pre-filled coordinates). Window-level targets still become center pixels.
pub fn apply_element_targets(value: &mut Value, elements: &[UiElement]) -> Result<(), ActionError> {
let Some(items) = value.as_array_mut() else {
return Ok(());
@ -58,6 +62,21 @@ pub fn apply_element_targets(value: &mut Value, elements: &[UiElement]) -> Resul
let Some(element) = elements.iter().find(|element| u64::from(element.id) == id) else {
return Err(ActionError::UnknownElement(id as u32));
};
let kind = action
.get("kind")
.and_then(Value::as_str)
.or_else(|| action.get("type").and_then(Value::as_str))
.unwrap_or("");
if element.has_ref() && matches!(kind, "click" | "type") {
if let Some(selector) = element.selector.clone() {
action.insert("target".into(), json!(selector));
action.insert(
"refKind".into(),
json!(element.kind.clone().unwrap_or_else(|| "a11y".into())),
);
}
continue;
}
if element.is_offscreen() {
return Err(ActionError::OffscreenElement(id as u32));
}
@ -68,6 +87,97 @@ pub fn apply_element_targets(value: &mut Value, elements: &[UiElement]) -> Resul
Ok(())
}
/// Fingerprint of the first click-like action, used to refuse repeating a miss.
pub fn click_fingerprint(actions: &Value) -> Option<String> {
let items = actions.as_array()?;
for raw in items {
let Some(action) = raw.as_object() else {
continue;
};
let kind = action
.get("kind")
.and_then(Value::as_str)
.or_else(|| action.get("type").and_then(Value::as_str))
.unwrap_or("");
if !matches!(kind, "click" | "down" | "drag") {
continue;
}
if let Some(id) = element_id(action.get("element")) {
return Some(format!("e{id}"));
}
match (
action.get("x").and_then(Value::as_f64),
action.get("y").and_then(Value::as_f64),
) {
(Some(x), Some(y)) if x.is_finite() && y.is_finite() => {
return Some(format!("p{},{}", x.round() as i64, y.round() as i64));
}
_ => {}
}
}
None
}
pub fn should_block_stale_click(miss_streak: u32, last: Option<&str>, next: Option<&str>) -> bool {
miss_streak >= 2 && next.is_some() && next == last
}
/// When a CDP page snapshot is live, refuse pixel-clicking the Chromium window.
pub fn browser_gui_block(actions: &Value, elements: &[UiElement]) -> Option<String> {
if !elements
.iter()
.any(|element| element.kind.as_deref() == Some("dom"))
{
return None;
}
let items = actions.as_array()?;
for raw in items {
let Some(action) = raw.as_object() else {
continue;
};
let kind = action
.get("kind")
.and_then(Value::as_str)
.or_else(|| action.get("type").and_then(Value::as_str))
.unwrap_or("");
if !matches!(kind, "click" | "move" | "down" | "up" | "hover" | "drag") {
continue;
}
if let Some(id) = element_id(action.get("element")) {
match elements.iter().find(|element| u64::from(element.id) == id) {
Some(element) if element.has_ref() => continue,
Some(element)
if element.kind.as_deref() == Some("window")
&& is_browser_title(&element.title) =>
{
return Some(browser_block_message(elements));
}
_ => continue,
}
} else {
return Some(browser_block_message(elements));
}
}
None
}
fn browser_block_message(elements: &[UiElement]) -> String {
let known: Vec<String> = elements
.iter()
.filter(|element| element.kind.as_deref() == Some("dom"))
.take(12)
.map(|element| format!("[{}] {}", element.id, element.title))
.collect();
format!(
"Chromium is in front: use the browser tool (snapshot / click element N) instead of computer_act pixel clicks. Known page elements: {}",
if known.is_empty() {
"call browser snapshot first".to_string()
} else {
known.join(", ")
}
)
}
pub fn parse_computer_actions(value: &Value) -> Result<Vec<ComputerAction>, ActionError> {
let Value::Array(items) = value else {
return Err(ActionError::Empty);
@ -90,6 +200,23 @@ pub fn parse_computer_actions(value: &Value) -> Result<Vec<ComputerAction>, Acti
.unwrap_or_default();
match kind {
"click" | "move" | "down" | "up" => {
if kind == "click" {
if let Some(target) = ref_target(action) {
let pointer = ComputerAction::Ref {
verb: RefVerb::Click,
target,
ref_kind: ref_kind(action),
text: None,
};
let doubled = action.get("double").and_then(Value::as_bool) == Some(true);
actions.push(pointer.clone());
if doubled {
actions.push(ComputerAction::Wait { ms: 70 });
actions.push(pointer);
}
continue;
}
}
let x = coordinate(action.get("x"), "x")?;
let y = coordinate(action.get("y"), "y")?;
let pointer_type = match kind {
@ -177,7 +304,16 @@ pub fn parse_computer_actions(value: &Value) -> Result<Vec<ComputerAction>, Acti
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
actions.push(ComputerAction::Clipboard { text });
if let Some(target) = ref_target(action) {
actions.push(ComputerAction::Ref {
verb: RefVerb::SetValue,
target,
ref_kind: ref_kind(action),
text: Some(text),
});
} else {
actions.push(ComputerAction::Clipboard { text });
}
}
"key" => {
let key = action
@ -247,6 +383,24 @@ pub fn parse_computer_actions(value: &Value) -> Result<Vec<ComputerAction>, Acti
Ok(actions)
}
fn ref_target(action: &serde_json::Map<String, Value>) -> Option<String> {
action
.get("target")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn ref_kind(action: &serde_json::Map<String, Value>) -> String {
action
.get("refKind")
.or_else(|| action.get("ref_kind"))
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.unwrap_or("a11y")
.to_string()
}
fn coordinate(value: Option<&Value>, name: &'static str) -> Result<u32, ActionError> {
let number = value.and_then(Value::as_f64).unwrap_or(f64::NAN).round();
if !number.is_finite() || number < 0.0 || number > 100_000.0 {
@ -386,10 +540,145 @@ mod tests {
kind: Some("dom".into()),
..UiElement::default()
};
let mut actions = json!([{"kind": "click", "element": 3}]);
let mut actions = json!([{"kind": "hover", "element": 3}]);
assert_eq!(
apply_element_targets(&mut actions, &[below_fold]).unwrap_err(),
ActionError::OffscreenElement(3)
);
}
fn a11y_button() -> UiElement {
UiElement {
id: 4,
title: "push button Open".into(),
selector: Some("0/2/1".into()),
kind: Some("a11y".into()),
role: Some("push button".into()),
x: 10,
y: 20,
w: 80,
h: 24,
..UiElement::default()
}
}
#[test]
fn a11y_click_stays_semantic() {
let mut actions = json!([{"kind": "click", "element": 4}]);
apply_element_targets(&mut actions, &[a11y_button()]).unwrap();
assert!(actions[0].get("x").is_none());
assert_eq!(actions[0]["target"], "0/2/1");
assert_eq!(actions[0]["refKind"], "a11y");
let parsed = parse_computer_actions(&actions).unwrap();
assert!(matches!(
parsed[0],
ComputerAction::Ref {
verb: RefVerb::Click,
..
}
));
}
#[test]
fn a11y_type_is_set_value() {
let mut actions = json!([{"kind": "type", "element": 4, "text": "report.pdf"}]);
apply_element_targets(&mut actions, &[a11y_button()]).unwrap();
let parsed = parse_computer_actions(&actions).unwrap();
match &parsed[0] {
ComputerAction::Ref {
verb: RefVerb::SetValue,
text,
..
} => assert_eq!(text.as_deref(), Some("report.pdf")),
other => panic!("{other:?}"),
}
}
#[test]
fn dom_click_does_not_fill_coordinates() {
let mut actions = json!([{"kind": "click", "element": 1}]);
let elements = vec![UiElement {
id: 1,
title: "Submit".into(),
selector: Some("[data-lazyboy=\"1\"]".into()),
kind: Some("dom".into()),
x: 10,
y: 20,
w: 80,
h: 24,
..UiElement::default()
}];
apply_element_targets(&mut actions, &elements).unwrap();
assert!(actions[0].get("x").is_none());
let parsed = parse_computer_actions(&actions).unwrap();
match &parsed[0] {
ComputerAction::Ref {
ref_kind, verb, ..
} => {
assert_eq!(ref_kind, "dom");
assert_eq!(*verb, RefVerb::Click);
}
other => panic!("{other:?}"),
}
}
#[test]
fn pixel_clicks_are_blocked_when_dom_is_live() {
let elements = vec![UiElement {
id: 1,
title: "Submit".into(),
selector: Some("[data-lazyboy=\"1\"]".into()),
kind: Some("dom".into()),
x: 10,
y: 20,
w: 80,
h: 24,
..UiElement::default()
}];
let blocked = browser_gui_block(&json!([{"kind":"click","x":40,"y":80}]), &elements);
assert!(blocked.unwrap().contains("browser"));
assert!(
browser_gui_block(&json!([{"kind":"click","element":1}]), &elements).is_none()
);
}
#[test]
fn chromium_window_clicks_are_blocked_when_dom_is_live() {
let elements = vec![
UiElement {
id: 1,
title: "Submit".into(),
selector: Some("[data-lazyboy=\"1\"]".into()),
kind: Some("dom".into()),
..UiElement::default()
},
UiElement {
id: 2,
title: "Chromium".into(),
kind: Some("window".into()),
x: 0,
y: 0,
w: 1280,
h: 800,
..UiElement::default()
},
];
let blocked = browser_gui_block(&json!([{"kind":"click","element":2}]), &elements);
assert!(blocked.unwrap().contains("browser"));
}
#[test]
fn stale_repeat_blocks_the_third_same_click() {
assert!(!should_block_stale_click(1, Some("e3"), Some("e3")));
assert!(should_block_stale_click(2, Some("e3"), Some("e3")));
assert!(!should_block_stale_click(2, Some("e3"), Some("e4")));
assert_eq!(
click_fingerprint(&json!([{"kind":"click","element":3}])).as_deref(),
Some("e3")
);
assert_eq!(
click_fingerprint(&json!([{"kind":"click","x":10,"y":20}])).as_deref(),
Some("p10,20")
);
}
}

View File

@ -12,101 +12,31 @@ def http_json(url, timeout=2):
return None
class Ws:
"""Use a maintained RFC6455 transport (fragmentation, ping/pong, handshake)."""
def __init__(self, url):
rest = url[5:]
hostpath = rest.split("/", 1)
hostport = hostpath[0]
path = "/" + (hostpath[1] if len(hostpath) > 1 else "")
if ":" in hostport:
host, port = hostport.rsplit(":", 1)
port = int(port)
else:
host, port = hostport, 80
self.sock = socket.create_connection((host, port), 5)
key = base64.b64encode(os.urandom(16)).decode()
req = (
"GET %s HTTP/1.1\r\nHost: %s\r\nUpgrade: websocket\r\n"
"Connection: Upgrade\r\nSec-WebSocket-Key: %s\r\n"
"Sec-WebSocket-Version: 13\r\nOrigin: http://%s\r\n\r\n"
% (path, hostport, key, hostport)
)
self.sock.sendall(req.encode())
buf = b""
while b"\r\n\r\n" not in buf:
chunk = self.sock.recv(4096)
if not chunk:
raise RuntimeError("ws handshake closed")
buf += chunk
import websocket
self.sock = websocket.create_connection(url, timeout=5, suppress_origin=True,
http_no_proxy=["127.0.0.1", "localhost"])
self.n = 0
def _frame(self, data):
n = len(data)
hdr = bytearray([0x81])
if n < 126:
hdr.append(0x80 | n)
elif n < 65536:
hdr.append(0x80 | 126)
hdr += struct.pack("!H", n)
else:
hdr.append(0x80 | 127)
hdr += struct.pack("!Q", n)
mask = os.urandom(4)
hdr += mask
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(data))
return bytes(hdr) + masked
def _read(self, n):
buf = b""
while len(buf) < n:
chunk = self.sock.recv(n - len(buf))
if not chunk:
raise RuntimeError("ws eof")
buf += chunk
return buf
def _read_frame(self):
hdr = self._read(2)
opcode = hdr[0] & 0x0F
masked = hdr[1] & 0x80
n = hdr[1] & 0x7F
if n == 126:
n = struct.unpack("!H", self._read(2))[0]
elif n == 127:
n = struct.unpack("!Q", self._read(8))[0]
mask = self._read(4) if masked else b""
payload = self._read(n)
if masked:
payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
return opcode, payload
def recv_json(self):
while True:
opcode, payload = self._read_frame()
if opcode == 0x8:
raise RuntimeError("ws closed")
if opcode == 0x9:
continue
if opcode in (0x1, 0x2):
return json.loads(payload.decode())
return json.loads(self.sock.recv())
def call(self, method, params=None):
self.n += 1
msg = {"id": self.n, "method": method}
if params:
msg["params"] = params
self.sock.sendall(self._frame(json.dumps(msg).encode()))
while True:
self.sock.send(json.dumps({"id": self.n, "method": method, "params": params or {}}))
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
self.sock.settimeout(max(.1, deadline-time.monotonic()))
obj = self.recv_json()
if obj.get("id") == self.n:
if "error" in obj:
raise RuntimeError(str(obj["error"]))
return obj.get("result") or {}
raise TimeoutError("CDP response deadline exceeded")
def close(self):
try:
self.sock.close()
except Exception:
pass
self.sock.close()
def probe(port):
return http_json("http://127.0.0.1:%s/json/version" % port) is not None
@ -140,7 +70,7 @@ def spawn_browser(display, profile, port):
if profile:
env["LAZYBOY_BROWSER_PROFILE"] = profile
subprocess.Popen(
["lazyboy-browser", "--remote-debugging-port=%s" % port, "--remote-allow-origins=*"],
["lazyboy-browser", "--remote-debugging-port=%s" % port],
env=env,
start_new_session=True,
stdout=subprocess.DEVNULL,
@ -171,6 +101,8 @@ SNAP_JS = r"""
const chromeW = Math.max(0, (window.outerWidth || 0) - (window.innerWidth || 0));
const sx0 = (window.screenX || 0) + Math.floor(chromeW / 2);
const sy0 = (window.screenY || 0) + chromeH;
document.querySelectorAll('[data-lazyboy]').forEach(el => el.removeAttribute('data-lazyboy'));
const generation = crypto.randomUUID();
const seen = new Set();
const inView = [];
const offView = [];
@ -181,7 +113,9 @@ SNAP_JS = r"""
if (st.visibility === "hidden" || st.display === "none" || Number(st.opacity) === 0) continue;
const type = (el.getAttribute("type") || "").toLowerCase();
let text;
if (el.tagName === "INPUT" && (type === "radio" || type === "checkbox")) {
if (type === "password" || /password|secret|token|one-time-code/i.test([el.name, el.id, el.autocomplete].join(" "))) {
text = "[protected input]";
} else if (el.tagName === "INPUT" && (type === "radio" || type === "checkbox")) {
// Quiz answers: the value is usually "on"; the label next to it is what
// the model must read to pick the right option.
const owner = (el.labels && el.labels[0]) || el.closest("label") || el.parentElement;
@ -214,12 +148,12 @@ SNAP_JS = r"""
const out = [];
let n = 1;
for (const item of inView.slice(0, 50).concat(offView.slice(0, 20))) {
item.el.setAttribute("data-lazyboy", String(n));
item.el.setAttribute("data-lazyboy", generation + "-" + n);
out.push({
id: n,
title: item.text,
tag: item.el.tagName.toLowerCase(),
selector: '[data-lazyboy="' + n + '"]',
selector: '[data-lazyboy="' + generation + "-" + n + '"]',
kind: "dom",
x: item.x,
y: item.y,
@ -239,6 +173,11 @@ CLICK_JS = r"""
if (!el) return {ok: false, error: "element gone"};
el.scrollIntoView({block: "center", inline: "nearest"});
const r = el.getBoundingClientRect();
const style = getComputedStyle(el);
const hit = document.elementFromPoint(r.x+r.width/2, r.y+r.height/2);
if (el.disabled || el.getAttribute("aria-disabled") === "true" || r.width <= 0 || r.height <= 0 || style.visibility === "hidden" || style.display === "none" || !hit || !(hit === el || el.contains(hit))) {
return {ok:false,error:"element is disabled, hidden, or covered; observe again"};
}
el.focus();
el.click();
const chromeH = Math.max(0, (window.outerHeight || 0) - (window.innerHeight || 0));
@ -302,7 +241,7 @@ def wait_for_visual_update(ws):
# exits, so wait for two animation frames to keep that screenshot aligned
# with the framebuffer streamed by VNC.
try:
evaluate(ws, "new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))")
evaluate(ws, "new Promise(resolve => {setTimeout(resolve, 250); requestAnimationFrame(() => requestAnimationFrame(resolve));})")
except Exception:
pass
@ -462,7 +401,7 @@ class Recorder:
msg["params"] = params
if session:
msg["sessionId"] = session
self.ws.sock.sendall(self.ws._frame(json.dumps(msg).encode()))
self.ws.sock.send(json.dumps(msg))
while True:
obj = self.ws.recv_json()
if obj.get("id") == self.ws.n:
@ -534,8 +473,47 @@ class Recorder:
self.handle(self.pending.pop(0))
self.handle(self.ws.recv_json())
FILL_LOGIN_JS = r"""
(creds) => {
if (!creds.expectedHost || location.protocol !== "https:" || location.hostname.toLowerCase() !== creds.expectedHost.toLowerCase()) {
return {ok: false, error: "saved login requires the exact configured HTTPS host"};
}
const user = creds.username || "";
const pass = creds.password || "";
const inputs = Array.from(document.querySelectorAll("input"));
const visible = (el) => {
const s = getComputedStyle(el);
const r = el.getBoundingClientRect();
return s.display !== "none" && s.visibility !== "hidden" && el.type !== "hidden" && r.width > 0 && r.height > 0;
};
const password = inputs.find((el) => el.type === "password" && visible(el) && !el.disabled);
if (!password) return {ok: false, error: "no password field on this page"};
const userish = /user|email|login|account|phone|id/i;
const username = inputs.find((el) => {
if (el === password || !visible(el) || el.disabled) return false;
const type = (el.type || "text").toLowerCase();
if (["email", "tel", "url"].includes(type)) return true;
if (type !== "text" && type !== "search") return false;
const blob = [el.name, el.id, el.placeholder, el.autocomplete, el.getAttribute("aria-label")].join(" ");
return userish.test(blob) || el === inputs[0];
});
function setValue(el, value) {
const proto = HTMLInputElement.prototype;
const desc = Object.getOwnPropertyDescriptor(proto, "value");
if (desc && desc.set) desc.set.call(el, value);
else el.value = value;
el.dispatchEvent(new Event("input", {bubbles: true}));
el.dispatchEvent(new Event("change", {bubbles: true}));
}
if (username) setValue(username, user);
setValue(password, pass);
return {ok: true, filledUsername: Boolean(username), submitted: false};
}
"""
def main():
req = json.loads(sys.argv[1])
raw = sys.argv[1] if len(sys.argv) > 1 and sys.argv[1].strip() else sys.stdin.read()
req = json.loads(raw)
action = req.get("action") or "snapshot"
display = req.get("display") or ":1"
profile = req.get("profile") or ""
@ -636,6 +614,23 @@ def main():
out["waitedSeconds"] = round(waited, 1)
print(json.dumps(out))
return
if action == "fill_login":
val = evaluate(ws, FILL_LOGIN_JS, {
"expectedHost": req.get("expectedHost") or "",
"username": req.get("username") or "",
"password": req.get("password") or "",
}) or {}
if not val.get("ok"):
fail(val.get("error") or "could not fill the login form")
wait_for_visual_update(ws)
print(json.dumps({
"ok": True,
"action": "fill_login",
"filledUsername": bool(val.get("filledUsername")),
"submitted": bool(val.get("submitted")),
"restarted": restarted,
}))
return
if action == "type":
sel = req.get("selector") or ""
text = req.get("text") or ""

View File

@ -42,6 +42,24 @@ pub fn cdp_command(request: &Value) -> Vec<String> {
cdp_command_on(PRIMARY_DISPLAY, None, request)
}
/// Same as `cdp_command_on` but the JSON body is meant to arrive on stdin
/// so secrets never appear on the process argv.
pub fn cdp_stdin_command_on(display: &str, profile: Option<&str>) -> Vec<String> {
let mut env = vec![
"env".into(),
format!("DISPLAY={}", normalize_display(display)),
];
if let Some(profile) = profile.filter(|value| !value.is_empty()) {
env.push(format!("LAZYBOY_BROWSER_PROFILE={profile}"));
}
env.extend([
"python3".into(),
"-c".into(),
CDP_PY.into(),
]);
env
}
/// Marker embedded in the recorder's argv so `pkill -f` can find exactly one
/// teaching session without touching other python processes.
pub fn teach_recorder_tag(skill_id: &str) -> String {
@ -132,21 +150,7 @@ pub fn parse_cdp_page(raw: &str) -> CdpPage {
}
pub fn merge_page_elements(windows: Vec<UiElement>, page: &[UiElement]) -> Vec<UiElement> {
if page.is_empty() {
return windows;
}
let mut elements = page.to_vec();
let mut next = elements.len() as u32 + 1;
for mut window in windows {
let title = window.title.to_lowercase();
if title.contains("chromium") || title.contains("chrome") {
continue;
}
window.id = next;
next += 1;
elements.push(window);
}
elements
crate::merge_ui_elements(windows, page, &[])
}
#[cfg(test)]

View File

@ -0,0 +1,51 @@
"""Set X11 clipboard, confirm ownership/content, then paste into the active app."""
import subprocess
import sys
import time
def run(argv, **kwargs):
return subprocess.run(argv, check=True, timeout=2, **kwargs)
def paste(text):
if not text:
return
raw = text.encode('utf-8')
# xclip forks after reading stdin; detached descriptors avoid pipe hangs.
run(['xclip', '-selection', 'clipboard', '-in'], input=raw,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
deadline = time.monotonic() + 2
while True:
actual = run(['xclip', '-selection', 'clipboard', '-out'], stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL).stdout
if actual == raw:
break
if time.monotonic() >= deadline:
raise RuntimeError('clipboard synchronization timed out; nothing pasted')
time.sleep(.02)
key_for_active_app('v')
def key_for_active_app(key):
window = run(['xdotool', 'getactivewindow'], stdout=subprocess.PIPE).stdout.decode().strip()
wmclass = run(['xprop', '-id', window, 'WM_CLASS'], stdout=subprocess.PIPE).stdout.decode().lower()
terminal = any(name in wmclass for name in ('terminal', 'xterm', 'kitty', 'alacritty', 'konsole'))
run(['xdotool', 'key', '--clearmodifiers', ('ctrl+shift+' if terminal else 'ctrl+') + key],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def copy_selection():
key_for_active_app('c')
# Wait for the application to process the shortcut before reading selection.
time.sleep(.1)
return run(['xclip', '-selection', 'clipboard', '-out'], stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL).stdout.decode('utf-8')
if __name__ == '__main__':
if len(sys.argv)>1 and sys.argv[1]=='copy':
sys.stdout.write(copy_selection())
else:
paste(sys.stdin.read())

View File

@ -1,3 +1,4 @@
mod a11y;
mod actions;
mod cdp;
mod lease;
@ -9,6 +10,7 @@ mod screen;
mod takeover;
mod x11;
pub use a11y::*;
pub use actions::*;
pub use cdp::*;
pub use lease::*;

View File

@ -58,6 +58,21 @@ pub struct CommandRequest {
pub argv: Vec<String>,
pub cwd: Option<String>,
pub timeout_ms: Option<u64>,
/// Optional bytes written to the process stdin, then closed.
/// Used so fill-login never puts a password on the argv of `ps`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stdin: Option<String>,
}
impl Default for CommandRequest {
fn default() -> Self {
Self {
argv: Vec::new(),
cwd: None,
timeout_ms: None,
stdin: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@ -193,17 +208,13 @@ pub trait SandboxProvider: Send + Sync {
&self,
computer: &ComputerRef,
context: &AdapterContext,
) -> Result<(), SandboxError> {
self.stop(computer, context).await
}
) -> Result<(), SandboxError>;
async fn resume(
&self,
request: ProvisionRequest,
context: &AdapterContext,
) -> Result<ComputerRef, SandboxError> {
self.provision(request, context).await
}
) -> Result<ComputerRef, SandboxError>;
async fn execute(
&self,

View File

@ -1,5 +1,10 @@
use lazyboy_contracts::{ComputerAction, PointerButton, PointerType, ScrollDirection};
pub fn is_browser_title(title: &str) -> bool {
let title = title.to_lowercase();
title.contains("chromium") || title.contains("chrome")
}
use crate::screen::{PRIMARY_DISPLAY, normalize_display};
pub const DISPLAY: &str = PRIMARY_DISPLAY;
@ -129,7 +134,8 @@ pub fn xdotool_argv_on(display: &str, action: &ComputerAction) -> Option<Vec<Str
}
ComputerAction::Wait { .. }
| ComputerAction::Open { .. }
| ComputerAction::Launch { .. } => {
| ComputerAction::Launch { .. }
| ComputerAction::Ref { .. } => {
return None;
}
}
@ -160,6 +166,7 @@ pub fn action_pause_ms(action: &ComputerAction) -> u64 {
ComputerAction::Focus { .. } => 90,
ComputerAction::Open { .. } | ComputerAction::Launch { .. } => 220,
ComputerAction::Wait { .. } => 0,
ComputerAction::Ref { .. } => 55,
}
}
@ -259,6 +266,11 @@ pub fn parse_ui_elements(raw: &str) -> Vec<lazyboy_contracts::UiElement> {
.and_then(serde_json::Value::as_str)
.filter(|value| !value.is_empty())
.map(str::to_string),
role: item
.get("role")
.and_then(serde_json::Value::as_str)
.filter(|value| !value.is_empty())
.map(str::to_string),
})
})
.collect()
@ -454,9 +466,38 @@ mod tests {
);
}
#[test]
fn parses_a11y_role_and_path() {
let elements = parse_ui_elements(
r#"[{"id":2,"title":"push button Open","selector":"0/3/1","kind":"a11y","role":"push button","x":8,"y":9,"w":40,"h":16}]"#,
);
assert_eq!(elements[0].kind.as_deref(), Some("a11y"));
assert_eq!(elements[0].role.as_deref(), Some("push button"));
assert_eq!(elements[0].selector.as_deref(), Some("0/3/1"));
assert!(elements[0].has_ref());
}
#[test]
fn browser_titles_match_chromium() {
assert!(is_browser_title("Example - Chromium"));
assert!(is_browser_title("chrome"));
assert!(!is_browser_title("Thunar"));
}
#[test]
fn empty_or_junk_window_list_is_empty() {
assert!(parse_ui_elements("").is_empty());
assert!(parse_ui_elements("not-json").is_empty());
}
}
/// Native clipboard path; text is supplied on stdin, never process arguments.
pub fn paste_command_on(display: &str) -> Vec<String> {
vec![
"env".into(),
display_env(display),
"python3".into(),
"-c".into(),
include_str!("clipboard.py").into(),
]
}

View File

@ -5,11 +5,12 @@ use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::routing::{get, post};
use axum::{Json, Router};
use lazyboy_contracts::ComputerAction;
use lazyboy_contracts::{ComputerAction, RefVerb};
use lazyboy_control::{
ActionRequest, PRIMARY_DISPLAY, action_pause_ms, launch_argv_on, normalize_display,
open_argv_on, parse_pointer_state, parse_ui_elements, pointer_state_command_on,
screenshot_command_on, window_list_command_on, xdotool_argv_on,
ActionRequest, PRIMARY_DISPLAY, a11y_command_on, action_pause_ms, cdp_command_on,
launch_argv_on, normalize_display, open_argv_on, parse_a11y_page, parse_cdp_page,
parse_pointer_state, parse_ui_elements, pointer_state_command_on, screenshot_command_on,
window_list_command_on, xdotool_argv_on,
};
use tokio::io::AsyncReadExt;
use tokio::process::Command;
@ -153,6 +154,12 @@ async fn apply_action(
.ok_or_else(|| "unknown application".to_string())?;
spawn_detached(&argv).await
}
ComputerAction::Ref {
verb,
target,
ref_kind,
text,
} => apply_ref(display, profile, *verb, target, ref_kind, text.as_deref()).await,
other => {
let argv =
xdotool_argv_on(display, other).ok_or_else(|| "unsupported action".to_string())?;
@ -170,6 +177,51 @@ async fn apply_action(
}
}
async fn apply_ref(
display: &str,
profile: Option<&str>,
verb: RefVerb,
target: &str,
kind: &str,
text: Option<&str>,
) -> Result<(), String> {
let action = match verb {
RefVerb::Click => "click",
RefVerb::SetValue => "type",
RefVerb::Focus => "focus",
};
let mut request = serde_json::json!({
"action": action,
"selector": target,
"display": display,
"ensure": false,
});
if let Some(text) = text {
request["text"] = serde_json::json!(text);
}
let argv = if kind == "dom" {
cdp_command_on(display, profile, &request)
} else {
a11y_command_on(display, &request)
};
let output = Command::new(&argv[0])
.args(&argv[1..])
.output()
.await
.map_err(|error| error.to_string())?;
let raw = if output.stdout.is_empty() {
String::from_utf8_lossy(&output.stderr).into_owned()
} else {
String::from_utf8_lossy(&output.stdout).into_owned()
};
let ok = if kind == "dom" {
parse_cdp_page(&raw).ok
} else {
parse_a11y_page(&raw).ok
};
if ok { Ok(()) } else { Err(raw) }
}
async fn spawn_detached(argv: &[String]) -> Result<(), String> {
Command::new(&argv[0])
.args(&argv[1..])
@ -186,14 +238,15 @@ async fn observation_json(display: &str, png: Vec<u8>) -> serde_json::Value {
let mut body = serde_json::json!({
"png_base64": base64::engine::general_purpose::STANDARD.encode(png)
});
let (cursor, window) = run_pointer_state(display).await;
let ((cursor, window), elements) =
tokio::join!(run_pointer_state(display), run_window_list(display));
if let Some(cursor) = cursor {
body["cursor"] = serde_json::json!({ "x": cursor.x, "y": cursor.y });
}
if let Some(window) = window {
body["activeWindow"] = serde_json::json!({ "id": window.id, "title": window.title });
}
let elements = run_window_list(display).await;
if !elements.is_empty() {
body["elements"] = serde_json::to_value(elements).unwrap_or(serde_json::json!([]));
}

View File

@ -10,3 +10,5 @@ lazyboy-contracts.workspace = true
serde.workspace = true
thiserror.workspace = true
rig-core.workspace = true
reqwest.workspace = true

View File

@ -106,8 +106,13 @@ fn rewrite_loopback_host(url: &str) -> String {
if !supervisor.contains("://supervisor") {
return url.to_string();
}
url.replace("://127.0.0.1", "://host.docker.internal")
.replace("://localhost", "://host.docker.internal")
let Ok(mut parsed) = reqwest::Url::parse(url) else {
return url.to_string();
};
if matches!(parsed.host_str(), Some("127.0.0.1" | "localhost" | "[::1]")) {
let _ = parsed.set_host(Some("host.docker.internal"));
}
parsed.to_string().trim_end_matches('/').to_string()
}
pub enum DynModel {

View File

@ -57,6 +57,29 @@ impl DockerSandbox {
fn url(&self, path: &str) -> String {
format!("{}{path}", self.base_url)
}
async fn post_lifecycle(
&self,
computer: &ComputerRef,
context: &AdapterContext,
action: &str,
) -> Result<(), SandboxError> {
let response = self
.client
.post(self.url(&format!("/computers/{}/{action}", computer.id)))
.headers(self.headers(context))
.send()
.await
.map_err(|error| SandboxError::message(error.to_string()))?;
if response.status().is_success() {
Ok(())
} else {
Err(SandboxError::message(format!(
"{action} failed: {}",
response.status()
)))
}
}
}
#[async_trait]
@ -345,18 +368,44 @@ impl SandboxProvider for DockerSandbox {
}
}
async fn suspend(
&self,
computer: &ComputerRef,
context: &AdapterContext,
) -> Result<(), SandboxError> {
self.post_lifecycle(computer, context, "pause").await
}
async fn resume(
&self,
request: ProvisionRequest,
context: &AdapterContext,
) -> Result<ComputerRef, SandboxError> {
if let Some(provider_ref) = request.provider_ref.clone() {
let computer = ComputerRef {
id: provider_ref.clone(),
home_key: request.home_key.clone(),
kind: SandboxKind::Docker,
provider_ref,
fresh: false,
};
if self
.post_lifecycle(&computer, context, "unpause")
.await
.is_ok()
{
return Ok(computer);
}
}
self.provision(request, context).await
}
async fn stop(
&self,
computer: &ComputerRef,
context: &AdapterContext,
) -> Result<(), SandboxError> {
self.client
.post(self.url(&format!("/computers/{}/stop", computer.id)))
.headers(self.headers(context))
.send()
.await
.map_err(|error| SandboxError::message(error.to_string()))?;
Ok(())
self.post_lifecycle(computer, context, "stop").await
}
async fn destroy(

View File

@ -166,6 +166,24 @@ impl SandboxProvider for FakeSandbox {
Ok(())
}
async fn suspend(
&self,
_computer: &ComputerRef,
_context: &AdapterContext,
) -> Result<(), SandboxError> {
Ok(())
}
async fn resume(
&self,
request: ProvisionRequest,
context: &AdapterContext,
) -> Result<ComputerRef, SandboxError> {
let mut computer = self.provision(request, context).await?;
computer.fresh = false;
Ok(computer)
}
async fn stop(
&self,
_computer: &ComputerRef,

View File

@ -21,3 +21,7 @@ base64.workspace = true
reqwest.workspace = true
async-trait = "0.1"
futures-util = "0.3"
hmac.workspace = true
sha2.workspace = true
hex.workspace = true

View File

@ -14,9 +14,8 @@ use bollard::network::CreateNetworkOptions;
use futures_util::StreamExt;
use lazyboy_control::{
ActionRequest, CommandRequest, CommandResult, EnsureScreenRequest, EnsureScreenResult, HOME,
ScreenTarget, TEAM_SCREEN_LIMIT, action_pause_ms, launch_argv_on, normalize_display,
normalize_workspace_path, open_argv_on, pointer_state_command_on, screen_layout,
screenshot_command_on, window_list_command_on, xdotool_argv_on,
ScreenTarget, TEAM_SCREEN_LIMIT, normalize_display, normalize_workspace_path,
pointer_state_command_on, screen_layout, screenshot_command_on, window_list_command_on,
};
use tokio::time::{Duration, sleep};
@ -68,22 +67,40 @@ impl DockerHost {
home_path: &str,
space_id: &str,
) -> Result<Provisioned, String> {
let home_path = host_bind_path(home_path);
tokio::fs::create_dir_all(&home_path)
let data_dir = std::env::var("DATA_DIR").unwrap_or_else(|_| "./data".into());
if home_key.is_empty()
|| !home_key
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"-_".contains(&b))
{
return Err("invalid home key".into());
}
let expected = PathBuf::from(&data_dir).join("homes").join(home_key);
if PathBuf::from(home_path) != expected {
return Err("home path is outside managed homes".into());
}
// Work on the container-local path, not the host daemon's bind path.
tokio::fs::create_dir_all(&expected)
.await
.map_err(|error| error.to_string())?;
let _ = tokio::process::Command::new("chown")
.args(["-R", "1000:1000", &home_path])
.status()
.await;
.map_err(|e| e.to_string())?;
let canonical = tokio::fs::canonicalize(&expected)
.await
.map_err(|e| e.to_string())?;
let root = tokio::fs::canonicalize(&data_dir)
.await
.map_err(|e| e.to_string())?;
if canonical != root.join("homes").join(home_key) {
return Err("symlinked home is not allowed".into());
}
#[cfg(unix)]
if std::env::var("HOST_DATA_DIR").is_ok() {
std::os::unix::fs::chown(&canonical, Some(1000), Some(1000))
.map_err(|e| e.to_string())?;
}
let home_path = host_bind_path(home_path);
if let Some(existing) = self.find(home_key).await? {
if self.container_reusable(&existing).await.unwrap_or(false) {
self.docker
.start_container(&existing, None::<StartContainerOptions<String>>)
.await
.ok();
self.wait_running(&existing).await?;
self.wait_ready(&existing).await?;
self.wake(&existing).await?;
let screen_url = self.screen_url(&existing, false).await.ok();
return Ok(Provisioned {
id: existing,
@ -100,6 +117,7 @@ impl DockerHost {
let mut labels = HashMap::new();
labels.insert("lazyboy.homeKey".into(), home_key.to_string());
labels.insert("lazyboy.spaceId".into(), space_id.to_string());
labels.insert("lazyboy.controlVersion".into(), "2".into());
let mut port_bindings = HashMap::new();
let mut exposed = HashMap::new();
@ -136,7 +154,10 @@ impl DockerHost {
env: Some(vec![
"DISPLAY=:1".into(),
format!("HOME={HOME}"),
format!("LAZYBOY_CONTROL_TOKEN={}", self.control_token),
format!(
"LAZYBOY_CONTROL_TOKEN={}",
scoped_control_token(&self.control_token, home_key)
),
]),
labels: Some(labels),
exposed_ports: Some(exposed),
@ -212,13 +233,27 @@ impl DockerHost {
}
None => HOME.to_string(),
};
let timeout_ms = request.timeout_ms.unwrap_or(30_000).clamp(100, 120_000);
let argv = if request.argv.is_empty() {
vec!["/bin/echo".into(), "ready".into()]
} else {
request.argv
};
self.exec_argv(id, &argv, Some(&cwd), &ScreenTarget::default())
.await
let mut bounded = vec![
"timeout".into(),
"--signal=TERM".into(),
"--kill-after=2s".into(),
format!("{}s", timeout_ms as f64 / 1000.0),
];
bounded.extend(argv);
self.exec_raw_cmd(
id,
&bounded,
Some(&cwd),
&ScreenTarget::default(),
request.stdin,
)
.await
}
pub async fn exec_on(
@ -239,12 +274,21 @@ impl DockerHost {
}
None => HOME.to_string(),
};
let timeout_ms = request.timeout_ms.unwrap_or(30_000).clamp(100, 120_000);
let argv = if request.argv.is_empty() {
vec!["/bin/echo".into(), "ready".into()]
} else {
request.argv
};
self.exec_argv(id, &argv, Some(&cwd), target).await
let mut bounded = vec![
"timeout".into(),
"--signal=TERM".into(),
"--kill-after=2s".into(),
format!("{}s", timeout_ms as f64 / 1000.0),
];
bounded.extend(argv);
self.exec_raw_cmd(id, &bounded, Some(&cwd), target, request.stdin)
.await
}
pub async fn observe(&self, id: &str) -> Result<Vec<u8>, String> {
@ -263,7 +307,13 @@ impl DockerHost {
value
} else {
let (stdout, stderr, code) = self
.exec_raw(id, &screenshot_command_on(&target.display), None, target)
.exec_raw(
id,
&screenshot_command_on(&target.display),
None,
target,
None,
)
.await?;
if code != 0 {
return Err(String::from_utf8_lossy(&stderr).into_owned());
@ -343,63 +393,8 @@ impl DockerHost {
request.profile_path.as_deref(),
None,
);
if let Ok(body) = self.control_act(id, &request, &target).await {
return Ok(body);
}
let mut completed = 0usize;
for action in &request.actions {
match action {
lazyboy_contracts::ComputerAction::Wait { ms } => {
sleep(Duration::from_millis(*ms as u64)).await;
}
lazyboy_contracts::ComputerAction::Open { path } => {
let _ = self
.exec_argv(
id,
&open_argv_on(&target.display, target.profile_path.as_deref(), path),
None,
&target,
)
.await?;
}
lazyboy_contracts::ComputerAction::Launch { application, uri } => {
let argv = launch_argv_on(
&target.display,
target.profile_path.as_deref(),
application,
uri.as_deref(),
)
.ok_or_else(|| "unknown application".to_string())?;
let _ = self.exec_argv(id, &argv, None, &target).await?;
}
other => {
let argv = xdotool_argv_on(&target.display, other)
.ok_or_else(|| "unsupported action".to_string())?;
let result = self.exec_argv(id, &argv, None, &target).await?;
if result.code != 0 {
return Err(result.stderr);
}
}
}
let pause = action_pause_ms(action);
if pause > 0 {
sleep(Duration::from_millis(pause)).await;
}
completed += 1;
}
if request.settle_ms > 0 {
sleep(Duration::from_millis(request.settle_ms as u64)).await;
}
let mut body = serde_json::json!({ "completed": completed });
if request.observe {
let payload = self.observe_payload(id, &target).await?;
if let serde_json::Value::Object(map) = payload.json {
if let Some(object) = body.as_object_mut() {
object.extend(map);
}
}
}
Ok(body)
// A transport failure may follow a successful click. Never replay mutations.
self.control_act(id, &request, &target).await
}
pub async fn screen_url(&self, id: &str, interactive: bool) -> Result<String, String> {
@ -536,6 +531,7 @@ PY"#,
],
None,
&ScreenTarget::default(),
None,
)
.await?;
if code != 0 {
@ -572,6 +568,18 @@ PY"#,
.map_err(|error| error.to_string())
}
pub async fn pause(&self, id: &str) -> Result<(), String> {
match self.docker.pause_container(id).await {
Ok(()) => Ok(()),
Err(error) if docker_already(&error.to_string(), "paused") => Ok(()),
Err(error) => Err(error.to_string()),
}
}
pub async fn unpause(&self, id: &str) -> Result<(), String> {
self.wake(id).await
}
pub async fn destroy(&self, id: &str) -> Result<(), String> {
let info = self.docker.inspect_container(id, None).await.ok();
let home_key = info
@ -610,17 +618,31 @@ PY"#,
.inspect_container(id, None)
.await
.map_err(|error| error.to_string())?;
if info
.config
.as_ref()
.and_then(|c| c.labels.as_ref())
.and_then(|l| l.get("lazyboy.controlVersion"))
.map(String::as_str)
!= Some("2")
{
return Ok(false);
}
let wanted = self.current_image_id().await?;
let have = info.image.unwrap_or_default();
if !image_ids_match(&wanted, &have) {
return Ok(false);
}
let running = info.state.as_ref().and_then(|state| state.running) == Some(true);
let paused = info.state.as_ref().and_then(|state| state.paused) == Some(true);
let exit = info
.state
.as_ref()
.and_then(|state| state.exit_code)
.unwrap_or(0);
if paused {
return Ok(true);
}
if !running && exit != 0 {
return Ok(false);
}
@ -659,6 +681,33 @@ PY"#,
}
}
async fn wake(&self, id: &str) -> Result<(), String> {
let info = self
.docker
.inspect_container(id, None)
.await
.map_err(|error| error.to_string())?;
let running = info.state.as_ref().and_then(|state| state.running) == Some(true);
let paused = info.state.as_ref().and_then(|state| state.paused) == Some(true);
if paused {
match self.docker.unpause_container(id).await {
Ok(()) => {}
Err(error) if docker_already(&error.to_string(), "not paused") => {}
Err(error) => return Err(error.to_string()),
}
return self.wait_ready_fast(id).await;
}
if running {
return self.wait_ready_fast(id).await;
}
self.docker
.start_container(id, None::<StartContainerOptions<String>>)
.await
.map_err(|error| error.to_string())?;
self.wait_running(id).await?;
self.wait_ready(id).await
}
async fn wait_running(&self, id: &str) -> Result<(), String> {
for _ in 0..40 {
let info = self
@ -675,12 +724,23 @@ PY"#,
}
async fn wait_ready(&self, id: &str) -> Result<(), String> {
for _ in 0..160 {
self.wait_ready_attempts(id, 160).await
}
async fn wait_ready_fast(&self, id: &str) -> Result<(), String> {
self.wait_ready_attempts(id, 20).await
}
async fn wait_ready_attempts(&self, id: &str, attempts: u32) -> Result<(), String> {
for _ in 0..attempts {
let info = self
.docker
.inspect_container(id, None)
.await
.map_err(|error| error.to_string())?;
if info.state.as_ref().and_then(|state| state.paused) == Some(true) {
return Err("computer is still paused".into());
}
if info.state.as_ref().and_then(|state| state.running) != Some(true) {
let exit = info.state.and_then(|state| state.exit_code).unwrap_or(1);
return Err(format!("computer exited during startup with code {exit}"));
@ -708,7 +768,20 @@ PY"#,
cwd: Option<&str>,
target: &ScreenTarget,
) -> Result<CommandResult, String> {
let (stdout, stderr, code) = self.exec_raw(id, argv, cwd, target).await?;
self.exec_raw_cmd(id, argv, cwd, target, None).await
}
async fn exec_raw_cmd(
&self,
id: &str,
argv: &[String],
cwd: Option<&str>,
target: &ScreenTarget,
stdin: Option<String>,
) -> Result<CommandResult, String> {
let (stdout, stderr, code) = self
.exec_raw(id, argv, cwd, target, stdin.as_deref())
.await?;
Ok(CommandResult {
stdout: String::from_utf8_lossy(&stdout).into_owned(),
stderr: String::from_utf8_lossy(&stderr).into_owned(),
@ -722,6 +795,7 @@ PY"#,
argv: &[String],
cwd: Option<&str>,
target: &ScreenTarget,
stdin: Option<&str>,
) -> Result<(Vec<u8>, Vec<u8>, i32), String> {
let display = normalize_display(&target.display);
let mut env = vec![
@ -737,6 +811,7 @@ PY"#,
.create_exec(
id,
CreateExecOptions {
attach_stdin: Some(stdin.is_some()),
attach_stdout: Some(true),
attach_stderr: Some(true),
cmd: Some(argv.to_vec()),
@ -750,7 +825,10 @@ PY"#,
.map_err(|error| error.to_string())?;
let mut stdout = Vec::new();
let mut stderr = Vec::new();
if let StartExecResults::Attached { mut output, .. } = self
if let StartExecResults::Attached {
mut output,
mut input,
} = self
.docker
.start_exec(
&exec.id,
@ -761,14 +839,26 @@ PY"#,
.await
.map_err(|error| error.to_string())?
{
if let Some(body) = stdin {
use tokio::io::AsyncWriteExt;
input
.write_all(body.as_bytes())
.await
.map_err(|error| error.to_string())?;
input.shutdown().await.map_err(|error| error.to_string())?;
}
while let Some(chunk) = output.next().await {
match chunk.map_err(|error| error.to_string())? {
bollard::container::LogOutput::StdOut { message } => {
stdout.extend_from_slice(&message)
}
bollard::container::LogOutput::StdErr { message } => {
stderr.extend_from_slice(&message)
}
bollard::container::LogOutput::StdOut { message } => stdout.extend_from_slice(
&message[..message
.len()
.min((16 * 1024 * 1024usize).saturating_sub(stdout.len()))],
),
bollard::container::LogOutput::StdErr { message } => stderr.extend_from_slice(
&message[..message
.len()
.min((1024 * 1024usize).saturating_sub(stderr.len()))],
),
_ => {}
}
}
@ -781,17 +871,45 @@ PY"#,
Ok((stdout, stderr, inspect.exit_code.unwrap_or(1) as i32))
}
pub async fn container_control_token(&self, id: &str) -> Result<String, String> {
let info = self
.docker
.inspect_container(id, None)
.await
.map_err(|e| e.to_string())?;
let labels = info
.config
.and_then(|c| c.labels)
.ok_or("unmanaged container")?;
let home = labels.get("lazyboy.homeKey").ok_or("unmanaged container")?;
Ok(scoped_control_token(&self.control_token, home))
}
async fn control_observe_json(
&self,
id: &str,
target: &ScreenTarget,
) -> Result<serde_json::Value, String> {
let script = format!(
"curl -fsS -H 'Authorization: Bearer {}' -H 'x-lazyboy-display: {}' http://127.0.0.1:7070/observe",
self.control_token, target.display
);
let token = self.container_control_token(id).await?;
let result = self
.exec_argv(id, &["bash".into(), "-lc".into(), script], None, target)
.exec_argv(
id,
&[
"curl".into(),
"-fsS".into(),
"--max-time".into(),
"20".into(),
"-X".into(),
"POST".into(),
"-H".into(),
format!("Authorization: Bearer {token}"),
"-H".into(),
format!("x-lazyboy-display: {}", target.display),
"http://127.0.0.1:7070/observe".into(),
],
None,
target,
)
.await?;
if result.code != 0 {
return Err(result.stderr);
@ -806,19 +924,29 @@ PY"#,
target: &ScreenTarget,
) -> Result<serde_json::Value, String> {
let payload = serde_json::to_string(request).map_err(|error| error.to_string())?;
let profile_header = target
.profile_path
.as_deref()
.map(|profile| format!(" -H 'x-lazyboy-profile: {profile}'"))
.unwrap_or_default();
let script = format!(
"curl -fsS -H 'Authorization: Bearer {}' -H 'x-lazyboy-display: {}'{profile_header} -H 'content-type: application/json' -d {} http://127.0.0.1:7070/act",
self.control_token,
target.display,
shell_single_quote(&payload)
);
let token = self.container_control_token(id).await?;
let mut argv = vec![
"curl".into(),
"-fsS".into(),
"--max-time".into(),
"120".into(),
"-H".into(),
format!("Authorization: Bearer {token}"),
"-H".into(),
format!("x-lazyboy-display: {}", target.display),
"-H".into(),
"content-type: application/json".into(),
];
if let Some(profile) = &target.profile_path {
argv.extend(["-H".into(), format!("x-lazyboy-profile: {profile}")]);
}
argv.extend([
"--data-binary".into(),
"@-".into(),
"http://127.0.0.1:7070/act".into(),
]);
let result = self
.exec_argv(id, &["bash".into(), "-lc".into(), script], None, target)
.exec_raw_cmd(id, &argv, None, target, Some(payload))
.await?;
if result.code != 0 {
return Err(result.stderr);
@ -871,6 +999,11 @@ fn computer_pids_limit() -> i64 {
.unwrap_or(2048)
}
fn docker_already(error: &str, needle: &str) -> bool {
let error = error.to_ascii_lowercase();
error.contains("409") || error.contains(needle)
}
fn container_name(home_key: &str) -> String {
let sanitized: String = home_key
.chars()
@ -890,3 +1023,26 @@ fn network_name(home_key: &str) -> String {
fn shell_single_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', r#"'"'"'"#))
}
fn scoped_control_token(master: &str, home: &str) -> String {
use hmac::{Hmac, Mac};
let mut mac = Hmac::<sha2::Sha256>::new_from_slice(master.as_bytes())
.expect("HMAC accepts any key length");
mac.update(b"lazyboy-computer-control-v2:");
mac.update(home.as_bytes());
hex::encode(mac.finalize().into_bytes())
}
#[cfg(test)]
mod credential_tests {
use super::*;
#[test]
fn computer_credentials_do_not_reveal_or_share_the_master() {
let master = "test-master-key-at-least-32-characters";
let a = scoped_control_token(master, "a");
let b = scoped_control_token(master, "b");
assert_ne!(a, master);
assert_ne!(a, b);
assert_eq!(a, scoped_control_token(master, "a"));
}
}

View File

@ -32,6 +32,15 @@ struct ProvisionBody {
#[tokio::main]
async fn main() {
if std::env::args().any(|arg| arg == "--healthcheck") {
let ok = reqwest::Client::new()
.get("http://127.0.0.1:7091/health")
.timeout(std::time::Duration::from_secs(3))
.send()
.await
.is_ok_and(|r| r.status().is_success());
std::process::exit(if ok { 0 } else { 1 });
}
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env().add_directive("info".parse().unwrap()))
.init();
@ -43,6 +52,15 @@ async fn main() {
);
let image =
std::env::var("LAZYBOY_COMPUTER_IMAGE").unwrap_or_else(|_| "lazyboy/computer:local".into());
let data_dir = std::env::var("DATA_DIR").unwrap_or_else(|_| "./data".into());
tokio::fs::create_dir_all(&data_dir)
.await
.expect("create data directory");
#[cfg(unix)]
if std::env::var("HOST_DATA_DIR").is_ok() {
std::os::unix::fs::chown(&data_dir, Some(1000), Some(1000))
.expect("set data directory owner");
}
let docker = DockerHost::connect(image, token.clone())
.await
.expect("docker");
@ -64,7 +82,13 @@ async fn main() {
.route("/computers/{id}/files", get(list_files).post(write_file))
.route("/computers/{id}/read", post(read_file))
.route("/computers/{id}/stop", post(stop))
.route("/computers/{id}/pause", post(pause))
.route("/computers/{id}/unpause", post(unpause))
.route("/computers/{id}", delete(destroy))
.route_layer(axum::middleware::from_fn_with_state(
app.clone(),
managed_boundary,
))
.with_state(app);
let bind = std::env::var("SUPERVISOR_BIND").unwrap_or_else(|_| "127.0.0.1:7091".into());
let listener = tokio::net::TcpListener::bind(&bind).await.expect("bind");
@ -301,6 +325,32 @@ async fn stop(
Ok(StatusCode::NO_CONTENT)
}
async fn pause(
State(app): State<App>,
headers: HeaderMap,
Path(id): Path<String>,
) -> Result<StatusCode, StatusCode> {
require_token(&headers, &app.token)?;
app.docker.pause(&id).await.map_err(|error| {
tracing::error!("pause: {error}");
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(StatusCode::NO_CONTENT)
}
async fn unpause(
State(app): State<App>,
headers: HeaderMap,
Path(id): Path<String>,
) -> Result<StatusCode, StatusCode> {
require_token(&headers, &app.token)?;
app.docker.unpause(&id).await.map_err(|error| {
tracing::error!("unpause: {error}");
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(StatusCode::NO_CONTENT)
}
async fn destroy(
State(app): State<App>,
headers: HeaderMap,
@ -321,3 +371,27 @@ struct _Home(&'static str);
fn _assert_home() {
let _ = HOME;
}
async fn managed_boundary(
State(app): State<App>,
req: axum::extract::Request,
next: axum::middleware::Next,
) -> axum::response::Response {
use axum::response::IntoResponse;
if req.uri().path() != "/health" {
if require_token(req.headers(), &app.token).is_err() {
return StatusCode::UNAUTHORIZED.into_response();
}
if let Some(id) = req
.uri()
.path()
.strip_prefix("/computers/")
.and_then(|p| p.split('/').next())
{
if app.docker.container_control_token(id).await.is_err() {
return StatusCode::NOT_FOUND.into_response();
}
}
}
next.run(req).await
}

5
docker-compose.dev.yml Normal file
View File

@ -0,0 +1,5 @@
# Opt-in host access for local Rust development only.
services:
postgres:
ports:
- "127.0.0.1:5434:5432"

View File

@ -1,12 +1,15 @@
services:
postgres:
restart: unless-stopped
networks: [database]
logging: &bounded-logs
driver: json-file
options: {max-size: "10m", max-file: "3"}
image: pgvector/pgvector:pg16
environment:
POSTGRES_USER: lazyboy
POSTGRES_PASSWORD: lazyboy
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-lazyboy}
POSTGRES_DB: lazyboy
ports:
- "127.0.0.1:5434:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
@ -25,6 +28,19 @@ services:
network_mode: none
supervisor:
restart: unless-stopped
init: true
networks: [control]
logging: *bounded-logs
security_opt: ["no-new-privileges:true"]
cap_drop: [ALL]
cap_add: [CHOWN, DAC_OVERRIDE]
read_only: true
healthcheck:
test: ["CMD", "/usr/local/bin/lazyboy-supervisor", "--healthcheck"]
interval: 5s
timeout: 4s
retries: 12
build:
context: .
dockerfile: image/supervisor/Dockerfile
@ -33,7 +49,7 @@ services:
LAZYBOY_COMPUTER_IMAGE: lazyboy/computer:local
SUPERVISOR_BIND: 0.0.0.0:7091
DATA_DIR: /data
HOST_DATA_DIR: ${PWD}/data
HOST_DATA_DIR: ${LAZYBOY_HOST_DATA_DIR:-${PWD}/data}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./data:/data
@ -44,18 +60,26 @@ services:
condition: service_completed_successfully
api:
restart: unless-stopped
init: true
networks: [database, control, egress]
logging: *bounded-logs
security_opt: ["no-new-privileges:true"]
cap_drop: [ALL]
pids_limit: 256
build:
context: .
dockerfile: image/api/Dockerfile
environment:
DATABASE_URL: postgres://lazyboy:lazyboy@postgres:5432/lazyboy
DATABASE_URL: postgres://lazyboy:${POSTGRES_PASSWORD:-lazyboy}@postgres:5432/lazyboy
SANDBOX_SUPERVISOR_URL: http://supervisor:7091
SANDBOX_SUPERVISOR_TOKEN: ${SANDBOX_SUPERVISOR_TOKEN:?Set SANDBOX_SUPERVISOR_TOKEN in .env}
LAZYBOY_APP_TOKEN: ${LAZYBOY_APP_TOKEN:-}
LAZYBOY_APP_TOKEN: ${LAZYBOY_APP_TOKEN:?Set LAZYBOY_APP_TOKEN in .env}
LAZYBOY_VAULT_KEY: ${LAZYBOY_VAULT_KEY:-}
LAZYBOY_SECURE_COOKIE: ${LAZYBOY_SECURE_COOKIE:-false}
SANDBOX_PROVIDER: docker
DATA_DIR: /data
HOST_DATA_DIR: ${PWD}/data
HOST_DATA_DIR: ${LAZYBOY_HOST_DATA_DIR:-${PWD}/data}
API_BIND: 0.0.0.0:3100
XAI_API_KEY: ${XAI_API_KEY:-}
OPENCODE_GO_API_KEY: ${OPENCODE_GO_API_KEY:-}
@ -67,7 +91,7 @@ services:
LAZYBOY_WEB_DIR: /web
LAZYBOY_SCREEN_UPSTREAM: host.docker.internal
ports:
- "3101:3100"
- "${LAZYBOY_BIND_IP:-127.0.0.1}:3101:3100"
volumes:
- ./data:/data
extra_hosts:
@ -76,7 +100,14 @@ services:
postgres:
condition: service_healthy
supervisor:
condition: service_started
condition: service_healthy
volumes:
pgdata:
networks:
database:
internal: true
control:
internal: true
egress: {}

442
docs/hero.html Normal file
View File

@ -0,0 +1,442 @@
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8"/>
<title>LazyBoy hero</title>
<style>
@font-face {
font-family: Huninn;
font-weight: 400;
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;
font-weight: 400;
src: url("../apps/web/node_modules/@fontsource/huninn/files/huninn-latin-ext-400-normal.woff2") format("woff2");
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1EFF;
}
@font-face {
font-family: Huninn;
font-weight: 400;
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: 640px;
overflow: hidden;
background: #f3f3f5;
color: #161618;
font-family: Huninn, "jf open 粉圓", "PingFang TC", sans-serif;
}
.page {
width: 1280px;
height: 640px;
padding: 28px 48px 0;
position: relative;
}
.top {
display: flex;
align-items: center;
justify-content: space-between;
height: 36px;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
font-size: 20px;
letter-spacing: .02em;
}
.pill {
display: inline-flex;
align-items: center;
gap: 8px;
height: 28px;
padding: 0 12px;
border: 1px solid #e4e4e8;
border-radius: 999px;
background: #fff;
color: #5c5c64;
font-size: 12px;
}
.pill b {
color: #0f766e;
font-weight: 400;
}
.copy {
text-align: center;
margin: 22px auto 18px;
max-width: 760px;
}
h1 {
margin: 0;
font-size: 42px;
font-weight: 400;
letter-spacing: .01em;
line-height: 1.15;
}
.lead {
margin: 10px 0 0;
color: #6b6b73;
font-size: 15px;
line-height: 1.55;
}
.window {
width: 1184px;
height: 470px;
margin: 0 auto;
border-radius: 16px 16px 0 0;
overflow: hidden;
background: #0d0d0e;
box-shadow: 0 24px 70px rgba(16, 16, 20, .28), 0 0 0 1px rgba(0,0,0,.08);
display: grid;
grid-template-rows: 36px 1fr;
}
.chrome {
display: flex;
align-items: center;
gap: 7px;
padding: 0 14px;
background: #161618;
border-bottom: 1px solid #202023;
}
.dot { width: 10px; height: 10px; border-radius: 50%; }
.dot.r { background: #ff5f57; }
.dot.y { background: #febc2e; }
.dot.g { background: #28c840; }
.app {
display: grid;
grid-template-columns: 228px 1fr 300px;
min-height: 0;
color: #dfdfe2;
background: #050506;
}
.side, .chat, .desk {
min-width: 0;
min-height: 0;
}
.side {
background: #0b0b0c;
border-right: 1px solid #171719;
padding: 12px 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
.ws {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 6px 8px;
font-size: 14px;
}
.ws i {
width: 26px; height: 26px;
display: grid; place-items: center;
border-radius: 50%;
background: #151517;
color: #85858a;
font-size: 9px;
font-style: normal;
letter-spacing: .04em;
}
.search {
height: 34px;
border: 1px solid #202023;
border-radius: 12px;
background: #121214;
color: #626267;
display: flex;
align-items: center;
padding: 0 10px;
font-size: 12px;
gap: 8px;
}
.search svg { width: 13px; height: 13px; stroke: currentColor; fill: none; stroke-width: 1.7; }
.label {
padding: 8px 8px 2px;
color: #626267;
font-size: 10px;
letter-spacing: .06em;
}
.bot {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border-radius: 12px;
}
.bot.on { background: #171719; }
.bot strong { display: block; font-size: 13px; font-weight: 400; }
.bot small { display: block; color: #85858a; font-size: 11px; margin-top: 1px; }
.bot .copy { min-width: 0; flex: 1; }
.bot .copy strong, .bot .copy small {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.time { color: #626267; font-size: 10px; align-self: start; }
.chat {
background: #0d0d0e;
display: grid;
grid-template-rows: 52px 1fr 118px;
}
.topbar {
display: flex;
align-items: center;
gap: 8px;
padding: 0 16px;
border-bottom: 1px solid #171719;
font-size: 15px;
}
.grow { flex: 1; }
.tools {
display: flex; gap: 2px; padding: 3px;
border: 1px solid #29292d; border-radius: 11px; background: rgba(18,18,20,.86);
}
.tools i {
width: 26px; height: 26px; border-radius: 8px;
display: grid; place-items: center;
font-style: normal;
}
.tools i.on { background: #151517; }
.tools svg { width: 13px; height: 13px; stroke: currentColor; fill: none; stroke-width: 1.7; }
.tools i.on svg { stroke: #f1f1f2; }
.tools i:not(.on) svg { stroke: #85858a; }
.msgs {
padding: 18px 28px 8px;
display: flex;
flex-direction: column;
gap: 12px;
}
.bubble {
max-width: 78%;
padding: 10px 14px;
border-radius: 18px;
line-height: 1.5;
font-size: 13px;
white-space: pre-wrap;
}
.me { align-self: flex-end; background: #f1f1ef; color: #1a1a1a; }
.them { align-self: flex-start; background: #19191c; }
.dock {
padding: 8px 22px 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.think {
display: flex; align-items: center; gap: 8px;
font-size: 12px;
}
.think .lbl {
background: linear-gradient(90deg, #7a8088 0%, #7a8088 28%, #fff 50%, #7a8088 72%, #7a8088 100%);
background-size: 220% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.think small { color: #85858a; }
.composer {
height: 52px;
display: flex; align-items: center; gap: 8px;
border: 1px solid #29292d;
background: #121214;
border-radius: 22px;
padding: 6px 8px;
}
.plus, .send {
width: 34px; height: 34px; border-radius: 50%;
display: grid; place-items: center;
}
.plus { color: #85858a; font-size: 20px; }
.ph { flex: 1; color: #626267; font-size: 13px; }
.send { background: #f1f1ef; color: #1b1b1c; font-size: 14px; }
.desk {
background: #0a0a0b;
border-left: 1px solid #171719;
padding: 10px 12px 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.desk-h {
display: flex; align-items: center; gap: 8px;
color: #f1f1f2; font-size: 13px;
}
.desk-h .dot-run {
width: 7px; height: 7px; border-radius: 50%; background: #4ecb71;
}
.desk-h small { color: #85858a; margin-left: auto; font-size: 11px; }
.preview {
position: relative;
flex: 1;
border: 1px solid #29292d;
border-radius: 12px;
background: #101012;
overflow: hidden;
min-height: 168px;
}
.desktop {
position: absolute; inset: 0;
background:
radial-gradient(circle at 20% 0%, #1a2430 0%, transparent 42%),
linear-gradient(180deg, #15202b, #0d1218);
}
.win {
position: absolute;
left: 14px; top: 12px; right: 14px; bottom: 28px;
border-radius: 8px;
background: #fff;
overflow: hidden;
box-shadow: 0 10px 28px rgba(0,0,0,.35);
display: grid;
grid-template-rows: 22px 28px 1fr;
}
.win-bar { background: #e8e8ea; display: flex; align-items: center; gap: 5px; padding: 0 8px; }
.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: #444; font-size: 10px;
border-bottom: 1px solid #ececf0;
}
.page-body { padding: 12px 14px; color: #222; }
.page-body h3 { margin: 0 0 6px; font-size: 13px; font-weight: 400; }
.page-body p { margin: 0; color: #666; font-size: 11px; line-height: 1.45; }
.progress {
margin-top: 10px; height: 6px; border-radius: 99px; background: #ececf0; overflow: hidden;
}
.progress b { display: block; width: 62%; height: 100%; background: #3ec5a8; }
.next {
margin-top: 10px; height: 24px; width: 64px;
border-radius: 6px; background: #1a1a1c; color: #fff;
display: grid; place-items: center; font-size: 11px;
}
.panel {
position: absolute; left: 0; right: 0; bottom: 0; height: 22px;
background: #1b242e; border-top: 1px solid #2a3540;
}
.cap {
display: flex; justify-content: space-between;
color: #85858a; font-size: 11px;
}
.btn {
height: 28px; padding: 0 10px; border-radius: 8px;
border: 1px solid #29292d; color: #dfdfe2; display: grid; place-items: center;
font-size: 12px;
}
.btn.cream { background: #f1f1ef; color: #181819; border-color: #f1f1ef; }
.actions { display: flex; justify-content: flex-end; gap: 6px; }
/* blobatar-ish faces */
.blob { width: 32px; height: 32px; flex: 0 0 32px; }
.blob.sm { width: 22px; height: 22px; flex-basis: 22px; }
.blob.md { width: 28px; height: 28px; flex-basis: 28px; }
.think-ring { position: relative; }
.think-ring:after {
content: "";
position: absolute; inset: -5px;
border-radius: 50%;
background: conic-gradient(from 40deg, transparent 0 8%, #ff4fd8 12%, #7c5cff 18%, transparent 26% 48%, #34d9ff 54%, transparent 62%);
-webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 2px), #000 calc(100% - 1.5px));
mask: radial-gradient(farthest-side, transparent calc(100% - 2px), #000 calc(100% - 1.5px));
}
</style>
</head>
<body>
<div class="page">
<div class="top">
<div class="brand">
<svg class="blob md" viewBox="0 0 64 64" aria-hidden="true">
<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>
LazyBoy
</div>
<div class="pill"><b>本機開源</b> 每個 Agent 有自己的 Linux 桌面</div>
</div>
<div class="copy">
<h1>給 Agent 一台真的電腦</h1>
<p class="lead">在瀏覽器裡開機器人。它會自己開網頁、敲指令、學你示範過的流程。<br/>金鑰、模型、桌面都在你這台機器上。</p>
</div>
<div class="window">
<div class="chrome"><i class="dot r"></i><i class="dot y"></i><i class="dot g"></i></div>
<div class="app">
<aside class="side">
<div class="ws"><i>LB</i> 本機工作區</div>
<div class="search"><svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/></svg>搜尋</div>
<div class="label">已釘選</div>
<div class="bot on">
<span class="think-ring">
<svg class="blob" 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>
</span>
<div class="copy"><strong>工程之神</strong><small>正在第 12 頁,等 Next 亮起</small></div>
<span class="time">14:08</span>
</div>
<div class="label">Agent</div>
<div class="bot">
<svg class="blob" viewBox="0 0 64 64"><path fill="#f5a03c" d="M30 7c13-1 26 10 25 24-1 15-12 26-25 27S5 46 7 31 17 8 30 7z"/><ellipse cx="25" cy="30" rx="4" ry="4.8" fill="#1b1b22"/><ellipse cx="40" cy="30" rx="4" ry="4.8" fill="#1b1b22"/><circle cx="26.3" cy="28.3" r="1.2" fill="#f3f3f6"/><circle cx="41.3" cy="28.3" r="1.2" fill="#f3f3f6"/></svg>
<div class="copy"><strong>早報摘要</strong><small>明天 09:00 會跑</small></div>
</div>
<div class="bot">
<svg class="blob" viewBox="0 0 64 64"><path fill="#6a6bf5" d="M34 6c12 2 22 12 21 25s-12 26-25 27S6 46 8 32 22 4 34 6z"/><ellipse cx="26" cy="29" rx="4.1" ry="5" fill="#f3f3f6"/><ellipse cx="41" cy="29" rx="4.1" ry="5" fill="#f3f3f6"/><circle cx="24.8" cy="27.4" r="1.2" fill="#1b1b22"/><circle cx="39.8" cy="27.4" r="1.2" fill="#1b1b22"/></svg>
<div class="copy"><strong>客服助手</strong><small>已記住回覆語氣</small></div>
</div>
</aside>
<main class="chat">
<header class="topbar">
<svg class="blob sm" 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>
工程之神
<span style="color:#85858a;font-size:12px">STAR 訓練 ▾</span>
<span class="grow"></span>
<div class="tools">
<i class="on"><svg viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="12" rx="2"/><path d="M8 21h8M12 17v4"/></svg></i>
<i><svg viewBox="0 0 24 24"><path d="M12 3a7 7 0 0 0-4 12.7V18h8v-2.3A7 7 0 0 0 12 3z"/><path d="M9 21h6"/></svg></i>
<i><svg viewBox="0 0 24 24"><path d="M12 5v2M12 17v2M5 12h2M17 12h2M7 7l1.5 1.5M15.5 15.5L17 17M7 17l1.5-1.5M15.5 8.5L17 7"/><circle cx="12" cy="12" r="3"/></svg></i>
</div>
</header>
<div class="msgs">
<div class="bubble me">幫我把 STAR 那堂課看完,並把測驗交了。</div>
<div class="bubble them">電腦已開。現在在第 12 / 24 頁,影片倒數結束我就按 Next。密碼欄我不會記。</div>
</div>
<div class="dock">
<div class="think">
<span class="think-ring"><svg class="blob sm" 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></span>
<span class="lbl">正在工作…</span>
<small>click Next</small>
</div>
<div class="composer"><div class="plus"></div><div class="ph">傳訊息給 工程之神</div><div class="send"></div></div>
</div>
</main>
<aside class="desk">
<div class="desk-h">工程之神 的電腦 <i class="dot-run"></i> <small>執行中</small></div>
<div class="preview">
<div class="desktop">
<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 · 第 12 頁</h3>
<p>看完這段說明後按 Next。頁面倒數結束才會解鎖。</p>
<div class="progress"><b></b></div>
<div class="next">Next</div>
</div>
</div>
<div class="panel"></div>
</div>
</div>
<div class="cap"><span>獨立螢幕</span><span>放大</span></div>
<div class="actions"><div class="btn cream">接手操作</div><div class="btn">停止任務</div></div>
</aside>
</div>
</div>
</div>
</body>
</html>

BIN
docs/readme-hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

View File

@ -1,39 +1,59 @@
# syntax=docker/dockerfile:1
FROM node:22-bookworm-slim AS web
WORKDIR /src/apps/web
COPY apps/web/package.json apps/web/package-lock.json ./
RUN npm ci
RUN --mount=type=cache,target=/root/.npm npm ci --no-audit --no-fund
COPY apps/web/index.html apps/web/tsconfig.json apps/web/vite.config.ts ./
COPY apps/web/src src
RUN npm run build
RUN npm run build \
&& apt-get update \
&& apt-get install -y --no-install-recommends binutils \
&& strip --strip-unneeded /usr/local/bin/node \
&& rm -rf /var/lib/apt/lists/*
FROM debian:trixie-slim AS python
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip python3-lxml \
&& pip3 install --break-system-packages --no-cache-dir \
--no-compile --target /opt/python mcp-server-fetch==2026.8.18 \
&& find /opt/python -type d -name __pycache__ -prune -exec rm -rf '{}' + \
&& rm -rf /var/lib/apt/lists/*
FROM rust:1-trixie AS build
WORKDIR /src
COPY Cargo.toml Cargo.lock ./
COPY crates crates
COPY migrations migrations
COPY apps/web apps/web
RUN cargo build --release -p lazyboy-api
COPY apps/web/vnc.html apps/web/vnc.html
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,target=/src/target,id=lazyboy-api-release,sharing=locked \
cargo build --locked --release -p lazyboy-api && cp /src/target/release/lazyboy-api /lazyboy-api
FROM debian:trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates python3 python3-pip python3-lxml \
&& pip3 install --break-system-packages --no-cache-dir mcp-server-fetch \
ca-certificates python3 python3-lxml \
&& rm -rf /var/lib/apt/lists/*
COPY --from=web /usr/local/bin/node /usr/local/bin/node
COPY --from=web /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/npm
COPY --from=build /src/target/release/lazyboy-api /usr/local/bin/lazyboy-api
COPY --from=python /opt/python /opt/python
COPY --from=build /lazyboy-api /usr/local/bin/lazyboy-api
COPY --from=web /src/apps/web/dist /web
COPY --from=web /src/apps/web/node_modules/@novnc/novnc/core /web/novnc/core
COPY --from=web /src/apps/web/node_modules/@novnc/novnc/vendor /web/novnc/vendor
RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
&& ln -sf /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx \
&& npm install -g --omit=dev \
@modelcontextprotocol/server-memory \
@modelcontextprotocol/server-sequential-thinking \
@notionhq/notion-mcp-server \
@modelcontextprotocol/server-memory@2026.8.31 \
@modelcontextprotocol/server-sequential-thinking@2026.8.31 \
@notionhq/notion-mcp-server@2.5.1 \
&& npm cache clean --force \
&& node -v && npx --version \
&& python3 -c "import mcp_server_fetch"
ENV LAZYBOY_WEB_DIR=/web
&& PYTHONPATH=/opt/python python3 -c "import mcp_server_fetch"
ENV LAZYBOY_WEB_DIR=/web \
PYTHONPATH=/opt/python
ENV NPM_CONFIG_UPDATE_NOTIFIER=false
ENV NPM_CONFIG_FUND=false
RUN useradd --create-home --uid 1000 lazyboy
USER 1000:1000
EXPOSE 3100
CMD ["lazyboy-api"]

View File

@ -1,3 +1,4 @@
# syntax=docker/dockerfile:1
# Real bot desktop. Every boot must look like a Linux workstation:
# XFCE panel + window manager, Chromium, zsh terminal. Never a kiosk/HTML shell.
# Traditional Chinese fonts/locale so CJK text does not mojibake.
@ -9,11 +10,19 @@ WORKDIR /src
COPY Cargo.toml Cargo.lock ./
COPY crates crates
COPY migrations migrations
RUN cargo build --release -p lazyboy-controld
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,target=/src/target,id=lazyboy-computer-release,sharing=locked \
cargo build --locked --release -p lazyboy-controld && cp /src/target/release/lazyboy-controld /lazyboy-controld
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
# Keep Chromium, XFCE, CJK, AT-SPI, git. Skip Debian novnc (pulls nodejs),
# fonts-noto-core (Latin is DejaVu/Liberation/huninn), and unused xterm.
RUN printf '%s\n' \
'path-include=/usr/share/locale/zh_TW/*' \
'path-include=/usr/share/locale/zh/*' \
> /etc/dpkg/dpkg.cfg.d/zz-lazyboy-locale \
&& apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
chromium \
curl \
@ -22,13 +31,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
fonts-liberation \
fonts-noto-cjk \
fonts-noto-color-emoji \
fonts-noto-core \
git \
htop \
imagemagick \
locales \
novnc \
procps \
python3 \
python3-websocket \
util-linux \
websockify \
wmctrl \
@ -44,17 +53,21 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
xfdesktop4 \
xdotool \
xfwm4 \
xterm \
xvfb \
thunar \
adwaita-icon-theme \
gnome-themes-extra \
librsvg2-common \
at-spi2-core \
libatk-adaptor \
python3-gi \
gir1.2-atspi-2.0 \
zsh \
&& echo "zh_TW.UTF-8 UTF-8" >> /etc/locale.gen \
&& echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen \
&& locale-gen \
&& rm -rf /var/lib/apt/lists/*
&& mkdir -p /usr/share/novnc \
&& rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* /usr/share/doc/* /usr/share/man/*
# jf open 粉圓 (system UI) + MesloLGS NF (Powerlevel10k glyphs, CJK via fontconfig).
RUN mkdir -p /usr/share/fonts/truetype/huninn /usr/share/fonts/truetype/meslo \
@ -83,7 +96,7 @@ RUN useradd --create-home --uid 1000 --shell /bin/zsh lazyboy \
&& mkdir -p /home/lazyboy /tmp/lazyboy /usr/share/lazyboy/skel /usr/share/lazyboy/xfce-skel /etc/gtk-3.0 /etc/fonts/conf.d \
&& chown -R 1000:1000 /home/lazyboy /tmp/lazyboy
COPY --from=controld --chmod=755 /src/target/release/lazyboy-controld /usr/local/bin/lazyboy-controld
COPY --from=controld --chmod=755 /lazyboy-controld /usr/local/bin/lazyboy-controld
COPY --chmod=755 image/computer/lazyboy-screen /usr/local/bin/lazyboy-screen
COPY --chmod=755 image/computer/lazyboy-browser /usr/local/bin/lazyboy-browser
COPY --chmod=755 image/computer/lazyboy-terminal /usr/local/bin/lazyboy-terminal
@ -100,20 +113,10 @@ COPY --chmod=644 image/computer/xfce/xfce4-desktop.xml /usr/share/lazyboy/xfce-s
COPY --chmod=644 image/computer/xfce/thunar.xml /usr/share/lazyboy/xfce-skel/xfce4/xfconf/xfce-perchannel-xml/thunar.xml
COPY --chmod=644 image/computer/xfce/terminal.desktop /usr/share/applications/lazyboy-terminal.desktop
COPY --chmod=644 image/computer/xfce/browser.desktop /usr/share/applications/lazyboy-browser.desktop
# Debian slim drops /usr/share/locale. Keep Traditional Chinese catalogs so
# XFCE menus are not English. Hide stock xterm / duplicate terminal entries;
# the panel launches lazyboy-terminal (zsh -l).
RUN printf '%s\n' \
'path-include=/usr/share/locale/zh_TW/*' \
'path-include=/usr/share/locale/zh/*' \
> /etc/dpkg/dpkg.cfg.d/zz-lazyboy-locale \
&& apt-get update \
&& apt-get install -y --no-install-recommends --reinstall \
xfce4-panel xfce4-terminal xfdesktop4 xfwm4 thunar xfce4-settings \
libxfce4ui-2-0 libxfce4ui-common libxfce4util7 libxfce4util-common \
xfce4-helpers libgarcon-common \
&& rm -rf /var/lib/apt/lists/* \
&& for f in xterm uxterm debian-xterm xfce4-terminal; do \
# Traditional Chinese catalogs were retained during package installation.
# Hide stock xterm / duplicate terminal entries; the panel launches
# lazyboy-terminal (zsh -l).
RUN for f in xterm uxterm debian-xterm xfce4-terminal; do \
if [ -f "/usr/share/applications/${f}.desktop" ]; then \
printf '\nNoDisplay=true\nHidden=true\n' >> "/usr/share/applications/${f}.desktop"; \
fi; \
@ -132,7 +135,8 @@ COPY --chmod=755 image/computer/start.sh /usr/local/bin/lazyboy-computer
USER 1000:1000
ENV HOME=/home/lazyboy DISPLAY=:1 SHELL=/bin/zsh TERM=xterm-256color \
LANG=zh_TW.UTF-8 LC_ALL=zh_TW.UTF-8 LANGUAGE=zh_TW:zh:en
LANG=zh_TW.UTF-8 LC_ALL=zh_TW.UTF-8 LANGUAGE=zh_TW:zh:en \
GTK_MODULES=atk-bridge GTK_A11Y=atspi GNOME_ACCESSIBILITY=1 NO_AT_BRIDGE=0
WORKDIR /home/lazyboy
EXPOSE 6080 6081 6082 6083 6084 6085 6086 6087
CMD ["/usr/local/bin/lazyboy-computer"]

View File

@ -43,8 +43,8 @@ fi
rm -f "$PROFILE/SingletonLock" "$PROFILE/SingletonCookie" "$PROFILE/SingletonSocket"
exec /usr/bin/chromium \
--no-sandbox \
--remote-debugging-address=127.0.0.1 \
--remote-debugging-port="$DEVTOOLS_PORT" \
--remote-allow-origins='*' \
--test-type \
--disable-gpu \
--disable-dev-shm-usage \

View File

@ -10,6 +10,10 @@ export TERM="${TERM:-xterm-256color}"
export LANG="${LANG:-zh_TW.UTF-8}"
export LC_ALL="${LC_ALL:-zh_TW.UTF-8}"
export LANGUAGE="${LANGUAGE:-zh_TW:zh:en}"
export GTK_MODULES="${GTK_MODULES:-atk-bridge}"
export GTK_A11Y="${GTK_A11Y:-atspi}"
export GNOME_ACCESSIBILITY="${GNOME_ACCESSIBILITY:-1}"
export NO_AT_BRIDGE="${NO_AT_BRIDGE:-0}"
export PATH="$HOME/.local/bin:/usr/local/bin:$PATH"
ROOT=/tmp/lazyboy
@ -112,6 +116,41 @@ alive_pidfile() {
[[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null
}
start_atspi() {
local display="$1"
local log="$2"
local number="${display#:}"
if [[ -n "${DBUS_SESSION_BUS_ADDRESS:-}" ]]; then
printf '%s\n' "$DBUS_SESSION_BUS_ADDRESS" >"$ROOT/screen-${number}.dbus"
fi
if [[ -n "${XDG_RUNTIME_DIR:-}" ]]; then
printf '%s\n' "$XDG_RUNTIME_DIR" >"$ROOT/screen-${number}.runtime"
fi
local launcher="" registry=""
local c
for c in /usr/libexec/at-spi-bus-launcher /usr/lib/at-spi2-core/at-spi-bus-launcher at-spi-bus-launcher; do
if [[ -x "$c" ]] || command -v "$c" >/dev/null 2>&1; then
launcher="$c"
break
fi
done
for c in /usr/libexec/at-spi2-registryd /usr/lib/at-spi2-core/at-spi2-registryd at-spi2-registryd; do
if [[ -x "$c" ]] || command -v "$c" >/dev/null 2>&1; then
registry="$c"
break
fi
done
if [[ -n "$launcher" ]]; then
DISPLAY="$display" "$launcher" --launch-immediately >"${log}-atspi-bus.log" 2>&1 &
echo $! >"${log}-atspi-bus.pid"
fi
if [[ -n "$registry" ]]; then
DISPLAY="$display" "$registry" >"${log}-atspi-registry.log" 2>&1 &
echo $! >"${log}-atspi-registry.pid"
fi
sleep 0.2
}
start_desktop() {
local display="$1"
local xfce_home="$2"
@ -131,6 +170,7 @@ start_desktop() {
eval "$(dbus-launch --sh-syntax)"
echo "${DBUS_SESSION_BUS_PID:-}" >"${log}-dbus.pid"
fi
start_atspi "$display" "$log"
if command -v xfconfd >/dev/null 2>&1; then
xfconfd --daemon >/dev/null 2>&1 || true
fi

View File

@ -10,6 +10,10 @@ export TERM="${TERM:-xterm-256color}"
export LANG="${LANG:-zh_TW.UTF-8}"
export LC_ALL="${LC_ALL:-zh_TW.UTF-8}"
export LANGUAGE="${LANGUAGE:-zh_TW:zh:en}"
export GTK_MODULES="${GTK_MODULES:-atk-bridge}"
export GTK_A11Y="${GTK_A11Y:-atspi}"
export GNOME_ACCESSIBILITY="${GNOME_ACCESSIBILITY:-1}"
export NO_AT_BRIDGE="${NO_AT_BRIDGE:-0}"
mkdir -p "$HOME" /tmp/lazyboy /tmp/.X11-unix
rm -f /tmp/lazyboy/ready
export PATH="$HOME/.local/bin:/usr/local/bin:$PATH"

View File

@ -1,12 +1,14 @@
# syntax=docker/dockerfile:1
FROM rust:1-bookworm AS build
WORKDIR /src
COPY Cargo.toml Cargo.lock ./
COPY crates crates
COPY migrations migrations
RUN cargo build --release -p lazyboy-supervisor
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,target=/src/target,id=lazyboy-supervisor-release,sharing=locked \
cargo build --locked --release -p lazyboy-supervisor && cp /src/target/release/lazyboy-supervisor /lazyboy-supervisor
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=build /src/target/release/lazyboy-supervisor /usr/local/bin/lazyboy-supervisor
FROM gcr.io/distroless/cc-debian12:latest
COPY --from=build /lazyboy-supervisor /usr/local/bin/lazyboy-supervisor
EXPOSE 7091
CMD ["lazyboy-supervisor"]
CMD ["/usr/local/bin/lazyboy-supervisor"]

View File

@ -0,0 +1,34 @@
CREATE TABLE IF NOT EXISTS vault_accounts (
id TEXT PRIMARY KEY,
space_id TEXT NOT NULL,
user_id TEXT NOT NULL,
bot_id TEXT NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
site TEXT NOT NULL,
host TEXT NOT NULL DEFAULT '',
username TEXT NOT NULL,
password_ciphertext TEXT NOT NULL,
notes TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS vault_accounts_bot_idx ON vault_accounts (bot_id, updated_at DESC);
CREATE TABLE IF NOT EXISTS schedules (
id TEXT PRIMARY KEY,
space_id TEXT NOT NULL,
user_id TEXT NOT NULL,
bot_id TEXT NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
thread_id TEXT REFERENCES threads(id) ON DELETE SET NULL,
name TEXT NOT NULL,
cron TEXT NOT NULL,
timezone TEXT NOT NULL DEFAULT 'Asia/Taipei',
instructions TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
last_run_at TIMESTAMPTZ,
next_run_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS schedules_due_idx ON schedules (enabled, next_run_at)
WHERE enabled AND next_run_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS schedules_bot_idx ON schedules (bot_id, enabled, updated_at DESC);

@ -1 +1 @@
Subproject commit 2b399a9d96e51449e1e8d6d1c7b81b8c4955fa74
Subproject commit 0f5c4cefd59cdbe440deb7e05fd3f503164a6068

View File

@ -2,7 +2,7 @@
set -euo pipefail
root="$(cd "$(dirname "$0")/.." && pwd)"
cd "$root"
docker compose up -d postgres
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d postgres
echo "waiting for postgres..."
for _ in $(seq 1 40); do
if docker compose exec -T postgres pg_isready -U lazyboy >/dev/null 2>&1; then

21
scripts/init-env.py Normal file
View File

@ -0,0 +1,21 @@
#!/usr/bin/env python3
"""Initialize new installs only. Existing keys and vault data are preserved."""
from pathlib import Path
import secrets
path=Path('.env')
if path.exists():
print('.env already exists; existing credentials were preserved.')
else:
values={key:secrets.token_hex(32) for key in ('LAZYBOY_APP_TOKEN','SANDBOX_SUPERVISOR_TOKEN','LAZYBOY_VAULT_KEY','POSTGRES_PASSWORD')}
text=Path('.env.example').read_text()
lines=[]
for line in text.splitlines():
key=line.split('=',1)[0]
if key in values:line=f'{key}={values[key]}'
elif key=='DATABASE_URL':line=f"DATABASE_URL=postgres://lazyboy:{values['POSTGRES_PASSWORD']}@127.0.0.1:5434/lazyboy"
lines.append(line)
with path.open('x') as out:
path.chmod(0o600)
out.write('\n'.join(lines)+'\n')
print('Created .env with independent app, supervisor, vault and database secrets. Set your model API key next.')

30
scripts/render-readme-hero.sh Executable file
View File

@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Render docs/hero.html to docs/readme-hero.png
# Same idea as Rakazo's README banner: a designed HTML frame (logo + headline
# + product window), then a browser screenshot. Not an image-model generation,
# so every character on the screen stays exact.
set -euo pipefail
root="$(cd "$(dirname "$0")/.." && pwd)"
html="$root/docs/hero.html"
out="$root/docs/readme-hero.png"
brave="/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"
if [[ ! -x "$brave" ]]; then
echo "Need Brave at $brave (or edit this script to your Chromium)." >&2
exit 1
fi
"$brave" \
--headless=new \
--disable-gpu \
--hide-scrollbars \
--force-device-scale-factor=2 \
--window-size=1280,640 \
--screenshot="$out" \
"file://$html"
python3 - "$out" <<'PY'
from pathlib import Path
import struct, sys
p = Path(sys.argv[1])
data = p.read_bytes()
w, h = struct.unpack(">II", data[16:24])
print(f"wrote {p} ({w}x{h}, {p.stat().st_size} bytes)")
PY

43
tests/control.test.py Normal file
View File

@ -0,0 +1,43 @@
import importlib.util
import subprocess
import unittest
from pathlib import Path
from unittest.mock import patch
def module(name, path):
spec=importlib.util.spec_from_file_location(name,path)
mod=importlib.util.module_from_spec(spec);spec.loader.exec_module(mod);return mod
clipboard=module('clipboard',Path('crates/control/src/clipboard.py'))
cdp=module('cdp',Path('crates/control/src/cdp.py'))
class ClipboardTest(unittest.TestCase):
def test_confirmed_unicode_then_terminal_shortcut(self):
calls=[]
def fake(argv,**kwargs):
calls.append(argv)
data=b''
if '-out' in argv: data='中文\nemoji🙂'.encode()
if 'getactivewindow' in argv:data=b'123'
if 'WM_CLASS' in argv:data=b'xfce4-terminal'
return subprocess.CompletedProcess(argv,0,stdout=data)
with patch.object(clipboard,'run',fake): clipboard.paste('中文\nemoji🙂')
self.assertEqual(calls[-1][-1],'ctrl+shift+v')
self.assertEqual(sum('key' in a for a in calls),1)
def test_no_paste_when_sync_fails(self):
calls=[]
def fake(argv,**kwargs):calls.append(argv);return subprocess.CompletedProcess(argv,0,stdout=b'stale')
with patch.object(clipboard,'run',fake),patch.object(clipboard.time,'monotonic',side_effect=[0,3]):
with self.assertRaises(RuntimeError):clipboard.paste('new')
self.assertFalse(any('key' in a for a in calls))
def test_cdp_handles_events_before_response(self):
class Socket:
def __init__(self):self.items=iter(['{"method":"Page.event"}','{"id":1,"result":{"ok":true}}'])
def send(self,x):pass
def settimeout(self,x):pass
def recv(self):return next(self.items)
ws=cdp.Ws.__new__(cdp.Ws);ws.sock=Socket();ws.n=0
self.assertEqual(ws.call('Runtime.test'),{'ok':True})
if __name__=='__main__':unittest.main()

33
tests/frontend.test.mjs Normal file
View File

@ -0,0 +1,33 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
import ts from '../apps/web/node_modules/typescript/lib/typescript.js';
const raw=fs.readFileSync('apps/web/src/schedule.tsx','utf8');
const js=ts.transpileModule(raw,{compilerOptions:{module:ts.ModuleKind.CommonJS,jsx:ts.JsxEmit.React}}).outputText;
const box={exports:{},require:()=>({t:x=>x})};vm.runInNewContext(js,box);const {cronFromPreset,presetFromCron,defaultCronPreset}=box.exports;
test('fixed intervals retain exact elapsed units',()=>{
for (const unit of ['minutes','hours','days']) {const p={...defaultCronPreset(),freq:'Interval',n:45,unit};const cron=cronFromPreset(p);const restored=presetFromCron(cron);assert.equal(restored.n,45);assert.equal(restored.unit,unit);}
});
test('unknown cron is preserved verbatim instead of silently rewritten',()=>{
for(const cron of ['0 0 9 * * 1','0 25 * * *','99 9 * * *','0 0 */3 * *','*/45 * * * *']){const p=presetFromCron(cron);assert.equal(p.freq,'Advanced');assert.equal(cronFromPreset(p),cron);}
});
test('blank advanced expressions are never converted to a scheduled job',()=>assert.equal(cronFromPreset({...defaultCronPreset(),freq:'Advanced',cron:''}),''));
test('VNC paste delegates once to confirmed backend and rejects another source',async()=>{
const source=fs.readFileSync('apps/web/vnc.html','utf8').match(/<script type="module">([\s\S]*?)<\/script>/)[1].replace(/import RFB[^;]+;/,'');
const handlers={},sent=[];class RFB{addEventListener(){} focus(){} sendKey(){}}
const window={location:{pathname:'/vnc.html',protocol:'http:',host:'localhost',origin:'http://localhost',hash:''},parent:{postMessage:x=>sent.push(x)},addEventListener:(n,f)=>handlers[n]=f};
vm.runInNewContext(source,{window,document:{location:{href:'http://localhost/vnc.html?view_only=false'},getElementById:()=>({}),querySelector:()=>null},navigator:{clipboard:{readText:async()=>'中文\nhello'}},RFB,setTimeout(){},clearTimeout(){}});
await handlers.keydown({ctrlKey:true,code:'KeyV',preventDefault(){},stopImmediatePropagation(){}});
assert.equal(sent.filter(x=>x.type==='lazyboy-paste-text').length,1);
assert.equal(sent.at(-1).text,'中文\nhello');
handlers.message({origin:'http://localhost',source:{},data:{type:'lazyboy-host-clipboard',text:'bad'}});assert.equal(sent.at(-1).text,'中文\nhello');
});
test('saved login rejects HTTP, lookalike hosts, and missing host before touching fields',()=>{
const py=fs.readFileSync('crates/control/src/cdp.py','utf8');const expression=py.match(/FILL_LOGIN_JS = r"""([\s\S]*?)"""/)[1];
for(const [protocol,hostname,expectedHost] of [['https:','evil.example','bank.example'],['http:','bank.example','bank.example'],['https:','bank.example.evil','bank.example'],['https:','bank.example','']]) {
const evaluate=vm.runInNewContext(`(${expression})`,{location:{protocol,hostname},document:{querySelectorAll(){throw Error('must not touch fields')}}});
assert.equal(evaluate({username:'u',password:'secret',expectedHost}).ok,false);
}
});