Compare commits
4 Commits
1414ed6166
...
0e02c2e148
| Author | SHA1 | Date |
|---|---|---|
|
|
0e02c2e148 | |
|
|
2adcdbc262 | |
|
|
8790712d9b | |
|
|
2bd00a4716 |
|
|
@ -4,3 +4,13 @@ data
|
|||
reference
|
||||
**/*.md
|
||||
apps/web/node_modules
|
||||
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
**/*.pem
|
||||
**/*.key
|
||||
**/node_modules
|
||||
apps/web/dist
|
||||
**/__pycache__
|
||||
.DS_Store
|
||||
|
|
|
|||
|
|
@ -4,10 +4,14 @@ OPENAI_API_KEY=
|
|||
# Generate independent values with: openssl rand -hex 32
|
||||
LAZYBOY_APP_TOKEN=
|
||||
SANDBOX_SUPERVISOR_TOKEN=
|
||||
# Keep this key stable when rotating the app login token.
|
||||
LAZYBOY_VAULT_KEY=
|
||||
POSTGRES_PASSWORD=lazyboy
|
||||
LAZYBOY_BIND_IP=127.0.0.1
|
||||
SANDBOX_PROVIDER=docker
|
||||
DATABASE_URL=postgres://lazyboy:lazyboy@127.0.0.1:5434/lazyboy
|
||||
DATA_DIR=./data
|
||||
API_BIND=0.0.0.0:3101
|
||||
API_BIND=127.0.0.1:3101
|
||||
SANDBOX_SUPERVISOR_URL=http://127.0.0.1:7091
|
||||
LAZYBOY_SECURE_COOKIE=false
|
||||
LAZYBOY_COMPUTER_MEMORY_MB=2048
|
||||
|
|
|
|||
|
|
@ -9,3 +9,6 @@ node_modules
|
|||
dist
|
||||
.DS_Store
|
||||
.gstack/
|
||||
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
|
|
|||
|
|
@ -8,6 +8,41 @@ version = "2.0.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aead"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes-gcm"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"aes",
|
||||
"cipher",
|
||||
"ctr",
|
||||
"ghash",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
|
|
@ -55,6 +90,12 @@ version = "0.2.21"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "ambient-authority"
|
||||
version = "0.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b"
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.6"
|
||||
|
|
@ -435,6 +476,36 @@ version = "1.12.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cap-primitives"
|
||||
version = "3.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e0bf07d379916947be6c4a07f43684153d710a2896c31f9e97781362895596c"
|
||||
dependencies = [
|
||||
"ambient-authority",
|
||||
"fs-set-times",
|
||||
"io-extras",
|
||||
"io-lifetimes",
|
||||
"ipnet",
|
||||
"maybe-owned",
|
||||
"rustix",
|
||||
"rustix-linux-procfs",
|
||||
"windows-sys 0.52.0",
|
||||
"winx",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cap-std"
|
||||
version = "3.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a59e59fa26472d29680ece6a9f8ee8b0551a719a33df2f5240bde065ecbddfd7"
|
||||
dependencies = [
|
||||
"cap-primitives",
|
||||
"io-extras",
|
||||
"io-lifetimes",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "castaway"
|
||||
version = "0.2.4"
|
||||
|
|
@ -491,6 +562,26 @@ dependencies = [
|
|||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono-tz"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"phf",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.58"
|
||||
|
|
@ -655,6 +746,17 @@ dependencies = [
|
|||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cron"
|
||||
version = "0.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5877d3fbf742507b66bc2a1945106bd30dd8504019d596901ddd012a4dd01740"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"once_cell",
|
||||
"winnow 0.6.26",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.7"
|
||||
|
|
@ -702,9 +804,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core 0.6.4",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctr"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.20.11"
|
||||
|
|
@ -1141,6 +1253,17 @@ dependencies = [
|
|||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs-set-times"
|
||||
version = "0.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a"
|
||||
dependencies = [
|
||||
"io-lifetimes",
|
||||
"rustix",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs_extra"
|
||||
version = "1.3.0"
|
||||
|
|
@ -1305,6 +1428,16 @@ dependencies = [
|
|||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ghash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
|
||||
dependencies = [
|
||||
"opaque-debug",
|
||||
"polyval",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gif"
|
||||
version = "0.14.2"
|
||||
|
|
@ -1843,6 +1976,15 @@ dependencies = [
|
|||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "interpolate_name"
|
||||
version = "0.2.4"
|
||||
|
|
@ -1854,6 +1996,22 @@ dependencies = [
|
|||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "io-extras"
|
||||
version = "0.18.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2285ddfe3054097ef4b2fe909ef8c3bcd1ea52a8f0d274416caebeef39f04a65"
|
||||
dependencies = [
|
||||
"io-lifetimes",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "io-lifetimes"
|
||||
version = "2.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983"
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.12.1"
|
||||
|
|
@ -2011,10 +2169,14 @@ dependencies = [
|
|||
name = "lazyboy-api"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"cap-std",
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
"cron",
|
||||
"dotenvy",
|
||||
"fastembed",
|
||||
"futures-util",
|
||||
|
|
@ -2026,6 +2188,7 @@ dependencies = [
|
|||
"lazyboy-control",
|
||||
"lazyboy-harness",
|
||||
"lazyboy-sandbox",
|
||||
"rand 0.8.8",
|
||||
"reqwest 0.12.28",
|
||||
"rig-core",
|
||||
"rmcp",
|
||||
|
|
@ -2089,6 +2252,7 @@ name = "lazyboy-harness"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"lazyboy-contracts",
|
||||
"reqwest 0.12.28",
|
||||
"rig-core",
|
||||
"serde",
|
||||
"thiserror",
|
||||
|
|
@ -2120,11 +2284,14 @@ dependencies = [
|
|||
"base64 0.22.1",
|
||||
"bollard",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"hmac",
|
||||
"lazyboy-contracts",
|
||||
"lazyboy-control",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
|
|
@ -2277,6 +2444,12 @@ dependencies = [
|
|||
"rawpointer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "maybe-owned"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4"
|
||||
|
||||
[[package]]
|
||||
name = "maybe-rayon"
|
||||
version = "0.1.1"
|
||||
|
|
@ -2602,6 +2775,12 @@ dependencies = [
|
|||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opaque-debug"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.81"
|
||||
|
|
@ -2755,6 +2934,24 @@ version = "2.3.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.13"
|
||||
|
|
@ -2827,6 +3024,18 @@ dependencies = [
|
|||
"miniz_oxide 0.8.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polyval"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.15.0"
|
||||
|
|
@ -3536,6 +3745,16 @@ dependencies = [
|
|||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix-linux-procfs"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.43"
|
||||
|
|
@ -3930,6 +4149,12 @@ version = "0.1.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
|
|
@ -4568,7 +4793,7 @@ dependencies = [
|
|||
"indexmap 2.14.1",
|
||||
"toml_datetime",
|
||||
"toml_parser",
|
||||
"winnow",
|
||||
"winnow 1.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4577,7 +4802,7 @@ version = "1.1.3+spec-1.1.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
|
||||
dependencies = [
|
||||
"winnow",
|
||||
"winnow 1.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4823,6 +5048,16 @@ version = "0.5.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3"
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
|
|
@ -5420,6 +5655,15 @@ version = "0.52.6"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.6.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e90edd2ac1aa278a5c4599b1d89cf03074b610800f866d4026dc199d7929a28"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.4"
|
||||
|
|
@ -5429,6 +5673,16 @@ dependencies = [
|
|||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winx"
|
||||
version = "0.36.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
|
|
|
|||
|
|
@ -16,6 +16,11 @@ version = "0.1.0"
|
|||
license = "MIT"
|
||||
publish = false
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = "symbols"
|
||||
|
||||
[workspace.dependencies]
|
||||
lazyboy-contracts = { path = "crates/contracts" }
|
||||
lazyboy-control = { path = "crates/control" }
|
||||
|
|
|
|||
26
Makefile
|
|
@ -53,26 +53,12 @@ help: ## Show this help
|
|||
|
||||
# --- Environment -----------------------------------------------------------
|
||||
|
||||
env: ## Create .env from example with fresh random tokens (no-op if .env exists)
|
||||
@if [ -f .env ]; then \
|
||||
echo ".env already exists — leaving it untouched (use 'make env-force' to regenerate tokens)."; \
|
||||
else \
|
||||
cp .env.example .env; \
|
||||
APP=$$(openssl rand -hex 32); SUP=$$(openssl rand -hex 32); \
|
||||
sed "s|^LAZYBOY_APP_TOKEN=.*|LAZYBOY_APP_TOKEN=$$APP|" .env > .env.tmp; \
|
||||
sed "s|^SANDBOX_SUPERVISOR_TOKEN=.*|SANDBOX_SUPERVISOR_TOKEN=$$SUP|" .env.tmp > .env.tmp2; \
|
||||
mv .env.tmp2 .env; rm -f .env.tmp; \
|
||||
echo "created .env with generated LAZYBOY_APP_TOKEN / SANDBOX_SUPERVISOR_TOKEN (64 hex chars)."; \
|
||||
echo "next: set XAI_API_KEY in .env, then run 'make up'."; \
|
||||
fi
|
||||
env: ## Create .env with independent random secrets (preserve existing .env)
|
||||
@python3 scripts/init-env.py
|
||||
|
||||
env-force: ## Regenerate .env (wipes XAI_API_KEY — you will set it again)
|
||||
@cp .env.example .env; \
|
||||
APP=$$(openssl rand -hex 32); SUP=$$(openssl rand -hex 32); \
|
||||
sed "s|^LAZYBOY_APP_TOKEN=.*|LAZYBOY_APP_TOKEN=$$APP|" .env > .env.tmp; \
|
||||
sed "s|^SANDBOX_SUPERVISOR_TOKEN=.*|SANDBOX_SUPERVISOR_TOKEN=$$SUP|" .env.tmp > .env.tmp2; \
|
||||
mv .env.tmp2 .env; rm -f .env.tmp; \
|
||||
echo "regenerated .env tokens (XAI_API_KEY reset — set it again)."
|
||||
env-force: ## Refuse destructive key regeneration; existing vault keys must be preserved
|
||||
@echo "Refusing to overwrite .env: this can orphan saved passwords. See docs/security-and-harness-review.md for safe rotation."
|
||||
@exit 1
|
||||
|
||||
# --- Full Docker stack -----------------------------------------------------
|
||||
|
||||
|
|
@ -104,7 +90,7 @@ computer: ## Build the Debian desktop image used to spawn bot computers
|
|||
docker build -f image/computer/Dockerfile -t $(COMPUTER_IMAGE) .
|
||||
|
||||
postgres: ## Start only postgres and wait until it is ready
|
||||
$(COMPOSE) up -d postgres
|
||||
$(COMPOSE) -f docker-compose.yml -f docker-compose.dev.yml up -d postgres
|
||||
@echo "waiting for postgres (127.0.0.1:5434) to be ready..."
|
||||
@for i in $$(seq 1 40); do if $(COMPOSE) exec -T postgres pg_isready -U lazyboy >/dev/null 2>&1; then echo "postgres ready"; break; fi; sleep 0.5; done
|
||||
|
||||
|
|
|
|||
366
README.md
|
|
@ -1,72 +1,332 @@
|
|||
# LazyBoy
|
||||
|
||||
Create a bot in the browser, give it a Team or Private computer, and let it drive a Linux desktop.
|
||||

|
||||
|
||||
## Run (full Docker, recommended)
|
||||
在瀏覽器裡開 Agent。每個都有自己的 Linux 桌面:開網頁、敲指令、學你示範過的流程。金鑰、模型、檔案都留在你這台機器上。
|
||||
|
||||
Everything (postgres, desktop image build, supervisor, API + web UI) runs in
|
||||
Docker Compose, driven by the Makefile:
|
||||
---
|
||||
|
||||
## 需要的硬體
|
||||
|
||||
LazyBoy 是本機系統,不是雲端沙盒。機器要跑四件事:**Postgres**、**API(含網頁)**、**supervisor**、以及每個 Agent 的 **Debian 桌面容器**。
|
||||
|
||||
| 項目 | 最低能跑 | 建議(一台桌面常開) |
|
||||
| --- | --- | --- |
|
||||
| 作業系統 | macOS / Linux,已裝 Docker Engine + Compose | 同上,磁碟給 Docker 至少 30 GB |
|
||||
| CPU | 4 核 | 8 核以上。每個桌面預設吃 2 核(`LAZYBOY_COMPUTER_CPUS`) |
|
||||
| 記憶體 | 8 GB(只能開一台、還會卡) | **16 GB**。每個桌面預設 2 GB(`LAZYBOY_COMPUTER_MEMORY_MB`)再加上 API、Postgres、嵌入模型 |
|
||||
| 磁碟 | 約 15 GB(桌面映像 + Postgres) | 家目錄 `data/homes/` 會隨瀏覽器設定檔長大 |
|
||||
| GPU | 不需要 | 模型走網路 API,畫面是 CPU 上的 Xvfb |
|
||||
| 網路 | 第一次建映像、拉套件需要 | 之後離線也能開 UI;聊天要模型金鑰能連外 |
|
||||
|
||||
預設一個 Team 電腦容器可同時掛最多 **8** 個螢幕(`TEAM_SCREEN_LIMIT`)。再開私人電腦就是再一個容器、再 2 GB。分頁開著時心跳會讓桌面保持熱機;關掉分頁約 10 分鐘後凍結(記憶體還在),約 6 小時後才真正停機。
|
||||
|
||||
---
|
||||
|
||||
## 怎麼快速啟動
|
||||
|
||||
要有 Docker(含 Compose 外掛)和 Make。第一次會編 `lazyboy/computer:local`(Debian + XFCE + Chromium),會比較久。
|
||||
|
||||
```bash
|
||||
make env # creates .env with two generated 64-hex tokens (no-op if .env exists)
|
||||
# then set XAI_API_KEY in .env
|
||||
make up # builds all images and starts the stack
|
||||
make env
|
||||
# 在 .env 填 XAI_API_KEY
|
||||
# (也可以之後在「本機工作區 → 設定」接 xAI / OpenCode Go / OpenAI 相容端點)
|
||||
make up
|
||||
make health
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:3101` and sign in with `LAZYBOY_APP_TOKEN`.
|
||||
打開 [http://127.0.0.1:3101](http://127.0.0.1:3101),用 `.env` 裡的 `LAZYBOY_APP_TOKEN` 登入,建一個 Agent,傳一句話。
|
||||
|
||||
Useful targets (see `make help`):
|
||||
|
||||
| target | what it does |
|
||||
| --- | --- |
|
||||
| `make up` / `make down` / `make purge` | start / stop (keep data) / stop + delete postgres volume |
|
||||
| `make logs`, `make ps`, `make health` | observe the stack |
|
||||
| `make computer` | build only the heavy desktop image `lazyboy/computer:local` |
|
||||
| `make postgres`, `make postgres-down` | start/stop just the database on `127.0.0.1:5434` |
|
||||
| `make build`, `make fmt`, `make clippy`, `make test` | cargo workspace tasks |
|
||||
| `make web` | build `apps/web` with npm (needs node) |
|
||||
| `make dev`, `make dev-supervisor`, `make dev-api` | local dev: postgres in Docker, Rust services on the host in two terminals |
|
||||
|
||||
Without make, the full-stack equivalent is:
|
||||
|
||||
```bash
|
||||
cp .env.example .env # fill in the two tokens + XAI_API_KEY
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
## Run (local)
|
||||
|
||||
Postgres is on `127.0.0.1:5434` so it does not collide with other stacks.
|
||||
沒有 Make:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Set XAI_API_KEY, then generate two different secrets:
|
||||
# openssl rand -hex 32
|
||||
# openssl rand -hex 32
|
||||
# Put them in LAZYBOY_APP_TOKEN and SANDBOX_SUPERVISOR_TOKEN.
|
||||
docker compose up -d postgres
|
||||
./scripts/build-computer-image.sh
|
||||
SANDBOX_SUPERVISOR_TOKEN=<same-strong-supervisor-token> \
|
||||
DATA_DIR=/root/LazyBoy/data cargo run -p lazyboy-supervisor
|
||||
DATABASE_URL=postgres://lazyboy:lazyboy@127.0.0.1:5434/lazyboy \
|
||||
SANDBOX_PROVIDER=docker \
|
||||
SANDBOX_SUPERVISOR_URL=http://127.0.0.1:7091 \
|
||||
SANDBOX_SUPERVISOR_TOKEN=<same-strong-supervisor-token> \
|
||||
LAZYBOY_APP_TOKEN=<strong-app-token> \
|
||||
DATA_DIR=/root/LazyBoy/data \
|
||||
LAZYBOY_WEB_DIR=apps/web \
|
||||
API_BIND=0.0.0.0:3101 \
|
||||
cargo run -p lazyboy-api
|
||||
# openssl rand -hex 32 → LAZYBOY_APP_TOKEN
|
||||
# openssl rand -hex 32 → SANDBOX_SUPERVISOR_TOKEN
|
||||
# openssl rand -hex 32 → LAZYBOY_VAULT_KEY
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Open `http://<host>:3101` and sign in with `LAZYBOY_APP_TOKEN`. The computer is a real Debian container: fluxbox toolbar, Chromium with tabs and URL bar, and xterm. The display is proxied through the authenticated API; the supervisor is internal-only in Docker Compose and must not be published to the LAN.
|
||||
前端熱重載:`apps/web` 裡 `npm install && npm run dev`,開 [http://127.0.0.1:5173](http://127.0.0.1:5173)。Vite 把 `/api` 和 `/view` 轉到 3101。
|
||||
|
||||
For LAN use, put LazyBoy behind HTTPS whenever possible. A shared token sent over plain HTTP can be observed by other devices on an untrusted network. Set `LAZYBOY_SECURE_COOKIE=true` when HTTPS terminates at LazyBoy or a trusted reverse proxy. The API refuses a non-loopback bind unless `LAZYBOY_APP_TOKEN` is at least 32 characters; the supervisor likewise rejects missing, short, or default tokens.
|
||||
本機 Rust 開發(Postgres 仍在 Docker):
|
||||
|
||||
For frontend development, run `npm install && npm run dev` in `apps/web`, then open
|
||||
`http://127.0.0.1:5173`. Vite proxies API and computer-screen traffic to the Rust API on port 3101.
|
||||
```bash
|
||||
make dev # 準備 .env、Postgres、桌面映像
|
||||
make dev-supervisor # 終端 1
|
||||
make dev-api # 終端 2
|
||||
```
|
||||
|
||||
The standalone supervisor listens on `127.0.0.1:7091` by default. Docker Compose reaches it internally as `supervisor:7091`; there is intentionally no host port `7092`.
|
||||
| 指令 | 做什麼 |
|
||||
| --- | --- |
|
||||
| `make up` / `make down` / `make purge` | 啟動/停止(留資料)/連 Postgres 一起清 |
|
||||
| `make logs` `make ps` `make health` | 看狀態 |
|
||||
| `make computer` | 只重建桌面映像 |
|
||||
| `make postgres` | 只開資料庫 `127.0.0.1:5434` |
|
||||
|
||||
Model providers: set the workspace default in 本機工作區 → 設定. v1 supports xAI (`XAI_API_KEY`), OpenCode Go (`OPENCODE_GO_API_KEY` or a key saved in settings), and a self-hosted OpenAI-compatible endpoint (base URL + optional key). Keys saved in the UI are stored on the local workspace and take precedence over env vars.
|
||||
---
|
||||
|
||||
## 跟 Grok Bot 比,好在哪
|
||||
|
||||
[Grok Bot](https://grok.com) 是 xAI 的雲端隊友:對話、電腦、排程都在他們的機器上。LazyBoy 走同一類產品(本機開源實作對齊 Rakazo 那條線),差在**誰擁有執行環境**。
|
||||
|
||||
| | Grok Bot | LazyBoy |
|
||||
| --- | --- | --- |
|
||||
| 跑在哪 | xAI 雲端 | 你的 Docker |
|
||||
| 模型 | Grok | 你帶金鑰:xAI、OpenCode Go、或任何 OpenAI 相容端點 |
|
||||
| 電腦 | 廠商提供的桌面 | 你映像裡的 Debian/XFCE/Chromium,家目錄在 `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` + MiniLM(384 維)。只有你叫它記住、或它呼叫 `remember` 的內容會進長期記憶。密碼與 token 會被拒。清除對話不會清記憶。
|
||||
|
||||
**保險箱**
|
||||
每個 bot 自己的站名/帳號/密碼。模型用 `list_accounts` 只看到站與使用者名稱,`use_saved_login` 在 Chromium 登入表單填入。金鑰用 `LAZYBOY_VAULT_KEY` 加密。
|
||||
|
||||
**排程**
|
||||
五欄 cron,預設 `Asia/Taipei`。對話裡講「以後每天九點」或側欄新增。tick 迴圈把到期列變成普通 queued run。
|
||||
|
||||
**MCP**
|
||||
工作區級外掛。市集或自訂 stdio/HTTP/SSE。stdio 跑在 API 容器裡。
|
||||
|
||||
**群組**
|
||||
多個 Agent 同一個 thread。Team 電腦上各用各的螢幕。同一 bot 同時只跑一個 run,後面的訊息排隊。
|
||||
|
||||
**附件**
|
||||
圖片給當則模型看,不進歷史二進位。要讓電腦開原檔會放 `inbox/`,兩小時後刪。
|
||||
|
||||
**頭像與狀態**
|
||||
Blobatar 色塊+眼睛。啟動、喚醒、連線、換手時,預覽左上角與思考列會顯示對應文字。分頁開著時心跳保住容器,換手不拆 VNC。
|
||||
|
||||
---
|
||||
|
||||
## 系統怎麼轉起來
|
||||
|
||||

|
||||
|
||||
Compose 裡 supervisor **不**對主機開埠。API 在容器網路連 `supervisor:7091`。家目錄 `data/homes/<homeKey>` bind 進容器的 `/home/lazyboy`。
|
||||
|
||||
---
|
||||
|
||||
## 你送一則訊息
|
||||
|
||||

|
||||
|
||||
同一 bot 已有進行中的工作時,新訊息會排隊(`queuedBehindActive`)。人正在接管時,後面的話只排隊,思考轉圈不會假裝它還在動。問候路徑會把工具表清空,從源頭避免「哈囉」去開電腦。
|
||||
|
||||
`execute_run` 每一輪:續租約 → 寫步驟文字 → 問模型 → 沒有工具就結束(技能沒過會再把畫面塞回去)→ 有工具且需要沙盒才 boot → 畫面沒變就不重複塞圖。回合上限:聊天 4、一般 40、技能 80。
|
||||
|
||||
---
|
||||
|
||||
## 電腦的作息
|
||||
|
||||

|
||||
|
||||
閒置(`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。
|
||||
|
||||
---
|
||||
|
||||
## 它怎麼看、怎麼點
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
模型從不直接連 VNC。它只打 API 工具;工具經 sandbox HTTP 進 supervisor,再 `docker exec` 或打容器內 `controld`。
|
||||
|
||||
編號只畫在給模型的 JPEG 上。VNC 是乾淨桌面。每次 navigation/snapshot 會重編號,舊 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。
|
||||
|
||||
---
|
||||
|
||||
## 模型金鑰從哪來
|
||||
|
||||

|
||||
|
||||
`crates/harness` 不管滑鼠,只決定這次 run 要用哪一家模型。真正的 agent 迴圈在 `crates/api/src/runs.rs`。
|
||||
|
||||
金鑰:**這個機器人 → 工作區設定 → 環境變數**。API 跑在 Docker 時,迴圈位址 `127.0.0.1` 會被改成 `host.docker.internal`,才能打到你本機的相容端點。
|
||||
|
||||
---
|
||||
|
||||
## 教會它
|
||||
|
||||

|
||||
|
||||
之後 run 若 prompt 對得上技能名,會把完整 playbook 塞進當則,並清掉舊聊天以免模型複誦上次的「還在倒數」。執行仍用 `browser`/`computer_act`,在**現在**的畫面上找「Next」,不是記像素。
|
||||
|
||||
---
|
||||
|
||||
## 排程怎麼進工作
|
||||
|
||||

|
||||
|
||||
Cron 五欄。時區寫在列上,預設台北。`立刻跑` 只是立刻插一筆 run,不改下一拍時間。
|
||||
|
||||
---
|
||||
|
||||
## 專案目錄(二次開發從這裡找)
|
||||
|
||||

|
||||
|
||||
Cargo workspace。畫面在 `apps/web`,對話與工作在 `api`,怎麼點在 `control`,開機在 `supervisor` + `image/computer`,問哪一家模型在 `harness`。前端是獨立的 Vite app,由 API 把 `apps/web/dist`(或開發時的 `apps/web`)端出去。
|
||||
|
||||
```text
|
||||
LazyBoy/
|
||||
├── apps/web/ 瀏覽器 UI(Vite + 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 內嵌桌面(noVNC);API 的 /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/ Docker:provision / pause / unpause / exec / observe / act
|
||||
│ ├── controld/ 打進容器的小 HTTP(127.0.0.1:7070)
|
||||
│ └── api/ 唯一對外程序:路由、worker、idle、排程 tick、靜態網頁
|
||||
│ └── src/
|
||||
│ ├── main.rs 啟動、三條背景迴圈
|
||||
│ ├── routes.rs 組 router;bot / 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 1:controld + 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 組 argv,Python 做 CDP/AT-SPI),supervisor 的 `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 開埠
|
||||
- 畫面走已登入 API,VNC 密碼不進瀏覽器 URL
|
||||
- 區網請走 HTTPS;終端是 HTTPS 時設 `LAZYBOY_SECURE_COOKIE=true`
|
||||
- API 綁非本機時 `LAZYBOY_APP_TOKEN` 至少 32 字;supervisor 拒絕空白、過短、`dev-token`
|
||||
- 保險箱用 `LAZYBOY_VAULT_KEY`;換登入 token 時這把 key 要留著
|
||||
- 模型看不到密碼本文;2FA/CAPTCHA 一定要人在**它的**畫面上處理
|
||||
|
|
|
|||
51
a.txt
|
|
@ -1,51 +0,0 @@
|
|||
我這邊體感滑,是因為多數工作走 Shell/API/瀏覽器 DOM,很少靠截圖猜 XY。你 repo 裡 瀏覽器這條其實已經對了;卡的是 桌面原生 App。
|
||||
|
||||
你已經有的(別拆)
|
||||
能力 在哪
|
||||
Docker 真實 Linux 桌面 + Screen/lease/takeover supervisor / sandbox / control
|
||||
shell 工具 crates/api/src/tools.rs
|
||||
browser(CDP DOM snapshot/click by id/selector) 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-spi2/python3-pyatspi 之類。
|
||||
原生「elements」在 window_list_command 只是 整窗標題,不是按鈕/輸入框。模型對 Thunar、對話框、面板仍要看圖或瞎點。
|
||||
|
||||
P0|element 最終還是 XY
|
||||
apply_element_targets 填的是中心座標,執行仍走 ComputerAction::Pointer → xdotool。
|
||||
視窗一動、動畫、縮放就歪。agent-desktop/Cua 是 對 element 做 AX/AT-SPI action,不是點中心像素。
|
||||
|
||||
P1|ComputerAction 契約只有像素/鍵
|
||||
action.rs 沒有 ActByRef { id }、SetValue、A11yPress 這類語意動作。擴 CDP/a11y 都會卡在契約層。
|
||||
|
||||
P1|控的是容器桌面,不是你 Mac
|
||||
對「操控我電腦」若指本機 Finder/系統設定,現在這條 stack 接不到 Mac AX。那是另一條 provider(agent-desktop/Cua),不是再調 xdotool。
|
||||
|
||||
P2|模型路由依賴 prompt
|
||||
tools 已寫「Chromium 用 browser」,但若模型仍狂 computer_observe+座標,體感就跟我差一截。缺硬性策略(例如:有 CDP page 時禁止對 browser 窗做 computer_act click)。
|
||||
|
||||
跟「我為什麼滑」的對照
|
||||
路徑 我(Grok Bot) LazyBoy feat/test
|
||||
Shell/檔案 預設 有
|
||||
瀏覽器 DOM/element ref 有 CDP,方向對
|
||||
桌面原生 另有 a11y/委派;截圖是備援 截圖 + xdotool 仍是主幹
|
||||
本機 Mac 受限/核准 Shell 尚未做 Host provider
|
||||
所以:你換好幾套「控制」若不順,多半都還停在 視覺/座標層;你 repo 差的是把 AT-SPI(容器內) 接到 observe/act,並讓 browser/shell 硬優先。
|
||||
|
||||
建議下一步(只做這 3 件)
|
||||
容器加 AT-SPI:image 裝 at-spi;observe 回傳 button/textfield 樹(精簡 YAML/JSON + stable id);act 對 id 做 DoAction/set text,失敗才 fallback XY。
|
||||
擴 ComputerAction/UiElement:kind: a11y|dom|window,ref 語意動作;computer_act 有 selector/a11y ref 時不要先轉座標。
|
||||
路由硬規則:Chromium 前台 → 強制 browser;能 shell 完成 → 禁止截圖迴圈;並加「連續同點無畫面變化 → 強制換策略」(你已有一點 stale-click 提示,可再硬一點)。
|
||||
本機 Mac 若也要滑:另開 HostComputerProvider(agent-desktop/Cua),別硬塞進現在的 X11 xdotool 路徑。
|
||||
|
||||
要的話我可以直接幫你開一張 P0 AT-SPI observe/act 的實作規格(改哪些檔、契約長怎樣),或直接在 feat/test 上開工。
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -11,10 +11,14 @@
|
|||
"dependencies": {
|
||||
"@blobatar/react": "^2.7.0",
|
||||
"@fontsource/huninn": "^5.3.0",
|
||||
"@novnc/novnc": "1.7.0",
|
||||
"blobatar": "^2.7.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"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": {
|
||||
"@types/react": "^19.0.0",
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 4.4 KiB |
|
|
@ -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 |
|
After Width: | Height: | Size: 604 B |
|
|
@ -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 |
|
|
@ -1,8 +1,7 @@
|
|||
import { FormEvent, KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { BotIcon, Brain, ChevronDown, ChevronsRight, CircleHelp, ClipboardPaste, Computer, Download, Ellipsis, Info, LogOut, Megaphone, Paperclip, Pencil, Pin, Plug, Plus, RefreshCw, Settings, Smartphone, Sparkle, Square, Upload, Users, X } from "./animated-icons";
|
||||
import UseAnimations from "react-useanimations";
|
||||
import UseAnimations from "./use-animations";
|
||||
import loading from "react-useanimations/lib/loading";
|
||||
import loading2 from "react-useanimations/lib/loading2";
|
||||
import arrowUp from "react-useanimations/lib/arrowUp";
|
||||
import bookmark from "react-useanimations/lib/bookmark";
|
||||
import copy from "react-useanimations/lib/copy";
|
||||
|
|
@ -19,22 +18,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 { 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 { 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 PANE_STORE="lazyboy.rightPane";
|
||||
const WORKSPACE_STORE="lazyboy.workspace";
|
||||
type RightPart="computer"|"memory"|"settings"|"plugins";
|
||||
type RightPart="computer"|"memory"|"settings"|"plugins"|"accounts";
|
||||
type AccountDialog="phone"|"settings"|"model"|"about"|"help"|"feedback"|null;
|
||||
function readSessionStore():Record<string,string>{try{const raw=localStorage.getItem(SESSION_STORE);return raw?JSON.parse(raw) as Record<string,string>:{}}catch{return {}}}
|
||||
function writeSessionStore(botId:string,sessionId:string){const store=readSessionStore();store[botId]=sessionId;localStorage.setItem(SESSION_STORE,JSON.stringify(store))}
|
||||
function readPaneStore():{collapsed:boolean;part:RightPart}{try{const raw=localStorage.getItem(PANE_STORE);if(!raw)return{collapsed:false,part:"computer"};const value=JSON.parse(raw) as {collapsed?:boolean;part?:string};return{collapsed:Boolean(value.collapsed),part:value.part==="memory"||value.part==="settings"||value.part==="plugins"?value.part:"computer"}}catch{return{collapsed:false,part:"computer"}}}
|
||||
function readPaneStore():{collapsed:boolean;part:RightPart}{try{const raw=localStorage.getItem(PANE_STORE);if(!raw)return{collapsed:false,part:"computer"};const value=JSON.parse(raw) as {collapsed?:boolean;part?:string};return{collapsed:Boolean(value.collapsed),part:value.part==="memory"||value.part==="settings"||value.part==="plugins"||value.part==="accounts"?value.part:"computer"}}catch{return{collapsed:false,part:"computer"}}}
|
||||
type WorkspacePrefs={name:string;showHidden:boolean};
|
||||
function readWorkspace():WorkspacePrefs{try{const raw=localStorage.getItem(WORKSPACE_STORE);if(!raw)return{name:t("localWorkspace"),showHidden:false};const value=JSON.parse(raw) as {name?:string;showHidden?:boolean};const name=value.name?.trim();return{name:name&&name!=="Local workspace"?name:t("localWorkspace"),showHidden:Boolean(value.showHidden)}}catch{return{name:t("localWorkspace"),showHidden:false}}}
|
||||
|
||||
function WorkspaceAvatar({name}:{name:string}){const parts=name.trim().split(/\s+/).filter(Boolean);const initials=(parts.length>1?parts.map(part=>part[0]).join(""):parts[0]?.slice(0,2)||"LB").slice(0,2).toUpperCase();return <span className="workspace-avatar" aria-hidden="true">{initials}</span>}
|
||||
function modeLabel(mode:ComputerMode){return mode==="team"?t("sharedComputer"):t("privateComputer")}
|
||||
function stateLabel(state:ComputerStatus["state"]){return ({stopped:t("stopped"),booting:t("booting"),running:t("running"),suspended:t("suspended"),error:t("error")})[state]}
|
||||
function isTransitionStep(step?:string|null){return step==="電腦啟動中"||step==="喚醒中"||step==="換手中"}
|
||||
function hudLabel(computer:ComputerStatus,connecting:boolean,handingOff:boolean){
|
||||
const step=computer.busyStep||"";
|
||||
if(computer.state==="booting"||step==="電腦啟動中")return t("hudBooting");
|
||||
if(computer.state==="suspended"||step==="喚醒中")return t("hudWaking");
|
||||
if(handingOff||step==="換手中")return t("hudHandoff");
|
||||
if(connecting)return t("hudConnecting");
|
||||
return null;
|
||||
}
|
||||
function inboxTime(value:string|null){if(!value)return "";const date=new Date(value),now=new Date();if(date.toDateString()===now.toDateString())return new Intl.DateTimeFormat("zh-TW",{hour:"2-digit",minute:"2-digit",hour12:false}).format(date);const days=Math.floor((new Date(now.getFullYear(),now.getMonth(),now.getDate()).getTime()-new Date(date.getFullYear(),date.getMonth(),date.getDate()).getTime())/86400000);if(days<7)return new Intl.DateTimeFormat("zh-TW",{weekday:"long"}).format(date);return new Intl.DateTimeFormat("zh-TW",{month:"numeric",day:"numeric"}).format(date)}
|
||||
const ATTACH_MAX=4;
|
||||
const ATTACH_MAX_BYTES=10*1024*1024;
|
||||
|
|
@ -45,6 +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 fileExt(name:string){const dot=name.lastIndexOf(".");const ext=dot>=0?name.slice(dot+1).replace(/[^a-z0-9]/gi,""):"";return (ext||"FILE").slice(0,4).toUpperCase()}
|
||||
function messageFiles(blocks:unknown):MessageFile[]{if(!Array.isArray(blocks))return [];return blocks.flatMap(block=>{if(!block||typeof block!=="object")return [];const value=block as {kind?:string;name?:string;mimeType?:string;size?:number};if(value.kind!=="file"&&value.kind!=="image")return [];return [{kind:value.kind,name:value.name||"file",mimeType:value.mimeType,size:value.size}]})}
|
||||
function chipBlocks(blocks:unknown){if(!Array.isArray(blocks))return [] as {kind:string;site?:string;why?:string;name?:string;human?:string}[];return blocks.flatMap(block=>{if(!block||typeof block!=="object")return [];const value=block as {kind?:string;site?:string;why?:string;name?:string;human?:string};if(value.kind==="login"||value.kind==="schedule"||value.kind==="scheduleRun")return [value];return []})}
|
||||
function isAutoAttachCaption(body:string,files:MessageFile[]){const text=body.trim();if(!files.length)return false;if(!text)return true;return files.some(file=>text===file.name||text===`附件 ${file.name}`||text===t("attachedFile",{name:file.name}))}
|
||||
function FileCard({file,preview,onRemove}:{file:{name:string;size?:number};preview?:string|null;onRemove?:()=>void}){const ext=fileExt(file.name);return <div className={`file-card ${onRemove?"is-pending":""}`}>{preview?<img className="file-card-thumb" src={preview} alt=""/>:<div className="file-card-badge" aria-hidden="true">{ext}</div>}<div className="file-card-meta"><strong>{file.name}</strong><small>{typeof file.size==="number"?formatBytes(file.size):ext}</small></div>{onRemove&&<button type="button" className="file-card-remove" title={t("attachRemove",{name:file.name})} onClick={onRemove}><X/></button>}</div>}
|
||||
function clientNonce(){
|
||||
|
|
@ -79,9 +90,14 @@ export function App(){
|
|||
const [roomToDelete,setRoomToDelete]=useState<Room|null>(null); const [mcpServers,setMcpServers]=useState<McpServer[]>([]);
|
||||
const [accountOpen,setAccountOpen]=useState(false); const [accountDialog,setAccountDialog]=useState<AccountDialog>(null);
|
||||
const [skills,setSkills]=useState<TaughtSkill[]>([]); const [plusOpen,setPlusOpen]=useState(false); const [skillQuery,setSkillQuery]=useState(""); const [teachOpen,setTeachOpen]=useState(false); const [editingSkillId,setEditingSkillId]=useState<string|null>(null);
|
||||
const [schedules,setSchedules]=useState<ScheduleItem[]>([]); const [scheduleDraft,setScheduleDraft]=useState<{name:string;instructions:string;enabled:boolean;preset:CronPreset;id?:string;timezone?:string;threadId?:string|null}|null>(null);
|
||||
const [scheduleError,setScheduleError]=useState<string|null>(null); const [scheduleSaving,setScheduleSaving]=useState(false); const [runningScheduleId,setRunningScheduleId]=useState<string|null>(null);
|
||||
const [workspaceName,setWorkspaceName]=useState(workspaceStart.name);
|
||||
const [looks,setLooks]=useState(readAvatarLooks);
|
||||
const sendingRef=useRef(false); const refreshSeqRef=useRef(0); const importRef=useRef<HTMLInputElement>(null); const attachRef=useRef<HTMLInputElement>(null);
|
||||
const desktopFrameRef=useRef<HTMLIFrameElement>(null);
|
||||
const holderRef=useRef(computer.controlHolder); const paneBotRef=useRef<string|null>(null); const skipHandoffRef=useRef(true);
|
||||
const [desktopReady,setDesktopReady]=useState(false); const [handingOff,setHandingOff]=useState(false);
|
||||
const [pendingFiles,setPendingFiles]=useState<PendingFile[]>([]);
|
||||
const messageEndRef=useRef<HTMLDivElement|null>(null);
|
||||
const sentHistoryRef=useRef<string[]>([]); const historyIndexRef=useRef<number|null>(null); const historyDraftRef=useRef("");
|
||||
|
|
@ -92,7 +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 paneBotId=busyMembers[0]?.id||activeRoom?.members[0]?.id||activeId;
|
||||
const paneBot=bots.find(bot=>bot.id===paneBotId)||active;
|
||||
const workingMembers=activeRoom?busyMembers:active&&computer.busySessionId===activeSessionId?[{id:active.id,name:active.name,avatarColor:active.avatarColor,avatarShape:active.avatarShape}]:[];
|
||||
const currentPaneRef=useRef(paneBotId);currentPaneRef.current=paneBotId;
|
||||
const pasteQueueRef=useRef<Promise<unknown>>(Promise.resolve());
|
||||
const [clipboardStatus,setClipboardStatus]=useState("");
|
||||
const workingMembers=activeRoom?busyMembers:active&&activeSessionId&&computer.busySessionId===activeSessionId?[{id:active.id,name:active.name,avatarColor:active.avatarColor,avatarShape:active.avatarShape}]:[];
|
||||
const lastMessageId=messages[messages.length-1]?.id||"";
|
||||
// The bot is parked in waiting_takeover: nothing moves (including queued
|
||||
// messages) until the human releases the screen, so say so loudly.
|
||||
|
|
@ -131,14 +150,40 @@ export function App(){
|
|||
useEffect(()=>{if(!plusOpen)setSkillQuery("")},[plusOpen]);
|
||||
useEffect(()=>{messageEndRef.current?.scrollIntoView({block:"end",behavior:"smooth"})},[activeSessionId,lastMessageId,workingMembers.length,pausedForUser]);
|
||||
useEffect(()=>{if(!activeSessionId||(!activeId&&!activeRoomId)){refreshSeqRef.current+=1;setMessages([]);setScreenUrl(null);if(!activeId&&!activeRoomId)setComputer(blankComputer);return}setScreenUrl(null);refresh().catch(e=>setError(e.message));const timer=setInterval(()=>{refresh().catch(()=>{});const beat=roomsRef.current.find(room=>room.id===activeRoomId)?.members[0]?.id||activeId;if(beat)api(`/api/computer/${beat}/heartbeat`,{method:"POST",body:"{}"}).catch(()=>{})},2000);return()=>{clearInterval(timer);refreshSeqRef.current+=1}},[activeId,activeRoomId,activeSessionId,refresh]);
|
||||
useEffect(()=>{const listener=(event:MessageEvent)=>{if(event.origin!==location.origin||!event.data)return;if(event.data.type==="lazyboy-desktop-clipboard"){const text=String(event.data.text||"");setDesktopClipboard(text);navigator.clipboard.writeText(text).catch(()=>{})}};window.addEventListener("message",listener);return()=>window.removeEventListener("message",listener)});
|
||||
useEffect(()=>{const listener=(event:MessageEvent)=>{if(event.origin!==location.origin||event.data?.type!=="lazyboy-request-control"||!paneBotId)return;void action(()=>api(`/api/computer/${paneBotId}/takeover`,{method:"POST",body:"{}"}))};window.addEventListener("message",listener);return()=>window.removeEventListener("message",listener)},[paneBotId]);
|
||||
useEffect(()=>{const listener=(event:MessageEvent)=>{if(event.origin!==location.origin||event.source!==desktopFrameRef.current?.contentWindow||!event.data)return;if(event.data.type==="lazyboy-desktop-clipboard"){const text=String(event.data.text||"");setDesktopClipboard(text);navigator.clipboard?.writeText(text).then(()=>setClipboardStatus("剪貼簿已同步")).catch(()=>setClipboardStatus("瀏覽器未允許同步,請按複製按鈕"))}if(event.data.type==="lazyboy-copy-request"&&computer.controlHolder==="user")void copySelection();if(event.data.type==="lazyboy-paste-text"&&typeof event.data.text==="string")pasteText(event.data.text);if(event.data.type==="lazyboy-paste-request"&&computer.controlHolder==="user")setClipboardOpen(true);if(event.data.type==="lazyboy-desktop-ready")setDesktopReady(true);if(event.data.type==="lazyboy-desktop-lost")setDesktopReady(false)};window.addEventListener("message",listener);return()=>window.removeEventListener("message",listener)});
|
||||
useEffect(()=>{setDesktopReady(false)},[screenUrl,paneBotId]);
|
||||
useEffect(()=>{if(skipHandoffRef.current){skipHandoffRef.current=false;holderRef.current=computer.controlHolder;paneBotRef.current=paneBotId;return}if((holderRef.current!==computer.controlHolder||paneBotRef.current!==paneBotId)&&computer.state==="running")setHandingOff(true);holderRef.current=computer.controlHolder;paneBotRef.current=paneBotId},[computer.controlHolder,paneBotId,computer.state]);
|
||||
useEffect(()=>{if(!handingOff)return;const timer=setTimeout(()=>setHandingOff(false),1600);return()=>clearTimeout(timer)},[handingOff]);
|
||||
useEffect(()=>{const frame=desktopFrameRef.current;if(!frame?.contentWindow||!screenUrl)return;frame.contentWindow.postMessage({type:"lazyboy-view-only",viewOnly:computer.controlHolder!=="user"},location.origin)},[computer.controlHolder,screenUrl,desktopReady]);
|
||||
useEffect(()=>{const listener=(event:MessageEvent)=>{if(event.origin!==location.origin||event.source!==desktopFrameRef.current?.contentWindow||event.data?.type!=="lazyboy-request-control"||!paneBotId)return;void action(()=>api(`/api/computer/${paneBotId}/takeover`,{method:"POST",body:"{}"}))};window.addEventListener("message",listener);return()=>window.removeEventListener("message",listener)},[paneBotId]);
|
||||
useEffect(()=>{const close=(event:MouseEvent)=>{const target=event.target;if(target instanceof Element&&target.closest(".create-menu-wrap,.account-wrap,.session-picker,.context-menu,.plus-menu-wrap"))return;setContext(null);setRoomContext(null);setSessionMenuOpen(false);setAccountOpen(false);setCreateMenuOpen(false);setPlusOpen(false)};window.addEventListener("click",close);return()=>window.removeEventListener("click",close)},[]);
|
||||
useEffect(()=>{const onKey=(event:KeyboardEvent)=>{if(event.key!=="Escape")return;setAccountOpen(false);setAccountDialog(null);setCreateMenuOpen(false);setPlusOpen(false);setTeachOpen(false);setEditingSkillId(null);setSessionMenuOpen(false);setContext(null);setRoomContext(null)};window.addEventListener("keydown",onKey);return()=>window.removeEventListener("keydown",onKey)},[]);
|
||||
useEffect(()=>{localStorage.setItem(WORKSPACE_STORE,JSON.stringify({name:workspaceName,showHidden}))},[workspaceName,showHidden]);
|
||||
useEffect(()=>{if(!sessionStoreKey||!activeSessionId)return;if(!sessions.some(session=>session.id===activeSessionId))return;if(!activeRoomId&&!sessions.some(session=>session.id===activeSessionId&&session.botId===activeId))return;writeSessionStore(sessionStoreKey,activeSessionId)},[sessionStoreKey,activeId,activeRoomId,activeSessionId,sessions]);
|
||||
useEffect(()=>{localStorage.setItem(PANE_STORE,JSON.stringify({collapsed:rightCollapsed,part:rightPart}))},[rightCollapsed,rightPart]);
|
||||
useEffect(()=>{if(!paneBotId){setSchedules([]);return}api<ScheduleItem[]>(`/api/bots/${paneBotId}/schedules`).then(setSchedules).catch(()=>setSchedules([]))},[paneBotId]);
|
||||
useEffect(()=>{if(computer.takeoverRequested){setRightPart("computer");setRightCollapsed(false)}},[computer.takeoverRequested]);
|
||||
function openPane(part:RightPart){setRightPart(part);setRightCollapsed(false)}
|
||||
async function openLoginScreen(){
|
||||
const id=paneBot?.id||active?.id;if(!id)return;
|
||||
setRightPart("computer");setRightCollapsed(false);setComputerOpen(true);
|
||||
await action(async()=>{
|
||||
try{await api(`/api/computer/${id}/boot`,{method:"POST",body:"{}"})}catch{/* already up */}
|
||||
await api(`/api/computer/${id}/takeover`,{method:"POST",body:"{}"}).catch(()=>{});
|
||||
});
|
||||
}
|
||||
async function reloadSchedules(){if(!paneBotId)return;setSchedules(await api<ScheduleItem[]>(`/api/bots/${paneBotId}/schedules`))}
|
||||
async function saveScheduleDraft(){
|
||||
if(!paneBot||!scheduleDraft)return;
|
||||
setScheduleSaving(true);setScheduleError(null);
|
||||
try{
|
||||
const body={name:scheduleDraft.name.trim(),cron:cronFromPreset(scheduleDraft.preset),instructions:scheduleDraft.instructions.trim(),timezone:scheduleDraft.timezone||Intl.DateTimeFormat().resolvedOptions().timeZone||"Asia/Taipei",enabled:scheduleDraft.enabled,threadId:scheduleDraft.id?scheduleDraft.threadId:activeRoomId?undefined:activeSessionId};
|
||||
if(scheduleDraft.id)await api(`/api/schedules/${scheduleDraft.id}`,{method:"PATCH",body:JSON.stringify(body)});
|
||||
else await api(`/api/bots/${paneBot.id}/schedules`,{method:"POST",body:JSON.stringify(body)});
|
||||
setScheduleDraft(null);await reloadSchedules();
|
||||
}catch(e){setScheduleError(e instanceof Error?e.message:t("operationFailed"))}
|
||||
finally{setScheduleSaving(false)}
|
||||
}
|
||||
|
||||
const sessionBusy=workingMembers.length>0;
|
||||
const otherSessionBusy=Boolean(computer.busyBotName&&!sessionBusy);
|
||||
|
|
@ -166,7 +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 deleteSession(id:string){if(!sessionsPath||!sessionStoreKey)return;setSessionMenuOpen(false);setBusy(true);setError(null);try{await api(`/api/sessions/${id}`,{method:"DELETE"});const next=await api<Session[]>(sessionsPath);setSessions(next);const pick=id===activeSessionId||!next.some(session=>session.id===activeSessionId)?next[0]?.id||null:activeSessionId;setActiveSessionId(pick);if(pick)writeSessionStore(sessionStoreKey,pick)}catch(e){setError(e instanceof Error?e.message:t("operationFailed"))}finally{setBusy(false)}}
|
||||
async function rememberMessage(message:Message){const botId=message.speakerBotId||active?.id||activeRoom?.members[0]?.id;if(!botId||!message.body.trim())return;try{await api(`/api/bots/${botId}/memories`,{method:"POST",body:JSON.stringify({content:message.body,sessionId:activeSessionId})});setRemembered(current=>({...current,[message.id]:true}))}catch(e){setError(e instanceof Error?e.message:t("rememberFailed"))}}
|
||||
async function pasteClipboard(){try{const text=await navigator.clipboard.readText();document.querySelectorAll<HTMLIFrameElement>(".desktop-frame").forEach(frame=>frame.contentWindow?.postMessage({type:"lazyboy-host-clipboard",text},location.origin))}catch{setClipboardOpen(true)}}
|
||||
function pasteText(text:string){
|
||||
const botId=paneBotId;
|
||||
if(!botId||computer.controlHolder!=="user")return;
|
||||
pasteQueueRef.current=pasteQueueRef.current.catch(()=>{}).then(async()=>{
|
||||
if(currentPaneRef.current!==botId)return;
|
||||
await api(`/api/computer/${botId}/input`,{method:"POST",body:JSON.stringify({kind:"clipboard",text})});
|
||||
if(currentPaneRef.current===botId)setClipboardStatus("已貼上文字");
|
||||
}).catch(()=>setClipboardStatus("貼上失敗,請確認已接管電腦後重試"));
|
||||
}
|
||||
async function copySelection(){
|
||||
const botId=paneBotId;if(!botId)return;
|
||||
try{const result=await api<{text:string}>(`/api/computer/${botId}/input`,{method:"POST",body:JSON.stringify({kind:"copy"})});
|
||||
if(currentPaneRef.current!==botId)return;
|
||||
setDesktopClipboard(result.text);await navigator.clipboard.writeText(result.text);setClipboardStatus("已複製選取文字");
|
||||
}catch{setClipboardStatus("複製未同步;請按複製按鈕,或在遠端使用 Ctrl+Shift+C");}
|
||||
}
|
||||
async function pasteClipboard(){try{pasteText(await navigator.clipboard.readText())}catch{setClipboardOpen(true)}}
|
||||
async function copyClipboard(){try{await navigator.clipboard.writeText(desktopClipboard)}catch{setError(t("clipboardWriteBlocked"))}}
|
||||
async function inbox(bot:Bot,actionName:string,groupName?:string|null){await api(`/api/bots/${bot.id}/inbox`,{method:"POST",body:JSON.stringify({action:actionName,groupName})});await loadBots()}
|
||||
function openBot(bot:Bot){setMobileNav(false);setSessionMenuOpen(false);if(bot.unreadCount>0)void inbox(bot,"read");if(bot.id===activeId&&!activeRoomId){if(!activeSessionId){const stored=readSessionStore()[bot.id];if(stored)setActiveSessionId(stored);else void loadSessions()}return}setMessages([]);setActiveSessionId(null);setActiveRoomId(null);setBusyMembers([]);setActiveId(bot.id)}
|
||||
|
|
@ -174,13 +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()})}
|
||||
function openAccount(dialog:AccountDialog){setAccountOpen(false);setAccountDialog(dialog)}
|
||||
async function logout(){setAccountOpen(false);await api("/api/session",{method:"DELETE",body:"{}"}).catch(()=>{});setBots([]);setRooms([]);setMcpServers([]);setActiveId(null);setActiveRoomId(null);setAuthRequired(true)}
|
||||
// Re-mount noVNC after every status transition so a freshly booted Docker
|
||||
// display cannot remain stuck on the previous disconnected iframe.
|
||||
const startBoot=async()=>{setScreenUrl(null);setComputer(current=>({...current,state:"booting"}));await api(`/api/computer/${paneBot?.id||active?.id}/boot`,{method:"POST",body:"{}"})};
|
||||
const restartComputer=async()=>{setScreenUrl(null);setComputer(current=>({...current,state:"booting"}));await api(`/api/computer/${paneBot?.id||active?.id}/restart`,{method:"POST",body:"{}"})};
|
||||
const frame=screenUrl?<iframe key={`${screenUrl}:${computer.state}:${computer.botId}:${computer.controlHolder}`} className="desktop-frame" src={screenUrl} title={t("agentComputer")} allow="fullscreen; clipboard-read; clipboard-write"/>:<EmptyComputer state={computer.state}/>;
|
||||
async function changeComputer(operation:"boot"|"restart"){
|
||||
const botId=paneBotId;if(!botId)return;
|
||||
const previous=computer;setScreenUrl(null);setDesktopReady(false);
|
||||
setComputer(current=>({...current,state:operation==="boot"&¤t.state==="suspended"?"suspended":"booting"}));
|
||||
try{await api(`/api/computer/${botId}/${operation}`,{method:"POST",body:"{}",signal:AbortSignal.timeout(120_000)})}
|
||||
catch(error){
|
||||
const status=await api<ComputerStatus>(`/api/computer/${botId}/status`,{signal:AbortSignal.timeout(5_000)}).catch(()=>previous);
|
||||
if(currentPaneRef.current===botId)setComputer(status);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const startBoot=()=>changeComputer("boot");
|
||||
const restartComputer=()=>changeComputer("restart");
|
||||
const connecting=computer.state==="running"&&Boolean(screenUrl)&&!desktopReady;
|
||||
const overlayLabel=hudLabel(computer,connecting,handingOff);
|
||||
const frame=screenUrl?<iframe ref={desktopFrameRef} key={screenUrl} className="desktop-frame" src={screenUrl} title={t("agentComputer")} allow="fullscreen; clipboard-read; clipboard-write"/>:<EmptyComputer state={computer.state}/>;
|
||||
const hud=paneBot&&overlayLabel?<ComputerHud bot={paneBot} label={overlayLabel}/>:null;
|
||||
const statusMembers=workingMembers;
|
||||
const topTools=<nav className="top-tools" aria-label={t("workTools")}>
|
||||
<button type="button" className={`top-tool-button ${!rightCollapsed&&rightPart==="computer"?"active":""}`} title={t("computer")} aria-label={t("computer")} onClick={()=>openPane("computer")}><Computer/></button>
|
||||
<button type="button" className={`top-tool-button ${!rightCollapsed&&rightPart==="accounts"?"active":""}`} title={t("accounts")} aria-label={t("accounts")} onClick={()=>openPane("accounts")} disabled={!paneBot}><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg></button>
|
||||
<button type="button" className={`top-tool-button ${!rightCollapsed&&rightPart==="memory"?"active":""}`} title={t("memory")} aria-label={t("memory")} onClick={()=>openPane("memory")} disabled={!paneBot}><Brain/></button>
|
||||
<button type="button" className={`top-tool-button ${!rightCollapsed&&rightPart==="plugins"?"active":""}`} title={t("plugins")} aria-label={t("plugins")} onClick={()=>openPane("plugins")}><Plug/></button>
|
||||
<button type="button" className={`top-tool-button ${!rightCollapsed&&rightPart==="settings"?"active":""}`} title={active?t("botSettings"):t("settings")} aria-label={active?t("botSettings"):t("settings")} onClick={()=>active?openPane("settings"):setAccountDialog("settings")}><Settings/></button>
|
||||
|
|
@ -222,26 +297,30 @@ export function App(){
|
|||
</aside>
|
||||
|
||||
<main className="chat-panel">
|
||||
<header className="topbar"><button className="icon-button mobile-menu" onClick={()=>setMobileNav(v=>!v)}><UseAnimations animation={menu} size={18} strokeColor="#dfdfe2"/></button>{activeRoom?<><AvatarStack members={activeRoom.members} online thinkingIds={busyMembers.map(member=>member.id)}/><strong>{activeRoom.name}</strong><SessionMenu sessions={sessions} activeSessionId={activeSessionId} open={sessionMenuOpen} setOpen={setSessionMenuOpen} busy={busy} onSelect={selectSession} onCreate={createSession} onDelete={id=>{setSessionMenuOpen(false);setSessionToDelete(id)}} onClear={()=>{setSessionMenuOpen(false);setClearOpen(true)}}/><span className="grow"/>{topTools}</>:active?<><Avatar lookId={active.id} name={active.name} color={active.avatarColor} shape={active.avatarShape} active online/><strong>{active.name}</strong><SessionMenu sessions={sessions} activeSessionId={activeSessionId} open={sessionMenuOpen} setOpen={setSessionMenuOpen} busy={busy} onSelect={selectSession} onCreate={createSession} onDelete={id=>{setSessionMenuOpen(false);setSessionToDelete(id)}} onClear={()=>{setSessionMenuOpen(false);setClearOpen(true)}}/><span className="grow"/>{topTools}</>:<><strong>{t("chooseBot")}</strong><span className="grow"/>{topTools}</>}</header>
|
||||
<div className="messages">{(activeRoom||active)&&messages.length===0?<div className="welcome">{activeRoom?<AvatarStack members={activeRoom.members} size={56} online/>:<Avatar lookId={active!.id} name={active!.name} color={active!.avatarColor} shape={active!.avatarShape} active online size={64}/>}<h1>{activeRoom?t("startRoomDiscussion",{name:activeRoom.name}):t("startBotWork",{name:active!.name})}</h1><p>{activeRoom?t("roomWillReply",{names:activeRoom.members.map(member=>member.name).join("、")}):active!.description||t("botWelcome")}</p></div>:messages.map(message=>{const spoken=message.role!=="user"&&Boolean(activeRoom);const speakerName=message.speakerName||(spoken?paneBot?.name:undefined);const speakerShape=(message.speakerShape||paneBot?.avatarShape||"blob") as AvatarShape;const files=messageFiles(message.blocks);const hideBody=isAutoAttachCaption(message.body,files);return <div key={message.id} className={`message ${message.role} ${spoken?"spoken":""} ${files.length?"with-files":""}`}>{spoken&&<span className="msg-avatar"><Avatar lookId={message.speakerBotId||paneBot?.id||undefined} name={speakerName||"agent"} color={message.speakerColor||undefined} shape={speakerShape} size={22}/></span>}{spoken&&<b className="speaker" style={{color:message.speakerColor||undefined}}>{speakerName}</b>}{files.length>0&&<div className="msg-attachments">{files.map(file=><FileCard key={file.name} file={file}/>)}</div>}{!hideBody&&<span className="message-body">{message.body}</span>}{!hideBody&&message.body.trim()&&<button type="button" className={`remember-msg ${remembered[message.id]?"saved":""}`} title={remembered[message.id]?t("remembered"):t("remember")} disabled={!!remembered[message.id]} onClick={()=>void rememberMessage(message)}><UseAnimations animation={bookmark} size={14} strokeColor="var(--muted)"/></button>}</div>})}{workingMembers.map(member=><div className="thinking-row" key={member.id}><Avatar lookId={member.id} name={member.name} color={member.avatarColor} shape={member.avatarShape} thinking online/><span className="working-label">{computer.busyStep&&member.id===computer.botId?t("workingStep",{name:member.name,step:computer.busyStep}):t("working",{name:member.name})}</span></div>)}{pausedForUser&&paneBot&&<div className="pause-banner" role="status"><span>{computer.controlHolder==="user"?t("pausedUserControl",{name:paneBot.name}):t("pausedNeedsUser",{name:paneBot.name})}{(computer.queuedRuns||0)>0&&<> {t("queuedMessages",{count:computer.queuedRuns||0})}</>}</span>{computer.controlHolder==="user"?<button type="button" className="primary" disabled={busy} onClick={()=>void action(()=>api(`/api/computer/${paneBot.id}/release`,{method:"POST",body:"{}"}))}>{t("releaseAndContinue")}</button>:<button type="button" className="primary" disabled={busy} onClick={()=>void action(()=>api(`/api/computer/${paneBot.id}/takeover`,{method:"POST",body:"{}"}))}>{t("takeOverNow")}</button>}</div>}{teaching&&active&&<div className="teach-banner recording" role="status"><span><i className="record-dot live"/>{t("teachingLive",{goal:teaching.goal})}<small>{t("teachingHint")}{teaching.eventCount>0&&<> · {t("teachingCaptured",{count:teaching.eventCount})}</>}</small></span><button type="button" className="primary" disabled={busy} onClick={()=>void stopTeaching()}>{t("finishDemo")}</button><button type="button" className="outline" disabled={busy} onClick={()=>void cancelTeaching()}>{t("cancel")}</button></div>}{drafting&&<div className="teach-banner" role="status"><UseAnimations animation={loading} size={18} wrapperStyle={{display:"inline-block",verticalAlign:"middle"}}/><span>{t("distilling",{goal:drafting.goal})}</span></div>}{skillDraft&&active&&<SkillDraftCard key={skillDraft.id} skill={skillDraft} busy={busy} onSave={(name,playbook)=>void saveSkill(skillDraft,name,playbook)} onTest={(name,playbook)=>void testSkill(skillDraft,name,playbook)} onDiscard={()=>void discardSkill(skillDraft)} onEdit={()=>setEditingSkillId(skillDraft.id)} onExport={(name,playbook)=>downloadSkill(name,skillDraft.goal,playbook)}/>}<div ref={messageEndRef} aria-hidden="true"/></div>
|
||||
<header className="topbar"><button className="icon-button mobile-menu" onClick={()=>setMobileNav(v=>!v)}><UseAnimations animation={menu} size={18} strokeColor="#dfdfe2"/></button>{activeRoom?<><AvatarStack members={activeRoom.members} size={32} online thinkingIds={busyMembers.map(member=>member.id)}/><strong>{activeRoom.name}</strong><SessionMenu sessions={sessions} activeSessionId={activeSessionId} open={sessionMenuOpen} setOpen={setSessionMenuOpen} busy={busy} onSelect={selectSession} onCreate={createSession} onDelete={id=>{setSessionMenuOpen(false);setSessionToDelete(id)}} onClear={()=>{setSessionMenuOpen(false);setClearOpen(true)}}/><span className="grow"/>{topTools}</>:active?<><Avatar lookId={active.id} name={active.name} color={active.avatarColor} shape={active.avatarShape} active online/><strong>{active.name}</strong><SessionMenu sessions={sessions} activeSessionId={activeSessionId} open={sessionMenuOpen} setOpen={setSessionMenuOpen} busy={busy} onSelect={selectSession} onCreate={createSession} onDelete={id=>{setSessionMenuOpen(false);setSessionToDelete(id)}} onClear={()=>{setSessionMenuOpen(false);setClearOpen(true)}}/><span className="grow"/>{topTools}</>:<><strong>{t("chooseBot")}</strong><span className="grow"/>{topTools}</>}</header>
|
||||
<div className="messages">{(activeRoom||active)&&messages.length===0?<div className="welcome">{activeRoom?<AvatarStack members={activeRoom.members} size={56} online/>:<Avatar lookId={active!.id} name={active!.name} color={active!.avatarColor} shape={active!.avatarShape} active online size={64}/>}<h1>{activeRoom?t("startRoomDiscussion",{name:activeRoom.name}):t("startBotWork",{name:active!.name})}</h1><p>{activeRoom?t("roomWillReply",{names:activeRoom.members.map(member=>member.name).join("、")}):active!.description||t("botWelcome")}</p></div>:messages.map(message=>{const spoken=message.role!=="user"&&Boolean(activeRoom);const speakerName=message.speakerName||(spoken?paneBot?.name:undefined);const speakerShape=(message.speakerShape||paneBot?.avatarShape||"blob") as AvatarShape;const files=messageFiles(message.blocks);const chips=chipBlocks(message.blocks);const hideBody=isAutoAttachCaption(message.body,files);return <div key={message.id} className={`message ${message.role} ${spoken?"spoken":""} ${files.length?"with-files":""}`}>{spoken&&<span className="msg-avatar"><Avatar lookId={message.speakerBotId||paneBot?.id||undefined} name={speakerName||"agent"} color={message.speakerColor||undefined} shape={speakerShape} size={22}/></span>}{spoken&&<b className="speaker" style={{color:message.speakerColor||undefined}}>{speakerName}</b>}{files.length>0&&<div className="msg-attachments">{files.map(file=><FileCard key={file.name} file={file}/>)}</div>}{chips.map((chip,index)=>chip.kind==="login"?<div className="login-chip" key={`${message.id}-login-${index}`}><div className="login-label">{t("loginNeedsYou")}</div><div className="login-site">{chip.site||message.body}</div>{chip.why?<div className="login-why">{t("loginWhy",{why:chip.why})}</div>:null}<button type="button" className="primary" onClick={()=>void openLoginScreen()}>{t("loginOpenScreen")}</button></div>:chip.kind==="schedule"?<div className="sched-chip" key={`${message.id}-sched-${index}`}><div className="sched-label">{t("scheduleChip")}</div><strong>{chip.name}</strong><small>{chip.human}</small></div>:<div className="sched-chip" key={`${message.id}-run-${index}`}><div className="sched-label">{t("scheduleRunChip")}</div><strong>{chip.name}</strong><small>{chip.human}</small></div>)}{!hideBody&&(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>}
|
||||
{otherSessionBusy&&<div className="queue-hint">{t("anotherConversationQueued")}</div>}
|
||||
<div className={`composer-dock ${statusMembers.length?"has-status":""}`}>
|
||||
{statusMembers.map(member=>{const step=computer.busyStep&&member.id===computer.botId?computer.busyStep:null;const transition=isTransitionStep(step);const label=t("working",{name:member.name});return <div className="thinking-row" key={member.id}><Avatar lookId={member.id} name={member.name} color={member.avatarColor} shape={member.avatarShape} thinking online/><span className="working-copy"><span className="working-label">{label}</span>{!transition&&step?<span className="working-step">{step}</span>:null}</span></div>})}
|
||||
<form className={`composer ${pendingFiles.length?"has-files":""}`} onSubmit={send} onDragOver={event=>{event.preventDefault()}} onDrop={event=>{event.preventDefault();if(event.dataTransfer.files.length)addPendingFiles(event.dataTransfer.files)}}><div className="plus-menu-wrap" onClick={event=>event.stopPropagation()}><button type="button" className={`composer-plus ${plusOpen?"open":""}`} disabled={!activeSessionId} title={t("moreActions")} aria-label={t("moreActions")} aria-haspopup="menu" aria-expanded={plusOpen} onClick={()=>setPlusOpen(v=>!v)}><Plus/></button>{plusOpen&&<div className="plus-menu" role="menu"><button type="button" role="menuitem" disabled={!activeSessionId||Boolean(teaching)} title={t("attachFileHint")} onClick={()=>{setPlusOpen(false);attachRef.current?.click()}}><Paperclip/>{t("attachFile")}</button><button type="button" role="menuitem" disabled={!active||Boolean(teaching)||Boolean(drafting)} title={active?t("teachTaskHint"):t("teachNeedsBot")} onClick={()=>{setPlusOpen(false);setTeachOpen(true)}}><i className="record-dot"/>{t("teachTask")}</button><button type="button" role="menuitem" disabled={!active} title={t("importSkillHint")} onClick={()=>{setPlusOpen(false);importRef.current?.click()}}><Upload/>{t("importSkill")}</button>{savedSkills.length>0&&<><hr/><div className="plus-menu-skills"><small className="plus-menu-label">{t("taughtSkills")}{savedSkills.length>5?` · ${savedSkills.length}`:""}</small>{savedSkills.length>=6&&<input className="plus-menu-search" value={skillQuery} onChange={e=>setSkillQuery(e.target.value)} placeholder={t("searchSkills")} aria-label={t("searchSkills")} onClick={e=>e.stopPropagation()}/>}<div className="plus-menu-skill-list">{listedSkills.map(skill=><div className="plus-menu-skill" key={skill.id}><button type="button" role="menuitem" title={t("runSkillNamed",{name:skill.name})+(skill.playbook.whenToUse?`\n${skill.playbook.whenToUse}`:"")} onClick={()=>runSkill(skill)}><Sparkle/>{skill.name}</button><button type="button" className="skill-edit" title={t("exportSkillHint")} aria-label={t("exportSkill")} onClick={()=>downloadSkill(skill.name,skill.goal,skill.playbook)}><Download/></button><button type="button" className="skill-edit" title={t("editSkill")} aria-label={t("editSkill")} onClick={()=>{setPlusOpen(false);setEditingSkillId(skill.id)}}><Pencil/></button></div>)}{listedSkills.length===0&&<small className="plus-menu-empty">{t("noMatchingSkills")}</small>}</div></div></>}</div>}</div><input ref={importRef} className="skill-import-input" type="file" accept="application/json,.json" tabIndex={-1} aria-hidden="true" onChange={event=>{const file=event.target.files?.[0];event.currentTarget.value="";if(file)void importSkillFile(file)}}/><input ref={attachRef} className="skill-import-input attach-input" type="file" multiple accept={ATTACH_ACCEPT} tabIndex={-1} aria-hidden="true" onChange={event=>{const files=[...event.target.files||[]];event.currentTarget.value="";if(files.length)addPendingFiles(files)}}/>{pendingFiles.length>0&&<div className="composer-files">{pendingFiles.map(item=><FileCard key={item.id} file={{name:item.file.name,size:item.file.size}} preview={item.preview} onRemove={()=>removePendingFile(item.id)}/>)}</div>}<textarea rows={1} value={draft} onChange={e=>setDraft(e.target.value)} onKeyDown={composerKeyDown} onPaste={event=>{const files=event.clipboardData?.files;if(files&&files.length){event.preventDefault();addPendingFiles(files)}}} placeholder={teaching?t("teachingComposerHint"):activeSessionId&&chatName?t("messageTo",{name:chatName}):t("chooseConversationFirst")} disabled={!activeSessionId||Boolean(teaching)}/>{sessionBusy?<button type="button" className="send stop-send" title={t("stopConversation")} onClick={()=>void action(stopChat)}><Square/></button>:<button className="send" disabled={!activeSessionId||(!draft.trim()&&pendingFiles.length===0)||busy}><UseAnimations animation={arrowUp} size={20} strokeColor="#1b1b1c"/></button>}</form>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{!rightCollapsed&&<div className="side-card-backdrop" onClick={()=>setRightCollapsed(true)}/>}
|
||||
{!rightCollapsed&&<aside className="side-card">
|
||||
<>
|
||||
<header className="side-card-head">
|
||||
<span className="side-card-title">{rightPart==="computer"?t("computer"):rightPart==="memory"?t("memory"):rightPart==="plugins"?t("plugins"):t("settings")}</span>
|
||||
<span className="side-card-title">{rightPart==="computer"?t("computer"):rightPart==="memory"?t("memory"):rightPart==="plugins"?t("plugins"):rightPart==="accounts"?t("accounts"):t("settings")}</span>
|
||||
<button type="button" className="icon-button" title={t("collapseSidebar")} onClick={()=>setRightCollapsed(true)}><ChevronsRight/></button>
|
||||
</header>
|
||||
<div className="side-card-body">
|
||||
<div className={`side-part computer-part ${rightPart==="computer"?"":"hidden-part"}`}>
|
||||
<div className="computer-status-row">{paneBot?<span>{t("botComputer",{name:paneBot.name})}</span>:<span>{t("computer")}</span>}{computer.state==="booting"?<UseAnimations animation={loading} size={17} wrapperStyle={{display:"inline-block",verticalAlign:"middle"}}/>:<i className={`state-dot ${computer.state}`}/>}<small>{stateLabel(computer.state)}</small></div>
|
||||
<div className="preview">{computerOpen?<EmptyComputer state={computer.state}/>:frame}</div>
|
||||
{paneBot&&<><div className="computer-caption"><span>{t("dedicatedScreen")}</span><button className="outline" onClick={()=>setComputerOpen(true)}>{t("enlarge")}</button></div>{teaching?<div className="control-bar"><div className="computer-actions"><button className="primary" disabled={busy} onClick={()=>void stopTeaching()}>{t("finishDemo")}</button><button className="outline" disabled={busy} onClick={()=>void cancelTeaching()}>{t("cancel")}</button></div></div>:<ControlBar active={paneBot} computer={computer} busy={busy} action={action} paste={pasteClipboard} copy={copyClipboard} sessionId={activeSessionId} onBoot={startBoot} onRestart={restartComputer}/>}</>}
|
||||
<div className="preview">{computerOpen?<EmptyComputer state={computer.state}/>:frame}{!computerOpen&&hud}</div>
|
||||
{paneBot&&<><div className="computer-caption"><span>{t("dedicatedScreen")}</span><button className="outline" onClick={()=>setComputerOpen(true)}>{t("enlarge")}</button></div>{teaching?<div className="control-bar"><div className="computer-actions"><button className="primary" disabled={busy} onClick={()=>void stopTeaching()}>{t("finishDemo")}</button><button className="outline" disabled={busy} onClick={()=>void cancelTeaching()}>{t("cancel")}</button></div></div>:<ControlBar active={paneBot} computer={computer} busy={busy} action={action} paste={pasteClipboard} copy={copyClipboard} sessionId={activeSessionId} onBoot={startBoot} onRestart={restartComputer}/>}<p className="computer-login-hint">{t("computerLoginHint")}</p><p className="clipboard-status" role="status">{clipboardStatus}</p>{scheduleDraft?<ScheduleEditor draft={scheduleDraft} timezone={scheduleDraft.timezone||Intl.DateTimeFormat().resolvedOptions().timeZone||"Asia/Taipei"} saving={scheduleSaving} error={scheduleError} onChange={next=>setScheduleDraft(current=>current?{...current,...next}:next)} onBack={()=>setScheduleDraft(null)} onSave={()=>void saveScheduleDraft()} onDelete={scheduleDraft.id?()=>void action(async()=>{await api(`/api/schedules/${scheduleDraft.id}`,{method:"DELETE"});setScheduleDraft(null);await reloadSchedules()}):undefined}/>:<ScheduleList items={schedules} runningId={runningScheduleId} onCreate={()=>setScheduleDraft({name:"",instructions:"",enabled:true,preset:defaultCronPreset()})} onOpen={item=>setScheduleDraft({id:item.id,timezone:item.timezone,threadId:item.threadId,name:item.name,instructions:item.instructions,enabled:item.enabled,preset:presetFromCron(item.cron)})} onRun={item=>void action(async()=>{setRunningScheduleId(item.id);try{await api(`/api/schedules/${item.id}/run`,{method:"POST",body:"{}"});await loadSessions()}finally{setRunningScheduleId(null)}})}/>}</>}
|
||||
</div>
|
||||
{rightPart==="accounts"&&paneBot&&<VaultPane bot={paneBot}/>}
|
||||
{rightPart==="memory"&&paneBot&&<MemoryPane bot={paneBot} changed={loadBots}/>}
|
||||
{rightPart==="plugins"&&<McpPane servers={mcpServers} reload={loadMcp}/>}
|
||||
{rightPart==="settings"&&active&&<BotSettingsPane bot={active} look={looks[active.id]||DEFAULT_LOOK} onLook={look=>{writeAvatarLook(active.id,look);setLooks(readAvatarLooks())}} saved={loadBots} onDelete={()=>setDeleteOpen(true)}/>}
|
||||
|
|
@ -249,10 +328,10 @@ export function App(){
|
|||
</>
|
||||
</aside>}
|
||||
|
||||
{computerOpen&&paneBot&&<div className="computer-overlay"><header><div><Avatar lookId={paneBot.id} name={paneBot.name} color={paneBot.avatarColor} shape={paneBot.avatarShape} active online/><strong>{modeLabel(computer.mode)}</strong><span className={`control-badge ${teaching?"teaching":""}`}>{teaching?t("teachingBadge"):computer.controlHolder==="user"?t("userControlling"):computer.busyBotName?t("aiReadOnly"):t("readOnly")}</span></div><div>{teaching?<div className="computer-actions"><button className="primary" disabled={busy} onClick={()=>void stopTeaching()}>{t("finishDemo")}</button><button className="outline" disabled={busy} onClick={()=>void cancelTeaching()}>{t("cancel")}</button></div>:<ControlButtons computer={computer} busy={busy} action={action} active={paneBot} sessionId={activeSessionId} onBoot={startBoot} onRestart={restartComputer}/>}<button className="icon-button" onClick={pasteClipboard} disabled={computer.controlHolder!=="user"} title={t("pasteClipboard")}><ClipboardPaste/></button><button className="icon-button" onClick={copyClipboard} disabled={computer.controlHolder!=="user"||!desktopClipboard} title={t("copyDesktopClipboard")}><UseAnimations animation={copy} size={18} strokeColor="#dfdfe2"/></button><button className="icon-button" title={t("moreActions")}><Ellipsis/></button><button className="icon-button" onClick={()=>setComputerOpen(false)}><X/></button></div></header><div className="overlay-screen">{frame}</div>{error&&<div className="overlay-error">{error}</div>}</div>}
|
||||
{computerOpen&&paneBot&&<div className="computer-overlay"><header><div><Avatar lookId={paneBot.id} name={paneBot.name} color={paneBot.avatarColor} shape={paneBot.avatarShape} active online/><strong>{modeLabel(computer.mode)}</strong><span className={`control-badge ${teaching?"teaching":""}`}>{teaching?t("teachingBadge"):computer.controlHolder==="user"?t("userControlling"):computer.usingComputer?t("aiReadOnly"):t("readOnly")}</span></div><div>{teaching?<div className="computer-actions"><button className="primary" disabled={busy} onClick={()=>void stopTeaching()}>{t("finishDemo")}</button><button className="outline" disabled={busy} onClick={()=>void cancelTeaching()}>{t("cancel")}</button></div>:<ControlButtons computer={computer} busy={busy} action={action} active={paneBot} sessionId={activeSessionId} onBoot={startBoot} onRestart={restartComputer}/>}<button className="icon-button" onClick={pasteClipboard} disabled={computer.controlHolder!=="user"} title={t("pasteClipboard")}><ClipboardPaste/></button><button className="icon-button" onClick={copyClipboard} disabled={computer.controlHolder!=="user"||!desktopClipboard} title={t("copyDesktopClipboard")}><UseAnimations animation={copy} size={18} strokeColor="#dfdfe2"/></button><button className="icon-button" title={t("moreActions")}><Ellipsis/></button><button className="icon-button" onClick={()=>setComputerOpen(false)}><X/></button></div></header><div className="overlay-screen"><div className="overlay-desktop">{frame}{hud}</div></div>{error&&<div className="overlay-error">{error}</div>}</div>}
|
||||
{teachOpen&&active&&<TeachDialog bot={active} busy={busy} close={()=>setTeachOpen(false)} start={goal=>void startTeaching(goal)}/>}
|
||||
{editingSkill&&<SkillEditDialog key={editingSkill.id} skill={editingSkill} busy={busy} close={()=>setEditingSkillId(null)} save={(name,playbook)=>void updateSkill(editingSkill,name,playbook)} test={(name,playbook)=>void testSkill(editingSkill,name,playbook)} remove={()=>void deleteSkill(editingSkill)} exportFile={(name,playbook)=>downloadSkill(name,editingSkill.goal,playbook)}/>}
|
||||
{clipboardOpen&&<ClipboardDialog close={()=>setClipboardOpen(false)} paste={text=>{document.querySelectorAll<HTMLIFrameElement>(".desktop-frame").forEach(frame=>frame.contentWindow?.postMessage({type:"lazyboy-host-clipboard",text},location.origin));setClipboardOpen(false)}}/>}
|
||||
{clipboardOpen&&<ClipboardDialog close={()=>setClipboardOpen(false)} paste={text=>{pasteText(text);setClipboardOpen(false)}}/>}
|
||||
|
||||
{createOpen&&<CreateDialog close={()=>setCreateOpen(false)} created={async bot=>{setCreateOpen(false);await loadBots();setActiveId(bot.id)}}/>}
|
||||
{groupOpen&&<CreateGroupDialog bots={bots} close={()=>setGroupOpen(false)} created={async room=>{setGroupOpen(false);setRooms(current=>[room,...current.filter(item=>item.id!==room.id)]);setActiveId(null);setActiveSessionId(null);setBusyMembers([]);setMessages([]);setRightPart("computer");setActiveRoomId(room.id);await loadBots()}}/>}
|
||||
|
|
@ -326,6 +405,30 @@ function BotSettingsPane({bot,look,onLook,saved,onDelete}:{bot:Bot;look:AvatarLo
|
|||
</form>
|
||||
}
|
||||
|
||||
function VaultPane({bot}:{bot:Bot}){
|
||||
type Account={id:string;site:string;host:string;username:string;notes:string};
|
||||
const[items,setItems]=useState<Account[]>([]);const[busy,setBusy]=useState(false);const[error,setError]=useState("");
|
||||
const[form,setForm]=useState({site:"",host:"",username:"",password:"",notes:""});
|
||||
const load=useCallback(()=>api<Account[]>(`/api/bots/${bot.id}/accounts`).then(setItems),[bot.id]);
|
||||
useEffect(()=>{load().catch(e=>setError(e instanceof Error?e.message:t("loadFailed")))},[load]);
|
||||
async function run(work:()=>Promise<unknown>){setBusy(true);setError("");try{await work();await load()}catch(e){setError(e instanceof Error?e.message:t("operationFailed"))}finally{setBusy(false)}}
|
||||
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>}){
|
||||
const[items,setItems]=useState<MemoryItem[]>([]);const[draft,setDraft]=useState("");const[query,setQuery]=useState("");
|
||||
const[enabled,setEnabled]=useState(bot.memoryEnabled);const[busy,setBusy]=useState(false);const[error,setError]=useState("");
|
||||
|
|
@ -460,8 +563,11 @@ function McpCustomForm({busy,error,onBack,onSubmit}:{busy:boolean;error:string;o
|
|||
</form>
|
||||
}
|
||||
|
||||
function EmptyComputer({state}:{state:ComputerStatus["state"]}){const loading=state==="booting";return <div className="empty-computer">{loading?<UseAnimations animation={loading2} size={38} wrapperStyle={{display:"block"}}/>:<Computer/>}<strong>{stateLabel(state)}</strong><span>{loading?t("preparingDesktop"):t("computerPreviewHint")}</span></div>}
|
||||
function ControlButtons({computer,busy,action,active,sessionId,onBoot,onRestart}:{computer:ComputerStatus;busy:boolean;action:(w:()=>Promise<unknown>)=>Promise<void>;active:Bot;sessionId?:string|null;onBoot?:()=>Promise<void>;onRestart?:()=>Promise<void>}){const working=Boolean(computer.busyBotName);const restart=<button className="outline restart-computer" disabled={busy} title={t("restartDocker")} onClick={()=>void action(onRestart||(()=>api(`/api/computer/${active.id}/restart`,{method:"POST",body:"{}"})))}><RefreshCw/> {t("restartDocker")}</button>;if(computer.state!=="running")return <div className="computer-actions"><button className="primary" disabled={busy||computer.state==="booting"} onClick={()=>void action(onBoot||(()=>api(`/api/computer/${active.id}/boot`,{method:"POST",body:"{}"})))}>{(busy||computer.state==="booting")&&<UseAnimations animation={loading} size={17} wrapperStyle={{display:"inline-block",verticalAlign:"middle",marginRight:7}}/>}{computer.state==="booting"?t("bootingProgress"):t("openComputer")}</button>{(computer.state==="booting"||computer.state==="error")&&restart}</div>;if(working)return <div className="computer-actions"><button className="primary" disabled={busy} onClick={()=>action(()=>api(`/api/computer/${active.id}/takeover`,{method:"POST",body:"{}"}))}>{t("takeOverNow")}</button><button className="outline" disabled={busy} onClick={()=>action(async()=>{if(sessionId)await api(`/api/sessions/${sessionId}/stop`,{method:"POST",body:"{}"});else await api(`/api/bots/${active.id}/stop`,{method:"POST",body:"{}"});})}><Square/>{t("stopTask")}</button>{restart}</div>;if(computer.controlHolder==="user")return <div className="computer-actions"><button className="outline" disabled={busy} onClick={()=>action(()=>api(`/api/computer/${active.id}/release`,{method:"POST",body:"{}"}))}>{t("releaseControl")}</button>{restart}</div>;return <div className="computer-actions"><button className="primary" disabled={busy} onClick={()=>action(()=>api(`/api/computer/${active.id}/takeover`,{method:"POST",body:"{}"}))}>{t("takeControl")}</button>{restart}</div>}
|
||||
function ComputerHud({bot,label}:{bot:{id:string;name:string;avatarColor?:string;avatarShape?:AvatarShape};label:string}){
|
||||
return <div className="computer-hud" role="status" aria-label={`${bot.name}:${label}`}><span className="computer-signal" aria-hidden="true"><span className="computer-signal-face"><i/><i/></span></span><span className="computer-hud-label">{label}</span></div>
|
||||
}
|
||||
function EmptyComputer({state}:{state:ComputerStatus["state"]}){if(state==="booting"||state==="suspended")return <div className="empty-computer is-waiting" aria-hidden="true"/>;return <div className="empty-computer"><Computer/><strong>{stateLabel(state)}</strong><span>{t("computerPreviewHint")}</span></div>}
|
||||
function ControlButtons({computer,busy,action,active,sessionId,onBoot,onRestart}:{computer:ComputerStatus;busy:boolean;action:(w:()=>Promise<unknown>)=>Promise<void>;active:Bot;sessionId?:string|null;onBoot?:()=>Promise<void>;onRestart?:()=>Promise<void>}){const working=Boolean(computer.usingComputer);const restart=<button className="outline restart-computer" disabled={busy} title={t("restartDocker")} onClick={()=>void action(onRestart||(()=>api(`/api/computer/${active.id}/restart`,{method:"POST",body:"{}"})))}><RefreshCw/> {t("restartDocker")}</button>;if(computer.state!=="running")return <div className="computer-actions"><button className="primary" disabled={busy||computer.state==="booting"} onClick={()=>void action(onBoot||(()=>api(`/api/computer/${active.id}/boot`,{method:"POST",body:"{}"})))}>{(busy||computer.state==="booting")&&<UseAnimations animation={loading} size={17} wrapperStyle={{display:"inline-block",verticalAlign:"middle",marginRight:7}}/>}{computer.state==="booting"?t("bootingProgress"):t("openComputer")}</button>{(computer.state==="booting"||computer.state==="error")&&restart}</div>;if(working)return <div className="computer-actions"><button className="primary" disabled={busy} onClick={()=>action(()=>api(`/api/computer/${active.id}/takeover`,{method:"POST",body:"{}"}))}>{t("takeOverNow")}</button><button className="outline" disabled={busy} onClick={()=>action(async()=>{if(sessionId)await api(`/api/sessions/${sessionId}/stop`,{method:"POST",body:"{}"});else await api(`/api/bots/${active.id}/stop`,{method:"POST",body:"{}"});})}><Square/>{t("stopTask")}</button>{restart}</div>;if(computer.controlHolder==="user")return <div className="computer-actions"><button className="outline" disabled={busy} onClick={()=>action(()=>api(`/api/computer/${active.id}/release`,{method:"POST",body:"{}"}))}>{t("releaseControl")}</button>{restart}</div>;return <div className="computer-actions"><button className="primary" disabled={busy} onClick={()=>action(()=>api(`/api/computer/${active.id}/takeover`,{method:"POST",body:"{}"}))}>{t("takeControl")}</button>{restart}</div>}
|
||||
function ControlBar(props:{active:Bot;computer:ComputerStatus;busy:boolean;action:(w:()=>Promise<unknown>)=>Promise<void>;paste:()=>void;copy:()=>void;sessionId?:string|null;onBoot?:()=>Promise<void>;onRestart?:()=>Promise<void>}){const interactive=props.computer.controlHolder==="user";return <div className="control-bar"><ControlButtons {...props}/><button className="icon-button" disabled={!interactive} onClick={props.paste}><ClipboardPaste/></button><button className="icon-button" disabled={!interactive} onClick={props.copy}><UseAnimations animation={copy} size={18} strokeColor="#dfdfe2"/></button></div>}
|
||||
function TeachDialog({bot,busy,close,start}:{bot:Bot;busy:boolean;close:()=>void;start:(goal:string)=>void}){
|
||||
const [goal,setGoal]=useState("");
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import UseAnimations from "react-useanimations";
|
||||
import UseAnimations from "./use-animations";
|
||||
import type { Animation } from "react-useanimations/utils";
|
||||
import activity from "react-useanimations/lib/activity";
|
||||
import archive from "react-useanimations/lib/archive";
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
@ -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)}
|
||||
|
|
@ -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)}
|
||||
|
|
@ -13,6 +13,7 @@ export const zhTW = {
|
|||
openOnPhone: "在手機開啟", settings: "設定", about: "關於", helpCenter: "說明中心", sendFeedback: "傳送意見回饋", logout: "登出", workspaceMenu: "工作區選單",
|
||||
chooseBot: "選擇一個機器人", startRoomDiscussion: "和 {name} 開始討論", startBotWork: "和 {name} 開始工作", roomWillReply: "{names} 會一起回覆。",
|
||||
botWelcome: "傳送訊息,讓它在自己的電腦上完成任務。", remembered: "已記住", remember: "記住", working: "{name} 正在工作…",
|
||||
copyCode: "複製程式碼", copiedCode: "已複製", copyMessage: "複製", copiedMessage: "已複製",
|
||||
anotherConversationQueued: "另一則對話正在執行,這則會排隊。",
|
||||
workingStep: "{name} 正在工作… {step}", queuedMessages: "還有 {count} 則訊息排隊中",
|
||||
pausedUserControl: "你正在操控畫面,{name} 已暫停。完成後按「釋放控制」,它會從目前畫面接著做(排隊中的訊息也會一起處理)。",
|
||||
|
|
@ -63,6 +64,7 @@ export const zhTW = {
|
|||
mcpConnectNamed: "接入 {name}", mcpKeyHint: "這個 MCP 需要憑證才能連。",
|
||||
noMcp: "還沒有 MCP。點上面的「選擇 MCP」從市集接入。", toolsCount: "{count} 個工具", disabled: "已關閉", disconnected: "未連線", noTools: "沒有可用工具", reconnect: "重新連線", disable: "停用", enable: "啟用",
|
||||
preparingDesktop: "正在準備 Agent 的獨立桌面…", computerPreviewHint: "開啟電腦後,畫面會顯示在這裡。", bootingProgress: "啟動中…", restartDocker: "重啟 Docker", stopAndTakeOver: "停止並接管",
|
||||
hudBooting: "電腦啟動中…", hudWaking: "喚醒中…", hudConnecting: "連線中…", hudHandoff: "換手中…",
|
||||
pasteToRemoteComputer: "貼到遠端電腦", pasteRemoteHelp: "把外面的文字貼在這裡,再送進 VNC。這個方式在區網 HTTP 也能使用。", pasteTextPlaceholder: "在此貼上文字…", pasteIntoVnc: "貼入 VNC",
|
||||
botNamePlaceholder: "例如:研究助理", sharedComputerHint: "與其他機器人共用環境", privateComputerHint: "全新的獨立 Docker", create: "建立",
|
||||
groupDescription: "拉進群組會開一個對話,選中的 Agent 都會在裡面發言。", groupName: "群組名稱", groupNamePlaceholder: "例如:產品研究", chooseBots: "選擇機器人(至少兩位)", creating: "建立中…", createGroup: "建立群組",
|
||||
|
|
@ -96,4 +98,19 @@ export const zhTW = {
|
|||
agentComputer: "Agent 電腦", url: "URL", stdio: "stdio", http: "HTTP", sse: "SSE",
|
||||
argumentsPlaceholder: "-y @modelcontextprotocol/server-github", environmentVariablesPlaceholder: "GITHUB_TOKEN=…",
|
||||
urlPlaceholder: "https://mcp.example.com/mcp", headersPlaceholder: "Authorization=Bearer …",
|
||||
back: "返回", accounts: "帳號", accountsHelp: "存在這台機器人自己的保險箱。密碼只在這裡填,不會進聊天、也不會給模型看。排程碰到登入牆時會自動填表。",
|
||||
accountSite: "站名", accountSitePlaceholder: "例如:Gmail", accountHost: "網址或網域", accountHostPlaceholder: "mail.google.com",
|
||||
accountUsername: "帳號", accountPassword: "密碼", accountPasswordKeep: "留空表示不改密碼",
|
||||
accountNotes: "備註", 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;
|
||||
|
|
|
|||
|
|
@ -4,5 +4,10 @@ import "blobatar/motion.css";
|
|||
import "blobatar/gaze.css";
|
||||
import "./styles.css";
|
||||
import "./refinements.css";
|
||||
import "./avatar.css";
|
||||
import "./chat.css";
|
||||
import "./computer.css";
|
||||
import "./schedule.css";
|
||||
import "./responsive.css";
|
||||
import { App } from "./App";
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(<App />);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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("間隔必須為 1–365 的整數");
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ export interface Message { id:string; sessionId?:string; seq?:number; role:strin
|
|||
export interface MessageFile { kind:"image"|"file"; name:string; mimeType?:string; size?:number }
|
||||
export interface RoomMember { id:string; name:string; avatarColor:string; avatarShape:AvatarShape }
|
||||
export interface Room { id:string; name:string; members:RoomMember[]; lastMessageAt:string|null; lastPreview:string|null; unreadCount:number }
|
||||
export interface ComputerStatus { botId:string; mode:ComputerMode; state:ComputerState; controlHolder:"none"|"bot"|"user"; takeoverRequested:boolean; busyBotName:string|null; busySessionId:string|null; busyRunId:string|null; busyStep?:string|null; waitingRunId?:string|null; waitingSessionId?:string|null; queuedRuns?:number; display:string|null; profileMode:string; screenAvailable:boolean }
|
||||
export interface ComputerStatus { botId:string; mode:ComputerMode; state:ComputerState; controlHolder:"none"|"bot"|"user"; takeoverRequested:boolean; busyBotName:string|null; busySessionId:string|null; busyRunId:string|null; busyStep?:string|null; usingComputer?:boolean; waitingRunId?:string|null; waitingSessionId?:string|null; queuedRuns?:number; display:string|null; profileMode:string; screenAvailable:boolean }
|
||||
export interface PlaybookStep { do:string; expect?:string; note?:string }
|
||||
export interface PlaybookInput { name:string; description?:string; example?:string }
|
||||
export interface Playbook { name?:string; whenToUse?:string; intent?:string; inputs?:PlaybookInput[]; preconditions?:string[]; steps?:(PlaybookStep|string)[]; howToCheck?:string; whatToReturn?:string; cautions?:string[] }
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -12,16 +12,7 @@
|
|||
overflow: hidden;
|
||||
}
|
||||
#status {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
top: 8px;
|
||||
z-index: 2;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
color: #fff;
|
||||
font: 12px/1.3 ui-sans-serif, system-ui, sans-serif;
|
||||
pointer-events: none;
|
||||
display: none;
|
||||
}
|
||||
#screen canvas { cursor: default; }
|
||||
</style>
|
||||
|
|
@ -60,17 +51,7 @@
|
|||
|
||||
function pasteIntoDesktop(text) {
|
||||
if (!rfb || rfb.viewOnly || !text) return;
|
||||
try { rfb.clipboardPasteFrom(text); } catch (_) {}
|
||||
// x11vnc applies ClientCutText asynchronously. Give X11 a moment before
|
||||
// sending Ctrl+V so pastes are reliable for Chromium and terminals.
|
||||
setTimeout(() => {
|
||||
try {
|
||||
rfb.sendKey(0xffe3, "ControlLeft", true);
|
||||
rfb.sendKey(0x0076, "KeyV", true);
|
||||
rfb.sendKey(0x0076, "KeyV", false);
|
||||
rfb.sendKey(0xffe3, "ControlLeft", false);
|
||||
} catch (_) {}
|
||||
}, 100);
|
||||
window.parent.postMessage({type:"lazyboy-paste-text", text}, parentOrigin);
|
||||
}
|
||||
|
||||
function pinTaskbar() {
|
||||
|
|
@ -87,23 +68,27 @@
|
|||
}
|
||||
}
|
||||
|
||||
function applyViewOnly(value) {
|
||||
if (!rfb) return;
|
||||
rfb.viewOnly = Boolean(value);
|
||||
}
|
||||
|
||||
function connect() {
|
||||
setStatus("Connecting to desktop…");
|
||||
statusEl.style.display = "block";
|
||||
window.parent.postMessage({ type: "lazyboy-desktop-lost" }, parentOrigin);
|
||||
rfb = new RFB(document.getElementById("screen"), url);
|
||||
rfb.viewOnly = flag("view_only", false);
|
||||
rfb.viewOnly = flag("view_only", true);
|
||||
rfb.scaleViewport = true;
|
||||
rfb.clipViewport = false;
|
||||
rfb.background = "#0f172a";
|
||||
pinTaskbar();
|
||||
rfb.addEventListener("connect", () => {
|
||||
pinTaskbar();
|
||||
setStatus(rfb.viewOnly ? "View only — click to take control" : "Click the desktop");
|
||||
try { rfb.focus(); } catch (_) {}
|
||||
setTimeout(() => { statusEl.style.display = "none"; }, 2500);
|
||||
window.parent.postMessage({ type: "lazyboy-desktop-ready" }, parentOrigin);
|
||||
});
|
||||
rfb.addEventListener("disconnect", (event) => {
|
||||
statusEl.style.display = "block";
|
||||
window.parent.postMessage({ type: "lazyboy-desktop-lost" }, parentOrigin);
|
||||
const clean = event && event.detail && event.detail.clean;
|
||||
setStatus(clean ? "Disconnected — retrying" : "Desktop connection lost — retrying");
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
|
|
@ -117,6 +102,24 @@
|
|||
});
|
||||
}
|
||||
|
||||
// Capture before noVNC forwards the shortcut; otherwise stale remote
|
||||
// clipboard contents can be pasted once before the local text arrives.
|
||||
window.addEventListener("keydown", async (event) => {
|
||||
if (rfb && !rfb.viewOnly && event.metaKey && event.code === "KeyC") {
|
||||
event.preventDefault(); event.stopImmediatePropagation();
|
||||
window.parent.postMessage({type:"lazyboy-copy-request"},parentOrigin); return;
|
||||
}
|
||||
if (!rfb || rfb.viewOnly || !(event.ctrlKey || event.metaKey) || event.altKey || event.code !== "KeyV") return;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
const target = rfb;
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (rfb === target && !target.viewOnly) pasteIntoDesktop(text);
|
||||
} catch (_) {
|
||||
window.parent.postMessage({ type: "lazyboy-paste-request" }, parentOrigin);
|
||||
}
|
||||
}, true);
|
||||
connect();
|
||||
window.addEventListener("resize", pinTaskbar);
|
||||
window.addEventListener("pointerdown", () => {
|
||||
|
|
@ -133,14 +136,19 @@
|
|||
pasteIntoDesktop(text);
|
||||
});
|
||||
window.addEventListener("message", (event) => {
|
||||
if (event.origin !== parentOrigin) return;
|
||||
if (!event.data || event.data.type !== "lazyboy-host-clipboard") return;
|
||||
if (event.origin !== parentOrigin || event.source !== window.parent) return;
|
||||
if (!event.data) return;
|
||||
if (event.data.type === "lazyboy-view-only") {
|
||||
applyViewOnly(event.data.viewOnly);
|
||||
return;
|
||||
}
|
||||
if (event.data.type !== "lazyboy-host-clipboard") return;
|
||||
pasteIntoDesktop(String(event.data.text || ""));
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="status">Loading desktop…</div>
|
||||
<div id="status" hidden>Loading desktop…</div>
|
||||
<div id="screen"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -35,3 +35,9 @@ dotenvy = "0.15"
|
|||
fastembed = "6.0.2"
|
||||
rmcp = { version = "3.2", default-features = false, features = ["client", "transport-child-process", "transport-streamable-http-client-reqwest"] }
|
||||
http = "1"
|
||||
aes-gcm = "0.10"
|
||||
cron = "0.15"
|
||||
chrono-tz = "0.10"
|
||||
rand = "0.8"
|
||||
|
||||
cap-std = "3"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
//! mounted home and delete anything older than [`INBOX_TTL`].
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime};
|
||||
use std::time::Duration;
|
||||
|
||||
use base64::Engine;
|
||||
use lazyboy_contracts::SessionAttachment;
|
||||
|
|
@ -13,7 +13,6 @@ use lazyboy_control::resolve_bot_workspace_path;
|
|||
use rig_core::completion::message::{ImageDetail, ImageMediaType, UserContent};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use tokio::fs;
|
||||
|
||||
use crate::computer;
|
||||
use crate::db::{Actor, parse_mode};
|
||||
|
|
@ -41,6 +40,7 @@ pub struct StoredAttachment {
|
|||
#[derive(Debug, Clone)]
|
||||
pub struct DecodedAttachment {
|
||||
pub name: String,
|
||||
pub stored_name: String,
|
||||
pub mime_type: String,
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
|
@ -68,8 +68,11 @@ pub fn decode_incoming(items: &[IncomingAttachment]) -> Result<Vec<DecodedAttach
|
|||
if total > MAX_TOTAL_BYTES {
|
||||
return Err("附件合計超過 20 MB".into());
|
||||
}
|
||||
let (stem, ext) = split_ext(&name);
|
||||
let stored_name = format!("{stem}-{}{ext}", uuid::Uuid::new_v4());
|
||||
out.push(DecodedAttachment {
|
||||
name,
|
||||
stored_name,
|
||||
mime_type: mime,
|
||||
bytes,
|
||||
});
|
||||
|
|
@ -86,7 +89,7 @@ pub fn stored_blocks(files: &[DecodedAttachment]) -> Vec<Value> {
|
|||
"name": file.name,
|
||||
"mimeType": file.mime_type,
|
||||
"size": file.bytes.len(),
|
||||
"path": format!("inbox/{}", file.name),
|
||||
"path": format!("inbox/{}", file.stored_name),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
|
|
@ -117,15 +120,30 @@ pub async fn stage_for_bots(
|
|||
let Some(dir) = inbox_dir_for_bot(state, actor, bot_id).await? else {
|
||||
continue;
|
||||
};
|
||||
fs::create_dir_all(&dir)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
let root = PathBuf::from(&state.data_dir).join("homes");
|
||||
let relative = dir
|
||||
.strip_prefix(&root)
|
||||
.map_err(|_| "invalid inbox root")?
|
||||
.to_path_buf();
|
||||
let files = files.to_vec();
|
||||
tokio::task::spawn_blocking(move || -> Result<(), String> {
|
||||
use std::io::Write;
|
||||
let root = cap_std::fs::Dir::open_ambient_dir(root, cap_std::ambient_authority())
|
||||
.map_err(|e| e.to_string())?;
|
||||
root.create_dir_all(&relative).map_err(|e| e.to_string())?;
|
||||
let inbox = root.open_dir(&relative).map_err(|e| e.to_string())?;
|
||||
for file in files {
|
||||
let path = unique_path(&dir, &file.name).await;
|
||||
fs::write(&path, &file.bytes)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut opts = cap_std::fs::OpenOptions::new();
|
||||
opts.write(true).create_new(true);
|
||||
let mut output = inbox
|
||||
.open_with(&file.stored_name, &opts)
|
||||
.map_err(|e| e.to_string())?;
|
||||
output.write_all(&file.bytes).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -151,8 +169,8 @@ pub async fn llm_parts(
|
|||
}
|
||||
for (stored, bytes) in files {
|
||||
notes.push(format!(
|
||||
"- {} ({}, {} bytes) at inbox/{}",
|
||||
stored.name, stored.mime_type, stored.size, stored.name
|
||||
"- {} ({}, {} bytes) at {}",
|
||||
stored.name, stored.mime_type, stored.size, stored.path
|
||||
));
|
||||
if is_image(&stored.mime_type) {
|
||||
if vision {
|
||||
|
|
@ -189,21 +207,52 @@ pub async fn llm_parts(
|
|||
|
||||
pub async fn sweep_all_inboxes(data_dir: &str) {
|
||||
let homes = PathBuf::from(data_dir).join("homes");
|
||||
let Ok(mut spaces) = fs::read_dir(&homes).await else {
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
let Ok(root) = cap_std::fs::Dir::open_ambient_dir(homes, cap_std::ambient_authority())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
while let Ok(Some(entry)) = spaces.next_entry().await {
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
sweep_dir(&path.join("inbox")).await;
|
||||
let bots = path.join("bots");
|
||||
let Ok(mut bots_dir) = fs::read_dir(&bots).await else {
|
||||
let Ok(entries) = root.entries() else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let Ok(home) = root.open_dir(entry.file_name()) else {
|
||||
continue;
|
||||
};
|
||||
while let Ok(Some(bot)) = bots_dir.next_entry().await {
|
||||
sweep_dir(&bot.path().join("inbox")).await;
|
||||
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;
|
||||
};
|
||||
let Ok(entries) = dir.entries() else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let Ok(meta) = entry.metadata() else {
|
||||
continue;
|
||||
};
|
||||
if meta.is_file()
|
||||
&& meta
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.into_std().elapsed().ok())
|
||||
.is_some_and(|age| age > INBOX_TTL)
|
||||
{
|
||||
let _ = dir.remove_file(entry.file_name());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -256,8 +305,31 @@ async fn load_from_blocks(
|
|||
let Some(file) = parse_stored(block) else {
|
||||
continue;
|
||||
};
|
||||
let path = dir.join(&file.name);
|
||||
let bytes = fs::read(&path).await.ok();
|
||||
let root = PathBuf::from(&state.data_dir).join("homes");
|
||||
let stored = file.path.strip_prefix("inbox/").unwrap_or(&file.name);
|
||||
let relative = dir.strip_prefix(&root).ok().map(|p| p.join(stored));
|
||||
let bytes = if let Some(relative) = relative {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
use std::io::Read;
|
||||
let root =
|
||||
cap_std::fs::Dir::open_ambient_dir(root, cap_std::ambient_authority()).ok()?;
|
||||
let input = root.open(relative).ok()?;
|
||||
if !input.metadata().ok()?.is_file() {
|
||||
return None;
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
input
|
||||
.take(MAX_BYTES as u64 + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.ok()?;
|
||||
(bytes.len() <= MAX_BYTES).then_some(bytes)
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
out.push((file, bytes));
|
||||
}
|
||||
out
|
||||
|
|
@ -269,6 +341,23 @@ fn parse_stored(value: &Value) -> Option<StoredAttachment> {
|
|||
return None;
|
||||
}
|
||||
let name = value.get("name").and_then(Value::as_str)?;
|
||||
if name.is_empty()
|
||||
|| name == "."
|
||||
|| name == ".."
|
||||
|| name.contains(['/', '\\'])
|
||||
|| name.chars().any(char::is_control)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let path = value.get("path").and_then(Value::as_str).unwrap_or("");
|
||||
if !path.is_empty() {
|
||||
let Some(stored) = path.strip_prefix("inbox/") else {
|
||||
return None;
|
||||
};
|
||||
if stored.is_empty() || stored == "." || stored == ".." || stored.contains(['/', '\\']) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(StoredAttachment {
|
||||
kind: if kind == "image" { "image" } else { "file" },
|
||||
name: name.to_string(),
|
||||
|
|
@ -286,41 +375,6 @@ fn parse_stored(value: &Value) -> Option<StoredAttachment> {
|
|||
})
|
||||
}
|
||||
|
||||
async fn sweep_dir(dir: &Path) {
|
||||
let Ok(mut entries) = fs::read_dir(dir).await else {
|
||||
return;
|
||||
};
|
||||
let cutoff = SystemTime::now() - INBOX_TTL;
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let path = entry.path();
|
||||
let Ok(meta) = fs::metadata(&path).await else {
|
||||
continue;
|
||||
};
|
||||
if !meta.is_file() {
|
||||
continue;
|
||||
}
|
||||
let modified = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
|
||||
if modified < cutoff {
|
||||
let _ = fs::remove_file(&path).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn unique_path(dir: &Path, name: &str) -> PathBuf {
|
||||
let candidate = dir.join(name);
|
||||
if fs::metadata(&candidate).await.is_err() {
|
||||
return candidate;
|
||||
}
|
||||
let (stem, ext) = split_ext(name);
|
||||
for n in 2..1000 {
|
||||
let next = dir.join(format!("{stem}-{n}{ext}"));
|
||||
if fs::metadata(&next).await.is_err() {
|
||||
return next;
|
||||
}
|
||||
}
|
||||
dir.join(format!("{stem}-{}.bin", uuid::Uuid::new_v4()))
|
||||
}
|
||||
|
||||
fn split_ext(name: &str) -> (String, String) {
|
||||
match name.rfind('.') {
|
||||
Some(index) if index > 0 => (name[..index].to_string(), name[index..].to_string()),
|
||||
|
|
@ -459,6 +513,7 @@ mod tests {
|
|||
fn stored_blocks_drop_bytes() {
|
||||
let files = [DecodedAttachment {
|
||||
name: "a.png".into(),
|
||||
stored_name: "a.png".into(),
|
||||
mime_type: "image/png".into(),
|
||||
bytes: vec![1, 2, 3],
|
||||
}];
|
||||
|
|
@ -479,3 +534,30 @@ mod tests {
|
|||
assert!(decode_incoming(&many).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod boundary_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn stored_metadata_cannot_escape_inbox() {
|
||||
for name in ["../secret", "/etc/passwd", "..", "x\\secret"] {
|
||||
assert!(parse_stored(&json!({"kind":"file","name":name})).is_none());
|
||||
}
|
||||
}
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn symlink_from_computer_home_cannot_read_host_secret() {
|
||||
let base = std::env::temp_dir().join(format!("lazyboy-boundary-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(base.join("homes")).unwrap();
|
||||
std::fs::write(base.join("secret"), b"private").unwrap();
|
||||
std::os::unix::fs::symlink(base.join("secret"), base.join("homes/link")).unwrap();
|
||||
let root =
|
||||
cap_std::fs::Dir::open_ambient_dir(base.join("homes"), cap_std::ambient_authority())
|
||||
.unwrap();
|
||||
assert!(root.read("link").is_err());
|
||||
assert!(root.write("link", b"overwritten").is_err());
|
||||
assert_eq!(std::fs::read(base.join("secret")).unwrap(), b"private");
|
||||
drop(root);
|
||||
std::fs::remove_dir_all(base).unwrap();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,11 @@ use axum::{Json, Router};
|
|||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Arc, Mutex},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
|
|
@ -14,7 +19,7 @@ const COOKIE_NAME: &str = "lazyboy_session";
|
|||
#[derive(Clone)]
|
||||
pub struct AuthConfig {
|
||||
token: Option<String>,
|
||||
session_value: Option<String>,
|
||||
sessions: Arc<Mutex<HashMap<String, Instant>>>,
|
||||
secure_cookie: bool,
|
||||
}
|
||||
|
||||
|
|
@ -23,18 +28,12 @@ impl AuthConfig {
|
|||
let token = std::env::var("LAZYBOY_APP_TOKEN")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty());
|
||||
let session_value = token.as_ref().map(|value| {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(b"lazyboy-session-v1:");
|
||||
hasher.update(value.as_bytes());
|
||||
hex::encode(hasher.finalize())
|
||||
});
|
||||
let secure_cookie = std::env::var("LAZYBOY_SECURE_COOKIE")
|
||||
.map(|value| matches!(value.as_str(), "1" | "true" | "yes"))
|
||||
.unwrap_or(false);
|
||||
Self {
|
||||
token,
|
||||
session_value,
|
||||
sessions: Arc::new(Mutex::new(HashMap::new())),
|
||||
secure_cookie,
|
||||
}
|
||||
}
|
||||
|
|
@ -58,21 +57,55 @@ impl AuthConfig {
|
|||
}
|
||||
|
||||
fn valid_session(&self, headers: &HeaderMap) -> bool {
|
||||
let Some(expected) = &self.session_value else {
|
||||
if !self.enabled() {
|
||||
return true;
|
||||
}
|
||||
let Some(value) = cookie_value(headers, COOKIE_NAME) else {
|
||||
return false;
|
||||
};
|
||||
cookie_value(headers, COOKIE_NAME)
|
||||
.map(|supplied| constant_time_eq(expected.as_bytes(), supplied.as_bytes()))
|
||||
.unwrap_or(false)
|
||||
let key = hex::encode(Sha256::digest(value.as_bytes()));
|
||||
self.sessions
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&key)
|
||||
.is_some_and(|expires| *expires > Instant::now())
|
||||
}
|
||||
|
||||
fn session_cookie(&self) -> Option<String> {
|
||||
self.session_value.as_ref().map(|value| {
|
||||
format!(
|
||||
if !self.enabled() {
|
||||
return None;
|
||||
}
|
||||
let value = format!(
|
||||
"{}{}",
|
||||
uuid::Uuid::new_v4().simple(),
|
||||
uuid::Uuid::new_v4().simple()
|
||||
);
|
||||
let key = hex::encode(Sha256::digest(value.as_bytes()));
|
||||
let mut sessions = self.sessions.lock().unwrap();
|
||||
sessions.retain(|_, expires| *expires > Instant::now());
|
||||
if sessions.len() >= 4096 {
|
||||
if let Some(oldest) = sessions
|
||||
.iter()
|
||||
.min_by_key(|(_, t)| **t)
|
||||
.map(|(k, _)| k.clone())
|
||||
{
|
||||
sessions.remove(&oldest);
|
||||
}
|
||||
}
|
||||
sessions.insert(key, Instant::now() + Duration::from_secs(604800));
|
||||
Some(format!(
|
||||
"{COOKIE_NAME}={value}; Path=/; HttpOnly; SameSite=Strict; Max-Age=604800{}",
|
||||
if self.secure_cookie { "; Secure" } else { "" }
|
||||
)
|
||||
})
|
||||
))
|
||||
}
|
||||
|
||||
fn revoke_session(&self, headers: &HeaderMap) {
|
||||
if let Some(value) = cookie_value(headers, COOKIE_NAME) {
|
||||
self.sessions
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove(&hex::encode(Sha256::digest(value.as_bytes())));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -143,7 +176,8 @@ async fn login(
|
|||
Ok(response)
|
||||
}
|
||||
|
||||
async fn logout() -> Response {
|
||||
async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
|
||||
state.auth.revoke_session(&headers);
|
||||
let mut response = Json(json!({"ok": true})).into_response();
|
||||
response.headers_mut().insert(
|
||||
header::SET_COOKIE,
|
||||
|
|
@ -175,3 +209,126 @@ mod tests {
|
|||
assert!(!constant_time_eq(b"correct", b"correct-longer"));
|
||||
}
|
||||
}
|
||||
|
||||
/// SameSite cookies do not replace Origin checks (including WebSockets).
|
||||
pub async fn browser_boundary(
|
||||
State(state): State<AppState>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
if !allowed_browser_request(request.headers(), state.auth.enabled()) {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({"message":"cross-origin request rejected"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let private =
|
||||
request.uri().path().starts_with("/api/") || request.uri().path().starts_with("/view/");
|
||||
let mut response = next.run(request).await;
|
||||
let headers = response.headers_mut();
|
||||
headers.insert(
|
||||
header::X_CONTENT_TYPE_OPTIONS,
|
||||
HeaderValue::from_static("nosniff"),
|
||||
);
|
||||
headers.insert(
|
||||
header::X_FRAME_OPTIONS,
|
||||
HeaderValue::from_static("SAMEORIGIN"),
|
||||
);
|
||||
headers.insert(
|
||||
header::REFERRER_POLICY,
|
||||
HeaderValue::from_static("same-origin"),
|
||||
);
|
||||
if private {
|
||||
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
fn allowed_browser_request(headers: &HeaderMap, authenticated_mode: bool) -> bool {
|
||||
if headers.get("sec-fetch-site").and_then(|v| v.to_str().ok()) == Some("cross-site") {
|
||||
return false;
|
||||
}
|
||||
let Some(host) = headers.get(header::HOST).and_then(|v| v.to_str().ok()) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(destination) = reqwest::Url::parse(&format!("http://{host}")) else {
|
||||
return false;
|
||||
};
|
||||
if !authenticated_mode
|
||||
&& !matches!(
|
||||
destination.host_str(),
|
||||
Some("localhost" | "127.0.0.1" | "[::1]")
|
||||
)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(origin) = headers.get(header::ORIGIN) {
|
||||
let Ok(origin) = origin
|
||||
.to_str()
|
||||
.ok()
|
||||
.and_then(|s| reqwest::Url::parse(s).ok())
|
||||
.ok_or(())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Ok(expected) = reqwest::Url::parse(&format!("{}://{host}", origin.scheme())) else {
|
||||
return false;
|
||||
};
|
||||
if !matches!(origin.scheme(), "http" | "https") || origin.origin() != expected.origin() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod origin_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn blocks_cross_origin_and_dns_rebinding() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(header::HOST, "localhost:3101".parse().unwrap());
|
||||
assert!(allowed_browser_request(&headers, false));
|
||||
headers.insert(header::ORIGIN, "https://evil.example".parse().unwrap());
|
||||
assert!(!allowed_browser_request(&headers, true));
|
||||
headers.insert(header::ORIGIN, "http://localhost:3101".parse().unwrap());
|
||||
assert!(allowed_browser_request(&headers, true));
|
||||
headers.remove(header::ORIGIN);
|
||||
headers.insert(header::HOST, "rebinding.example".parse().unwrap());
|
||||
assert!(!allowed_browser_request(&headers, false));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod session_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn sessions_are_distinct_expire_and_are_revoked_on_logout() {
|
||||
let config = AuthConfig {
|
||||
token: Some("secret".into()),
|
||||
sessions: Arc::new(Mutex::new(HashMap::new())),
|
||||
secure_cookie: true,
|
||||
};
|
||||
let first = config.session_cookie().unwrap();
|
||||
let second = config.session_cookie().unwrap();
|
||||
assert_ne!(first, second);
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::COOKIE,
|
||||
first.split(';').next().unwrap().parse().unwrap(),
|
||||
);
|
||||
assert!(config.valid_session(&headers));
|
||||
config.revoke_session(&headers);
|
||||
assert!(!config.valid_session(&headers));
|
||||
headers.insert(
|
||||
header::COOKIE,
|
||||
second.split(';').next().unwrap().parse().unwrap(),
|
||||
);
|
||||
assert!(config.valid_session(&headers));
|
||||
for expiry in config.sessions.lock().unwrap().values_mut() {
|
||||
*expiry = Instant::now() - Duration::from_secs(1);
|
||||
}
|
||||
assert!(!config.valid_session(&headers));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,10 @@ use crate::db::{
|
|||
};
|
||||
use crate::state::AppState;
|
||||
|
||||
pub const STEP_BOOTING: &str = "電腦啟動中";
|
||||
pub const STEP_WAKING: &str = "喚醒中";
|
||||
pub const STEP_HANDOFF: &str = "換手中";
|
||||
|
||||
pub fn status_from(
|
||||
bot_id: &str,
|
||||
computer: &ComputerRow,
|
||||
|
|
@ -55,6 +59,7 @@ pub fn status_from(
|
|||
busy_session_id: None,
|
||||
busy_run_id: None,
|
||||
busy_step: None,
|
||||
using_computer: false,
|
||||
waiting_run_id: None,
|
||||
waiting_session_id: None,
|
||||
queued_runs: 0,
|
||||
|
|
@ -320,6 +325,7 @@ async fn probe_computer_container(
|
|||
],
|
||||
cwd: None,
|
||||
timeout_ms: Some(5_000),
|
||||
stdin: None,
|
||||
},
|
||||
&adapter_context(actor, bot_id, "probe"),
|
||||
)
|
||||
|
|
@ -500,6 +506,21 @@ pub async fn boot(state: &AppState, actor: &Actor, bot_id: &str) -> Result<Compu
|
|||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
if computer.state == "suspended" && computer.provider_ref.is_some() {
|
||||
match resume_paused(state, actor, bot_id, &computer).await {
|
||||
Ok(status) => return Ok(status),
|
||||
Err(error) => {
|
||||
tracing::warn!("computer {computer_id} resume failed: {error}");
|
||||
let _ = sqlx::query(
|
||||
"UPDATE computers SET state = 'stopped', updated_at = now()
|
||||
WHERE id = $1 AND state = 'suspended'",
|
||||
)
|
||||
.bind(&computer_id)
|
||||
.execute(state.pool())
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
let claimed = sqlx::query(
|
||||
"UPDATE computers SET state = 'booting', updated_at = now()
|
||||
WHERE id = $1 AND state IN ('stopped','suspended','error')",
|
||||
|
|
@ -563,6 +584,7 @@ pub async fn boot(state: &AppState, actor: &Actor, bot_id: &str) -> Result<Compu
|
|||
argv: vec!["mkdir".into(), "-p".into(), "shared".into(), folder],
|
||||
cwd: None,
|
||||
timeout_ms: Some(10_000),
|
||||
stdin: None,
|
||||
},
|
||||
&ctx,
|
||||
),
|
||||
|
|
@ -604,6 +626,71 @@ pub async fn boot(state: &AppState, actor: &Actor, bot_id: &str) -> Result<Compu
|
|||
Ok(status_from(bot_id, &computer, screen.as_ref(), None))
|
||||
}
|
||||
|
||||
async fn resume_paused(
|
||||
state: &AppState,
|
||||
actor: &Actor,
|
||||
bot_id: &str,
|
||||
computer: &ComputerRow,
|
||||
) -> Result<ComputerStatus, String> {
|
||||
let ctx = adapter_context(actor, bot_id, "resume");
|
||||
let home = home_path(&state.data_dir, &computer.home_key);
|
||||
let resumed = match tokio::time::timeout(
|
||||
Duration::from_secs(20),
|
||||
state.sandbox.resume(
|
||||
ProvisionRequest {
|
||||
home_key: computer.home_key.clone(),
|
||||
home_path: home.to_string_lossy().into_owned(),
|
||||
provider_ref: computer.provider_ref.clone(),
|
||||
},
|
||||
&ctx,
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(resumed)) => resumed,
|
||||
Ok(Err(error)) => return Err(error.to_string()),
|
||||
Err(_) => return Err("computer resume timed out after 20 seconds".into()),
|
||||
};
|
||||
let running = sqlx::query(
|
||||
"UPDATE computers SET state = 'running', provider_ref = $2, kind = $3, updated_at = now()
|
||||
WHERE id = $1 AND state = 'suspended'",
|
||||
)
|
||||
.bind(&computer.id)
|
||||
.bind(&resumed.provider_ref)
|
||||
.bind(resumed.kind.as_str())
|
||||
.execute(state.pool())
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
if running.rows_affected() != 1 {
|
||||
let current = state
|
||||
.db
|
||||
.get_computer(&computer.id)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
.ok_or_else(|| "computer not found".to_string())?;
|
||||
if current.state == "running" {
|
||||
let screen = ensure_bot_screen(state, actor, bot_id, ¤t, None)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|bound| bound.row);
|
||||
return Ok(status_from(bot_id, ¤t, screen.as_ref(), None));
|
||||
}
|
||||
return Err("computer resume was superseded".into());
|
||||
}
|
||||
let computer = state
|
||||
.db
|
||||
.get_computer(&computer.id)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
.ok_or_else(|| "computer not found".to_string())?;
|
||||
let screen = ensure_bot_screen(state, actor, bot_id, &computer, None)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|bound| bound.row);
|
||||
restore_computer_screens(state, actor, &computer, bot_id).await;
|
||||
Ok(status_from(bot_id, &computer, screen.as_ref(), None))
|
||||
}
|
||||
|
||||
async fn mark_boot_error(state: &AppState, computer_id: &str, provider_ref: Option<&str>) {
|
||||
if let Err(error) = sqlx::query(
|
||||
"UPDATE computers SET state = 'error', provider_ref = COALESCE($2, provider_ref), updated_at = now()
|
||||
|
|
@ -813,6 +900,7 @@ pub async fn takeover(
|
|||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
for (run_id, thread_id) in paused {
|
||||
crate::runs::set_run_step(state, &run_id, STEP_HANDOFF).await;
|
||||
let _ = crate::runs::append_bot_message(
|
||||
state,
|
||||
&thread_id,
|
||||
|
|
@ -878,6 +966,11 @@ pub async fn heartbeat(state: &AppState, actor: &Actor, bot_id: &str) -> Result<
|
|||
return Ok(());
|
||||
};
|
||||
let expires = Utc::now() + TimeDelta::minutes(15);
|
||||
sqlx::query("UPDATE computers SET updated_at = now() WHERE id = $1")
|
||||
.bind(&computer_id)
|
||||
.execute(state.pool())
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
sqlx::query(
|
||||
"UPDATE computer_screens SET control_lease_expires_at = $2, updated_at = now()
|
||||
WHERE computer_id = $1 AND bot_id = $3 AND control_holder = 'user'",
|
||||
|
|
@ -933,6 +1026,36 @@ pub async fn idle_loop(state: AppState) {
|
|||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(60)).await;
|
||||
crate::attachments::sweep_all_inboxes(&state.data_dir).await;
|
||||
pause_idle_computers(&state).await;
|
||||
stop_parked_computers(&state).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn computer_has_active_work(state: &AppState, computer_id: &str) -> bool {
|
||||
let active: Result<Option<(i64,)>, _> = sqlx::query_as(
|
||||
"SELECT 1 FROM runs WHERE status IN ('queued','leased','running','waiting_input','waiting_takeover')
|
||||
AND bot_id IN (SELECT id FROM bots WHERE computer_id = $1)
|
||||
UNION ALL
|
||||
SELECT 1 FROM taught_skills WHERE status IN ('recording','drafting')
|
||||
AND bot_id IN (SELECT id FROM bots WHERE computer_id = $1)
|
||||
LIMIT 1",
|
||||
)
|
||||
.bind(computer_id)
|
||||
.fetch_optional(state.pool())
|
||||
.await;
|
||||
matches!(active, Ok(Some(_)))
|
||||
}
|
||||
|
||||
fn idle_adapter(computer: &ComputerRow, operation: &str) -> AdapterContext {
|
||||
AdapterContext {
|
||||
operation_id: operation.into(),
|
||||
space_id: computer.space_id.clone(),
|
||||
user_id: computer.user_id.clone(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn pause_idle_computers(state: &AppState) {
|
||||
let cutoff = Utc::now() - TimeDelta::minutes(10);
|
||||
let rows = sqlx::query_as::<_, ComputerRow>(
|
||||
"SELECT id, space_id, user_id, scope, scope_key, home_key, home_revision, kind, provider_ref, state,
|
||||
|
|
@ -944,51 +1067,62 @@ pub async fn idle_loop(state: AppState) {
|
|||
.bind(cutoff)
|
||||
.fetch_all(state.pool())
|
||||
.await;
|
||||
let Ok(rows) = rows else { continue };
|
||||
let Ok(rows) = rows else { return };
|
||||
for computer in rows {
|
||||
let active: Result<Option<(i64,)>, _> = sqlx::query_as(
|
||||
"SELECT 1 FROM runs WHERE status IN ('queued','leased','running','waiting_input','waiting_takeover')
|
||||
AND bot_id IN (SELECT id FROM bots WHERE computer_id = $1)
|
||||
UNION ALL
|
||||
SELECT 1 FROM taught_skills WHERE status IN ('recording','drafting')
|
||||
AND bot_id IN (SELECT id FROM bots WHERE computer_id = $1)
|
||||
LIMIT 1",
|
||||
)
|
||||
.bind(&computer.id)
|
||||
.fetch_optional(state.pool())
|
||||
.await;
|
||||
if matches!(active, Ok(Some(_))) {
|
||||
if computer_has_active_work(state, &computer.id).await {
|
||||
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
|
||||
if let Some(computer_ref) = computer_ref(&computer) {
|
||||
if 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;
|
||||
.suspend(&computer_ref, &idle_adapter(&computer, "idle"))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let _ = sqlx::query(
|
||||
"UPDATE computers SET state = 'stopped', updated_at = now() WHERE id = $1",
|
||||
"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;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn computer_ref(computer: &ComputerRow) -> Option<lazyboy_control::ComputerRef> {
|
||||
|
|
@ -1069,6 +1203,11 @@ pub async fn current_status(
|
|||
status.busy_run_id = Some(run.id.clone());
|
||||
status.busy_session_id = Some(run.thread_id.clone());
|
||||
status.busy_step = run.step.clone();
|
||||
status.using_computer = screen
|
||||
.as_ref()
|
||||
.and_then(|row| row.execution_run_id.as_deref())
|
||||
== Some(run.id.as_str())
|
||||
|| computer.execution_run_id.as_deref() == Some(run.id.as_str());
|
||||
}
|
||||
if let Some(run) = waiting {
|
||||
status.waiting_run_id = Some(run.id.clone());
|
||||
|
|
|
|||
|
|
@ -8,11 +8,13 @@ mod memory;
|
|||
mod rooms;
|
||||
mod routes;
|
||||
mod runs;
|
||||
mod schedules;
|
||||
mod screen_proxy;
|
||||
mod sessions;
|
||||
mod skills;
|
||||
mod state;
|
||||
mod tools;
|
||||
mod vault;
|
||||
mod workspace;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
|
@ -57,6 +59,10 @@ async fn main() {
|
|||
tokio::spawn(async move {
|
||||
computer::idle_loop(idle_state).await;
|
||||
});
|
||||
let schedule_state = state.clone();
|
||||
tokio::spawn(async move {
|
||||
schedules::tick_loop(schedule_state).await;
|
||||
});
|
||||
|
||||
let bind = std::env::var("API_BIND").unwrap_or_else(|_| "127.0.0.1:3101".into());
|
||||
let addr: SocketAddr = bind.parse().expect("API_BIND");
|
||||
|
|
@ -66,16 +72,20 @@ async fn main() {
|
|||
);
|
||||
}
|
||||
|
||||
let web_dir = std::env::var("LAZYBOY_WEB_DIR").unwrap_or_else(|_| "apps/web".into());
|
||||
let web_dir = std::env::var("LAZYBOY_WEB_DIR").unwrap_or_else(|_| "apps/web/dist".into());
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/health",
|
||||
axum::routing::get(|| async { axum::Json(serde_json::json!({"ok": true})) }),
|
||||
)
|
||||
.merge(auth::public_router(state.clone()))
|
||||
.merge(routes::router(state))
|
||||
.merge(routes::router(state.clone()))
|
||||
.fallback_service(ServeDir::new(web_dir))
|
||||
.layer(DefaultBodyLimit::max(24 * 1024 * 1024));
|
||||
.layer(DefaultBodyLimit::max(28 * 1024 * 1024))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
state,
|
||||
auth::browser_boundary,
|
||||
));
|
||||
|
||||
tracing::info!("api listening on {addr}");
|
||||
let listener = tokio::net::TcpListener::bind(addr).await.expect("bind");
|
||||
|
|
|
|||
|
|
@ -254,6 +254,21 @@ async fn connect_client(row: &McpRow) -> Result<LiveClient, String> {
|
|||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| "stdio 需要 command".to_string())?;
|
||||
let mut cmd = Command::new(command);
|
||||
cmd.env_clear();
|
||||
for key in [
|
||||
"PATH",
|
||||
"HOME",
|
||||
"LANG",
|
||||
"LC_ALL",
|
||||
"TMPDIR",
|
||||
"PYTHONPATH",
|
||||
"NODE_EXTRA_CA_CERTS",
|
||||
] {
|
||||
if let Some(value) = std::env::var_os(key) {
|
||||
cmd.env(key, value);
|
||||
}
|
||||
}
|
||||
cmd.kill_on_drop(true);
|
||||
cmd.args(&row.args);
|
||||
cmd.stdin(std::process::Stdio::piped());
|
||||
cmd.stdout(std::process::Stdio::piped());
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ pub fn router(state: AppState) -> Router {
|
|||
.merge(crate::mcp::router())
|
||||
.merge(crate::workspace::router())
|
||||
.merge(crate::skills::router())
|
||||
.merge(crate::vault::router())
|
||||
.merge(crate::schedules::router())
|
||||
.route("/api/bots", get(list_bots).post(create_bot))
|
||||
.route(
|
||||
"/api/bots/{id}",
|
||||
|
|
@ -649,7 +651,7 @@ async fn screen_url(
|
|||
.await
|
||||
.map_err(|_| StatusCode::BAD_GATEWAY)?;
|
||||
Ok(Json(json!({
|
||||
"url": format!("/view/{id}/vnc.html?view_only={}", !interactive)
|
||||
"url": format!("/view/{id}/vnc.html")
|
||||
})))
|
||||
}
|
||||
|
||||
|
|
@ -722,6 +724,39 @@ async fn input(
|
|||
return Err(StatusCode::CONFLICT);
|
||||
}
|
||||
let computer_ref = computer::computer_ref(&computer).ok_or(StatusCode::BAD_REQUEST)?;
|
||||
if body.kind == "clipboard" || body.kind == "copy" {
|
||||
let text = body.text.unwrap_or_default();
|
||||
if text.len() > 1024 * 1024 {
|
||||
return Err(StatusCode::PAYLOAD_TOO_LARGE);
|
||||
}
|
||||
let context =
|
||||
computer::adapter_context_for(&actor, &id, "clipboard", screen.as_ref(), None);
|
||||
let mut argv =
|
||||
lazyboy_control::paste_command_on(context.display.as_deref().unwrap_or(":1"));
|
||||
if body.kind == "copy" {
|
||||
argv.push("copy".into());
|
||||
}
|
||||
let result = state
|
||||
.sandbox
|
||||
.execute(
|
||||
&computer_ref,
|
||||
lazyboy_control::CommandRequest {
|
||||
argv,
|
||||
cwd: None,
|
||||
timeout_ms: Some(10_000),
|
||||
stdin: Some(text),
|
||||
},
|
||||
&context,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| StatusCode::BAD_GATEWAY)?;
|
||||
if result.code != 0 {
|
||||
return Err(StatusCode::BAD_GATEWAY);
|
||||
}
|
||||
return Ok(Json(
|
||||
json!({"ok":true,"text":if body.kind=="copy" {Some(result.stdout)} else {None}}),
|
||||
));
|
||||
}
|
||||
let action = match body.kind.as_str() {
|
||||
"key" => lazyboy_contracts::ComputerAction::Key {
|
||||
key: body.key.unwrap_or_default(),
|
||||
|
|
|
|||
|
|
@ -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 1–80 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 1–8000 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 1–365".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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -31,6 +31,18 @@ async fn proxy(state: AppState, bot_id: String, rest: String, req: Request) -> R
|
|||
.and_then(|value| value.to_str().ok())
|
||||
.map(|value| value.eq_ignore_ascii_case("websocket"))
|
||||
.unwrap_or(false);
|
||||
if !upgrade {
|
||||
if req.method() != axum::http::Method::GET && req.method() != axum::http::Method::HEAD {
|
||||
return StatusCode::METHOD_NOT_ALLOWED.into_response();
|
||||
}
|
||||
if is_viewer_page(&rest) {
|
||||
return viewer_page().await;
|
||||
}
|
||||
return trusted_asset(&rest).await;
|
||||
}
|
||||
if rest != "websockify" {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
let ensure = upgrade || is_viewer_page(&rest) || rest.contains("websockify");
|
||||
let port = match upstream_port(&state, &bot_id, ensure).await {
|
||||
Ok(port) => port,
|
||||
|
|
@ -47,7 +59,7 @@ async fn proxy(state: AppState, bot_id: String, rest: String, req: Request) -> R
|
|||
if is_viewer_page(&rest) {
|
||||
return viewer_page().await;
|
||||
}
|
||||
http_proxy(port, &rest, req).await
|
||||
StatusCode::NOT_FOUND.into_response()
|
||||
}
|
||||
|
||||
fn is_viewer_page(rest: &str) -> bool {
|
||||
|
|
@ -71,7 +83,6 @@ async fn viewer_page() -> Response {
|
|||
"text/html; charset=utf-8".parse().unwrap(),
|
||||
);
|
||||
headers.insert(header::CACHE_CONTROL, "no-store".parse().unwrap());
|
||||
headers.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*".parse().unwrap());
|
||||
(StatusCode::OK, headers, html).into_response()
|
||||
}
|
||||
|
||||
|
|
@ -129,49 +140,36 @@ fn rewrite_upstream(url: &str) -> String {
|
|||
url.replace("127.0.0.1", &host).replace("localhost", &host)
|
||||
}
|
||||
|
||||
async fn http_proxy(port: u16, rest: &str, req: Request) -> Response {
|
||||
let host = std::env::var("LAZYBOY_SCREEN_UPSTREAM").unwrap_or_else(|_| "127.0.0.1".into());
|
||||
let path = if rest.is_empty() {
|
||||
"vnc_lite.html"
|
||||
fn safe_asset(rest: &str) -> bool {
|
||||
(rest.starts_with("core/") || rest.starts_with("vendor/"))
|
||||
&& rest.ends_with(".js")
|
||||
&& rest
|
||||
.split('/')
|
||||
.all(|p| !p.is_empty() && p != "." && p != "..")
|
||||
&& rest
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b"/._-".contains(&b))
|
||||
}
|
||||
|
||||
async fn trusted_asset(rest: &str) -> Response {
|
||||
if !safe_asset(rest) {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
let root = std::env::var("LAZYBOY_NOVNC_DIR").unwrap_or_else(|_| {
|
||||
let web = std::env::var("LAZYBOY_WEB_DIR").unwrap_or_else(|_| "apps/web".into());
|
||||
if std::path::Path::new(&web).join("novnc").is_dir() {
|
||||
format!("{web}/novnc")
|
||||
} else {
|
||||
rest
|
||||
};
|
||||
let query = req.uri().query().unwrap_or_default();
|
||||
let url = if query.is_empty() {
|
||||
format!("http://{host}:{port}/{path}")
|
||||
} else {
|
||||
format!("http://{host}:{port}/{path}?{query}")
|
||||
};
|
||||
let client = reqwest::Client::new();
|
||||
let method = reqwest::Method::from_bytes(req.method().as_str().as_bytes())
|
||||
.unwrap_or(reqwest::Method::GET);
|
||||
match client.request(method, url).send().await {
|
||||
Ok(upstream) => {
|
||||
let status =
|
||||
StatusCode::from_u16(upstream.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
|
||||
let content_type = upstream
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
match upstream.bytes().await {
|
||||
Ok(bytes) => {
|
||||
let mut headers = HeaderMap::new();
|
||||
if let Ok(value) = content_type.parse() {
|
||||
headers.insert(header::CONTENT_TYPE, value);
|
||||
"apps/web/node_modules/@novnc/novnc".into()
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -292,6 +292,7 @@ async fn start_skill(
|
|||
argv,
|
||||
cwd: None,
|
||||
timeout_ms: Some(10_000),
|
||||
stdin: None,
|
||||
},
|
||||
&ctx,
|
||||
)
|
||||
|
|
@ -902,6 +903,7 @@ async fn stop_recorder(
|
|||
argv: cdp_record_stop_command(skill_id),
|
||||
cwd: None,
|
||||
timeout_ms: Some(5_000),
|
||||
stdin: None,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
|
@ -928,6 +930,7 @@ async fn collect_browser_events(
|
|||
],
|
||||
cwd: None,
|
||||
timeout_ms: Some(10_000),
|
||||
stdin: None,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 1–80 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 1–200 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,14 @@ pub enum ScrollDirection {
|
|||
Down,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum RefVerb {
|
||||
Click,
|
||||
Focus,
|
||||
SetValue,
|
||||
}
|
||||
|
||||
/// Canonical actions the control plane understands.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "lowercase")]
|
||||
|
|
@ -36,6 +44,15 @@ pub enum ComputerAction {
|
|||
#[serde(default)]
|
||||
button: Option<PointerButton>,
|
||||
},
|
||||
/// Semantic target (DOM selector or AT-SPI path). Execute via CDP/a11y, not xdotool.
|
||||
Ref {
|
||||
verb: RefVerb,
|
||||
target: String,
|
||||
#[serde(default, rename = "refKind")]
|
||||
ref_kind: String,
|
||||
#[serde(default)]
|
||||
text: Option<String>,
|
||||
},
|
||||
Clipboard {
|
||||
text: String,
|
||||
},
|
||||
|
|
@ -86,15 +103,24 @@ pub struct UiElement {
|
|||
pub y: u32,
|
||||
pub w: u32,
|
||||
pub h: u32,
|
||||
/// CSS selector for in-page controls (Chromium CDP). Native windows leave this empty.
|
||||
/// CSS selector (DOM) or AT-SPI path (a11y). Native windows leave this empty.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub selector: Option<String>,
|
||||
/// "dom" for page controls, "window" for native windows.
|
||||
/// "dom" for page controls, "a11y" for AT-SPI widgets, "window" for native windows.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub kind: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub role: Option<String>,
|
||||
}
|
||||
|
||||
impl UiElement {
|
||||
pub fn has_ref(&self) -> bool {
|
||||
self.selector
|
||||
.as_deref()
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
&& matches!(self.kind.as_deref(), Some("dom") | Some("a11y"))
|
||||
}
|
||||
|
||||
pub fn center(&self) -> (u32, u32) {
|
||||
(
|
||||
self.x.saturating_add(self.w / 2),
|
||||
|
|
|
|||
|
|
@ -189,6 +189,10 @@ pub struct ComputerStatus {
|
|||
/// What the active run is doing right now ("思考中", "browser: click #12"…).
|
||||
#[serde(default)]
|
||||
pub busy_step: Option<String>,
|
||||
/// True only while the active run holds the desktop screen lease.
|
||||
/// Chat-only replies set busy_session_id but not this.
|
||||
#[serde(default)]
|
||||
pub using_computer: bool,
|
||||
/// Run paused in `waiting_takeover` (bot asked for, or user forced, control).
|
||||
#[serde(default)]
|
||||
pub waiting_run_id: Option<String>,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,7 @@
|
|||
use lazyboy_contracts::{ComputerAction, PointerButton, PointerType, ScrollDirection, UiElement};
|
||||
use crate::is_browser_title;
|
||||
use lazyboy_contracts::{
|
||||
ComputerAction, PointerButton, PointerType, RefVerb, ScrollDirection, UiElement,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use thiserror::Error;
|
||||
|
||||
|
|
@ -43,7 +46,8 @@ pub fn element_id(value: Option<&Value>) -> Option<u64> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Fill x/y from a numbered on-screen element so the model can click by id.
|
||||
/// Resolve a numbered on-screen element. DOM/a11y refs stay semantic (no
|
||||
/// pre-filled coordinates). Window-level targets still become center pixels.
|
||||
pub fn apply_element_targets(value: &mut Value, elements: &[UiElement]) -> Result<(), ActionError> {
|
||||
let Some(items) = value.as_array_mut() else {
|
||||
return Ok(());
|
||||
|
|
@ -58,6 +62,21 @@ pub fn apply_element_targets(value: &mut Value, elements: &[UiElement]) -> Resul
|
|||
let Some(element) = elements.iter().find(|element| u64::from(element.id) == id) else {
|
||||
return Err(ActionError::UnknownElement(id as u32));
|
||||
};
|
||||
let kind = action
|
||||
.get("kind")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| action.get("type").and_then(Value::as_str))
|
||||
.unwrap_or("");
|
||||
if element.has_ref() && matches!(kind, "click" | "type") {
|
||||
if let Some(selector) = element.selector.clone() {
|
||||
action.insert("target".into(), json!(selector));
|
||||
action.insert(
|
||||
"refKind".into(),
|
||||
json!(element.kind.clone().unwrap_or_else(|| "a11y".into())),
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if element.is_offscreen() {
|
||||
return Err(ActionError::OffscreenElement(id as u32));
|
||||
}
|
||||
|
|
@ -68,6 +87,97 @@ pub fn apply_element_targets(value: &mut Value, elements: &[UiElement]) -> Resul
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Fingerprint of the first click-like action, used to refuse repeating a miss.
|
||||
pub fn click_fingerprint(actions: &Value) -> Option<String> {
|
||||
let items = actions.as_array()?;
|
||||
for raw in items {
|
||||
let Some(action) = raw.as_object() else {
|
||||
continue;
|
||||
};
|
||||
let kind = action
|
||||
.get("kind")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| action.get("type").and_then(Value::as_str))
|
||||
.unwrap_or("");
|
||||
if !matches!(kind, "click" | "down" | "drag") {
|
||||
continue;
|
||||
}
|
||||
if let Some(id) = element_id(action.get("element")) {
|
||||
return Some(format!("e{id}"));
|
||||
}
|
||||
match (
|
||||
action.get("x").and_then(Value::as_f64),
|
||||
action.get("y").and_then(Value::as_f64),
|
||||
) {
|
||||
(Some(x), Some(y)) if x.is_finite() && y.is_finite() => {
|
||||
return Some(format!("p{},{}", x.round() as i64, y.round() as i64));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn should_block_stale_click(miss_streak: u32, last: Option<&str>, next: Option<&str>) -> bool {
|
||||
miss_streak >= 2 && next.is_some() && next == last
|
||||
}
|
||||
|
||||
/// When a CDP page snapshot is live, refuse pixel-clicking the Chromium window.
|
||||
pub fn browser_gui_block(actions: &Value, elements: &[UiElement]) -> Option<String> {
|
||||
if !elements
|
||||
.iter()
|
||||
.any(|element| element.kind.as_deref() == Some("dom"))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let items = actions.as_array()?;
|
||||
for raw in items {
|
||||
let Some(action) = raw.as_object() else {
|
||||
continue;
|
||||
};
|
||||
let kind = action
|
||||
.get("kind")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| action.get("type").and_then(Value::as_str))
|
||||
.unwrap_or("");
|
||||
if !matches!(kind, "click" | "move" | "down" | "up" | "hover" | "drag") {
|
||||
continue;
|
||||
}
|
||||
if let Some(id) = element_id(action.get("element")) {
|
||||
match elements.iter().find(|element| u64::from(element.id) == id) {
|
||||
Some(element) if element.has_ref() => continue,
|
||||
Some(element)
|
||||
if element.kind.as_deref() == Some("window")
|
||||
&& is_browser_title(&element.title) =>
|
||||
{
|
||||
return Some(browser_block_message(elements));
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
} else {
|
||||
return Some(browser_block_message(elements));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn browser_block_message(elements: &[UiElement]) -> String {
|
||||
let known: Vec<String> = elements
|
||||
.iter()
|
||||
.filter(|element| element.kind.as_deref() == Some("dom"))
|
||||
.take(12)
|
||||
.map(|element| format!("[{}] {}", element.id, element.title))
|
||||
.collect();
|
||||
format!(
|
||||
"Chromium is in front: use the browser tool (snapshot / click element N) instead of computer_act pixel clicks. Known page elements: {}",
|
||||
if known.is_empty() {
|
||||
"call browser snapshot first".to_string()
|
||||
} else {
|
||||
known.join(", ")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
pub fn parse_computer_actions(value: &Value) -> Result<Vec<ComputerAction>, ActionError> {
|
||||
let Value::Array(items) = value else {
|
||||
return Err(ActionError::Empty);
|
||||
|
|
@ -90,6 +200,23 @@ pub fn parse_computer_actions(value: &Value) -> Result<Vec<ComputerAction>, Acti
|
|||
.unwrap_or_default();
|
||||
match kind {
|
||||
"click" | "move" | "down" | "up" => {
|
||||
if kind == "click" {
|
||||
if let Some(target) = ref_target(action) {
|
||||
let pointer = ComputerAction::Ref {
|
||||
verb: RefVerb::Click,
|
||||
target,
|
||||
ref_kind: ref_kind(action),
|
||||
text: None,
|
||||
};
|
||||
let doubled = action.get("double").and_then(Value::as_bool) == Some(true);
|
||||
actions.push(pointer.clone());
|
||||
if doubled {
|
||||
actions.push(ComputerAction::Wait { ms: 70 });
|
||||
actions.push(pointer);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let x = coordinate(action.get("x"), "x")?;
|
||||
let y = coordinate(action.get("y"), "y")?;
|
||||
let pointer_type = match kind {
|
||||
|
|
@ -177,8 +304,17 @@ pub fn parse_computer_actions(value: &Value) -> Result<Vec<ComputerAction>, Acti
|
|||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if let Some(target) = ref_target(action) {
|
||||
actions.push(ComputerAction::Ref {
|
||||
verb: RefVerb::SetValue,
|
||||
target,
|
||||
ref_kind: ref_kind(action),
|
||||
text: Some(text),
|
||||
});
|
||||
} else {
|
||||
actions.push(ComputerAction::Clipboard { text });
|
||||
}
|
||||
}
|
||||
"key" => {
|
||||
let key = action
|
||||
.get("key")
|
||||
|
|
@ -247,6 +383,24 @@ pub fn parse_computer_actions(value: &Value) -> Result<Vec<ComputerAction>, Acti
|
|||
Ok(actions)
|
||||
}
|
||||
|
||||
fn ref_target(action: &serde_json::Map<String, Value>) -> Option<String> {
|
||||
action
|
||||
.get("target")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn ref_kind(action: &serde_json::Map<String, Value>) -> String {
|
||||
action
|
||||
.get("refKind")
|
||||
.or_else(|| action.get("ref_kind"))
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("a11y")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn coordinate(value: Option<&Value>, name: &'static str) -> Result<u32, ActionError> {
|
||||
let number = value.and_then(Value::as_f64).unwrap_or(f64::NAN).round();
|
||||
if !number.is_finite() || number < 0.0 || number > 100_000.0 {
|
||||
|
|
@ -386,10 +540,145 @@ mod tests {
|
|||
kind: Some("dom".into()),
|
||||
..UiElement::default()
|
||||
};
|
||||
let mut actions = json!([{"kind": "click", "element": 3}]);
|
||||
let mut actions = json!([{"kind": "hover", "element": 3}]);
|
||||
assert_eq!(
|
||||
apply_element_targets(&mut actions, &[below_fold]).unwrap_err(),
|
||||
ActionError::OffscreenElement(3)
|
||||
);
|
||||
}
|
||||
|
||||
fn a11y_button() -> UiElement {
|
||||
UiElement {
|
||||
id: 4,
|
||||
title: "push button Open".into(),
|
||||
selector: Some("0/2/1".into()),
|
||||
kind: Some("a11y".into()),
|
||||
role: Some("push button".into()),
|
||||
x: 10,
|
||||
y: 20,
|
||||
w: 80,
|
||||
h: 24,
|
||||
..UiElement::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a11y_click_stays_semantic() {
|
||||
let mut actions = json!([{"kind": "click", "element": 4}]);
|
||||
apply_element_targets(&mut actions, &[a11y_button()]).unwrap();
|
||||
assert!(actions[0].get("x").is_none());
|
||||
assert_eq!(actions[0]["target"], "0/2/1");
|
||||
assert_eq!(actions[0]["refKind"], "a11y");
|
||||
let parsed = parse_computer_actions(&actions).unwrap();
|
||||
assert!(matches!(
|
||||
parsed[0],
|
||||
ComputerAction::Ref {
|
||||
verb: RefVerb::Click,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a11y_type_is_set_value() {
|
||||
let mut actions = json!([{"kind": "type", "element": 4, "text": "report.pdf"}]);
|
||||
apply_element_targets(&mut actions, &[a11y_button()]).unwrap();
|
||||
let parsed = parse_computer_actions(&actions).unwrap();
|
||||
match &parsed[0] {
|
||||
ComputerAction::Ref {
|
||||
verb: RefVerb::SetValue,
|
||||
text,
|
||||
..
|
||||
} => assert_eq!(text.as_deref(), Some("report.pdf")),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dom_click_does_not_fill_coordinates() {
|
||||
let mut actions = json!([{"kind": "click", "element": 1}]);
|
||||
let elements = vec![UiElement {
|
||||
id: 1,
|
||||
title: "Submit".into(),
|
||||
selector: Some("[data-lazyboy=\"1\"]".into()),
|
||||
kind: Some("dom".into()),
|
||||
x: 10,
|
||||
y: 20,
|
||||
w: 80,
|
||||
h: 24,
|
||||
..UiElement::default()
|
||||
}];
|
||||
apply_element_targets(&mut actions, &elements).unwrap();
|
||||
assert!(actions[0].get("x").is_none());
|
||||
let parsed = parse_computer_actions(&actions).unwrap();
|
||||
match &parsed[0] {
|
||||
ComputerAction::Ref {
|
||||
ref_kind, verb, ..
|
||||
} => {
|
||||
assert_eq!(ref_kind, "dom");
|
||||
assert_eq!(*verb, RefVerb::Click);
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_clicks_are_blocked_when_dom_is_live() {
|
||||
let elements = vec![UiElement {
|
||||
id: 1,
|
||||
title: "Submit".into(),
|
||||
selector: Some("[data-lazyboy=\"1\"]".into()),
|
||||
kind: Some("dom".into()),
|
||||
x: 10,
|
||||
y: 20,
|
||||
w: 80,
|
||||
h: 24,
|
||||
..UiElement::default()
|
||||
}];
|
||||
let blocked = browser_gui_block(&json!([{"kind":"click","x":40,"y":80}]), &elements);
|
||||
assert!(blocked.unwrap().contains("browser"));
|
||||
assert!(
|
||||
browser_gui_block(&json!([{"kind":"click","element":1}]), &elements).is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chromium_window_clicks_are_blocked_when_dom_is_live() {
|
||||
let elements = vec![
|
||||
UiElement {
|
||||
id: 1,
|
||||
title: "Submit".into(),
|
||||
selector: Some("[data-lazyboy=\"1\"]".into()),
|
||||
kind: Some("dom".into()),
|
||||
..UiElement::default()
|
||||
},
|
||||
UiElement {
|
||||
id: 2,
|
||||
title: "Chromium".into(),
|
||||
kind: Some("window".into()),
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 1280,
|
||||
h: 800,
|
||||
..UiElement::default()
|
||||
},
|
||||
];
|
||||
let blocked = browser_gui_block(&json!([{"kind":"click","element":2}]), &elements);
|
||||
assert!(blocked.unwrap().contains("browser"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_repeat_blocks_the_third_same_click() {
|
||||
assert!(!should_block_stale_click(1, Some("e3"), Some("e3")));
|
||||
assert!(should_block_stale_click(2, Some("e3"), Some("e3")));
|
||||
assert!(!should_block_stale_click(2, Some("e3"), Some("e4")));
|
||||
assert_eq!(
|
||||
click_fingerprint(&json!([{"kind":"click","element":3}])).as_deref(),
|
||||
Some("e3")
|
||||
);
|
||||
assert_eq!(
|
||||
click_fingerprint(&json!([{"kind":"click","x":10,"y":20}])).as_deref(),
|
||||
Some("p10,20")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,101 +12,31 @@ def http_json(url, timeout=2):
|
|||
return None
|
||||
|
||||
class Ws:
|
||||
"""Use a maintained RFC6455 transport (fragmentation, ping/pong, handshake)."""
|
||||
def __init__(self, url):
|
||||
rest = url[5:]
|
||||
hostpath = rest.split("/", 1)
|
||||
hostport = hostpath[0]
|
||||
path = "/" + (hostpath[1] if len(hostpath) > 1 else "")
|
||||
if ":" in hostport:
|
||||
host, port = hostport.rsplit(":", 1)
|
||||
port = int(port)
|
||||
else:
|
||||
host, port = hostport, 80
|
||||
self.sock = socket.create_connection((host, port), 5)
|
||||
key = base64.b64encode(os.urandom(16)).decode()
|
||||
req = (
|
||||
"GET %s HTTP/1.1\r\nHost: %s\r\nUpgrade: websocket\r\n"
|
||||
"Connection: Upgrade\r\nSec-WebSocket-Key: %s\r\n"
|
||||
"Sec-WebSocket-Version: 13\r\nOrigin: http://%s\r\n\r\n"
|
||||
% (path, hostport, key, hostport)
|
||||
)
|
||||
self.sock.sendall(req.encode())
|
||||
buf = b""
|
||||
while b"\r\n\r\n" not in buf:
|
||||
chunk = self.sock.recv(4096)
|
||||
if not chunk:
|
||||
raise RuntimeError("ws handshake closed")
|
||||
buf += chunk
|
||||
import websocket
|
||||
self.sock = websocket.create_connection(url, timeout=5, suppress_origin=True,
|
||||
http_no_proxy=["127.0.0.1", "localhost"])
|
||||
self.n = 0
|
||||
|
||||
def _frame(self, data):
|
||||
n = len(data)
|
||||
hdr = bytearray([0x81])
|
||||
if n < 126:
|
||||
hdr.append(0x80 | n)
|
||||
elif n < 65536:
|
||||
hdr.append(0x80 | 126)
|
||||
hdr += struct.pack("!H", n)
|
||||
else:
|
||||
hdr.append(0x80 | 127)
|
||||
hdr += struct.pack("!Q", n)
|
||||
mask = os.urandom(4)
|
||||
hdr += mask
|
||||
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(data))
|
||||
return bytes(hdr) + masked
|
||||
|
||||
def _read(self, n):
|
||||
buf = b""
|
||||
while len(buf) < n:
|
||||
chunk = self.sock.recv(n - len(buf))
|
||||
if not chunk:
|
||||
raise RuntimeError("ws eof")
|
||||
buf += chunk
|
||||
return buf
|
||||
|
||||
def _read_frame(self):
|
||||
hdr = self._read(2)
|
||||
opcode = hdr[0] & 0x0F
|
||||
masked = hdr[1] & 0x80
|
||||
n = hdr[1] & 0x7F
|
||||
if n == 126:
|
||||
n = struct.unpack("!H", self._read(2))[0]
|
||||
elif n == 127:
|
||||
n = struct.unpack("!Q", self._read(8))[0]
|
||||
mask = self._read(4) if masked else b""
|
||||
payload = self._read(n)
|
||||
if masked:
|
||||
payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
|
||||
return opcode, payload
|
||||
|
||||
def recv_json(self):
|
||||
while True:
|
||||
opcode, payload = self._read_frame()
|
||||
if opcode == 0x8:
|
||||
raise RuntimeError("ws closed")
|
||||
if opcode == 0x9:
|
||||
continue
|
||||
if opcode in (0x1, 0x2):
|
||||
return json.loads(payload.decode())
|
||||
return json.loads(self.sock.recv())
|
||||
|
||||
def call(self, method, params=None):
|
||||
self.n += 1
|
||||
msg = {"id": self.n, "method": method}
|
||||
if params:
|
||||
msg["params"] = params
|
||||
self.sock.sendall(self._frame(json.dumps(msg).encode()))
|
||||
while True:
|
||||
self.sock.send(json.dumps({"id": self.n, "method": method, "params": params or {}}))
|
||||
deadline = time.monotonic() + 10
|
||||
while time.monotonic() < deadline:
|
||||
self.sock.settimeout(max(.1, deadline-time.monotonic()))
|
||||
obj = self.recv_json()
|
||||
if obj.get("id") == self.n:
|
||||
if "error" in obj:
|
||||
raise RuntimeError(str(obj["error"]))
|
||||
return obj.get("result") or {}
|
||||
raise TimeoutError("CDP response deadline exceeded")
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
self.sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def probe(port):
|
||||
return http_json("http://127.0.0.1:%s/json/version" % port) is not None
|
||||
|
|
@ -140,7 +70,7 @@ def spawn_browser(display, profile, port):
|
|||
if profile:
|
||||
env["LAZYBOY_BROWSER_PROFILE"] = profile
|
||||
subprocess.Popen(
|
||||
["lazyboy-browser", "--remote-debugging-port=%s" % port, "--remote-allow-origins=*"],
|
||||
["lazyboy-browser", "--remote-debugging-port=%s" % port],
|
||||
env=env,
|
||||
start_new_session=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
|
|
@ -171,6 +101,8 @@ SNAP_JS = r"""
|
|||
const chromeW = Math.max(0, (window.outerWidth || 0) - (window.innerWidth || 0));
|
||||
const sx0 = (window.screenX || 0) + Math.floor(chromeW / 2);
|
||||
const sy0 = (window.screenY || 0) + chromeH;
|
||||
document.querySelectorAll('[data-lazyboy]').forEach(el => el.removeAttribute('data-lazyboy'));
|
||||
const generation = crypto.randomUUID();
|
||||
const seen = new Set();
|
||||
const inView = [];
|
||||
const offView = [];
|
||||
|
|
@ -181,7 +113,9 @@ SNAP_JS = r"""
|
|||
if (st.visibility === "hidden" || st.display === "none" || Number(st.opacity) === 0) continue;
|
||||
const type = (el.getAttribute("type") || "").toLowerCase();
|
||||
let text;
|
||||
if (el.tagName === "INPUT" && (type === "radio" || type === "checkbox")) {
|
||||
if (type === "password" || /password|secret|token|one-time-code/i.test([el.name, el.id, el.autocomplete].join(" "))) {
|
||||
text = "[protected input]";
|
||||
} else if (el.tagName === "INPUT" && (type === "radio" || type === "checkbox")) {
|
||||
// Quiz answers: the value is usually "on"; the label next to it is what
|
||||
// the model must read to pick the right option.
|
||||
const owner = (el.labels && el.labels[0]) || el.closest("label") || el.parentElement;
|
||||
|
|
@ -214,12 +148,12 @@ SNAP_JS = r"""
|
|||
const out = [];
|
||||
let n = 1;
|
||||
for (const item of inView.slice(0, 50).concat(offView.slice(0, 20))) {
|
||||
item.el.setAttribute("data-lazyboy", String(n));
|
||||
item.el.setAttribute("data-lazyboy", generation + "-" + n);
|
||||
out.push({
|
||||
id: n,
|
||||
title: item.text,
|
||||
tag: item.el.tagName.toLowerCase(),
|
||||
selector: '[data-lazyboy="' + n + '"]',
|
||||
selector: '[data-lazyboy="' + generation + "-" + n + '"]',
|
||||
kind: "dom",
|
||||
x: item.x,
|
||||
y: item.y,
|
||||
|
|
@ -239,6 +173,11 @@ CLICK_JS = r"""
|
|||
if (!el) return {ok: false, error: "element gone"};
|
||||
el.scrollIntoView({block: "center", inline: "nearest"});
|
||||
const r = el.getBoundingClientRect();
|
||||
const style = getComputedStyle(el);
|
||||
const hit = document.elementFromPoint(r.x+r.width/2, r.y+r.height/2);
|
||||
if (el.disabled || el.getAttribute("aria-disabled") === "true" || r.width <= 0 || r.height <= 0 || style.visibility === "hidden" || style.display === "none" || !hit || !(hit === el || el.contains(hit))) {
|
||||
return {ok:false,error:"element is disabled, hidden, or covered; observe again"};
|
||||
}
|
||||
el.focus();
|
||||
el.click();
|
||||
const chromeH = Math.max(0, (window.outerHeight || 0) - (window.innerHeight || 0));
|
||||
|
|
@ -302,7 +241,7 @@ def wait_for_visual_update(ws):
|
|||
# exits, so wait for two animation frames to keep that screenshot aligned
|
||||
# with the framebuffer streamed by VNC.
|
||||
try:
|
||||
evaluate(ws, "new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))")
|
||||
evaluate(ws, "new Promise(resolve => {setTimeout(resolve, 250); requestAnimationFrame(() => requestAnimationFrame(resolve));})")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
@ -462,7 +401,7 @@ class Recorder:
|
|||
msg["params"] = params
|
||||
if session:
|
||||
msg["sessionId"] = session
|
||||
self.ws.sock.sendall(self.ws._frame(json.dumps(msg).encode()))
|
||||
self.ws.sock.send(json.dumps(msg))
|
||||
while True:
|
||||
obj = self.ws.recv_json()
|
||||
if obj.get("id") == self.ws.n:
|
||||
|
|
@ -534,8 +473,47 @@ class Recorder:
|
|||
self.handle(self.pending.pop(0))
|
||||
self.handle(self.ws.recv_json())
|
||||
|
||||
FILL_LOGIN_JS = r"""
|
||||
(creds) => {
|
||||
if (!creds.expectedHost || location.protocol !== "https:" || location.hostname.toLowerCase() !== creds.expectedHost.toLowerCase()) {
|
||||
return {ok: false, error: "saved login requires the exact configured HTTPS host"};
|
||||
}
|
||||
const user = creds.username || "";
|
||||
const pass = creds.password || "";
|
||||
const inputs = Array.from(document.querySelectorAll("input"));
|
||||
const visible = (el) => {
|
||||
const s = getComputedStyle(el);
|
||||
const r = el.getBoundingClientRect();
|
||||
return s.display !== "none" && s.visibility !== "hidden" && el.type !== "hidden" && r.width > 0 && r.height > 0;
|
||||
};
|
||||
const password = inputs.find((el) => el.type === "password" && visible(el) && !el.disabled);
|
||||
if (!password) return {ok: false, error: "no password field on this page"};
|
||||
const userish = /user|email|login|account|phone|id/i;
|
||||
const username = inputs.find((el) => {
|
||||
if (el === password || !visible(el) || el.disabled) return false;
|
||||
const type = (el.type || "text").toLowerCase();
|
||||
if (["email", "tel", "url"].includes(type)) return true;
|
||||
if (type !== "text" && type !== "search") return false;
|
||||
const blob = [el.name, el.id, el.placeholder, el.autocomplete, el.getAttribute("aria-label")].join(" ");
|
||||
return userish.test(blob) || el === inputs[0];
|
||||
});
|
||||
function setValue(el, value) {
|
||||
const proto = HTMLInputElement.prototype;
|
||||
const desc = Object.getOwnPropertyDescriptor(proto, "value");
|
||||
if (desc && desc.set) desc.set.call(el, value);
|
||||
else el.value = value;
|
||||
el.dispatchEvent(new Event("input", {bubbles: true}));
|
||||
el.dispatchEvent(new Event("change", {bubbles: true}));
|
||||
}
|
||||
if (username) setValue(username, user);
|
||||
setValue(password, pass);
|
||||
return {ok: true, filledUsername: Boolean(username), submitted: false};
|
||||
}
|
||||
"""
|
||||
|
||||
def main():
|
||||
req = json.loads(sys.argv[1])
|
||||
raw = sys.argv[1] if len(sys.argv) > 1 and sys.argv[1].strip() else sys.stdin.read()
|
||||
req = json.loads(raw)
|
||||
action = req.get("action") or "snapshot"
|
||||
display = req.get("display") or ":1"
|
||||
profile = req.get("profile") or ""
|
||||
|
|
@ -636,6 +614,23 @@ def main():
|
|||
out["waitedSeconds"] = round(waited, 1)
|
||||
print(json.dumps(out))
|
||||
return
|
||||
if action == "fill_login":
|
||||
val = evaluate(ws, FILL_LOGIN_JS, {
|
||||
"expectedHost": req.get("expectedHost") or "",
|
||||
"username": req.get("username") or "",
|
||||
"password": req.get("password") or "",
|
||||
}) or {}
|
||||
if not val.get("ok"):
|
||||
fail(val.get("error") or "could not fill the login form")
|
||||
wait_for_visual_update(ws)
|
||||
print(json.dumps({
|
||||
"ok": True,
|
||||
"action": "fill_login",
|
||||
"filledUsername": bool(val.get("filledUsername")),
|
||||
"submitted": bool(val.get("submitted")),
|
||||
"restarted": restarted,
|
||||
}))
|
||||
return
|
||||
if action == "type":
|
||||
sel = req.get("selector") or ""
|
||||
text = req.get("text") or ""
|
||||
|
|
|
|||
|
|
@ -42,6 +42,24 @@ pub fn cdp_command(request: &Value) -> Vec<String> {
|
|||
cdp_command_on(PRIMARY_DISPLAY, None, request)
|
||||
}
|
||||
|
||||
/// Same as `cdp_command_on` but the JSON body is meant to arrive on stdin
|
||||
/// so secrets never appear on the process argv.
|
||||
pub fn cdp_stdin_command_on(display: &str, profile: Option<&str>) -> Vec<String> {
|
||||
let mut env = vec![
|
||||
"env".into(),
|
||||
format!("DISPLAY={}", normalize_display(display)),
|
||||
];
|
||||
if let Some(profile) = profile.filter(|value| !value.is_empty()) {
|
||||
env.push(format!("LAZYBOY_BROWSER_PROFILE={profile}"));
|
||||
}
|
||||
env.extend([
|
||||
"python3".into(),
|
||||
"-c".into(),
|
||||
CDP_PY.into(),
|
||||
]);
|
||||
env
|
||||
}
|
||||
|
||||
/// Marker embedded in the recorder's argv so `pkill -f` can find exactly one
|
||||
/// teaching session without touching other python processes.
|
||||
pub fn teach_recorder_tag(skill_id: &str) -> String {
|
||||
|
|
@ -132,21 +150,7 @@ pub fn parse_cdp_page(raw: &str) -> CdpPage {
|
|||
}
|
||||
|
||||
pub fn merge_page_elements(windows: Vec<UiElement>, page: &[UiElement]) -> Vec<UiElement> {
|
||||
if page.is_empty() {
|
||||
return windows;
|
||||
}
|
||||
let mut elements = page.to_vec();
|
||||
let mut next = elements.len() as u32 + 1;
|
||||
for mut window in windows {
|
||||
let title = window.title.to_lowercase();
|
||||
if title.contains("chromium") || title.contains("chrome") {
|
||||
continue;
|
||||
}
|
||||
window.id = next;
|
||||
next += 1;
|
||||
elements.push(window);
|
||||
}
|
||||
elements
|
||||
crate::merge_ui_elements(windows, page, &[])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
mod a11y;
|
||||
mod actions;
|
||||
mod cdp;
|
||||
mod lease;
|
||||
|
|
@ -9,6 +10,7 @@ mod screen;
|
|||
mod takeover;
|
||||
mod x11;
|
||||
|
||||
pub use a11y::*;
|
||||
pub use actions::*;
|
||||
pub use cdp::*;
|
||||
pub use lease::*;
|
||||
|
|
|
|||
|
|
@ -58,6 +58,21 @@ pub struct CommandRequest {
|
|||
pub argv: Vec<String>,
|
||||
pub cwd: Option<String>,
|
||||
pub timeout_ms: Option<u64>,
|
||||
/// Optional bytes written to the process stdin, then closed.
|
||||
/// Used so fill-login never puts a password on the argv of `ps`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub stdin: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for CommandRequest {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
argv: Vec::new(),
|
||||
cwd: None,
|
||||
timeout_ms: None,
|
||||
stdin: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
@ -193,17 +208,13 @@ pub trait SandboxProvider: Send + Sync {
|
|||
&self,
|
||||
computer: &ComputerRef,
|
||||
context: &AdapterContext,
|
||||
) -> Result<(), SandboxError> {
|
||||
self.stop(computer, context).await
|
||||
}
|
||||
) -> Result<(), SandboxError>;
|
||||
|
||||
async fn resume(
|
||||
&self,
|
||||
request: ProvisionRequest,
|
||||
context: &AdapterContext,
|
||||
) -> Result<ComputerRef, SandboxError> {
|
||||
self.provision(request, context).await
|
||||
}
|
||||
) -> Result<ComputerRef, SandboxError>;
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
use lazyboy_contracts::{ComputerAction, PointerButton, PointerType, ScrollDirection};
|
||||
|
||||
pub fn is_browser_title(title: &str) -> bool {
|
||||
let title = title.to_lowercase();
|
||||
title.contains("chromium") || title.contains("chrome")
|
||||
}
|
||||
|
||||
use crate::screen::{PRIMARY_DISPLAY, normalize_display};
|
||||
|
||||
pub const DISPLAY: &str = PRIMARY_DISPLAY;
|
||||
|
|
@ -129,7 +134,8 @@ pub fn xdotool_argv_on(display: &str, action: &ComputerAction) -> Option<Vec<Str
|
|||
}
|
||||
ComputerAction::Wait { .. }
|
||||
| ComputerAction::Open { .. }
|
||||
| ComputerAction::Launch { .. } => {
|
||||
| ComputerAction::Launch { .. }
|
||||
| ComputerAction::Ref { .. } => {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
|
@ -160,6 +166,7 @@ pub fn action_pause_ms(action: &ComputerAction) -> u64 {
|
|||
ComputerAction::Focus { .. } => 90,
|
||||
ComputerAction::Open { .. } | ComputerAction::Launch { .. } => 220,
|
||||
ComputerAction::Wait { .. } => 0,
|
||||
ComputerAction::Ref { .. } => 55,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -259,6 +266,11 @@ pub fn parse_ui_elements(raw: &str) -> Vec<lazyboy_contracts::UiElement> {
|
|||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string),
|
||||
role: item
|
||||
.get("role")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
|
|
@ -454,9 +466,38 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_a11y_role_and_path() {
|
||||
let elements = parse_ui_elements(
|
||||
r#"[{"id":2,"title":"push button Open","selector":"0/3/1","kind":"a11y","role":"push button","x":8,"y":9,"w":40,"h":16}]"#,
|
||||
);
|
||||
assert_eq!(elements[0].kind.as_deref(), Some("a11y"));
|
||||
assert_eq!(elements[0].role.as_deref(), Some("push button"));
|
||||
assert_eq!(elements[0].selector.as_deref(), Some("0/3/1"));
|
||||
assert!(elements[0].has_ref());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_titles_match_chromium() {
|
||||
assert!(is_browser_title("Example - Chromium"));
|
||||
assert!(is_browser_title("chrome"));
|
||||
assert!(!is_browser_title("Thunar"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_or_junk_window_list_is_empty() {
|
||||
assert!(parse_ui_elements("").is_empty());
|
||||
assert!(parse_ui_elements("not-json").is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// Native clipboard path; text is supplied on stdin, never process arguments.
|
||||
pub fn paste_command_on(display: &str) -> Vec<String> {
|
||||
vec![
|
||||
"env".into(),
|
||||
display_env(display),
|
||||
"python3".into(),
|
||||
"-c".into(),
|
||||
include_str!("clipboard.py").into(),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,11 +5,12 @@ use axum::extract::State;
|
|||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use lazyboy_contracts::ComputerAction;
|
||||
use lazyboy_contracts::{ComputerAction, RefVerb};
|
||||
use lazyboy_control::{
|
||||
ActionRequest, PRIMARY_DISPLAY, action_pause_ms, launch_argv_on, normalize_display,
|
||||
open_argv_on, parse_pointer_state, parse_ui_elements, pointer_state_command_on,
|
||||
screenshot_command_on, window_list_command_on, xdotool_argv_on,
|
||||
ActionRequest, PRIMARY_DISPLAY, a11y_command_on, action_pause_ms, cdp_command_on,
|
||||
launch_argv_on, normalize_display, open_argv_on, parse_a11y_page, parse_cdp_page,
|
||||
parse_pointer_state, parse_ui_elements, pointer_state_command_on, screenshot_command_on,
|
||||
window_list_command_on, xdotool_argv_on,
|
||||
};
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::process::Command;
|
||||
|
|
@ -153,6 +154,12 @@ async fn apply_action(
|
|||
.ok_or_else(|| "unknown application".to_string())?;
|
||||
spawn_detached(&argv).await
|
||||
}
|
||||
ComputerAction::Ref {
|
||||
verb,
|
||||
target,
|
||||
ref_kind,
|
||||
text,
|
||||
} => apply_ref(display, profile, *verb, target, ref_kind, text.as_deref()).await,
|
||||
other => {
|
||||
let argv =
|
||||
xdotool_argv_on(display, other).ok_or_else(|| "unsupported action".to_string())?;
|
||||
|
|
@ -170,6 +177,51 @@ async fn apply_action(
|
|||
}
|
||||
}
|
||||
|
||||
async fn apply_ref(
|
||||
display: &str,
|
||||
profile: Option<&str>,
|
||||
verb: RefVerb,
|
||||
target: &str,
|
||||
kind: &str,
|
||||
text: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let action = match verb {
|
||||
RefVerb::Click => "click",
|
||||
RefVerb::SetValue => "type",
|
||||
RefVerb::Focus => "focus",
|
||||
};
|
||||
let mut request = serde_json::json!({
|
||||
"action": action,
|
||||
"selector": target,
|
||||
"display": display,
|
||||
"ensure": false,
|
||||
});
|
||||
if let Some(text) = text {
|
||||
request["text"] = serde_json::json!(text);
|
||||
}
|
||||
let argv = if kind == "dom" {
|
||||
cdp_command_on(display, profile, &request)
|
||||
} else {
|
||||
a11y_command_on(display, &request)
|
||||
};
|
||||
let output = Command::new(&argv[0])
|
||||
.args(&argv[1..])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
let raw = if output.stdout.is_empty() {
|
||||
String::from_utf8_lossy(&output.stderr).into_owned()
|
||||
} else {
|
||||
String::from_utf8_lossy(&output.stdout).into_owned()
|
||||
};
|
||||
let ok = if kind == "dom" {
|
||||
parse_cdp_page(&raw).ok
|
||||
} else {
|
||||
parse_a11y_page(&raw).ok
|
||||
};
|
||||
if ok { Ok(()) } else { Err(raw) }
|
||||
}
|
||||
|
||||
async fn spawn_detached(argv: &[String]) -> Result<(), String> {
|
||||
Command::new(&argv[0])
|
||||
.args(&argv[1..])
|
||||
|
|
@ -186,14 +238,15 @@ async fn observation_json(display: &str, png: Vec<u8>) -> serde_json::Value {
|
|||
let mut body = serde_json::json!({
|
||||
"png_base64": base64::engine::general_purpose::STANDARD.encode(png)
|
||||
});
|
||||
let (cursor, window) = run_pointer_state(display).await;
|
||||
let ((cursor, window), elements) =
|
||||
tokio::join!(run_pointer_state(display), run_window_list(display));
|
||||
if let Some(cursor) = cursor {
|
||||
body["cursor"] = serde_json::json!({ "x": cursor.x, "y": cursor.y });
|
||||
}
|
||||
if let Some(window) = window {
|
||||
body["activeWindow"] = serde_json::json!({ "id": window.id, "title": window.title });
|
||||
}
|
||||
let elements = run_window_list(display).await;
|
||||
|
||||
if !elements.is_empty() {
|
||||
body["elements"] = serde_json::to_value(elements).unwrap_or(serde_json::json!([]));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,3 +10,5 @@ lazyboy-contracts.workspace = true
|
|||
serde.workspace = true
|
||||
thiserror.workspace = true
|
||||
rig-core.workspace = true
|
||||
|
||||
reqwest.workspace = true
|
||||
|
|
|
|||
|
|
@ -106,8 +106,13 @@ fn rewrite_loopback_host(url: &str) -> String {
|
|||
if !supervisor.contains("://supervisor") {
|
||||
return url.to_string();
|
||||
}
|
||||
url.replace("://127.0.0.1", "://host.docker.internal")
|
||||
.replace("://localhost", "://host.docker.internal")
|
||||
let Ok(mut parsed) = reqwest::Url::parse(url) else {
|
||||
return url.to_string();
|
||||
};
|
||||
if matches!(parsed.host_str(), Some("127.0.0.1" | "localhost" | "[::1]")) {
|
||||
let _ = parsed.set_host(Some("host.docker.internal"));
|
||||
}
|
||||
parsed.to_string().trim_end_matches('/').to_string()
|
||||
}
|
||||
|
||||
pub enum DynModel {
|
||||
|
|
|
|||
|
|
@ -57,6 +57,29 @@ impl DockerSandbox {
|
|||
fn url(&self, path: &str) -> String {
|
||||
format!("{}{path}", self.base_url)
|
||||
}
|
||||
|
||||
async fn post_lifecycle(
|
||||
&self,
|
||||
computer: &ComputerRef,
|
||||
context: &AdapterContext,
|
||||
action: &str,
|
||||
) -> Result<(), SandboxError> {
|
||||
let response = self
|
||||
.client
|
||||
.post(self.url(&format!("/computers/{}/{action}", computer.id)))
|
||||
.headers(self.headers(context))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| SandboxError::message(error.to_string()))?;
|
||||
if response.status().is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(SandboxError::message(format!(
|
||||
"{action} failed: {}",
|
||||
response.status()
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
|
@ -345,18 +368,44 @@ impl SandboxProvider for DockerSandbox {
|
|||
}
|
||||
}
|
||||
|
||||
async fn suspend(
|
||||
&self,
|
||||
computer: &ComputerRef,
|
||||
context: &AdapterContext,
|
||||
) -> Result<(), SandboxError> {
|
||||
self.post_lifecycle(computer, context, "pause").await
|
||||
}
|
||||
|
||||
async fn resume(
|
||||
&self,
|
||||
request: ProvisionRequest,
|
||||
context: &AdapterContext,
|
||||
) -> Result<ComputerRef, SandboxError> {
|
||||
if let Some(provider_ref) = request.provider_ref.clone() {
|
||||
let computer = ComputerRef {
|
||||
id: provider_ref.clone(),
|
||||
home_key: request.home_key.clone(),
|
||||
kind: SandboxKind::Docker,
|
||||
provider_ref,
|
||||
fresh: false,
|
||||
};
|
||||
if self
|
||||
.post_lifecycle(&computer, context, "unpause")
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
return Ok(computer);
|
||||
}
|
||||
}
|
||||
self.provision(request, context).await
|
||||
}
|
||||
|
||||
async fn stop(
|
||||
&self,
|
||||
computer: &ComputerRef,
|
||||
context: &AdapterContext,
|
||||
) -> Result<(), SandboxError> {
|
||||
self.client
|
||||
.post(self.url(&format!("/computers/{}/stop", computer.id)))
|
||||
.headers(self.headers(context))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| SandboxError::message(error.to_string()))?;
|
||||
Ok(())
|
||||
self.post_lifecycle(computer, context, "stop").await
|
||||
}
|
||||
|
||||
async fn destroy(
|
||||
|
|
|
|||
|
|
@ -166,6 +166,24 @@ impl SandboxProvider for FakeSandbox {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn suspend(
|
||||
&self,
|
||||
_computer: &ComputerRef,
|
||||
_context: &AdapterContext,
|
||||
) -> Result<(), SandboxError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn resume(
|
||||
&self,
|
||||
request: ProvisionRequest,
|
||||
context: &AdapterContext,
|
||||
) -> Result<ComputerRef, SandboxError> {
|
||||
let mut computer = self.provision(request, context).await?;
|
||||
computer.fresh = false;
|
||||
Ok(computer)
|
||||
}
|
||||
|
||||
async fn stop(
|
||||
&self,
|
||||
_computer: &ComputerRef,
|
||||
|
|
|
|||
|
|
@ -21,3 +21,7 @@ base64.workspace = true
|
|||
reqwest.workspace = true
|
||||
async-trait = "0.1"
|
||||
futures-util = "0.3"
|
||||
|
||||
hmac.workspace = true
|
||||
sha2.workspace = true
|
||||
hex.workspace = true
|
||||
|
|
|
|||
|
|
@ -14,9 +14,8 @@ use bollard::network::CreateNetworkOptions;
|
|||
use futures_util::StreamExt;
|
||||
use lazyboy_control::{
|
||||
ActionRequest, CommandRequest, CommandResult, EnsureScreenRequest, EnsureScreenResult, HOME,
|
||||
ScreenTarget, TEAM_SCREEN_LIMIT, action_pause_ms, launch_argv_on, normalize_display,
|
||||
normalize_workspace_path, open_argv_on, pointer_state_command_on, screen_layout,
|
||||
screenshot_command_on, window_list_command_on, xdotool_argv_on,
|
||||
ScreenTarget, TEAM_SCREEN_LIMIT, normalize_display, normalize_workspace_path,
|
||||
pointer_state_command_on, screen_layout, screenshot_command_on, window_list_command_on,
|
||||
};
|
||||
use tokio::time::{Duration, sleep};
|
||||
|
||||
|
|
@ -68,22 +67,40 @@ impl DockerHost {
|
|||
home_path: &str,
|
||||
space_id: &str,
|
||||
) -> Result<Provisioned, String> {
|
||||
let home_path = host_bind_path(home_path);
|
||||
tokio::fs::create_dir_all(&home_path)
|
||||
let data_dir = std::env::var("DATA_DIR").unwrap_or_else(|_| "./data".into());
|
||||
if home_key.is_empty()
|
||||
|| !home_key
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b"-_".contains(&b))
|
||||
{
|
||||
return Err("invalid home key".into());
|
||||
}
|
||||
let expected = PathBuf::from(&data_dir).join("homes").join(home_key);
|
||||
if PathBuf::from(home_path) != expected {
|
||||
return Err("home path is outside managed homes".into());
|
||||
}
|
||||
// Work on the container-local path, not the host daemon's bind path.
|
||||
tokio::fs::create_dir_all(&expected)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
let _ = tokio::process::Command::new("chown")
|
||||
.args(["-R", "1000:1000", &home_path])
|
||||
.status()
|
||||
.await;
|
||||
.map_err(|e| e.to_string())?;
|
||||
let canonical = tokio::fs::canonicalize(&expected)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let root = tokio::fs::canonicalize(&data_dir)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if canonical != root.join("homes").join(home_key) {
|
||||
return Err("symlinked home is not allowed".into());
|
||||
}
|
||||
#[cfg(unix)]
|
||||
if std::env::var("HOST_DATA_DIR").is_ok() {
|
||||
std::os::unix::fs::chown(&canonical, Some(1000), Some(1000))
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
let home_path = host_bind_path(home_path);
|
||||
if let Some(existing) = self.find(home_key).await? {
|
||||
if self.container_reusable(&existing).await.unwrap_or(false) {
|
||||
self.docker
|
||||
.start_container(&existing, None::<StartContainerOptions<String>>)
|
||||
.await
|
||||
.ok();
|
||||
self.wait_running(&existing).await?;
|
||||
self.wait_ready(&existing).await?;
|
||||
self.wake(&existing).await?;
|
||||
let screen_url = self.screen_url(&existing, false).await.ok();
|
||||
return Ok(Provisioned {
|
||||
id: existing,
|
||||
|
|
@ -100,6 +117,7 @@ impl DockerHost {
|
|||
let mut labels = HashMap::new();
|
||||
labels.insert("lazyboy.homeKey".into(), home_key.to_string());
|
||||
labels.insert("lazyboy.spaceId".into(), space_id.to_string());
|
||||
labels.insert("lazyboy.controlVersion".into(), "2".into());
|
||||
|
||||
let mut port_bindings = HashMap::new();
|
||||
let mut exposed = HashMap::new();
|
||||
|
|
@ -136,7 +154,10 @@ impl DockerHost {
|
|||
env: Some(vec![
|
||||
"DISPLAY=:1".into(),
|
||||
format!("HOME={HOME}"),
|
||||
format!("LAZYBOY_CONTROL_TOKEN={}", self.control_token),
|
||||
format!(
|
||||
"LAZYBOY_CONTROL_TOKEN={}",
|
||||
scoped_control_token(&self.control_token, home_key)
|
||||
),
|
||||
]),
|
||||
labels: Some(labels),
|
||||
exposed_ports: Some(exposed),
|
||||
|
|
@ -212,12 +233,26 @@ impl DockerHost {
|
|||
}
|
||||
None => HOME.to_string(),
|
||||
};
|
||||
let timeout_ms = request.timeout_ms.unwrap_or(30_000).clamp(100, 120_000);
|
||||
let argv = if request.argv.is_empty() {
|
||||
vec!["/bin/echo".into(), "ready".into()]
|
||||
} else {
|
||||
request.argv
|
||||
};
|
||||
self.exec_argv(id, &argv, Some(&cwd), &ScreenTarget::default())
|
||||
let mut bounded = vec![
|
||||
"timeout".into(),
|
||||
"--signal=TERM".into(),
|
||||
"--kill-after=2s".into(),
|
||||
format!("{}s", timeout_ms as f64 / 1000.0),
|
||||
];
|
||||
bounded.extend(argv);
|
||||
self.exec_raw_cmd(
|
||||
id,
|
||||
&bounded,
|
||||
Some(&cwd),
|
||||
&ScreenTarget::default(),
|
||||
request.stdin,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
|
|
@ -239,12 +274,21 @@ impl DockerHost {
|
|||
}
|
||||
None => HOME.to_string(),
|
||||
};
|
||||
let timeout_ms = request.timeout_ms.unwrap_or(30_000).clamp(100, 120_000);
|
||||
let argv = if request.argv.is_empty() {
|
||||
vec!["/bin/echo".into(), "ready".into()]
|
||||
} else {
|
||||
request.argv
|
||||
};
|
||||
self.exec_argv(id, &argv, Some(&cwd), target).await
|
||||
let mut bounded = vec![
|
||||
"timeout".into(),
|
||||
"--signal=TERM".into(),
|
||||
"--kill-after=2s".into(),
|
||||
format!("{}s", timeout_ms as f64 / 1000.0),
|
||||
];
|
||||
bounded.extend(argv);
|
||||
self.exec_raw_cmd(id, &bounded, Some(&cwd), target, request.stdin)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn observe(&self, id: &str) -> Result<Vec<u8>, String> {
|
||||
|
|
@ -263,7 +307,13 @@ impl DockerHost {
|
|||
value
|
||||
} else {
|
||||
let (stdout, stderr, code) = self
|
||||
.exec_raw(id, &screenshot_command_on(&target.display), None, target)
|
||||
.exec_raw(
|
||||
id,
|
||||
&screenshot_command_on(&target.display),
|
||||
None,
|
||||
target,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
if code != 0 {
|
||||
return Err(String::from_utf8_lossy(&stderr).into_owned());
|
||||
|
|
@ -343,63 +393,8 @@ impl DockerHost {
|
|||
request.profile_path.as_deref(),
|
||||
None,
|
||||
);
|
||||
if let Ok(body) = self.control_act(id, &request, &target).await {
|
||||
return Ok(body);
|
||||
}
|
||||
let mut completed = 0usize;
|
||||
for action in &request.actions {
|
||||
match action {
|
||||
lazyboy_contracts::ComputerAction::Wait { ms } => {
|
||||
sleep(Duration::from_millis(*ms as u64)).await;
|
||||
}
|
||||
lazyboy_contracts::ComputerAction::Open { path } => {
|
||||
let _ = self
|
||||
.exec_argv(
|
||||
id,
|
||||
&open_argv_on(&target.display, target.profile_path.as_deref(), path),
|
||||
None,
|
||||
&target,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
lazyboy_contracts::ComputerAction::Launch { application, uri } => {
|
||||
let argv = launch_argv_on(
|
||||
&target.display,
|
||||
target.profile_path.as_deref(),
|
||||
application,
|
||||
uri.as_deref(),
|
||||
)
|
||||
.ok_or_else(|| "unknown application".to_string())?;
|
||||
let _ = self.exec_argv(id, &argv, None, &target).await?;
|
||||
}
|
||||
other => {
|
||||
let argv = xdotool_argv_on(&target.display, other)
|
||||
.ok_or_else(|| "unsupported action".to_string())?;
|
||||
let result = self.exec_argv(id, &argv, None, &target).await?;
|
||||
if result.code != 0 {
|
||||
return Err(result.stderr);
|
||||
}
|
||||
}
|
||||
}
|
||||
let pause = action_pause_ms(action);
|
||||
if pause > 0 {
|
||||
sleep(Duration::from_millis(pause)).await;
|
||||
}
|
||||
completed += 1;
|
||||
}
|
||||
if request.settle_ms > 0 {
|
||||
sleep(Duration::from_millis(request.settle_ms as u64)).await;
|
||||
}
|
||||
let mut body = serde_json::json!({ "completed": completed });
|
||||
if request.observe {
|
||||
let payload = self.observe_payload(id, &target).await?;
|
||||
if let serde_json::Value::Object(map) = payload.json {
|
||||
if let Some(object) = body.as_object_mut() {
|
||||
object.extend(map);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(body)
|
||||
// A transport failure may follow a successful click. Never replay mutations.
|
||||
self.control_act(id, &request, &target).await
|
||||
}
|
||||
|
||||
pub async fn screen_url(&self, id: &str, interactive: bool) -> Result<String, String> {
|
||||
|
|
@ -536,6 +531,7 @@ PY"#,
|
|||
],
|
||||
None,
|
||||
&ScreenTarget::default(),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
if code != 0 {
|
||||
|
|
@ -572,6 +568,18 @@ PY"#,
|
|||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub async fn pause(&self, id: &str) -> Result<(), String> {
|
||||
match self.docker.pause_container(id).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if docker_already(&error.to_string(), "paused") => Ok(()),
|
||||
Err(error) => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn unpause(&self, id: &str) -> Result<(), String> {
|
||||
self.wake(id).await
|
||||
}
|
||||
|
||||
pub async fn destroy(&self, id: &str) -> Result<(), String> {
|
||||
let info = self.docker.inspect_container(id, None).await.ok();
|
||||
let home_key = info
|
||||
|
|
@ -610,17 +618,31 @@ PY"#,
|
|||
.inspect_container(id, None)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
if info
|
||||
.config
|
||||
.as_ref()
|
||||
.and_then(|c| c.labels.as_ref())
|
||||
.and_then(|l| l.get("lazyboy.controlVersion"))
|
||||
.map(String::as_str)
|
||||
!= Some("2")
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
let wanted = self.current_image_id().await?;
|
||||
let have = info.image.unwrap_or_default();
|
||||
if !image_ids_match(&wanted, &have) {
|
||||
return Ok(false);
|
||||
}
|
||||
let running = info.state.as_ref().and_then(|state| state.running) == Some(true);
|
||||
let paused = info.state.as_ref().and_then(|state| state.paused) == Some(true);
|
||||
let exit = info
|
||||
.state
|
||||
.as_ref()
|
||||
.and_then(|state| state.exit_code)
|
||||
.unwrap_or(0);
|
||||
if paused {
|
||||
return Ok(true);
|
||||
}
|
||||
if !running && exit != 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
|
|
@ -659,6 +681,33 @@ PY"#,
|
|||
}
|
||||
}
|
||||
|
||||
async fn wake(&self, id: &str) -> Result<(), String> {
|
||||
let info = self
|
||||
.docker
|
||||
.inspect_container(id, None)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
let running = info.state.as_ref().and_then(|state| state.running) == Some(true);
|
||||
let paused = info.state.as_ref().and_then(|state| state.paused) == Some(true);
|
||||
if paused {
|
||||
match self.docker.unpause_container(id).await {
|
||||
Ok(()) => {}
|
||||
Err(error) if docker_already(&error.to_string(), "not paused") => {}
|
||||
Err(error) => return Err(error.to_string()),
|
||||
}
|
||||
return self.wait_ready_fast(id).await;
|
||||
}
|
||||
if running {
|
||||
return self.wait_ready_fast(id).await;
|
||||
}
|
||||
self.docker
|
||||
.start_container(id, None::<StartContainerOptions<String>>)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
self.wait_running(id).await?;
|
||||
self.wait_ready(id).await
|
||||
}
|
||||
|
||||
async fn wait_running(&self, id: &str) -> Result<(), String> {
|
||||
for _ in 0..40 {
|
||||
let info = self
|
||||
|
|
@ -675,12 +724,23 @@ PY"#,
|
|||
}
|
||||
|
||||
async fn wait_ready(&self, id: &str) -> Result<(), String> {
|
||||
for _ in 0..160 {
|
||||
self.wait_ready_attempts(id, 160).await
|
||||
}
|
||||
|
||||
async fn wait_ready_fast(&self, id: &str) -> Result<(), String> {
|
||||
self.wait_ready_attempts(id, 20).await
|
||||
}
|
||||
|
||||
async fn wait_ready_attempts(&self, id: &str, attempts: u32) -> Result<(), String> {
|
||||
for _ in 0..attempts {
|
||||
let info = self
|
||||
.docker
|
||||
.inspect_container(id, None)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
if info.state.as_ref().and_then(|state| state.paused) == Some(true) {
|
||||
return Err("computer is still paused".into());
|
||||
}
|
||||
if info.state.as_ref().and_then(|state| state.running) != Some(true) {
|
||||
let exit = info.state.and_then(|state| state.exit_code).unwrap_or(1);
|
||||
return Err(format!("computer exited during startup with code {exit}"));
|
||||
|
|
@ -708,7 +768,20 @@ PY"#,
|
|||
cwd: Option<&str>,
|
||||
target: &ScreenTarget,
|
||||
) -> Result<CommandResult, String> {
|
||||
let (stdout, stderr, code) = self.exec_raw(id, argv, cwd, target).await?;
|
||||
self.exec_raw_cmd(id, argv, cwd, target, None).await
|
||||
}
|
||||
|
||||
async fn exec_raw_cmd(
|
||||
&self,
|
||||
id: &str,
|
||||
argv: &[String],
|
||||
cwd: Option<&str>,
|
||||
target: &ScreenTarget,
|
||||
stdin: Option<String>,
|
||||
) -> Result<CommandResult, String> {
|
||||
let (stdout, stderr, code) = self
|
||||
.exec_raw(id, argv, cwd, target, stdin.as_deref())
|
||||
.await?;
|
||||
Ok(CommandResult {
|
||||
stdout: String::from_utf8_lossy(&stdout).into_owned(),
|
||||
stderr: String::from_utf8_lossy(&stderr).into_owned(),
|
||||
|
|
@ -722,6 +795,7 @@ PY"#,
|
|||
argv: &[String],
|
||||
cwd: Option<&str>,
|
||||
target: &ScreenTarget,
|
||||
stdin: Option<&str>,
|
||||
) -> Result<(Vec<u8>, Vec<u8>, i32), String> {
|
||||
let display = normalize_display(&target.display);
|
||||
let mut env = vec![
|
||||
|
|
@ -737,6 +811,7 @@ PY"#,
|
|||
.create_exec(
|
||||
id,
|
||||
CreateExecOptions {
|
||||
attach_stdin: Some(stdin.is_some()),
|
||||
attach_stdout: Some(true),
|
||||
attach_stderr: Some(true),
|
||||
cmd: Some(argv.to_vec()),
|
||||
|
|
@ -750,7 +825,10 @@ PY"#,
|
|||
.map_err(|error| error.to_string())?;
|
||||
let mut stdout = Vec::new();
|
||||
let mut stderr = Vec::new();
|
||||
if let StartExecResults::Attached { mut output, .. } = self
|
||||
if let StartExecResults::Attached {
|
||||
mut output,
|
||||
mut input,
|
||||
} = self
|
||||
.docker
|
||||
.start_exec(
|
||||
&exec.id,
|
||||
|
|
@ -761,14 +839,26 @@ PY"#,
|
|||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
{
|
||||
if let Some(body) = stdin {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
input
|
||||
.write_all(body.as_bytes())
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
input.shutdown().await.map_err(|error| error.to_string())?;
|
||||
}
|
||||
while let Some(chunk) = output.next().await {
|
||||
match chunk.map_err(|error| error.to_string())? {
|
||||
bollard::container::LogOutput::StdOut { message } => {
|
||||
stdout.extend_from_slice(&message)
|
||||
}
|
||||
bollard::container::LogOutput::StdErr { message } => {
|
||||
stderr.extend_from_slice(&message)
|
||||
}
|
||||
bollard::container::LogOutput::StdOut { message } => stdout.extend_from_slice(
|
||||
&message[..message
|
||||
.len()
|
||||
.min((16 * 1024 * 1024usize).saturating_sub(stdout.len()))],
|
||||
),
|
||||
bollard::container::LogOutput::StdErr { message } => stderr.extend_from_slice(
|
||||
&message[..message
|
||||
.len()
|
||||
.min((1024 * 1024usize).saturating_sub(stderr.len()))],
|
||||
),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
@ -781,17 +871,45 @@ PY"#,
|
|||
Ok((stdout, stderr, inspect.exit_code.unwrap_or(1) as i32))
|
||||
}
|
||||
|
||||
pub async fn container_control_token(&self, id: &str) -> Result<String, String> {
|
||||
let info = self
|
||||
.docker
|
||||
.inspect_container(id, None)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let labels = info
|
||||
.config
|
||||
.and_then(|c| c.labels)
|
||||
.ok_or("unmanaged container")?;
|
||||
let home = labels.get("lazyboy.homeKey").ok_or("unmanaged container")?;
|
||||
Ok(scoped_control_token(&self.control_token, home))
|
||||
}
|
||||
|
||||
async fn control_observe_json(
|
||||
&self,
|
||||
id: &str,
|
||||
target: &ScreenTarget,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let script = format!(
|
||||
"curl -fsS -H 'Authorization: Bearer {}' -H 'x-lazyboy-display: {}' http://127.0.0.1:7070/observe",
|
||||
self.control_token, target.display
|
||||
);
|
||||
let token = self.container_control_token(id).await?;
|
||||
let result = self
|
||||
.exec_argv(id, &["bash".into(), "-lc".into(), script], None, target)
|
||||
.exec_argv(
|
||||
id,
|
||||
&[
|
||||
"curl".into(),
|
||||
"-fsS".into(),
|
||||
"--max-time".into(),
|
||||
"20".into(),
|
||||
"-X".into(),
|
||||
"POST".into(),
|
||||
"-H".into(),
|
||||
format!("Authorization: Bearer {token}"),
|
||||
"-H".into(),
|
||||
format!("x-lazyboy-display: {}", target.display),
|
||||
"http://127.0.0.1:7070/observe".into(),
|
||||
],
|
||||
None,
|
||||
target,
|
||||
)
|
||||
.await?;
|
||||
if result.code != 0 {
|
||||
return Err(result.stderr);
|
||||
|
|
@ -806,19 +924,29 @@ PY"#,
|
|||
target: &ScreenTarget,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let payload = serde_json::to_string(request).map_err(|error| error.to_string())?;
|
||||
let profile_header = target
|
||||
.profile_path
|
||||
.as_deref()
|
||||
.map(|profile| format!(" -H 'x-lazyboy-profile: {profile}'"))
|
||||
.unwrap_or_default();
|
||||
let script = format!(
|
||||
"curl -fsS -H 'Authorization: Bearer {}' -H 'x-lazyboy-display: {}'{profile_header} -H 'content-type: application/json' -d {} http://127.0.0.1:7070/act",
|
||||
self.control_token,
|
||||
target.display,
|
||||
shell_single_quote(&payload)
|
||||
);
|
||||
let token = self.container_control_token(id).await?;
|
||||
let mut argv = vec![
|
||||
"curl".into(),
|
||||
"-fsS".into(),
|
||||
"--max-time".into(),
|
||||
"120".into(),
|
||||
"-H".into(),
|
||||
format!("Authorization: Bearer {token}"),
|
||||
"-H".into(),
|
||||
format!("x-lazyboy-display: {}", target.display),
|
||||
"-H".into(),
|
||||
"content-type: application/json".into(),
|
||||
];
|
||||
if let Some(profile) = &target.profile_path {
|
||||
argv.extend(["-H".into(), format!("x-lazyboy-profile: {profile}")]);
|
||||
}
|
||||
argv.extend([
|
||||
"--data-binary".into(),
|
||||
"@-".into(),
|
||||
"http://127.0.0.1:7070/act".into(),
|
||||
]);
|
||||
let result = self
|
||||
.exec_argv(id, &["bash".into(), "-lc".into(), script], None, target)
|
||||
.exec_raw_cmd(id, &argv, None, target, Some(payload))
|
||||
.await?;
|
||||
if result.code != 0 {
|
||||
return Err(result.stderr);
|
||||
|
|
@ -871,6 +999,11 @@ fn computer_pids_limit() -> i64 {
|
|||
.unwrap_or(2048)
|
||||
}
|
||||
|
||||
fn docker_already(error: &str, needle: &str) -> bool {
|
||||
let error = error.to_ascii_lowercase();
|
||||
error.contains("409") || error.contains(needle)
|
||||
}
|
||||
|
||||
fn container_name(home_key: &str) -> String {
|
||||
let sanitized: String = home_key
|
||||
.chars()
|
||||
|
|
@ -890,3 +1023,26 @@ fn network_name(home_key: &str) -> String {
|
|||
fn shell_single_quote(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', r#"'"'"'"#))
|
||||
}
|
||||
|
||||
fn scoped_control_token(master: &str, home: &str) -> String {
|
||||
use hmac::{Hmac, Mac};
|
||||
let mut mac = Hmac::<sha2::Sha256>::new_from_slice(master.as_bytes())
|
||||
.expect("HMAC accepts any key length");
|
||||
mac.update(b"lazyboy-computer-control-v2:");
|
||||
mac.update(home.as_bytes());
|
||||
hex::encode(mac.finalize().into_bytes())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod credential_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn computer_credentials_do_not_reveal_or_share_the_master() {
|
||||
let master = "test-master-key-at-least-32-characters";
|
||||
let a = scoped_control_token(master, "a");
|
||||
let b = scoped_control_token(master, "b");
|
||||
assert_ne!(a, master);
|
||||
assert_ne!(a, b);
|
||||
assert_eq!(a, scoped_control_token(master, "a"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,15 @@ struct ProvisionBody {
|
|||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
if std::env::args().any(|arg| arg == "--healthcheck") {
|
||||
let ok = reqwest::Client::new()
|
||||
.get("http://127.0.0.1:7091/health")
|
||||
.timeout(std::time::Duration::from_secs(3))
|
||||
.send()
|
||||
.await
|
||||
.is_ok_and(|r| r.status().is_success());
|
||||
std::process::exit(if ok { 0 } else { 1 });
|
||||
}
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(EnvFilter::from_default_env().add_directive("info".parse().unwrap()))
|
||||
.init();
|
||||
|
|
@ -43,6 +52,15 @@ async fn main() {
|
|||
);
|
||||
let image =
|
||||
std::env::var("LAZYBOY_COMPUTER_IMAGE").unwrap_or_else(|_| "lazyboy/computer:local".into());
|
||||
let data_dir = std::env::var("DATA_DIR").unwrap_or_else(|_| "./data".into());
|
||||
tokio::fs::create_dir_all(&data_dir)
|
||||
.await
|
||||
.expect("create data directory");
|
||||
#[cfg(unix)]
|
||||
if std::env::var("HOST_DATA_DIR").is_ok() {
|
||||
std::os::unix::fs::chown(&data_dir, Some(1000), Some(1000))
|
||||
.expect("set data directory owner");
|
||||
}
|
||||
let docker = DockerHost::connect(image, token.clone())
|
||||
.await
|
||||
.expect("docker");
|
||||
|
|
@ -64,7 +82,13 @@ async fn main() {
|
|||
.route("/computers/{id}/files", get(list_files).post(write_file))
|
||||
.route("/computers/{id}/read", post(read_file))
|
||||
.route("/computers/{id}/stop", post(stop))
|
||||
.route("/computers/{id}/pause", post(pause))
|
||||
.route("/computers/{id}/unpause", post(unpause))
|
||||
.route("/computers/{id}", delete(destroy))
|
||||
.route_layer(axum::middleware::from_fn_with_state(
|
||||
app.clone(),
|
||||
managed_boundary,
|
||||
))
|
||||
.with_state(app);
|
||||
let bind = std::env::var("SUPERVISOR_BIND").unwrap_or_else(|_| "127.0.0.1:7091".into());
|
||||
let listener = tokio::net::TcpListener::bind(&bind).await.expect("bind");
|
||||
|
|
@ -301,6 +325,32 @@ async fn stop(
|
|||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn pause(
|
||||
State(app): State<App>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
require_token(&headers, &app.token)?;
|
||||
app.docker.pause(&id).await.map_err(|error| {
|
||||
tracing::error!("pause: {error}");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn unpause(
|
||||
State(app): State<App>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
require_token(&headers, &app.token)?;
|
||||
app.docker.unpause(&id).await.map_err(|error| {
|
||||
tracing::error!("unpause: {error}");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn destroy(
|
||||
State(app): State<App>,
|
||||
headers: HeaderMap,
|
||||
|
|
@ -321,3 +371,27 @@ struct _Home(&'static str);
|
|||
fn _assert_home() {
|
||||
let _ = HOME;
|
||||
}
|
||||
|
||||
async fn managed_boundary(
|
||||
State(app): State<App>,
|
||||
req: axum::extract::Request,
|
||||
next: axum::middleware::Next,
|
||||
) -> axum::response::Response {
|
||||
use axum::response::IntoResponse;
|
||||
if req.uri().path() != "/health" {
|
||||
if require_token(req.headers(), &app.token).is_err() {
|
||||
return StatusCode::UNAUTHORIZED.into_response();
|
||||
}
|
||||
if let Some(id) = req
|
||||
.uri()
|
||||
.path()
|
||||
.strip_prefix("/computers/")
|
||||
.and_then(|p| p.split('/').next())
|
||||
{
|
||||
if app.docker.container_control_token(id).await.is_err() {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
next.run(req).await
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
# Opt-in host access for local Rust development only.
|
||||
services:
|
||||
postgres:
|
||||
ports:
|
||||
- "127.0.0.1:5434:5432"
|
||||
|
|
@ -1,12 +1,15 @@
|
|||
services:
|
||||
postgres:
|
||||
restart: unless-stopped
|
||||
networks: [database]
|
||||
logging: &bounded-logs
|
||||
driver: json-file
|
||||
options: {max-size: "10m", max-file: "3"}
|
||||
image: pgvector/pgvector:pg16
|
||||
environment:
|
||||
POSTGRES_USER: lazyboy
|
||||
POSTGRES_PASSWORD: lazyboy
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-lazyboy}
|
||||
POSTGRES_DB: lazyboy
|
||||
ports:
|
||||
- "127.0.0.1:5434:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
|
|
@ -25,6 +28,19 @@ services:
|
|||
network_mode: none
|
||||
|
||||
supervisor:
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
networks: [control]
|
||||
logging: *bounded-logs
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
cap_drop: [ALL]
|
||||
cap_add: [CHOWN, DAC_OVERRIDE]
|
||||
read_only: true
|
||||
healthcheck:
|
||||
test: ["CMD", "/usr/local/bin/lazyboy-supervisor", "--healthcheck"]
|
||||
interval: 5s
|
||||
timeout: 4s
|
||||
retries: 12
|
||||
build:
|
||||
context: .
|
||||
dockerfile: image/supervisor/Dockerfile
|
||||
|
|
@ -33,7 +49,7 @@ services:
|
|||
LAZYBOY_COMPUTER_IMAGE: lazyboy/computer:local
|
||||
SUPERVISOR_BIND: 0.0.0.0:7091
|
||||
DATA_DIR: /data
|
||||
HOST_DATA_DIR: ${PWD}/data
|
||||
HOST_DATA_DIR: ${LAZYBOY_HOST_DATA_DIR:-${PWD}/data}
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./data:/data
|
||||
|
|
@ -44,18 +60,26 @@ services:
|
|||
condition: service_completed_successfully
|
||||
|
||||
api:
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
networks: [database, control, egress]
|
||||
logging: *bounded-logs
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
cap_drop: [ALL]
|
||||
pids_limit: 256
|
||||
build:
|
||||
context: .
|
||||
dockerfile: image/api/Dockerfile
|
||||
environment:
|
||||
DATABASE_URL: postgres://lazyboy:lazyboy@postgres:5432/lazyboy
|
||||
DATABASE_URL: postgres://lazyboy:${POSTGRES_PASSWORD:-lazyboy}@postgres:5432/lazyboy
|
||||
SANDBOX_SUPERVISOR_URL: http://supervisor:7091
|
||||
SANDBOX_SUPERVISOR_TOKEN: ${SANDBOX_SUPERVISOR_TOKEN:?Set SANDBOX_SUPERVISOR_TOKEN in .env}
|
||||
LAZYBOY_APP_TOKEN: ${LAZYBOY_APP_TOKEN:-}
|
||||
LAZYBOY_APP_TOKEN: ${LAZYBOY_APP_TOKEN:?Set LAZYBOY_APP_TOKEN in .env}
|
||||
LAZYBOY_VAULT_KEY: ${LAZYBOY_VAULT_KEY:-}
|
||||
LAZYBOY_SECURE_COOKIE: ${LAZYBOY_SECURE_COOKIE:-false}
|
||||
SANDBOX_PROVIDER: docker
|
||||
DATA_DIR: /data
|
||||
HOST_DATA_DIR: ${PWD}/data
|
||||
HOST_DATA_DIR: ${LAZYBOY_HOST_DATA_DIR:-${PWD}/data}
|
||||
API_BIND: 0.0.0.0:3100
|
||||
XAI_API_KEY: ${XAI_API_KEY:-}
|
||||
OPENCODE_GO_API_KEY: ${OPENCODE_GO_API_KEY:-}
|
||||
|
|
@ -67,7 +91,7 @@ services:
|
|||
LAZYBOY_WEB_DIR: /web
|
||||
LAZYBOY_SCREEN_UPSTREAM: host.docker.internal
|
||||
ports:
|
||||
- "3101:3100"
|
||||
- "${LAZYBOY_BIND_IP:-127.0.0.1}:3101:3100"
|
||||
volumes:
|
||||
- ./data:/data
|
||||
extra_hosts:
|
||||
|
|
@ -76,7 +100,14 @@ services:
|
|||
postgres:
|
||||
condition: service_healthy
|
||||
supervisor:
|
||||
condition: service_started
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
|
||||
networks:
|
||||
database:
|
||||
internal: true
|
||||
control:
|
||||
internal: true
|
||||
egress: {}
|
||||
|
|
|
|||
|
|
@ -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,空值禁止儲存;修正 Unix/Rust 星期編號差異 |
|
||||
| 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 提醒仍存在;未量測首屏時間。
|
||||
|
|
@ -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>單元 12/18</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>單元 12/18 · 看完按 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>單元 12/18 · 看完按 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>容器的生老病死,以及 XFCE/Chromium 長什麼樣。</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>
|
||||
|
After Width: | Height: | Size: 315 KiB |
|
After Width: | Height: | Size: 225 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 453 KiB |
|
After Width: | Height: | Size: 340 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 427 KiB |
|
After Width: | Height: | Size: 455 KiB |
|
|
@ -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>
|
||||
|
After Width: | Height: | Size: 263 KiB |
|
|
@ -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、伺服器期限及登出撤銷,加入 Origin/Fetch 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 開放 API,Postgres 不對主機映射。開發資料庫使用 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 daemon/VM。桌面 Chromium 既有 --no-sandbox 與可信 MCP 的執行邊界仍需納入威脅模型。基底映像尚未固定 digest,未做完整 OS image CVE 掃描;直接套件固定版本不代表所有下載資產都可完全重現。
|
||||
|
||||
## 驗證與剩餘告警
|
||||
|
||||
- Rust workspace:114 個測試通過,含隔離 PostgreSQL 測試;前端 Node 5 個、Python 3 個測試通過。
|
||||
- 前端 typecheck、production build、程式碼 diff 空白檢查(不含 README 的 Markdown 換行空白)、Compose config 驗證通過;三個 Docker 映像實際建置成功。
|
||||
- 隔離 API smoke test:未登入拒絕、跨站登入拒絕、合法登入可存取 API、可信 noVNC asset 可用、登出後舊 cookie 被拒絕;均使用最後建置的映像驗證。
|
||||
- npm audit:0。cargo-audit:RUSTSEC-2023-0071(rsa 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)
|
||||
|
|
@ -1,39 +1,59 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
FROM node:22-bookworm-slim AS web
|
||||
WORKDIR /src/apps/web
|
||||
COPY apps/web/package.json apps/web/package-lock.json ./
|
||||
RUN npm ci
|
||||
RUN --mount=type=cache,target=/root/.npm npm ci --no-audit --no-fund
|
||||
COPY apps/web/index.html apps/web/tsconfig.json apps/web/vite.config.ts ./
|
||||
COPY apps/web/src src
|
||||
RUN npm run build
|
||||
RUN npm run build \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends binutils \
|
||||
&& strip --strip-unneeded /usr/local/bin/node \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
FROM debian:trixie-slim AS python
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 python3-pip python3-lxml \
|
||||
&& pip3 install --break-system-packages --no-cache-dir \
|
||||
--no-compile --target /opt/python mcp-server-fetch==2026.8.18 \
|
||||
&& find /opt/python -type d -name __pycache__ -prune -exec rm -rf '{}' + \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
FROM rust:1-trixie AS build
|
||||
WORKDIR /src
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY crates crates
|
||||
COPY migrations migrations
|
||||
COPY apps/web apps/web
|
||||
RUN cargo build --release -p lazyboy-api
|
||||
COPY apps/web/vnc.html apps/web/vnc.html
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \
|
||||
--mount=type=cache,target=/src/target,id=lazyboy-api-release,sharing=locked \
|
||||
cargo build --locked --release -p lazyboy-api && cp /src/target/release/lazyboy-api /lazyboy-api
|
||||
|
||||
FROM debian:trixie-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates python3 python3-pip python3-lxml \
|
||||
&& pip3 install --break-system-packages --no-cache-dir mcp-server-fetch \
|
||||
ca-certificates python3 python3-lxml \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=web /usr/local/bin/node /usr/local/bin/node
|
||||
COPY --from=web /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/npm
|
||||
COPY --from=build /src/target/release/lazyboy-api /usr/local/bin/lazyboy-api
|
||||
COPY --from=python /opt/python /opt/python
|
||||
COPY --from=build /lazyboy-api /usr/local/bin/lazyboy-api
|
||||
COPY --from=web /src/apps/web/dist /web
|
||||
COPY --from=web /src/apps/web/node_modules/@novnc/novnc/core /web/novnc/core
|
||||
COPY --from=web /src/apps/web/node_modules/@novnc/novnc/vendor /web/novnc/vendor
|
||||
RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
|
||||
&& ln -sf /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx \
|
||||
&& npm install -g --omit=dev \
|
||||
@modelcontextprotocol/server-memory \
|
||||
@modelcontextprotocol/server-sequential-thinking \
|
||||
@notionhq/notion-mcp-server \
|
||||
@modelcontextprotocol/server-memory@2026.8.31 \
|
||||
@modelcontextprotocol/server-sequential-thinking@2026.8.31 \
|
||||
@notionhq/notion-mcp-server@2.5.1 \
|
||||
&& npm cache clean --force \
|
||||
&& node -v && npx --version \
|
||||
&& python3 -c "import mcp_server_fetch"
|
||||
ENV LAZYBOY_WEB_DIR=/web
|
||||
&& PYTHONPATH=/opt/python python3 -c "import mcp_server_fetch"
|
||||
ENV LAZYBOY_WEB_DIR=/web \
|
||||
PYTHONPATH=/opt/python
|
||||
ENV NPM_CONFIG_UPDATE_NOTIFIER=false
|
||||
ENV NPM_CONFIG_FUND=false
|
||||
RUN useradd --create-home --uid 1000 lazyboy
|
||||
USER 1000:1000
|
||||
EXPOSE 3100
|
||||
CMD ["lazyboy-api"]
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
# Real bot desktop. Every boot must look like a Linux workstation:
|
||||
# XFCE panel + window manager, Chromium, zsh terminal. Never a kiosk/HTML shell.
|
||||
# Traditional Chinese fonts/locale so CJK text does not mojibake.
|
||||
|
|
@ -9,11 +10,19 @@ WORKDIR /src
|
|||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY crates crates
|
||||
COPY migrations migrations
|
||||
RUN cargo build --release -p lazyboy-controld
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \
|
||||
--mount=type=cache,target=/src/target,id=lazyboy-computer-release,sharing=locked \
|
||||
cargo build --locked --release -p lazyboy-controld && cp /src/target/release/lazyboy-controld /lazyboy-controld
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# Keep Chromium, XFCE, CJK, AT-SPI, git. Skip Debian novnc (pulls nodejs),
|
||||
# fonts-noto-core (Latin is DejaVu/Liberation/huninn), and unused xterm.
|
||||
RUN printf '%s\n' \
|
||||
'path-include=/usr/share/locale/zh_TW/*' \
|
||||
'path-include=/usr/share/locale/zh/*' \
|
||||
> /etc/dpkg/dpkg.cfg.d/zz-lazyboy-locale \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
chromium \
|
||||
curl \
|
||||
|
|
@ -22,13 +31,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||
fonts-liberation \
|
||||
fonts-noto-cjk \
|
||||
fonts-noto-color-emoji \
|
||||
fonts-noto-core \
|
||||
git \
|
||||
htop \
|
||||
imagemagick \
|
||||
locales \
|
||||
novnc \
|
||||
procps \
|
||||
python3 \
|
||||
python3-websocket \
|
||||
util-linux \
|
||||
websockify \
|
||||
wmctrl \
|
||||
|
|
@ -44,17 +53,21 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||
xfdesktop4 \
|
||||
xdotool \
|
||||
xfwm4 \
|
||||
xterm \
|
||||
xvfb \
|
||||
thunar \
|
||||
adwaita-icon-theme \
|
||||
gnome-themes-extra \
|
||||
librsvg2-common \
|
||||
at-spi2-core \
|
||||
libatk-adaptor \
|
||||
python3-gi \
|
||||
gir1.2-atspi-2.0 \
|
||||
zsh \
|
||||
&& echo "zh_TW.UTF-8 UTF-8" >> /etc/locale.gen \
|
||||
&& echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen \
|
||||
&& locale-gen \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
&& mkdir -p /usr/share/novnc \
|
||||
&& rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* /usr/share/doc/* /usr/share/man/*
|
||||
|
||||
# jf open 粉圓 (system UI) + MesloLGS NF (Powerlevel10k glyphs, CJK via fontconfig).
|
||||
RUN mkdir -p /usr/share/fonts/truetype/huninn /usr/share/fonts/truetype/meslo \
|
||||
|
|
@ -83,7 +96,7 @@ RUN useradd --create-home --uid 1000 --shell /bin/zsh lazyboy \
|
|||
&& mkdir -p /home/lazyboy /tmp/lazyboy /usr/share/lazyboy/skel /usr/share/lazyboy/xfce-skel /etc/gtk-3.0 /etc/fonts/conf.d \
|
||||
&& chown -R 1000:1000 /home/lazyboy /tmp/lazyboy
|
||||
|
||||
COPY --from=controld --chmod=755 /src/target/release/lazyboy-controld /usr/local/bin/lazyboy-controld
|
||||
COPY --from=controld --chmod=755 /lazyboy-controld /usr/local/bin/lazyboy-controld
|
||||
COPY --chmod=755 image/computer/lazyboy-screen /usr/local/bin/lazyboy-screen
|
||||
COPY --chmod=755 image/computer/lazyboy-browser /usr/local/bin/lazyboy-browser
|
||||
COPY --chmod=755 image/computer/lazyboy-terminal /usr/local/bin/lazyboy-terminal
|
||||
|
|
@ -100,20 +113,10 @@ COPY --chmod=644 image/computer/xfce/xfce4-desktop.xml /usr/share/lazyboy/xfce-s
|
|||
COPY --chmod=644 image/computer/xfce/thunar.xml /usr/share/lazyboy/xfce-skel/xfce4/xfconf/xfce-perchannel-xml/thunar.xml
|
||||
COPY --chmod=644 image/computer/xfce/terminal.desktop /usr/share/applications/lazyboy-terminal.desktop
|
||||
COPY --chmod=644 image/computer/xfce/browser.desktop /usr/share/applications/lazyboy-browser.desktop
|
||||
# Debian slim drops /usr/share/locale. Keep Traditional Chinese catalogs so
|
||||
# XFCE menus are not English. Hide stock xterm / duplicate terminal entries;
|
||||
# the panel launches lazyboy-terminal (zsh -l).
|
||||
RUN printf '%s\n' \
|
||||
'path-include=/usr/share/locale/zh_TW/*' \
|
||||
'path-include=/usr/share/locale/zh/*' \
|
||||
> /etc/dpkg/dpkg.cfg.d/zz-lazyboy-locale \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends --reinstall \
|
||||
xfce4-panel xfce4-terminal xfdesktop4 xfwm4 thunar xfce4-settings \
|
||||
libxfce4ui-2-0 libxfce4ui-common libxfce4util7 libxfce4util-common \
|
||||
xfce4-helpers libgarcon-common \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& for f in xterm uxterm debian-xterm xfce4-terminal; do \
|
||||
# Traditional Chinese catalogs were retained during package installation.
|
||||
# Hide stock xterm / duplicate terminal entries; the panel launches
|
||||
# lazyboy-terminal (zsh -l).
|
||||
RUN for f in xterm uxterm debian-xterm xfce4-terminal; do \
|
||||
if [ -f "/usr/share/applications/${f}.desktop" ]; then \
|
||||
printf '\nNoDisplay=true\nHidden=true\n' >> "/usr/share/applications/${f}.desktop"; \
|
||||
fi; \
|
||||
|
|
@ -132,7 +135,8 @@ COPY --chmod=755 image/computer/start.sh /usr/local/bin/lazyboy-computer
|
|||
|
||||
USER 1000:1000
|
||||
ENV HOME=/home/lazyboy DISPLAY=:1 SHELL=/bin/zsh TERM=xterm-256color \
|
||||
LANG=zh_TW.UTF-8 LC_ALL=zh_TW.UTF-8 LANGUAGE=zh_TW:zh:en
|
||||
LANG=zh_TW.UTF-8 LC_ALL=zh_TW.UTF-8 LANGUAGE=zh_TW:zh:en \
|
||||
GTK_MODULES=atk-bridge GTK_A11Y=atspi GNOME_ACCESSIBILITY=1 NO_AT_BRIDGE=0
|
||||
WORKDIR /home/lazyboy
|
||||
EXPOSE 6080 6081 6082 6083 6084 6085 6086 6087
|
||||
CMD ["/usr/local/bin/lazyboy-computer"]
|
||||
|
|
|
|||
|
|
@ -43,8 +43,8 @@ fi
|
|||
rm -f "$PROFILE/SingletonLock" "$PROFILE/SingletonCookie" "$PROFILE/SingletonSocket"
|
||||
exec /usr/bin/chromium \
|
||||
--no-sandbox \
|
||||
--remote-debugging-address=127.0.0.1 \
|
||||
--remote-debugging-port="$DEVTOOLS_PORT" \
|
||||
--remote-allow-origins='*' \
|
||||
--test-type \
|
||||
--disable-gpu \
|
||||
--disable-dev-shm-usage \
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ export TERM="${TERM:-xterm-256color}"
|
|||
export LANG="${LANG:-zh_TW.UTF-8}"
|
||||
export LC_ALL="${LC_ALL:-zh_TW.UTF-8}"
|
||||
export LANGUAGE="${LANGUAGE:-zh_TW:zh:en}"
|
||||
export GTK_MODULES="${GTK_MODULES:-atk-bridge}"
|
||||
export GTK_A11Y="${GTK_A11Y:-atspi}"
|
||||
export GNOME_ACCESSIBILITY="${GNOME_ACCESSIBILITY:-1}"
|
||||
export NO_AT_BRIDGE="${NO_AT_BRIDGE:-0}"
|
||||
export PATH="$HOME/.local/bin:/usr/local/bin:$PATH"
|
||||
|
||||
ROOT=/tmp/lazyboy
|
||||
|
|
@ -112,6 +116,41 @@ alive_pidfile() {
|
|||
[[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null
|
||||
}
|
||||
|
||||
start_atspi() {
|
||||
local display="$1"
|
||||
local log="$2"
|
||||
local number="${display#:}"
|
||||
if [[ -n "${DBUS_SESSION_BUS_ADDRESS:-}" ]]; then
|
||||
printf '%s\n' "$DBUS_SESSION_BUS_ADDRESS" >"$ROOT/screen-${number}.dbus"
|
||||
fi
|
||||
if [[ -n "${XDG_RUNTIME_DIR:-}" ]]; then
|
||||
printf '%s\n' "$XDG_RUNTIME_DIR" >"$ROOT/screen-${number}.runtime"
|
||||
fi
|
||||
local launcher="" registry=""
|
||||
local c
|
||||
for c in /usr/libexec/at-spi-bus-launcher /usr/lib/at-spi2-core/at-spi-bus-launcher at-spi-bus-launcher; do
|
||||
if [[ -x "$c" ]] || command -v "$c" >/dev/null 2>&1; then
|
||||
launcher="$c"
|
||||
break
|
||||
fi
|
||||
done
|
||||
for c in /usr/libexec/at-spi2-registryd /usr/lib/at-spi2-core/at-spi2-registryd at-spi2-registryd; do
|
||||
if [[ -x "$c" ]] || command -v "$c" >/dev/null 2>&1; then
|
||||
registry="$c"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ -n "$launcher" ]]; then
|
||||
DISPLAY="$display" "$launcher" --launch-immediately >"${log}-atspi-bus.log" 2>&1 &
|
||||
echo $! >"${log}-atspi-bus.pid"
|
||||
fi
|
||||
if [[ -n "$registry" ]]; then
|
||||
DISPLAY="$display" "$registry" >"${log}-atspi-registry.log" 2>&1 &
|
||||
echo $! >"${log}-atspi-registry.pid"
|
||||
fi
|
||||
sleep 0.2
|
||||
}
|
||||
|
||||
start_desktop() {
|
||||
local display="$1"
|
||||
local xfce_home="$2"
|
||||
|
|
@ -131,6 +170,7 @@ start_desktop() {
|
|||
eval "$(dbus-launch --sh-syntax)"
|
||||
echo "${DBUS_SESSION_BUS_PID:-}" >"${log}-dbus.pid"
|
||||
fi
|
||||
start_atspi "$display" "$log"
|
||||
if command -v xfconfd >/dev/null 2>&1; then
|
||||
xfconfd --daemon >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ export TERM="${TERM:-xterm-256color}"
|
|||
export LANG="${LANG:-zh_TW.UTF-8}"
|
||||
export LC_ALL="${LC_ALL:-zh_TW.UTF-8}"
|
||||
export LANGUAGE="${LANGUAGE:-zh_TW:zh:en}"
|
||||
export GTK_MODULES="${GTK_MODULES:-atk-bridge}"
|
||||
export GTK_A11Y="${GTK_A11Y:-atspi}"
|
||||
export GNOME_ACCESSIBILITY="${GNOME_ACCESSIBILITY:-1}"
|
||||
export NO_AT_BRIDGE="${NO_AT_BRIDGE:-0}"
|
||||
mkdir -p "$HOME" /tmp/lazyboy /tmp/.X11-unix
|
||||
rm -f /tmp/lazyboy/ready
|
||||
export PATH="$HOME/.local/bin:/usr/local/bin:$PATH"
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
FROM rust:1-bookworm AS build
|
||||
WORKDIR /src
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY crates crates
|
||||
COPY migrations migrations
|
||||
RUN cargo build --release -p lazyboy-supervisor
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \
|
||||
--mount=type=cache,target=/src/target,id=lazyboy-supervisor-release,sharing=locked \
|
||||
cargo build --locked --release -p lazyboy-supervisor && cp /src/target/release/lazyboy-supervisor /lazyboy-supervisor
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=build /src/target/release/lazyboy-supervisor /usr/local/bin/lazyboy-supervisor
|
||||
FROM gcr.io/distroless/cc-debian12:latest
|
||||
COPY --from=build /lazyboy-supervisor /usr/local/bin/lazyboy-supervisor
|
||||
EXPOSE 7091
|
||||
CMD ["lazyboy-supervisor"]
|
||||
CMD ["/usr/local/bin/lazyboy-supervisor"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
set -euo pipefail
|
||||
root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$root"
|
||||
docker compose up -d postgres
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d postgres
|
||||
echo "waiting for postgres..."
|
||||
for _ in $(seq 1 40); do
|
||||
if docker compose exec -T postgres pg_isready -U lazyboy >/dev/null 2>&1; then
|
||||
|
|
|
|||
|
|
@ -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.')
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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);
|
||||
});
|
||||