Compare commits

...

4 Commits

Author SHA1 Message Date
王性驊 0e02c2e148 Merge pull request 'feat/schedule' (#3) from feat/schedule into main
Reviewed-on: #3
2026-09-05 15:19:26 +00:00
王性驊 2adcdbc262 add icon 2026-09-05 23:18:24 +08:00
王性驊 8790712d9b fix all context bubble 2026-09-05 22:59:07 +08:00
王性驊 2bd00a4716 feat: 排程、保險箱、熱機桌面與 README
Agent 可建 cron 排程,登入帳號進每 bot 保險箱。閒置桌面改 docker pause,
分頁心跳保活,喚醒不再整套重開;啟動/換手用左上頭像小卡。原生 GUI
走 AT-SPI,VNC 貼上與唯讀不拆 iframe。README 補硬體、對 Grok Bot、
流程圖與二次開發目錄。
2026-09-05 17:41:44 +08:00
94 changed files with 10415 additions and 927 deletions

View File

@ -4,3 +4,13 @@ data
reference reference
**/*.md **/*.md
apps/web/node_modules 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 # Generate independent values with: openssl rand -hex 32
LAZYBOY_APP_TOKEN= LAZYBOY_APP_TOKEN=
SANDBOX_SUPERVISOR_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 SANDBOX_PROVIDER=docker
DATABASE_URL=postgres://lazyboy:lazyboy@127.0.0.1:5434/lazyboy DATABASE_URL=postgres://lazyboy:lazyboy@127.0.0.1:5434/lazyboy
DATA_DIR=./data 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 SANDBOX_SUPERVISOR_URL=http://127.0.0.1:7091
LAZYBOY_SECURE_COOKIE=false LAZYBOY_SECURE_COOKIE=false
LAZYBOY_COMPUTER_MEMORY_MB=2048 LAZYBOY_COMPUTER_MEMORY_MB=2048

3
.gitignore vendored
View File

@ -9,3 +9,6 @@ node_modules
dist dist
.DS_Store .DS_Store
.gstack/ .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" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" 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]] [[package]]
name = "ahash" name = "ahash"
version = "0.8.12" version = "0.8.12"
@ -55,6 +90,12 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "ambient-authority"
version = "0.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b"
[[package]] [[package]]
name = "android_system_properties" name = "android_system_properties"
version = "0.1.6" version = "0.1.6"
@ -435,6 +476,36 @@ version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" 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]] [[package]]
name = "castaway" name = "castaway"
version = "0.2.4" version = "0.2.4"
@ -491,6 +562,26 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "cmake" name = "cmake"
version = "0.1.58" version = "0.1.58"
@ -655,6 +746,17 @@ dependencies = [
"cfg-if", "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]] [[package]]
name = "crossbeam-deque" name = "crossbeam-deque"
version = "0.8.7" version = "0.8.7"
@ -702,9 +804,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [ dependencies = [
"generic-array", "generic-array",
"rand_core 0.6.4",
"typenum", "typenum",
] ]
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]] [[package]]
name = "darling" name = "darling"
version = "0.20.11" version = "0.20.11"
@ -1141,6 +1253,17 @@ dependencies = [
"percent-encoding", "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]] [[package]]
name = "fs_extra" name = "fs_extra"
version = "1.3.0" version = "1.3.0"
@ -1305,6 +1428,16 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "gif" name = "gif"
version = "0.14.2" version = "0.14.2"
@ -1843,6 +1976,15 @@ dependencies = [
"web-time", "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]] [[package]]
name = "interpolate_name" name = "interpolate_name"
version = "0.2.4" version = "0.2.4"
@ -1854,6 +1996,22 @@ dependencies = [
"syn 2.0.119", "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]] [[package]]
name = "ipnet" name = "ipnet"
version = "2.12.1" version = "2.12.1"
@ -2011,10 +2169,14 @@ dependencies = [
name = "lazyboy-api" name = "lazyboy-api"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"aes-gcm",
"async-trait", "async-trait",
"axum", "axum",
"base64 0.22.1", "base64 0.22.1",
"cap-std",
"chrono", "chrono",
"chrono-tz",
"cron",
"dotenvy", "dotenvy",
"fastembed", "fastembed",
"futures-util", "futures-util",
@ -2026,6 +2188,7 @@ dependencies = [
"lazyboy-control", "lazyboy-control",
"lazyboy-harness", "lazyboy-harness",
"lazyboy-sandbox", "lazyboy-sandbox",
"rand 0.8.8",
"reqwest 0.12.28", "reqwest 0.12.28",
"rig-core", "rig-core",
"rmcp", "rmcp",
@ -2089,6 +2252,7 @@ name = "lazyboy-harness"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"lazyboy-contracts", "lazyboy-contracts",
"reqwest 0.12.28",
"rig-core", "rig-core",
"serde", "serde",
"thiserror", "thiserror",
@ -2120,11 +2284,14 @@ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bollard", "bollard",
"futures-util", "futures-util",
"hex",
"hmac",
"lazyboy-contracts", "lazyboy-contracts",
"lazyboy-control", "lazyboy-control",
"reqwest 0.12.28", "reqwest 0.12.28",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"thiserror", "thiserror",
"tokio", "tokio",
"tracing", "tracing",
@ -2277,6 +2444,12 @@ dependencies = [
"rawpointer", "rawpointer",
] ]
[[package]]
name = "maybe-owned"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4"
[[package]] [[package]]
name = "maybe-rayon" name = "maybe-rayon"
version = "0.1.1" version = "0.1.1"
@ -2602,6 +2775,12 @@ dependencies = [
"pkg-config", "pkg-config",
] ]
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]] [[package]]
name = "openssl" name = "openssl"
version = "0.10.81" version = "0.10.81"
@ -2755,6 +2934,24 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" 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]] [[package]]
name = "pin-project" name = "pin-project"
version = "1.1.13" version = "1.1.13"
@ -2827,6 +3024,18 @@ dependencies = [
"miniz_oxide 0.8.9", "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]] [[package]]
name = "portable-atomic" name = "portable-atomic"
version = "1.15.0" version = "1.15.0"
@ -3536,6 +3745,16 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.43" version = "0.23.43"
@ -3930,6 +4149,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "siphasher"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
@ -4568,7 +4793,7 @@ dependencies = [
"indexmap 2.14.1", "indexmap 2.14.1",
"toml_datetime", "toml_datetime",
"toml_parser", "toml_parser",
"winnow", "winnow 1.0.4",
] ]
[[package]] [[package]]
@ -4577,7 +4802,7 @@ version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
dependencies = [ dependencies = [
"winnow", "winnow 1.0.4",
] ]
[[package]] [[package]]
@ -4823,6 +5048,16 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" 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]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.9.0" version = "0.9.0"
@ -5420,6 +5655,15 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winnow"
version = "0.6.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e90edd2ac1aa278a5c4599b1d89cf03074b610800f866d4026dc199d7929a28"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "winnow" name = "winnow"
version = "1.0.4" version = "1.0.4"
@ -5429,6 +5673,16 @@ dependencies = [
"memchr", "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]] [[package]]
name = "wit-bindgen" name = "wit-bindgen"
version = "0.57.1" version = "0.57.1"

View File

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

View File

@ -53,26 +53,12 @@ help: ## Show this help
# --- Environment ----------------------------------------------------------- # --- Environment -----------------------------------------------------------
env: ## Create .env from example with fresh random tokens (no-op if .env exists) env: ## Create .env with independent random secrets (preserve existing .env)
@if [ -f .env ]; then \ @python3 scripts/init-env.py
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-force: ## Regenerate .env (wipes XAI_API_KEY — you will set it again) env-force: ## Refuse destructive key regeneration; existing vault keys must be preserved
@cp .env.example .env; \ @echo "Refusing to overwrite .env: this can orphan saved passwords. See docs/security-and-harness-review.md for safe rotation."
APP=$$(openssl rand -hex 32); SUP=$$(openssl rand -hex 32); \ @exit 1
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)."
# --- Full Docker stack ----------------------------------------------------- # --- 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) . docker build -f image/computer/Dockerfile -t $(COMPUTER_IMAGE) .
postgres: ## Start only postgres and wait until it is ready 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..." @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 @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

366
README.md
View File

@ -1,72 +1,332 @@
# LazyBoy # 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 ```bash
make env # creates .env with two generated 64-hex tokens (no-op if .env exists) make env
# then set XAI_API_KEY in .env # 在 .env 填 XAI_API_KEY
make up # builds all images and starts the stack # (也可以之後在「本機工作區 → 設定」接 xAI / OpenCode Go / OpenAI 相容端點)
make up
make health 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`): 沒有 Make
| 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.
```bash ```bash
cp .env.example .env cp .env.example .env
# Set XAI_API_KEY, then generate two different secrets: # openssl rand -hex 32 → LAZYBOY_APP_TOKEN
# openssl rand -hex 32 # openssl rand -hex 32 → SANDBOX_SUPERVISOR_TOKEN
# openssl rand -hex 32 # openssl rand -hex 32 → LAZYBOY_VAULT_KEY
# Put them in LAZYBOY_APP_TOKEN and SANDBOX_SUPERVISOR_TOKEN. docker compose up -d --build
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
``` ```
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 ```bash
`http://127.0.0.1:5173`. Vite proxies API and computer-screen traffic to the Rust API on port 3101. 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。
---
## 系統怎麼轉起來
![其實只有三個角色你、LazyBoy、它的電腦](./docs/diagrams/map.png)
Compose 裡 supervisor **不**對主機開埠。API 在容器網路連 `supervisor:7091`。家目錄 `data/homes/<homeKey>` bind 進容器的 `/home/lazyboy`
---
## 你送一則訊息
![它先決定:聊就好,還是要動手](./docs/diagrams/chat.png)
同一 bot 已有進行中的工作時,新訊息會排隊(`queuedBehindActive`)。人正在接管時,後面的話只排隊,思考轉圈不會假裝它還在動。問候路徑會把工具表清空,從源頭避免「哈囉」去開電腦。
`execute_run` 每一輪:續租約 → 寫步驟文字 → 問模型 → 沒有工具就結束(技能沒過會再把畫面塞回去)→ 有工具且需要沙盒才 boot → 畫面沒變就不重複塞圖。回合上限:聊天 4、一般 40、技能 80。
---
## 電腦的作息
![開著、小睡、關機](./docs/diagrams/sleep.png)
閒置(`crates/api/src/computer.rs` `idle_loop`
1. 執行中、超過 10 分鐘沒人看、也沒有進行中的 run示範 → `docker pause`,狀態 `suspended`
2. 休眠超過 6 小時 → `docker stop`,狀態 `stopped`
3. 分頁還在就心跳,不會進 1
開機/喚醒:已在跑就直接回;凍結中就 `unpause`(約一秒);沒有容器才 `provision`,等 `/tmp/lazyboy/ready`。畫面走 `/view/{bot}/vnc.html`,已登入的 cookie 轉到 websockify。
---
## 它怎麼看、怎麼點
![你看乾淨的,它看有編號的](./docs/diagrams/look.png)
![能認控制項,就不要猜座標](./docs/diagrams/click.png)
模型從不直接連 VNC。它只打 API 工具;工具經 sandbox HTTP 進 supervisor`docker exec` 或打容器內 `controld`
編號只畫在給模型的 JPEG 上。VNC 是乾淨桌面。每次 navigationsnapshot 會重編號,舊 id 作廢。`computer_act` 點在瀏覽器視窗上會被拒,避免用像素點網頁。解析度契約是 **1280×800**
每個 bot 一個 `computer_screens`slot、DISPLAY、執行租約、控制租約。人接管寫 `control_holder=user`worker 在回合邊界停,不跟你搶滑鼠。`view_only` 用 postMessage 切,不重掛 iframe所以換手時預覽不會黑掉。
`lazyboy-controld``127.0.0.1:7070`。Team 多螢幕slot 0 = `:1`slot N = `:N+1`。`lazyboy-screen ensure` 在同一個容器裡再長一組 Xvfb。
---
## 模型金鑰從哪來
![由近到遠,找到第一把就用](./docs/diagrams/keys.png)
`crates/harness` 不管滑鼠,只決定這次 run 要用哪一家模型。真正的 agent 迴圈在 `crates/api/src/runs.rs`
金鑰:**這個機器人 → 工作區設定 → 環境變數**。API 跑在 Docker 時,迴圈位址 `127.0.0.1` 會被改成 `host.docker.internal`,才能打到你本機的相容端點。
---
## 教會它
![你做一次,它記住為什麼](./docs/diagrams/teach.png)
之後 run 若 prompt 對得上技能名,會把完整 playbook 塞進當則,並清掉舊聊天以免模型複誦上次的「還在倒數」。執行仍用 `browser``computer_act`,在**現在**的畫面上找「Next」不是記像素。
---
## 排程怎麼進工作
![到點以後,跟你傳訊息同一條路](./docs/diagrams/schedule.png)
Cron 五欄。時區寫在列上,預設台北。`立刻跑` 只是立刻插一筆 run不改下一拍時間。
---
## 專案目錄(二次開發從這裡找)
![把它當成幾間房間,不是分層蛋糕](./docs/diagrams/folders.png)
Cargo workspace。畫面在 `apps/web`,對話與工作在 `api`,怎麼點在 `control`,開機在 `supervisor` + `image/computer`,問哪一家模型在 `harness`。前端是獨立的 Vite app由 API 把 `apps/web/dist`(或開發時的 `apps/web`)端出去。
```text
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 依賴
```
### 想改什麼,開哪個檔
| 你要做的事 | 先開 |
| --- | --- |
| 加一個模型工具(例如 `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

@ -1 +1 @@
<!doctype html><html lang="zh-Hant"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#050506"/><title>LazyBoy</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html> <!doctype html><html lang="zh-Hant"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#050506"/><title>LazyBoy</title><link rel="icon" href="/favicon.svg" type="image/svg+xml"/><link rel="icon" href="/favicon.png" type="image/png" sizes="32x32"/><link rel="apple-touch-icon" href="/apple-touch-icon.png"/></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>

File diff suppressed because it is too large Load Diff

View File

@ -11,10 +11,14 @@
"dependencies": { "dependencies": {
"@blobatar/react": "^2.7.0", "@blobatar/react": "^2.7.0",
"@fontsource/huninn": "^5.3.0", "@fontsource/huninn": "^5.3.0",
"@novnc/novnc": "1.7.0",
"blobatar": "^2.7.0", "blobatar": "^2.7.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-useanimations": "^2.10.0" "react-markdown": "^10.1.0",
"react-useanimations": "^2.10.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^19.0.0", "@types/react": "^19.0.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" rx="22" fill="#050506"/>
<g transform="translate(4 4) scale(.92)">
<path fill="#f1f1ef" d="M84.5 48.88C84.5 66.49 68.81 81.73 50.68 81.73C32.55 81.73 16.87 66.49 16.87 48.88C16.87 31.27 32.55 16.04 50.68 16.04C68.81 16.04 84.5 31.27 84.5 48.88Z"/>
<g fill="#1b1b22">
<ellipse cx="43.16" cy="49.68" rx="4.2" ry="11.2"/>
<ellipse cx="61.4" cy="48.81" rx="5.0" ry="14.1"/>
</g>
<g fill="#f3f3f6">
<circle cx="44.6" cy="43.2" r="1.7"/>
<circle cx="63.1" cy="41.4" r="2"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 629 B

BIN
apps/web/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 604 B

View File

@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<title>LazyBoy</title>
<!-- Round blobatar (traits.shape 0.11) with enlarged eyes for tab size. -->
<path fill="#f1f1ef" d="M84.5 48.88C84.5 66.49 68.81 81.73 50.68 81.73C32.55 81.73 16.87 66.49 16.87 48.88C16.87 31.27 32.55 16.04 50.68 16.04C68.81 16.04 84.5 31.27 84.5 48.88Z"/>
<g fill="#1b1b22">
<ellipse cx="43.16" cy="49.68" rx="4.2" ry="11.2"/>
<ellipse cx="61.4" cy="48.81" rx="5.0" ry="14.1"/>
</g>
<g fill="#f3f3f6">
<circle cx="44.6" cy="43.2" r="1.7"/>
<circle cx="63.1" cy="41.4" r="2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 606 B

View File

@ -1,8 +1,7 @@
import { FormEvent, KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; 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 { 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 loading from "react-useanimations/lib/loading";
import loading2 from "react-useanimations/lib/loading2";
import arrowUp from "react-useanimations/lib/arrowUp"; import arrowUp from "react-useanimations/lib/arrowUp";
import bookmark from "react-useanimations/lib/bookmark"; import bookmark from "react-useanimations/lib/bookmark";
import copy from "react-useanimations/lib/copy"; import copy from "react-useanimations/lib/copy";
@ -19,22 +18,33 @@ 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 { 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 { t, type MessageKey } from "./i18n";
import type { AvatarShape, Bot, ComputerMode, ComputerStatus, McpCatalogEntry, McpServer, McpTransport, MemoryItem, Message, MessageFile, ModelProviderId, Playbook, PlaybookInput, PlaybookStep, Room, RoomMember, Session, TaughtSkill, WorkspaceSettings } from "./types"; import type { AvatarShape, Bot, ComputerMode, ComputerStatus, McpCatalogEntry, McpServer, McpTransport, MemoryItem, Message, MessageFile, ModelProviderId, Playbook, PlaybookInput, PlaybookStep, Room, RoomMember, Session, TaughtSkill, WorkspaceSettings } from "./types";
import { ChatMarkdown, CopyMessageButton } from "./markdown";
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 SESSION_STORE="lazyboy.sessionByBot";
const PANE_STORE="lazyboy.rightPane"; const PANE_STORE="lazyboy.rightPane";
const WORKSPACE_STORE="lazyboy.workspace"; 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; 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 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 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}; 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 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 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 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 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)} 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=4;
const ATTACH_MAX_BYTES=10*1024*1024; const ATTACH_MAX_BYTES=10*1024*1024;
@ -45,6 +55,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 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 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 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 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 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(){ function clientNonce(){
@ -79,9 +90,14 @@ export function App(){
const [roomToDelete,setRoomToDelete]=useState<Room|null>(null); const [mcpServers,setMcpServers]=useState<McpServer[]>([]); const [roomToDelete,setRoomToDelete]=useState<Room|null>(null); const [mcpServers,setMcpServers]=useState<McpServer[]>([]);
const [accountOpen,setAccountOpen]=useState(false); const [accountDialog,setAccountDialog]=useState<AccountDialog>(null); 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 [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 [workspaceName,setWorkspaceName]=useState(workspaceStart.name);
const [looks,setLooks]=useState(readAvatarLooks); const [looks,setLooks]=useState(readAvatarLooks);
const sendingRef=useRef(false); const refreshSeqRef=useRef(0); const importRef=useRef<HTMLInputElement>(null); const attachRef=useRef<HTMLInputElement>(null); 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 [pendingFiles,setPendingFiles]=useState<PendingFile[]>([]);
const messageEndRef=useRef<HTMLDivElement|null>(null); const messageEndRef=useRef<HTMLDivElement|null>(null);
const sentHistoryRef=useRef<string[]>([]); const historyIndexRef=useRef<number|null>(null); const historyDraftRef=useRef(""); const sentHistoryRef=useRef<string[]>([]); const historyIndexRef=useRef<number|null>(null); const historyDraftRef=useRef("");
@ -92,7 +108,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 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 paneBotId=busyMembers[0]?.id||activeRoom?.members[0]?.id||activeId;
const paneBot=bots.find(bot=>bot.id===paneBotId)||active; 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||""; const lastMessageId=messages[messages.length-1]?.id||"";
// The bot is parked in waiting_takeover: nothing moves (including queued // The bot is parked in waiting_takeover: nothing moves (including queued
// messages) until the human releases the screen, so say so loudly. // messages) until the human releases the screen, so say so loudly.
@ -131,14 +150,40 @@ export function App(){
useEffect(()=>{if(!plusOpen)setSkillQuery("")},[plusOpen]); useEffect(()=>{if(!plusOpen)setSkillQuery("")},[plusOpen]);
useEffect(()=>{messageEndRef.current?.scrollIntoView({block:"end",behavior:"smooth"})},[activeSessionId,lastMessageId,workingMembers.length,pausedForUser]); 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(()=>{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.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(()=>{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(()=>{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 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(()=>{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(()=>{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(()=>{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(()=>{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)} 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 sessionBusy=workingMembers.length>0;
const otherSessionBusy=Boolean(computer.busyBotName&&!sessionBusy); const otherSessionBusy=Boolean(computer.busyBotName&&!sessionBusy);
@ -166,7 +211,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 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 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 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 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()} async function inbox(bot:Bot,actionName:string,groupName?:string|null){await api(`/api/bots/${bot.id}/inbox`,{method:"POST",body:JSON.stringify({action:actionName,groupName})});await loadBots()}
function openBot(bot:Bot){setMobileNav(false);setSessionMenuOpen(false);if(bot.unreadCount>0)void inbox(bot,"read");if(bot.id===activeId&&!activeRoomId){if(!activeSessionId){const stored=readSessionStore()[bot.id];if(stored)setActiveSessionId(stored);else void loadSessions()}return}setMessages([]);setActiveSessionId(null);setActiveRoomId(null);setBusyMembers([]);setActiveId(bot.id)} function 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 +235,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()})} 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)} function openAccount(dialog:AccountDialog){setAccountOpen(false);setAccountDialog(dialog)}
async function logout(){setAccountOpen(false);await api("/api/session",{method:"DELETE",body:"{}"}).catch(()=>{});setBots([]);setRooms([]);setMcpServers([]);setActiveId(null);setActiveRoomId(null);setAuthRequired(true)} async function 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 async function changeComputer(operation:"boot"|"restart"){
// display cannot remain stuck on the previous disconnected iframe. const botId=paneBotId;if(!botId)return;
const startBoot=async()=>{setScreenUrl(null);setComputer(current=>({...current,state:"booting"}));await api(`/api/computer/${paneBot?.id||active?.id}/boot`,{method:"POST",body:"{}"})}; const previous=computer;setScreenUrl(null);setDesktopReady(false);
const restartComputer=async()=>{setScreenUrl(null);setComputer(current=>({...current,state:"booting"}));await api(`/api/computer/${paneBot?.id||active?.id}/restart`,{method:"POST",body:"{}"})}; setComputer(current=>({...current,state:operation==="boot"&&current.state==="suspended"?"suspended":"booting"}));
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}/>; 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")}> 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==="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==="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==="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> <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 +297,30 @@ export function App(){
</aside> </aside>
<main className="chat-panel"> <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> <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 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> <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&&(message.role==="assistant"?<div className="message-stack"><div className="message-body md"><ChatMarkdown>{message.body}</ChatMarkdown></div>{message.body.trim()?<CopyMessageButton text={message.body}/>:null}</div>:<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>} {error&&<div className="error-banner"><span>{error}</span><button onClick={()=>setError(null)}><X/></button></div>}
{otherSessionBusy&&<div className="queue-hint">{t("anotherConversationQueued")}</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> <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> </main>
{!rightCollapsed&&<div className="side-card-backdrop" onClick={()=>setRightCollapsed(true)}/>} {!rightCollapsed&&<div className="side-card-backdrop" onClick={()=>setRightCollapsed(true)}/>}
{!rightCollapsed&&<aside className="side-card"> {!rightCollapsed&&<aside className="side-card">
<> <>
<header className="side-card-head"> <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> <button type="button" className="icon-button" title={t("collapseSidebar")} onClick={()=>setRightCollapsed(true)}><ChevronsRight/></button>
</header> </header>
<div className="side-card-body"> <div className="side-card-body">
<div className={`side-part computer-part ${rightPart==="computer"?"":"hidden-part"}`}> <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="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> <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}/>}</>} {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> </div>
{rightPart==="accounts"&&paneBot&&<VaultPane bot={paneBot}/>}
{rightPart==="memory"&&paneBot&&<MemoryPane bot={paneBot} changed={loadBots}/>} {rightPart==="memory"&&paneBot&&<MemoryPane bot={paneBot} changed={loadBots}/>}
{rightPart==="plugins"&&<McpPane servers={mcpServers} reload={loadMcp}/>} {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)}/>} {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 +328,10 @@ export function App(){
</> </>
</aside>} </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)}/>} {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)}/>} {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)}}/>} {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()}}/>} {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 +405,30 @@ function BotSettingsPane({bot,look,onLook,saved,onDelete}:{bot:Bot;look:AvatarLo
</form> </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)}}
function setField<K extends keyof typeof form>(key:K,value:string){setForm(current=>({...current,[key]:value}))}
return <div className="vault-pane">
<p className="memory-help">{t("accountsHelp")}</p>
<form className="vault-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:""})})}}>
<div className="vault-fields">
<label>{t("accountSite")}<input value={form.site} onChange={e=>setField("site",e.target.value)} placeholder={t("accountSitePlaceholder")} autoComplete="off"/></label>
<label>{t("accountHost")}<input value={form.host} onChange={e=>setField("host",e.target.value)} placeholder={t("accountHostPlaceholder")} autoComplete="off"/></label>
<label>{t("accountUsername")}<input value={form.username} onChange={e=>setField("username",e.target.value)} autoComplete="username"/></label>
<label>{t("accountPassword")}<input type="password" value={form.password} onChange={e=>setField("password",e.target.value)} autoComplete="new-password"/></label>
</div>
<label>{t("accountNotes")}<input value={form.notes} onChange={e=>setField("notes",e.target.value)} placeholder={t("accountNotesPlaceholder")} autoComplete="off"/></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 className="vault-empty">{t("noAccounts")}</p>:items.map(item=><div className="account-row" key={item.id}><div className="account-row-main"><strong>{item.site}</strong><small>{item.username}{item.host?` · ${item.host}`:""}</small>{item.notes?<small className="account-notes">{item.notes}</small>:null}</div><button type="button" className="icon-button" title={t("delete")} aria-label={t("delete")} disabled={busy} onClick={()=>void run(()=>api(`/api/bots/${bot.id}/accounts/${item.id}`,{method:"DELETE"}))}><UseAnimations animation={trash2} size={18} strokeColor="#dfdfe2"/></button></div>)}</div>
{error&&<div className="pane-error">{error}</div>}
</div>
}
function MemoryPane({bot,changed}:{bot:Bot;changed:()=>Promise<void>}){ function MemoryPane({bot,changed}:{bot:Bot;changed:()=>Promise<void>}){
const[items,setItems]=useState<MemoryItem[]>([]);const[draft,setDraft]=useState("");const[query,setQuery]=useState(""); 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(""); const[enabled,setEnabled]=useState(bot.memoryEnabled);const[busy,setBusy]=useState(false);const[error,setError]=useState("");
@ -460,8 +563,11 @@ function McpCustomForm({busy,error,onBack,onSubmit}:{busy:boolean;error:string;o
</form> </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 ComputerHud({bot,label}:{bot:{id:string;name:string;avatarColor?:string;avatarShape?:AvatarShape};label:string}){
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>} 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 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}){ function TeachDialog({bot,busy,close,start}:{bot:Bot;busy:boolean;close:()=>void;start:(goal:string)=>void}){
const [goal,setGoal]=useState(""); 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 type { Animation } from "react-useanimations/utils";
import activity from "react-useanimations/lib/activity"; import activity from "react-useanimations/lib/activity";
import archive from "react-useanimations/lib/archive"; 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}

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

@ -0,0 +1,67 @@
.messages{flex:1;overflow:auto;padding:34px max(34px,7vw) 150px;display:flex;flex-direction:column;gap:18px}
.message{display:flex}
.message>.message-body,.message-stack>.message-body{max-width:76%;padding:13px 17px;border-radius:22px;white-space:pre-wrap;line-height:1.5}
.message>.message-body.md,.message-stack>.message-body.md{min-width:0;white-space:normal}
.message.user{justify-content:flex-end}
.message.user>.message-body{background:var(--cream);color:#1a1a1a}
.message.assistant>.message-body,.message-stack>.message-body{background:#19191c}
.message-stack{display:flex;flex-direction:column;align-items:flex-start;max-width:76%;min-width:0}
.message-stack>.message-body{max-width:100%}
.copy-msg{display:inline-flex;align-items:center;gap:6px;height:28px;margin-top:6px;padding:0 8px 0 6px;border:0;border-radius:8px;background:transparent;color:var(--muted);font:inherit;font-size:12px;cursor:pointer}
.copy-msg svg{flex:0 0 14px}
.copy-msg:hover{background:rgba(255,255,255,.07);color:var(--ink)}
.copy-msg.copied,.copy-msg.copied:hover{color:var(--accent);background:transparent;cursor:default}
.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,.message.with-files .message-stack{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,.message.assistant.spoken>.message-stack{grid-column:2;max-width:82%;min-width:0}
.message.assistant.spoken .message-stack>.message-body{max-width:100%;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

@ -13,6 +13,7 @@ export const zhTW = {
openOnPhone: "在手機開啟", settings: "設定", about: "關於", helpCenter: "說明中心", sendFeedback: "傳送意見回饋", logout: "登出", workspaceMenu: "工作區選單", openOnPhone: "在手機開啟", settings: "設定", about: "關於", helpCenter: "說明中心", sendFeedback: "傳送意見回饋", logout: "登出", workspaceMenu: "工作區選單",
chooseBot: "選擇一個機器人", startRoomDiscussion: "和 {name} 開始討論", startBotWork: "和 {name} 開始工作", roomWillReply: "{names} 會一起回覆。", chooseBot: "選擇一個機器人", startRoomDiscussion: "和 {name} 開始討論", startBotWork: "和 {name} 開始工作", roomWillReply: "{names} 會一起回覆。",
botWelcome: "傳送訊息,讓它在自己的電腦上完成任務。", remembered: "已記住", remember: "記住", working: "{name} 正在工作…", botWelcome: "傳送訊息,讓它在自己的電腦上完成任務。", remembered: "已記住", remember: "記住", working: "{name} 正在工作…",
copyCode: "複製程式碼", copiedCode: "已複製", copyMessage: "複製", copiedMessage: "已複製",
anotherConversationQueued: "另一則對話正在執行,這則會排隊。", anotherConversationQueued: "另一則對話正在執行,這則會排隊。",
workingStep: "{name} 正在工作… {step}", queuedMessages: "還有 {count} 則訊息排隊中", workingStep: "{name} 正在工作… {step}", queuedMessages: "還有 {count} 則訊息排隊中",
pausedUserControl: "你正在操控畫面,{name} 已暫停。完成後按「釋放控制」,它會從目前畫面接著做(排隊中的訊息也會一起處理)。", pausedUserControl: "你正在操控畫面,{name} 已暫停。完成後按「釋放控制」,它會從目前畫面接著做(排隊中的訊息也會一起處理)。",
@ -63,6 +64,7 @@ export const zhTW = {
mcpConnectNamed: "接入 {name}", mcpKeyHint: "這個 MCP 需要憑證才能連。", mcpConnectNamed: "接入 {name}", mcpKeyHint: "這個 MCP 需要憑證才能連。",
noMcp: "還沒有 MCP。點上面的「選擇 MCP」從市集接入。", toolsCount: "{count} 個工具", disabled: "已關閉", disconnected: "未連線", noTools: "沒有可用工具", reconnect: "重新連線", disable: "停用", enable: "啟用", noMcp: "還沒有 MCP。點上面的「選擇 MCP」從市集接入。", toolsCount: "{count} 個工具", disabled: "已關閉", disconnected: "未連線", noTools: "沒有可用工具", reconnect: "重新連線", disable: "停用", enable: "啟用",
preparingDesktop: "正在準備 Agent 的獨立桌面…", computerPreviewHint: "開啟電腦後,畫面會顯示在這裡。", bootingProgress: "啟動中…", restartDocker: "重啟 Docker", stopAndTakeOver: "停止並接管", preparingDesktop: "正在準備 Agent 的獨立桌面…", computerPreviewHint: "開啟電腦後,畫面會顯示在這裡。", bootingProgress: "啟動中…", restartDocker: "重啟 Docker", stopAndTakeOver: "停止並接管",
hudBooting: "電腦啟動中…", hudWaking: "喚醒中…", hudConnecting: "連線中…", hudHandoff: "換手中…",
pasteToRemoteComputer: "貼到遠端電腦", pasteRemoteHelp: "把外面的文字貼在這裡,再送進 VNC。這個方式在區網 HTTP 也能使用。", pasteTextPlaceholder: "在此貼上文字…", pasteIntoVnc: "貼入 VNC", pasteToRemoteComputer: "貼到遠端電腦", pasteRemoteHelp: "把外面的文字貼在這裡,再送進 VNC。這個方式在區網 HTTP 也能使用。", pasteTextPlaceholder: "在此貼上文字…", pasteIntoVnc: "貼入 VNC",
botNamePlaceholder: "例如:研究助理", sharedComputerHint: "與其他機器人共用環境", privateComputerHint: "全新的獨立 Docker", create: "建立", botNamePlaceholder: "例如:研究助理", sharedComputerHint: "與其他機器人共用環境", privateComputerHint: "全新的獨立 Docker", create: "建立",
groupDescription: "拉進群組會開一個對話,選中的 Agent 都會在裡面發言。", groupName: "群組名稱", groupNamePlaceholder: "例如:產品研究", chooseBots: "選擇機器人(至少兩位)", creating: "建立中…", createGroup: "建立群組", groupDescription: "拉進群組會開一個對話,選中的 Agent 都會在裡面發言。", groupName: "群組名稱", groupNamePlaceholder: "例如:產品研究", chooseBots: "選擇機器人(至少兩位)", creating: "建立中…", createGroup: "建立群組",
@ -96,4 +98,19 @@ export const zhTW = {
agentComputer: "Agent 電腦", url: "URL", stdio: "stdio", http: "HTTP", sse: "SSE", agentComputer: "Agent 電腦", url: "URL", stdio: "stdio", http: "HTTP", sse: "SSE",
argumentsPlaceholder: "-y @modelcontextprotocol/server-github", environmentVariablesPlaceholder: "GITHUB_TOKEN=…", argumentsPlaceholder: "-y @modelcontextprotocol/server-github", environmentVariablesPlaceholder: "GITHUB_TOKEN=…",
urlPlaceholder: "https://mcp.example.com/mcp", headersPlaceholder: "Authorization=Bearer …", urlPlaceholder: "https://mcp.example.com/mcp", headersPlaceholder: "Authorization=Bearer …",
back: "返回", accounts: "帳號", accountsHelp: "存在這台機器人自己的保險箱。密碼只在這裡填,不會進聊天、也不會給模型看。排程碰到登入牆時會自動填表。",
accountSite: "站名", accountSitePlaceholder: "例如Gmail", accountHost: "網址或網域", accountHostPlaceholder: "mail.google.com",
accountUsername: "帳號", accountPassword: "密碼", accountPasswordKeep: "留空表示不改密碼",
accountNotes: "備註", accountNotesPlaceholder: "選填,例如:公司信箱",
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; } as const;

View File

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

56
apps/web/src/markdown.css Normal file
View File

@ -0,0 +1,56 @@
.md-body{min-width:0;overflow-wrap:anywhere}
.md-body>:first-child,.md-body>:first-child>:first-child{margin-top:0}
.md-body>:last-child,.md-body>:last-child>:last-child{margin-bottom:0}
.md-body p,.md-body blockquote,.md-body ul,.md-body ol,.md-body table{margin:.65em 0}
.md-body h1,.md-body h2,.md-body h3,.md-body h4,.md-body h5,.md-body h6{
margin:.85em 0 .3em;color:var(--ink);font-weight:400;line-height:1.3;letter-spacing:.01em
}
.md-body h1{font-size:1.22em}
.md-body h2{font-size:1.12em}
.md-body h3,.md-body h4,.md-body h5,.md-body h6{font-size:1.04em}
.md-body ul,.md-body ol{padding-left:1.4em}
.md-body ul{list-style:disc}
.md-body ol{list-style:decimal}
.md-body li+li{margin-top:.28em}
.md-body li>p{margin:0}
.md-body .contains-task-list,.md-body .task-list-item{list-style:none}
.md-body .contains-task-list{padding-left:0}
.md-body a{
color:var(--accent);text-decoration:underline;
text-decoration-color:color-mix(in srgb,var(--accent) 55%,transparent);
text-underline-offset:.16em
}
.md-body a:hover{color:#7ee0c8}
.md-body code{
border:1px solid var(--border);border-radius:.35em;background:#101012;
padding:.08em .32em;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
font-size:.88em;direction:ltr;unicode-bidi:isolate
}
.md-body pre{
max-width:100%;overflow-x:auto;margin:0;border:1px solid var(--border);
border-radius:12px;background:#0c0c0d;padding:.75em .9em;direction:ltr;unicode-bidi:isolate
}
.md-body pre code{border:0;background:transparent;padding:0;font-size:.84em;white-space:pre}
.md-pre-wrap{position:relative;margin:.65em 0}
.md-copy{
position:absolute;top:.45em;right:.45em;display:grid;place-items:center;
width:28px;height:28px;border:1px solid var(--border);border-radius:8px;
background:#17171a;color:var(--muted);opacity:0;cursor:pointer
}
.md-pre-wrap:hover .md-copy,.md-copy:focus-visible{opacity:1}
.md-copy:hover{color:var(--ink);background:#202024}
.md-body blockquote{
margin:.65em 0;border-left:3px solid var(--accent);padding-left:.85em;color:var(--muted)
}
.md-body table{display:block;max-width:100%;overflow-x:auto;border-collapse:collapse;font-size:.92em}
.md-body th,.md-body td{
border:1px solid var(--border);padding:.4em .6em;text-align:left;overflow-wrap:break-word
}
.md-body th{background:#1a1a1d;color:var(--ink);font-weight:400}
.md-body hr{margin:.9em 0;border:0;border-top:1px solid var(--border)}
.md-body img{display:block;max-width:100%;height:auto;margin:.65em 0;border-radius:12px}
.md-body input[type="checkbox"]{margin-right:.4em;accent-color:var(--accent)}
.message-body.md{white-space:normal}
.user .md-body code,.user .md-body pre,.user .md-body th{background:#e8e8e6;border-color:#d4d4d2;color:#1a1a1a}
.user .md-body a{color:#0f766e}
.user .md-copy{background:#ececea;color:#555}

110
apps/web/src/markdown.tsx Normal file
View File

@ -0,0 +1,110 @@
import { memo, useCallback, useRef, useState, type ComponentPropsWithoutRef } from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import { t } from "./i18n";
import "./markdown.css";
const protocolPattern = /^([a-z][a-z\d+.-]*):/i;
const safeProtocols = new Set(["http", "https", "mailto", "tel"]);
export function sanitizeMarkdownUrl(url: string): string | undefined {
const value = url.trim();
const protocol = value.match(protocolPattern)?.[1]?.toLowerCase();
if (protocol) return safeProtocols.has(protocol) ? value : undefined;
if (value.startsWith("#") && !value.toLowerCase().startsWith("#javascript")) return value;
return undefined;
}
function CopyIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<rect x="9" y="9" width="12" height="12" rx="2" stroke="currentColor" strokeWidth="2" strokeLinejoin="round"/>
<path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
}
function CheckIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M4 12.5 9.5 18 20 6" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
}
function useCopiedFlag() {
const [copied, setCopied] = useState(false);
const timerRef = useRef<number>(undefined);
const markCopied = useCallback(() => {
setCopied(true);
window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => setCopied(false), 1500);
}, []);
return {copied, markCopied};
}
function copyText(text: string): Promise<boolean> {
if (!navigator.clipboard) return Promise.resolve(false);
return navigator.clipboard.writeText(text).then(() => true).catch(() => false);
}
function CodeBlock(props: ComponentPropsWithoutRef<"pre">) {
const preRef = useRef<HTMLPreElement>(null);
const {copied, markCopied} = useCopiedFlag();
const handleCopy = useCallback(() => {
void copyText(preRef.current?.textContent ?? "").then((ok) => { if (ok) markCopied(); });
}, [markCopied]);
return (
<div className="md-pre-wrap">
<pre {...props} ref={preRef}/>
<button type="button" className="md-copy" onClick={handleCopy} aria-label={copied ? t("copiedCode") : t("copyCode")}>
{copied ? <CheckIcon/> : <CopyIcon/>}
</button>
</div>
);
}
const components: Components = {
a({node: _node, ...props}) {
return <a {...props} target="_blank" rel="noreferrer noopener"/>;
},
img({node: _node, ...props}) {
return <img {...props} alt={props.alt ?? ""} loading="lazy"/>;
},
pre({node: _node, ...props}) {
return <CodeBlock {...props}/>;
},
};
export function CopyMessageButton({text}:{text:string}) {
const {copied, markCopied} = useCopiedFlag();
if (!text.trim()) return null;
return (
<button
type="button"
className={`copy-msg ${copied ? "copied" : ""}`}
title={copied ? t("copiedMessage") : t("copyMessage")}
aria-label={copied ? t("copiedMessage") : t("copyMessage")}
onClick={() => { void copyText(text).then((ok) => { if (ok) markCopied(); }); }}
>
{copied ? <CheckIcon/> : <CopyIcon/>}
<span>{copied ? t("copiedMessage") : t("copyMessage")}</span>
</button>
);
}
export const ChatMarkdown = memo(function ChatMarkdown({children}:{children:string}) {
return (
<div className="md-body">
<ReactMarkdown
components={components}
remarkPlugins={[remarkGfm, remarkBreaks]}
skipHtml
urlTransform={(url) => sanitizeMarkdownUrl(url) ?? ""}
>
{children}
</ReactMarkdown>
</div>
);
});

File diff suppressed because it is too large Load Diff

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,.message>.message-body,.message-stack{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 MessageFile { kind:"image"|"file"; name:string; mimeType?:string; size?:number }
export interface RoomMember { id:string; name:string; avatarColor:string; avatarShape:AvatarShape } 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 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 PlaybookStep { do:string; expect?:string; note?:string }
export interface PlaybookInput { name:string; description?:string; example?: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[] } 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; overflow: hidden;
} }
#status { #status {
position: absolute; display: none;
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;
} }
#screen canvas { cursor: default; } #screen canvas { cursor: default; }
</style> </style>
@ -60,17 +51,7 @@
function pasteIntoDesktop(text) { function pasteIntoDesktop(text) {
if (!rfb || rfb.viewOnly || !text) return; if (!rfb || rfb.viewOnly || !text) return;
try { rfb.clipboardPasteFrom(text); } catch (_) {} window.parent.postMessage({type:"lazyboy-paste-text", text}, parentOrigin);
// 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);
} }
function pinTaskbar() { function pinTaskbar() {
@ -87,23 +68,27 @@
} }
} }
function applyViewOnly(value) {
if (!rfb) return;
rfb.viewOnly = Boolean(value);
}
function connect() { function connect() {
setStatus("Connecting to desktop…"); setStatus("Connecting to desktop…");
statusEl.style.display = "block"; window.parent.postMessage({ type: "lazyboy-desktop-lost" }, parentOrigin);
rfb = new RFB(document.getElementById("screen"), url); rfb = new RFB(document.getElementById("screen"), url);
rfb.viewOnly = flag("view_only", false); rfb.viewOnly = flag("view_only", true);
rfb.scaleViewport = true; rfb.scaleViewport = true;
rfb.clipViewport = false; rfb.clipViewport = false;
rfb.background = "#0f172a"; rfb.background = "#0f172a";
pinTaskbar(); pinTaskbar();
rfb.addEventListener("connect", () => { rfb.addEventListener("connect", () => {
pinTaskbar(); pinTaskbar();
setStatus(rfb.viewOnly ? "View only — click to take control" : "Click the desktop");
try { rfb.focus(); } catch (_) {} try { rfb.focus(); } catch (_) {}
setTimeout(() => { statusEl.style.display = "none"; }, 2500); window.parent.postMessage({ type: "lazyboy-desktop-ready" }, parentOrigin);
}); });
rfb.addEventListener("disconnect", (event) => { rfb.addEventListener("disconnect", (event) => {
statusEl.style.display = "block"; window.parent.postMessage({ type: "lazyboy-desktop-lost" }, parentOrigin);
const clean = event && event.detail && event.detail.clean; const clean = event && event.detail && event.detail.clean;
setStatus(clean ? "Disconnected — retrying" : "Desktop connection lost — retrying"); setStatus(clean ? "Disconnected — retrying" : "Desktop connection lost — retrying");
if (reconnectTimer) clearTimeout(reconnectTimer); 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(); connect();
window.addEventListener("resize", pinTaskbar); window.addEventListener("resize", pinTaskbar);
window.addEventListener("pointerdown", () => { window.addEventListener("pointerdown", () => {
@ -133,14 +136,19 @@
pasteIntoDesktop(text); pasteIntoDesktop(text);
}); });
window.addEventListener("message", (event) => { window.addEventListener("message", (event) => {
if (event.origin !== parentOrigin) return; if (event.origin !== parentOrigin || event.source !== window.parent) return;
if (!event.data || event.data.type !== "lazyboy-host-clipboard") 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 || "")); pasteIntoDesktop(String(event.data.text || ""));
}); });
</script> </script>
</head> </head>
<body> <body>
<div id="status">Loading desktop…</div> <div id="status" hidden>Loading desktop…</div>
<div id="screen"></div> <div id="screen"></div>
</body> </body>
</html> </html>

View File

@ -35,3 +35,9 @@ dotenvy = "0.15"
fastembed = "6.0.2" fastembed = "6.0.2"
rmcp = { version = "3.2", default-features = false, features = ["client", "transport-child-process", "transport-streamable-http-client-reqwest"] } rmcp = { version = "3.2", default-features = false, features = ["client", "transport-child-process", "transport-streamable-http-client-reqwest"] }
http = "1" 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`]. //! mounted home and delete anything older than [`INBOX_TTL`].
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime}; use std::time::Duration;
use base64::Engine; use base64::Engine;
use lazyboy_contracts::SessionAttachment; use lazyboy_contracts::SessionAttachment;
@ -13,7 +13,6 @@ use lazyboy_control::resolve_bot_workspace_path;
use rig_core::completion::message::{ImageDetail, ImageMediaType, UserContent}; use rig_core::completion::message::{ImageDetail, ImageMediaType, UserContent};
use serde::Serialize; use serde::Serialize;
use serde_json::{Value, json}; use serde_json::{Value, json};
use tokio::fs;
use crate::computer; use crate::computer;
use crate::db::{Actor, parse_mode}; use crate::db::{Actor, parse_mode};
@ -41,6 +40,7 @@ pub struct StoredAttachment {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct DecodedAttachment { pub struct DecodedAttachment {
pub name: String, pub name: String,
pub stored_name: String,
pub mime_type: String, pub mime_type: String,
pub bytes: Vec<u8>, pub bytes: Vec<u8>,
} }
@ -68,8 +68,11 @@ pub fn decode_incoming(items: &[IncomingAttachment]) -> Result<Vec<DecodedAttach
if total > MAX_TOTAL_BYTES { if total > MAX_TOTAL_BYTES {
return Err("附件合計超過 20 MB".into()); return Err("附件合計超過 20 MB".into());
} }
let (stem, ext) = split_ext(&name);
let stored_name = format!("{stem}-{}{ext}", uuid::Uuid::new_v4());
out.push(DecodedAttachment { out.push(DecodedAttachment {
name, name,
stored_name,
mime_type: mime, mime_type: mime,
bytes, bytes,
}); });
@ -86,7 +89,7 @@ pub fn stored_blocks(files: &[DecodedAttachment]) -> Vec<Value> {
"name": file.name, "name": file.name,
"mimeType": file.mime_type, "mimeType": file.mime_type,
"size": file.bytes.len(), "size": file.bytes.len(),
"path": format!("inbox/{}", file.name), "path": format!("inbox/{}", file.stored_name),
}) })
}) })
.collect() .collect()
@ -117,15 +120,30 @@ pub async fn stage_for_bots(
let Some(dir) = inbox_dir_for_bot(state, actor, bot_id).await? else { let Some(dir) = inbox_dir_for_bot(state, actor, bot_id).await? else {
continue; continue;
}; };
fs::create_dir_all(&dir) let root = PathBuf::from(&state.data_dir).join("homes");
.await let relative = dir
.map_err(|error| error.to_string())?; .strip_prefix(&root)
for file in files { .map_err(|_| "invalid inbox root")?
let path = unique_path(&dir, &file.name).await; .to_path_buf();
fs::write(&path, &file.bytes) let files = files.to_vec();
.await tokio::task::spawn_blocking(move || -> Result<(), String> {
.map_err(|error| error.to_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(()) Ok(())
} }
@ -151,8 +169,8 @@ pub async fn llm_parts(
} }
for (stored, bytes) in files { for (stored, bytes) in files {
notes.push(format!( notes.push(format!(
"- {} ({}, {} bytes) at inbox/{}", "- {} ({}, {} bytes) at {}",
stored.name, stored.mime_type, stored.size, stored.name stored.name, stored.mime_type, stored.size, stored.path
)); ));
if is_image(&stored.mime_type) { if is_image(&stored.mime_type) {
if vision { if vision {
@ -189,21 +207,52 @@ pub async fn llm_parts(
pub async fn sweep_all_inboxes(data_dir: &str) { pub async fn sweep_all_inboxes(data_dir: &str) {
let homes = PathBuf::from(data_dir).join("homes"); 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; return;
}; };
while let Ok(Some(entry)) = spaces.next_entry().await { let Ok(entries) = dir.entries() else {
let path = entry.path(); return;
if !path.is_dir() { };
continue; for entry in entries.flatten() {
} let Ok(meta) = entry.metadata() else {
sweep_dir(&path.join("inbox")).await;
let bots = path.join("bots");
let Ok(mut bots_dir) = fs::read_dir(&bots).await else {
continue; continue;
}; };
while let Ok(Some(bot)) = bots_dir.next_entry().await { if meta.is_file()
sweep_dir(&bot.path().join("inbox")).await; && 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 { let Some(file) = parse_stored(block) else {
continue; continue;
}; };
let path = dir.join(&file.name); let root = PathBuf::from(&state.data_dir).join("homes");
let bytes = fs::read(&path).await.ok(); 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.push((file, bytes));
} }
out out
@ -269,6 +341,23 @@ fn parse_stored(value: &Value) -> Option<StoredAttachment> {
return None; return None;
} }
let name = value.get("name").and_then(Value::as_str)?; 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 { Some(StoredAttachment {
kind: if kind == "image" { "image" } else { "file" }, kind: if kind == "image" { "image" } else { "file" },
name: name.to_string(), 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) { fn split_ext(name: &str) -> (String, String) {
match name.rfind('.') { match name.rfind('.') {
Some(index) if index > 0 => (name[..index].to_string(), name[index..].to_string()), Some(index) if index > 0 => (name[..index].to_string(), name[index..].to_string()),
@ -459,6 +513,7 @@ mod tests {
fn stored_blocks_drop_bytes() { fn stored_blocks_drop_bytes() {
let files = [DecodedAttachment { let files = [DecodedAttachment {
name: "a.png".into(), name: "a.png".into(),
stored_name: "a.png".into(),
mime_type: "image/png".into(), mime_type: "image/png".into(),
bytes: vec![1, 2, 3], bytes: vec![1, 2, 3],
}]; }];
@ -479,3 +534,30 @@ mod tests {
assert!(decode_incoming(&many).is_err()); 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::Deserialize;
use serde_json::json; use serde_json::json;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::{
collections::HashMap,
sync::{Arc, Mutex},
time::{Duration, Instant},
};
use crate::state::AppState; use crate::state::AppState;
@ -14,7 +19,7 @@ const COOKIE_NAME: &str = "lazyboy_session";
#[derive(Clone)] #[derive(Clone)]
pub struct AuthConfig { pub struct AuthConfig {
token: Option<String>, token: Option<String>,
session_value: Option<String>, sessions: Arc<Mutex<HashMap<String, Instant>>>,
secure_cookie: bool, secure_cookie: bool,
} }
@ -23,18 +28,12 @@ impl AuthConfig {
let token = std::env::var("LAZYBOY_APP_TOKEN") let token = std::env::var("LAZYBOY_APP_TOKEN")
.ok() .ok()
.filter(|value| !value.trim().is_empty()); .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") let secure_cookie = std::env::var("LAZYBOY_SECURE_COOKIE")
.map(|value| matches!(value.as_str(), "1" | "true" | "yes")) .map(|value| matches!(value.as_str(), "1" | "true" | "yes"))
.unwrap_or(false); .unwrap_or(false);
Self { Self {
token, token,
session_value, sessions: Arc::new(Mutex::new(HashMap::new())),
secure_cookie, secure_cookie,
} }
} }
@ -58,21 +57,55 @@ impl AuthConfig {
} }
fn valid_session(&self, headers: &HeaderMap) -> bool { fn valid_session(&self, headers: &HeaderMap) -> bool {
let Some(expected) = &self.session_value else { if !self.enabled() {
return true; return true;
}
let Some(value) = cookie_value(headers, COOKIE_NAME) else {
return false;
}; };
cookie_value(headers, COOKIE_NAME) let key = hex::encode(Sha256::digest(value.as_bytes()));
.map(|supplied| constant_time_eq(expected.as_bytes(), supplied.as_bytes())) self.sessions
.unwrap_or(false) .lock()
.unwrap()
.get(&key)
.is_some_and(|expires| *expires > Instant::now())
} }
fn session_cookie(&self) -> Option<String> { fn session_cookie(&self) -> Option<String> {
self.session_value.as_ref().map(|value| { if !self.enabled() {
format!( return None;
"{COOKIE_NAME}={value}; Path=/; HttpOnly; SameSite=Strict; Max-Age=604800{}", }
if self.secure_cookie { "; Secure" } else { "" } 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) 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(); let mut response = Json(json!({"ok": true})).into_response();
response.headers_mut().insert( response.headers_mut().insert(
header::SET_COOKIE, header::SET_COOKIE,
@ -175,3 +209,126 @@ mod tests {
assert!(!constant_time_eq(b"correct", b"correct-longer")); 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; use crate::state::AppState;
pub const STEP_BOOTING: &str = "電腦啟動中";
pub const STEP_WAKING: &str = "喚醒中";
pub const STEP_HANDOFF: &str = "換手中";
pub fn status_from( pub fn status_from(
bot_id: &str, bot_id: &str,
computer: &ComputerRow, computer: &ComputerRow,
@ -55,6 +59,7 @@ pub fn status_from(
busy_session_id: None, busy_session_id: None,
busy_run_id: None, busy_run_id: None,
busy_step: None, busy_step: None,
using_computer: false,
waiting_run_id: None, waiting_run_id: None,
waiting_session_id: None, waiting_session_id: None,
queued_runs: 0, queued_runs: 0,
@ -320,6 +325,7 @@ async fn probe_computer_container(
], ],
cwd: None, cwd: None,
timeout_ms: Some(5_000), timeout_ms: Some(5_000),
stdin: None,
}, },
&adapter_context(actor, bot_id, "probe"), &adapter_context(actor, bot_id, "probe"),
) )
@ -500,6 +506,21 @@ pub async fn boot(state: &AppState, actor: &Actor, bot_id: &str) -> Result<Compu
.await .await
.map_err(|error| error.to_string())?; .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( let claimed = sqlx::query(
"UPDATE computers SET state = 'booting', updated_at = now() "UPDATE computers SET state = 'booting', updated_at = now()
WHERE id = $1 AND state IN ('stopped','suspended','error')", 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], argv: vec!["mkdir".into(), "-p".into(), "shared".into(), folder],
cwd: None, cwd: None,
timeout_ms: Some(10_000), timeout_ms: Some(10_000),
stdin: None,
}, },
&ctx, &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)) 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>) { async fn mark_boot_error(state: &AppState, computer_id: &str, provider_ref: Option<&str>) {
if let Err(error) = sqlx::query( if let Err(error) = sqlx::query(
"UPDATE computers SET state = 'error', provider_ref = COALESCE($2, provider_ref), updated_at = now() "UPDATE computers SET state = 'error', provider_ref = COALESCE($2, provider_ref), updated_at = now()
@ -813,6 +900,7 @@ pub async fn takeover(
.await .await
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
for (run_id, thread_id) in paused { for (run_id, thread_id) in paused {
crate::runs::set_run_step(state, &run_id, STEP_HANDOFF).await;
let _ = crate::runs::append_bot_message( let _ = crate::runs::append_bot_message(
state, state,
&thread_id, &thread_id,
@ -878,6 +966,11 @@ pub async fn heartbeat(state: &AppState, actor: &Actor, bot_id: &str) -> Result<
return Ok(()); return Ok(());
}; };
let expires = Utc::now() + TimeDelta::minutes(15); 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( sqlx::query(
"UPDATE computer_screens SET control_lease_expires_at = $2, updated_at = now() "UPDATE computer_screens SET control_lease_expires_at = $2, updated_at = now()
WHERE computer_id = $1 AND bot_id = $3 AND control_holder = 'user'", WHERE computer_id = $1 AND bot_id = $3 AND control_holder = 'user'",
@ -933,61 +1026,102 @@ pub async fn idle_loop(state: AppState) {
loop { loop {
tokio::time::sleep(Duration::from_secs(60)).await; tokio::time::sleep(Duration::from_secs(60)).await;
crate::attachments::sweep_all_inboxes(&state.data_dir).await; crate::attachments::sweep_all_inboxes(&state.data_dir).await;
let cutoff = Utc::now() - TimeDelta::minutes(10); pause_idle_computers(&state).await;
let rows = sqlx::query_as::<_, ComputerRow>( stop_parked_computers(&state).await;
"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 async fn computer_has_active_work(state: &AppState, computer_id: &str) -> bool {
FROM computers WHERE state = 'running' AND updated_at < $1", let active: Result<Option<(i64,)>, _> = sqlx::query_as(
) "SELECT 1 FROM runs WHERE status IN ('queued','leased','running','waiting_input','waiting_takeover')
.bind(cutoff) AND bot_id IN (SELECT id FROM bots WHERE computer_id = $1)
.fetch_all(state.pool()) UNION ALL
.await; SELECT 1 FROM taught_skills WHERE status IN ('recording','drafting')
let Ok(rows) = rows else { continue }; AND bot_id IN (SELECT id FROM bots WHERE computer_id = $1)
for computer in rows { LIMIT 1",
let active: Result<Option<(i64,)>, _> = sqlx::query_as( )
"SELECT 1 FROM runs WHERE status IN ('queued','leased','running','waiting_input','waiting_takeover') .bind(computer_id)
AND bot_id IN (SELECT id FROM bots WHERE computer_id = $1) .fetch_optional(state.pool())
UNION ALL .await;
SELECT 1 FROM taught_skills WHERE status IN ('recording','drafting') matches!(active, Ok(Some(_)))
AND bot_id IN (SELECT id FROM bots WHERE computer_id = $1) }
LIMIT 1",
) fn idle_adapter(computer: &ComputerRow, operation: &str) -> AdapterContext {
.bind(&computer.id) AdapterContext {
.fetch_optional(state.pool()) operation_id: operation.into(),
.await; space_id: computer.space_id.clone(),
if matches!(active, Ok(Some(_))) { 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; 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_run_id = Some(run.id.clone());
status.busy_session_id = Some(run.thread_id.clone()); status.busy_session_id = Some(run.thread_id.clone());
status.busy_step = run.step.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 { if let Some(run) = waiting {
status.waiting_run_id = Some(run.id.clone()); status.waiting_run_id = Some(run.id.clone());

View File

@ -8,11 +8,13 @@ mod memory;
mod rooms; mod rooms;
mod routes; mod routes;
mod runs; mod runs;
mod schedules;
mod screen_proxy; mod screen_proxy;
mod sessions; mod sessions;
mod skills; mod skills;
mod state; mod state;
mod tools; mod tools;
mod vault;
mod workspace; mod workspace;
use std::net::SocketAddr; use std::net::SocketAddr;
@ -57,6 +59,10 @@ async fn main() {
tokio::spawn(async move { tokio::spawn(async move {
computer::idle_loop(idle_state).await; 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 bind = std::env::var("API_BIND").unwrap_or_else(|_| "127.0.0.1:3101".into());
let addr: SocketAddr = bind.parse().expect("API_BIND"); 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() let app = Router::new()
.route( .route(
"/api/health", "/api/health",
axum::routing::get(|| async { axum::Json(serde_json::json!({"ok": true})) }), axum::routing::get(|| async { axum::Json(serde_json::json!({"ok": true})) }),
) )
.merge(auth::public_router(state.clone())) .merge(auth::public_router(state.clone()))
.merge(routes::router(state)) .merge(routes::router(state.clone()))
.fallback_service(ServeDir::new(web_dir)) .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}"); tracing::info!("api listening on {addr}");
let listener = tokio::net::TcpListener::bind(addr).await.expect("bind"); 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()) .filter(|value| !value.is_empty())
.ok_or_else(|| "stdio 需要 command".to_string())?; .ok_or_else(|| "stdio 需要 command".to_string())?;
let mut cmd = Command::new(command); 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.args(&row.args);
cmd.stdin(std::process::Stdio::piped()); cmd.stdin(std::process::Stdio::piped());
cmd.stdout(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::mcp::router())
.merge(crate::workspace::router()) .merge(crate::workspace::router())
.merge(crate::skills::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", get(list_bots).post(create_bot))
.route( .route(
"/api/bots/{id}", "/api/bots/{id}",
@ -649,7 +651,7 @@ async fn screen_url(
.await .await
.map_err(|_| StatusCode::BAD_GATEWAY)?; .map_err(|_| StatusCode::BAD_GATEWAY)?;
Ok(Json(json!({ 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); return Err(StatusCode::CONFLICT);
} }
let computer_ref = computer::computer_ref(&computer).ok_or(StatusCode::BAD_REQUEST)?; 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() { let action = match body.kind.as_str() {
"key" => lazyboy_contracts::ComputerAction::Key { "key" => lazyboy_contracts::ComputerAction::Key {
key: body.key.unwrap_or_default(), 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()) .and_then(|value| value.to_str().ok())
.map(|value| value.eq_ignore_ascii_case("websocket")) .map(|value| value.eq_ignore_ascii_case("websocket"))
.unwrap_or(false); .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 ensure = upgrade || is_viewer_page(&rest) || rest.contains("websockify");
let port = match upstream_port(&state, &bot_id, ensure).await { let port = match upstream_port(&state, &bot_id, ensure).await {
Ok(port) => port, 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) { if is_viewer_page(&rest) {
return viewer_page().await; return viewer_page().await;
} }
http_proxy(port, &rest, req).await StatusCode::NOT_FOUND.into_response()
} }
fn is_viewer_page(rest: &str) -> bool { fn is_viewer_page(rest: &str) -> bool {
@ -71,7 +83,6 @@ async fn viewer_page() -> Response {
"text/html; charset=utf-8".parse().unwrap(), "text/html; charset=utf-8".parse().unwrap(),
); );
headers.insert(header::CACHE_CONTROL, "no-store".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() (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) url.replace("127.0.0.1", &host).replace("localhost", &host)
} }
async fn http_proxy(port: u16, rest: &str, req: Request) -> Response { fn safe_asset(rest: &str) -> bool {
let host = std::env::var("LAZYBOY_SCREEN_UPSTREAM").unwrap_or_else(|_| "127.0.0.1".into()); (rest.starts_with("core/") || rest.starts_with("vendor/"))
let path = if rest.is_empty() { && rest.ends_with(".js")
"vnc_lite.html" && rest
} else { .split('/')
rest .all(|p| !p.is_empty() && p != "." && p != "..")
}; && rest
let query = req.uri().query().unwrap_or_default(); .bytes()
let url = if query.is_empty() { .all(|b| b.is_ascii_alphanumeric() || b"/._-".contains(&b))
format!("http://{host}:{port}/{path}") }
} else {
format!("http://{host}:{port}/{path}?{query}") async fn trusted_asset(rest: &str) -> Response {
}; if !safe_asset(rest) {
let client = reqwest::Client::new(); return StatusCode::NOT_FOUND.into_response();
let method = reqwest::Method::from_bytes(req.method().as_str().as_bytes()) }
.unwrap_or(reqwest::Method::GET); let root = std::env::var("LAZYBOY_NOVNC_DIR").unwrap_or_else(|_| {
match client.request(method, url).send().await { let web = std::env::var("LAZYBOY_WEB_DIR").unwrap_or_else(|_| "apps/web".into());
Ok(upstream) => { if std::path::Path::new(&web).join("novnc").is_dir() {
let status = format!("{web}/novnc")
StatusCode::from_u16(upstream.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); } else {
let content_type = upstream "apps/web/node_modules/@novnc/novnc".into()
.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(),
}
} }
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, argv,
cwd: None, cwd: None,
timeout_ms: Some(10_000), timeout_ms: Some(10_000),
stdin: None,
}, },
&ctx, &ctx,
) )
@ -902,6 +903,7 @@ async fn stop_recorder(
argv: cdp_record_stop_command(skill_id), argv: cdp_record_stop_command(skill_id),
cwd: None, cwd: None,
timeout_ms: Some(5_000), timeout_ms: Some(5_000),
stdin: None,
}, },
ctx, ctx,
) )
@ -928,6 +930,7 @@ async fn collect_browser_events(
], ],
cwd: None, cwd: None,
timeout_ms: Some(10_000), timeout_ms: Some(10_000),
stdin: None,
}, },
ctx, 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, 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. /// Canonical actions the control plane understands.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")] #[serde(tag = "kind", rename_all = "lowercase")]
@ -36,6 +44,15 @@ pub enum ComputerAction {
#[serde(default)] #[serde(default)]
button: Option<PointerButton>, 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 { Clipboard {
text: String, text: String,
}, },
@ -86,15 +103,24 @@ pub struct UiElement {
pub y: u32, pub y: u32,
pub w: u32, pub w: u32,
pub h: 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")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub selector: Option<String>, 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")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<String>, pub kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
} }
impl UiElement { 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) { pub fn center(&self) -> (u32, u32) {
( (
self.x.saturating_add(self.w / 2), 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"…). /// What the active run is doing right now ("思考中", "browser: click #12"…).
#[serde(default)] #[serde(default)]
pub busy_step: Option<String>, 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). /// Run paused in `waiting_takeover` (bot asked for, or user forced, control).
#[serde(default)] #[serde(default)]
pub waiting_run_id: Option<String>, 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 serde_json::{Value, json};
use thiserror::Error; 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> { pub fn apply_element_targets(value: &mut Value, elements: &[UiElement]) -> Result<(), ActionError> {
let Some(items) = value.as_array_mut() else { let Some(items) = value.as_array_mut() else {
return Ok(()); 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 { let Some(element) = elements.iter().find(|element| u64::from(element.id) == id) else {
return Err(ActionError::UnknownElement(id as u32)); 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() { if element.is_offscreen() {
return Err(ActionError::OffscreenElement(id as u32)); return Err(ActionError::OffscreenElement(id as u32));
} }
@ -68,6 +87,97 @@ pub fn apply_element_targets(value: &mut Value, elements: &[UiElement]) -> Resul
Ok(()) 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> { pub fn parse_computer_actions(value: &Value) -> Result<Vec<ComputerAction>, ActionError> {
let Value::Array(items) = value else { let Value::Array(items) = value else {
return Err(ActionError::Empty); return Err(ActionError::Empty);
@ -90,6 +200,23 @@ pub fn parse_computer_actions(value: &Value) -> Result<Vec<ComputerAction>, Acti
.unwrap_or_default(); .unwrap_or_default();
match kind { match kind {
"click" | "move" | "down" | "up" => { "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 x = coordinate(action.get("x"), "x")?;
let y = coordinate(action.get("y"), "y")?; let y = coordinate(action.get("y"), "y")?;
let pointer_type = match kind { 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) .and_then(Value::as_str)
.unwrap_or("") .unwrap_or("")
.to_string(); .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" => { "key" => {
let key = action let key = action
@ -247,6 +383,24 @@ pub fn parse_computer_actions(value: &Value) -> Result<Vec<ComputerAction>, Acti
Ok(actions) 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> { fn coordinate(value: Option<&Value>, name: &'static str) -> Result<u32, ActionError> {
let number = value.and_then(Value::as_f64).unwrap_or(f64::NAN).round(); let number = value.and_then(Value::as_f64).unwrap_or(f64::NAN).round();
if !number.is_finite() || number < 0.0 || number > 100_000.0 { if !number.is_finite() || number < 0.0 || number > 100_000.0 {
@ -386,10 +540,145 @@ mod tests {
kind: Some("dom".into()), kind: Some("dom".into()),
..UiElement::default() ..UiElement::default()
}; };
let mut actions = json!([{"kind": "click", "element": 3}]); let mut actions = json!([{"kind": "hover", "element": 3}]);
assert_eq!( assert_eq!(
apply_element_targets(&mut actions, &[below_fold]).unwrap_err(), apply_element_targets(&mut actions, &[below_fold]).unwrap_err(),
ActionError::OffscreenElement(3) 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 return None
class Ws: class Ws:
"""Use a maintained RFC6455 transport (fragmentation, ping/pong, handshake)."""
def __init__(self, url): def __init__(self, url):
rest = url[5:] import websocket
hostpath = rest.split("/", 1) self.sock = websocket.create_connection(url, timeout=5, suppress_origin=True,
hostport = hostpath[0] http_no_proxy=["127.0.0.1", "localhost"])
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
self.n = 0 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): def recv_json(self):
while True: return json.loads(self.sock.recv())
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())
def call(self, method, params=None): def call(self, method, params=None):
self.n += 1 self.n += 1
msg = {"id": self.n, "method": method} self.sock.send(json.dumps({"id": self.n, "method": method, "params": params or {}}))
if params: deadline = time.monotonic() + 10
msg["params"] = params while time.monotonic() < deadline:
self.sock.sendall(self._frame(json.dumps(msg).encode())) self.sock.settimeout(max(.1, deadline-time.monotonic()))
while True:
obj = self.recv_json() obj = self.recv_json()
if obj.get("id") == self.n: if obj.get("id") == self.n:
if "error" in obj: if "error" in obj:
raise RuntimeError(str(obj["error"])) raise RuntimeError(str(obj["error"]))
return obj.get("result") or {} return obj.get("result") or {}
raise TimeoutError("CDP response deadline exceeded")
def close(self): def close(self):
try: self.sock.close()
self.sock.close()
except Exception:
pass
def probe(port): def probe(port):
return http_json("http://127.0.0.1:%s/json/version" % port) is not None 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: if profile:
env["LAZYBOY_BROWSER_PROFILE"] = profile env["LAZYBOY_BROWSER_PROFILE"] = profile
subprocess.Popen( subprocess.Popen(
["lazyboy-browser", "--remote-debugging-port=%s" % port, "--remote-allow-origins=*"], ["lazyboy-browser", "--remote-debugging-port=%s" % port],
env=env, env=env,
start_new_session=True, start_new_session=True,
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
@ -171,6 +101,8 @@ SNAP_JS = r"""
const chromeW = Math.max(0, (window.outerWidth || 0) - (window.innerWidth || 0)); const chromeW = Math.max(0, (window.outerWidth || 0) - (window.innerWidth || 0));
const sx0 = (window.screenX || 0) + Math.floor(chromeW / 2); const sx0 = (window.screenX || 0) + Math.floor(chromeW / 2);
const sy0 = (window.screenY || 0) + chromeH; 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 seen = new Set();
const inView = []; const inView = [];
const offView = []; const offView = [];
@ -181,7 +113,9 @@ SNAP_JS = r"""
if (st.visibility === "hidden" || st.display === "none" || Number(st.opacity) === 0) continue; if (st.visibility === "hidden" || st.display === "none" || Number(st.opacity) === 0) continue;
const type = (el.getAttribute("type") || "").toLowerCase(); const type = (el.getAttribute("type") || "").toLowerCase();
let text; 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 // Quiz answers: the value is usually "on"; the label next to it is what
// the model must read to pick the right option. // the model must read to pick the right option.
const owner = (el.labels && el.labels[0]) || el.closest("label") || el.parentElement; const owner = (el.labels && el.labels[0]) || el.closest("label") || el.parentElement;
@ -214,12 +148,12 @@ SNAP_JS = r"""
const out = []; const out = [];
let n = 1; let n = 1;
for (const item of inView.slice(0, 50).concat(offView.slice(0, 20))) { 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({ out.push({
id: n, id: n,
title: item.text, title: item.text,
tag: item.el.tagName.toLowerCase(), tag: item.el.tagName.toLowerCase(),
selector: '[data-lazyboy="' + n + '"]', selector: '[data-lazyboy="' + generation + "-" + n + '"]',
kind: "dom", kind: "dom",
x: item.x, x: item.x,
y: item.y, y: item.y,
@ -239,6 +173,11 @@ CLICK_JS = r"""
if (!el) return {ok: false, error: "element gone"}; if (!el) return {ok: false, error: "element gone"};
el.scrollIntoView({block: "center", inline: "nearest"}); el.scrollIntoView({block: "center", inline: "nearest"});
const r = el.getBoundingClientRect(); 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.focus();
el.click(); el.click();
const chromeH = Math.max(0, (window.outerHeight || 0) - (window.innerHeight || 0)); 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 # exits, so wait for two animation frames to keep that screenshot aligned
# with the framebuffer streamed by VNC. # with the framebuffer streamed by VNC.
try: try:
evaluate(ws, "new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))") evaluate(ws, "new Promise(resolve => {setTimeout(resolve, 250); requestAnimationFrame(() => requestAnimationFrame(resolve));})")
except Exception: except Exception:
pass pass
@ -462,7 +401,7 @@ class Recorder:
msg["params"] = params msg["params"] = params
if session: if session:
msg["sessionId"] = session msg["sessionId"] = session
self.ws.sock.sendall(self.ws._frame(json.dumps(msg).encode())) self.ws.sock.send(json.dumps(msg))
while True: while True:
obj = self.ws.recv_json() obj = self.ws.recv_json()
if obj.get("id") == self.ws.n: if obj.get("id") == self.ws.n:
@ -534,8 +473,47 @@ class Recorder:
self.handle(self.pending.pop(0)) self.handle(self.pending.pop(0))
self.handle(self.ws.recv_json()) 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(): 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" action = req.get("action") or "snapshot"
display = req.get("display") or ":1" display = req.get("display") or ":1"
profile = req.get("profile") or "" profile = req.get("profile") or ""
@ -636,6 +614,23 @@ def main():
out["waitedSeconds"] = round(waited, 1) out["waitedSeconds"] = round(waited, 1)
print(json.dumps(out)) print(json.dumps(out))
return 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": if action == "type":
sel = req.get("selector") or "" sel = req.get("selector") or ""
text = req.get("text") 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) 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 /// Marker embedded in the recorder's argv so `pkill -f` can find exactly one
/// teaching session without touching other python processes. /// teaching session without touching other python processes.
pub fn teach_recorder_tag(skill_id: &str) -> String { 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> { pub fn merge_page_elements(windows: Vec<UiElement>, page: &[UiElement]) -> Vec<UiElement> {
if page.is_empty() { crate::merge_ui_elements(windows, page, &[])
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
} }
#[cfg(test)] #[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 actions;
mod cdp; mod cdp;
mod lease; mod lease;
@ -9,6 +10,7 @@ mod screen;
mod takeover; mod takeover;
mod x11; mod x11;
pub use a11y::*;
pub use actions::*; pub use actions::*;
pub use cdp::*; pub use cdp::*;
pub use lease::*; pub use lease::*;

View File

@ -58,6 +58,21 @@ pub struct CommandRequest {
pub argv: Vec<String>, pub argv: Vec<String>,
pub cwd: Option<String>, pub cwd: Option<String>,
pub timeout_ms: Option<u64>, 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)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@ -193,17 +208,13 @@ pub trait SandboxProvider: Send + Sync {
&self, &self,
computer: &ComputerRef, computer: &ComputerRef,
context: &AdapterContext, context: &AdapterContext,
) -> Result<(), SandboxError> { ) -> Result<(), SandboxError>;
self.stop(computer, context).await
}
async fn resume( async fn resume(
&self, &self,
request: ProvisionRequest, request: ProvisionRequest,
context: &AdapterContext, context: &AdapterContext,
) -> Result<ComputerRef, SandboxError> { ) -> Result<ComputerRef, SandboxError>;
self.provision(request, context).await
}
async fn execute( async fn execute(
&self, &self,

View File

@ -1,5 +1,10 @@
use lazyboy_contracts::{ComputerAction, PointerButton, PointerType, ScrollDirection}; 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}; use crate::screen::{PRIMARY_DISPLAY, normalize_display};
pub const DISPLAY: &str = PRIMARY_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::Wait { .. }
| ComputerAction::Open { .. } | ComputerAction::Open { .. }
| ComputerAction::Launch { .. } => { | ComputerAction::Launch { .. }
| ComputerAction::Ref { .. } => {
return None; return None;
} }
} }
@ -160,6 +166,7 @@ pub fn action_pause_ms(action: &ComputerAction) -> u64 {
ComputerAction::Focus { .. } => 90, ComputerAction::Focus { .. } => 90,
ComputerAction::Open { .. } | ComputerAction::Launch { .. } => 220, ComputerAction::Open { .. } | ComputerAction::Launch { .. } => 220,
ComputerAction::Wait { .. } => 0, 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) .and_then(serde_json::Value::as_str)
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.map(str::to_string), .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() .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] #[test]
fn empty_or_junk_window_list_is_empty() { fn empty_or_junk_window_list_is_empty() {
assert!(parse_ui_elements("").is_empty()); assert!(parse_ui_elements("").is_empty());
assert!(parse_ui_elements("not-json").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::http::{HeaderMap, StatusCode};
use axum::routing::{get, post}; use axum::routing::{get, post};
use axum::{Json, Router}; use axum::{Json, Router};
use lazyboy_contracts::ComputerAction; use lazyboy_contracts::{ComputerAction, RefVerb};
use lazyboy_control::{ use lazyboy_control::{
ActionRequest, PRIMARY_DISPLAY, action_pause_ms, launch_argv_on, normalize_display, ActionRequest, PRIMARY_DISPLAY, a11y_command_on, action_pause_ms, cdp_command_on,
open_argv_on, parse_pointer_state, parse_ui_elements, pointer_state_command_on, launch_argv_on, normalize_display, open_argv_on, parse_a11y_page, parse_cdp_page,
screenshot_command_on, window_list_command_on, xdotool_argv_on, 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::io::AsyncReadExt;
use tokio::process::Command; use tokio::process::Command;
@ -153,6 +154,12 @@ async fn apply_action(
.ok_or_else(|| "unknown application".to_string())?; .ok_or_else(|| "unknown application".to_string())?;
spawn_detached(&argv).await spawn_detached(&argv).await
} }
ComputerAction::Ref {
verb,
target,
ref_kind,
text,
} => apply_ref(display, profile, *verb, target, ref_kind, text.as_deref()).await,
other => { other => {
let argv = let argv =
xdotool_argv_on(display, other).ok_or_else(|| "unsupported action".to_string())?; 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> { async fn spawn_detached(argv: &[String]) -> Result<(), String> {
Command::new(&argv[0]) Command::new(&argv[0])
.args(&argv[1..]) .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!({ let mut body = serde_json::json!({
"png_base64": base64::engine::general_purpose::STANDARD.encode(png) "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 { if let Some(cursor) = cursor {
body["cursor"] = serde_json::json!({ "x": cursor.x, "y": cursor.y }); body["cursor"] = serde_json::json!({ "x": cursor.x, "y": cursor.y });
} }
if let Some(window) = window { if let Some(window) = window {
body["activeWindow"] = serde_json::json!({ "id": window.id, "title": window.title }); body["activeWindow"] = serde_json::json!({ "id": window.id, "title": window.title });
} }
let elements = run_window_list(display).await;
if !elements.is_empty() { if !elements.is_empty() {
body["elements"] = serde_json::to_value(elements).unwrap_or(serde_json::json!([])); 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 serde.workspace = true
thiserror.workspace = true thiserror.workspace = true
rig-core.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") { if !supervisor.contains("://supervisor") {
return url.to_string(); return url.to_string();
} }
url.replace("://127.0.0.1", "://host.docker.internal") let Ok(mut parsed) = reqwest::Url::parse(url) else {
.replace("://localhost", "://host.docker.internal") 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 { pub enum DynModel {

View File

@ -57,6 +57,29 @@ impl DockerSandbox {
fn url(&self, path: &str) -> String { fn url(&self, path: &str) -> String {
format!("{}{path}", self.base_url) 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] #[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( async fn stop(
&self, &self,
computer: &ComputerRef, computer: &ComputerRef,
context: &AdapterContext, context: &AdapterContext,
) -> Result<(), SandboxError> { ) -> Result<(), SandboxError> {
self.client self.post_lifecycle(computer, context, "stop").await
.post(self.url(&format!("/computers/{}/stop", computer.id)))
.headers(self.headers(context))
.send()
.await
.map_err(|error| SandboxError::message(error.to_string()))?;
Ok(())
} }
async fn destroy( async fn destroy(

View File

@ -166,6 +166,24 @@ impl SandboxProvider for FakeSandbox {
Ok(()) 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( async fn stop(
&self, &self,
_computer: &ComputerRef, _computer: &ComputerRef,

View File

@ -21,3 +21,7 @@ base64.workspace = true
reqwest.workspace = true reqwest.workspace = true
async-trait = "0.1" async-trait = "0.1"
futures-util = "0.3" 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 futures_util::StreamExt;
use lazyboy_control::{ use lazyboy_control::{
ActionRequest, CommandRequest, CommandResult, EnsureScreenRequest, EnsureScreenResult, HOME, ActionRequest, CommandRequest, CommandResult, EnsureScreenRequest, EnsureScreenResult, HOME,
ScreenTarget, TEAM_SCREEN_LIMIT, action_pause_ms, launch_argv_on, normalize_display, ScreenTarget, TEAM_SCREEN_LIMIT, normalize_display, normalize_workspace_path,
normalize_workspace_path, open_argv_on, pointer_state_command_on, screen_layout, pointer_state_command_on, screen_layout, screenshot_command_on, window_list_command_on,
screenshot_command_on, window_list_command_on, xdotool_argv_on,
}; };
use tokio::time::{Duration, sleep}; use tokio::time::{Duration, sleep};
@ -68,22 +67,40 @@ impl DockerHost {
home_path: &str, home_path: &str,
space_id: &str, space_id: &str,
) -> Result<Provisioned, String> { ) -> Result<Provisioned, String> {
let home_path = host_bind_path(home_path); let data_dir = std::env::var("DATA_DIR").unwrap_or_else(|_| "./data".into());
tokio::fs::create_dir_all(&home_path) 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 .await
.map_err(|error| error.to_string())?; .map_err(|e| e.to_string())?;
let _ = tokio::process::Command::new("chown") let canonical = tokio::fs::canonicalize(&expected)
.args(["-R", "1000:1000", &home_path]) .await
.status() .map_err(|e| e.to_string())?;
.await; 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 let Some(existing) = self.find(home_key).await? {
if self.container_reusable(&existing).await.unwrap_or(false) { if self.container_reusable(&existing).await.unwrap_or(false) {
self.docker self.wake(&existing).await?;
.start_container(&existing, None::<StartContainerOptions<String>>)
.await
.ok();
self.wait_running(&existing).await?;
self.wait_ready(&existing).await?;
let screen_url = self.screen_url(&existing, false).await.ok(); let screen_url = self.screen_url(&existing, false).await.ok();
return Ok(Provisioned { return Ok(Provisioned {
id: existing, id: existing,
@ -100,6 +117,7 @@ impl DockerHost {
let mut labels = HashMap::new(); let mut labels = HashMap::new();
labels.insert("lazyboy.homeKey".into(), home_key.to_string()); labels.insert("lazyboy.homeKey".into(), home_key.to_string());
labels.insert("lazyboy.spaceId".into(), space_id.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 port_bindings = HashMap::new();
let mut exposed = HashMap::new(); let mut exposed = HashMap::new();
@ -136,7 +154,10 @@ impl DockerHost {
env: Some(vec![ env: Some(vec![
"DISPLAY=:1".into(), "DISPLAY=:1".into(),
format!("HOME={HOME}"), 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), labels: Some(labels),
exposed_ports: Some(exposed), exposed_ports: Some(exposed),
@ -212,13 +233,27 @@ impl DockerHost {
} }
None => HOME.to_string(), 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() { let argv = if request.argv.is_empty() {
vec!["/bin/echo".into(), "ready".into()] vec!["/bin/echo".into(), "ready".into()]
} else { } else {
request.argv request.argv
}; };
self.exec_argv(id, &argv, Some(&cwd), &ScreenTarget::default()) let mut bounded = vec![
.await "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( pub async fn exec_on(
@ -239,12 +274,21 @@ impl DockerHost {
} }
None => HOME.to_string(), 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() { let argv = if request.argv.is_empty() {
vec!["/bin/echo".into(), "ready".into()] vec!["/bin/echo".into(), "ready".into()]
} else { } else {
request.argv 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> { pub async fn observe(&self, id: &str) -> Result<Vec<u8>, String> {
@ -263,7 +307,13 @@ impl DockerHost {
value value
} else { } else {
let (stdout, stderr, code) = self 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?; .await?;
if code != 0 { if code != 0 {
return Err(String::from_utf8_lossy(&stderr).into_owned()); return Err(String::from_utf8_lossy(&stderr).into_owned());
@ -343,63 +393,8 @@ impl DockerHost {
request.profile_path.as_deref(), request.profile_path.as_deref(),
None, None,
); );
if let Ok(body) = self.control_act(id, &request, &target).await { // A transport failure may follow a successful click. Never replay mutations.
return Ok(body); self.control_act(id, &request, &target).await
}
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)
} }
pub async fn screen_url(&self, id: &str, interactive: bool) -> Result<String, String> { pub async fn screen_url(&self, id: &str, interactive: bool) -> Result<String, String> {
@ -536,6 +531,7 @@ PY"#,
], ],
None, None,
&ScreenTarget::default(), &ScreenTarget::default(),
None,
) )
.await?; .await?;
if code != 0 { if code != 0 {
@ -572,6 +568,18 @@ PY"#,
.map_err(|error| error.to_string()) .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> { pub async fn destroy(&self, id: &str) -> Result<(), String> {
let info = self.docker.inspect_container(id, None).await.ok(); let info = self.docker.inspect_container(id, None).await.ok();
let home_key = info let home_key = info
@ -610,17 +618,31 @@ PY"#,
.inspect_container(id, None) .inspect_container(id, None)
.await .await
.map_err(|error| error.to_string())?; .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 wanted = self.current_image_id().await?;
let have = info.image.unwrap_or_default(); let have = info.image.unwrap_or_default();
if !image_ids_match(&wanted, &have) { if !image_ids_match(&wanted, &have) {
return Ok(false); return Ok(false);
} }
let running = info.state.as_ref().and_then(|state| state.running) == Some(true); 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 let exit = info
.state .state
.as_ref() .as_ref()
.and_then(|state| state.exit_code) .and_then(|state| state.exit_code)
.unwrap_or(0); .unwrap_or(0);
if paused {
return Ok(true);
}
if !running && exit != 0 { if !running && exit != 0 {
return Ok(false); 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> { async fn wait_running(&self, id: &str) -> Result<(), String> {
for _ in 0..40 { for _ in 0..40 {
let info = self let info = self
@ -675,12 +724,23 @@ PY"#,
} }
async fn wait_ready(&self, id: &str) -> Result<(), String> { 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 let info = self
.docker .docker
.inspect_container(id, None) .inspect_container(id, None)
.await .await
.map_err(|error| error.to_string())?; .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) { 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); let exit = info.state.and_then(|state| state.exit_code).unwrap_or(1);
return Err(format!("computer exited during startup with code {exit}")); return Err(format!("computer exited during startup with code {exit}"));
@ -708,7 +768,20 @@ PY"#,
cwd: Option<&str>, cwd: Option<&str>,
target: &ScreenTarget, target: &ScreenTarget,
) -> Result<CommandResult, String> { ) -> 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 { Ok(CommandResult {
stdout: String::from_utf8_lossy(&stdout).into_owned(), stdout: String::from_utf8_lossy(&stdout).into_owned(),
stderr: String::from_utf8_lossy(&stderr).into_owned(), stderr: String::from_utf8_lossy(&stderr).into_owned(),
@ -722,6 +795,7 @@ PY"#,
argv: &[String], argv: &[String],
cwd: Option<&str>, cwd: Option<&str>,
target: &ScreenTarget, target: &ScreenTarget,
stdin: Option<&str>,
) -> Result<(Vec<u8>, Vec<u8>, i32), String> { ) -> Result<(Vec<u8>, Vec<u8>, i32), String> {
let display = normalize_display(&target.display); let display = normalize_display(&target.display);
let mut env = vec![ let mut env = vec![
@ -737,6 +811,7 @@ PY"#,
.create_exec( .create_exec(
id, id,
CreateExecOptions { CreateExecOptions {
attach_stdin: Some(stdin.is_some()),
attach_stdout: Some(true), attach_stdout: Some(true),
attach_stderr: Some(true), attach_stderr: Some(true),
cmd: Some(argv.to_vec()), cmd: Some(argv.to_vec()),
@ -750,7 +825,10 @@ PY"#,
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
let mut stdout = Vec::new(); let mut stdout = Vec::new();
let mut stderr = Vec::new(); let mut stderr = Vec::new();
if let StartExecResults::Attached { mut output, .. } = self if let StartExecResults::Attached {
mut output,
mut input,
} = self
.docker .docker
.start_exec( .start_exec(
&exec.id, &exec.id,
@ -761,14 +839,26 @@ PY"#,
.await .await
.map_err(|error| error.to_string())? .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 { while let Some(chunk) = output.next().await {
match chunk.map_err(|error| error.to_string())? { match chunk.map_err(|error| error.to_string())? {
bollard::container::LogOutput::StdOut { message } => { bollard::container::LogOutput::StdOut { message } => stdout.extend_from_slice(
stdout.extend_from_slice(&message) &message[..message
} .len()
bollard::container::LogOutput::StdErr { message } => { .min((16 * 1024 * 1024usize).saturating_sub(stdout.len()))],
stderr.extend_from_slice(&message) ),
} 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)) 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( async fn control_observe_json(
&self, &self,
id: &str, id: &str,
target: &ScreenTarget, target: &ScreenTarget,
) -> Result<serde_json::Value, String> { ) -> Result<serde_json::Value, String> {
let script = format!( let token = self.container_control_token(id).await?;
"curl -fsS -H 'Authorization: Bearer {}' -H 'x-lazyboy-display: {}' http://127.0.0.1:7070/observe",
self.control_token, target.display
);
let result = self 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?; .await?;
if result.code != 0 { if result.code != 0 {
return Err(result.stderr); return Err(result.stderr);
@ -806,19 +924,29 @@ PY"#,
target: &ScreenTarget, target: &ScreenTarget,
) -> Result<serde_json::Value, String> { ) -> Result<serde_json::Value, String> {
let payload = serde_json::to_string(request).map_err(|error| error.to_string())?; let payload = serde_json::to_string(request).map_err(|error| error.to_string())?;
let profile_header = target let token = self.container_control_token(id).await?;
.profile_path let mut argv = vec![
.as_deref() "curl".into(),
.map(|profile| format!(" -H 'x-lazyboy-profile: {profile}'")) "-fsS".into(),
.unwrap_or_default(); "--max-time".into(),
let script = format!( "120".into(),
"curl -fsS -H 'Authorization: Bearer {}' -H 'x-lazyboy-display: {}'{profile_header} -H 'content-type: application/json' -d {} http://127.0.0.1:7070/act", "-H".into(),
self.control_token, format!("Authorization: Bearer {token}"),
target.display, "-H".into(),
shell_single_quote(&payload) 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 let result = self
.exec_argv(id, &["bash".into(), "-lc".into(), script], None, target) .exec_raw_cmd(id, &argv, None, target, Some(payload))
.await?; .await?;
if result.code != 0 { if result.code != 0 {
return Err(result.stderr); return Err(result.stderr);
@ -871,6 +999,11 @@ fn computer_pids_limit() -> i64 {
.unwrap_or(2048) .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 { fn container_name(home_key: &str) -> String {
let sanitized: String = home_key let sanitized: String = home_key
.chars() .chars()
@ -890,3 +1023,26 @@ fn network_name(home_key: &str) -> String {
fn shell_single_quote(value: &str) -> String { fn shell_single_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', r#"'"'"'"#)) 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] #[tokio::main]
async fn 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() tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env().add_directive("info".parse().unwrap())) .with_env_filter(EnvFilter::from_default_env().add_directive("info".parse().unwrap()))
.init(); .init();
@ -43,6 +52,15 @@ async fn main() {
); );
let image = let image =
std::env::var("LAZYBOY_COMPUTER_IMAGE").unwrap_or_else(|_| "lazyboy/computer:local".into()); 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()) let docker = DockerHost::connect(image, token.clone())
.await .await
.expect("docker"); .expect("docker");
@ -64,7 +82,13 @@ async fn main() {
.route("/computers/{id}/files", get(list_files).post(write_file)) .route("/computers/{id}/files", get(list_files).post(write_file))
.route("/computers/{id}/read", post(read_file)) .route("/computers/{id}/read", post(read_file))
.route("/computers/{id}/stop", post(stop)) .route("/computers/{id}/stop", post(stop))
.route("/computers/{id}/pause", post(pause))
.route("/computers/{id}/unpause", post(unpause))
.route("/computers/{id}", delete(destroy)) .route("/computers/{id}", delete(destroy))
.route_layer(axum::middleware::from_fn_with_state(
app.clone(),
managed_boundary,
))
.with_state(app); .with_state(app);
let bind = std::env::var("SUPERVISOR_BIND").unwrap_or_else(|_| "127.0.0.1:7091".into()); 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"); let listener = tokio::net::TcpListener::bind(&bind).await.expect("bind");
@ -301,6 +325,32 @@ async fn stop(
Ok(StatusCode::NO_CONTENT) 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( async fn destroy(
State(app): State<App>, State(app): State<App>,
headers: HeaderMap, headers: HeaderMap,
@ -321,3 +371,27 @@ struct _Home(&'static str);
fn _assert_home() { fn _assert_home() {
let _ = 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: services:
postgres: postgres:
restart: unless-stopped
networks: [database]
logging: &bounded-logs
driver: json-file
options: {max-size: "10m", max-file: "3"}
image: pgvector/pgvector:pg16 image: pgvector/pgvector:pg16
environment: environment:
POSTGRES_USER: lazyboy POSTGRES_USER: lazyboy
POSTGRES_PASSWORD: lazyboy POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-lazyboy}
POSTGRES_DB: lazyboy POSTGRES_DB: lazyboy
ports:
- "127.0.0.1:5434:5432"
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
healthcheck: healthcheck:
@ -25,6 +28,19 @@ services:
network_mode: none network_mode: none
supervisor: 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: build:
context: . context: .
dockerfile: image/supervisor/Dockerfile dockerfile: image/supervisor/Dockerfile
@ -33,7 +49,7 @@ services:
LAZYBOY_COMPUTER_IMAGE: lazyboy/computer:local LAZYBOY_COMPUTER_IMAGE: lazyboy/computer:local
SUPERVISOR_BIND: 0.0.0.0:7091 SUPERVISOR_BIND: 0.0.0.0:7091
DATA_DIR: /data DATA_DIR: /data
HOST_DATA_DIR: ${PWD}/data HOST_DATA_DIR: ${LAZYBOY_HOST_DATA_DIR:-${PWD}/data}
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- ./data:/data - ./data:/data
@ -44,18 +60,26 @@ services:
condition: service_completed_successfully condition: service_completed_successfully
api: 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: build:
context: . context: .
dockerfile: image/api/Dockerfile dockerfile: image/api/Dockerfile
environment: 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_URL: http://supervisor:7091
SANDBOX_SUPERVISOR_TOKEN: ${SANDBOX_SUPERVISOR_TOKEN:?Set SANDBOX_SUPERVISOR_TOKEN in .env} 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} LAZYBOY_SECURE_COOKIE: ${LAZYBOY_SECURE_COOKIE:-false}
SANDBOX_PROVIDER: docker SANDBOX_PROVIDER: docker
DATA_DIR: /data DATA_DIR: /data
HOST_DATA_DIR: ${PWD}/data HOST_DATA_DIR: ${LAZYBOY_HOST_DATA_DIR:-${PWD}/data}
API_BIND: 0.0.0.0:3100 API_BIND: 0.0.0.0:3100
XAI_API_KEY: ${XAI_API_KEY:-} XAI_API_KEY: ${XAI_API_KEY:-}
OPENCODE_GO_API_KEY: ${OPENCODE_GO_API_KEY:-} OPENCODE_GO_API_KEY: ${OPENCODE_GO_API_KEY:-}
@ -67,7 +91,7 @@ services:
LAZYBOY_WEB_DIR: /web LAZYBOY_WEB_DIR: /web
LAZYBOY_SCREEN_UPSTREAM: host.docker.internal LAZYBOY_SCREEN_UPSTREAM: host.docker.internal
ports: ports:
- "3101:3100" - "${LAZYBOY_BIND_IP:-127.0.0.1}:3101:3100"
volumes: volumes:
- ./data:/data - ./data:/data
extra_hosts: extra_hosts:
@ -76,7 +100,14 @@ services:
postgres: postgres:
condition: service_healthy condition: service_healthy
supervisor: supervisor:
condition: service_started condition: service_healthy
volumes: volumes:
pgdata: pgdata:
networks:
database:
internal: true
control:
internal: true
egress: {}

View File

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

925
docs/diagrams.html Normal file
View File

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

BIN
docs/diagrams/chat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

BIN
docs/diagrams/click.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

BIN
docs/diagrams/folders.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

BIN
docs/diagrams/keys.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

BIN
docs/diagrams/look.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 KiB

BIN
docs/diagrams/map.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

BIN
docs/diagrams/schedule.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

BIN
docs/diagrams/sleep.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 KiB

BIN
docs/diagrams/teach.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 KiB

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

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

View File

@ -1,39 +1,59 @@
# syntax=docker/dockerfile:1
FROM node:22-bookworm-slim AS web FROM node:22-bookworm-slim AS web
WORKDIR /src/apps/web WORKDIR /src/apps/web
COPY apps/web/package.json apps/web/package-lock.json ./ 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/index.html apps/web/tsconfig.json apps/web/vite.config.ts ./
COPY apps/web/src src 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 FROM rust:1-trixie AS build
WORKDIR /src WORKDIR /src
COPY Cargo.toml Cargo.lock ./ COPY Cargo.toml Cargo.lock ./
COPY crates crates COPY crates crates
COPY migrations migrations COPY migrations migrations
COPY apps/web apps/web COPY apps/web/vnc.html apps/web/vnc.html
RUN cargo build --release -p lazyboy-api 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 FROM debian:trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates python3 python3-pip python3-lxml \ ca-certificates python3 python3-lxml \
&& pip3 install --break-system-packages --no-cache-dir mcp-server-fetch \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY --from=web /usr/local/bin/node /usr/local/bin/node 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=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/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 \ 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 \ && ln -sf /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx \
&& npm install -g --omit=dev \ && npm install -g --omit=dev \
@modelcontextprotocol/server-memory \ @modelcontextprotocol/server-memory@2026.8.31 \
@modelcontextprotocol/server-sequential-thinking \ @modelcontextprotocol/server-sequential-thinking@2026.8.31 \
@notionhq/notion-mcp-server \ @notionhq/notion-mcp-server@2.5.1 \
&& npm cache clean --force \ && npm cache clean --force \
&& node -v && npx --version \ && node -v && npx --version \
&& python3 -c "import mcp_server_fetch" && PYTHONPATH=/opt/python python3 -c "import mcp_server_fetch"
ENV LAZYBOY_WEB_DIR=/web ENV LAZYBOY_WEB_DIR=/web \
PYTHONPATH=/opt/python
ENV NPM_CONFIG_UPDATE_NOTIFIER=false ENV NPM_CONFIG_UPDATE_NOTIFIER=false
ENV NPM_CONFIG_FUND=false ENV NPM_CONFIG_FUND=false
RUN useradd --create-home --uid 1000 lazyboy
USER 1000:1000
EXPOSE 3100 EXPOSE 3100
CMD ["lazyboy-api"] CMD ["lazyboy-api"]

View File

@ -1,3 +1,4 @@
# syntax=docker/dockerfile:1
# Real bot desktop. Every boot must look like a Linux workstation: # Real bot desktop. Every boot must look like a Linux workstation:
# XFCE panel + window manager, Chromium, zsh terminal. Never a kiosk/HTML shell. # XFCE panel + window manager, Chromium, zsh terminal. Never a kiosk/HTML shell.
# Traditional Chinese fonts/locale so CJK text does not mojibake. # Traditional Chinese fonts/locale so CJK text does not mojibake.
@ -9,11 +10,19 @@ WORKDIR /src
COPY Cargo.toml Cargo.lock ./ COPY Cargo.toml Cargo.lock ./
COPY crates crates COPY crates crates
COPY migrations migrations 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 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 \ ca-certificates \
chromium \ chromium \
curl \ curl \
@ -22,13 +31,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
fonts-liberation \ fonts-liberation \
fonts-noto-cjk \ fonts-noto-cjk \
fonts-noto-color-emoji \ fonts-noto-color-emoji \
fonts-noto-core \
git \ git \
htop \
imagemagick \ imagemagick \
locales \ locales \
novnc \
procps \ procps \
python3 \ python3 \
python3-websocket \
util-linux \ util-linux \
websockify \ websockify \
wmctrl \ wmctrl \
@ -44,17 +53,21 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
xfdesktop4 \ xfdesktop4 \
xdotool \ xdotool \
xfwm4 \ xfwm4 \
xterm \
xvfb \ xvfb \
thunar \ thunar \
adwaita-icon-theme \ adwaita-icon-theme \
gnome-themes-extra \ gnome-themes-extra \
librsvg2-common \ librsvg2-common \
at-spi2-core \
libatk-adaptor \
python3-gi \
gir1.2-atspi-2.0 \
zsh \ zsh \
&& echo "zh_TW.UTF-8 UTF-8" >> /etc/locale.gen \ && echo "zh_TW.UTF-8 UTF-8" >> /etc/locale.gen \
&& echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen \ && echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen \
&& 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). # jf open 粉圓 (system UI) + MesloLGS NF (Powerlevel10k glyphs, CJK via fontconfig).
RUN mkdir -p /usr/share/fonts/truetype/huninn /usr/share/fonts/truetype/meslo \ 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 \ && 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 && 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-screen /usr/local/bin/lazyboy-screen
COPY --chmod=755 image/computer/lazyboy-browser /usr/local/bin/lazyboy-browser COPY --chmod=755 image/computer/lazyboy-browser /usr/local/bin/lazyboy-browser
COPY --chmod=755 image/computer/lazyboy-terminal /usr/local/bin/lazyboy-terminal 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/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/terminal.desktop /usr/share/applications/lazyboy-terminal.desktop
COPY --chmod=644 image/computer/xfce/browser.desktop /usr/share/applications/lazyboy-browser.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 # Traditional Chinese catalogs were retained during package installation.
# XFCE menus are not English. Hide stock xterm / duplicate terminal entries; # Hide stock xterm / duplicate terminal entries; the panel launches
# the panel launches lazyboy-terminal (zsh -l). # lazyboy-terminal (zsh -l).
RUN printf '%s\n' \ RUN for f in xterm uxterm debian-xterm xfce4-terminal; do \
'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 \
if [ -f "/usr/share/applications/${f}.desktop" ]; then \ if [ -f "/usr/share/applications/${f}.desktop" ]; then \
printf '\nNoDisplay=true\nHidden=true\n' >> "/usr/share/applications/${f}.desktop"; \ printf '\nNoDisplay=true\nHidden=true\n' >> "/usr/share/applications/${f}.desktop"; \
fi; \ fi; \
@ -132,7 +135,8 @@ COPY --chmod=755 image/computer/start.sh /usr/local/bin/lazyboy-computer
USER 1000:1000 USER 1000:1000
ENV HOME=/home/lazyboy DISPLAY=:1 SHELL=/bin/zsh TERM=xterm-256color \ 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 WORKDIR /home/lazyboy
EXPOSE 6080 6081 6082 6083 6084 6085 6086 6087 EXPOSE 6080 6081 6082 6083 6084 6085 6086 6087
CMD ["/usr/local/bin/lazyboy-computer"] CMD ["/usr/local/bin/lazyboy-computer"]

View File

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

View File

@ -10,6 +10,10 @@ export TERM="${TERM:-xterm-256color}"
export LANG="${LANG:-zh_TW.UTF-8}" export LANG="${LANG:-zh_TW.UTF-8}"
export LC_ALL="${LC_ALL:-zh_TW.UTF-8}" export LC_ALL="${LC_ALL:-zh_TW.UTF-8}"
export LANGUAGE="${LANGUAGE:-zh_TW:zh:en}" 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" export PATH="$HOME/.local/bin:/usr/local/bin:$PATH"
ROOT=/tmp/lazyboy ROOT=/tmp/lazyboy
@ -112,6 +116,41 @@ alive_pidfile() {
[[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null [[ -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() { start_desktop() {
local display="$1" local display="$1"
local xfce_home="$2" local xfce_home="$2"
@ -131,6 +170,7 @@ start_desktop() {
eval "$(dbus-launch --sh-syntax)" eval "$(dbus-launch --sh-syntax)"
echo "${DBUS_SESSION_BUS_PID:-}" >"${log}-dbus.pid" echo "${DBUS_SESSION_BUS_PID:-}" >"${log}-dbus.pid"
fi fi
start_atspi "$display" "$log"
if command -v xfconfd >/dev/null 2>&1; then if command -v xfconfd >/dev/null 2>&1; then
xfconfd --daemon >/dev/null 2>&1 || true xfconfd --daemon >/dev/null 2>&1 || true
fi fi

View File

@ -10,6 +10,10 @@ export TERM="${TERM:-xterm-256color}"
export LANG="${LANG:-zh_TW.UTF-8}" export LANG="${LANG:-zh_TW.UTF-8}"
export LC_ALL="${LC_ALL:-zh_TW.UTF-8}" export LC_ALL="${LC_ALL:-zh_TW.UTF-8}"
export LANGUAGE="${LANGUAGE:-zh_TW:zh:en}" 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 mkdir -p "$HOME" /tmp/lazyboy /tmp/.X11-unix
rm -f /tmp/lazyboy/ready rm -f /tmp/lazyboy/ready
export PATH="$HOME/.local/bin:/usr/local/bin:$PATH" 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 FROM rust:1-bookworm AS build
WORKDIR /src WORKDIR /src
COPY Cargo.toml Cargo.lock ./ COPY Cargo.toml Cargo.lock ./
COPY crates crates COPY crates crates
COPY migrations migrations 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 FROM gcr.io/distroless/cc-debian12:latest
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/* COPY --from=build /lazyboy-supervisor /usr/local/bin/lazyboy-supervisor
COPY --from=build /src/target/release/lazyboy-supervisor /usr/local/bin/lazyboy-supervisor
EXPOSE 7091 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 set -euo pipefail
root="$(cd "$(dirname "$0")/.." && pwd)" root="$(cd "$(dirname "$0")/.." && pwd)"
cd "$root" 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..." echo "waiting for postgres..."
for _ in $(seq 1 40); do for _ in $(seq 1 40); do
if docker compose exec -T postgres pg_isready -U lazyboy >/dev/null 2>&1; then 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.')

View File

@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Render docs/diagrams.html slides to docs/diagrams/*.png
set -euo pipefail
root="$(cd "$(dirname "$0")/.." && pwd)"
html="$root/docs/diagrams.html"
out="$root/docs/diagrams"
brave="/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"
mkdir -p "$out"
if [[ ! -x "$brave" ]]; then
echo "Need Brave at $brave" >&2
exit 1
fi
for p in map chat sleep look click teach schedule keys folders; do
"$brave" \
--headless=new \
--disable-gpu \
--hide-scrollbars \
--force-device-scale-factor=2 \
--window-size=1280,720 \
--screenshot="$out/$p.png" \
"file://$html?p=$p"
echo "wrote $out/$p.png"
done

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()

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

@ -0,0 +1,53 @@
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);
}
});
const mdJs=ts.transpileModule(fs.readFileSync('apps/web/src/markdown.tsx','utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,jsx:ts.JsxEmit.ReactJSX}}).outputText;
const mdBox={exports:{},require:(name)=>{
if(name==='react')return{useCallback:fn=>fn,useRef:()=>({current:null}),useState:()=>[false,()=>{}],memo:fn=>fn};
if(name==='react/jsx-runtime')return{jsx:()=>null,jsxs:()=>null,Fragment:'Fragment'};
if(name==='react-markdown'||name==='remark-gfm'||name==='remark-breaks')return{default:()=>null};
if(name==='./i18n')return{t:key=>key};
if(name.endsWith('.css'))return{};
throw new Error('unexpected import '+name);
}};
vm.runInNewContext(mdJs,mdBox);
const {sanitizeMarkdownUrl}=mdBox.exports;
test('markdown links only keep http(s), mailto, tel, and in-page hashes',()=>{
assert.equal(sanitizeMarkdownUrl('https://example.com/docs'),'https://example.com/docs');
assert.equal(sanitizeMarkdownUrl('mailto:hi@example.com'),'mailto:hi@example.com');
assert.equal(sanitizeMarkdownUrl('#section'),'#section');
assert.equal(sanitizeMarkdownUrl('javascript:alert(1)'),undefined);
assert.equal(sanitizeMarkdownUrl('data:text/html,<script>alert(1)</script>'),undefined);
assert.equal(sanitizeMarkdownUrl('/relative'),undefined);
});