Merge pull request 'feat/test' (#2) from feat/test into main

Reviewed-on: #2
This commit is contained in:
王性驊 2026-09-05 05:14:42 +00:00
commit 1414ed6166
74 changed files with 11094 additions and 917 deletions

View File

@ -1,4 +1,6 @@
XAI_API_KEY= XAI_API_KEY=
OPENCODE_GO_API_KEY=
OPENAI_API_KEY=
# Generate independent values with: openssl rand -hex 32 # Generate independent values with: openssl rand -hex 32
LAZYBOY_APP_TOKEN= LAZYBOY_APP_TOKEN=
SANDBOX_SUPERVISOR_TOKEN= SANDBOX_SUPERVISOR_TOKEN=

1
.gitignore vendored
View File

@ -8,3 +8,4 @@ image/computer/lazyboy-controld
node_modules node_modules
dist dist
.DS_Store .DS_Store
.gstack/

118
Cargo.lock generated
View File

@ -2020,6 +2020,7 @@ dependencies = [
"futures-util", "futures-util",
"hex", "hex",
"hmac", "hmac",
"http",
"http-body-util", "http-body-util",
"lazyboy-contracts", "lazyboy-contracts",
"lazyboy-control", "lazyboy-control",
@ -2027,6 +2028,7 @@ dependencies = [
"lazyboy-sandbox", "lazyboy-sandbox",
"reqwest 0.12.28", "reqwest 0.12.28",
"rig-core", "rig-core",
"rmcp",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
@ -2058,6 +2060,7 @@ dependencies = [
"async-trait", "async-trait",
"chrono", "chrono",
"hex", "hex",
"image",
"lazyboy-contracts", "lazyboy-contracts",
"serde", "serde",
"serde_json", "serde_json",
@ -2423,6 +2426,18 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nix"
version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags 2.13.1",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]] [[package]]
name = "no_std_io2" name = "no_std_io2"
version = "0.9.4" version = "0.9.4"
@ -2869,6 +2884,20 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "process-wrap"
version = "9.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e842efad9119158434d193c6682e2ebee4b44d6ad801d7b349623b3f57cdf55"
dependencies = [
"futures",
"indexmap 2.14.1",
"nix",
"tokio",
"tracing",
"windows",
]
[[package]] [[package]]
name = "profiling" name = "profiling"
version = "1.0.18" version = "1.0.18"
@ -3434,6 +3463,31 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "rmcp"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42b6914fac0be956fe704a38239c3f44a9f841d1b06a5713d2f638065593f5b5"
dependencies = [
"base64 0.23.1",
"bytes",
"chrono",
"futures",
"http",
"indexmap 2.14.1",
"pin-project-lite",
"process-wrap",
"reqwest 0.13.4",
"serde",
"serde_json",
"sse-stream",
"thiserror",
"tokio",
"tokio-stream",
"tokio-util",
"tracing",
]
[[package]] [[package]]
name = "rsa" name = "rsa"
version = "0.9.10" version = "0.9.10"
@ -4141,6 +4195,19 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "sse-stream"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c25ac7aff0abd1dbc474536e40416e1102c7dd9bfba0b9861c6d357f835dcfb4"
dependencies = [
"bytes",
"futures-util",
"http-body",
"http-body-util",
"pin-project-lite",
]
[[package]] [[package]]
name = "stable_deref_trait" name = "stable_deref_trait"
version = "1.2.1" version = "1.2.1"
@ -5084,6 +5151,27 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
dependencies = [
"windows-collections",
"windows-core",
"windows-future",
"windows-numerics",
]
[[package]]
name = "windows-collections"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
dependencies = [
"windows-core",
]
[[package]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.62.2" version = "0.62.2"
@ -5097,6 +5185,17 @@ dependencies = [
"windows-strings", "windows-strings",
] ]
[[package]]
name = "windows-future"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
dependencies = [
"windows-core",
"windows-link",
"windows-threading",
]
[[package]] [[package]]
name = "windows-implement" name = "windows-implement"
version = "0.60.2" version = "0.60.2"
@ -5125,6 +5224,16 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-numerics"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
dependencies = [
"windows-core",
"windows-link",
]
[[package]] [[package]]
name = "windows-registry" name = "windows-registry"
version = "0.6.1" version = "0.6.1"
@ -5212,6 +5321,15 @@ dependencies = [
"windows_x86_64_msvc 0.52.6", "windows_x86_64_msvc 0.52.6",
] ]
[[package]]
name = "windows-threading"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
dependencies = [
"windows-link",
]
[[package]] [[package]]
name = "windows_aarch64_gnullvm" name = "windows_aarch64_gnullvm"
version = "0.48.5" version = "0.48.5"

View File

@ -26,6 +26,7 @@ sha2 = "0.10"
hex = "0.4" hex = "0.4"
base64 = "0.22" base64 = "0.22"
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
rig-core = "0.42" rig-core = "0.42"
async-trait = "0.1" async-trait = "0.1"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "process", "io-util", "fs", "signal", "time"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "process", "io-util", "fs", "signal", "time"] }

172
Makefile Normal file
View File

@ -0,0 +1,172 @@
# LazyBoy — developer Makefile
# Wraps docker compose + cargo so the whole stack can be built and launched
# with a few targets. See `make help`.
.DEFAULT_GOAL := help
COMPOSE ?= docker compose
COMPUTER_IMAGE ?= lazyboy/computer:local
WEB_DIR ?= apps/web
DATA_DIR ?= ./data
.PHONY: help env env-force \
up logs ps health down purge \
computer postgres postgres-down \
build build-api build-supervisor build-controld \
fmt clippy test clean \
web \
dev dev-supervisor dev-api
help: ## Show this help
@echo "LazyBoy — make targets"
@echo ""
@echo " Full stack (recommended, everything in Docker):"
@echo " make env Create .env with generated tokens (idempotent)"
@echo " make up Build all images + start postgres/computer/supervisor/api"
@echo " make health Curl the API health endpoint"
@echo " make logs Tail container logs"
@echo " make ps Show container status"
@echo " make down Stop + remove containers (keep postgres data)"
@echo " make purge Stop + remove containers AND the postgres volume"
@echo ""
@echo " Individual pieces:"
@echo " make computer Build the heavy Debian desktop image (lazyboy/computer:local)"
@echo " make postgres Start only postgres (127.0.0.1:5434) and wait for ready"
@echo " make postgres-down Stop postgres"
@echo ""
@echo " Local dev (postgres in Docker, Rust services on the host):"
@echo " make dev Prep .env + postgres + computer image, then print run steps"
@echo " make dev-supervisor Run lazyboy-supervisor on the host (terminal 1)"
@echo " make dev-api Run lazyboy-api on the host (terminal 2)"
@echo ""
@echo " Rust / web:"
@echo " make build cargo build --release (whole workspace)"
@echo " make build-api cargo build --release -p lazyboy-api"
@echo " make fmt cargo fmt --all"
@echo " make clippy cargo clippy (deny warnings)"
@echo " make test cargo test --workspace"
@echo " make web Build the frontend in $(WEB_DIR) (needs node/npm)"
@echo " make clean cargo clean"
@echo ""
@echo " After 'make up': open http://127.0.0.1:3101 and sign in with LAZYBOY_APP_TOKEN."
@echo " Set XAI_API_KEY in .env for chat."
# --- 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-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)."
# --- Full Docker stack -----------------------------------------------------
up: env ## Build every image and start the whole stack in Docker
@echo "building images + starting stack (first run is slow: builds desktop + rust + web)..."
$(COMPOSE) up -d --build
@echo ""
@echo "stack launched. open http://127.0.0.1:3101 and sign in with LAZYBOY_APP_TOKEN."
$(COMPOSE) ps
logs: ## Tail logs for all services
$(COMPOSE) logs -f
ps: ## Show container status
$(COMPOSE) ps
health: ## Check the API health endpoint
@curl -fsS http://127.0.0.1:3101/api/health && echo " <- ok"
down: ## Stop and remove containers (keeps postgres volume)
$(COMPOSE) down
purge: ## Stop containers and delete the postgres data volume
$(COMPOSE) down -v
# --- Individual pieces -----------------------------------------------------
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
@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
postgres-down: ## Stop postgres
$(COMPOSE) down postgres
# --- Rust / web ------------------------------------------------------------
build: ## Release build of the whole workspace
cargo build --release
build-api: ## Release build of the API binary
cargo build --release -p lazyboy-api
build-supervisor: ## Release build of the supervisor binary
cargo build --release -p lazyboy-supervisor
build-controld: ## Release build of the controld binary
cargo build --release -p lazyboy-controld
fmt: ## Format all Rust code
cargo fmt --all
clippy: ## Run clippy, denying warnings
cargo clippy --all-targets -- -D warnings
test: ## Run the test suite
cargo test --workspace
web: ## Build the frontend (needs node/npm)
cd $(WEB_DIR) && npm install && npm run build
clean: ## Remove cargo build artifacts
cargo clean
# --- Local dev (postgres in Docker, Rust on the host) ----------------------
dev: env postgres computer ## Prep local dev, then print the two run commands
@echo ""
@echo "prep complete. run these in TWO terminals:"
@echo " terminal 1: make dev-supervisor"
@echo " terminal 2: make dev-api"
@echo "then open http://127.0.0.1:3101"
dev-supervisor: ## Run lazyboy-supervisor on the host (terminal 1)
@test -f .env || { echo "run 'make env' first"; exit 1; }
@set -a; . ./.env; set +a; \
export DATA_DIR="$${DATA_DIR:-$$PWD/data}"; mkdir -p "$$DATA_DIR"; \
export SUPERVISOR_BIND="$${SUPERVISOR_BIND:-127.0.0.1:7091}"; \
export LAZYBOY_COMPUTER_IMAGE="$${LAZYBOY_COMPUTER_IMAGE:-$(COMPUTER_IMAGE)}"; \
echo "starting supervisor on $$SUPERVISOR_BIND (log: stderr)"; \
exec cargo run -p lazyboy-supervisor
dev-api: ## Run lazyboy-api on the host (terminal 2)
@test -f .env || { echo "run 'make env' first"; exit 1; }
@set -a; . ./.env; set +a; \
export DATABASE_URL="$${DATABASE_URL:-postgres://lazyboy:lazyboy@127.0.0.1:5434/lazyboy}"; \
export SANDBOX_PROVIDER="$${SANDBOX_PROVIDER:-docker}"; \
export SANDBOX_SUPERVISOR_URL="$${SANDBOX_SUPERVISOR_URL:-http://127.0.0.1:7091}"; \
export DATA_DIR="$${DATA_DIR:-$$PWD/data}"; \
export API_BIND="$${API_BIND:-0.0.0.0:3101}"; \
export LAZYBOY_WEB_DIR="$${LAZYBOY_WEB_DIR:-$$PWD/$(WEB_DIR)}"; \
mkdir -p "$$DATA_DIR"; \
echo "starting api on $$API_BIND (web dir $$LAZYBOY_WEB_DIR)"; \
exec cargo run -p lazyboy-api

View File

@ -2,6 +2,39 @@
Create a bot in the browser, give it a Team or Private computer, and let it drive a Linux desktop. Create a bot in the browser, give it a Team or Private computer, and let it drive a Linux desktop.
## Run (full Docker, recommended)
Everything (postgres, desktop image build, supervisor, API + web UI) runs in
Docker Compose, driven by the Makefile:
```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 health
```
Open `http://127.0.0.1:3101` and sign in with `LAZYBOY_APP_TOKEN`.
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) ## Run (local)
Postgres is on `127.0.0.1:5434` so it does not collide with other stacks. Postgres is on `127.0.0.1:5434` so it does not collide with other stacks.
@ -36,4 +69,4 @@ For frontend development, run `npm install && npm run dev` in `apps/web`, then o
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`. 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`.
Model providers: v1 talks to xAI (`XAI_API_KEY`). `openai` / `anthropic` / `openrouter` are reserved on the factory and return `unsupported_provider`. 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.

51
a.txt Normal file
View File

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

View File

@ -8,11 +8,12 @@
"name": "lazyboy-web", "name": "lazyboy-web",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@blobatar/react": "^2.7.0",
"@fontsource/huninn": "^5.3.0", "@fontsource/huninn": "^5.3.0",
"blobshape": "^1.0.0", "blobatar": "^2.7.0",
"lucide-react": "^0.468.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0",
"react-useanimations": "^2.10.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
@ -304,6 +305,16 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@blobatar/react": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@blobatar/react/-/react-2.7.0.tgz",
"integrity": "sha512-rysDM1+2qoeIwIezVw/em+ZxOMyqqjqJOGNEvtOOU1nFp0jmzyEjlXtJTDM6xPKamq59pq6wcG9+mHIUZV572A==",
"license": "MIT",
"peerDependencies": {
"blobatar": "2.x",
"react": ">=18"
}
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@ -1327,11 +1338,23 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/blobshape": { "node_modules/blobatar": {
"version": "1.0.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/blobshape/-/blobshape-1.0.0.tgz", "resolved": "https://registry.npmjs.org/blobatar/-/blobatar-2.7.0.tgz",
"integrity": "sha512-GUD6tUIvj7k8zLSMtfdM1RyusWDjFqYL9g6J6QIgwxbVjL3b6mbRz5peerDc+6kbZX/14BSr3ed9sizrJKdkQQ==", "integrity": "sha512-tL1p2J3dXE/Z9U/ITwHqU7HBHmTH+Am+hif1J1hWAhvFT1VYC2HG+eXtN7081yuY+L2lrp9bs/VaF7dVH3S2jw==",
"license": "MIT" "license": "MIT",
"peerDependencies": {
"react": ">=18",
"vue": ">=3"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"vue": {
"optional": true
}
}
}, },
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.28.8", "version": "4.28.8",
@ -1555,6 +1578,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/lottie-web": {
"version": "5.13.0",
"resolved": "https://registry.npmjs.org/lottie-web/-/lottie-web-5.13.0.tgz",
"integrity": "sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==",
"license": "MIT"
},
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@ -1565,15 +1594,6 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/lucide-react": {
"version": "0.468.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz",
"integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -1690,6 +1710,19 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-useanimations": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/react-useanimations/-/react-useanimations-2.10.0.tgz",
"integrity": "sha512-MzGNv8vkvb6qEvMBCj+O6nUloUHSJRubMAH3uE7J4M+pjt5ud5xDaXBrQgv5GbvBg29XzviKWHTfvvkofDEu+Q==",
"license": "MIT",
"dependencies": {
"lottie-web": "^5.5.7"
},
"peerDependencies": {
"react": ">=16",
"react-dom": ">=16"
}
},
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.63.1", "version": "4.63.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz",

View File

@ -1 +1,26 @@
{"name":"lazyboy-web","private":true,"version":"0.1.0","type":"module","scripts":{"dev":"vite --host 0.0.0.0","build":"tsc --noEmit && vite build","typecheck":"tsc --noEmit --pretty false"},"dependencies":{"@fontsource/huninn":"^5.3.0","blobshape":"^1.0.0","lucide-react":"^0.468.0","react":"^19.0.0","react-dom":"^19.0.0"},"devDependencies":{"@types/react":"^19.0.0","@types/react-dom":"^19.0.0","@vitejs/plugin-react":"^4.3.4","typescript":"^5.7.2","vite":"^6.0.7"}} {
"name": "lazyboy-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit --pretty false"
},
"dependencies": {
"@blobatar/react": "^2.7.0",
"@fontsource/huninn": "^5.3.0",
"blobatar": "^2.7.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-useanimations": "^2.10.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.2",
"vite": "^6.0.7"
}
}

View File

@ -1,112 +1,652 @@
import { FormEvent, useCallback, useEffect, useMemo, useState } from "react"; import { FormEvent, KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ArrowUp, Bot as BotIcon, Brain, ChevronDown, Clipboard, ClipboardPaste, Computer, Ellipsis, Eye, EyeOff, FolderPlus, LoaderCircle, LogOut, Mail, Menu, Pin, Plus, Search, Settings, Square, Trash2, Users, X } from "lucide-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 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";
import folder from "react-useanimations/lib/folder";
import mail from "react-useanimations/lib/mail";
import menu from "react-useanimations/lib/menu";
import plusToX from "react-useanimations/lib/plusToX";
import settings from "react-useanimations/lib/settings";
import trash2 from "react-useanimations/lib/trash2";
import visibility from "react-useanimations/lib/visibility";
import visibility2 from "react-useanimations/lib/visibility2";
import searchToX from "react-useanimations/lib/searchToX";
import { api, ApiError } from "./api"; import { api, ApiError } from "./api";
import { t } from "./i18n"; 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 blobshape from "blobshape"; import { t, type MessageKey } from "./i18n";
import type { AvatarShape, Bot, ComputerMode, ComputerStatus, MemoryItem, Message, Session } from "./types"; import type { AvatarShape, Bot, ComputerMode, ComputerStatus, McpCatalogEntry, McpServer, McpTransport, MemoryItem, Message, MessageFile, ModelProviderId, Playbook, PlaybookInput, PlaybookStep, Room, RoomMember, Session, TaughtSkill, WorkspaceSettings } from "./types";
const blankComputer:ComputerStatus={botId:"",mode:"team",state:"stopped",controlHolder:"none",takeoverRequested:false,busyBotName:null,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,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 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"}}}
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}}}
const botColors=["#3ec5a8","#f5a03c","#6a6bf5","#9b5cf6","#3b82f6","#d9508a"]; 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 Avatar({name,color,shape="blob",active=false,thinking=false,size=32}:{name:string;color?:string;shape?:AvatarShape;active?:boolean;thinking?:boolean;size?:number}){const hash=[...`${name}-${shape}`].reduce((n,c)=>(n*31+c.charCodeAt(0))>>>0,7);const resolved=color||botColors[hash%botColors.length];const generated=shape==="blob"||shape.startsWith("organic-");const edges=shape==="blob"?7:Number(shape.slice(8));const generatedPath=generated?blobshape({size:100,growth:shape==="blob"?8:6,edges,seed:hash||1}).path:null;const specialPath=shape==="cloud"?"M23 80C9 80 2 70 7 57C10 48 18 44 27 45C29 30 40 21 53 24C63 25 70 32 72 43C87 42 96 51 95 64C94 75 86 81 73 80C67 88 56 90 48 84C39 91 28 89 23 80Z":shape==="drop"?"M52 5C44 20 18 44 18 65C18 83 32 95 50 95C69 95 83 82 82 64C81 43 60 21 52 5Z":null;const path=generatedPath||specialPath;const svgShape=Boolean(path);return <span className={`avatar robot avatar-${shape} ${svgShape?"avatar-organic":""} ${active?"online":""} ${thinking?"thinking":""}`} style={{"--bot-color":resolved,"--avatar-size":`${size}px`} as React.CSSProperties}>{path&&<svg className="avatar-shape" viewBox="0 0 100 100" aria-hidden="true"><path d={path}/></svg>}<span className="robot-eyes"><i/><i/></span></span>}
function modeLabel(mode:ComputerMode){return mode==="team"?t("sharedComputer"):t("privateComputer")} function modeLabel(mode:ComputerMode){return mode==="team"?t("sharedComputer"):t("privateComputer")}
function stateLabel(state:ComputerStatus["state"]){return ({stopped:t("stopped"),booting:t("booting"),running:t("running"),suspended:t("suspended"),error:t("error")})[state]} function stateLabel(state:ComputerStatus["state"]){return ({stopped:t("stopped"),booting:t("booting"),running:t("running"),suspended:t("suspended"),error:t("error")})[state]}
function inboxTime(value:string|null){if(!value)return "";const date=new Date(value),now=new Date();if(date.toDateString()===now.toDateString())return new Intl.DateTimeFormat("zh-TW",{hour:"2-digit",minute:"2-digit",hour12:false}).format(date);const days=Math.floor((new Date(now.getFullYear(),now.getMonth(),now.getDate()).getTime()-new Date(date.getFullYear(),date.getMonth(),date.getDate()).getTime())/86400000);if(days<7)return new Intl.DateTimeFormat("zh-TW",{weekday:"long"}).format(date);return new Intl.DateTimeFormat("zh-TW",{month:"numeric",day:"numeric"}).format(date)} function inboxTime(value:string|null){if(!value)return "";const date=new Date(value),now=new Date();if(date.toDateString()===now.toDateString())return new Intl.DateTimeFormat("zh-TW",{hour:"2-digit",minute:"2-digit",hour12:false}).format(date);const days=Math.floor((new Date(now.getFullYear(),now.getMonth(),now.getDate()).getTime()-new Date(date.getFullYear(),date.getMonth(),date.getDate()).getTime())/86400000);if(days<7)return new Intl.DateTimeFormat("zh-TW",{weekday:"long"}).format(date);return new Intl.DateTimeFormat("zh-TW",{month:"numeric",day:"numeric"}).format(date)}
const ATTACH_MAX=4;
const ATTACH_MAX_BYTES=10*1024*1024;
const ATTACH_ACCEPT=".png,.jpg,.jpeg,.gif,.webp,.pdf,.txt,.md,.csv,.json,.html,.htm,.xml,.docx,.xlsx,.pptx,image/*,text/*,application/pdf";
type PendingFile={id:string;file:File;preview:string|null};
function attachAllowed(file:File){const mime=(file.type||"").toLowerCase();if(mime.startsWith("image/")||mime.startsWith("text/")||mime==="application/pdf"||mime==="application/json"||mime==="application/xml")return true;return /\.(png|jpe?g|gif|webp|pdf|txt|md|csv|json|html?|xml|docx|xlsx|pptx)$/i.test(file.name)}
function readAsBase64(file:File){return new Promise<string>((resolve,reject)=>{const reader=new FileReader();reader.onload=()=>{const value=String(reader.result||"");const comma=value.indexOf(",");resolve(comma>=0?value.slice(comma+1):value)};reader.onerror=()=>reject(reader.error||new Error("read failed"));reader.readAsDataURL(file)})}
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 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(){
const webCrypto=globalThis.crypto;
if(webCrypto&&typeof webCrypto.randomUUID==="function")return webCrypto.randomUUID();
const bytes=new Uint8Array(16);
if(webCrypto&&typeof webCrypto.getRandomValues==="function")webCrypto.getRandomValues(bytes);
else for(let i=0;i<bytes.length;i++)bytes[i]=Math.floor(Math.random()*256);
bytes[6]=(bytes[6]&0x0f)|0x40;
bytes[8]=(bytes[8]&0x3f)|0x80;
const hex=[...bytes].map(b=>b.toString(16).padStart(2,"0")).join("");
return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`;
}
export function App(){ export function App(){
const [bots,setBots]=useState<Bot[]>([]); const [activeId,setActiveId]=useState<string|null>(null); const [bots,setBots]=useState<Bot[]>([]); const [rooms,setRooms]=useState<Room[]>([]); const [activeId,setActiveId]=useState<string|null>(null); const [activeRoomId,setActiveRoomId]=useState<string|null>(null); const [busyMembers,setBusyMembers]=useState<RoomMember[]>([]);
const [sessions,setSessions]=useState<Session[]>([]); const [activeSessionId,setActiveSessionId]=useState<string|null>(null); const [sessions,setSessions]=useState<Session[]>([]); const [activeSessionId,setActiveSessionId]=useState<string|null>(null);
const [messages,setMessages]=useState<Message[]>([]); const [computer,setComputer]=useState<ComputerStatus>(blankComputer); const [messages,setMessages]=useState<Message[]>([]); const [computer,setComputer]=useState<ComputerStatus>(blankComputer);
const [screenUrl,setScreenUrl]=useState<string|null>(null); const [draft,setDraft]=useState(""); const [query,setQuery]=useState(""); const [screenUrl,setScreenUrl]=useState<string|null>(null); const [draft,setDraft]=useState(""); const [query,setQuery]=useState("");
const [createOpen,setCreateOpen]=useState(false); const [createMenuOpen,setCreateMenuOpen]=useState(false); const [groupOpen,setGroupOpen]=useState(false); const [settingsOpen,setSettingsOpen]=useState(false); const [memoryOpen,setMemoryOpen]=useState(false); const [deleteOpen,setDeleteOpen]=useState(false); const [computerOpen,setComputerOpen]=useState(false); const [createOpen,setCreateOpen]=useState(false); const [createMenuOpen,setCreateMenuOpen]=useState(false); const [groupOpen,setGroupOpen]=useState(false); const [deleteOpen,setDeleteOpen]=useState(false); const [computerOpen,setComputerOpen]=useState(false);
const paneStart=readPaneStore();
const [rightCollapsed,setRightCollapsed]=useState(paneStart.collapsed); const [rightPart,setRightPart]=useState<RightPart>(paneStart.part);
const [sessionMenuOpen,setSessionMenuOpen]=useState(false); const [clearOpen,setClearOpen]=useState(false); const [sessionToDelete,setSessionToDelete]=useState<string|null>(null); const [remembered,setRemembered]=useState<Record<string,boolean>>({});
const [mobileNav,setMobileNav]=useState(false); const [error,setError]=useState<string|null>(null); const [busy,setBusy]=useState(false); const [mobileNav,setMobileNav]=useState(false); const [error,setError]=useState<string|null>(null); const [busy,setBusy]=useState(false);
const [desktopClipboard,setDesktopClipboard]=useState(""); const active=bots.find(b=>b.id===activeId)||null; const [desktopClipboard,setDesktopClipboard]=useState(""); const active=bots.find(b=>b.id===activeId)||null;
const activeRoom=rooms.find(room=>room.id===activeRoomId)||null;
const [clipboardOpen,setClipboardOpen]=useState(false); const [clipboardOpen,setClipboardOpen]=useState(false);
const [authRequired,setAuthRequired]=useState(false); const [authRequired,setAuthRequired]=useState(false);
const [showHidden,setShowHidden]=useState(false);const[context,setContext]=useState<{bot:Bot;x:number;y:number}|null>(null); const workspaceStart=readWorkspace();
const [showHidden,setShowHidden]=useState(workspaceStart.showHidden);const[context,setContext]=useState<{bot:Bot;x:number;y:number}|null>(null);
const [roomContext,setRoomContext]=useState<{room:Room;x:number;y:number}|null>(null);
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 [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 [pendingFiles,setPendingFiles]=useState<PendingFile[]>([]);
const messageEndRef=useRef<HTMLDivElement|null>(null);
const sentHistoryRef=useRef<string[]>([]); const historyIndexRef=useRef<number|null>(null); const historyDraftRef=useRef("");
const roomsRef=useRef(rooms);
roomsRef.current=rooms;
const filtered=useMemo(()=>bots.filter(b=>(showHidden||!b.hidden)&&b.name.toLowerCase().includes(query.toLowerCase())),[bots,query,showHidden]); const filtered=useMemo(()=>bots.filter(b=>(showHidden||!b.hidden)&&b.name.toLowerCase().includes(query.toLowerCase())),[bots,query,showHidden]);
const sections=useMemo(()=>{const map=new Map<string,Bot[]>();for(const bot of filtered){const key=bot.pinned?"已釘選":bot.groupName||"Agent";map.set(key,[...(map.get(key)||[]),bot])}return [...map.entries()]},[filtered]); const filteredRooms=useMemo(()=>{const q=query.trim().toLowerCase();return rooms.filter(room=>!q||room.name.toLowerCase().includes(q)||room.members.some(member=>member.name.toLowerCase().includes(q)))},[rooms,query]);
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 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.
const pausedForUser=computer.takeoverRequested&&workingMembers.length===0&&(!computer.waitingSessionId||computer.waitingSessionId===activeSessionId||Boolean(activeRoom));
// Teaching by demonstration: one recording at a time per bot, then a draft
// playbook the human names and saves before it becomes a real skill.
const teaching=skills.find(skill=>skill.status==="recording")||null;
const drafting=skills.find(skill=>skill.status==="drafting")||null;
const skillDraft=teaching||drafting?null:skills.find(skill=>skill.status==="draft")||null;
const savedSkills=skills.filter(skill=>skill.status==="saved");
const skillNeedle=skillQuery.trim().toLowerCase();
const listedSkills=skillNeedle?savedSkills.filter(skill=>skill.name.toLowerCase().includes(skillNeedle)||(skill.playbook.whenToUse||"").toLowerCase().includes(skillNeedle)||skill.goal.toLowerCase().includes(skillNeedle)):savedSkills;
const loadBots=useCallback(async()=>{const next=await api<Bot[]>("/api/bots");setBots(next);setActiveId(id=>id&&next.some(b=>b.id===id)?id:next[0]?.id||null)},[]); const loadMcp=useCallback(async()=>{setMcpServers(await api<McpServer[]>("/api/mcp-servers").catch(()=>[] as McpServer[]))},[]);
const loadSessions=useCallback(async()=>{if(!activeId){setSessions([]);setActiveSessionId(null);return}const next=await api<Session[]>(`/api/bots/${activeId}/sessions`);setSessions(next);setActiveSessionId(id=>id&&next.some(session=>session.id===id)?id:next[0]?.id||null)},[activeId]); const loadBots=useCallback(async()=>{const [next,nextRooms]=await Promise.all([api<Bot[]>("/api/bots"),api<Room[]>("/api/rooms").catch(()=>[] as Room[])]);setBots(next);setRooms(nextRooms);setActiveRoomId(id=>id&&nextRooms.some(room=>room.id===id)?id:null);setActiveId(id=>id&&next.some(b=>b.id===id)?id:null);await loadMcp()},[loadMcp]);
const refresh=useCallback(async()=>{if(!activeId||!activeSessionId)return;const [nextMessages,nextComputer,screen]=await Promise.all([api<Message[]>(`/api/sessions/${activeSessionId}/messages`),api<ComputerStatus>(`/api/computer/${activeId}/status`),api<{url:string|null}>(`/api/computer/${activeId}/screen`).catch(()=>({url:null}))]);setMessages(nextMessages);setComputer(nextComputer);setScreenUrl(screen.url)},[activeId,activeSessionId]); const sessionStoreKey=activeRoomId?`room:${activeRoomId}`:activeId;
const sessionsPath=activeRoomId?`/api/rooms/${activeRoomId}/sessions`:activeId?`/api/bots/${activeId}/sessions`:null;
const loadSessions=useCallback(async()=>{if(!sessionsPath){setSessions([]);setActiveSessionId(null);return}const next=await api<Session[]>(sessionsPath);setSessions(next);setActiveSessionId(id=>{if(id&&next.some(session=>session.id===id))return id;const stored=sessionStoreKey?readSessionStore()[sessionStoreKey]:undefined;if(stored&&next.some(session=>session.id===stored))return stored;return next[0]?.id||null})},[sessionsPath,sessionStoreKey]);
const refresh=useCallback(async()=>{if(!activeSessionId)return;const refreshSeq=++refreshSeqRef.current;let nextBusy:RoomMember[]=[];
let computerBot=activeId;
if(activeRoomId){
try{const roomStatus=await api<{busy:RoomMember[]}>(`/api/rooms/${activeRoomId}/status`);if(refreshSeq!==refreshSeqRef.current)return;nextBusy=roomStatus.busy;computerBot=roomStatus.busy[0]?.id||roomsRef.current.find(room=>room.id===activeRoomId)?.members[0]?.id||null}catch{if(refreshSeq!==refreshSeqRef.current)return;computerBot=roomsRef.current.find(room=>room.id===activeRoomId)?.members[0]?.id||null}
}
const messagesJob=api<Message[]>(`/api/sessions/${activeSessionId}/messages`);
if(!computerBot){const nextMessages=await messagesJob;if(refreshSeq===refreshSeqRef.current){setBusyMembers(nextBusy);setMessages(nextMessages);setComputer(blankComputer);setSkills([]);setScreenUrl(null)}return}
const skillsJob=activeRoomId?Promise.resolve([] as TaughtSkill[]):api<TaughtSkill[]>(`/api/bots/${computerBot}/skills`).catch(()=>[] as TaughtSkill[]);
const status=await api<ComputerStatus>(`/api/computer/${computerBot}/status`);if(refreshSeq!==refreshSeqRef.current)return;
const [nextMessages,screen,nextSkills]=await Promise.all([messagesJob,status.state==="running"?api<{url:string|null}>(`/api/computer/${computerBot}/screen`).catch(()=>({url:null})):Promise.resolve({url:null}),skillsJob]);
if(refreshSeq!==refreshSeqRef.current)return;
setBusyMembers(nextBusy);setComputer(status);setMessages(nextMessages);setSkills(nextSkills);setScreenUrl(status.botId===computerBot?screen.url:null)
},[activeId,activeRoomId,activeSessionId]);
useEffect(()=>{loadBots().catch(e=>{if(e instanceof ApiError&&e.status===401)setAuthRequired(true);else setError(e.message)})},[loadBots]); useEffect(()=>{loadBots().catch(e=>{if(e instanceof ApiError&&e.status===401)setAuthRequired(true);else setError(e.message)})},[loadBots]);
useEffect(()=>{setMessages([]);setActiveSessionId(null);loadSessions().catch(e=>setError(e.message))},[loadSessions]); useEffect(()=>{if(activeId||activeRoomId||bots.length===0)return;setActiveId(bots[0].id)},[bots,activeId,activeRoomId]);
useEffect(()=>{if(!activeId||!activeSessionId){setMessages([]);if(!activeId)setComputer(blankComputer);return}refresh().catch(e=>setError(e.message));const timer=setInterval(()=>{refresh().catch(()=>{});api(`/api/computer/${activeId}/heartbeat`,{method:"POST",body:"{}"}).catch(()=>{})},2000);return()=>clearInterval(timer)},[activeId,activeSessionId,refresh]); useEffect(()=>{setMessages([]);loadSessions().catch(e=>setError(e.message))},[loadSessions]);
useEffect(()=>{historyIndexRef.current=null;historyDraftRef.current="";setPendingFiles(current=>{current.forEach(file=>file.preview&&URL.revokeObjectURL(file.preview));return []})},[activeSessionId]);
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)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"||!activeId)return;void action(()=>api(`/api/computer/${activeId}/takeover`,{method:"POST",body:"{}"}))};window.addEventListener("message",listener);return()=>window.removeEventListener("message",listener)},[activeId]); 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 close=()=>setContext(null);window.addEventListener("click",close);return()=>window.removeEventListener("click",close)},[]); useEffect(()=>{const close=(event:MouseEvent)=>{const target=event.target;if(target instanceof Element&&target.closest(".create-menu-wrap,.account-wrap,.session-picker,.context-menu,.plus-menu-wrap"))return;setContext(null);setRoomContext(null);setSessionMenuOpen(false);setAccountOpen(false);setCreateMenuOpen(false);setPlusOpen(false)};window.addEventListener("click",close);return()=>window.removeEventListener("click",close)},[]);
useEffect(()=>{const onKey=(event:KeyboardEvent)=>{if(event.key!=="Escape")return;setAccountOpen(false);setAccountDialog(null);setCreateMenuOpen(false);setPlusOpen(false);setTeachOpen(false);setEditingSkillId(null);setSessionMenuOpen(false);setContext(null);setRoomContext(null)};window.addEventListener("keydown",onKey);return()=>window.removeEventListener("keydown",onKey)},[]);
useEffect(()=>{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]);
function openPane(part:RightPart){setRightPart(part);setRightCollapsed(false)}
async function action(work:()=>Promise<unknown>){setBusy(true);setError(null);try{await work();await refresh()}catch(e){setError(e instanceof Error?e.message:"操作失敗")}finally{setBusy(false)}} const sessionBusy=workingMembers.length>0;
async function send(event:FormEvent){event.preventDefault();const text=draft.trim();if(!active||!activeSessionId||!text)return;setDraft("");await action(()=>api(`/api/sessions/${activeSessionId}/messages`,{method:"POST",body:JSON.stringify({text,clientNonce:crypto.randomUUID()})}))} const otherSessionBusy=Boolean(computer.busyBotName&&!sessionBusy);
async function createSession(){if(!active)return;await action(async()=>{const session=await api<Session>(`/api/bots/${active.id}/sessions`,{method:"POST",body:JSON.stringify({title:`對話 ${sessions.length+1}`})});await loadSessions();setActiveSessionId(session.id);setMessages([])})} const chatName=activeRoom?.name||active?.name||"";
async function action(work:()=>Promise<unknown>){setBusy(true);setError(null);try{await work();await refresh()}catch(e){setError(e instanceof Error?e.message:t("operationFailed"))}finally{setBusy(false)}}
async function stopChat(){if(activeSessionId)await api(`/api/sessions/${activeSessionId}/stop`,{method:"POST",body:"{}"});else if(active)await api(`/api/bots/${active.id}/stop`,{method:"POST",body:"{}"});}
async function startTeaching(goal:string){if(!active)return;setTeachOpen(false);await action(async()=>{await api(`/api/bots/${active.id}/skills/start`,{method:"POST",body:JSON.stringify({goal})});setComputerOpen(true)})}
async function stopTeaching(){if(!active)return;await action(()=>api(`/api/bots/${active.id}/skills/stop`,{method:"POST",body:"{}"}));setComputerOpen(false)}
async function cancelTeaching(){if(!active)return;await action(()=>api(`/api/bots/${active.id}/skills/cancel`,{method:"POST",body:"{}"}));setComputerOpen(false)}
async function saveSkill(skill:TaughtSkill,name:string,playbook:Playbook){await action(()=>api(`/api/skills/${skill.id}`,{method:"PATCH",body:JSON.stringify({name,playbook,save:true})}))}
async function testSkill(skill:TaughtSkill,name:string,playbook:Playbook){await action(async()=>{await api(`/api/skills/${skill.id}`,{method:"PATCH",body:JSON.stringify({name,playbook})});await api(`/api/skills/${skill.id}/test`,{method:"POST",body:"{}"})})}
async function discardSkill(skill:TaughtSkill){await action(()=>api(`/api/skills/${skill.id}`,{method:"DELETE"}))}
async function updateSkill(skill:TaughtSkill,name:string,playbook:Playbook){await action(()=>api(`/api/skills/${skill.id}`,{method:"PATCH",body:JSON.stringify({name,playbook,save:true})}));setEditingSkillId(null)}
async function deleteSkill(skill:TaughtSkill){if(!window.confirm(t("deleteSkillConfirm",{name:skill.name||skill.goal})))return;await action(()=>api(`/api/skills/${skill.id}`,{method:"DELETE"}));setEditingSkillId(null)}
const editingSkill=editingSkillId?skills.find(skill=>skill.id===editingSkillId)||null:null;
function runSkill(skill:TaughtSkill){setPlusOpen(false);setDraft(current=>`${current.trim()?current.trimEnd()+"\n":""}執行「${skill.name}`);document.querySelector<HTMLTextAreaElement>(".composer textarea")?.focus()}
async function importSkillFile(file:File){if(!active)return;await action(async()=>{let payload:unknown;try{payload=JSON.parse(await file.text())}catch{throw new Error(t("skillImportInvalid"))}const skill=await api<TaughtSkill>(`/api/bots/${active.id}/skills/import`,{method:"POST",body:JSON.stringify(payload)});setEditingSkillId(skill.id)})}
function addPendingFiles(list:FileList|File[]){const incoming=[...list];if(!incoming.length)return;setError(null);setPendingFiles(current=>{const next=[...current];for(const file of incoming){if(next.length>=ATTACH_MAX){setError(t("attachTooMany"));break}if(file.size>ATTACH_MAX_BYTES){setError(t("attachTooLarge"));continue}if(!attachAllowed(file)){setError(t("attachType"));continue}next.push({id:clientNonce(),file,preview:file.type.startsWith("image/")?URL.createObjectURL(file):null})}return next})}
function removePendingFile(id:string){setPendingFiles(current=>current.filter(item=>{if(item.id===id&&item.preview)URL.revokeObjectURL(item.preview);return item.id!==id}))}
async function send(event:FormEvent){event.preventDefault();if(sendingRef.current||busy)return;const text=draft.trim();const files=pendingFiles;if((!active&&!activeRoom)||!activeSessionId||(!text&&files.length===0))return;sendingRef.current=true;setDraft("");setPendingFiles([]);historyIndexRef.current=null;historyDraftRef.current="";try{await action(async()=>{try{const attachments=await Promise.all(files.map(async item=>({name:item.file.name,mimeType:item.file.type,content:await readAsBase64(item.file)})));await api(`/api/sessions/${activeSessionId}/messages`,{method:"POST",body:JSON.stringify({text,clientNonce:clientNonce(),attachments})});sentHistoryRef.current.push(text||files[0]?.file.name||"");if(sentHistoryRef.current.length>100)sentHistoryRef.current.shift();files.forEach(item=>item.preview&&URL.revokeObjectURL(item.preview));await loadSessions()}catch(error){setPendingFiles(files);throw error}})}finally{sendingRef.current=false}}
function composerKeyDown(event:ReactKeyboardEvent<HTMLTextAreaElement>){if(event.nativeEvent.isComposing||event.key==="Process")return;const history=sentHistoryRef.current;if(event.key==="ArrowUp"&&history.length>0&&(!event.currentTarget.value.includes("\n")||event.currentTarget.selectionStart===0)){event.preventDefault();if(historyIndexRef.current===null){historyDraftRef.current=draft;historyIndexRef.current=history.length-1}else historyIndexRef.current=Math.max(0,historyIndexRef.current-1);setDraft(history[historyIndexRef.current]);return}if(event.key==="ArrowDown"&&historyIndexRef.current!==null&&(!event.currentTarget.value.includes("\n")||event.currentTarget.selectionEnd===event.currentTarget.value.length)){event.preventDefault();if(historyIndexRef.current<history.length-1){historyIndexRef.current+=1;setDraft(history[historyIndexRef.current])}else{historyIndexRef.current=null;setDraft(historyDraftRef.current)}return}if(event.key==="Enter"&&!event.shiftKey){event.preventDefault();if(sendingRef.current||busy)return;event.currentTarget.form?.requestSubmit()}}
function selectSession(id:string){setActiveSessionId(id);setSessionMenuOpen(false);if(sessionStoreKey)writeSessionStore(sessionStoreKey,id)}
async function createSession(){if(!sessionsPath||!sessionStoreKey)return;setSessionMenuOpen(false);setBusy(true);setError(null);try{const session=await api<Session>(sessionsPath,{method:"POST",body:JSON.stringify({title:t("newConversation")})});writeSessionStore(sessionStoreKey,session.id);const next=await api<Session[]>(sessionsPath);setSessions(next);setActiveSessionId(session.id);setMessages([])}catch(e){setError(e instanceof Error?e.message:t("operationFailed"))}finally{setBusy(false)}}
async function clearSession(){if(!activeSessionId)return;setClearOpen(false);setSessionMenuOpen(false);await action(async()=>{await api(`/api/sessions/${activeSessionId}/messages`,{method:"DELETE"});setMessages([]);await loadSessions()})}
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)}} 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)}}
async function copyClipboard(){try{await navigator.clipboard.writeText(desktopClipboard)}catch{setError("瀏覽器封鎖剪貼簿寫入。")}} async function copyClipboard(){try{await navigator.clipboard.writeText(desktopClipboard)}catch{setError(t("clipboardWriteBlocked"))}}
async function inbox(bot:Bot,actionName:string,groupName?:string|null){await api(`/api/bots/${bot.id}/inbox`,{method:"POST",body:JSON.stringify({action:actionName,groupName})});await loadBots()} async function inbox(bot:Bot,actionName:string,groupName?:string|null){await api(`/api/bots/${bot.id}/inbox`,{method:"POST",body:JSON.stringify({action:actionName,groupName})});await loadBots()}
function openBot(bot:Bot){setActiveSessionId(null);setActiveId(bot.id);setMobileNav(false);if(bot.unreadCount>0)void inbox(bot,"read")} 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)}
const frame=screenUrl?<iframe className="desktop-frame" src={screenUrl} title="Agent computer" allow="fullscreen; clipboard-read; clipboard-write"/>:<EmptyComputer state={computer.state}/>; function openRoom(room:Room){setMobileNav(false);setSessionMenuOpen(false);if(rightPart==="settings")setRightPart("computer");if(room.id===activeRoomId){if(!activeSessionId){const stored=readSessionStore()[`room:${room.id}`];if(stored)setActiveSessionId(stored);else void loadSessions()}return}setMessages([]);setActiveSessionId(null);setActiveId(null);setBusyMembers([]);setActiveRoomId(room.id)}
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}/>;
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==="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>
</nav>;
if(authRequired)return <LoginScreen authenticated={async()=>{setAuthRequired(false);setError(null);try{await loadBots()}catch(e){if(e instanceof ApiError&&e.status===401)setAuthRequired(true);else setError(e instanceof Error?e.message:"登入失敗")}}}/>; if(authRequired)return <LoginScreen authenticated={async()=>{setAuthRequired(false);setError(null);try{await loadBots()}catch(e){if(e instanceof ApiError&&e.status===401)setAuthRequired(true);else setError(e instanceof Error?e.message:t("loginFailed"))}}}/>;
return <div className="app-shell"> return <AvatarLookProvider value={looks}><div className={`app-shell ${rightCollapsed?"right-collapsed":"right-open"}`}>
<aside className={`sidebar ${mobileNav?"open":""}`}> <aside className={`sidebar ${mobileNav?"open":""}`}>
<div className="brand"><span>LazyBoy</span><div className="create-menu-wrap"><button className="icon-button" onClick={()=>setCreateMenuOpen(v=>!v)} aria-label="新增"><Plus/></button>{createMenuOpen&&<div className="create-menu"><button onClick={()=>{setCreateMenuOpen(false);setCreateOpen(true)}}><BotIcon/></button><button onClick={()=>{setCreateMenuOpen(false);setGroupOpen(true)}} disabled={bots.length===0}><Users/></button></div>}</div></div> <div className="brand"><span>LazyBoy</span><div className="create-menu-wrap" onClick={event=>event.stopPropagation()}><button type="button" className="icon-button" onClick={event=>{event.stopPropagation();setCreateMenuOpen(v=>!v)}} aria-label={t("add")} aria-haspopup="menu" aria-expanded={createMenuOpen}><UseAnimations animation={plusToX} size={18} strokeColor="#dfdfe2" wrapperStyle={{pointerEvents:"none"}}/></button>{createMenuOpen&&<div className="create-menu" role="menu"><button type="button" role="menuitem" onClick={()=>{setCreateMenuOpen(false);setCreateOpen(true)}}><BotIcon/>{t("addBot")}</button><button type="button" role="menuitem" onClick={()=>{setCreateMenuOpen(false);setGroupOpen(true)}} disabled={bots.length===0}><Users/>{t("addGroup")}</button></div>}</div></div>
<label className="search"><Search/><input value={query} onChange={e=>setQuery(e.target.value)} placeholder={t("search")}/></label> <label className="search"><UseAnimations animation={searchToX} size={16} strokeColor="var(--muted)"/><input value={query} onChange={e=>setQuery(e.target.value)} placeholder={t("search")}/></label>
<div className="bot-list">{sections.map(([label,items])=><section className="bot-group" key={label}><div className="group-label">{label}</div>{items.map(bot=><button className={`bot-row ${bot.id===activeId?"selected":""}`} key={bot.id} onClick={()=>openBot(bot)} onContextMenu={e=>{e.preventDefault();setContext({bot,x:e.clientX,y:e.clientY})}}><span className="avatar-wrap"><Avatar name={bot.name} color={bot.avatarColor} shape={bot.avatarShape} active={bot.id===activeId}/>{bot.unreadCount>0&&<i className="unread-dot" title={`${bot.unreadCount} 則未讀訊息`}/>}</span><span className="bot-copy"><strong>{bot.name}</strong><small>{modeLabel(bot.computerMode)}</small></span>{bot.tags?.[0]&&<span className="bot-tag side-tag">{bot.tags[0]}</span>}{bot.lastMessageAt&&<time className="row-time">{inboxTime(bot.lastMessageAt)}</time>}{bot.pinned&&<Pin className="row-pin"/>}</button>)}</section>)}</div> <div className="bot-list">
<button className="hidden-toggle" onClick={()=>setShowHidden(v=>!v)}>{showHidden?<EyeOff/>:<Eye/>}{showHidden?"隱藏已隱藏項目":"顯示已隱藏項目"}</button> {filteredRooms.length>0&&<section className="bot-group"><div className="group-label">{t("groups")}</div>{filteredRooms.map(room=><button className={`bot-row room-row ${room.id===activeRoomId?"selected":""}`} key={room.id} onClick={()=>openRoom(room)} onContextMenu={e=>{e.preventDefault();setRoomContext({room,x:e.clientX,y:e.clientY})}}><span className="avatar-wrap"><AvatarStack members={room.members} online thinkingIds={room.id===activeRoomId?busyMembers.map(member=>member.id):[]}/>{room.unreadCount>0&&<i className="unread-dot" title={t("unreadMessages",{count:room.unreadCount})}/>}</span><span className="bot-copy"><strong>{room.name}</strong><small>{room.lastPreview||t("members",{count:room.members.length})}</small></span>{room.lastMessageAt&&<time className="row-time">{inboxTime(room.lastMessageAt)}</time>}</button>)}</section>}
<div className="sidebar-bottom"><button className="account" title="目前工作區"><Avatar name="L"/><span>Local workspace</span><ChevronDown/></button><button className="icon-button logout-button" title="登出" onClick={async()=>{await api("/api/session",{method:"DELETE",body:"{}"}).catch(()=>{});setBots([]);setActiveId(null);setAuthRequired(true)}}><LogOut/></button></div> {sections.map(([label,items])=><section className="bot-group" key={label}><div className="group-label">{label}</div>{items.map(bot=><button className={`bot-row ${bot.id===activeId&&!activeRoomId?"selected":""}`} key={bot.id} onClick={()=>openBot(bot)} onContextMenu={e=>{e.preventDefault();setContext({bot,x:e.clientX,y:e.clientY})}}><span className="avatar-wrap"><Avatar lookId={bot.id} name={bot.name} color={bot.avatarColor} shape={bot.avatarShape} active={bot.id===activeId&&!activeRoomId} online/>{bot.unreadCount>0&&<i className="unread-dot" title={t("unreadMessages",{count:bot.unreadCount})}/>}</span><span className="bot-copy"><strong>{bot.name}</strong><small>{modeLabel(bot.computerMode)}</small></span>{bot.tags?.[0]&&<span className="bot-tag side-tag">{bot.tags[0]}</span>}{bot.lastMessageAt&&<time className="row-time">{inboxTime(bot.lastMessageAt)}</time>}{bot.pinned&&<Pin className="row-pin"/>}</button>)}</section>)}
</div>
<button className="hidden-toggle" onClick={()=>setShowHidden(v=>!v)}>{showHidden?<UseAnimations animation={visibility2} size={14} strokeColor="var(--muted)"/>:<UseAnimations animation={visibility} size={14} strokeColor="var(--muted)"/>}{showHidden?t("hideHiddenItems"):t("showHiddenItems")}</button>
<div className="sidebar-bottom">
<button type="button" className={`plugin-row ${rightPart==="plugins"&&!rightCollapsed?"selected":""}`} onClick={()=>openPane("plugins")}>
<span className="plugin-icon"><Plug/></span>
<span className="bot-copy"><strong>{t("plugins")}</strong><small>{mcpServers.filter(server=>server.status==="connected").length>0?t("mcpConnected",{count:mcpServers.filter(server=>server.status==="connected").length}):t("connectMcpServer")}</small></span>
</button>
<div className="account-wrap" onClick={event=>event.stopPropagation()}>
{accountOpen&&<div className="account-menu" role="menu">
<button type="button" role="menuitem" onClick={()=>openAccount("phone")}><Smartphone/>{t("openOnPhone")}</button>
<button type="button" role="menuitem" onClick={()=>openAccount("settings")}><Settings/>{t("settings")}</button>
<button type="button" role="menuitem" onClick={()=>openAccount("model")}><BotIcon/>{t("modelSettings")}</button>
<button type="button" role="menuitem" disabled={!paneBot} onClick={()=>{setAccountOpen(false);openPane("memory")}}><Brain/>{t("memory")}</button>
<button type="button" role="menuitem" onClick={()=>openAccount("about")}><Info/>{t("about")}</button>
<button type="button" role="menuitem" onClick={()=>openAccount("help")}><CircleHelp/>{t("helpCenter")}</button>
<button type="button" role="menuitem" onClick={()=>openAccount("feedback")}><Megaphone/>{t("sendFeedback")}</button>
<hr/>
<button type="button" role="menuitem" onClick={()=>void logout()}><LogOut/>{t("logout")}</button>
</div>}
<button type="button" className={`account ${accountOpen?"open":""}`} title={t("workspaceMenu")} aria-haspopup="menu" aria-expanded={accountOpen} onClick={()=>{setAccountOpen(open=>!open);setCreateMenuOpen(false)}}>
<WorkspaceAvatar name={workspaceName}/><span>{workspaceName}</span><ChevronDown className="chevron"/>
</button>
</div>
</div>
</aside> </aside>
<main className="chat-panel"> <main className="chat-panel">
<header className="topbar"><button className="icon-button mobile-menu" onClick={()=>setMobileNav(v=>!v)}><Menu/></button>{active?<><Avatar name={active.name} color={active.avatarColor} shape={active.avatarShape} active/><strong>{active.name}</strong><div className="session-picker"><select aria-label="選擇對話" value={activeSessionId||""} onChange={event=>setActiveSessionId(event.target.value)}>{sessions.map(session=><option value={session.id} key={session.id}>{session.title}</option>)}</select><button className="icon-button" onClick={createSession} title="新增對話" disabled={busy}><Plus/></button></div><span className="grow"/><button className="icon-button" onClick={()=>setMemoryOpen(true)} title="Agent 記憶"><Brain/></button><button className="icon-button" onClick={()=>setSettingsOpen(true)} title="機器人設定"><Settings/></button><button className="icon-button computer-toggle" onClick={()=>setComputerOpen(true)} title="開啟電腦"><Computer/></button><button className="icon-button danger-ghost" onClick={()=>setDeleteOpen(true)} title="刪除機器人"><Trash2/></button></>:<strong></strong>}</header> <header className="topbar"><button className="icon-button mobile-menu" onClick={()=>setMobileNav(v=>!v)}><UseAnimations animation={menu} size={18} strokeColor="#dfdfe2"/></button>{activeRoom?<><AvatarStack members={activeRoom.members} 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">{active&&messages.length===0?<div className="welcome"><Avatar name={active.name} color={active.avatarColor} shape={active.avatarShape} active size={64}/><h1> {active.name} </h1><p>{active.description||"傳送訊息,讓它在自己的電腦上完成任務。"}</p></div>:messages.map(message=><div key={message.id} className={`message ${message.role}`}><span>{message.body}</span></div>)}{active&&computer.busyBotName&&<div className="thinking-row"><Avatar name={active.name} color={active.avatarColor} shape={active.avatarShape} active/><span className="thinking-dots" aria-label="正在思考"><i/><i/><i/></span></div>}</div> <div className="messages">{(activeRoom||active)&&messages.length===0?<div className="welcome">{activeRoom?<AvatarStack members={activeRoom.members} size={56} online/>:<Avatar lookId={active!.id} name={active!.name} color={active!.avatarColor} shape={active!.avatarShape} active online size={64}/>}<h1>{activeRoom?t("startRoomDiscussion",{name:activeRoom.name}):t("startBotWork",{name:active!.name})}</h1><p>{activeRoom?t("roomWillReply",{names:activeRoom.members.map(member=>member.name).join("、")}):active!.description||t("botWelcome")}</p></div>:messages.map(message=>{const spoken=message.role!=="user"&&Boolean(activeRoom);const speakerName=message.speakerName||(spoken?paneBot?.name:undefined);const speakerShape=(message.speakerShape||paneBot?.avatarShape||"blob") as AvatarShape;const files=messageFiles(message.blocks);const 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>
{error&&<div className="error-banner"><span>{error}</span><button onClick={()=>setError(null)}><X/></button></div>} {error&&<div className="error-banner"><span>{error}</span><button onClick={()=>setError(null)}><X/></button></div>}
<form className="composer" onSubmit={send}><button type="button" className="composer-plus" disabled title="附件功能尚未開放" aria-label="附件功能尚未開放"><Plus/></button><textarea rows={1} value={draft} onChange={e=>setDraft(e.target.value)} onKeyDown={e=>{if(e.key==="Enter"&&!e.shiftKey){e.preventDefault();e.currentTarget.form?.requestSubmit()}}} placeholder={activeSessionId&&active?`傳訊息給 ${active.name}`:"先選擇對話"} disabled={!activeSessionId}/>{computer.busyBotName?<button type="button" className="send stop-send" title="停止對話" onClick={()=>active&&action(()=>api(`/api/bots/${active.id}/stop`,{method:"POST",body:"{}"}))}><Square/></button>:<button className="send" disabled={!activeSessionId||!draft.trim()||busy}><ArrowUp/></button>}</form> {otherSessionBusy&&<div className="queue-hint">{t("anotherConversationQueued")}</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>
</main> </main>
<aside className="computer-panel"><ComputerHeader active={active} computer={computer}/><div className="preview">{computerOpen?<EmptyComputer state={computer.state}/>:frame}</div>{active&&<><div className="computer-caption"><span>{active.name} </span><button className="outline" onClick={()=>setComputerOpen(true)}></button></div><ControlBar active={active} computer={computer} busy={busy} action={action} paste={pasteClipboard} copy={copyClipboard}/></>}</aside> {!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>
<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>
{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)}/>}
</div>
</>
</aside>}
{computerOpen&&active&&<div className="computer-overlay"><header><div><Avatar name={active.name} color={active.avatarColor} shape={active.avatarShape} active/><strong>{modeLabel(computer.mode)}</strong><span className="control-badge">{computer.controlHolder==="user"?"你正在控制":computer.busyBotName?"AI 操作中(唯讀)":"唯讀"}</span></div><div><ControlButtons computer={computer} busy={busy} action={action} active={active}/><button className="icon-button" onClick={pasteClipboard} disabled={computer.controlHolder!=="user"} title="貼上剪貼簿"><ClipboardPaste/></button><button className="icon-button" onClick={copyClipboard} disabled={computer.controlHolder!=="user"||!desktopClipboard} title="複製桌面剪貼簿"><Clipboard/></button><button className="icon-button" title="更多操作"><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.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>}
{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=>{document.querySelectorAll<HTMLIFrameElement>(".desktop-frame").forEach(frame=>frame.contentWindow?.postMessage({type:"lazyboy-host-clipboard",text},location.origin));setClipboardOpen(false)}}/>}
{settingsOpen&&active&&<BotSettingsDialog bot={active} close={()=>setSettingsOpen(false)} saved={async()=>{setSettingsOpen(false);await loadBots()}}/>}
{memoryOpen&&active&&<MemoryDialog bot={active} close={()=>setMemoryOpen(false)} changed={loadBots}/>}
{createOpen&&<CreateDialog close={()=>setCreateOpen(false)} created={async bot=>{setCreateOpen(false);await loadBots();setActiveId(bot.id)}}/>} {createOpen&&<CreateDialog close={()=>setCreateOpen(false)} created={async bot=>{setCreateOpen(false);await loadBots();setActiveId(bot.id)}}/>}
{groupOpen&&<CreateGroupDialog bots={bots} close={()=>setGroupOpen(false)} created={async()=>{setGroupOpen(false);await loadBots()}}/>} {groupOpen&&<CreateGroupDialog bots={bots} close={()=>setGroupOpen(false)} created={async room=>{setGroupOpen(false);setRooms(current=>[room,...current.filter(item=>item.id!==room.id)]);setActiveId(null);setActiveSessionId(null);setBusyMembers([]);setMessages([]);setRightPart("computer");setActiveRoomId(room.id);await loadBots()}}/>}
{deleteOpen&&active&&<ConfirmDelete bot={active} close={()=>setDeleteOpen(false)} confirm={()=>action(async()=>{await api(`/api/bots/${active.id}`,{method:"DELETE"});setDeleteOpen(false);setActiveId(null);await loadBots()})}/>} {deleteOpen&&paneBot&&!activeRoom&&<ConfirmDelete bot={paneBot} close={()=>setDeleteOpen(false)} confirm={()=>action(async()=>{await api(`/api/bots/${paneBot.id}`,{method:"DELETE"});setDeleteOpen(false);setActiveId(null);await loadBots()})}/>}
{context&&<BotContextMenu context={context} close={()=>setContext(null)} run={async(actionName,group)=>{setContext(null);if(actionName==="delete"){setActiveId(context.bot.id);setDeleteOpen(true);return}await inbox(context.bot,actionName,group)}}/>} {clearOpen&&<ConfirmClear close={()=>setClearOpen(false)} confirm={()=>void clearSession()}/>}
{sessionToDelete&&<ConfirmSessionDelete close={()=>setSessionToDelete(null)} confirm={()=>{const id=sessionToDelete;setSessionToDelete(null);void deleteSession(id)}}/>}
{roomToDelete&&<ConfirmRoomDelete room={roomToDelete} close={()=>setRoomToDelete(null)} confirm={()=>void deleteRoom(roomToDelete)}/>}
{context&&<BotContextMenu context={context} close={()=>setContext(null)} run={async(actionName,group)=>{setContext(null);if(actionName==="delete"){setActiveId(context.bot.id);setActiveRoomId(null);setDeleteOpen(true);return}await inbox(context.bot,actionName,group)}}/>}
{roomContext&&<RoomContextMenu context={roomContext} close={()=>setRoomContext(null)} onDelete={()=>{setRoomToDelete(roomContext.room);setRoomContext(null)}}/>}
{accountDialog==="phone"&&<PhoneAccessDialog close={()=>setAccountDialog(null)}/>}
{accountDialog==="settings"&&<WorkspaceSettingsDialog name={workspaceName} setName={setWorkspaceName} showHidden={showHidden} setShowHidden={setShowHidden} rightCollapsed={rightCollapsed} setRightCollapsed={setRightCollapsed} close={()=>setAccountDialog(null)}/>}
{accountDialog==="model"&&<ModelSettingsDialog close={()=>setAccountDialog(null)}/>}
{accountDialog==="about"&&<AboutDialog close={()=>setAccountDialog(null)}/>}
{accountDialog==="help"&&<HelpDialog close={()=>setAccountDialog(null)}/>}
{accountDialog==="feedback"&&<FeedbackDialog close={()=>setAccountDialog(null)}/>}
</div></AvatarLookProvider>
}
function BotContextMenu({context,close,run}:{context:{bot:Bot;x:number;y:number};close:()=>void;run:(action:string,group?:string|null)=>void}){const bot=context.bot;return <div className="context-menu" style={{left:Math.min(context.x,window.innerWidth-220),top:Math.min(context.y,window.innerHeight-280)}} onClick={e=>e.stopPropagation()}><button onClick={()=>run(bot.pinned?"unpin":"pin")}><Pin/>{bot.pinned?t("unpin"):t("pin")}</button><button onClick={()=>run("unread")}><UseAnimations animation={mail} size={15} strokeColor="#dfdfe2"/>{t("markUnread")}</button><button onClick={()=>{const name=window.prompt(t("enterGroupName"),bot.groupName||"");if(name!==null)run("group",name)}}><UseAnimations animation={folder} size={15} strokeColor="#dfdfe2"/>{bot.groupName?t("changeGroup"):t("createOrMoveGroup")}</button>{bot.groupName&&<button onClick={()=>run("group",null)}><X/>{t("removeFromGroup")}</button>}<button onClick={()=>run(bot.hidden?"show":"hide")}>{bot.hidden?<UseAnimations animation={visibility} size={15} strokeColor="#dfdfe2"/>:<UseAnimations animation={visibility2} size={15} strokeColor="#dfdfe2"/>}{bot.hidden?t("unhide"):t("hide")}</button><hr/><button className="danger-item" onClick={()=>run("delete")}><UseAnimations animation={trash2} size={15} strokeColor="#ff7777"/>{t("delete")}</button><button className="context-close" onClick={close}><X/></button></div>}
function SessionMenu({sessions,activeSessionId,open,setOpen,busy,onSelect,onCreate,onDelete,onClear}:{sessions:Session[];activeSessionId:string|null;open:boolean;setOpen:(fn:(value:boolean)=>boolean)=>void;busy:boolean;onSelect:(id:string)=>void;onCreate:()=>void;onDelete:(id:string)=>void;onClear:()=>void}){
const active=sessions.find(session=>session.id===activeSessionId);
return <div className="session-picker" onClick={event=>event.stopPropagation()}>
<button type="button" className="session-current" onClick={()=>setOpen(value=>!value)} aria-label={t("chooseConversation")} aria-expanded={open}>
<span>{active?.title||t("chooseConversation")}</span><ChevronDown/>
</button>
<button type="button" className="icon-button" onClick={onCreate} title={t("newConversation")} disabled={busy}><Plus/></button>
{open&&<div className="session-menu">
<button type="button" onClick={onCreate} disabled={busy}><Plus/>{t("newConversation")}</button>
<hr/>
<div className="session-menu-list">{sessions.map(session=><div key={session.id} className={`session-item ${session.id===activeSessionId?"selected":""}`}>
<button type="button" className="session-item-main" onClick={()=>onSelect(session.id)}>
<strong>{session.title}</strong>
<time>{inboxTime(session.updatedAt)}</time>
</button>
<button type="button" className="icon-button" title={t("deleteConversation")} disabled={busy} onClick={()=>onDelete(session.id)}><UseAnimations animation={trash2} size={18} strokeColor="#dfdfe2"/></button>
</div>)}</div>
<hr/>
<button type="button" className="danger-item" disabled={busy||!activeSessionId} onClick={onClear}>{t("clearConversation")}</button>
</div>}
</div> </div>
} }
function BotContextMenu({context,close,run}:{context:{bot:Bot;x:number;y:number};close:()=>void;run:(action:string,group?:string|null)=>void}){const bot=context.bot;return <div className="context-menu" style={{left:Math.min(context.x,window.innerWidth-220),top:Math.min(context.y,window.innerHeight-280)}} onClick={e=>e.stopPropagation()}><button onClick={()=>run(bot.pinned?"unpin":"pin")}><Pin/>{bot.pinned?"取消釘選":"釘選"}</button><button onClick={()=>run("unread")}><Mail/></button><button onClick={()=>{const name=window.prompt("輸入分組名稱",bot.groupName||"");if(name!==null)run("group",name)}}><FolderPlus/>{bot.groupName?"變更分組":"建立/移入分組"}</button>{bot.groupName&&<button onClick={()=>run("group",null)}><X/></button>}<button onClick={()=>run(bot.hidden?"show":"hide")}>{bot.hidden?<Eye/>:<EyeOff/>}{bot.hidden?"取消隱藏":"隱藏"}</button><hr/><button className="danger-item" onClick={()=>run("delete")}><Trash2/></button><button className="context-close" onClick={close}><X/></button></div>} function ConfirmClear({close,confirm}:{close:()=>void;confirm:()=>void}){
return <div className="modal-backdrop"><div className="dialog compact"><h2>{t("clearConversationTitle")}</h2><p>{t("clearConversationDescription")}</p><div className="dialog-actions"><button className="outline" onClick={close}>{t("cancel")}</button><button className="danger" onClick={confirm}>{t("clearMessages")}</button></div></div></div>
}
function BotSettingsDialog({bot,close,saved}:{bot:Bot;close:()=>void;saved:()=>void}){const[name,setName]=useState(bot.name);const[title,setTitle]=useState(bot.title);const[description,setDescription]=useState(bot.description);const[color,setColor]=useState(bot.avatarColor||"#8B5CF6");const[shape,setShape]=useState<AvatarShape>(bot.avatarShape||"blob");const[tagText,setTagText]=useState((bot.tags||[]).join("、"));const[busy,setBusy]=useState(false);const colors=["#08A99D","#F1F2F2","#956A43","#DD263B","#F36C05","#F39A00","#00B873","#1985E6","#7140D9","#DC2781","#A7A7A7"];const shapes:AvatarShape[]=["round","blob","squircle","capsule","triangle","hexagon","cloud","drop"];return <div className="modal-backdrop"><form className="dialog settings-dialog" onSubmit={async e=>{e.preventDefault();if(!name.trim())return;setBusy(true);try{await api(`/api/bots/${bot.id}`,{method:"PATCH",body:JSON.stringify({name:name.trim(),title,description,avatarColor:color,avatarShape:shape,tags:tagText.split(/[、,]/).map(v=>v.trim()).filter(Boolean)})});saved()}finally{setBusy(false)}}}><div className="dialog-title"><h2></h2><button type="button" onClick={close}><X/></button></div><div className="avatar-editor"><Avatar name={name||bot.name} color={color} shape={shape} size={78}/><strong></strong><small>使</small></div><fieldset><legend></legend><div className="color-grid">{colors.map(value=><button type="button" key={value} className={color===value?"selected":""} style={{background:value}} onClick={()=>setColor(value)} aria-label={`選擇 ${value}`}/>) }<label className="custom-color" title="自訂顏色"><input type="color" value={color} onChange={e=>setColor(e.target.value.toUpperCase())}/><span></span></label></div></fieldset><fieldset><legend></legend><div className="shape-grid">{shapes.map(value=><button type="button" className={shape===value?"selected":""} onClick={()=>setShape(value)} key={value}><Avatar name={name||bot.name} color={color} shape={value}/></button>)}</div></fieldset><label><input value={name} maxLength={80} onChange={e=>setName(e.target.value)}/></label><label><input value={tagText} maxLength={120} onChange={e=>setTagText(e.target.value)} placeholder="研究、設計、客服(用逗號分隔)"/><small> 6 </small></label><label><input value={title} maxLength={100} onChange={e=>setTitle(e.target.value)} placeholder="例如:產品研究助理"/></label><label><textarea value={description} maxLength={1000} rows={4} onChange={e=>setDescription(e.target.value)} placeholder="說明這個機器人的用途與工作範圍"/></label><div className="dialog-actions"><button type="button" className="outline" onClick={close}></button><button className="primary" disabled={busy||!name.trim()}>{busy?"儲存中…":"儲存設定"}</button></div></form></div>} function ConfirmSessionDelete({close,confirm}:{close:()=>void;confirm:()=>void}){
return <div className="modal-backdrop"><div className="dialog compact"><h2>{t("deleteConversationTitle")}</h2><p>{t("deleteConversationDescription")}</p><div className="dialog-actions"><button className="outline" onClick={close}>{t("cancel")}</button><button className="danger" onClick={confirm}>{t("deleteConversation")}</button></div></div></div>
}
function MemoryDialog({bot,close,changed}:{bot:Bot;close:()=>void;changed:()=>Promise<void>}){ const EXPR_I18N = {idle:"exprIdle",happy:"exprHappy",sad:"exprSad",mad:"exprMad",surprised:"exprSurprised",wink:"exprWink",sleepy:"exprSleepy",smug:"exprSmug",unsure:"exprUnsure",scared:"exprScared",love:"exprLove",shy:"exprShy",sick:"exprSick",thinking:"exprThinking"} as const satisfies Record<AvatarExpression, MessageKey>;
const[items,setItems]=useState<MemoryItem[]>([]);const[draft,setDraft]=useState("");const[enabled,setEnabled]=useState(bot.memoryEnabled);const[busy,setBusy]=useState(false);const[error,setError]=useState(""); const BG_I18N = {none:"bgNone",circle:"bgCircle",squircle:"bgSquircle",square:"bgSquare"} as const satisfies Record<AvatarBackground, MessageKey>;
function BotSettingsPane({bot,look,onLook,saved,onDelete}:{bot:Bot;look:AvatarLook;onLook:(look:AvatarLook)=>void;saved:()=>Promise<void>|void;onDelete:()=>void}){
const[name,setName]=useState(bot.name);const[title,setTitle]=useState(bot.title);const[description,setDescription]=useState(bot.description);const[color,setColor]=useState(bot.avatarColor||"#8B5CF6");const[shape,setShape]=useState<AvatarShape>(resolveBlobatarShape(bot.avatarShape));const[expression,setExpression]=useState<AvatarExpression>(look.expression);const[background,setBackground]=useState<AvatarBackground>(look.background);const[tagText,setTagText]=useState((bot.tags||[]).join("、"));const[busy,setBusy]=useState(false);
useEffect(()=>{setName(bot.name);setTitle(bot.title);setDescription(bot.description);setColor(bot.avatarColor||"#8B5CF6");setShape(resolveBlobatarShape(bot.avatarShape));setExpression(look.expression);setBackground(look.background);setTagText((bot.tags||[]).join("、"))},[bot.id,bot.name,bot.title,bot.description,bot.avatarColor,bot.avatarShape,bot.tags,look.expression,look.background]);
const colors=["#08A99D","#F1F2F2","#956A43","#DD263B","#F36C05","#F39A00","#00B873","#1985E6","#7140D9","#DC2781","#A7A7A7"];
const face={name:name||bot.name,color,shape,expression,background};
return <form className="pane-form settings-pane" onSubmit={async e=>{e.preventDefault();if(!name.trim())return;setBusy(true);try{await api(`/api/bots/${bot.id}`,{method:"PATCH",body:JSON.stringify({name:name.trim(),title,description,avatarColor:color,avatarShape:persistBlobatarShape(shape),tags:tagText.split(/[、,]/).map(v=>v.trim()).filter(Boolean)})});onLook({expression,background});await saved()}finally{setBusy(false)}}}>
<div className="avatar-editor"><Avatar {...face} size={96}/><strong>{t("avatarAppearance")}</strong><small>{t("avatarAppearanceHint")}</small></div>
<fieldset><legend>{t("color")}</legend><div className="color-grid">{colors.map(value=><button type="button" key={value} className={color===value?"selected":""} style={{background:value}} onClick={()=>setColor(value)} aria-label={t("chooseColor",{value})}/>)}<label className="custom-color" title={t("customColor")}><input type="color" value={color} onChange={e=>setColor(e.target.value.toUpperCase())}/><span></span></label></div></fieldset>
<fieldset><legend>{t("shape")}</legend><div className="shape-grid">{BLOBATAR_SHAPES.map(value=><button type="button" className={resolveBlobatarShape(shape)===value?"selected":""} onClick={()=>setShape(value)} key={value}><Avatar {...face} shape={value} gaze={false}/></button>)}</div></fieldset>
<fieldset><legend>{t("expression")}</legend><div className="expr-grid">{BLOBATAR_EXPRESSIONS.map(value=><button type="button" className={expression===value?"selected":""} onClick={()=>setExpression(value)} key={value}><Avatar {...face} expression={value} gaze={false} size={36}/><span>{t(EXPR_I18N[value])}</span></button>)}</div></fieldset>
<fieldset><legend>{t("background")}</legend><div className="bg-grid">{BLOBATAR_BACKGROUNDS.map(value=><button type="button" className={background===value?"selected":""} onClick={()=>setBackground(value)} key={value}><Avatar {...face} background={value} gaze={false} size={36}/><span>{t(BG_I18N[value])}</span></button>)}</div></fieldset>
<label>{t("name")}<input value={name} maxLength={80} onChange={e=>setName(e.target.value)}/></label>
<label>{t("tags")}<input value={tagText} maxLength={120} onChange={e=>setTagText(e.target.value)} placeholder={t("tagsPlaceholder")}/><small>{t("tagsLimit")}</small></label>
<label>{t("shortTitle")}<input value={title} maxLength={100} onChange={e=>setTitle(e.target.value)} placeholder={t("shortTitlePlaceholder")}/></label>
<label>{t("description")}<textarea value={description} maxLength={1000} rows={4} onChange={e=>setDescription(e.target.value)} placeholder={t("descriptionPlaceholder")}/></label>
<div className="pane-actions"><button className="primary" disabled={busy||!name.trim()}>{busy?t("saving"):t("saveSettings")}</button><button type="button" className="danger" onClick={onDelete}>{t("deleteBot")}</button></div>
</form>
}
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("");
const[editingId,setEditingId]=useState<string|null>(null);const[editingText,setEditingText]=useState("");const[confirmClear,setConfirmClear]=useState(false);
const load=useCallback(()=>api<MemoryItem[]>(`/api/bots/${bot.id}/memories`).then(setItems),[bot.id]); const load=useCallback(()=>api<MemoryItem[]>(`/api/bots/${bot.id}/memories`).then(setItems),[bot.id]);
useEffect(()=>{load().catch(e=>setError(e instanceof Error?e.message:"載入失敗"))},[load]); 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:"操作失敗")}finally{setBusy(false)}} useEffect(()=>setEnabled(bot.memoryEnabled),[bot.id,bot.memoryEnabled]);
async function toggle(value:boolean){setEnabled(value);try{await api(`/api/bots/${bot.id}`,{method:"PATCH",body:JSON.stringify({name:bot.name,title:bot.title,description:bot.description,avatarColor:bot.avatarColor,avatarShape:bot.avatarShape,tags:bot.tags,memoryEnabled:value})});await changed()}catch(e){setEnabled(!value);setError(e instanceof Error?e.message:"設定失敗")}} const visible=items.filter(item=>!query.trim()||item.content.toLowerCase().includes(query.trim().toLowerCase()));
return <div className="modal-backdrop"><div className="dialog settings-dialog memory-dialog"> 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)}}
<div className="dialog-title"><h2>{bot.name} </h2><button onClick={close}><X/></button></div> async function toggle(value:boolean){setEnabled(value);try{await api(`/api/bots/${bot.id}`,{method:"PATCH",body:JSON.stringify({name:bot.name,title:bot.title,description:bot.description,avatarColor:bot.avatarColor,avatarShape:bot.avatarShape,tags:bot.tags,memoryEnabled:value})});await changed()}catch(e){setEnabled(!value);setError(e instanceof Error?e.message:t("settingsFailed"))}}
<label><input type="checkbox" checked={enabled} onChange={e=>void toggle(e.target.checked)}/> Agent durable memory</label> return <div className="memory-pane">
<p className="memory-help">{t("memoryHelp")}</p>
<label className="memory-toggle"><input type="checkbox" checked={enabled} onChange={e=>void toggle(e.target.checked)}/> {t("enableLongTermMemory")}</label>
<form onSubmit={e=>{e.preventDefault();const content=draft.trim();if(!content)return;void run(async()=>{await api(`/api/bots/${bot.id}/memories`,{method:"POST",body:JSON.stringify({content,importance:.5})});setDraft("")})}}> <form onSubmit={e=>{e.preventDefault();const content=draft.trim();if(!content)return;void run(async()=>{await api(`/api/bots/${bot.id}/memories`,{method:"POST",body:JSON.stringify({content,importance:.5})});setDraft("")})}}>
<label><textarea value={draft} onChange={e=>setDraft(e.target.value)} rows={3} placeholder="只儲存明確偏好或事實;密碼與 token 會被拒絕。"/></label> <label>{t("addMemory")}<textarea value={draft} onChange={e=>setDraft(e.target.value)} rows={3} placeholder={t("memoryPlaceholder")}/></label>
<button className="primary" disabled={busy||!draft.trim()}></button> <button className="primary" disabled={busy||!draft.trim()}>{t("add")}</button>
</form> </form>
<div className="memory-list">{items.length===0?<p></p>:items.map(item=><div className="memory-row" key={item.id}><span>{item.content}</span><small> {item.importance.toFixed(2)} · rev {item.revision}</small><div><button className="outline" disabled={busy} onClick={()=>{const content=window.prompt("編輯記憶",item.content);if(content!==null)void run(()=>api(`/api/bots/${bot.id}/memories/${item.id}`,{method:"PATCH",body:JSON.stringify({content,importance:item.importance})}))}}></button><button className="danger-ghost" disabled={busy} onClick={()=>void run(()=>api(`/api/bots/${bot.id}/memories/${item.id}`,{method:"DELETE"}))}></button></div></div>)}</div> {items.length>0&&<label className="memory-search">{t("memorySearch")}<input value={query} onChange={e=>setQuery(e.target.value)} placeholder={t("filterMemory")}/></label>}
{error&&<div className="error-banner">{error}</div>} <div className="memory-list">{items.length===0?<p>{t("noMemory")}</p>:visible.length===0?<p>{t("noMatchingMemory")}</p>:visible.map(item=><div className="memory-row" key={item.id}>
<div className="dialog-actions"><button className="danger" disabled={busy||items.length===0} onClick={()=>{if(window.confirm("清除這個 Agent 的所有記憶?"))void run(()=>api(`/api/bots/${bot.id}/memories`,{method:"DELETE"}))}}></button><button className="outline" onClick={close}></button></div> {editingId===item.id?<textarea value={editingText} onChange={e=>setEditingText(e.target.value)} rows={3}/>:<span>{item.content}</span>}
<small>{inboxTime(item.updatedAt)}</small>
<div>{editingId===item.id?<>
<button className="primary" disabled={busy||!editingText.trim()} onClick={()=>void run(async()=>{await api(`/api/bots/${bot.id}/memories/${item.id}`,{method:"PATCH",body:JSON.stringify({content:editingText,importance:item.importance})});setEditingId(null)})}>{t("save")}</button>
<button className="outline" disabled={busy} onClick={()=>setEditingId(null)}>{t("cancel")}</button>
</>:<>
<button className="outline" disabled={busy} onClick={()=>{setEditingId(item.id);setEditingText(item.content)}}>{t("edit")}</button>
<button className="danger-ghost" disabled={busy} onClick={()=>void run(()=>api(`/api/bots/${bot.id}/memories/${item.id}`,{method:"DELETE"}))}>{t("delete")}</button>
</>}</div>
</div>)}</div>
{error&&<div className="pane-error">{error}</div>}
<div className="pane-actions">
{confirmClear?<span className="memory-clear-confirm">{t("clearAllMemoryConfirm")}<button className="danger" disabled={busy} onClick={()=>void run(async()=>{await api(`/api/bots/${bot.id}/memories`,{method:"DELETE"});setConfirmClear(false)})}>{t("clearMemoryConfirm")}</button><button className="outline" onClick={()=>setConfirmClear(false)}>{t("cancel")}</button></span>:<button className="danger" disabled={busy||items.length===0} onClick={()=>setConfirmClear(true)}>{t("clearAll")}</button>}
</div>
</div>
}
function parsePairs(text:string){const out:Record<string,string>={};for(const line of text.split(/\n+/)){const trimmed=line.trim();if(!trimmed)continue;const cut=trimmed.indexOf("=");if(cut<=0)continue;out[trimmed.slice(0,cut).trim()]=trimmed.slice(cut+1)}return out}
function McpPane({servers,reload}:{servers:McpServer[];reload:()=>Promise<void>}){
const[picker,setPicker]=useState(false);const[busy,setBusy]=useState(false);const[error,setError]=useState("");const[openId,setOpenId]=useState<string|null>(null);
async function run(work:()=>Promise<unknown>){setBusy(true);setError("");try{await work();await reload()}catch(e){setError(e instanceof Error?e.message:t("operationFailed"))}finally{setBusy(false)}}
return <div className="mcp-pane">
<p className="memory-help">{t("mcpHelp")}</p>
<button type="button" className="primary mcp-choose" disabled={busy} onClick={()=>setPicker(true)}><Plug/>{t("chooseMcp")}</button>
{picker&&<McpPickerDialog added={servers.map(server=>server.name)} close={()=>setPicker(false)} connected={async()=>{setPicker(false);await reload()}}/>}
<div className="mcp-list">{servers.length===0?<p>{t("noMcp")}</p>:servers.map(server=><div className={`mcp-card ${server.status}`} key={server.id}>
<button type="button" className="mcp-card-head" onClick={()=>setOpenId(id=>id===server.id?null:server.id)}>
<i className={`mcp-dot ${server.status}`}/><strong>{server.name}</strong>
<small>{server.status==="connected"?t("toolsCount",{count:server.tools.length}):server.status==="disabled"?t("disabled"):t("disconnected")}</small>
</button>
{server.error&&<div className="mcp-error">{server.error}</div>}
{openId===server.id&&<>
<div className="mcp-tools">{server.tools.length===0?<span>{t("noTools")}</span>:server.tools.map(tool=><div className="mcp-tool" key={tool.exposedName}><code>{tool.exposedName}</code><span>{tool.description||tool.name}</span></div>)}</div>
<div className="mcp-card-actions">
<button className="outline" disabled={busy} onClick={()=>void run(()=>api(`/api/mcp-servers/${server.id}/reconnect`,{method:"POST",body:"{}"}))}><RefreshCw/>{t("reconnect")}</button>
<button className="outline" disabled={busy} onClick={()=>void run(()=>api(`/api/mcp-servers/${server.id}`,{method:"PATCH",body:JSON.stringify({enabled:!server.enabled})}))}>{server.enabled?t("disable"):t("enable")}</button>
<button className="danger-ghost" disabled={busy} onClick={()=>void run(()=>api(`/api/mcp-servers/${server.id}`,{method:"DELETE"}))}>{t("delete")}</button>
</div>
</>}
</div>)}</div>
{error&&<div className="pane-error">{error}</div>}
</div>
}
function McpPickerDialog({added,close,connected}:{added:string[];close:()=>void;connected:()=>Promise<void>}){
const[query,setQuery]=useState("");const[custom,setCustom]=useState(false);
const[items,setItems]=useState<McpCatalogEntry[]>([]);const[loading,setLoading]=useState(true);
const[picked,setPicked]=useState<McpCatalogEntry|null>(null);
const[secrets,setSecrets]=useState<Record<string,string>>({});
const[busy,setBusy]=useState(false);const[error,setError]=useState("");
const addedSet=useMemo(()=>new Set(added.map(name=>name.toLowerCase())),[added]);
useEffect(()=>{const timer=setTimeout(()=>{setLoading(true);api<{servers:McpCatalogEntry[]}>(`/api/mcp-catalog?q=${encodeURIComponent(query.trim())}`).then(result=>setItems(result.servers)).catch(e=>setError(e instanceof Error?e.message:t("loadFailed"))).finally(()=>setLoading(false))},query?280:0);return()=>clearTimeout(timer)},[query]);
function needsSecrets(entry:McpCatalogEntry){return entry.envKeys.some(field=>field.required)||entry.headerKeys.some(field=>field.required)}
async function connect(entry:McpCatalogEntry,values:Record<string,string>){
const env:Record<string,string>={};const headers:Record<string,string>={};
for(const field of entry.envKeys){const value=values[field.name]?.trim();if(value)env[field.name]=value;else if(field.required){setError(t("mcpKeyHint"));return}}
for(const field of entry.headerKeys){const value=values[field.name]?.trim();if(value)headers[field.name]=value;else if(field.required){setError(t("mcpKeyHint"));return}}
setBusy(true);setError("");
try{
const server=await api<McpServer>("/api/mcp-servers",{method:"POST",body:JSON.stringify({name:entry.title.slice(0,80),transport:entry.transport,command:entry.command,args:entry.args,url:entry.url,env,headers,enabled:true})});
if(server.enabled&&server.status!=="connected"){setError(server.error||t("mcpConnectFailed"));return}
await connected();
}catch(e){setError(e instanceof Error?e.message:t("operationFailed"))}
finally{setBusy(false)}
}
function choose(entry:McpCatalogEntry){
if(addedSet.has(entry.title.toLowerCase())||addedSet.has(entry.id.toLowerCase()))return;
if(needsSecrets(entry)){setPicked(entry);setSecrets({});return}
void connect(entry,{});
}
return <div className="modal-backdrop" onClick={close}><div className="dialog mcp-picker" onClick={e=>e.stopPropagation()}>
<div className="dialog-title"><h2>{t("mcpPickerTitle")}</h2><button type="button" onClick={close}><X/></button></div>
{picked?<div className="mcp-secret-step">
<button type="button" className="outline mcp-back" onClick={()=>setPicked(null)}>{t("mcpBackToList")}</button>
<strong>{t("mcpConnectNamed",{name:picked.title})}</strong>
<p className="dialog-lead">{t("mcpKeyHint")}</p>
{[...picked.envKeys,...picked.headerKeys].map(field=><label key={field.name}>{field.name}{field.required?" *":""}<input type={field.secret?"password":"text"} autoComplete="off" value={secrets[field.name]||""} onChange={e=>setSecrets(current=>({...current,[field.name]:e.target.value}))} placeholder={field.hint||field.name}/></label>)}
{error&&<div className="pane-error">{error}</div>}
<div className="dialog-actions"><button type="button" className="outline" onClick={()=>setPicked(null)}>{t("cancel")}</button><button type="button" className="primary" disabled={busy} onClick={()=>void connect(picked,secrets)}>{busy?t("connecting"):t("connectMcp")}</button></div>
</div>:custom?<McpCustomForm busy={busy} error={error} onBack={()=>setCustom(false)} onSubmit={async payload=>{setBusy(true);setError("");try{const server=await api<McpServer>("/api/mcp-servers",{method:"POST",body:JSON.stringify(payload)});if(server.enabled&&server.status!=="connected"){setError(server.error||t("mcpConnectFailed"));setBusy(false);return}await connected()}catch(e){setError(e instanceof Error?e.message:t("operationFailed"));setBusy(false)}}}/>:<>
<p className="dialog-lead">{t("mcpPickerLead")}</p>
<label className="mcp-picker-search"><UseAnimations animation={searchToX} size={16} strokeColor="var(--muted)"/><input autoFocus value={query} onChange={e=>setQuery(e.target.value)} placeholder={t("searchMcp")}/></label>
<div className="mcp-picker-grid">{loading&&items.length===0?<div className="mcp-picker-empty">{t("connecting")}</div>:items.length===0?<div className="mcp-picker-empty">{t("mcpNoResults")}</div>:items.map(entry=>{const already=addedSet.has(entry.title.toLowerCase())||addedSet.has(entry.id.toLowerCase());return <button type="button" className={`mcp-pick-card ${already?"added":""}`} key={entry.id} disabled={already||busy} onClick={()=>choose(entry)}>
<span className="mcp-pick-icon">{entry.title.slice(0,1).toUpperCase()}</span>
<strong>{entry.title}</strong>
<small>{entry.description}</small>
<span className="mcp-pick-meta">{already?t("mcpAdded"):entry.remote?t("mcpRemote"):t("mcpLocal")}{needsSecrets(entry)&&!already?` · ${t("mcpNeedsKey")}`:""}</span>
</button>})}</div>
{error&&<div className="pane-error">{error}</div>}
<div className="dialog-actions"><button type="button" className="outline" onClick={()=>setCustom(true)}>{t("mcpCustom")}</button><button type="button" className="outline" onClick={close}>{t("close")}</button></div>
</>}
</div></div> </div></div>
} }
function ComputerHeader({active,computer}:{active:Bot|null;computer:ComputerStatus}){const loading=computer.state==="booting";return <header className="panel-head"><span>{active?`${active.name} 的電腦`:"電腦"}</span>{loading?<LoaderCircle className="spinner"/>:<i className={`state-dot ${computer.state}`}/>}<small>{stateLabel(computer.state)}</small></header>} function McpCustomForm({busy,error,onBack,onSubmit}:{busy:boolean;error:string;onBack:()=>void;onSubmit:(payload:Record<string,unknown>)=>Promise<void>}){
function EmptyComputer({state}:{state:ComputerStatus["state"]}){const loading=state==="booting";return <div className="empty-computer">{loading?<LoaderCircle className="spinner large"/>:<Computer/>}<strong>{stateLabel(state)}</strong><span>{loading?"正在準備 Agent 的獨立桌面…":"開啟電腦後,畫面會顯示在這裡。"}</span></div>} const[name,setName]=useState("");const[transport,setTransport]=useState<McpTransport>("http");
function ControlButtons({computer,busy,action,active}:{computer:ComputerStatus;busy:boolean;action:(w:()=>Promise<unknown>)=>Promise<void>;active:Bot}){const working=Boolean(computer.busyBotName);if(computer.state!=="running")return <button className="primary" disabled={busy||computer.state==="booting"} onClick={()=>action(()=>api(`/api/computer/${active.id}/boot`,{method:"POST",body:"{}"}))}>{(busy||computer.state==="booting")&&<LoaderCircle className="spinner"/>}{computer.state==="booting"?"啟動中…":t("openComputer")}</button>;if(working)return <button className="outline" disabled={busy} onClick={()=>action(async()=>{await api(`/api/bots/${active.id}/stop`,{method:"POST",body:"{}"});if(computer.takeoverRequested)await api(`/api/computer/${active.id}/takeover`,{method:"POST",body:"{}"})})}><Square/>{computer.takeoverRequested?"停止並接管":t("stopTask")}</button>;if(computer.controlHolder==="user")return <button className="outline" disabled={busy} onClick={()=>action(()=>api(`/api/computer/${active.id}/release`,{method:"POST",body:"{}"}))}></button>;return <button className="primary" disabled={busy} onClick={()=>action(()=>api(`/api/computer/${active.id}/takeover`,{method:"POST",body:"{}"}))}></button>} const[command,setCommand]=useState("");const[args,setArgs]=useState("");const[url,setUrl]=useState("");
function ControlBar(props:{active:Bot;computer:ComputerStatus;busy:boolean;action:(w:()=>Promise<unknown>)=>Promise<void>;paste:()=>void;copy:()=>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}><Clipboard/></button></div>} const[envText,setEnvText]=useState("");const[headerText,setHeaderText]=useState("");
function ClipboardDialog({close,paste}:{close:()=>void;paste:(text:string)=>void}){const[text,setText]=useState("");return <div className="modal-backdrop"><div className="dialog compact"><div className="dialog-title"><h2></h2><button onClick={close}><X/></button></div><p> VNC HTTP 使</p><textarea className="clipboard-text" autoFocus value={text} onChange={e=>setText(e.target.value)} placeholder="在此貼上文字…"/><div className="dialog-actions"><button className="outline" onClick={close}></button><button className="primary" disabled={!text} onClick={()=>paste(text)}> VNC</button></div></div></div>} return <form className="mcp-add" onSubmit={e=>{e.preventDefault();const trimmed=name.trim();if(!trimmed)return;void onSubmit({name:trimmed,transport,command:command.trim()||null,args:args.trim()?args.trim().split(/\s+/):[],url:url.trim()||null,env:parsePairs(envText),headers:parsePairs(headerText),enabled:true})}}>
function CreateDialog({close,created}:{close:()=>void;created:(bot:Bot)=>void}){const[name,setName]=useState("");const[mode,setMode]=useState<ComputerMode>("team");const[busy,setBusy]=useState(false);return <div className="modal-backdrop"><form className="dialog" onSubmit={async e=>{e.preventDefault();if(!name.trim())return;setBusy(true);try{created(await api<Bot>("/api/bots",{method:"POST",body:JSON.stringify({name:name.trim(),computerMode:mode})}))}finally{setBusy(false)}}}><div className="dialog-title"><h2></h2><button type="button" onClick={close}><X/></button></div><label><input autoFocus value={name} onChange={e=>setName(e.target.value)} placeholder="例如:研究助理"/></label><div className="mode-grid"><button type="button" className={mode==="team"?"picked":""} onClick={()=>setMode("team")}><BotIcon/><strong></strong><small></small></button><button type="button" className={mode==="dedicated"?"picked":""} onClick={()=>setMode("dedicated")}><Computer/><strong></strong><small> Docker</small></button></div><div className="dialog-actions"><button type="button" className="outline" onClick={close}></button><button className="primary" disabled={busy||!name.trim()}></button></div></form></div>} <button type="button" className="outline mcp-back" onClick={onBack}>{t("mcpBackToList")}</button>
function CreateGroupDialog({bots,close,created}:{bots:Bot[];close:()=>void;created:()=>void}){const[name,setName]=useState("");const[selected,setSelected]=useState<string[]>([]);const[busy,setBusy]=useState(false);return <div className="modal-backdrop"><form className="dialog" onSubmit={async e=>{e.preventDefault();const groupName=name.trim();if(!groupName||selected.length===0)return;setBusy(true);try{await Promise.all(selected.map(id=>api(`/api/bots/${id}/inbox`,{method:"POST",body:JSON.stringify({action:"group",groupName})})));created()}finally{setBusy(false)}}}><div className="dialog-title"><h2></h2><button type="button" onClick={close}><X/></button></div><label><input autoFocus value={name} maxLength={30} onChange={e=>setName(e.target.value)} placeholder="例如:產品研究"/></label><fieldset className="group-picker"><legend></legend>{bots.filter(bot=>!bot.hidden).map(bot=><label key={bot.id}><input type="checkbox" checked={selected.includes(bot.id)} onChange={()=>setSelected(ids=>ids.includes(bot.id)?ids.filter(id=>id!==bot.id):[...ids,bot.id])}/><Avatar name={bot.name} color={bot.avatarColor} shape={bot.avatarShape}/><span>{bot.name}</span></label>)}</fieldset><div className="dialog-actions"><button type="button" className="outline" onClick={close}></button><button className="primary" disabled={busy||!name.trim()||selected.length===0}>{busy?"建立中…":"建立群組"}</button></div></form></div>} <label>{t("name")}<input autoFocus value={name} maxLength={80} onChange={e=>setName(e.target.value)} placeholder={t("mcpNamePlaceholder")}/></label>
function ConfirmDelete({bot,close,confirm}:{bot:Bot;close:()=>void;confirm:()=>void}){return <div className="modal-backdrop"><div className="dialog compact"><h2> {bot.name}</h2><p>{bot.computerMode==="dedicated"?"對話、私人電腦與其中的檔案都會永久刪除。":"對話會刪除,但共用電腦與其中的檔案會保留。"}</p><div className="dialog-actions"><button className="outline" onClick={close}></button><button className="danger" onClick={confirm}></button></div></div></div>} <div className="mcp-transport">{(["http","sse","stdio"] as McpTransport[]).map(value=><button type="button" key={value} className={transport===value?"picked":""} onClick={()=>setTransport(value)}>{value==="http"?"HTTP":value==="sse"?"SSE":"stdio"}</button>)}</div>
{transport==="stdio"?<>
<label>{t("command")}<input value={command} onChange={e=>setCommand(e.target.value)} placeholder={t("commandPlaceholder")}/></label>
<label>{t("arguments")}<input value={args} onChange={e=>setArgs(e.target.value)} placeholder={t("argumentsPlaceholder")}/></label>
<label>{t("environmentVariables")}<textarea rows={3} value={envText} onChange={e=>setEnvText(e.target.value)} placeholder={t("environmentVariablesPlaceholder")}/></label>
</>:<>
<label>{t("url")}<input value={url} onChange={e=>setUrl(e.target.value)} placeholder={t("urlPlaceholder")}/></label>
<label>{t("headers")}<textarea rows={3} value={headerText} onChange={e=>setHeaderText(e.target.value)} placeholder={t("headersPlaceholder")}/></label>
</>}
{error&&<div className="pane-error">{error}</div>}
<div className="dialog-actions"><button type="button" className="outline" onClick={onBack}>{t("cancel")}</button><button className="primary" disabled={busy||!name.trim()||(transport==="stdio"?!command.trim():!url.trim())}>{busy?t("connecting"):t("connectMcp")}</button></div>
</form>
}
function LoginScreen({authenticated}:{authenticated:()=>void}){const[token,setToken]=useState("");const[busy,setBusy]=useState(false);const[error,setError]=useState("");return <main className="login-screen"><form className="dialog compact login-dialog" onSubmit={async e=>{e.preventDefault();if(!token)return;setBusy(true);setError("");try{await api("/api/session",{method:"POST",body:JSON.stringify({token})});authenticated()}catch(err){setError(err instanceof Error?err.message:"登入失敗")}finally{setBusy(false)}}}><Avatar name="L" size={58}/><h1> LazyBoy</h1><p> token</p><label> token<input type="password" autoFocus autoComplete="current-password" value={token} onChange={e=>setToken(e.target.value)}/></label>{error&&<div className="login-error">{error}</div>}<button className="primary" disabled={busy||!token}>{busy?"驗證中…":"登入"}</button></form></main>} 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 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("");
return <div className="modal-backdrop" onClick={close}><form className="dialog teach-dialog" onClick={e=>e.stopPropagation()} onSubmit={e=>{e.preventDefault();if(goal.trim())start(goal.trim())}}>
<div className="dialog-title"><h2><i className="record-dot"/>{t("teachTask")}</h2><button type="button" onClick={close}><X/></button></div>
<p>{t("teachDialogLead",{name:bot.name})}</p>
<label>{t("teachGoalLabel")}<textarea className="teach-goal" autoFocus rows={3} value={goal} onChange={e=>setGoal(e.target.value)} placeholder={t("teachGoalPlaceholder")} onKeyDown={e=>{if(e.key==="Enter"&&!e.shiftKey&&!e.nativeEvent.isComposing){e.preventDefault();e.currentTarget.form?.requestSubmit()}}}/></label>
<ul className="teach-tips"><li>{t("teachTip1")}</li><li>{t("teachTip2")}</li><li>{t("teachTip3")}</li></ul>
<div className="dialog-actions"><button type="button" className="outline" onClick={close}>{t("cancel")}</button><button className="primary" disabled={busy||!goal.trim()}>{t("startDemo")}</button></div>
</form></div>;
}
function stepText(step:unknown):string{if(typeof step==="string")return step;if(step&&typeof step==="object"&&"do" in step)return String((step as {do:unknown}).do||"");return ""}
function skillFilename(name:string){const slug=name.trim().replace(/[<>:"/\\|?*\u0000-\u001f]+/g,"").replace(/\s+/g,"-").replace(/\.+$/,"").slice(0,40)||"skill";return `${slug}.json`}
function downloadSkill(name:string,goal:string,playbook:Playbook){const payload={kind:"lazyboy.skill",version:1,name,goal,playbook:{...playbook,name}};const blob=new Blob([JSON.stringify(payload,null,2)],{type:"application/json"});const url=URL.createObjectURL(blob);const a=document.createElement("a");a.href=url;a.download=skillFilename(name);document.body.appendChild(a);a.click();a.remove();URL.revokeObjectURL(url)}
function SkillDraftCard({skill,busy,onSave,onTest,onDiscard,onEdit,onExport}:{skill:TaughtSkill;busy:boolean;onSave:(name:string,playbook:Playbook)=>void;onTest:(name:string,playbook:Playbook)=>void;onDiscard:()=>void;onEdit:()=>void;onExport:(name:string,playbook:Playbook)=>void}){
const [name,setName]=useState(skill.name||skill.playbook.name||"");
const [stepsText,setStepsText]=useState((skill.playbook.steps||[]).map(step=>stepText(step)).join("\n"));
const [expanded,setExpanded]=useState(false);
const steps=skill.playbook.steps||[];
const build=():Playbook=>{const lines=stepsText.split("\n").map(line=>line.replace(/^\s*\d+[.、)]\s*/,"").trim()).filter(Boolean);const next=lines.map((line,index)=>{const prev=steps[index];return typeof prev==="object"&&prev&&stepText(prev)===line?prev:{do:line,expect:typeof prev==="object"&&prev&&stepText(prev)===line?prev.expect:"",note:""}});return {...skill.playbook,name:name.trim(),steps:next}};
const inputs=(skill.playbook.inputs||[]).map(input=>input.name).filter(Boolean);
return <section className="skill-draft" aria-label={t("skillDraftTitle")}>
<header><Sparkle/><strong>{t("skillDraftTitle")}</strong><small>{t("skillDraftFrom",{count:skill.eventCount})}</small></header>
<label className="skill-name">{t("skillName")}<input value={name} maxLength={40} onChange={e=>setName(e.target.value)} placeholder={t("skillNamePlaceholder")}/></label>
{skill.playbook.intent&&<p className="skill-intent">{skill.playbook.intent}</p>}
{inputs.length>0&&<p className="skill-inputs">{t("skillInputs")}{inputs.map(input=><code key={input}>{input}</code>)}</p>}
<label className="skill-steps">{t("skillSteps")}{expanded?<textarea rows={Math.min(12,Math.max(4,stepsText.split("\n").length+1))} value={stepsText} onChange={e=>setStepsText(e.target.value)}/>:<ol onClick={()=>setExpanded(true)}>{stepsText.split("\n").filter(Boolean).slice(0,6).map((line,index)=><li key={index}>{line}</li>)}{stepsText.split("\n").filter(Boolean).length>6&&<li className="more">{t("moreSteps",{count:stepsText.split("\n").filter(Boolean).length-6})}</li>}</ol>}{!expanded&&<button type="button" className="link" onClick={()=>setExpanded(true)}>{t("editSteps")}</button>}</label>
{skill.playbook.howToCheck&&<p className="skill-check">{t("skillCheck")}{skill.playbook.howToCheck}</p>}
{skill.error&&<p className="skill-error">{t("skillDistillFailed")}</p>}
<div className="skill-actions"><button type="button" className="outline danger-ghost" disabled={busy} onClick={onDiscard}>{t("discard")}</button><button type="button" className="link" disabled={busy} onClick={onEdit}><Pencil/>{t("editSkillFull")}</button><button type="button" className="link" disabled={busy||!name.trim()} onClick={()=>onExport(name.trim(),build())}><Download/>{t("exportSkill")}</button><span className="grow"/><button type="button" className="outline" disabled={busy||!name.trim()} onClick={()=>onTest(name.trim(),build())}>{t("testRun")}</button><button type="button" className="primary" disabled={busy||!name.trim()} onClick={()=>onSave(name.trim(),build())}>{t("saveSkill")}</button></div>
</section>;
}
const lines=(text:string)=>text.split("\n").map(line=>line.replace(/^\s*(?:\d+[.、)]|[-•*])\s*/,"").trim()).filter(Boolean);
function stepsToText(steps:(PlaybookStep|string)[]|undefined){return (steps||[]).map(step=>{if(typeof step==="string")return step;return step.expect?`${step.do}${step.expect}`:step.do}).join("\n")}
function textToSteps(text:string,previous:(PlaybookStep|string)[]|undefined):PlaybookStep[]{const old=(previous||[]).filter((step):step is PlaybookStep=>typeof step==="object"&&step!==null);return lines(text).map(line=>{const [doPart,...rest]=line.split(/\s*(?:→|->|=>)\s*/);const expect=rest.join(" → ").trim();const match=old.find(step=>step.do===doPart.trim());return {do:doPart.trim(),expect:expect||(match&&!rest.length?match.expect:undefined)||"",note:match?.note||""}})}
function inputsToText(inputs:PlaybookInput[]|undefined){return (inputs||[]).map(input=>`${input.name}${input.description?`${input.description}`:""}${input.example?`|例:${input.example}`:""}`).join("\n")}
function textToInputs(text:string):PlaybookInput[]{return lines(text).map(line=>{const [head,...exampleParts]=line.split(/\s*[|]\s*/);const example=exampleParts.join("").replace(/^例[:]\s*/,"").trim();const [name,...descParts]=head.split(/[:]/);return {name:name.trim(),description:descParts.join("").trim()||undefined,example:example||undefined}}).filter(input=>input.name)}
function SkillEditDialog({skill,busy,close,save,test,remove,exportFile}:{skill:TaughtSkill;busy:boolean;close:()=>void;save:(name:string,playbook:Playbook)=>void;test:(name:string,playbook:Playbook)=>void;remove:()=>void;exportFile:(name:string,playbook:Playbook)=>void}){
const p=skill.playbook;
const [name,setName]=useState(skill.name||p.name||"");
const [intent,setIntent]=useState(p.intent||"");
const [whenToUse,setWhenToUse]=useState(p.whenToUse||"");
const [inputs,setInputs]=useState(inputsToText(p.inputs));
const [preconditions,setPreconditions]=useState((p.preconditions||[]).join("\n"));
const [steps,setSteps]=useState(stepsToText(p.steps));
const [howToCheck,setHowToCheck]=useState(p.howToCheck||"");
const [whatToReturn,setWhatToReturn]=useState(p.whatToReturn||"");
const [cautions,setCautions]=useState((p.cautions||[]).join("\n"));
const build=():Playbook=>({...p,name:name.trim(),intent:intent.trim(),whenToUse:whenToUse.trim(),inputs:textToInputs(inputs),preconditions:lines(preconditions),steps:textToSteps(steps,p.steps),howToCheck:howToCheck.trim(),whatToReturn:whatToReturn.trim(),cautions:lines(cautions)});
const valid=name.trim().length>0&&lines(steps).length>0;
const rows=(text:string,min:number,max:number)=>Math.min(max,Math.max(min,text.split("\n").length+1));
return <div className="modal-backdrop" onClick={close}><form className="dialog skill-edit" onClick={e=>e.stopPropagation()} onSubmit={e=>{e.preventDefault();if(valid)save(name.trim(),build())}}>
<div className="dialog-title"><h2><Sparkle/>{t("editSkill")}</h2><button type="button" onClick={close}><X/></button></div>
<p className="dialog-lead">{t("skillEditLead")}</p>
<label>{t("skillName")}<input autoFocus value={name} maxLength={40} onChange={e=>setName(e.target.value)} placeholder={t("skillNamePlaceholder")}/></label>
<label>{t("skillIntent")}<textarea rows={rows(intent,2,5)} value={intent} onChange={e=>setIntent(e.target.value)}/></label>
<label>{t("skillWhenToUse")}<input value={whenToUse} onChange={e=>setWhenToUse(e.target.value)}/></label>
<label>{t("skillInputsLabel")}<textarea rows={rows(inputs,2,5)} value={inputs} onChange={e=>setInputs(e.target.value)} placeholder={t("skillInputsPlaceholder")}/></label>
<label>{t("skillPreconditions")}<textarea rows={rows(preconditions,2,5)} value={preconditions} onChange={e=>setPreconditions(e.target.value)}/></label>
<label>{t("skillStepsLabel")}<textarea rows={rows(steps,5,14)} value={steps} onChange={e=>setSteps(e.target.value)} placeholder={t("skillStepsPlaceholder")}/></label>
<label>{t("skillHowToCheck")}<textarea rows={rows(howToCheck,2,4)} value={howToCheck} onChange={e=>setHowToCheck(e.target.value)}/></label>
<label>{t("skillWhatToReturn")}<input value={whatToReturn} onChange={e=>setWhatToReturn(e.target.value)}/></label>
<label>{t("skillCautions")}<textarea rows={rows(cautions,2,5)} value={cautions} onChange={e=>setCautions(e.target.value)}/></label>
<div className="dialog-actions"><button type="button" className="outline danger-ghost" disabled={busy} onClick={remove}>{t("deleteSkill")}</button><span className="grow"/><button type="button" className="outline" disabled={busy} onClick={close}>{t("cancel")}</button><button type="button" className="outline" disabled={!valid} onClick={()=>exportFile(name.trim(),build())}><Download/>{t("exportSkill")}</button><button type="button" className="outline" disabled={busy||!valid} onClick={()=>test(name.trim(),build())}>{t("testRun")}</button><button className="primary" disabled={busy||!valid}>{t("saveSkill")}</button></div>
</form></div>;
}
function ClipboardDialog({close,paste}:{close:()=>void;paste:(text:string)=>void}){const[text,setText]=useState("");return <div className="modal-backdrop"><div className="dialog compact"><div className="dialog-title"><h2>{t("pasteToRemoteComputer")}</h2><button onClick={close}><X/></button></div><p>{t("pasteRemoteHelp")}</p><textarea className="clipboard-text" autoFocus value={text} onChange={e=>setText(e.target.value)} placeholder={t("pasteTextPlaceholder")}/><div className="dialog-actions"><button className="outline" onClick={close}>{t("cancel")}</button><button className="primary" disabled={!text} onClick={()=>paste(text)}>{t("pasteIntoVnc")}</button></div></div></div>}
function CreateDialog({close,created}:{close:()=>void;created:(bot:Bot)=>void}){const[name,setName]=useState("");const[mode,setMode]=useState<ComputerMode>("team");const[busy,setBusy]=useState(false);return <div className="modal-backdrop"><form className="dialog" onSubmit={async e=>{e.preventDefault();if(!name.trim())return;setBusy(true);try{created(await api<Bot>("/api/bots",{method:"POST",body:JSON.stringify({name:name.trim(),computerMode:mode})}))}finally{setBusy(false)}}}><div className="dialog-title"><h2>{t("addBot")}</h2><button type="button" onClick={close}><X/></button></div><label>{t("name")}<input autoFocus value={name} onChange={e=>setName(e.target.value)} placeholder={t("botNamePlaceholder")}/></label><div className="mode-grid"><button type="button" className={mode==="team"?"picked":""} onClick={()=>setMode("team")}><BotIcon/><strong>{t("sharedComputer")}</strong><small>{t("sharedComputerHint")}</small></button><button type="button" className={mode==="dedicated"?"picked":""} onClick={()=>setMode("dedicated")}><Computer/><strong>{t("privateComputer")}</strong><small>{t("privateComputerHint")}</small></button></div><div className="dialog-actions"><button type="button" className="outline" onClick={close}>{t("cancel")}</button><button className="primary" disabled={busy||!name.trim()}>{t("create")}</button></div></form></div>}
function CreateGroupDialog({bots,close,created}:{bots:Bot[];close:()=>void;created:(room:Room)=>void}){const[name,setName]=useState("");const[selected,setSelected]=useState<string[]>([]);const[busy,setBusy]=useState(false);const visible=bots.filter(bot=>!bot.hidden);return <div className="modal-backdrop"><form className="dialog" onSubmit={async e=>{e.preventDefault();const groupName=name.trim();if(!groupName||selected.length<2)return;setBusy(true);try{created(await api<Room>("/api/rooms",{method:"POST",body:JSON.stringify({name:groupName,memberIds:selected})}))}finally{setBusy(false)}}}><div className="dialog-title"><h2>{t("addGroup")}</h2><button type="button" onClick={close}><X/></button></div><p className="dialog-lead">{t("groupDescription")}</p><label>{t("groupName")}<input autoFocus value={name} maxLength={30} onChange={e=>setName(e.target.value)} placeholder={t("groupNamePlaceholder")}/></label><fieldset className="group-picker"><legend>{t("chooseBots")}</legend>{visible.map(bot=><label key={bot.id}><input type="checkbox" checked={selected.includes(bot.id)} onChange={()=>setSelected(ids=>ids.includes(bot.id)?ids.filter(id=>id!==bot.id):[...ids,bot.id])}/><Avatar lookId={bot.id} name={bot.name} color={bot.avatarColor} shape={bot.avatarShape} online/><span>{bot.name}</span></label>)}</fieldset><div className="dialog-actions"><button type="button" className="outline" onClick={close}>{t("cancel")}</button><button className="primary" disabled={busy||!name.trim()||selected.length<2}>{busy?t("creating"):t("createGroup")}</button></div></form></div>}
function RoomContextMenu({context,close,onDelete}:{context:{room:Room;x:number;y:number};close:()=>void;onDelete:()=>void}){return <div className="context-menu" style={{left:Math.min(context.x,window.innerWidth-220),top:Math.min(context.y,window.innerHeight-160)}} onClick={e=>e.stopPropagation()}><button className="danger-item" onClick={onDelete}><UseAnimations animation={trash2} size={15} strokeColor="#ff7777"/>{t("deleteGroup")}</button><button className="context-close" onClick={close}><X/></button></div>}
function ConfirmRoomDelete({room,close,confirm}:{room:Room;close:()=>void;confirm:()=>void}){return <div className="modal-backdrop"><div className="dialog compact"><h2>{t("deleteNamed",{name:room.name})}</h2><p>{t("deleteGroupDescription")}</p><div className="dialog-actions"><button className="outline" onClick={close}>{t("cancel")}</button><button className="danger" onClick={confirm}>{t("deleteGroup")}</button></div></div></div>}
function ConfirmDelete({bot,close,confirm}:{bot:Bot;close:()=>void;confirm:()=>void}){return <div className="modal-backdrop"><div className="dialog compact"><h2>{t("deleteNamed",{name:bot.name})}</h2><p>{bot.computerMode==="dedicated"?t("deleteDedicatedBotDescription"):t("deleteSharedBotDescription")}</p><div className="dialog-actions"><button className="outline" onClick={close}>{t("cancel")}</button><button className="danger" onClick={confirm}>{t("delete")}</button></div></div></div>}
function PhoneAccessDialog({close}:{close:()=>void}){
const url=location.origin;
const local=/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(url);
const[copied,setCopied]=useState(false);
return <div className="modal-backdrop"><div className="dialog compact"><div className="dialog-title"><h2>{t("openOnPhone")}</h2><button type="button" onClick={close}><X/></button></div>
<p>{t("phoneAccessDescription")}</p>
<code className="share-url">{url}</code>
{local&&<p className="dialog-lead">{t("localAddressWarning")}</p>}
<div className="dialog-actions"><button className="outline" onClick={close}>{t("close")}</button><button className="primary" onClick={async()=>{try{await navigator.clipboard.writeText(url);setCopied(true)}catch{setCopied(false)}}}>{copied?t("copied"):t("copyUrl")}</button></div>
</div></div>
}
function WorkspaceSettingsDialog({name,setName,showHidden,setShowHidden,rightCollapsed,setRightCollapsed,close}:{name:string;setName:(value:string)=>void;showHidden:boolean;setShowHidden:(value:boolean)=>void;rightCollapsed:boolean;setRightCollapsed:(value:boolean)=>void;close:()=>void}){
const[draft,setDraft]=useState(name);
return <div className="modal-backdrop"><form className="dialog compact" onSubmit={e=>{e.preventDefault();setName(draft.trim()||t("localWorkspace"));close()}}>
<div className="dialog-title"><h2>{t("settings")}</h2><button type="button" onClick={close}><X/></button></div>
<label>{t("workspaceName")}<input autoFocus value={draft} maxLength={40} onChange={e=>setDraft(e.target.value)}/></label>
<label className="memory-toggle"><input type="checkbox" checked={showHidden} onChange={e=>setShowHidden(e.target.checked)}/> {t("showHiddenBots")}</label>
<label className="memory-toggle"><input type="checkbox" checked={rightCollapsed} onChange={e=>setRightCollapsed(e.target.checked)}/> {t("collapseRightSidebar")}</label>
<p className="dialog-lead">{t("workspaceSettingsHint")}</p>
<div className="dialog-actions"><button type="button" className="outline" onClick={close}>{t("cancel")}</button><button className="primary">{t("save")}</button></div>
</form></div>
}
function ModelSettingsDialog({close}:{close:()=>void}){
const[settings,setSettings]=useState<WorkspaceSettings|null>(null);
const[provider,setProvider]=useState<ModelProviderId>("xai");
const[modelId,setModelId]=useState("");
const[baseUrl,setBaseUrl]=useState("");
const[apiKey,setApiKey]=useState("");
const[clearKey,setClearKey]=useState(false);
const[models,setModels]=useState<{id:string;name:string}[]>([]);
const[busy,setBusy]=useState(false);
const[error,setError]=useState("");
const current=settings?.providers.find(item=>item.id===provider);
useEffect(()=>{api<WorkspaceSettings>("/api/workspace/settings").then(value=>{setSettings(value);setProvider(value.provider);setModelId(value.modelId);setBaseUrl(value.baseUrl);setModels(value.models)}).catch(e=>setError(e instanceof Error?e.message:t("loadFailed")))},[]);
async function loadModels(nextProvider:ModelProviderId,nextBaseUrl:string){
if(nextProvider==="openai-compatible"&&!nextBaseUrl.trim()){setModels([]);return}
try{
const query=new URLSearchParams({provider:nextProvider});
if(nextBaseUrl.trim())query.set("baseUrl",nextBaseUrl.trim());
const result=await api<{models:{id:string;name:string}[]}>(`/api/workspace/models?${query}`);
setModels(result.models);
setModelId(current=>result.models.some(item=>item.id===current)?current:result.models[0]?.id||current);
}catch{if(nextProvider==="openai-compatible")setModels([])}
}
function pickProvider(id:ModelProviderId){
setProvider(id);
const info=settings?.providers.find(item=>item.id===id);
if(id==="openai-compatible"){
setModels([]);
setBaseUrl(current=>current.includes("opencode.ai")||current.includes("api.x.ai")?"":current);
if(info?.defaultModel)setModelId(info.defaultModel);else setModelId("");
return;
}
if(info?.defaultBaseUrl)setBaseUrl(info.defaultBaseUrl);
if(info?.defaultModel)setModelId(info.defaultModel);
void loadModels(id,info?.defaultBaseUrl||"");
}
return <div className="modal-backdrop"><form className="dialog settings-dialog" onSubmit={async e=>{e.preventDefault();setBusy(true);setError("");try{await api("/api/workspace/settings",{method:"PATCH",body:JSON.stringify({provider,modelId:modelId.trim(),baseUrl:baseUrl.trim()||null,apiKey:clearKey?"":apiKey.trim()||null,clearApiKey:clearKey})});close()}catch(e){setError(e instanceof Error?e.message:t("settingsFailed"))}finally{setBusy(false)}}}>
<div className="dialog-title"><h2>{t("modelSettings")}</h2><button type="button" onClick={close}><X/></button></div>
<p className="dialog-lead">{t("modelSettingsHint")}</p>
<fieldset className="provider-fieldset"><legend>{t("modelProvider")}</legend>
<div className="provider-grid">{(settings?.providers||[{id:"xai" as const,name:t("providerXai")},{id:"opencode-go" as const,name:t("providerOpencodeGo")},{id:"openai-compatible" as const,name:t("providerOpenaiCompatible")}]).map(item=><button type="button" key={item.id} className={provider===item.id?"picked":""} onClick={()=>pickProvider(item.id)}>{item.id==="xai"?t("providerXai"):item.id==="opencode-go"?t("providerOpencodeGo"):t("providerOpenaiCompatible")}</button>)}</div>
<p className="dialog-lead">{provider==="xai"?t("providerXaiHint"):provider==="opencode-go"?t("providerOpencodeGoHint"):t("providerOpenaiCompatibleHint")}</p>
</fieldset>
<label>{t("apiKey")}<input type="password" autoComplete="off" value={apiKey} onChange={e=>{setApiKey(e.target.value);setClearKey(false)}} placeholder={settings?.apiKeySet?t("apiKeyStored"):t("apiKeyPlaceholder")}/></label>
{settings?.envKeySet&&settings.provider===provider&&!apiKey&&!clearKey&&<p className="dialog-lead">{t("usingEnvKey",{name:settings.envKeyName})}</p>}
{settings?.apiKeySet&&<label className="memory-toggle"><input type="checkbox" checked={clearKey} onChange={e=>setClearKey(e.target.checked)}/> {t("clearApiKey")}</label>}
{(current?.needsBaseUrl||provider==="openai-compatible")&&<label>{t("baseUrl")}<input value={baseUrl} onChange={e=>setBaseUrl(e.target.value)} placeholder={t("baseUrlPlaceholder")}/><small>{t("baseUrlHint")}</small></label>}
<label>{t("modelId")}
{models.length>0?<select value={models.some(item=>item.id===modelId)?modelId:""} onChange={e=>setModelId(e.target.value)}>
{models.map(item=><option key={item.id} value={item.id}>{item.name}</option>)}
{!models.some(item=>item.id===modelId)&&modelId?<option value={modelId}>{modelId}</option>:null}
</select>:<input value={modelId} onChange={e=>setModelId(e.target.value)} placeholder={t("modelIdPlaceholder")}/>}
{models.length>0&&<input value={modelId} onChange={e=>setModelId(e.target.value)} placeholder={t("modelIdPlaceholder")}/>}
</label>
<button type="button" className="outline" onClick={()=>void loadModels(provider,baseUrl)}>{t("reloadModels")}</button>
{error&&<div className="pane-error">{error}</div>}
<div className="dialog-actions"><button type="button" className="outline" onClick={close}>{t("cancel")}</button><button className="primary" disabled={busy||!modelId.trim()||(provider==="openai-compatible"&&!baseUrl.trim())}>{busy?t("saving"):t("save")}</button></div>
</form></div>
}
function AboutDialog({close}:{close:()=>void}){
const[health,setHealth]=useState<"ok"|"bad"|"…">("…");
useEffect(()=>{fetch("/api/health").then(r=>r.ok?setHealth("ok"):setHealth("bad")).catch(()=>setHealth("bad"))},[]);
return <div className="modal-backdrop"><div className="dialog compact"><div className="dialog-title"><h2>{t("about")}</h2><button type="button" onClick={close}><X/></button></div>
<div className="about-brand"><Avatar name="L" size={52} online/><strong>LazyBoy</strong><small>0.1.0</small></div>
<p>{t("aboutDescription")}</p>
<p className="dialog-lead">{t("apiStatus",{status:health==="ok"?t("statusNormal"):health==="bad"?t("statusUnavailable"):t("statusChecking")})}</p>
<div className="dialog-actions"><button className="primary" onClick={close}>{t("close")}</button></div>
</div></div>
}
function HelpDialog({close}:{close:()=>void}){
return <div className="modal-backdrop"><div className="dialog help-dialog"><div className="dialog-title"><h2>{t("helpCenter")}</h2><button type="button" onClick={close}><X/></button></div>
<div className="help-body">
<section><h3>{t("helpBotsTitle")}</h3><p>{t("helpBots")}</p></section>
<section><h3>{t("helpGroupsTitle")}</h3><p>{t("helpGroups")}</p></section>
<section><h3>{t("helpComputerTitle")}</h3><p>{t("helpComputer")}</p></section>
<section><h3>{t("helpMemoryTitle")}</h3><p>{t("helpMemory")}</p></section>
<section><h3>{t("helpMcpTitle")}</h3><p>{t("helpMcp")}</p></section>
<section><h3>{t("helpSkillsTitle")}</h3><p>{t("helpSkills")}</p></section>
<section><h3>{t("helpAttachTitle")}</h3><p>{t("helpAttach")}</p></section>
<section><h3>{t("helpShortcutsTitle")}</h3><p>{t("helpShortcuts")}</p></section>
</div>
<div className="dialog-actions"><button className="primary" onClick={close}>{t("close")}</button></div>
</div></div>
}
function FeedbackDialog({close}:{close:()=>void}){
const[text,setText]=useState("");const[copied,setCopied]=useState(false);
return <div className="modal-backdrop"><div className="dialog compact"><div className="dialog-title"><h2>{t("sendFeedback")}</h2><button type="button" onClick={close}><X/></button></div>
<p>{t("feedbackDescription")}</p>
<textarea className="clipboard-text" autoFocus rows={6} value={text} onChange={e=>setText(e.target.value)} placeholder={t("feedbackPlaceholder")}/>
<div className="dialog-actions"><button className="outline" onClick={close}>{t("cancel")}</button><button className="primary" disabled={!text.trim()} onClick={async()=>{try{await navigator.clipboard.writeText(text.trim());setCopied(true)}catch{setCopied(false)}}}>{copied?t("copied"):t("copyContent")}</button></div>
</div></div>
}
function LoginScreen({authenticated}:{authenticated:()=>void}){const[token,setToken]=useState("");const[busy,setBusy]=useState(false);const[error,setError]=useState("");return <main className="login-screen"><form className="dialog compact login-dialog" onSubmit={async e=>{e.preventDefault();if(!token)return;setBusy(true);setError("");try{await api("/api/session",{method:"POST",body:JSON.stringify({token})});authenticated()}catch(err){setError(err instanceof Error?err.message:t("loginFailed"))}finally{setBusy(false)}}}><Avatar name="L" size={58}/><h1>{t("loginTitle")}</h1><p>{t("loginDescription")}</p><label>{t("accessToken")}<input type="password" autoFocus autoComplete="current-password" value={token} onChange={e=>setToken(e.target.value)}/></label>{error&&<div className="login-error">{error}</div>}<button className="primary" disabled={busy||!token}>{busy?t("verifying"):t("login")}</button></form></main>}

View File

@ -0,0 +1,69 @@
import UseAnimations from "react-useanimations";
import type { Animation } from "react-useanimations/utils";
import activity from "react-useanimations/lib/activity";
import archive from "react-useanimations/lib/archive";
import star from "react-useanimations/lib/star";
import edit from "react-useanimations/lib/edit";
import airplay from "react-useanimations/lib/airplay";
import arrowDown from "react-useanimations/lib/arrowDown";
import arrowRightCircle from "react-useanimations/lib/arrowRightCircle";
import download from "react-useanimations/lib/download";
import help from "react-useanimations/lib/help";
import info from "react-useanimations/lib/info";
import menu3 from "react-useanimations/lib/menu3";
import notification from "react-useanimations/lib/notification";
import playPause from "react-useanimations/lib/playPause";
import plusToX from "react-useanimations/lib/plusToX";
import pocket from "react-useanimations/lib/pocket";
import settings2 from "react-useanimations/lib/settings2";
import skipBack from "react-useanimations/lib/skipBack";
import toggle from "react-useanimations/lib/toggle";
import userPlus from "react-useanimations/lib/userPlus";
import video from "react-useanimations/lib/video";
type IconProps={className?:string;size?:number};
function animatedIcon(animation:Animation,defaultSize=16,reverse=false){
return function AnimatedIcon({className,size=defaultSize}:IconProps){
return <UseAnimations
animation={animation}
reverse={reverse}
size={size}
strokeColor="currentColor"
className={["animated-icon",className].filter(Boolean).join(" ")}
aria-hidden="true"
wrapperStyle={{display:"inline-flex",alignItems:"center",justifyContent:"center",flex:"0 0 auto"}}
/>;
};
}
export const BotIcon=animatedIcon(userPlus,18);
export const Brain=animatedIcon(activity,17);
export const ChevronDown=animatedIcon(arrowDown,14);
export const ChevronsRight=animatedIcon(arrowRightCircle,17);
export const CircleHelp=animatedIcon(help,16);
export const ClipboardPaste=animatedIcon(download,17);
export const Computer=animatedIcon(airplay,18);
export const Ellipsis=animatedIcon(menu3,18);
export const Info=animatedIcon(info,16);
export const LogOut=animatedIcon(arrowRightCircle,16);
export const Megaphone=animatedIcon(notification,16);
export const Paperclip=animatedIcon(archive,16);
export const Pin=animatedIcon(pocket,14);
export const Sparkle=animatedIcon(star,16);
export const Pencil=animatedIcon(edit,14);
export const Plug=animatedIcon(toggle,16);
export const Plus=animatedIcon(plusToX,17);
export const RefreshCw=animatedIcon(skipBack,14);
export const Settings=animatedIcon(settings2,16);
export const Smartphone=animatedIcon(video,16);
export const Square=animatedIcon(playPause,14);
export const Users=animatedIcon(userPlus,17);
export const X=animatedIcon(plusToX,16,true);
export function Download({className,size=16}:IconProps){
return <svg className={["animated-icon",className].filter(Boolean).join(" ")} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>;
}
export function Upload({className,size=16}:IconProps){
return <svg className={["animated-icon",className].filter(Boolean).join(" ")} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>;
}

319
apps/web/src/avatar.tsx Normal file
View File

@ -0,0 +1,319 @@
import { Blobatar } from "@blobatar/react";
import { useGaze } from "@blobatar/react/gaze";
import {
happy,
idle,
love,
mad,
sad,
scared,
shy,
sick,
sleepy,
smug,
surprised,
thinking as thinkingPose,
unsure,
wink,
type Expression,
} from "blobatar/expression";
import { createContext, useContext, type CSSProperties, type ReactNode } from "react";
import type { AvatarShape, RoomMember } from "./types";
export const BLOBATAR_SHAPES = [
"round",
"organic",
"boxy",
"capsule",
"nub",
"cloud",
"droplet",
"hexagon",
"sun",
"triangle",
] as const;
export type BlobatarShape = (typeof BLOBATAR_SHAPES)[number];
export const BLOBATAR_EXPRESSIONS = [
"idle",
"happy",
"sad",
"mad",
"surprised",
"wink",
"sleepy",
"smug",
"unsure",
"scared",
"love",
"shy",
"sick",
"thinking",
] as const;
export type AvatarExpression = (typeof BLOBATAR_EXPRESSIONS)[number];
export const BLOBATAR_BACKGROUNDS = ["none", "circle", "squircle", "square"] as const;
export type AvatarBackground = (typeof BLOBATAR_BACKGROUNDS)[number];
export interface AvatarLook {
expression: AvatarExpression;
background: AvatarBackground;
}
export const DEFAULT_LOOK: AvatarLook = { expression: "idle", background: "none" };
const EXPRESSION_POSE: Record<AvatarExpression, Expression> = {
idle,
happy,
sad,
mad,
surprised,
wink,
sleepy,
smug,
unsure,
scared,
love,
shy,
sick,
thinking: thinkingPose,
};
/** Midpoints of blobatar gen2 shape bands in styles/blob.ts. */
const SHAPE_TRAIT: Record<BlobatarShape, number> = {
round: 0.11,
organic: 0.35,
boxy: 0.54,
capsule: 0.65,
nub: 0.745,
cloud: 0.825,
droplet: 0.887,
hexagon: 0.932,
sun: 0.965,
triangle: 0.99,
};
const SHAPE_ALIAS: Record<string, BlobatarShape> = {
blob: "organic",
squircle: "boxy",
diamond: "boxy",
drop: "droplet",
cat: "nub",
bunny: "nub",
star: "sun",
heart: "organic",
egg: "round",
ghost: "cloud",
sprout: "nub",
cactus: "nub",
mushroom: "nub",
paw: "nub",
};
const FALLBACK_COLORS = ["#3ec5a8", "#f5a03c", "#6a6bf5", "#9b5cf6", "#3b82f6", "#d9508a"];
const LOOK_STORE = "lazyboy.avatarLook";
const AvatarLookContext = createContext<Record<string, AvatarLook>>({});
export function AvatarLookProvider({
value,
children,
}: {
value: Record<string, AvatarLook>;
children: ReactNode;
}) {
return <AvatarLookContext.Provider value={value}>{children}</AvatarLookContext.Provider>;
}
export function readAvatarLooks(): Record<string, AvatarLook> {
try {
const raw = localStorage.getItem(LOOK_STORE);
if (!raw) return {};
const parsed = JSON.parse(raw) as Record<string, Partial<AvatarLook>>;
const out: Record<string, AvatarLook> = {};
for (const [id, look] of Object.entries(parsed || {})) {
out[id] = {
expression: isExpression(look.expression) ? look.expression : "idle",
background: isBackground(look.background) ? look.background : "none",
};
}
return out;
} catch {
return {};
}
}
export function writeAvatarLook(id: string, look: AvatarLook) {
const next = { ...readAvatarLooks(), [id]: look };
localStorage.setItem(LOOK_STORE, JSON.stringify(next));
}
export function resolveBlobatarShape(shape: string | undefined | null): BlobatarShape {
if (shape && (BLOBATAR_SHAPES as readonly string[]).includes(shape)) return shape as BlobatarShape;
if (shape && SHAPE_ALIAS[shape]) return SHAPE_ALIAS[shape];
if (shape?.startsWith("organic-")) return "organic";
return "organic";
}
/** Persist using names the existing API already accepts. */
const STORE_SHAPE: Record<BlobatarShape, AvatarShape> = {
round: "round",
organic: "blob",
boxy: "squircle",
capsule: "capsule",
nub: "paw",
cloud: "cloud",
droplet: "drop",
hexagon: "hexagon",
sun: "star",
triangle: "triangle",
};
export function persistBlobatarShape(shape: string | undefined | null): AvatarShape {
return STORE_SHAPE[resolveBlobatarShape(shape)];
}
function isExpression(value: unknown): value is AvatarExpression {
return typeof value === "string" && (BLOBATAR_EXPRESSIONS as readonly string[]).includes(value);
}
function isBackground(value: unknown): value is AvatarBackground {
return typeof value === "string" && (BLOBATAR_BACKGROUNDS as readonly string[]).includes(value);
}
function fallbackColor(name: string): string {
const hash = [...name].reduce((n, c) => (n * 31 + c.charCodeAt(0)) >>> 0, 7);
return FALLBACK_COLORS[hash % FALLBACK_COLORS.length];
}
/** Dark eyes on light bodies, light eyes on dark ones — same polarity blobatar uses. */
export function contrastEye(hex: string): string {
const value = /^#([0-9a-f]{6})$/i.test(hex) ? hex : "#8B5CF6";
const n = parseInt(value.slice(1), 16);
const channel = (shift: number) => {
const s = ((n >> shift) & 255) / 255;
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
};
const luminance = 0.2126 * channel(16) + 0.7152 * channel(8) + 0.0722 * channel(0);
return luminance >= 0.5 ? "#1b1b22" : "#f3f3f6";
}
function blobBackground(value: AvatarBackground | undefined): false | "circle" | "squircle" | "square" {
return !value || value === "none" ? false : value;
}
export function Avatar({
name,
color,
shape = "blob",
active = false,
thinking = false,
online = false,
size = 32,
lookId,
expression,
background,
gaze = true,
}: {
name: string;
color?: string;
shape?: AvatarShape;
active?: boolean;
thinking?: boolean;
online?: boolean;
size?: number;
lookId?: string;
expression?: AvatarExpression;
background?: AvatarBackground;
gaze?: boolean;
}) {
const stored = useContext(AvatarLookContext)[lookId || ""] || DEFAULT_LOOK;
const resolved = color || fallbackColor(name);
const silhouette = resolveBlobatarShape(shape);
const poseName = thinking ? "thinking" : expression || stored.expression;
const pose = poseName === "idle" ? undefined : EXPRESSION_POSE[poseName];
const plate = blobBackground(background || stored.background);
const travel = Math.max(1.8, Math.min(4, size * 0.045));
const { ref } = useGaze({ travel, lookAt: gaze ? "pointer" : null });
return (
<span
className={`avatar blobatar ${active ? "online" : ""} ${thinking ? "thinking" : ""}`}
style={{ "--bot-color": resolved, "--avatar-size": `${size}px` } as CSSProperties}
>
<Blobatar
ref={ref}
name={name}
animate="always"
expression={pose}
background={plate}
traits={{ shape: SHAPE_TRAIT[silhouette] }}
palette={{ head: resolved, eye: contrastEye(resolved) }}
title={name}
/>
{(online || active) && <i className="presence" aria-hidden="true" />}
</span>
);
}
export function AvatarStack({
members,
size = 38,
online = false,
thinkingIds,
}: {
members: RoomMember[];
size?: number;
online?: boolean;
thinkingIds?: string[];
}) {
if (members.length === 1) {
const member = members[0];
return (
<Avatar
lookId={member.id}
name={member.name}
color={member.avatarColor}
shape={member.avatarShape}
size={size}
thinking={Boolean(thinkingIds?.includes(member.id))}
online={online}
/>
);
}
const pair = members.length === 2;
const miniSize = Math.round(size * (pair ? 0.65 : 0.54));
const shown = members.slice(0, members.length > 3 ? 2 : 3);
const positions = pair
? [{ left: 0, top: 0 }, { left: size - miniSize, top: size - miniSize }]
: [
{ left: (size - miniSize) / 2, top: 0 },
{ left: 0, top: size - miniSize },
{ left: size - miniSize, top: size - miniSize },
];
return (
<span className="avatar-stack" style={{ width: size, height: size }}>
{shown.map((member, index) => (
<span className="stack-item" key={member.id} style={{ ...positions[index], zIndex: index + 1 }}>
<Avatar
lookId={member.id}
name={member.name}
color={member.avatarColor}
shape={member.avatarShape}
size={miniSize}
thinking={Boolean(thinkingIds?.includes(member.id))}
online={Boolean(online && index === shown.length - 1)}
/>
</span>
))}
{members.length > 3 && (
<span
className="stack-extra"
style={{ left: size - miniSize, top: size - miniSize, zIndex: 3, width: miniSize, height: miniSize }}
>
+{members.length - 2}
</span>
)}
</span>
);
}

View File

@ -1,3 +0,0 @@
declare module "blobshape" {
export default function blobshape(options?: { size?:number; growth?:number; edges?:number; seed?:number }): { path:string; seedValue:number };
}

View File

@ -1,25 +1,18 @@
import { zhTW } from "./locales/zh-TW";
export const supportedLocales = ["zh-TW"] as const; export const supportedLocales = ["zh-TW"] as const;
export type Locale = (typeof supportedLocales)[number]; export type Locale = (typeof supportedLocales)[number];
const messages = { const messages = { "zh-TW": zhTW } as const;
"zh-TW": {
search: "搜尋",
sharedComputer: "共用電腦",
privateComputer: "私人電腦",
stopped: "已關閉",
booting: "啟動中",
running: "執行中",
suspended: "休眠中",
error: "發生錯誤",
openComputer: "開啟電腦",
stopTask: "停止任務",
takeControl: "取得控制權",
releaseControl: "交還控制",
done: "完成",
skip: "略過",
},
} as const;
export const locale: Locale = "zh-TW"; export const locale: Locale = "zh-TW";
export type MessageKey = keyof (typeof messages)["zh-TW"]; export type MessageKey = keyof typeof zhTW;
export function t(key: MessageKey): string { return messages[locale][key]; } export type MessageParams = Record<string, string | number>;
export function t(key: MessageKey, params?: MessageParams): string {
const message: string = messages[locale][key];
if (!params) return message;
return message.replace(/\{(\w+)\}/g, (match, name: string) =>
Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : match,
);
}

View File

@ -0,0 +1,99 @@
/** Traditional Chinese UI copy. Keep keys stable when adding another locale. */
export const zhTW = {
search: "搜尋", sharedComputer: "共用電腦", privateComputer: "私人電腦",
stopped: "已關閉", booting: "啟動中", running: "執行中", suspended: "休眠中", error: "發生錯誤",
openComputer: "開啟電腦", stopTask: "停止任務", takeControl: "取得控制", takeOverNow: "接手操作", releaseControl: "釋放控制", done: "完成", skip: "略過",
localWorkspace: "本機工作區", pinned: "已釘選", agentGroup: "Agent",
operationFailed: "操作失敗", loadFailed: "載入失敗", settingsFailed: "設定失敗", loginFailed: "登入失敗",
rememberFailed: "無法記住這則訊息", clipboardWriteBlocked: "瀏覽器封鎖剪貼簿寫入。",
newConversation: "新對話", workTools: "工作工具", computer: "電腦", memory: "記憶", plugins: "外掛程式", pluginShort: "外掛", botSettings: "機器人設定",
add: "新增", addBot: "新增機器人", addGroup: "新增群組", groups: "群組",
unreadMessages: "{count} 則未讀訊息", members: "{count} 位成員", hideHiddenItems: "隱藏已隱藏項目", showHiddenItems: "顯示已隱藏項目",
mcpConnected: "{count} 個 MCP 已連線", connectMcpServer: "接入 MCP server",
openOnPhone: "在手機開啟", settings: "設定", about: "關於", helpCenter: "說明中心", sendFeedback: "傳送意見回饋", logout: "登出", workspaceMenu: "工作區選單",
chooseBot: "選擇一個機器人", startRoomDiscussion: "和 {name} 開始討論", startBotWork: "和 {name} 開始工作", roomWillReply: "{names} 會一起回覆。",
botWelcome: "傳送訊息,讓它在自己的電腦上完成任務。", remembered: "已記住", remember: "記住", working: "{name} 正在工作…",
anotherConversationQueued: "另一則對話正在執行,這則會排隊。",
workingStep: "{name} 正在工作… {step}", queuedMessages: "還有 {count} 則訊息排隊中",
pausedUserControl: "你正在操控畫面,{name} 已暫停。完成後按「釋放控制」,它會從目前畫面接著做(排隊中的訊息也會一起處理)。",
pausedNeedsUser: "{name} 需要你接手畫面(例如登入或驗證)。按「接手操作」處理,完成後再「釋放控制」。",
releaseAndContinue: "釋放控制並繼續", attachFile: "附加檔案", attachFileHint: "圖片會直接給模型看;其他檔案放到電腦 inbox兩小時後刪除",
attachTooMany: "一次最多 4 個附件", attachTooLarge: "單檔不能超過 10 MB", attachType: "這個檔案類型不能附加",
attachRemove: "移除 {name}", attachedFile: "附件 {name}", attachDrop: "放到這裡附加",
teachTask: "教它一項任務", teachTaskHint: "你示範一次,它學成技能", teachNeedsBot: "先選擇一個機器人", taughtSkills: "已學會的技能",
searchSkills: "搜尋技能", noMatchingSkills: "沒有符合的技能",
teachDialogLead: "接下來畫面交給你操作,{name} 會在旁邊看:記錄你點了哪些控制項、輸入了什麼、去了哪些頁面,之後整理成一個「知道目的與流程」的技能,而不是死記座標。",
teachGoalLabel: "你要示範什麼?(一句話說目標)", teachGoalPlaceholder: "例如:到 STAR 訓練系統,把指定課程的影片看完並完成測驗",
teachTip1: "用平常的方式操作即可,不用刻意放慢;多餘的點擊會被忽略。", teachTip2: "會變動的值(搜尋字、名稱、日期)之後可以當作參數,做的時候先用實際例子。", teachTip3: "密碼欄位不會被記錄;遇到登入請先登好再開始示範。",
startDemo: "開始示範", finishDemo: "完成示範", teachingBadge: "示範中", teachingComposerHint: "示範中:做完按「完成示範」再送訊息",
teachingLive: "正在學:{goal}", teachingHint: "畫面已交給你,直接在右側(或放大)操作;做完按「完成示範」。", teachingCaptured: "已記錄 {count} 個動作",
distilling: "正在把「{goal}」的示範整理成技能…",
skillDraftTitle: "新技能草稿", skillDraftFrom: "從 {count} 個動作整理而來", skillName: "技能名稱", skillNamePlaceholder: "例如:完成 STAR 訓練課程",
skillInputs: "可變輸入", skillSteps: "步驟(依意圖描述,可修改)", moreSteps: "…還有 {count} 步", editSteps: "編輯步驟", skillCheck: "怎麼確認完成",
skillDistillFailed: "模型整理失敗,這是依動作直接列出的版本,建議修改後再儲存。", discard: "捨棄", testRun: "試跑", saveSkill: "儲存技能", messageTo: "傳訊息給 {name}", chooseConversationFirst: "先選擇對話", stopConversation: "停止對話",
editSkill: "編輯技能", editSkillFull: "完整編輯", runSkillNamed: "執行「{name}」", skillEditLead: "寫給機器人看的說明,用意圖與畫面上的文字描述,不要寫座標。每行一項。",
skillIntent: "意圖(這個技能要達成什麼)", skillWhenToUse: "使用時機", skillInputsLabel: "可變輸入(每行:名稱:說明|例:範例值)", skillInputsPlaceholder: "courseName課程名稱【TW】Workplace Bullying Prevention (2026)",
skillPreconditions: "前提(開始前要成立的條件)", skillStepsLabel: "步驟(每行一步,可用 → 接上預期看到的畫面)", skillStepsPlaceholder: "點「Next」翻頁 → 頁碼加一;若 Next 鎖住就等它啟用", skillHowToCheck: "怎麼確認完成", skillWhatToReturn: "做完要回報什麼", skillCautions: "注意事項(每行一項)",
deleteSkill: "刪除技能", deleteSkillConfirm: "確定刪除技能「{name}」?機器人之後就不會再認得它。", skillSaved: "已儲存",
exportSkill: "匯出", exportSkillHint: "下載成 JSON之後可以匯入到其他機器人",
importSkill: "匯入技能", importSkillHint: "從先前匯出的 JSON 檔加入技能",
skillImportInvalid: "這不是有效的技能檔。請選擇先前匯出的 JSON。",
collapseSidebar: "收合側欄", botComputer: "{name} 的電腦", dedicatedScreen: "獨立螢幕", enlarge: "放大",
userControlling: "你正在控制", aiReadOnly: "AI 操作中(唯讀)", readOnly: "唯讀", pasteClipboard: "貼上剪貼簿", copyDesktopClipboard: "複製桌面剪貼簿", moreActions: "更多操作",
unpin: "取消釘選", pin: "釘選", markUnread: "標示為未讀", enterGroupName: "輸入分組名稱", changeGroup: "變更分組", createOrMoveGroup: "建立/移入分組", removeFromGroup: "移出分組", unhide: "取消隱藏", hide: "隱藏", delete: "刪除",
chooseConversation: "選擇對話", deleteConversation: "刪除對話", clearConversation: "清除此對話",
clearConversationTitle: "清除此對話?", clearConversationDescription: "訊息會刪除。這個 Agent 的長期記憶會保留。", cancel: "取消", clearMessages: "清除訊息",
deleteConversationTitle: "刪除這則對話?", deleteConversationDescription: "這則對話會從列表消失。這個 Agent 的長期記憶會保留。",
avatarAppearance: "頭像外觀", avatarAppearanceHint: "顏色、形狀、表情與背景都可選,眼睛會跟著游標", color: "顏色", chooseColor: "選擇 {value}", customColor: "自訂顏色", shape: "形狀",
expression: "表情", background: "背景", bgNone: "無", bgCircle: "圓", bgSquircle: "方圓", bgSquare: "方",
exprIdle: "閒置", exprHappy: "開心", exprSad: "難過", exprMad: "生氣", exprSurprised: "驚訝", exprWink: "眨眼",
exprSleepy: "想睡", exprSmug: "得意", exprUnsure: "猶豫", exprScared: "害怕", exprLove: "喜歡", exprShy: "害羞",
exprSick: "不適", exprThinking: "思考",
name: "名稱", tags: "標籤",
tagsPlaceholder: "研究、設計、客服(用逗號分隔)", tagsLimit: "最多儲存 6 個標籤", shortTitle: "簡短標題", shortTitlePlaceholder: "例如:產品研究助理", description: "說明", descriptionPlaceholder: "說明這個機器人的用途與工作範圍",
saving: "儲存中…", saveSettings: "儲存設定", deleteBot: "刪除機器人",
memoryHelp: "清除對話不會刪這些。只存明確偏好或事實,不要存密碼。", enableLongTermMemory: "啟用長期記憶", addMemory: "新增記憶", memoryPlaceholder: "只儲存明確偏好或事實;密碼與 token 會被拒絕。",
memorySearch: "搜尋", filterMemory: "過濾記憶", noMemory: "還沒有長期記憶。對話裡講過的偏好,可以叫 Agent 記住,或你在這裡新增。", noMatchingMemory: "沒有符合的記憶。",
save: "儲存", edit: "編輯", clearAllMemoryConfirm: "確定清除全部記憶?", clearMemoryConfirm: "確定清除", clearAll: "全部清除",
mcpHelp: "點「選擇 MCP」從市集接入。標了需要金鑰的先填 token 才能連。本機 stdio 會在 API 容器裡啟動。", mcpNamePlaceholder: "例如github", mcpConnectFailed: "接入失敗,請檢查金鑰或稍後重試。",
command: "Command", commandPlaceholder: "npx 或 uvx 或完整路徑", arguments: "參數", environmentVariables: "環境變數 KEY=value", headers: "Headers KEY=value", connecting: "連線中…", connectMcp: "接入 MCP",
chooseMcp: "選擇 MCP", mcpPickerTitle: "選擇 MCP", mcpPickerLead: "從精選或官方市集挑一個,點下去接入。所有 Agent 都能用它的工具。",
searchMcp: "搜尋 MCP", mcpRemote: "遠端", mcpLocal: "本機", mcpAdded: "已接入", mcpNeedsKey: "需要金鑰",
mcpNoResults: "找不到符合的 MCP。換個關鍵字或改用自訂接入。", mcpCustom: "自訂接入", mcpBackToList: "回到列表",
mcpConnectNamed: "接入 {name}", mcpKeyHint: "這個 MCP 需要憑證才能連。",
noMcp: "還沒有 MCP。點上面的「選擇 MCP」從市集接入。", toolsCount: "{count} 個工具", disabled: "已關閉", disconnected: "未連線", noTools: "沒有可用工具", reconnect: "重新連線", disable: "停用", enable: "啟用",
preparingDesktop: "正在準備 Agent 的獨立桌面…", computerPreviewHint: "開啟電腦後,畫面會顯示在這裡。", bootingProgress: "啟動中…", restartDocker: "重啟 Docker", stopAndTakeOver: "停止並接管",
pasteToRemoteComputer: "貼到遠端電腦", pasteRemoteHelp: "把外面的文字貼在這裡,再送進 VNC。這個方式在區網 HTTP 也能使用。", pasteTextPlaceholder: "在此貼上文字…", pasteIntoVnc: "貼入 VNC",
botNamePlaceholder: "例如:研究助理", sharedComputerHint: "與其他機器人共用環境", privateComputerHint: "全新的獨立 Docker", create: "建立",
groupDescription: "拉進群組會開一個對話,選中的 Agent 都會在裡面發言。", groupName: "群組名稱", groupNamePlaceholder: "例如:產品研究", chooseBots: "選擇機器人(至少兩位)", creating: "建立中…", createGroup: "建立群組",
deleteGroup: "刪除群組", deleteNamed: "刪除 {name}", deleteGroupDescription: "群組對話會刪除。裡面的機器人與他們自己的對話、記憶都會保留。", deleteDedicatedBotDescription: "對話、私人電腦與其中的檔案都會永久刪除。", deleteSharedBotDescription: "對話會刪除,但共用電腦與其中的檔案會保留。",
phoneAccessDescription: "同一區網的手機用瀏覽器打開這個網址,再用同一個存取 token 登入。", localAddressWarning: "這是本機位址,手機打不開。請改成這台電腦的區網 IP例如 http://192.168.x.x:3101。",
close: "關閉", copied: "已複製", copyUrl: "複製網址", workspaceName: "工作區名稱", showHiddenBots: "顯示已隱藏的機器人", collapseRightSidebar: "收合右側欄",
workspaceSettingsHint: "機器人外觀、記憶與 MCP 外掛在右側分頁。這裡只改這個工作區的顯示。",
modelSettings: "模型", modelSettingsHint: "這裡的供應商與模型會套用到還沒單獨指定模型的機器人。",
modelProvider: "模型供應商", providerXai: "xAI", providerOpencodeGo: "OpenCode Go", providerOpenaiCompatible: "OpenAI 相容",
providerXaiHint: "Grok。金鑰從 console.x.ai 取得。",
providerOpencodeGoHint: "OpenCode Go 訂閱。金鑰從 opencode.ai/auth 取得。",
providerOpenaiCompatibleHint: "自架 vLLM、Ollama、LiteLLM 或其他 OpenAI 相容端點。",
apiKey: "API 金鑰", apiKeyPlaceholder: "sk-…", apiKeyStored: "已儲存金鑰,留空表示沿用。",
usingEnvKey: "目前會用環境變數 {name}。",
clearApiKey: "清除已存金鑰",
modelId: "模型", modelIdPlaceholder: "例如qwen2.5 或 llama3.1",
reloadModels: "重新載入模型列表",
baseUrl: "Base URL", baseUrlPlaceholder: "http://127.0.0.1:11434/v1",
baseUrlHint: "API 跑在 Docker 時,本機服務請填 host.docker.internal 而不是 localhost。",
workspaceModelHint: "先選供應商再填金鑰與模型。OpenAI 相容需要自己填端點。",
aboutDescription: "本機多 Agent 工作區。每個機器人有自己的 Linux 電腦,也可以拉進群組一起討論,並接入 MCP 工具。",
apiStatus: "API 狀態:{status}", statusNormal: "正常", statusUnavailable: "無法連線", statusChecking: "檢查中…",
helpBotsTitle: "機器人", helpBots: "左上角 新增機器人。點左側列進入對話。右鍵可以釘選、隱藏或刪除。", helpGroupsTitle: "群組", helpGroups: " → 新增群組,選至少兩位。傳一句話,裡面的 Agent 都會回。",
helpComputerTitle: "電腦", helpComputer: "右側「電腦」是這個 Agent 的獨立桌面。可以啟動、接管滑鼠鍵盤,或讓它自己操作。", helpMemoryTitle: "記憶", helpMemory: "清除對話不會刪長期記憶。可以叫 Agent 記住,或在右側「記憶」手動新增。",
helpMcpTitle: "MCP 外掛", helpMcp: "左下「外掛程式」接入 MCP server。連上的工具會顯示在畫面上對話時 Agent 可以使用。",
helpSkillsTitle: "技能", helpSkills: " → 教它一項任務,示範一次就會整理成技能。示範結束後可以匯出 JSON或把別人的技能檔匯入換一個機器人也適用。",
helpAttachTitle: "附件", helpAttach: " → 附加檔案。圖片這則訊息就會給模型看,不會存進對話紀錄。若機器人電腦要打開原檔,會暫放 inbox/,兩小時後自動刪,避免把磁碟塞滿。",
helpShortcutsTitle: "快捷鍵", helpShortcuts: "Enter 送出Shift+Enter 換行。正在回覆時送出鈕會變成停止。",
feedbackDescription: "寫下問題、想法或想要的功能。這是本機工作區,內容會複製到剪貼簿,方便你貼到 issue 或訊息裡。", feedbackPlaceholder: "例如:群組對話希望可以指定誰先發言…", copyContent: "複製內容",
loginTitle: "登入 LazyBoy", loginDescription: "輸入伺服器設定的共享存取 token。", accessToken: "存取 token", verifying: "驗證中…", login: "登入",
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 …",
} as const;

View File

@ -1,7 +1,8 @@
import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import "@fontsource/huninn"; import "@fontsource/huninn";
import "blobatar/motion.css";
import "blobatar/gaze.css";
import "./styles.css"; import "./styles.css";
import "./refinements.css"; import "./refinements.css";
import { App } from "./App"; import { App } from "./App";
ReactDOM.createRoot(document.getElementById("root")!).render(<React.StrictMode><App /></React.StrictMode>); ReactDOM.createRoot(document.getElementById("root")!).render(<App />);

View File

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

View File

@ -1,8 +1,27 @@
export type ComputerMode = "team" | "dedicated"; export type ComputerMode = "team" | "dedicated";
export type ComputerState = "stopped" | "booting" | "running" | "suspended" | "error"; export type ComputerState = "stopped" | "booting" | "running" | "suspended" | "error";
export type AvatarShape = "round"|"blob"|"squircle"|"capsule"|"triangle"|"hexagon"|"cloud"|"drop"|"diamond"|"organic-4"|"organic-5"|"organic-6"|"organic-7"|"organic-8"|"organic-9"|"organic-10"|"organic-11"; export type BlobatarShape = "round"|"organic"|"boxy"|"capsule"|"nub"|"cloud"|"droplet"|"hexagon"|"sun"|"triangle";
/** Stored values include pre-blobatar aliases so existing bots keep rendering. */
export type AvatarShape = BlobatarShape|"blob"|"squircle"|"diamond"|"drop"|"organic-4"|"organic-5"|"organic-6"|"organic-7"|"organic-8"|"organic-9"|"organic-10"|"organic-11"|"cat"|"bunny"|"star"|"heart"|"egg"|"ghost"|"sprout"|"cactus"|"mushroom"|"paw";
export interface Bot { id:string; spaceId:string; name:string; title:string; description:string; avatarColor:string; avatarShape:AvatarShape; tags:string[]; pinned:boolean; hidden:boolean; groupName:string|null; unreadCount:number; lastMessageAt:string|null; instructions:string; threadId:string; computerId:string; computerMode:ComputerMode; memoryEnabled:boolean } export interface Bot { id:string; spaceId:string; name:string; title:string; description:string; avatarColor:string; avatarShape:AvatarShape; tags:string[]; pinned:boolean; hidden:boolean; groupName:string|null; unreadCount:number; lastMessageAt:string|null; instructions:string; threadId:string; computerId:string; computerMode:ComputerMode; memoryEnabled:boolean }
export interface Session { id:string; botId:string; title:string; status:"active"|"archived"; createdAt:string; updatedAt:string; nextMessageSeq:number; historySummary:string; historySummarySeq:number } export interface Session { id:string; botId:string; title:string; status:"active"|"archived"; createdAt:string; updatedAt:string; nextMessageSeq:number; historySummary:string; historySummarySeq:number }
export interface Message { id:string; sessionId?:string; seq?:number; role:string; body:string; blocks?:unknown[]; runId?:string|null; clientNonce?:string|null; createdAt:string } export interface Message { id:string; sessionId?:string; seq?:number; role:string; body:string; blocks?:unknown[]; runId?:string|null; clientNonce?:string|null; createdAt:string; speakerBotId?:string|null; speakerName?:string|null; speakerColor?:string|null; speakerShape?:AvatarShape|null }
export interface ComputerStatus { botId:string; mode:ComputerMode; state:ComputerState; controlHolder:"none"|"bot"|"user"; takeoverRequested:boolean; busyBotName:string|null; display:string|null; profileMode:string; screenAvailable:boolean } 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 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[] }
export type TaughtSkillStatus = "recording"|"drafting"|"draft"|"saved"|"failed"|"cancelled";
export interface TaughtSkill { id:string; botId:string; threadId:string|null; name:string; goal:string; status:TaughtSkillStatus; playbook:Playbook; error:string|null; startedAt:string|null; expiresAt:string|null; stoppedAt:string|null; createdAt:string; updatedAt:string; eventCount:number; frameCount:number }
export interface MemoryItem { id:string; sessionId:string|null; sourceRunId:string|null; sourceMessageId:string|null; content:string; importance:number; revision:number; createdAt:string; updatedAt:string } export interface MemoryItem { id:string; sessionId:string|null; sourceRunId:string|null; sourceMessageId:string|null; content:string; importance:number; revision:number; createdAt:string; updatedAt:string }
export type McpTransport = "stdio" | "http" | "sse";
export interface McpTool { name:string; exposedName:string; description:string }
export interface McpServer { id:string; name:string; transport:McpTransport; command:string|null; args:string[]; env:Record<string,string>; url:string|null; headers:Record<string,string>; enabled:boolean; status:"connected"|"disconnected"|"disabled"; error:string|null; tools:McpTool[]; createdAt:string; updatedAt:string }
export interface McpSecretField { name:string; required:boolean; secret:boolean; hint:string }
export interface McpCatalogEntry { id:string; title:string; description:string; transport:McpTransport; command:string|null; args:string[]; url:string|null; envKeys:McpSecretField[]; headerKeys:McpSecretField[]; source:"featured"|"registry"; remote:boolean }
export type ModelProviderId = "xai" | "opencode-go" | "openai-compatible";
export interface WorkspaceProvider { id:ModelProviderId; name:string; needsBaseUrl:boolean; needsKey:boolean; defaultBaseUrl:string|null; defaultModel:string|null }
export interface WorkspaceModel { id:string; name:string }
export interface WorkspaceSettings { provider:ModelProviderId; modelId:string; baseUrl:string; apiKeySet:boolean; envKeySet:boolean; envKeyName:string; providers:WorkspaceProvider[]; models:WorkspaceModel[] }

View File

@ -33,3 +33,5 @@ futures-util = "0.3"
http-body-util = "0.1" http-body-util = "0.1"
dotenvy = "0.15" dotenvy = "0.15"
fastembed = "6.0.2" fastembed = "6.0.2"
rmcp = { version = "3.2", default-features = false, features = ["client", "transport-child-process", "transport-streamable-http-client-reqwest"] }
http = "1"

View File

@ -0,0 +1,481 @@
//! Chat attachments: bytes go to the model once, then we drop them.
//!
//! The chat row only stores name/mime/size. If the bot's computer needs a copy
//! (open a PDF, upload a spreadsheet), we write it under `inbox/` on the bind-
//! mounted home and delete anything older than [`INBOX_TTL`].
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use base64::Engine;
use lazyboy_contracts::SessionAttachment;
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};
use crate::state::AppState;
pub const INBOX_TTL: Duration = Duration::from_secs(2 * 60 * 60);
pub const MAX_COUNT: usize = 4;
pub const MAX_BYTES: usize = 10 * 1024 * 1024;
pub const MAX_TOTAL_BYTES: usize = 20 * 1024 * 1024;
const TEXT_INLINE_CHARS: usize = 80_000;
const NAME_MAX: usize = 80;
pub type IncomingAttachment = SessionAttachment;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StoredAttachment {
pub kind: &'static str,
pub name: String,
pub mime_type: String,
pub size: usize,
pub path: String,
}
#[derive(Debug, Clone)]
pub struct DecodedAttachment {
pub name: String,
pub mime_type: String,
pub bytes: Vec<u8>,
}
pub fn decode_incoming(items: &[IncomingAttachment]) -> Result<Vec<DecodedAttachment>, String> {
if items.len() > MAX_COUNT {
return Err(format!("最多 {MAX_COUNT} 個附件"));
}
let mut out = Vec::with_capacity(items.len());
let mut total = 0usize;
for item in items {
let name = safe_name(&item.name)?;
let mime = normalize_mime(&item.mime_type, &name);
if !allowed_mime(&mime) {
return Err(format!("不支援的檔案類型:{name}"));
}
let bytes = decode_base64(&item.content)?;
if bytes.is_empty() {
return Err(format!("空檔案:{name}"));
}
if bytes.len() > MAX_BYTES {
return Err(format!("{name} 超過 10 MB"));
}
total = total.saturating_add(bytes.len());
if total > MAX_TOTAL_BYTES {
return Err("附件合計超過 20 MB".into());
}
out.push(DecodedAttachment {
name,
mime_type: mime,
bytes,
});
}
Ok(out)
}
pub fn stored_blocks(files: &[DecodedAttachment]) -> Vec<Value> {
files
.iter()
.map(|file| {
json!({
"kind": if is_image(&file.mime_type) { "image" } else { "file" },
"name": file.name,
"mimeType": file.mime_type,
"size": file.bytes.len(),
"path": format!("inbox/{}", file.name),
})
})
.collect()
}
pub fn caption_for_title(text: &str, files: &[DecodedAttachment]) -> String {
let trimmed = text.trim();
if !trimmed.is_empty() {
return trimmed.to_string();
}
match files.first() {
Some(file) => format!("附件 {}", file.name),
None => String::new(),
}
}
/// Write copies into each bot's `inbox/` so the desktop can open them.
pub async fn stage_for_bots(
state: &AppState,
actor: &Actor,
bot_ids: &[String],
files: &[DecodedAttachment],
) -> Result<(), String> {
if files.is_empty() {
return Ok(());
}
for bot_id in bot_ids {
let Some(dir) = inbox_dir_for_bot(state, actor, bot_id).await? else {
continue;
};
fs::create_dir_all(&dir)
.await
.map_err(|error| error.to_string())?;
for file in files {
let path = unique_path(&dir, &file.name).await;
fs::write(&path, &file.bytes)
.await
.map_err(|error| error.to_string())?;
}
}
Ok(())
}
pub async fn llm_parts(
state: &AppState,
actor: &Actor,
bot_id: &str,
blocks: &[Value],
vision: bool,
) -> Vec<UserContent> {
let files = load_from_blocks(state, actor, bot_id, blocks).await;
if files.is_empty() && blocks.is_empty() {
return Vec::new();
}
let mut parts = Vec::new();
let mut notes = Vec::new();
if !blocks.is_empty() {
notes.push(format!(
"User attached {} file(s). Chat history does not keep the bytes. Copies live in inbox/ on this computer and expire after 2 hours.",
blocks.len()
));
}
for (stored, bytes) in files {
notes.push(format!(
"- {} ({}, {} bytes) at inbox/{}",
stored.name, stored.mime_type, stored.size, stored.name
));
if is_image(&stored.mime_type) {
if vision {
if let Some(bytes) = bytes {
parts.push(UserContent::image_base64(
base64::engine::general_purpose::STANDARD.encode(bytes),
Some(image_media(&stored.mime_type)),
Some(ImageDetail::High),
));
} else {
notes.push(" (image missing from inbox; it may have expired)".into());
}
}
} else if is_text_mime(&stored.mime_type) {
if let Some(bytes) = bytes {
if let Some(text) = utf8_preview(&bytes) {
parts.push(UserContent::text(format!(
"Contents of {}:\n```\n{text}\n```",
stored.name
)));
}
}
} else {
notes.push(
" Open this with open_path or the desktop if you need the original file.".into(),
);
}
}
if !notes.is_empty() {
parts.insert(0, UserContent::text(notes.join("\n")));
}
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 {
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 {
continue;
};
while let Ok(Some(bot)) = bots_dir.next_entry().await {
sweep_dir(&bot.path().join("inbox")).await;
}
}
}
async fn inbox_dir_for_bot(
state: &AppState,
actor: &Actor,
bot_id: &str,
) -> Result<Option<PathBuf>, String> {
let Some(bot) = state
.db
.get_bot(actor, bot_id)
.await
.map_err(|error| error.to_string())?
else {
return Ok(None);
};
let Some(computer_id) = bot.computer_id.as_deref() else {
return Ok(None);
};
let Some(computer) = state
.db
.get_computer(computer_id)
.await
.map_err(|error| error.to_string())?
else {
return Ok(None);
};
let home = computer::home_path(&state.data_dir, &computer.home_key);
let relative = resolve_bot_workspace_path(parse_mode(&computer.scope), bot_id, "inbox")
.unwrap_or_else(|_| "inbox".into());
Ok(Some(home.join(relative)))
}
async fn load_from_blocks(
state: &AppState,
actor: &Actor,
bot_id: &str,
blocks: &[Value],
) -> Vec<(StoredAttachment, Option<Vec<u8>>)> {
let Some(dir) = inbox_dir_for_bot(state, actor, bot_id).await.ok().flatten() else {
return blocks
.iter()
.filter_map(parse_stored)
.map(|file| (file, None))
.collect();
};
let mut out = Vec::new();
for block in blocks {
let Some(file) = parse_stored(block) else {
continue;
};
let path = dir.join(&file.name);
let bytes = fs::read(&path).await.ok();
out.push((file, bytes));
}
out
}
fn parse_stored(value: &Value) -> Option<StoredAttachment> {
let kind = value.get("kind").and_then(Value::as_str)?;
if kind != "file" && kind != "image" {
return None;
}
let name = value.get("name").and_then(Value::as_str)?;
Some(StoredAttachment {
kind: if kind == "image" { "image" } else { "file" },
name: name.to_string(),
mime_type: value
.get("mimeType")
.and_then(Value::as_str)
.unwrap_or("application/octet-stream")
.to_string(),
size: value.get("size").and_then(Value::as_u64).unwrap_or(0) as usize,
path: value
.get("path")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
})
}
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()),
_ => (name.to_string(), String::new()),
}
}
pub fn safe_name(name: &str) -> Result<String, String> {
let file = Path::new(name)
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("")
.trim();
let mut out = String::new();
for ch in file.chars() {
if ch.is_control() || "/\\:".contains(ch) {
continue;
}
out.push(ch);
if out.chars().count() >= NAME_MAX {
break;
}
}
let out = out.trim_matches('.').trim().to_string();
if out.is_empty() || out == "." || out == ".." {
return Err("檔名無效".into());
}
Ok(out)
}
fn decode_base64(value: &str) -> Result<Vec<u8>, String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err("附件內容是空的".into());
}
base64::engine::general_purpose::STANDARD
.decode(trimmed)
.or_else(|_| base64::engine::general_purpose::STANDARD.decode(trimmed.replace('\n', "")))
.map_err(|_| "附件不是有效的 base64".into())
}
fn normalize_mime(mime: &str, name: &str) -> String {
let mime = mime.trim().to_ascii_lowercase();
if !mime.is_empty() && mime != "application/octet-stream" {
return mime.split(';').next().unwrap_or(&mime).trim().to_string();
}
match Path::new(name)
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("")
.to_ascii_lowercase()
.as_str()
{
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"pdf" => "application/pdf",
"txt" | "log" => "text/plain",
"md" => "text/markdown",
"csv" => "text/csv",
"json" => "application/json",
"html" | "htm" => "text/html",
"xml" => "application/xml",
"docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
_ => "application/octet-stream",
}
.into()
}
fn allowed_mime(mime: &str) -> bool {
is_image(mime)
|| is_text_mime(mime)
|| matches!(
mime,
"application/pdf"
| "application/xml"
| "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
| "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
| "application/vnd.openxmlformats-officedocument.presentationml.presentation"
)
}
fn is_image(mime: &str) -> bool {
matches!(
mime,
"image/jpeg" | "image/png" | "image/gif" | "image/webp"
)
}
fn is_text_mime(mime: &str) -> bool {
mime.starts_with("text/") || matches!(mime, "application/json" | "application/xml")
}
fn image_media(mime: &str) -> ImageMediaType {
if mime == "image/jpeg" {
ImageMediaType::JPEG
} else {
ImageMediaType::PNG
}
}
fn utf8_preview(bytes: &[u8]) -> Option<String> {
if bytes.contains(&0) {
return None;
}
let text = std::str::from_utf8(bytes).ok()?;
let preview: String = text.chars().take(TEXT_INLINE_CHARS).collect();
if text.chars().count() > TEXT_INLINE_CHARS {
Some(format!("{preview}\n…(truncated)"))
} else {
Some(preview)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_path_in_name() {
assert_eq!(safe_name("../../etc/passwd").unwrap(), "passwd");
assert!(safe_name("...").is_err());
}
#[test]
fn guesses_png_mime() {
assert_eq!(normalize_mime("", "shot.PNG"), "image/png");
assert!(allowed_mime("image/png"));
assert!(!allowed_mime("application/x-msdownload"));
}
#[test]
fn stored_blocks_drop_bytes() {
let files = [DecodedAttachment {
name: "a.png".into(),
mime_type: "image/png".into(),
bytes: vec![1, 2, 3],
}];
let blocks = stored_blocks(&files);
assert_eq!(blocks[0]["kind"], "image");
assert_eq!(blocks[0]["path"], "inbox/a.png");
assert!(blocks[0].get("content").is_none());
}
#[test]
fn decode_limits_count() {
let item = IncomingAttachment {
name: "a.txt".into(),
mime_type: "text/plain".into(),
content: base64::engine::general_purpose::STANDARD.encode("hi"),
};
let many = vec![item; 5];
assert!(decode_incoming(&many).is_err());
}
}

View File

@ -1,5 +1,5 @@
use axum::extract::{Request, State}; use axum::extract::{Request, State};
use axum::http::{header, HeaderMap, HeaderValue, StatusCode}; use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
use axum::middleware::Next; use axum::middleware::Next;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::{Json, Router}; use axum::{Json, Router};
@ -104,7 +104,10 @@ struct LoginInput {
pub fn public_router(state: AppState) -> Router { pub fn public_router(state: AppState) -> Router {
Router::new() Router::new()
.route("/api/session", axum::routing::get(session).post(login).delete(logout)) .route(
"/api/session",
axum::routing::get(session).post(login).delete(logout),
)
.with_state(state) .with_state(state)
} }
@ -144,18 +147,12 @@ async fn logout() -> Response {
let mut response = Json(json!({"ok": true})).into_response(); let mut response = Json(json!({"ok": true})).into_response();
response.headers_mut().insert( response.headers_mut().insert(
header::SET_COOKIE, header::SET_COOKIE,
HeaderValue::from_static( HeaderValue::from_static("lazyboy_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0"),
"lazyboy_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0",
),
); );
response response
} }
pub async fn require_auth( pub async fn require_auth(State(state): State<AppState>, request: Request, next: Next) -> Response {
State(state): State<AppState>,
request: Request,
next: Next,
) -> Response {
if state.auth.valid_session(request.headers()) { if state.auth.valid_session(request.headers()) {
next.run(request).await next.run(request).await
} else { } else {

View File

@ -3,19 +3,19 @@ use std::time::Duration;
use chrono::{TimeDelta, Utc}; use chrono::{TimeDelta, Utc};
use lazyboy_contracts::{ use lazyboy_contracts::{
BrowserProfileMode, ComputerCapabilities, ComputerMode, ComputerState, ComputerStatus, ControlHolder, BrowserProfileMode, ComputerCapabilities, ComputerMode, ComputerState, ComputerStatus,
DEFAULT_SCREEN_HEIGHT, DEFAULT_SCREEN_WIDTH, ControlHolder, DEFAULT_SCREEN_HEIGHT, DEFAULT_SCREEN_WIDTH,
}; };
use lazyboy_control::{ use lazyboy_control::{
admit_gui, admit_new_screen, browser_profile_path, execution_blocks_user_takeover, profile_lock_key, AdapterContext, CommandRequest, EnsureScreenRequest, ProvisionRequest, admit_gui,
screen_layout, team_bot_workspace_directory, user_holds_control, AdapterContext, CommandRequest, admit_new_screen, browser_profile_path, execution_blocks_user_takeover, profile_lock_key,
EnsureScreenRequest, ProvisionRequest, screen_layout, team_bot_workspace_directory, user_holds_control,
}; };
use uuid::Uuid; use uuid::Uuid;
use crate::db::{ use crate::db::{
parse_holder, parse_kind, parse_mode, parse_profile_mode, parse_run_status, parse_state, Actor, ComputerRow, Actor, ComputerRow, ScreenRow, parse_holder, parse_kind, parse_mode, parse_profile_mode,
ScreenRow, parse_run_status, parse_state,
}; };
use crate::state::AppState; use crate::state::AppState;
@ -52,6 +52,12 @@ pub fn status_from(
screen_height: DEFAULT_SCREEN_HEIGHT, screen_height: DEFAULT_SCREEN_HEIGHT,
home_revision: Some(computer.home_revision.clone()), home_revision: Some(computer.home_revision.clone()),
busy_bot_name, busy_bot_name,
busy_session_id: None,
busy_run_id: None,
busy_step: None,
waiting_run_id: None,
waiting_session_id: None,
queued_runs: 0,
multi_screen: true, multi_screen: true,
screen_id: screen.map(|row| row.id.clone()), screen_id: screen.map(|row| row.id.clone()),
display: screen.map(|row| row.display.clone()), display: screen.map(|row| row.display.clone()),
@ -141,12 +147,14 @@ pub async fn ensure_bot_screen(
let profile_path = browser_profile_path(profile_mode, bot_id, run_id); let profile_path = browser_profile_path(profile_mode, bot_id, run_id);
let row = if let Some(existing) = existing { let row = if let Some(existing) = existing {
if existing.profile_path != profile_path && profile_mode == BrowserProfileMode::PerTask { if existing.profile_path != profile_path && profile_mode == BrowserProfileMode::PerTask {
sqlx::query("UPDATE computer_screens SET profile_path = $2, updated_at = now() WHERE id = $1") sqlx::query(
.bind(&existing.id) "UPDATE computer_screens SET profile_path = $2, updated_at = now() WHERE id = $1",
.bind(&profile_path) )
.execute(state.pool()) .bind(&existing.id)
.await .bind(&profile_path)
.map_err(|error| error.to_string())?; .execute(state.pool())
.await
.map_err(|error| error.to_string())?;
} }
state state
.db .db
@ -177,12 +185,19 @@ pub async fn ensure_bot_screen(
.await; .await;
match inserted { match inserted {
Ok(row) => row, Ok(row) => row,
Err(error) if error.to_string().contains("computer_screens_computer_id_slot") => { Err(error)
if error
.to_string()
.contains("computer_screens_computer_id_slot") =>
{
return Ok(BoundScreen { return Ok(BoundScreen {
row: None, row: None,
gui_block: Some(admit_new_screen(&caps, &used, None).err().map(|block| block.message()).unwrap_or_else(|| { gui_block: Some(
lazyboy_contracts::TEAM_SCREENS_FULL.to_string() admit_new_screen(&caps, &used, None)
})), .err()
.map(|block| block.message())
.unwrap_or_else(|| lazyboy_contracts::TEAM_SCREENS_FULL.to_string()),
),
}); });
} }
Err(error) => return Err(error.to_string()), Err(error) => return Err(error.to_string()),
@ -196,7 +211,11 @@ pub async fn ensure_bot_screen(
}; };
let mut last_error = None; let mut last_error = None;
for attempt in 0..8 { for attempt in 0..8 {
match state.sandbox.ensure_screen(&computer_ref, request.clone(), &ctx).await { match state
.sandbox
.ensure_screen(&computer_ref, request.clone(), &ctx)
.await
{
Ok(_) => { Ok(_) => {
last_error = None; last_error = None;
break; break;
@ -253,23 +272,88 @@ async fn restore_computer_screens(
} }
} }
async fn guest_has_screens(state: &AppState, actor: &Actor, bot_id: &str, computer: &ComputerRow) -> bool { async fn guest_has_screens(
state: &AppState,
actor: &Actor,
bot_id: &str,
computer: &ComputerRow,
) -> bool {
matches!(
probe_computer_container(state, actor, bot_id, computer).await,
ContainerProbe::Alive
)
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum ContainerProbe {
Alive,
Missing,
Unknown,
}
fn container_missing_error(error: &str) -> bool {
let error = error.to_ascii_lowercase();
error.contains("is not running")
|| error.contains("no such container")
|| error.contains("404 not found")
|| error.contains("status code 409")
}
async fn probe_computer_container(
state: &AppState,
actor: &Actor,
bot_id: &str,
computer: &ComputerRow,
) -> ContainerProbe {
let Some(computer_ref) = computer_ref(computer) else { let Some(computer_ref) = computer_ref(computer) else {
return false; return ContainerProbe::Missing;
}; };
let result = state match state
.sandbox .sandbox
.execute( .execute(
&computer_ref, &computer_ref,
CommandRequest { CommandRequest {
argv: vec!["test".into(), "-x".into(), "/usr/local/bin/lazyboy-screen".into()], argv: vec![
"test".into(),
"-x".into(),
"/usr/local/bin/lazyboy-screen".into(),
],
cwd: None, cwd: None,
timeout_ms: Some(5_000), timeout_ms: Some(5_000),
}, },
&adapter_context(actor, bot_id, "probe"), &adapter_context(actor, bot_id, "probe"),
) )
.await; .await
matches!(result, Ok(output) if output.code == 0) {
Ok(output) if output.code == 0 => ContainerProbe::Alive,
Ok(_) => ContainerProbe::Missing,
Err(error) if container_missing_error(&error.to_string()) => ContainerProbe::Missing,
Err(error) => {
tracing::warn!("computer {} probe failed: {error}", computer.id);
ContainerProbe::Unknown
}
}
}
async fn mark_computer_missing(state: &AppState, computer: &mut ComputerRow) {
if computer.state != "running" {
return;
}
tracing::warn!(
"computer {} is marked running but the container is gone",
computer.id
);
if let Err(error) = sqlx::query(
"UPDATE computers SET state = 'error', updated_at = now() WHERE id = $1 AND state = 'running'",
)
.bind(&computer.id)
.execute(state.pool())
.await
{
tracing::error!("failed to mark computer {} missing: {error}", computer.id);
return;
}
computer.state = "error".into();
} }
pub async fn take_screen_execution( pub async fn take_screen_execution(
@ -369,7 +453,11 @@ pub async fn take_profile_lock(
) )
.err() .err()
.map(|block| block.message()) .map(|block| block.message())
.unwrap_or_else(|| format!("Another bot is using this shared browser profile. Currently in use by {other}.")), .unwrap_or_else(|| {
format!(
"Another bot is using this shared browser profile. Currently in use by {other}."
)
}),
)) ))
} }
@ -380,7 +468,10 @@ pub async fn boot(state: &AppState, actor: &Actor, bot_id: &str) -> Result<Compu
.await .await
.map_err(|error| error.to_string())? .map_err(|error| error.to_string())?
.ok_or_else(|| "bot not found".to_string())?; .ok_or_else(|| "bot not found".to_string())?;
let computer_id = bot.computer_id.clone().ok_or_else(|| "bot has no computer".to_string())?; let computer_id = bot
.computer_id
.clone()
.ok_or_else(|| "bot has no computer".to_string())?;
let computer = state let computer = state
.db .db
.get_computer(&computer_id) .get_computer(&computer_id)
@ -417,31 +508,56 @@ pub async fn boot(state: &AppState, actor: &Actor, bot_id: &str) -> Result<Compu
.execute(state.pool()) .execute(state.pool())
.await .await
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
if claimed.rows_affected() != 1 && computer.state != "booting" { // Only the request that atomically transitions the row to booting may
// provision it. Allowing every caller that observes `booting` through
// here starts duplicate containers and can leave the row inconsistent.
if claimed.rows_affected() != 1 {
return Err("Computer is busy".into()); return Err("Computer is busy".into());
} }
let ctx = adapter_context(actor, bot_id, "boot"); let ctx = adapter_context(actor, bot_id, "boot");
let home = home_path(&state.data_dir, &computer.home_key); let home = home_path(&state.data_dir, &computer.home_key);
tokio::fs::create_dir_all(&home) if let Err(error) = tokio::fs::create_dir_all(&home).await {
.await let message = error.to_string();
.map_err(|error| error.to_string())?; mark_boot_error(state, &computer_id, None).await;
let provisioned = state return Err(message);
.sandbox }
.provision( let provisioned = match tokio::time::timeout(
Duration::from_secs(120),
state.sandbox.provision(
ProvisionRequest { ProvisionRequest {
home_key: computer.home_key.clone(), home_key: computer.home_key.clone(),
home_path: home.to_string_lossy().into_owned(), home_path: home.to_string_lossy().into_owned(),
provider_ref: computer.provider_ref.clone(), provider_ref: computer.provider_ref.clone(),
}, },
&ctx, &ctx,
) ),
.await )
.map_err(|error| error.to_string())?; .await
{
Ok(Ok(provisioned)) => provisioned,
Ok(Err(error)) => {
let message = error.to_string();
mark_boot_error(state, &computer_id, None).await;
return Err(message);
}
Err(_) => {
let message = "computer provision timed out after 120 seconds".to_string();
mark_boot_error(state, &computer_id, None).await;
return Err(message);
}
};
if parse_mode(&computer.scope) == ComputerMode::Team { if parse_mode(&computer.scope) == ComputerMode::Team {
let folder = team_bot_workspace_directory(bot_id).map_err(|error| error.to_string())?; let folder = match team_bot_workspace_directory(bot_id) {
let _ = state Ok(folder) => folder,
.sandbox Err(error) => {
.execute( let message = error.to_string();
mark_boot_error(state, &computer_id, None).await;
return Err(message);
}
};
let setup = tokio::time::timeout(
Duration::from_secs(15),
state.sandbox.execute(
&provisioned, &provisioned,
CommandRequest { CommandRequest {
argv: vec!["mkdir".into(), "-p".into(), "shared".into(), folder], argv: vec!["mkdir".into(), "-p".into(), "shared".into(), folder],
@ -449,10 +565,19 @@ pub async fn boot(state: &AppState, actor: &Actor, bot_id: &str) -> Result<Compu
timeout_ms: Some(10_000), timeout_ms: Some(10_000),
}, },
&ctx, &ctx,
) ),
.await; )
.await;
if let Err(error) = match setup {
Ok(result) => result.map_err(|error| error.to_string()),
Err(_) => Err("computer workspace setup timed out after 15 seconds".into()),
} {
// Workspace setup is additive; a failed mkdir should not make an
// otherwise usable desktop unavailable. The next run can retry it.
tracing::warn!("computer workspace setup failed for {bot_id}: {error}");
}
} }
sqlx::query( let running = sqlx::query(
"UPDATE computers SET state = 'running', provider_ref = $2, kind = $3, updated_at = now() "UPDATE computers SET state = 'running', provider_ref = $2, kind = $3, updated_at = now()
WHERE id = $1 AND state = 'booting'", WHERE id = $1 AND state = 'booting'",
) )
@ -462,6 +587,9 @@ pub async fn boot(state: &AppState, actor: &Actor, bot_id: &str) -> Result<Compu
.execute(state.pool()) .execute(state.pool())
.await .await
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
if running.rows_affected() != 1 {
return Err("computer boot was superseded".into());
}
let computer = state let computer = state
.db .db
.get_computer(&computer_id) .get_computer(&computer_id)
@ -476,6 +604,20 @@ pub async fn boot(state: &AppState, actor: &Actor, bot_id: &str) -> Result<Compu
Ok(status_from(bot_id, &computer, screen.as_ref(), None)) Ok(status_from(bot_id, &computer, screen.as_ref(), None))
} }
async fn 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()
WHERE id = $1 AND state = 'booting'",
)
.bind(computer_id)
.bind(provider_ref)
.execute(state.pool())
.await
{
tracing::error!("failed to mark computer {computer_id} boot error: {error}");
}
}
pub async fn stop(state: &AppState, actor: &Actor, bot_id: &str) -> Result<ComputerStatus, String> { pub async fn stop(state: &AppState, actor: &Actor, bot_id: &str) -> Result<ComputerStatus, String> {
let bot = state let bot = state
.db .db
@ -483,7 +625,10 @@ pub async fn stop(state: &AppState, actor: &Actor, bot_id: &str) -> Result<Compu
.await .await
.map_err(|error| error.to_string())? .map_err(|error| error.to_string())?
.ok_or_else(|| "bot not found".to_string())?; .ok_or_else(|| "bot not found".to_string())?;
let computer_id = bot.computer_id.clone().ok_or_else(|| "bot has no computer".to_string())?; let computer_id = bot
.computer_id
.clone()
.ok_or_else(|| "bot has no computer".to_string())?;
let computer = state let computer = state
.db .db
.get_computer(&computer_id) .get_computer(&computer_id)
@ -515,18 +660,30 @@ pub async fn stop(state: &AppState, actor: &Actor, bot_id: &str) -> Result<Compu
.execute(state.pool()) .execute(state.pool())
.await .await
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
let computer = state.db.get_computer(&computer_id).await.map_err(|e| e.to_string())?.unwrap(); let computer = state
.db
.get_computer(&computer_id)
.await
.map_err(|e| e.to_string())?
.unwrap();
Ok(status_from(bot_id, &computer, None, None)) Ok(status_from(bot_id, &computer, None, None))
} }
pub async fn restart(state: &AppState, actor: &Actor, bot_id: &str) -> Result<ComputerStatus, String> { pub async fn restart(
state: &AppState,
actor: &Actor,
bot_id: &str,
) -> Result<ComputerStatus, String> {
let bot = state let bot = state
.db .db
.get_bot(actor, bot_id) .get_bot(actor, bot_id)
.await .await
.map_err(|error| error.to_string())? .map_err(|error| error.to_string())?
.ok_or_else(|| "bot not found".to_string())?; .ok_or_else(|| "bot not found".to_string())?;
let computer_id = bot.computer_id.clone().ok_or_else(|| "bot has no computer".to_string())?; let computer_id = bot
.computer_id
.clone()
.ok_or_else(|| "bot has no computer".to_string())?;
let computer = state let computer = state
.db .db
.get_computer(&computer_id) .get_computer(&computer_id)
@ -535,8 +692,30 @@ pub async fn restart(state: &AppState, actor: &Actor, bot_id: &str) -> Result<Co
.ok_or_else(|| "computer not found".to_string())?; .ok_or_else(|| "computer not found".to_string())?;
if let Some(computer_ref) = computer_ref(&computer) { if let Some(computer_ref) = computer_ref(&computer) {
let ctx = adapter_context(actor, bot_id, "restart"); let ctx = adapter_context(actor, bot_id, "restart");
let _ = state.sandbox.stop(&computer_ref, &ctx).await; if tokio::time::timeout(
let _ = state.sandbox.destroy(&computer_ref, &ctx).await; Duration::from_secs(20),
state.sandbox.stop(&computer_ref, &ctx),
)
.await
.is_err()
{
tracing::warn!(
"computer stop timed out during restart: {}",
computer_ref.id
);
}
if tokio::time::timeout(
Duration::from_secs(20),
state.sandbox.destroy(&computer_ref, &ctx),
)
.await
.is_err()
{
tracing::warn!(
"computer destroy timed out during restart: {}",
computer_ref.id
);
}
} }
sqlx::query( sqlx::query(
"UPDATE computers SET state = 'stopped', provider_ref = NULL, control_holder = 'none', "UPDATE computers SET state = 'stopped', provider_ref = NULL, control_holder = 'none',
@ -552,14 +731,21 @@ pub async fn restart(state: &AppState, actor: &Actor, bot_id: &str) -> Result<Co
boot(state, actor, bot_id).await boot(state, actor, bot_id).await
} }
pub async fn takeover(state: &AppState, actor: &Actor, bot_id: &str) -> Result<(String, String), String> { pub async fn takeover(
state: &AppState,
actor: &Actor,
bot_id: &str,
) -> Result<(String, String), String> {
let bot = state let bot = state
.db .db
.get_bot(actor, bot_id) .get_bot(actor, bot_id)
.await .await
.map_err(|error| error.to_string())? .map_err(|error| error.to_string())?
.ok_or_else(|| "bot not found".to_string())?; .ok_or_else(|| "bot not found".to_string())?;
let computer_id = bot.computer_id.clone().ok_or_else(|| "bot has no computer".to_string())?; let computer_id = bot
.computer_id
.clone()
.ok_or_else(|| "bot has no computer".to_string())?;
let computer = state let computer = state
.db .db
.get_computer(&computer_id) .get_computer(&computer_id)
@ -570,9 +756,19 @@ pub async fn takeover(state: &AppState, actor: &Actor, bot_id: &str) -> Result<(
return Err("computer must be running".into()); return Err("computer must be running".into());
} }
let bound = ensure_bot_screen(state, actor, bot_id, &computer, None).await?; let bound = ensure_bot_screen(state, actor, bot_id, &computer, None).await?;
let screen = bound.row.ok_or_else(|| bound.gui_block.unwrap_or_else(|| "screen unavailable".into()))?; let screen = bound.row.ok_or_else(|| {
let active = state.db.active_run(bot_id).await.map_err(|error| error.to_string())?; bound
let run_status = active.as_ref().and_then(|(_, status)| parse_run_status(status)); .gui_block
.unwrap_or_else(|| "screen unavailable".into())
})?;
let active = state
.db
.active_run(bot_id)
.await
.map_err(|error| error.to_string())?;
let run_status = active
.as_ref()
.and_then(|(_, status, _)| parse_run_status(status));
if execution_blocks_user_takeover( if execution_blocks_user_takeover(
screen.execution_run_id.is_some(), screen.execution_run_id.is_some(),
screen.execution_lease_expires_at, screen.execution_lease_expires_at,
@ -604,6 +800,28 @@ pub async fn takeover(state: &AppState, actor: &Actor, bot_id: &str) -> Result<(
.execute(state.pool()) .execute(state.pool())
.await .await
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
let paused: Vec<(String, String)> = sqlx::query_as(
"UPDATE runs SET status = 'waiting_takeover',
checkpoint = COALESCE(checkpoint, '{}'::jsonb)
|| jsonb_build_object('resumeAfterTakeover', true),
updated_at = now()
WHERE bot_id = $1 AND status IN ('queued','leased','running')
RETURNING id, thread_id",
)
.bind(bot_id)
.fetch_all(state.pool())
.await
.map_err(|error| error.to_string())?;
for (run_id, thread_id) in paused {
let _ = crate::runs::append_bot_message(
state,
&thread_id,
&run_id,
bot_id,
"你已接手操作。完成後釋放控制權,我會從目前畫面繼續。",
)
.await;
}
Ok((lease_id, expires.to_rfc3339())) Ok((lease_id, expires.to_rfc3339()))
} }
@ -688,7 +906,11 @@ pub fn user_has_control(computer: &ComputerRow, bot_id: &str) -> bool {
user_has_screen_control(computer, None, bot_id) user_has_screen_control(computer, None, bot_id)
} }
pub fn user_has_screen_control(computer: &ComputerRow, screen: Option<&ScreenRow>, bot_id: &str) -> bool { pub fn user_has_screen_control(
computer: &ComputerRow,
screen: Option<&ScreenRow>,
bot_id: &str,
) -> bool {
if let Some(screen) = screen { if let Some(screen) = screen {
return user_holds_control( return user_holds_control(
parse_holder(&screen.control_holder), parse_holder(&screen.control_holder),
@ -710,6 +932,7 @@ pub fn user_has_screen_control(computer: &ComputerRow, screen: Option<&ScreenRow
pub async fn idle_loop(state: AppState) { pub async fn idle_loop(state: AppState) {
loop { loop {
tokio::time::sleep(Duration::from_secs(60)).await; tokio::time::sleep(Duration::from_secs(60)).await;
crate::attachments::sweep_all_inboxes(&state.data_dir).await;
let cutoff = Utc::now() - TimeDelta::minutes(10); let cutoff = Utc::now() - TimeDelta::minutes(10);
let rows = sqlx::query_as::<_, ComputerRow>( let rows = sqlx::query_as::<_, ComputerRow>(
"SELECT id, space_id, user_id, scope, scope_key, home_key, home_revision, kind, provider_ref, state, "SELECT id, space_id, user_id, scope, scope_key, home_key, home_revision, kind, provider_ref, state,
@ -725,7 +948,11 @@ pub async fn idle_loop(state: AppState) {
for computer in rows { for computer in rows {
let active: Result<Option<(i64,)>, _> = sqlx::query_as( let active: Result<Option<(i64,)>, _> = sqlx::query_as(
"SELECT 1 FROM runs WHERE status IN ('queued','leased','running','waiting_input','waiting_takeover') "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) LIMIT 1", 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) .bind(&computer.id)
.fetch_optional(state.pool()) .fetch_optional(state.pool())
@ -754,10 +981,12 @@ pub async fn idle_loop(state: AppState) {
) )
.await; .await;
} }
let _ = sqlx::query("UPDATE computers SET state = 'stopped', updated_at = now() WHERE id = $1") let _ = sqlx::query(
.bind(&computer.id) "UPDATE computers SET state = 'stopped', updated_at = now() WHERE id = $1",
.execute(state.pool()) )
.await; .bind(&computer.id)
.execute(state.pool())
.await;
} }
} }
} }
@ -773,41 +1002,95 @@ pub fn computer_ref(computer: &ComputerRow) -> Option<lazyboy_control::ComputerR
}) })
} }
pub async fn current_status(state: &AppState, actor: &Actor, bot_id: &str) -> Result<ComputerStatus, String> { pub async fn current_status(
state: &AppState,
actor: &Actor,
bot_id: &str,
) -> Result<ComputerStatus, String> {
let bot = state let bot = state
.db .db
.get_bot(actor, bot_id) .get_bot(actor, bot_id)
.await .await
.map_err(|error| error.to_string())? .map_err(|error| error.to_string())?
.ok_or_else(|| "bot not found".to_string())?; .ok_or_else(|| "bot not found".to_string())?;
let computer_id = bot.computer_id.ok_or_else(|| "bot has no computer".to_string())?; let computer_id = bot
let computer = state .computer_id
.ok_or_else(|| "bot has no computer".to_string())?;
let mut computer = state
.db .db
.get_computer(&computer_id) .get_computer(&computer_id)
.await .await
.map_err(|error| error.to_string())? .map_err(|error| error.to_string())?
.ok_or_else(|| "computer not found".to_string())?; .ok_or_else(|| "computer not found".to_string())?;
if computer.state == "running"
&& probe_computer_container(state, actor, bot_id, &computer).await
== ContainerProbe::Missing
{
mark_computer_missing(state, &mut computer).await;
}
let screen = state let screen = state
.db .db
.get_screen(&computer.id, bot_id) .get_screen(&computer.id, bot_id)
.await .await
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
let run_status = state // Newest-first is wrong here: a message queued behind a paused run must not
.db // hide the pause. Rank by what the user needs to know about.
.active_run(bot_id) let active: Vec<ActiveRunRow> = sqlx::query_as(
.await "SELECT id, status, thread_id, checkpoint->>'step' AS step FROM runs
.ok() WHERE bot_id = $1
.flatten() AND status IN ('queued','leased','running','waiting_input','waiting_takeover')
.and_then(|(_, run_status)| parse_run_status(&run_status)); ORDER BY CASE status
let waiting_for_takeover = run_status == Some(lazyboy_contracts::RunStatus::WaitingTakeover); WHEN 'running' THEN 0 WHEN 'leased' THEN 1
let busy_bot_name = run_status WHEN 'waiting_takeover' THEN 2 WHEN 'waiting_input' THEN 3
.filter(|status| status.is_active() && *status != lazyboy_contracts::RunStatus::WaitingTakeover) ELSE 4 END,
.map(|_| bot.name); created_at ASC",
)
.bind(bot_id)
.fetch_all(state.pool())
.await
.unwrap_or_default();
let waiting = active.iter().find(|run| run.status == "waiting_takeover");
let busy = active.iter().find(|run| {
parse_run_status(&run.status).is_some_and(|status| {
status.is_active() && status != lazyboy_contracts::RunStatus::WaitingTakeover
})
});
// While the bot is paused for the human, later messages just queue up; the
// spinner would lie, so report them as queued instead of busy.
let busy = if waiting.is_some() {
busy.filter(|run| run.status != "queued")
} else {
busy
};
let busy_bot_name = busy.map(|_| bot.name.clone());
let mut status = status_from(bot_id, &computer, screen.as_ref(), busy_bot_name); let mut status = status_from(bot_id, &computer, screen.as_ref(), busy_bot_name);
status.takeover_requested = waiting_for_takeover; status.takeover_requested = waiting.is_some();
if let Some(run) = busy {
status.busy_run_id = Some(run.id.clone());
status.busy_session_id = Some(run.thread_id.clone());
status.busy_step = run.step.clone();
}
if let Some(run) = waiting {
status.waiting_run_id = Some(run.id.clone());
status.waiting_session_id = Some(run.thread_id.clone());
}
status.queued_runs = active
.iter()
.filter(|run| {
run.status == "queued" && Some(run.id.as_str()) != busy.map(|b| b.id.as_str())
})
.count() as u32;
Ok(status) Ok(status)
} }
#[derive(sqlx::FromRow)]
struct ActiveRunRow {
id: String,
status: String,
thread_id: String,
step: Option<String>,
}
pub fn _keep_state(state: ComputerState, holder: ControlHolder, mode: BrowserProfileMode) { pub fn _keep_state(state: ComputerState, holder: ControlHolder, mode: BrowserProfileMode) {
let _ = (state, holder, mode); let _ = (state, holder, mode);
} }

View File

@ -1,7 +1,7 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use lazyboy_contracts::{ use lazyboy_contracts::{
computer_home_key, computer_scope_key, Bot, BrowserProfileMode, ComputerMode, ComputerState, Bot, BrowserProfileMode, ComputerMode, ComputerState, ControlHolder, RunStatus, SandboxKind,
ControlHolder, RunStatus, SandboxKind, computer_home_key, computer_scope_key,
}; };
use sqlx::{FromRow, PgPool}; use sqlx::{FromRow, PgPool};
use uuid::Uuid; use uuid::Uuid;
@ -85,6 +85,18 @@ pub struct BotRow {
pub memory_enabled: bool, pub memory_enabled: bool,
} }
#[derive(Debug, Clone, FromRow)]
#[allow(dead_code)]
pub struct SpaceRow {
pub id: String,
pub user_id: String,
pub name: String,
pub default_model_provider: String,
pub default_model_id: String,
pub default_model_base_url: Option<String>,
pub default_model_api_key: Option<String>,
}
impl Db { impl Db {
pub async fn ensure_local_actor(&self) -> Result<Actor, sqlx::Error> { pub async fn ensure_local_actor(&self) -> Result<Actor, sqlx::Error> {
let user_id = "local-user"; let user_id = "local-user";
@ -110,13 +122,72 @@ impl Db {
}) })
} }
pub async fn list_bots(&self, actor: &Actor) -> Result<Vec<(BotRow, String, ComputerRow)>, sqlx::Error> { pub async fn get_space(&self, actor: &Actor) -> Result<Option<SpaceRow>, sqlx::Error> {
sqlx::query_as(
"SELECT id, user_id, name, default_model_provider, default_model_id,
default_model_base_url, default_model_api_key
FROM spaces WHERE id = $1 AND user_id = $2",
)
.bind(&actor.space_id)
.bind(&actor.user_id)
.fetch_optional(&self.pool)
.await
}
pub async fn update_workspace_model(
&self,
actor: &Actor,
provider: &str,
model_id: &str,
base_url: Option<&str>,
api_key: Option<Option<&str>>,
) -> Result<SpaceRow, sqlx::Error> {
match api_key {
Some(key) => {
sqlx::query(
"UPDATE spaces
SET default_model_provider = $3, default_model_id = $4,
default_model_base_url = $5, default_model_api_key = $6
WHERE id = $1 AND user_id = $2",
)
.bind(&actor.space_id)
.bind(&actor.user_id)
.bind(provider)
.bind(model_id)
.bind(base_url)
.bind(key)
.execute(&self.pool)
.await?;
}
None => {
sqlx::query(
"UPDATE spaces
SET default_model_provider = $3, default_model_id = $4,
default_model_base_url = $5
WHERE id = $1 AND user_id = $2",
)
.bind(&actor.space_id)
.bind(&actor.user_id)
.bind(provider)
.bind(model_id)
.bind(base_url)
.execute(&self.pool)
.await?;
}
}
self.get_space(actor).await?.ok_or(sqlx::Error::RowNotFound)
}
pub async fn list_bots(
&self,
actor: &Actor,
) -> Result<Vec<(BotRow, String, ComputerRow)>, sqlx::Error> {
let bots: Vec<BotRow> = sqlx::query_as( let bots: Vec<BotRow> = sqlx::query_as(
"SELECT b.id, b.space_id, b.user_id, b.name, b.title, b.description, b.avatar_color, b.avatar_shape, b.tags, "SELECT b.id, b.space_id, b.user_id, b.name, b.title, b.description, b.avatar_color, b.avatar_shape, b.tags,
b.pinned, b.hidden, b.group_name, b.pinned, b.hidden, b.group_name,
(SELECT COUNT(*) FROM messages m JOIN threads t ON t.id=m.thread_id (SELECT COUNT(*) FROM messages m JOIN threads t ON t.id=m.thread_id
WHERE t.bot_id=b.id AND m.role='assistant' AND m.created_at>b.last_read_at) AS unread_count, WHERE t.bot_id=b.id AND t.room_id IS NULL AND m.role='assistant' AND m.created_at>b.last_read_at) AS unread_count,
(SELECT MAX(m.created_at) FROM messages m JOIN threads t ON t.id=m.thread_id WHERE t.bot_id=b.id) AS last_message_at, (SELECT MAX(m.created_at) FROM messages m JOIN threads t ON t.id=m.thread_id WHERE t.bot_id=b.id AND t.room_id IS NULL) AS last_message_at,
b.instructions, b.computer_id, b.model_provider, b.model_id, b.memory_enabled b.instructions, b.computer_id, b.model_provider, b.model_id, b.memory_enabled
FROM bots b WHERE b.space_id = $1 AND b.user_id = $2 ORDER BY b.pinned DESC, b.created_at DESC", FROM bots b WHERE b.space_id = $1 AND b.user_id = $2 ORDER BY b.pinned DESC, b.created_at DESC",
) )
@ -126,18 +197,19 @@ impl Db {
.await?; .await?;
let mut out = Vec::new(); let mut out = Vec::new();
for bot in bots { for bot in bots {
let thread_id: (String,) = let thread_id: (String,) = sqlx::query_as(
sqlx::query_as( "SELECT id FROM threads
"SELECT id FROM threads WHERE bot_id = $1 AND space_id = $2 AND user_id = $3 AND room_id IS NULL
WHERE bot_id = $1 AND space_id = $2 AND user_id = $3
ORDER BY updated_at DESC, created_at ASC LIMIT 1", ORDER BY updated_at DESC, created_at ASC LIMIT 1",
) )
.bind(&bot.id) .bind(&bot.id)
.bind(&actor.space_id) .bind(&actor.space_id)
.bind(&actor.user_id) .bind(&actor.user_id)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await?; .await?;
let computer = self.get_computer(bot.computer_id.as_deref().unwrap_or("")).await?; let computer = self
.get_computer(bot.computer_id.as_deref().unwrap_or(""))
.await?;
if let Some(computer) = computer { if let Some(computer) = computer {
out.push((bot, thread_id.0, computer)); out.push((bot, thread_id.0, computer));
} }
@ -145,13 +217,17 @@ impl Db {
Ok(out) Ok(out)
} }
pub async fn get_bot(&self, actor: &Actor, bot_id: &str) -> Result<Option<BotRow>, sqlx::Error> { pub async fn get_bot(
&self,
actor: &Actor,
bot_id: &str,
) -> Result<Option<BotRow>, sqlx::Error> {
sqlx::query_as( sqlx::query_as(
"SELECT b.id, b.space_id, b.user_id, b.name, b.title, b.description, b.avatar_color, b.avatar_shape, b.tags, "SELECT b.id, b.space_id, b.user_id, b.name, b.title, b.description, b.avatar_color, b.avatar_shape, b.tags,
b.pinned, b.hidden, b.group_name, b.pinned, b.hidden, b.group_name,
(SELECT COUNT(*) FROM messages m JOIN threads t ON t.id=m.thread_id (SELECT COUNT(*) FROM messages m JOIN threads t ON t.id=m.thread_id
WHERE t.bot_id=b.id AND m.role='assistant' AND m.created_at>b.last_read_at) AS unread_count, WHERE t.bot_id=b.id AND t.room_id IS NULL AND m.role='assistant' AND m.created_at>b.last_read_at) AS unread_count,
(SELECT MAX(m.created_at) FROM messages m JOIN threads t ON t.id=m.thread_id WHERE t.bot_id=b.id) AS last_message_at, (SELECT MAX(m.created_at) FROM messages m JOIN threads t ON t.id=m.thread_id WHERE t.bot_id=b.id AND t.room_id IS NULL) AS last_message_at,
b.instructions, b.computer_id, b.model_provider, b.model_id, b.memory_enabled b.instructions, b.computer_id, b.model_provider, b.model_id, b.memory_enabled
FROM bots b WHERE b.id = $1 AND b.space_id = $2 AND b.user_id = $3", FROM bots b WHERE b.id = $1 AND b.space_id = $2 AND b.user_id = $3",
) )
@ -162,7 +238,10 @@ impl Db {
.await .await
} }
pub async fn get_computer(&self, computer_id: &str) -> Result<Option<ComputerRow>, sqlx::Error> { pub async fn get_computer(
&self,
computer_id: &str,
) -> Result<Option<ComputerRow>, sqlx::Error> {
sqlx::query_as( sqlx::query_as(
"SELECT id, space_id, user_id, scope, scope_key, home_key, home_revision, kind, provider_ref, state, "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, control_holder, control_lease_id, control_lease_expires_at, control_bot_id, control_run_id,
@ -214,7 +293,7 @@ impl Db {
.bind(memory_enabled) .bind(memory_enabled)
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
sqlx::query("INSERT INTO threads (id, space_id, bot_id, user_id) VALUES ($1,$2,$3,$4)") sqlx::query("INSERT INTO threads (id, space_id, bot_id, user_id, title) VALUES ($1,$2,$3,$4,'新對話')")
.bind(&thread_id) .bind(&thread_id)
.bind(&actor.space_id) .bind(&actor.space_id)
.bind(&bot_id) .bind(&bot_id)
@ -246,7 +325,11 @@ impl Db {
}) })
} }
pub async fn get_screen(&self, computer_id: &str, bot_id: &str) -> Result<Option<ScreenRow>, sqlx::Error> { pub async fn get_screen(
&self,
computer_id: &str,
bot_id: &str,
) -> Result<Option<ScreenRow>, sqlx::Error> {
sqlx::query_as( sqlx::query_as(
"SELECT id, computer_id, bot_id, slot, display, view_port, profile_mode, profile_path, "SELECT id, computer_id, bot_id, slot, display, view_port, profile_mode, profile_path,
control_holder, control_lease_id, control_lease_expires_at, execution_run_id, control_holder, control_lease_id, control_lease_expires_at, execution_run_id,
@ -278,9 +361,12 @@ impl Db {
.await .await
} }
pub async fn active_run(&self, bot_id: &str) -> Result<Option<(String, String)>, sqlx::Error> { pub async fn active_run(
&self,
bot_id: &str,
) -> Result<Option<(String, String, String)>, sqlx::Error> {
sqlx::query_as( sqlx::query_as(
"SELECT id, status FROM runs "SELECT id, status, thread_id FROM runs
WHERE bot_id = $1 WHERE bot_id = $1
AND status IN ('queued','leased','running','waiting_input','waiting_takeover') AND status IN ('queued','leased','running','waiting_input','waiting_takeover')
ORDER BY created_at DESC LIMIT 1", ORDER BY created_at DESC LIMIT 1",

View File

@ -1,18 +1,25 @@
mod attachments;
mod auth; mod auth;
mod computer; mod computer;
mod db; mod db;
mod mcp;
mod mcp_catalog;
mod memory; mod memory;
mod rooms;
mod routes; mod routes;
mod runs; mod runs;
mod screen_proxy; mod screen_proxy;
mod sessions; mod sessions;
mod skills;
mod state; mod state;
mod tools; mod tools;
mod workspace;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::Arc; use std::sync::Arc;
use axum::Router; use axum::Router;
use axum::extract::DefaultBodyLimit;
use state::AppState; use state::AppState;
use tower_http::services::ServeDir; use tower_http::services::ServeDir;
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
@ -20,9 +27,8 @@ use tracing_subscriber::EnvFilter;
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
let _ = dotenvy::dotenv(); let _ = dotenvy::dotenv();
let _ = dotenvy::from_path( let _ =
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../.env"), dotenvy::from_path(std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../.env"));
);
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env().add_directive("info".parse().unwrap())) .with_env_filter(EnvFilter::from_default_env().add_directive("info".parse().unwrap()))
.init(); .init();
@ -36,10 +42,12 @@ async fn main() {
let database_url = std::env::var("DATABASE_URL") let database_url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgres://lazyboy:lazyboy@127.0.0.1:5434/lazyboy".into()); .unwrap_or_else(|_| "postgres://lazyboy:lazyboy@127.0.0.1:5434/lazyboy".into());
let state = AppState::connect(&database_url) let state = AppState::connect(&database_url).await.expect("database");
.await let actor = state.bootstrap().await.expect("bootstrap");
.expect("database"); let mcp_state = state.clone();
state.bootstrap().await.expect("bootstrap"); tokio::spawn(async move {
mcp_state.mcp.reconnect_all(mcp_state.pool(), &actor).await;
});
let worker_state = state.clone(); let worker_state = state.clone();
tokio::spawn(async move { tokio::spawn(async move {
@ -53,15 +61,21 @@ async fn main() {
let bind = std::env::var("API_BIND").unwrap_or_else(|_| "127.0.0.1:3101".into()); let bind = std::env::var("API_BIND").unwrap_or_else(|_| "127.0.0.1:3101".into());
let addr: SocketAddr = bind.parse().expect("API_BIND"); let addr: SocketAddr = bind.parse().expect("API_BIND");
if !addr.ip().is_loopback() && !state.auth.strong_enough_for_network() { if !addr.ip().is_loopback() && !state.auth.strong_enough_for_network() {
panic!("LAZYBOY_APP_TOKEN must be set to at least 32 characters when API_BIND is not loopback"); panic!(
"LAZYBOY_APP_TOKEN must be set to at least 32 characters when API_BIND is not loopback"
);
} }
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".into());
let app = Router::new() let app = Router::new()
.route("/api/health", axum::routing::get(|| async { axum::Json(serde_json::json!({"ok": true})) })) .route(
"/api/health",
axum::routing::get(|| async { axum::Json(serde_json::json!({"ok": true})) }),
)
.merge(auth::public_router(state.clone())) .merge(auth::public_router(state.clone()))
.merge(routes::router(state)) .merge(routes::router(state))
.fallback_service(ServeDir::new(web_dir)); .fallback_service(ServeDir::new(web_dir))
.layer(DefaultBodyLimit::max(24 * 1024 * 1024));
tracing::info!("api listening on {addr}"); tracing::info!("api listening on {addr}");
let listener = tokio::net::TcpListener::bind(addr).await.expect("bind"); let listener = tokio::net::TcpListener::bind(addr).await.expect("bind");

752
crates/api/src/mcp.rs Normal file
View File

@ -0,0 +1,752 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::routing::get;
use axum::{Json, Router};
use chrono::{DateTime, Utc};
use lazyboy_contracts::{McpServer, McpTool, PatchMcpServerInput, UpsertMcpServerInput};
use rig_core::completion::ToolDefinition;
use rmcp::model::{CallToolRequestParams, ClientInfo, Tool};
use rmcp::service::RunningService;
use rmcp::transport::StreamableHttpClientTransport;
use rmcp::transport::child_process::TokioChildProcess;
use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig;
use rmcp::{RoleClient, ServiceExt};
use serde_json::{Map, Value, json};
use tokio::process::Command;
use tokio::sync::Mutex;
use uuid::Uuid;
use crate::db::Actor;
use crate::state::AppState;
type ApiError = (StatusCode, Json<Value>);
type LiveClient = RunningService<RoleClient, ClientInfo>;
const CONNECT_TIMEOUT: Duration = Duration::from_secs(60);
#[derive(Clone)]
pub struct McpHub {
inner: Arc<Mutex<HashMap<String, Live>>>,
errors: Arc<Mutex<HashMap<String, String>>>,
}
struct Live {
client: LiveClient,
tools: Vec<McpTool>,
defs: Vec<ToolDefinition>,
}
impl Default for McpHub {
fn default() -> Self {
Self {
inner: Arc::new(Mutex::new(HashMap::new())),
errors: Arc::new(Mutex::new(HashMap::new())),
}
}
}
impl McpHub {
pub fn new() -> Self {
Self::default()
}
pub async fn snapshot(&self, rows: Vec<McpRow>) -> Vec<McpServer> {
let live = self.inner.lock().await;
let errors = self.errors.lock().await;
rows.into_iter()
.map(|row| {
let (status, error, tools) = if !row.enabled {
("disabled".into(), None, Vec::new())
} else if let Some(entry) = live.get(&row.id) {
("connected".into(), None, entry.tools.clone())
} else {
(
"disconnected".into(),
errors.get(&row.id).cloned().or(row.last_error.clone()),
Vec::new(),
)
};
row.into_server(status, error, tools)
})
.collect()
}
pub async fn definitions(&self) -> Vec<ToolDefinition> {
let live = self.inner.lock().await;
live.values().flat_map(|entry| entry.defs.clone()).collect()
}
pub async fn call(&self, exposed: &str, args: &Value) -> Result<String, String> {
let live = self.inner.lock().await;
for entry in live.values() {
for tool in &entry.tools {
if tool.exposed_name != exposed {
continue;
}
let arguments = match args {
Value::Object(map) => map.clone(),
Value::Null => Map::new(),
other => {
let mut map = Map::new();
map.insert("value".into(), other.clone());
map
}
};
let params =
CallToolRequestParams::new(tool.name.clone()).with_arguments(arguments);
let result = entry
.client
.call_tool(params)
.await
.map_err(|error| error.to_string())?;
return serde_json::to_string_pretty(&result).map_err(|error| error.to_string());
}
}
Err(format!("unknown MCP tool {exposed}"))
}
pub async fn disconnect(&self, id: &str) {
self.inner.lock().await.remove(id);
}
pub async fn forget(&self, id: &str) {
self.inner.lock().await.remove(id);
self.errors.lock().await.remove(id);
}
pub async fn connect_row(&self, row: &McpRow) -> Result<Vec<McpTool>, String> {
self.disconnect(&row.id).await;
if !row.enabled {
self.errors.lock().await.remove(&row.id);
return Ok(Vec::new());
}
let client = match connect_client(row).await {
Ok(client) => client,
Err(error) => {
self.errors
.lock()
.await
.insert(row.id.clone(), error.clone());
return Err(error);
}
};
let raw = match client.list_all_tools().await {
Ok(raw) => raw,
Err(error) => {
let message = error.to_string();
self.errors
.lock()
.await
.insert(row.id.clone(), message.clone());
return Err(message);
}
};
let slug = slugify(&row.name);
let tools: Vec<McpTool> = raw
.iter()
.map(|tool| McpTool {
name: tool.name.to_string(),
exposed_name: exposed_name_for(&slug, tool.name.as_ref()),
description: tool
.description
.as_deref()
.unwrap_or("")
.chars()
.take(400)
.collect(),
})
.collect();
let defs = raw
.iter()
.zip(tools.iter())
.map(|(tool, meta)| {
let description = if meta.description.is_empty() {
format!("MCP tool from {}", row.name)
} else {
format!("{} (MCP · {})", meta.description, row.name)
};
ToolDefinition {
name: meta.exposed_name.clone(),
description,
parameters: schema_value(tool),
}
})
.collect();
self.inner.lock().await.insert(
row.id.clone(),
Live {
client,
tools: tools.clone(),
defs,
},
);
self.errors.lock().await.remove(&row.id);
Ok(tools)
}
pub async fn reconnect_all(&self, pool: &sqlx::PgPool, actor: &Actor) {
let rows = match load_rows(pool, actor).await {
Ok(rows) => rows,
Err(error) => {
tracing::warn!("mcp load failed: {error}");
return;
}
};
for row in rows.into_iter().filter(|row| row.enabled) {
if let Err(error) = self.connect_row(&row).await {
tracing::warn!("mcp {} ({}) failed: {error}", row.name, row.id);
}
}
}
}
fn schema_value(tool: &Tool) -> Value {
let schema = Value::Object((*tool.input_schema).clone());
if schema.get("type").is_some() {
schema
} else {
json!({"type":"object","properties": schema.get("properties").cloned().unwrap_or(json!({})), "additionalProperties": true})
}
}
fn slugify(name: &str) -> String {
let mut slug: String = name
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() {
ch.to_ascii_lowercase()
} else {
'_'
}
})
.collect();
while slug.contains("__") {
slug = slug.replace("__", "_");
}
let slug = slug.trim_matches('_').chars().take(24).collect::<String>();
if slug.is_empty() { "mcp".into() } else { slug }
}
fn exposed_name_for(slug: &str, tool: &str) -> String {
let tool: String = tool
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
ch
} else {
'_'
}
})
.collect();
format!("mcp_{slug}_{tool}")
}
async fn connect_client(row: &McpRow) -> Result<LiveClient, String> {
match row.transport.as_str() {
"stdio" => {
let command = row
.command
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| "stdio 需要 command".to_string())?;
let mut cmd = Command::new(command);
cmd.args(&row.args);
cmd.stdin(std::process::Stdio::piped());
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
for (key, value) in &row.env {
if let Some(text) = value.as_str() {
cmd.env(key, text);
}
}
let transport = TokioChildProcess::new(cmd)
.map_err(|error| humanize_mcp_error(Some(command), &error.to_string()))?;
ClientInfo::default()
.serve(transport)
.await
.map_err(|error| humanize_mcp_error(Some(command), &error.to_string()))
}
"http" | "sse" => {
let url = row
.url
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| "HTTP MCP 需要 url".to_string())?;
let mut config = StreamableHttpClientTransportConfig::with_uri(url.to_string());
let mut custom = HashMap::new();
for (key, value) in &row.headers {
let Some(text) = value.as_str() else { continue };
if key.eq_ignore_ascii_case("authorization") {
let token = text
.strip_prefix("Bearer ")
.or_else(|| text.strip_prefix("bearer "))
.unwrap_or(text);
config = config.auth_header(token.to_string());
continue;
}
if let (Ok(name), Ok(header)) = (
http::HeaderName::from_bytes(key.as_bytes()),
http::HeaderValue::from_str(text),
) {
custom.insert(name, header);
}
}
if !custom.is_empty() {
config = config.custom_headers(custom);
}
let transport = StreamableHttpClientTransport::from_config(config);
ClientInfo::default()
.serve(transport)
.await
.map_err(|error| humanize_mcp_error(None, &error.to_string()))
}
other => Err(format!("不支援的 transport{other}")),
}
}
fn humanize_mcp_error(command: Option<&str>, error: &str) -> String {
let lower = error.to_ascii_lowercase();
if lower.contains("auth required") || lower.contains("unauthorized") || lower.contains("401") {
return "這個 MCP 需要有效金鑰才能連線。請填 token 後再試。".into();
}
if lower.contains("no such file or directory") {
return match command {
Some(cmd) => format!("找不到指令 `{cmd}`。API 容器沒有這個執行檔。"),
None => "找不到 MCP 指令。".into(),
};
}
if lower.contains("timed out") || lower.contains("timeout") {
return "連線逾時。遠端服務沒回應,或第一次下載套件太久。".into();
}
error.to_string()
}
#[derive(Clone)]
pub struct McpRow {
pub id: String,
pub name: String,
pub transport: String,
pub command: Option<String>,
pub args: Vec<String>,
pub env: Map<String, Value>,
pub url: Option<String>,
pub headers: Map<String, Value>,
pub enabled: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub last_error: Option<String>,
}
impl McpRow {
fn into_server(self, status: String, error: Option<String>, tools: Vec<McpTool>) -> McpServer {
McpServer {
id: self.id,
name: self.name,
transport: self.transport,
command: self.command,
args: self.args,
env: self.env,
url: self.url,
headers: self.headers,
enabled: self.enabled,
status,
error,
tools,
created_at: self.created_at,
updated_at: self.updated_at,
}
}
}
type RowTuple = (
String,
String,
String,
Option<String>,
Value,
Value,
Option<String>,
Value,
bool,
DateTime<Utc>,
DateTime<Utc>,
);
fn row_from(tuple: RowTuple) -> McpRow {
McpRow {
id: tuple.0,
name: tuple.1,
transport: tuple.2,
command: tuple.3,
args: value_to_strings(&tuple.4),
env: value_to_map(&tuple.5),
url: tuple.6,
headers: value_to_map(&tuple.7),
enabled: tuple.8,
created_at: tuple.9,
updated_at: tuple.10,
last_error: None,
}
}
fn value_to_strings(value: &Value) -> Vec<String> {
value
.as_array()
.map(|items| {
items
.iter()
.filter_map(|item| item.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default()
}
fn value_to_map(value: &Value) -> Map<String, Value> {
value.as_object().cloned().unwrap_or_default()
}
async fn load_rows(pool: &sqlx::PgPool, actor: &Actor) -> Result<Vec<McpRow>, sqlx::Error> {
let rows: Vec<RowTuple> = sqlx::query_as(
"SELECT id, name, transport, command, args, env, url, headers, enabled, created_at, updated_at
FROM mcp_servers WHERE space_id=$1 AND user_id=$2 ORDER BY created_at, name",
)
.bind(&actor.space_id)
.bind(&actor.user_id)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(row_from).collect())
}
async fn load_row(
pool: &sqlx::PgPool,
actor: &Actor,
id: &str,
) -> Result<Option<McpRow>, sqlx::Error> {
let row: Option<RowTuple> = sqlx::query_as(
"SELECT id, name, transport, command, args, env, url, headers, enabled, created_at, updated_at
FROM mcp_servers WHERE id=$1 AND space_id=$2 AND user_id=$3",
)
.bind(id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.fetch_optional(pool)
.await?;
Ok(row.map(row_from))
}
pub fn router() -> Router<AppState> {
Router::new()
.route("/api/mcp-servers", get(list_servers).post(create_server))
.route(
"/api/mcp-servers/{id}",
get(get_server).patch(update_server).delete(delete_server),
)
.route(
"/api/mcp-servers/{id}/reconnect",
axum::routing::post(reconnect_server),
)
.merge(crate::mcp_catalog::router())
}
fn internal(message: String) -> ApiError {
tracing::error!("mcp: {message}");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message":"internal error"})),
)
}
fn bad(message: &str) -> ApiError {
(StatusCode::BAD_REQUEST, Json(json!({"message": message})))
}
async fn rollback_server(state: &AppState, id: &str) {
state.mcp.forget(id).await;
let _ = sqlx::query("DELETE FROM mcp_servers WHERE id=$1")
.bind(id)
.execute(state.pool())
.await;
}
async fn actor(state: &AppState) -> Result<Actor, ApiError> {
state
.bootstrap()
.await
.map_err(|error| internal(error.to_string()))
}
async fn present(state: &AppState, _actor: &Actor, mut row: McpRow) -> McpServer {
let live = state.mcp.inner.lock().await;
if !row.enabled {
return row.into_server("disabled".into(), None, Vec::new());
}
if let Some(entry) = live.get(&row.id) {
return row.into_server("connected".into(), None, entry.tools.clone());
}
let error = row.last_error.take();
row.into_server("disconnected".into(), error, Vec::new())
}
async fn list_servers(State(state): State<AppState>) -> Result<Json<Vec<McpServer>>, ApiError> {
let actor = actor(&state).await?;
let rows = load_rows(state.pool(), &actor)
.await
.map_err(|error| internal(error.to_string()))?;
Ok(Json(state.mcp.snapshot(rows).await))
}
fn validate_input(input: &UpsertMcpServerInput) -> Result<(), ApiError> {
let name = input.name.trim();
if name.is_empty() {
return Err(bad("需要名稱"));
}
match input.transport.as_str() {
"stdio" => {
if input.command.as_deref().unwrap_or("").trim().is_empty() {
return Err(bad("stdio 需要 command例如 npx 或 uvx"));
}
}
"http" | "sse" => {
if input.url.as_deref().unwrap_or("").trim().is_empty() {
return Err(bad("HTTP / SSE 需要 url"));
}
}
_ => return Err(bad("transport 只能是 stdio、http 或 sse")),
}
Ok(())
}
async fn create_server(
State(state): State<AppState>,
Json(input): Json<UpsertMcpServerInput>,
) -> Result<(StatusCode, Json<McpServer>), ApiError> {
validate_input(&input)?;
let actor = actor(&state).await?;
let id = Uuid::new_v4().to_string();
let name = input.name.trim().chars().take(80).collect::<String>();
sqlx::query(
"INSERT INTO mcp_servers (id,space_id,user_id,name,transport,command,args,env,url,headers,enabled)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)",
)
.bind(&id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.bind(&name)
.bind(&input.transport)
.bind(input.command.as_deref().map(str::trim).filter(|value| !value.is_empty()))
.bind(json!(input.args))
.bind(Value::Object(input.env.clone()))
.bind(input.url.as_deref().map(str::trim).filter(|value| !value.is_empty()))
.bind(Value::Object(input.headers.clone()))
.bind(input.enabled)
.execute(state.pool())
.await
.map_err(|error| {
if error.to_string().contains("mcp_servers_space_id_user_id_name_key")
|| error.to_string().contains("duplicate key")
{
bad("已有同名 MCP")
} else {
internal(error.to_string())
}
})?;
let mut row = load_row(state.pool(), &actor, &id)
.await
.map_err(|error| internal(error.to_string()))?
.ok_or_else(|| internal("missing row".into()))?;
let mut status = if row.enabled {
"disconnected"
} else {
"disabled"
}
.to_string();
let mut tools = Vec::new();
if row.enabled {
match tokio::time::timeout(CONNECT_TIMEOUT, state.mcp.connect_row(&row)).await {
Ok(Ok(connected)) => {
status = "connected".into();
tools = connected;
}
Ok(Err(message)) => {
rollback_server(&state, &id).await;
return Err((StatusCode::BAD_GATEWAY, Json(json!({"message": message}))));
}
Err(_) => {
rollback_server(&state, &id).await;
return Err(bad("連線逾時60 秒)"));
}
}
}
Ok((
StatusCode::CREATED,
Json(row.into_server(status, None, tools)),
))
}
async fn get_server(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<McpServer>, ApiError> {
let actor = actor(&state).await?;
let row = load_row(state.pool(), &actor, &id)
.await
.map_err(|error| internal(error.to_string()))?
.ok_or((StatusCode::NOT_FOUND, Json(json!({"message":"not found"}))))?;
Ok(Json(present(&state, &actor, row).await))
}
async fn update_server(
State(state): State<AppState>,
Path(id): Path<String>,
Json(input): Json<PatchMcpServerInput>,
) -> Result<Json<McpServer>, ApiError> {
let actor = actor(&state).await?;
let current = load_row(state.pool(), &actor, &id)
.await
.map_err(|error| internal(error.to_string()))?
.ok_or((StatusCode::NOT_FOUND, Json(json!({"message":"not found"}))))?;
let name = input
.name
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(&current.name)
.chars()
.take(40)
.collect::<String>();
let transport = input.transport.unwrap_or(current.transport.clone());
let command = input
.command
.or(current.command.clone())
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let args = input.args.unwrap_or(current.args);
let env = input.env.unwrap_or(current.env);
let url = input
.url
.or(current.url)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let headers = input.headers.unwrap_or(current.headers);
let enabled = input.enabled.unwrap_or(current.enabled);
validate_input(&UpsertMcpServerInput {
name: name.clone(),
transport: transport.clone(),
command: command.clone(),
args: args.clone(),
env: env.clone(),
url: url.clone(),
headers: headers.clone(),
enabled,
})?;
if !matches!(transport.as_str(), "stdio" | "http" | "sse") {
return Err(bad("transport 只能是 stdio、http 或 sse"));
}
sqlx::query(
"UPDATE mcp_servers
SET name=$2, transport=$3, command=$4, args=$5, env=$6, url=$7, headers=$8, enabled=$9, updated_at=now()
WHERE id=$1 AND space_id=$10 AND user_id=$11",
)
.bind(&id)
.bind(&name)
.bind(&transport)
.bind(&command)
.bind(json!(args))
.bind(Value::Object(env))
.bind(&url)
.bind(Value::Object(headers))
.bind(enabled)
.bind(&actor.space_id)
.bind(&actor.user_id)
.execute(state.pool())
.await
.map_err(|error| internal(error.to_string()))?;
let mut row = load_row(state.pool(), &actor, &id)
.await
.map_err(|error| internal(error.to_string()))?
.ok_or((StatusCode::NOT_FOUND, Json(json!({"message":"not found"}))))?;
if row.enabled {
state.mcp.disconnect(&id).await;
} else {
state.mcp.forget(&id).await;
}
let mut status = if row.enabled {
"disconnected"
} else {
"disabled"
}
.to_string();
let mut error = None;
let mut tools = Vec::new();
if row.enabled {
match tokio::time::timeout(CONNECT_TIMEOUT, state.mcp.connect_row(&row)).await {
Ok(Ok(connected)) => {
status = "connected".into();
tools = connected;
}
Ok(Err(message)) => error = Some(message),
Err(_) => error = Some("連線逾時60 秒)".into()),
}
}
row.last_error = error.clone();
Ok(Json(row.into_server(status, error, tools)))
}
async fn delete_server(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<StatusCode, ApiError> {
let actor = actor(&state).await?;
state.mcp.forget(&id).await;
let deleted = sqlx::query("DELETE FROM mcp_servers 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(|error| internal(error.to_string()))?;
if deleted.rows_affected() == 0 {
return Err((StatusCode::NOT_FOUND, Json(json!({"message":"not found"}))));
}
Ok(StatusCode::NO_CONTENT)
}
async fn reconnect_server(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<McpServer>, ApiError> {
let actor = actor(&state).await?;
let mut row = load_row(state.pool(), &actor, &id)
.await
.map_err(|error| internal(error.to_string()))?
.ok_or((StatusCode::NOT_FOUND, Json(json!({"message":"not found"}))))?;
if !row.enabled {
state.mcp.disconnect(&id).await;
return Ok(Json(row.into_server("disabled".into(), None, Vec::new())));
}
match tokio::time::timeout(CONNECT_TIMEOUT, state.mcp.connect_row(&row)).await {
Ok(Ok(tools)) => Ok(Json(row.into_server("connected".into(), None, tools))),
Ok(Err(message)) => {
state.mcp.disconnect(&id).await;
row.last_error = Some(message.clone());
Ok(Json(row.into_server(
"disconnected".into(),
Some(message),
Vec::new(),
)))
}
Err(_) => {
state.mcp.disconnect(&id).await;
Ok(Json(row.into_server(
"disconnected".into(),
Some("連線逾時60 秒)".into()),
Vec::new(),
)))
}
}
}

View File

@ -0,0 +1,367 @@
use axum::extract::Query;
use axum::http::StatusCode;
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::state::AppState;
const REGISTRY_URL: &str = "https://registry.modelcontextprotocol.io/v0/servers";
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct CatalogEntry {
pub id: String,
pub title: String,
pub description: String,
pub transport: String,
pub command: Option<String>,
pub args: Vec<String>,
pub url: Option<String>,
pub env_keys: Vec<SecretField>,
pub header_keys: Vec<SecretField>,
pub source: &'static str,
pub remote: bool,
}
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct SecretField {
pub name: String,
pub required: bool,
pub secret: bool,
pub hint: String,
}
#[derive(Deserialize)]
pub struct CatalogQuery {
#[serde(default)]
pub q: String,
}
pub fn router() -> Router<AppState> {
Router::new().route("/api/mcp-catalog", axum::routing::get(list_catalog))
}
async fn list_catalog(Query(query): Query<CatalogQuery>) -> Result<Json<Value>, StatusCode> {
let q = query.q.trim().to_ascii_lowercase();
let mut items = featured();
if !q.is_empty() {
items.retain(|item| {
item.title.to_ascii_lowercase().contains(&q)
|| item.description.to_ascii_lowercase().contains(&q)
|| item.id.to_ascii_lowercase().contains(&q)
});
if let Ok(remote) = fetch_registry(&query.q).await {
for entry in remote {
if items
.iter()
.any(|item| item.id == entry.id || item.title == entry.title)
{
continue;
}
items.push(entry);
}
}
}
Ok(Json(json!({ "servers": items })))
}
fn featured() -> Vec<CatalogEntry> {
vec![
remote(
"context7",
"Context7",
"即時函式庫文件與程式範例,寫程式時查最新 API。",
"https://mcp.context7.com/mcp",
),
remote(
"deepwiki",
"DeepWiki",
"讀 GitHub 專案的 Wiki 風格說明。",
"https://mcp.deepwiki.com/mcp",
),
remote(
"cloudflare",
"Cloudflare",
"搜尋 Cloudflare 文件Workers、DNS、R2 等)。",
"https://docs.mcp.cloudflare.com/mcp",
),
remote_auth(
"linear",
"Linear",
"查詢與建立 Linear issue、專案與留言。",
"https://mcp.linear.app/mcp",
&[("Authorization", true, "Linear API key")],
),
remote_auth(
"sentry",
"Sentry",
"讀取錯誤、issue 與專案狀態。",
"https://mcp.sentry.dev/mcp",
&[("Authorization", true, "Sentry auth token")],
),
remote_auth(
"github",
"GitHub",
"搜尋 repo、issue、PR。",
"https://api.githubcopilot.com/mcp/",
&[("Authorization", true, "GitHub PATrepo 權限)")],
),
stdio(
"notion",
"Notion",
"搜尋與讀取 Notion 頁面。",
"npx",
&["-y", "@notionhq/notion-mcp-server"],
&[("NOTION_TOKEN", true, "Notion integration token")],
),
stdio(
"fetch",
"Fetch",
"抓網頁內容給 Agent 讀。",
"python3",
&["-m", "mcp_server_fetch"],
&[],
),
stdio(
"memory",
"Memory",
"給 Agent 一個可持久的知識圖譜。",
"npx",
&["-y", "@modelcontextprotocol/server-memory"],
&[],
),
stdio(
"sequential-thinking",
"Sequential Thinking",
"把推理拆成步驟,適合複雜規劃。",
"npx",
&["-y", "@modelcontextprotocol/server-sequential-thinking"],
&[],
),
]
}
fn remote(id: &str, title: &str, description: &str, url: &str) -> CatalogEntry {
CatalogEntry {
id: id.into(),
title: title.into(),
description: description.into(),
transport: "http".into(),
command: None,
args: Vec::new(),
url: Some(url.into()),
env_keys: Vec::new(),
header_keys: Vec::new(),
source: "featured",
remote: true,
}
}
fn remote_auth(
id: &str,
title: &str,
description: &str,
url: &str,
headers: &[(&str, bool, &str)],
) -> CatalogEntry {
let mut entry = remote(id, title, description, url);
entry.header_keys = headers
.iter()
.map(|(name, required, hint)| SecretField {
name: (*name).into(),
required: *required,
secret: true,
hint: (*hint).into(),
})
.collect();
entry
}
fn stdio(
id: &str,
title: &str,
description: &str,
command: &str,
args: &[&str],
env: &[(&str, bool, &str)],
) -> CatalogEntry {
CatalogEntry {
id: id.into(),
title: title.into(),
description: description.into(),
transport: "stdio".into(),
command: Some(command.into()),
args: args.iter().map(|value| (*value).to_string()).collect(),
url: None,
env_keys: env
.iter()
.map(|(name, required, hint)| SecretField {
name: (*name).into(),
required: *required,
secret: true,
hint: (*hint).into(),
})
.collect(),
header_keys: Vec::new(),
source: "featured",
remote: false,
}
}
async fn fetch_registry(search: &str) -> Result<Vec<CatalogEntry>, String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(8))
.build()
.map_err(|error| error.to_string())?;
let response = client
.get(REGISTRY_URL)
.query(&[("search", search), ("limit", "24")])
.send()
.await
.map_err(|error| error.to_string())?;
if !response.status().is_success() {
return Err(format!("registry {}", response.status()));
}
let body: Value = response.json().await.map_err(|error| error.to_string())?;
let servers = body
.get("servers")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
Ok(servers.iter().filter_map(parse_registry_item).collect())
}
fn parse_registry_item(item: &Value) -> Option<CatalogEntry> {
let server = item.get("server").unwrap_or(item);
let id = server.get("name")?.as_str()?.to_string();
let title = server
.get("title")
.and_then(Value::as_str)
.unwrap_or_else(|| id.rsplit('/').next().unwrap_or(&id))
.to_string();
let description = server
.get("description")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
if let Some(remote) = server
.get("remotes")
.and_then(Value::as_array)
.and_then(|items| {
items
.iter()
.find(|item| item.get("url").and_then(Value::as_str).is_some())
})
{
let url = remote.get("url")?.as_str()?.to_string();
let transport = match remote
.get("type")
.and_then(Value::as_str)
.unwrap_or("streamable-http")
{
"sse" => "sse",
_ => "http",
};
let header_keys = remote
.get("headers")
.and_then(Value::as_array)
.map(|headers| {
headers
.iter()
.filter_map(|header| {
Some(SecretField {
name: header.get("name")?.as_str()?.to_string(),
required: header
.get("isRequired")
.and_then(Value::as_bool)
.unwrap_or(true),
secret: header
.get("isSecret")
.and_then(Value::as_bool)
.unwrap_or(true),
hint: header
.get("description")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
})
})
.collect()
})
.unwrap_or_default();
return Some(CatalogEntry {
id,
title,
description,
transport: transport.into(),
command: None,
args: Vec::new(),
url: Some(url),
env_keys: Vec::new(),
header_keys,
source: "registry",
remote: true,
});
}
let package = server.get("packages").and_then(Value::as_array)?.first()?;
let identifier = package.get("identifier")?.as_str()?;
let registry_type = package
.get("registryType")
.and_then(Value::as_str)
.unwrap_or("npm");
let (command, args) = match registry_type {
"npm" => ("npx".into(), vec!["-y".into(), identifier.into()]),
"pypi" => ("uvx".into(), vec![identifier.into()]),
_ => return None,
};
let extra = package
.get("runtimeArguments")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|arg| arg.get("value").and_then(Value::as_str).map(str::to_string));
let mut args = args;
args.extend(extra);
let env_keys = package
.get("environmentVariables")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(|item| {
Some(SecretField {
name: item.get("name")?.as_str()?.to_string(),
required: item
.get("isRequired")
.and_then(Value::as_bool)
.unwrap_or(false),
secret: item
.get("isSecret")
.and_then(Value::as_bool)
.unwrap_or(true),
hint: item
.get("description")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
})
})
.collect()
})
.unwrap_or_default();
Some(CatalogEntry {
id,
title,
description,
transport: "stdio".into(),
command: Some(command),
args,
url: None,
env_keys,
header_keys: Vec::new(),
source: "registry",
remote: false,
})
}

View File

@ -8,7 +8,7 @@ use axum::{Json, Router};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use fastembed::{EmbeddingModel, TextEmbedding, TextInitOptions}; use fastembed::{EmbeddingModel, TextEmbedding, TextInitOptions};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{Value, json};
use sqlx::{FromRow, PgPool}; use sqlx::{FromRow, PgPool};
use uuid::Uuid; use uuid::Uuid;
@ -94,7 +94,9 @@ impl MemoryService {
let model = self.model.clone(); let model = self.model.clone();
let cache_dir = self.cache_dir.clone(); let cache_dir = self.cache_dir.clone();
match tokio::task::spawn_blocking(move || { match tokio::task::spawn_blocking(move || {
let mut state = model.lock().map_err(|_| "embedding model lock poisoned".to_string())?; let mut state = model
.lock()
.map_err(|_| "embedding model lock poisoned".to_string())?;
if matches!(*state, ModelState::Uninitialized) { if matches!(*state, ModelState::Uninitialized) {
let options = TextInitOptions::new(EmbeddingModel::AllMiniLML6V2) let options = TextInitOptions::new(EmbeddingModel::AllMiniLML6V2)
.with_cache_dir(cache_dir) .with_cache_dir(cache_dir)
@ -113,7 +115,9 @@ impl MemoryService {
let mut values = embedding let mut values = embedding
.embed(vec![text], None) .embed(vec![text], None)
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
let value = values.pop().ok_or_else(|| "FastEmbed returned no vector".to_string())?; let value = values
.pop()
.ok_or_else(|| "FastEmbed returned no vector".to_string())?;
if value.len() != EMBEDDING_DIMENSION { if value.len() != EMBEDDING_DIMENSION {
return Err(format!( return Err(format!(
"embedding dimension {} does not match schema {}", "embedding dimension {} does not match schema {}",
@ -262,7 +266,11 @@ impl MemoryService {
) -> Result<Option<MemoryItem>, String> { ) -> Result<Option<MemoryItem>, String> {
let content = validate_content(&input.content)?; let content = validate_content(&input.content)?;
validate_importance(input.importance)?; validate_importance(input.importance)?;
let vector = self.embed(content.clone()).await.as_ref().map(vector_literal); let vector = self
.embed(content.clone())
.await
.as_ref()
.map(vector_literal);
let mut tx = pool.begin().await.map_err(|error| error.to_string())?; let mut tx = pool.begin().await.map_err(|error| error.to_string())?;
let item: Option<MemoryItem> = sqlx::query_as( let item: Option<MemoryItem> = sqlx::query_as(
"UPDATE memory_items SET content=$1,importance=$2,embedding=$3::vector, "UPDATE memory_items SET content=$1,importance=$2,embedding=$3::vector,
@ -316,12 +324,7 @@ impl MemoryService {
Ok(item.is_some()) Ok(item.is_some())
} }
pub async fn clear( pub async fn clear(&self, pool: &PgPool, actor: &Actor, bot_id: &str) -> Result<u64, String> {
&self,
pool: &PgPool,
actor: &Actor,
bot_id: &str,
) -> Result<u64, String> {
let ids: Vec<Uuid> = sqlx::query_scalar( let ids: Vec<Uuid> = sqlx::query_scalar(
"SELECT id FROM memory_items "SELECT id FROM memory_items
WHERE bot_id=$1 AND space_id=$2 AND user_id=$3 AND deleted_at IS NULL", WHERE bot_id=$1 AND space_id=$2 AND user_id=$3 AND deleted_at IS NULL",
@ -377,7 +380,11 @@ async fn insert_revision(
fn vector_literal(vector: &Vec<f32>) -> String { fn vector_literal(vector: &Vec<f32>) -> String {
format!( format!(
"[{}]", "[{}]",
vector.iter().map(f32::to_string).collect::<Vec<_>>().join(",") vector
.iter()
.map(f32::to_string)
.collect::<Vec<_>>()
.join(",")
) )
} }
@ -395,7 +402,9 @@ pub fn validate_content(value: &str) -> Result<String, String> {
return Err("memory content must contain 1 to 16000 bytes".into()); return Err("memory content must contain 1 to 16000 bytes".into());
} }
if looks_like_secret(content) { if looks_like_secret(content) {
return Err("refusing to store a password, token, private key, or other obvious secret".into()); return Err(
"refusing to store a password, token, private key, or other obvious secret".into(),
);
} }
Ok(content.to_string()) Ok(content.to_string())
} }
@ -403,9 +412,19 @@ pub fn validate_content(value: &str) -> Result<String, String> {
pub fn looks_like_secret(value: &str) -> bool { pub fn looks_like_secret(value: &str) -> bool {
let lower = value.to_ascii_lowercase(); let lower = value.to_ascii_lowercase();
let labels = [ let labels = [
"password=", "password:", "passwd=", "passwd:", "api_key=", "api key:", "password=",
"apikey=", "access_token=", "access token:", "refresh_token=", "bearer ", "password:",
"client_secret=", "client secret:", "passwd=",
"passwd:",
"api_key=",
"api key:",
"apikey=",
"access_token=",
"access token:",
"refresh_token=",
"bearer ",
"client_secret=",
"client secret:",
]; ];
labels.iter().any(|label| lower.contains(label)) labels.iter().any(|label| lower.contains(label))
|| lower.contains("-----begin private key-----") || lower.contains("-----begin private key-----")
@ -441,60 +460,126 @@ pub fn memory_block(items: &[MemoryItem], budget: usize) -> String {
fn env_bool(name: &str, default: bool) -> bool { fn env_bool(name: &str, default: bool) -> bool {
std::env::var(name) std::env::var(name)
.ok() .ok()
.map(|value| !matches!(value.to_ascii_lowercase().as_str(), "0" | "false" | "off" | "no")) .map(|value| {
!matches!(
value.to_ascii_lowercase().as_str(),
"0" | "false" | "off" | "no"
)
})
.unwrap_or(default) .unwrap_or(default)
} }
fn env_usize(name: &str, default: usize) -> usize { fn env_usize(name: &str, default: usize) -> usize {
std::env::var(name).ok().and_then(|value| value.parse().ok()).unwrap_or(default) std::env::var(name)
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(default)
} }
pub fn router() -> Router<AppState> { pub fn router() -> Router<AppState> {
Router::new() Router::new()
.route("/api/bots/{bot_id}/memories", get(list_memories).post(create_memory).delete(clear_memories)) .route(
.route("/api/bots/{bot_id}/memories/{memory_id}", delete(delete_memory).patch(update_memory)) "/api/bots/{bot_id}/memories",
get(list_memories)
.post(create_memory)
.delete(clear_memories),
)
.route(
"/api/bots/{bot_id}/memories/{memory_id}",
delete(delete_memory).patch(update_memory),
)
} }
async fn scoped_actor(state: &AppState, bot_id: &str) -> Result<Actor, StatusCode> { async fn scoped_actor(state: &AppState, bot_id: &str) -> Result<Actor, StatusCode> {
let actor = state.bootstrap().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let actor = state
state.db.get_bot(&actor, bot_id).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .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_or(StatusCode::NOT_FOUND)?;
Ok(actor) Ok(actor)
} }
async fn list_memories(State(state): State<AppState>, Path(bot_id): Path<String>) -> Result<Json<Vec<MemoryItem>>, StatusCode> { async fn list_memories(
State(state): State<AppState>,
Path(bot_id): Path<String>,
) -> Result<Json<Vec<MemoryItem>>, StatusCode> {
let actor = scoped_actor(&state, &bot_id).await?; let actor = scoped_actor(&state, &bot_id).await?;
state.memory.list(state.pool(), &actor, &bot_id).await.map(Json) state
.memory
.list(state.pool(), &actor, &bot_id)
.await
.map(Json)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
} }
async fn create_memory(State(state): State<AppState>, Path(bot_id): Path<String>, Json(input): Json<CreateMemoryInput>) -> Result<Json<MemoryItem>, (StatusCode, Json<Value>)> { async fn create_memory(
State(state): State<AppState>,
Path(bot_id): Path<String>,
Json(input): Json<CreateMemoryInput>,
) -> Result<Json<MemoryItem>, (StatusCode, Json<Value>)> {
let actor = scoped_actor(&state, &bot_id).await.map_err(api_status)?; let actor = scoped_actor(&state, &bot_id).await.map_err(api_status)?;
state.memory.remember(state.pool(), &actor, &bot_id, input).await.map(Json).map_err(api_error) state
.memory
.remember(state.pool(), &actor, &bot_id, input)
.await
.map(Json)
.map_err(api_error)
} }
async fn update_memory(State(state): State<AppState>, Path((bot_id, memory_id)): Path<(String, Uuid)>, Json(input): Json<UpdateMemoryInput>) -> Result<Json<MemoryItem>, (StatusCode, Json<Value>)> { async fn update_memory(
State(state): State<AppState>,
Path((bot_id, memory_id)): Path<(String, Uuid)>,
Json(input): Json<UpdateMemoryInput>,
) -> Result<Json<MemoryItem>, (StatusCode, Json<Value>)> {
let actor = scoped_actor(&state, &bot_id).await.map_err(api_status)?; let actor = scoped_actor(&state, &bot_id).await.map_err(api_status)?;
state.memory.update(state.pool(), &actor, &bot_id, memory_id, input).await state
.map_err(api_error)?.map(Json).ok_or_else(|| api_status(StatusCode::NOT_FOUND)) .memory
.update(state.pool(), &actor, &bot_id, memory_id, input)
.await
.map_err(api_error)?
.map(Json)
.ok_or_else(|| api_status(StatusCode::NOT_FOUND))
} }
async fn delete_memory(State(state): State<AppState>, Path((bot_id, memory_id)): Path<(String, Uuid)>) -> Result<Json<Value>, (StatusCode, Json<Value>)> { async fn delete_memory(
State(state): State<AppState>,
Path((bot_id, memory_id)): Path<(String, Uuid)>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let actor = scoped_actor(&state, &bot_id).await.map_err(api_status)?; let actor = scoped_actor(&state, &bot_id).await.map_err(api_status)?;
if !state.memory.forget(state.pool(), &actor, &bot_id, memory_id).await.map_err(api_error)? { if !state
.memory
.forget(state.pool(), &actor, &bot_id, memory_id)
.await
.map_err(api_error)?
{
return Err(api_status(StatusCode::NOT_FOUND)); return Err(api_status(StatusCode::NOT_FOUND));
} }
Ok(Json(json!({"ok":true}))) Ok(Json(json!({"ok":true})))
} }
async fn clear_memories(State(state): State<AppState>, Path(bot_id): Path<String>) -> Result<Json<Value>, (StatusCode, Json<Value>)> { async fn clear_memories(
State(state): State<AppState>,
Path(bot_id): Path<String>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let actor = scoped_actor(&state, &bot_id).await.map_err(api_status)?; let actor = scoped_actor(&state, &bot_id).await.map_err(api_status)?;
let deleted = state.memory.clear(state.pool(), &actor, &bot_id).await.map_err(api_error)?; let deleted = state
.memory
.clear(state.pool(), &actor, &bot_id)
.await
.map_err(api_error)?;
Ok(Json(json!({"ok":true,"deleted":deleted}))) Ok(Json(json!({"ok":true,"deleted":deleted})))
} }
fn api_status(status: StatusCode) -> (StatusCode, Json<Value>) { fn api_status(status: StatusCode) -> (StatusCode, Json<Value>) {
(status, Json(json!({"message":status.canonical_reason().unwrap_or("request failed")}))) (
status,
Json(json!({"message":status.canonical_reason().unwrap_or("request failed")})),
)
} }
fn api_error(error: String) -> (StatusCode, Json<Value>) { fn api_error(error: String) -> (StatusCode, Json<Value>) {
@ -504,7 +589,7 @@ fn api_error(error: String) -> (StatusCode, Json<Value>) {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{looks_like_secret, memory_block, MemoryItem, MemoryService, ModelState}; use super::{MemoryItem, MemoryService, ModelState, looks_like_secret, memory_block};
use crate::db::Actor; use crate::db::Actor;
use chrono::Utc; use chrono::Utc;
use std::path::PathBuf; use std::path::PathBuf;
@ -513,23 +598,35 @@ mod tests {
fn item(content: &str) -> MemoryItem { fn item(content: &str) -> MemoryItem {
MemoryItem { MemoryItem {
id: Uuid::new_v4(), session_id: None, source_run_id: None, source_message_id: None, id: Uuid::new_v4(),
content: content.into(), importance: 0.5, revision: 1, session_id: None,
created_at: Utc::now(), updated_at: Utc::now(), deleted_at: None, source_run_id: None,
source_message_id: None,
content: content.into(),
importance: 0.5,
revision: 1,
created_at: Utc::now(),
updated_at: Utc::now(),
deleted_at: None,
} }
} }
#[test] #[test]
fn secret_guard_rejects_obvious_credentials_without_blocking_normal_preferences() { fn secret_guard_rejects_obvious_credentials_without_blocking_normal_preferences() {
assert!(looks_like_secret("password=hunter2")); assert!(looks_like_secret("password=hunter2"));
assert!(looks_like_secret("Authorization: Bearer abcdefghijklmnopqrstuvwxyz")); assert!(looks_like_secret(
"Authorization: Bearer abcdefghijklmnopqrstuvwxyz"
));
assert!(looks_like_secret("-----BEGIN PRIVATE KEY-----")); assert!(looks_like_secret("-----BEGIN PRIVATE KEY-----"));
assert!(!looks_like_secret("I prefer passkeys instead of passwords")); assert!(!looks_like_secret("I prefer passkeys instead of passwords"));
} }
#[test] #[test]
fn durable_block_is_bounded_and_marks_memory_as_data() { fn durable_block_is_bounded_and_marks_memory_as_data() {
let block = memory_block(&[item("prefers concise replies"), item(&"x".repeat(1000))], 220); let block = memory_block(
&[item("prefers concise replies"), item(&"x".repeat(1000))],
220,
);
assert!(block.len() <= 220); assert!(block.len() <= 220);
assert!(block.contains("DATA ONLY")); assert!(block.contains("DATA ONLY"));
assert!(block.contains("prefers concise replies")); assert!(block.contains("prefers concise replies"));
@ -539,31 +636,62 @@ mod tests {
#[sqlx::test(migrations = "../../migrations")] #[sqlx::test(migrations = "../../migrations")]
async fn database_enforces_agent_scope_and_queries_do_not_leak(pool: sqlx::PgPool) { async fn database_enforces_agent_scope_and_queries_do_not_leak(pool: sqlx::PgPool) {
sqlx::query("INSERT INTO users(id,name) VALUES ('u','test')") sqlx::query("INSERT INTO users(id,name) VALUES ('u','test')")
.execute(&pool).await.unwrap(); .execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO spaces(id,user_id,name) VALUES ('s','u','test')") sqlx::query("INSERT INTO spaces(id,user_id,name) VALUES ('s','u','test')")
.execute(&pool).await.unwrap(); .execute(&pool)
.await
.unwrap();
for bot in ["a", "b"] { for bot in ["a", "b"] {
sqlx::query("INSERT INTO bots(id,space_id,user_id,name) VALUES ($1,'s','u',$1)") sqlx::query("INSERT INTO bots(id,space_id,user_id,name) VALUES ($1,'s','u',$1)")
.bind(bot).execute(&pool).await.unwrap(); .bind(bot)
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO threads(id,space_id,user_id,bot_id) VALUES ($1,'s','u',$2)") sqlx::query("INSERT INTO threads(id,space_id,user_id,bot_id) VALUES ($1,'s','u',$2)")
.bind(format!("thread-{bot}")).bind(bot).execute(&pool).await.unwrap(); .bind(format!("thread-{bot}"))
.bind(bot)
.execute(&pool)
.await
.unwrap();
} }
let cross_scope = sqlx::query( let cross_scope = sqlx::query(
"INSERT INTO memory_items(id,space_id,user_id,bot_id,session_id,content) "INSERT INTO memory_items(id,space_id,user_id,bot_id,session_id,content)
VALUES ($1,'s','u','a','thread-b','must fail')", VALUES ($1,'s','u','a','thread-b','must fail')",
).bind(Uuid::new_v4()).execute(&pool).await; )
.bind(Uuid::new_v4())
.execute(&pool)
.await;
assert!(cross_scope.is_err()); assert!(cross_scope.is_err());
sqlx::query( sqlx::query(
"INSERT INTO memory_items(id,space_id,user_id,bot_id,session_id,content) "INSERT INTO memory_items(id,space_id,user_id,bot_id,session_id,content)
VALUES ($1,'s','u','a','thread-a','only a'),($2,'s','u','b','thread-b','only b')", VALUES ($1,'s','u','a','thread-a','only a'),($2,'s','u','b','thread-b','only b')",
).bind(Uuid::new_v4()).bind(Uuid::new_v4()).execute(&pool).await.unwrap(); )
.bind(Uuid::new_v4())
.bind(Uuid::new_v4())
.execute(&pool)
.await
.unwrap();
let service = MemoryService { let service = MemoryService {
enabled: false, top_k: 8, byte_budget: 6000, cache_dir: PathBuf::new(), enabled: false,
top_k: 8,
byte_budget: 6000,
cache_dir: PathBuf::new(),
model: Arc::new(Mutex::new(ModelState::Unavailable)), model: Arc::new(Mutex::new(ModelState::Unavailable)),
}; };
let rows = service.list(&pool, &Actor { user_id: "u".into(), space_id: "s".into() }, "a") let rows = service
.await.unwrap(); .list(
&pool,
&Actor {
user_id: "u".into(),
space_id: "s".into(),
},
"a",
)
.await
.unwrap();
assert_eq!(rows.len(), 1); assert_eq!(rows.len(), 1);
assert_eq!(rows[0].content, "only a"); assert_eq!(rows[0].content, "only a");
} }

357
crates/api/src/rooms.rs Normal file
View File

@ -0,0 +1,357 @@
use std::collections::HashSet;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::routing::get;
use axum::{Json, Router};
use lazyboy_contracts::{
CreateRoomInput, CreateSessionInput, Room, RoomMember, RoomStatus, Session,
};
use serde_json::{Value, json};
use uuid::Uuid;
use crate::db::Actor;
use crate::sessions::{normalized_title, session_from_row};
use crate::state::AppState;
type ApiError = (StatusCode, Json<Value>);
pub fn router() -> Router<AppState> {
Router::new()
.route("/api/rooms", get(list_rooms).post(create_room))
.route("/api/rooms/{id}", get(get_room).delete(delete_room))
.route(
"/api/rooms/{id}/sessions",
get(list_sessions).post(create_session),
)
.route("/api/rooms/{id}/status", get(room_status))
}
async fn actor(state: &AppState) -> Result<Actor, ApiError> {
state
.bootstrap()
.await
.map_err(|error| internal(error.to_string()))
}
fn internal(message: String) -> ApiError {
tracing::error!("rooms: {message}");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message":"internal error"})),
)
}
fn member_from_row(row: (String, String, String, String)) -> RoomMember {
RoomMember {
id: row.0,
name: row.1,
avatar_color: row.2,
avatar_shape: row.3,
}
}
async fn members_for(state: &AppState, room_id: &str) -> Result<Vec<RoomMember>, sqlx::Error> {
let rows: Vec<(String, String, String, String)> = sqlx::query_as(
"SELECT b.id, b.name, b.avatar_color, b.avatar_shape
FROM room_members m JOIN bots b ON b.id=m.bot_id
WHERE m.room_id=$1
ORDER BY m.created_at, b.name",
)
.bind(room_id)
.fetch_all(state.pool())
.await?;
Ok(rows.into_iter().map(member_from_row).collect())
}
async fn room_from_id(
state: &AppState,
actor: &Actor,
id: &str,
) -> Result<Option<Room>, sqlx::Error> {
let row: Option<(String, String, Option<chrono::DateTime<chrono::Utc>>, Option<String>, i64)> =
sqlx::query_as(
"SELECT r.id, r.name,
(SELECT MAX(m.created_at) FROM messages m JOIN threads t ON t.id=m.thread_id WHERE t.room_id=r.id),
(SELECT m.body FROM messages m JOIN threads t ON t.id=m.thread_id
WHERE t.room_id=r.id ORDER BY m.created_at DESC, m.seq DESC LIMIT 1),
(SELECT COUNT(*) FROM messages m JOIN threads t ON t.id=m.thread_id
WHERE t.room_id=r.id AND m.role='assistant' AND m.created_at > COALESCE(
(SELECT MIN(b.last_read_at) FROM room_members rm JOIN bots b ON b.id=rm.bot_id WHERE rm.room_id=r.id),
now()
))
FROM rooms r
WHERE r.id=$1 AND r.space_id=$2 AND r.user_id=$3",
)
.bind(id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.fetch_optional(state.pool())
.await?;
let Some((id, name, last_message_at, last_preview, unread_count)) = row else {
return Ok(None);
};
Ok(Some(Room {
members: members_for(state, &id).await?,
id,
name,
last_message_at,
last_preview,
unread_count,
}))
}
async fn list_rooms(State(state): State<AppState>) -> Result<Json<Vec<Room>>, ApiError> {
let actor = actor(&state).await?;
let ids: Vec<String> = sqlx::query_scalar(
"SELECT id FROM rooms WHERE space_id=$1 AND user_id=$2 ORDER BY updated_at DESC, created_at DESC",
)
.bind(&actor.space_id)
.bind(&actor.user_id)
.fetch_all(state.pool())
.await
.map_err(|error| internal(error.to_string()))?;
let mut rooms = Vec::new();
for id in ids {
if let Some(room) = room_from_id(&state, &actor, &id)
.await
.map_err(|error| internal(error.to_string()))?
{
rooms.push(room);
}
}
Ok(Json(rooms))
}
async fn create_room(
State(state): State<AppState>,
Json(input): Json<CreateRoomInput>,
) -> Result<(StatusCode, Json<Room>), ApiError> {
let actor = actor(&state).await?;
let name = input.name.trim();
let mut seen = HashSet::new();
let member_ids: Vec<String> = input
.member_ids
.into_iter()
.filter(|id| seen.insert(id.clone()))
.collect();
if name.is_empty() || member_ids.len() < 2 {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({"message":"群組需要名稱,並至少兩位機器人"})),
));
}
let mut members = Vec::new();
for bot_id in &member_ids {
let exists: Option<(String, String, String, String)> = sqlx::query_as(
"SELECT id, name, avatar_color, avatar_shape FROM bots
WHERE id=$1 AND space_id=$2 AND user_id=$3",
)
.bind(bot_id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.fetch_optional(state.pool())
.await
.map_err(|error| internal(error.to_string()))?;
let Some(row) = exists else {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({"message":"找不到機器人"})),
));
};
members.push(member_from_row(row));
}
let room_id = Uuid::new_v4().to_string();
let host_id = members[0].id.clone();
let mut tx = state
.pool()
.begin()
.await
.map_err(|error| internal(error.to_string()))?;
sqlx::query("INSERT INTO rooms (id,space_id,user_id,name) VALUES ($1,$2,$3,$4)")
.bind(&room_id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.bind(name)
.execute(&mut *tx)
.await
.map_err(|error| internal(error.to_string()))?;
for member in &members {
sqlx::query("INSERT INTO room_members (room_id,bot_id) VALUES ($1,$2)")
.bind(&room_id)
.bind(&member.id)
.execute(&mut *tx)
.await
.map_err(|error| internal(error.to_string()))?;
}
sqlx::query(
"INSERT INTO threads (id,space_id,bot_id,user_id,title,room_id)
VALUES ($1,$2,$3,$4,'',$5)",
)
.bind(Uuid::new_v4().to_string())
.bind(&actor.space_id)
.bind(&host_id)
.bind(&actor.user_id)
.bind(&room_id)
.execute(&mut *tx)
.await
.map_err(|error| internal(error.to_string()))?;
tx.commit()
.await
.map_err(|error| internal(error.to_string()))?;
Ok((
StatusCode::CREATED,
Json(Room {
id: room_id,
name: name.into(),
members,
last_message_at: None,
last_preview: None,
unread_count: 0,
}),
))
}
async fn get_room(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Room>, ApiError> {
let actor = actor(&state).await?;
room_from_id(&state, &actor, &id)
.await
.map_err(|error| internal(error.to_string()))?
.map(Json)
.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(json!({"message":"group not found"})),
)
})
}
async fn delete_room(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<StatusCode, ApiError> {
let actor = actor(&state).await?;
let deleted = sqlx::query("DELETE FROM rooms 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(|error| internal(error.to_string()))?;
if deleted.rows_affected() == 0 {
return Err((
StatusCode::NOT_FOUND,
Json(json!({"message":"group not found"})),
));
}
Ok(StatusCode::NO_CONTENT)
}
async fn list_sessions(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Vec<Session>>, ApiError> {
let actor = actor(&state).await?;
if room_from_id(&state, &actor, &id)
.await
.map_err(|error| internal(error.to_string()))?
.is_none()
{
return Err((
StatusCode::NOT_FOUND,
Json(json!({"message":"group not found"})),
));
}
let rows = sqlx::query_as(
"SELECT id, bot_id, title, status, created_at, updated_at, next_message_seq,
history_summary, history_summary_seq
FROM threads
WHERE room_id=$1 AND space_id=$2 AND user_id=$3 AND status='active'
ORDER BY updated_at DESC, created_at DESC",
)
.bind(id)
.bind(actor.space_id)
.bind(actor.user_id)
.fetch_all(state.pool())
.await
.map_err(|error| internal(error.to_string()))?;
Ok(Json(rows.into_iter().map(session_from_row).collect()))
}
async fn create_session(
State(state): State<AppState>,
Path(id): Path<String>,
Json(input): Json<CreateSessionInput>,
) -> Result<(StatusCode, Json<Session>), ApiError> {
let actor = actor(&state).await?;
let Some(room) = room_from_id(&state, &actor, &id)
.await
.map_err(|error| internal(error.to_string()))?
else {
return Err((
StatusCode::NOT_FOUND,
Json(json!({"message":"group not found"})),
));
};
let host = room.members.first().ok_or_else(|| {
(
StatusCode::BAD_REQUEST,
Json(json!({"message":"群組沒有成員"})),
)
})?;
let title = normalized_title(&input.title);
let row = sqlx::query_as(
"INSERT INTO threads (id, space_id, bot_id, user_id, title, room_id)
VALUES ($1,$2,$3,$4,$5,$6)
RETURNING id, bot_id, title, status, created_at, updated_at, next_message_seq,
history_summary, history_summary_seq",
)
.bind(Uuid::new_v4().to_string())
.bind(&actor.space_id)
.bind(&host.id)
.bind(&actor.user_id)
.bind(title)
.bind(&id)
.fetch_one(state.pool())
.await
.map_err(|error| internal(error.to_string()))?;
Ok((StatusCode::CREATED, Json(session_from_row(row))))
}
async fn room_status(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<RoomStatus>, ApiError> {
let actor = actor(&state).await?;
if room_from_id(&state, &actor, &id)
.await
.map_err(|error| internal(error.to_string()))?
.is_none()
{
return Err((
StatusCode::NOT_FOUND,
Json(json!({"message":"group not found"})),
));
}
let rows: Vec<(String, String, String, String)> = sqlx::query_as(
"SELECT DISTINCT b.id, b.name, b.avatar_color, b.avatar_shape
FROM runs r
JOIN bots b ON b.id=r.bot_id
JOIN threads t ON t.id=r.thread_id
WHERE t.room_id=$1 AND t.space_id=$2 AND t.user_id=$3
AND r.status IN ('queued','leased','running','waiting_input','waiting_takeover')
ORDER BY b.name",
)
.bind(&id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.fetch_all(state.pool())
.await
.map_err(|error| internal(error.to_string()))?;
Ok(Json(RoomStatus {
busy: rows.into_iter().map(member_from_row).collect(),
}))
}

View File

@ -5,22 +5,32 @@ use axum::routing::{any, delete, get, post};
use axum::{Json, Router}; use axum::{Json, Router};
use lazyboy_contracts::{Bot, ComputerMode, CreateBotInput, UpdateBotInput}; use lazyboy_contracts::{Bot, ComputerMode, CreateBotInput, UpdateBotInput};
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{Value, json};
use crate::computer; use crate::computer;
use crate::db::{parse_mode, Actor}; use crate::db::{Actor, parse_mode};
use crate::state::AppState; use crate::state::AppState;
pub fn router(state: AppState) -> Router { pub fn router(state: AppState) -> Router {
Router::new() Router::new()
.merge(crate::sessions::router()) .merge(crate::sessions::router())
.merge(crate::memory::router()) .merge(crate::memory::router())
.merge(crate::rooms::router())
.merge(crate::mcp::router())
.merge(crate::workspace::router())
.merge(crate::skills::router())
.route("/api/bots", get(list_bots).post(create_bot)) .route("/api/bots", get(list_bots).post(create_bot))
.route("/api/bots/{id}", get(get_bot).patch(update_bot).delete(delete_bot)) .route(
"/api/bots/{id}",
get(get_bot).patch(update_bot).delete(delete_bot),
)
.route("/api/bots/{id}/stop", post(stop_task)) .route("/api/bots/{id}/stop", post(stop_task))
.route("/api/bots/{id}/inbox", post(update_inbox)) .route("/api/bots/{id}/inbox", post(update_inbox))
.route("/api/environments/{id}", delete(delete_environment)) .route("/api/environments/{id}", delete(delete_environment))
.route("/api/bots/{id}/messages", get(list_messages).post(send_message)) .route(
"/api/bots/{id}/messages",
get(list_messages).post(send_message),
)
.route("/api/computer/{id}/status", get(computer_status)) .route("/api/computer/{id}/status", get(computer_status))
.route("/api/computer/{id}/boot", post(boot)) .route("/api/computer/{id}/boot", post(boot))
.route("/api/computer/{id}/restart", post(restart)) .route("/api/computer/{id}/restart", post(restart))
@ -40,12 +50,19 @@ pub fn router(state: AppState) -> Router {
} }
async fn actor(state: &AppState) -> Result<Actor, StatusCode> { async fn actor(state: &AppState) -> Result<Actor, StatusCode> {
state.bootstrap().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) state
.bootstrap()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
} }
async fn list_bots(State(state): State<AppState>) -> Result<Json<Vec<Bot>>, StatusCode> { async fn list_bots(State(state): State<AppState>) -> Result<Json<Vec<Bot>>, StatusCode> {
let actor = actor(&state).await?; let actor = actor(&state).await?;
let rows = state.db.list_bots(&actor).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let rows = state
.db
.list_bots(&actor)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json( Ok(Json(
rows.into_iter() rows.into_iter()
.map(|(bot, thread_id, computer)| Bot { .map(|(bot, thread_id, computer)| Bot {
@ -100,7 +117,10 @@ async fn create_bot(
Ok(Json(bot)) Ok(Json(bot))
} }
async fn get_bot(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Value>, StatusCode> { async fn get_bot(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Value>, StatusCode> {
let actor = actor(&state).await?; let actor = actor(&state).await?;
let bot = state let bot = state
.db .db
@ -149,13 +169,24 @@ async fn get_bot(State(state): State<AppState>, Path(id): Path<String>) -> Resul
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct InboxInput { action: String, group_name: Option<String> } struct InboxInput {
action: String,
group_name: Option<String>,
}
async fn update_inbox(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<InboxInput>) -> Result<Json<Value>, (StatusCode, Json<Value>)> { async fn update_inbox(
let actor = actor(&state).await.map_err(|status| (status, Json(json!({"message":"actor"}))))?; State(state): State<AppState>,
Path(id): Path<String>,
Json(input): Json<InboxInput>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let actor = actor(&state)
.await
.map_err(|status| (status, Json(json!({"message":"actor"}))))?;
let query = match input.action.as_str() { let query = match input.action.as_str() {
"read" => "UPDATE bots SET last_read_at=now() WHERE id=$1 AND space_id=$2 AND user_id=$3", "read" => "UPDATE bots SET last_read_at=now() WHERE id=$1 AND space_id=$2 AND user_id=$3",
"unread" => "UPDATE bots SET last_read_at='1970-01-01' WHERE id=$1 AND space_id=$2 AND user_id=$3", "unread" => {
"UPDATE bots SET last_read_at='1970-01-01' WHERE id=$1 AND space_id=$2 AND user_id=$3"
}
"pin" => "UPDATE bots SET pinned=TRUE WHERE id=$1 AND space_id=$2 AND user_id=$3", "pin" => "UPDATE bots SET pinned=TRUE WHERE id=$1 AND space_id=$2 AND user_id=$3",
"unpin" => "UPDATE bots SET pinned=FALSE WHERE id=$1 AND space_id=$2 AND user_id=$3", "unpin" => "UPDATE bots SET pinned=FALSE WHERE id=$1 AND space_id=$2 AND user_id=$3",
"hide" => "UPDATE bots SET hidden=TRUE WHERE id=$1 AND space_id=$2 AND user_id=$3", "hide" => "UPDATE bots SET hidden=TRUE WHERE id=$1 AND space_id=$2 AND user_id=$3",
@ -163,10 +194,28 @@ async fn update_inbox(State(state): State<AppState>, Path(id): Path<String>, Jso
"group" => "UPDATE bots SET group_name=$4 WHERE id=$1 AND space_id=$2 AND user_id=$3", "group" => "UPDATE bots SET group_name=$4 WHERE id=$1 AND space_id=$2 AND user_id=$3",
_ => return Err((StatusCode::BAD_REQUEST, Json(json!({"message":"未知操作"})))), _ => return Err((StatusCode::BAD_REQUEST, Json(json!({"message":"未知操作"})))),
}; };
let mut statement = sqlx::query(query).bind(&id).bind(&actor.space_id).bind(&actor.user_id); let mut statement = sqlx::query(query)
if input.action == "group" { statement = statement.bind(input.group_name.map(|v| v.trim().chars().take(30).collect::<String>()).filter(|v| !v.is_empty())); } .bind(&id)
let result = statement.execute(state.pool()).await.map_err(internal_error)?; .bind(&actor.space_id)
if result.rows_affected()!=1 { return Err((StatusCode::NOT_FOUND, Json(json!({"message":"bot not found"})))); } .bind(&actor.user_id);
if input.action == "group" {
statement = statement.bind(
input
.group_name
.map(|v| v.trim().chars().take(30).collect::<String>())
.filter(|v| !v.is_empty()),
);
}
let result = statement
.execute(state.pool())
.await
.map_err(internal_error)?;
if result.rows_affected() != 1 {
return Err((
StatusCode::NOT_FOUND,
Json(json!({"message":"bot not found"})),
));
}
Ok(Json(json!({"ok":true}))) Ok(Json(json!({"ok":true})))
} }
@ -175,33 +224,86 @@ async fn update_bot(
Path(id): Path<String>, Path(id): Path<String>,
Json(input): Json<UpdateBotInput>, Json(input): Json<UpdateBotInput>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> { ) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let actor = actor(&state).await.map_err(|status| (status, Json(json!({"message":"actor"}))))?; let actor = actor(&state)
.await
.map_err(|status| (status, Json(json!({"message":"actor"}))))?;
let name = input.name.trim(); let name = input.name.trim();
let color_ok = input.avatar_color.len() == 7 let color_ok = input.avatar_color.len() == 7
&& input.avatar_color.starts_with('#') && input.avatar_color.starts_with('#')
&& input.avatar_color[1..].bytes().all(|c| c.is_ascii_hexdigit()); && input.avatar_color[1..]
let shape_ok = matches!(input.avatar_shape.as_str(), .bytes()
"blob" | "round" | "diamond" | "squircle" | "capsule" | .all(|c| c.is_ascii_hexdigit());
"triangle" | "hexagon" | "cloud" | "drop" | let shape_ok = matches!(
"organic-4" | "organic-5" | "organic-6" | "organic-7" | input.avatar_shape.as_str(),
"organic-8" | "organic-9" | "organic-10" | "organic-11" "round"
| "organic"
| "boxy"
| "capsule"
| "nub"
| "cloud"
| "droplet"
| "hexagon"
| "sun"
| "triangle"
| "blob"
| "diamond"
| "squircle"
| "drop"
| "organic-4"
| "organic-5"
| "organic-6"
| "organic-7"
| "organic-8"
| "organic-9"
| "organic-10"
| "organic-11"
| "cat"
| "bunny"
| "star"
| "heart"
| "egg"
| "ghost"
| "sprout"
| "cactus"
| "mushroom"
| "paw"
); );
if name.is_empty() || name.chars().count() > 80 || !color_ok || !shape_ok { if name.is_empty() || name.chars().count() > 80 || !color_ok || !shape_ok {
return Err((StatusCode::BAD_REQUEST, Json(json!({"message":"設定格式不正確"})))); return Err((
StatusCode::BAD_REQUEST,
Json(json!({"message":"設定格式不正確"})),
));
} }
let tags: Vec<String> = input.tags.into_iter().map(|tag| tag.trim().to_string()) let tags: Vec<String> = input
.filter(|tag| !tag.is_empty()).take(6).collect(); .tags
.into_iter()
.map(|tag| tag.trim().to_string())
.filter(|tag| !tag.is_empty())
.take(6)
.collect();
let result = sqlx::query( let result = sqlx::query(
"UPDATE bots SET name=$1,title=$2,description=$3,avatar_color=$4,avatar_shape=$5,tags=$6, "UPDATE bots SET name=$1,title=$2,description=$3,avatar_color=$4,avatar_shape=$5,tags=$6,
memory_enabled=COALESCE($7,memory_enabled),updated_at=now() memory_enabled=COALESCE($7,memory_enabled),updated_at=now()
WHERE id=$8 AND space_id=$9 AND user_id=$10", WHERE id=$8 AND space_id=$9 AND user_id=$10",
) )
.bind(name).bind(input.title.trim()).bind(input.description.trim()) .bind(name)
.bind(input.avatar_color.to_uppercase()).bind(input.avatar_shape).bind(tags) .bind(input.title.trim())
.bind(input.memory_enabled).bind(&id).bind(&actor.space_id).bind(&actor.user_id) .bind(input.description.trim())
.execute(state.pool()).await.map_err(internal_error)?; .bind(input.avatar_color.to_uppercase())
.bind(input.avatar_shape)
.bind(tags)
.bind(input.memory_enabled)
.bind(&id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.execute(state.pool())
.await
.map_err(internal_error)?;
if result.rows_affected() != 1 { if result.rows_affected() != 1 {
return Err((StatusCode::NOT_FOUND, Json(json!({"message":"bot not found"})))); return Err((
StatusCode::NOT_FOUND,
Json(json!({"message":"bot not found"})),
));
} }
Ok(Json(json!({"ok":true}))) Ok(Json(json!({"ok":true})))
} }
@ -218,18 +320,33 @@ async fn delete_bot(
.get_bot(&actor, &id) .get_bot(&actor, &id)
.await .await
.map_err(internal_error)? .map_err(internal_error)?
.ok_or_else(|| (StatusCode::NOT_FOUND, Json(json!({ "message": "bot not found" }))))?; .ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(json!({ "message": "bot not found" })),
)
})?;
let computer = match bot.computer_id.as_deref() { let computer = match bot.computer_id.as_deref() {
Some(computer_id) => state.db.get_computer(computer_id).await.map_err(internal_error)?, Some(computer_id) => state
.db
.get_computer(computer_id)
.await
.map_err(internal_error)?,
None => None, None => None,
}; };
// Dedicated computers belong to the bot. Team computers belong to the environment and survive. // Dedicated computers belong to the bot. Team computers belong to the environment and survive.
if let Some(computer) = computer.as_ref().filter(|row| parse_mode(&row.scope) == ComputerMode::Dedicated) { if let Some(computer) = computer
.as_ref()
.filter(|row| parse_mode(&row.scope) == ComputerMode::Dedicated)
{
if let Some(computer_ref) = computer::computer_ref(computer) { if let Some(computer_ref) = computer::computer_ref(computer) {
state state
.sandbox .sandbox
.destroy(&computer_ref, &computer::adapter_context(&actor, &id, "delete-bot")) .destroy(
&computer_ref,
&computer::adapter_context(&actor, &id, "delete-bot"),
)
.await .await
.map_err(|error| bad_gateway(error.to_string()))?; .map_err(|error| bad_gateway(error.to_string()))?;
} }
@ -258,7 +375,10 @@ async fn delete_bot(
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(internal_error)?; .map_err(internal_error)?;
if let Some(computer) = computer.as_ref().filter(|row| parse_mode(&row.scope) == ComputerMode::Dedicated) { if let Some(computer) = computer
.as_ref()
.filter(|row| parse_mode(&row.scope) == ComputerMode::Dedicated)
{
sqlx::query("DELETE FROM computers WHERE id = $1") sqlx::query("DELETE FROM computers WHERE id = $1")
.bind(&computer.id) .bind(&computer.id)
.execute(&mut *tx) .execute(&mut *tx)
@ -267,7 +387,8 @@ async fn delete_bot(
} }
tx.commit().await.map_err(internal_error)?; tx.commit().await.map_err(internal_error)?;
if let Some(computer) = computer.filter(|row| parse_mode(&row.scope) == ComputerMode::Dedicated) { if let Some(computer) = computer.filter(|row| parse_mode(&row.scope) == ComputerMode::Dedicated)
{
remove_home(&state, &computer.home_key).await?; remove_home(&state, &computer.home_key).await?;
} }
Ok(Json(json!({ "ok": true }))) Ok(Json(json!({ "ok": true })))
@ -281,7 +402,10 @@ async fn delete_environment(
.await .await
.map_err(|status| (status, Json(json!({ "message": "actor" }))))?; .map_err(|status| (status, Json(json!({ "message": "actor" }))))?;
if id != actor.space_id { if id != actor.space_id {
return Err((StatusCode::NOT_FOUND, Json(json!({ "message": "environment not found" })))); return Err((
StatusCode::NOT_FOUND,
Json(json!({ "message": "environment not found" })),
));
} }
let computers: Vec<crate::db::ComputerRow> = sqlx::query_as( let computers: Vec<crate::db::ComputerRow> = sqlx::query_as(
"SELECT id, space_id, user_id, scope, scope_key, home_key, home_revision, kind, provider_ref, state, "SELECT id, space_id, user_id, scope, scope_key, home_key, home_revision, kind, provider_ref, state,
@ -299,7 +423,10 @@ async fn delete_environment(
if let Some(computer_ref) = computer::computer_ref(row) { if let Some(computer_ref) = computer::computer_ref(row) {
state state
.sandbox .sandbox
.destroy(&computer_ref, &computer::adapter_context(&actor, "environment", "delete-environment")) .destroy(
&computer_ref,
&computer::adapter_context(&actor, "environment", "delete-environment"),
)
.await .await
.map_err(|error| bad_gateway(error.to_string()))?; .map_err(|error| bad_gateway(error.to_string()))?;
} }
@ -311,7 +438,10 @@ async fn delete_environment(
.await .await
.map_err(internal_error)?; .map_err(internal_error)?;
if deleted.rows_affected() != 1 { if deleted.rows_affected() != 1 {
return Err((StatusCode::NOT_FOUND, Json(json!({ "message": "environment not found" })))); return Err((
StatusCode::NOT_FOUND,
Json(json!({ "message": "environment not found" })),
));
} }
for row in computers { for row in computers {
remove_home(&state, &row.home_key).await?; remove_home(&state, &row.home_key).await?;
@ -321,7 +451,10 @@ async fn delete_environment(
fn internal_error(error: impl std::fmt::Display) -> (StatusCode, Json<Value>) { fn internal_error(error: impl std::fmt::Display) -> (StatusCode, Json<Value>) {
tracing::error!("delete: {error}"); tracing::error!("delete: {error}");
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "message": "internal error" }))) (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "message": "internal error" })),
)
} }
fn bad_gateway(message: String) -> (StatusCode, Json<Value>) { fn bad_gateway(message: String) -> (StatusCode, Json<Value>) {
@ -345,6 +478,8 @@ struct SendBody {
client_nonce: Option<String>, client_nonce: Option<String>,
#[serde(default)] #[serde(default)]
blocks: Vec<Value>, blocks: Vec<Value>,
#[serde(default)]
attachments: Vec<lazyboy_contracts::SessionAttachment>,
} }
async fn send_message( async fn send_message(
@ -371,13 +506,14 @@ async fn send_message(
&body.text, &body.text,
body.client_nonce.as_deref(), body.client_nonce.as_deref(),
&body.blocks, &body.blocks,
&body.attachments,
) )
.await .await
.map_err(|error| { .map_err(|error| {
tracing::error!("send: {error}"); tracing::error!("send: {error}");
StatusCode::BAD_REQUEST StatusCode::BAD_REQUEST
}) })
.map(Json) .map(Json)
} }
async fn list_messages( async fn list_messages(
@ -412,29 +548,9 @@ async fn stop_task(
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?; .ok_or(StatusCode::NOT_FOUND)?;
let run_ids: Vec<String> = sqlx::query_scalar( crate::runs::cancel_active_runs(&state, &id)
"UPDATE runs SET status = 'cancelled', completed_at = now(), updated_at = now() .await
WHERE bot_id = $1 AND status IN ('queued','leased','running','waiting_input','waiting_takeover') .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
RETURNING id",
)
.bind(&id)
.fetch_all(state.pool())
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
for run_id in &run_ids {
computer::release_screen_execution(&state, run_id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
}
sqlx::query(
"UPDATE computers SET execution_bot_id = NULL, execution_run_id = NULL,
execution_lease_expires_at = NULL, updated_at = now()
WHERE execution_bot_id = $1",
)
.bind(&id)
.execute(state.pool())
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(json!({ "ok": true }))) Ok(Json(json!({ "ok": true })))
} }
@ -449,7 +565,10 @@ async fn computer_status(
.map_err(|_| StatusCode::NOT_FOUND) .map_err(|_| StatusCode::NOT_FOUND)
} }
async fn boot(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Value>, StatusCode> { async fn boot(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Value>, StatusCode> {
let actor = actor(&state).await?; let actor = actor(&state).await?;
computer::boot(&state, &actor, &id) computer::boot(&state, &actor, &id)
.await .await
@ -464,7 +583,10 @@ async fn boot(State(state): State<AppState>, Path(id): Path<String>) -> Result<J
}) })
} }
async fn restart(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Value>, StatusCode> { async fn restart(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Value>, StatusCode> {
let actor = actor(&state).await?; let actor = actor(&state).await?;
computer::restart(&state, &actor, &id) computer::restart(&state, &actor, &id)
.await .await
@ -475,7 +597,10 @@ async fn restart(State(state): State<AppState>, Path(id): Path<String>) -> Resul
}) })
} }
async fn stop(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Value>, StatusCode> { async fn stop(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Value>, StatusCode> {
let actor = actor(&state).await?; let actor = actor(&state).await?;
computer::stop(&state, &actor, &id) computer::stop(&state, &actor, &id)
.await .await
@ -536,12 +661,17 @@ async fn takeover(
.await .await
.map_err(|status| (status, Json(json!({"message": "actor"}))))?; .map_err(|status| (status, Json(json!({"message": "actor"}))))?;
match computer::takeover(&state, &actor, &id).await { match computer::takeover(&state, &actor, &id).await {
Ok((lease_id, expires_at)) => Ok(Json(json!({ "leaseId": lease_id, "expiresAt": expires_at }))), Ok((lease_id, expires_at)) => Ok(Json(
json!({ "leaseId": lease_id, "expiresAt": expires_at }),
)),
Err(error) => Err((StatusCode::BAD_REQUEST, Json(json!({ "message": error })))), Err(error) => Err((StatusCode::BAD_REQUEST, Json(json!({ "message": error })))),
} }
} }
async fn release(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Value>, StatusCode> { async fn release(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Value>, StatusCode> {
let actor = actor(&state).await?; let actor = actor(&state).await?;
computer::release(&state, &actor, &id) computer::release(&state, &actor, &id)
.await .await
@ -549,7 +679,10 @@ async fn release(State(state): State<AppState>, Path(id): Path<String>) -> Resul
.map_err(|_| StatusCode::BAD_REQUEST) .map_err(|_| StatusCode::BAD_REQUEST)
} }
async fn heartbeat(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Value>, StatusCode> { async fn heartbeat(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Value>, StatusCode> {
let actor = actor(&state).await?; let actor = actor(&state).await?;
computer::heartbeat(&state, &actor, &id) computer::heartbeat(&state, &actor, &id)
.await .await

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
use axum::extract::ws::{Message as AxumMessage, WebSocket, WebSocketUpgrade}; use axum::extract::ws::{Message as AxumMessage, WebSocket, WebSocketUpgrade};
use axum::extract::{FromRequest, Path, Request, State}; use axum::extract::{FromRequest, Path, Request, State};
use axum::http::{header, HeaderMap, StatusCode, Uri}; use axum::http::{HeaderMap, StatusCode, Uri, header};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
use tokio_tungstenite::tungstenite::Message as WsMessage; use tokio_tungstenite::tungstenite::Message as WsMessage;
@ -66,7 +66,10 @@ async fn viewer_page() -> Response {
Err(_) => include_str!("../../../apps/web/vnc.html").to_string(), Err(_) => include_str!("../../../apps/web/vnc.html").to_string(),
}; };
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.insert(header::CONTENT_TYPE, "text/html; charset=utf-8".parse().unwrap()); headers.insert(
header::CONTENT_TYPE,
"text/html; charset=utf-8".parse().unwrap(),
);
headers.insert(header::CACHE_CONTROL, "no-store".parse().unwrap()); headers.insert(header::CACHE_CONTROL, "no-store".parse().unwrap());
headers.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*".parse().unwrap()); headers.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*".parse().unwrap());
(StatusCode::OK, headers, html).into_response() (StatusCode::OK, headers, html).into_response()
@ -123,13 +126,16 @@ async fn upstream_port(state: &AppState, bot_id: &str, ensure: bool) -> Result<u
fn rewrite_upstream(url: &str) -> String { fn rewrite_upstream(url: &str) -> String {
let host = std::env::var("LAZYBOY_SCREEN_UPSTREAM").unwrap_or_else(|_| "127.0.0.1".into()); let host = std::env::var("LAZYBOY_SCREEN_UPSTREAM").unwrap_or_else(|_| "127.0.0.1".into());
url.replace("127.0.0.1", &host) url.replace("127.0.0.1", &host).replace("localhost", &host)
.replace("localhost", &host)
} }
async fn http_proxy(port: u16, rest: &str, req: Request) -> Response { 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 host = std::env::var("LAZYBOY_SCREEN_UPSTREAM").unwrap_or_else(|_| "127.0.0.1".into());
let path = if rest.is_empty() { "vnc_lite.html" } else { rest }; let path = if rest.is_empty() {
"vnc_lite.html"
} else {
rest
};
let query = req.uri().query().unwrap_or_default(); let query = req.uri().query().unwrap_or_default();
let url = if query.is_empty() { let url = if query.is_empty() {
format!("http://{host}:{port}/{path}") format!("http://{host}:{port}/{path}")
@ -137,10 +143,12 @@ async fn http_proxy(port: u16, rest: &str, req: Request) -> Response {
format!("http://{host}:{port}/{path}?{query}") format!("http://{host}:{port}/{path}?{query}")
}; };
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let method = reqwest::Method::from_bytes(req.method().as_str().as_bytes()).unwrap_or(reqwest::Method::GET); let method = reqwest::Method::from_bytes(req.method().as_str().as_bytes())
.unwrap_or(reqwest::Method::GET);
match client.request(method, url).send().await { match client.request(method, url).send().await {
Ok(upstream) => { Ok(upstream) => {
let status = StatusCode::from_u16(upstream.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); let status =
StatusCode::from_u16(upstream.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
let content_type = upstream let content_type = upstream
.headers() .headers()
.get(reqwest::header::CONTENT_TYPE) .get(reqwest::header::CONTENT_TYPE)
@ -169,7 +177,11 @@ async fn http_proxy(port: u16, rest: &str, req: Request) -> Response {
async fn proxy_socket(mut client: WebSocket, port: u16, rest: String) { async fn proxy_socket(mut client: WebSocket, port: u16, rest: String) {
let host = std::env::var("LAZYBOY_SCREEN_UPSTREAM").unwrap_or_else(|_| "127.0.0.1".into()); let host = std::env::var("LAZYBOY_SCREEN_UPSTREAM").unwrap_or_else(|_| "127.0.0.1".into());
let path = if rest.is_empty() { "websockify".into() } else { rest }; let path = if rest.is_empty() {
"websockify".into()
} else {
rest
};
let url = format!("ws://{host}:{port}/{path}"); let url = format!("ws://{host}:{port}/{path}");
let Ok((upstream, _)) = tokio_tungstenite::connect_async(url).await else { let Ok((upstream, _)) = tokio_tungstenite::connect_async(url).await else {
let _ = client.send(AxumMessage::Close(None)).await; let _ = client.send(AxumMessage::Close(None)).await;
@ -217,4 +229,3 @@ async fn proxy_socket(mut client: WebSocket, port: u16, rest: String) {
} }
} }
} }

View File

@ -4,13 +4,13 @@ use std::time::Duration;
use axum::extract::{Path, State}; use axum::extract::{Path, State};
use axum::http::{HeaderMap, StatusCode}; use axum::http::{HeaderMap, StatusCode};
use axum::response::sse::{Event, KeepAlive, Sse}; use axum::response::sse::{Event, KeepAlive, Sse};
use axum::routing::get; use axum::routing::{get, post};
use axum::{Json, Router}; use axum::{Json, Router};
use futures_util::stream; use futures_util::stream;
use lazyboy_contracts::{ use lazyboy_contracts::{
CreateSessionInput, SendSessionMessageInput, Session, SessionMessage, UpdateSessionInput, CreateSessionInput, SendSessionMessageInput, Session, SessionMessage, UpdateSessionInput,
}; };
use serde_json::{json, Value}; use serde_json::{Value, json};
use uuid::Uuid; use uuid::Uuid;
use crate::db::Actor; use crate::db::Actor;
@ -20,16 +20,22 @@ type ApiError = (StatusCode, Json<Value>);
pub fn router() -> Router<AppState> { pub fn router() -> Router<AppState> {
Router::new() Router::new()
.route("/api/bots/{id}/sessions", get(list_sessions).post(create_session)) .route(
"/api/bots/{id}/sessions",
get(list_sessions).post(create_session),
)
.route( .route(
"/api/sessions/{id}", "/api/sessions/{id}",
get(get_session).patch(update_session).delete(delete_session), get(get_session)
.patch(update_session)
.delete(delete_session),
) )
.route( .route(
"/api/sessions/{id}/messages", "/api/sessions/{id}/messages",
get(list_messages).post(send_message), get(list_messages).post(send_message).delete(clear_messages),
) )
.route("/api/sessions/{id}/events", get(events)) .route("/api/sessions/{id}/events", get(events))
.route("/api/sessions/{id}/stop", post(stop_session))
} }
async fn actor(state: &AppState) -> Result<Actor, ApiError> { async fn actor(state: &AppState) -> Result<Actor, ApiError> {
@ -47,7 +53,7 @@ fn internal(message: String) -> ApiError {
) )
} }
fn session_from_row( pub(crate) fn session_from_row(
row: ( row: (
String, String,
String, String,
@ -78,23 +84,25 @@ async fn list_sessions(
Path(bot_id): Path<String>, Path(bot_id): Path<String>,
) -> Result<Json<Vec<Session>>, ApiError> { ) -> Result<Json<Vec<Session>>, ApiError> {
let actor = actor(&state).await?; let actor = actor(&state).await?;
let exists: Option<i32> = sqlx::query_scalar( let exists: Option<i32> =
"SELECT 1 FROM bots WHERE id=$1 AND space_id=$2 AND user_id=$3", sqlx::query_scalar("SELECT 1 FROM bots WHERE id=$1 AND space_id=$2 AND user_id=$3")
) .bind(&bot_id)
.bind(&bot_id) .bind(&actor.space_id)
.bind(&actor.space_id) .bind(&actor.user_id)
.bind(&actor.user_id) .fetch_optional(state.pool())
.fetch_optional(state.pool()) .await
.await .map_err(|error| internal(error.to_string()))?;
.map_err(|error| internal(error.to_string()))?;
if exists.is_none() { if exists.is_none() {
return Err((StatusCode::NOT_FOUND, Json(json!({"message":"bot not found"})))); return Err((
StatusCode::NOT_FOUND,
Json(json!({"message":"bot not found"})),
));
} }
let rows = sqlx::query_as( let rows = sqlx::query_as(
"SELECT id, bot_id, title, status, created_at, updated_at, next_message_seq, "SELECT id, bot_id, title, status, created_at, updated_at, next_message_seq,
history_summary, history_summary_seq history_summary, history_summary_seq
FROM threads FROM threads
WHERE bot_id=$1 AND space_id=$2 AND user_id=$3 WHERE bot_id=$1 AND space_id=$2 AND user_id=$3 AND status='active' AND room_id IS NULL
ORDER BY updated_at DESC, created_at DESC", ORDER BY updated_at DESC, created_at DESC",
) )
.bind(bot_id) .bind(bot_id)
@ -112,17 +120,19 @@ async fn create_session(
Json(input): Json<CreateSessionInput>, Json(input): Json<CreateSessionInput>,
) -> Result<(StatusCode, Json<Session>), ApiError> { ) -> Result<(StatusCode, Json<Session>), ApiError> {
let actor = actor(&state).await?; let actor = actor(&state).await?;
let exists: Option<i32> = sqlx::query_scalar( let exists: Option<i32> =
"SELECT 1 FROM bots WHERE id=$1 AND space_id=$2 AND user_id=$3", sqlx::query_scalar("SELECT 1 FROM bots WHERE id=$1 AND space_id=$2 AND user_id=$3")
) .bind(&bot_id)
.bind(&bot_id) .bind(&actor.space_id)
.bind(&actor.space_id) .bind(&actor.user_id)
.bind(&actor.user_id) .fetch_optional(state.pool())
.fetch_optional(state.pool()) .await
.await .map_err(|error| internal(error.to_string()))?;
.map_err(|error| internal(error.to_string()))?;
if exists.is_none() { if exists.is_none() {
return Err((StatusCode::NOT_FOUND, Json(json!({"message":"bot not found"})))); return Err((
StatusCode::NOT_FOUND,
Json(json!({"message":"bot not found"})),
));
} }
let title = normalized_title(&input.title); let title = normalized_title(&input.title);
let row = sqlx::query_as( let row = sqlx::query_as(
@ -149,7 +159,12 @@ async fn get_session(
let actor = actor(&state).await?; let actor = actor(&state).await?;
let row = scoped_session_row(&state, &actor, &id) let row = scoped_session_row(&state, &actor, &id)
.await? .await?
.ok_or_else(|| (StatusCode::NOT_FOUND, Json(json!({"message":"session not found"}))))?; .ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(json!({"message":"session not found"})),
)
})?;
Ok(Json(session_from_row(row))) Ok(Json(session_from_row(row)))
} }
@ -164,7 +179,10 @@ async fn update_session(
.as_deref() .as_deref()
.is_some_and(|status| !matches!(status, "active" | "archived")) .is_some_and(|status| !matches!(status, "active" | "archived"))
{ {
return Err((StatusCode::BAD_REQUEST, Json(json!({"message":"invalid status"})))); return Err((
StatusCode::BAD_REQUEST,
Json(json!({"message":"invalid status"})),
));
} }
let title = input.title.as_deref().map(normalized_title); let title = input.title.as_deref().map(normalized_title);
let row = sqlx::query_as( let row = sqlx::query_as(
@ -182,7 +200,12 @@ async fn update_session(
.fetch_optional(state.pool()) .fetch_optional(state.pool())
.await .await
.map_err(|error| internal(error.to_string()))? .map_err(|error| internal(error.to_string()))?
.ok_or_else(|| (StatusCode::NOT_FOUND, Json(json!({"message":"session not found"}))))?; .ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(json!({"message":"session not found"})),
)
})?;
Ok(Json(session_from_row(row))) Ok(Json(session_from_row(row)))
} }
@ -191,7 +214,20 @@ async fn delete_session(
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let actor = actor(&state).await?; let actor = actor(&state).await?;
let mut tx = state.pool().begin().await.map_err(|error| internal(error.to_string()))?; if scoped_session_row(&state, &actor, &id).await?.is_none() {
return Err((
StatusCode::NOT_FOUND,
Json(json!({"message":"session not found"})),
));
}
cancel_session_runs(&state, &id)
.await
.map_err(|error| internal(error))?;
let mut tx = state
.pool()
.begin()
.await
.map_err(|error| internal(error.to_string()))?;
let bot_id: Option<String> = sqlx::query_scalar( let bot_id: Option<String> = sqlx::query_scalar(
"DELETE FROM threads WHERE id=$1 AND space_id=$2 AND user_id=$3 RETURNING bot_id", "DELETE FROM threads WHERE id=$1 AND space_id=$2 AND user_id=$3 RETURNING bot_id",
) )
@ -202,7 +238,10 @@ async fn delete_session(
.await .await
.map_err(|error| internal(error.to_string()))?; .map_err(|error| internal(error.to_string()))?;
let Some(bot_id) = bot_id else { let Some(bot_id) = bot_id else {
return Err((StatusCode::NOT_FOUND, Json(json!({"message":"session not found"})))); return Err((
StatusCode::NOT_FOUND,
Json(json!({"message":"session not found"})),
));
}; };
let remaining: bool = let remaining: bool =
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM threads WHERE bot_id=$1)") sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM threads WHERE bot_id=$1)")
@ -212,7 +251,7 @@ async fn delete_session(
.map_err(|error| internal(error.to_string()))?; .map_err(|error| internal(error.to_string()))?;
if !remaining { if !remaining {
sqlx::query( sqlx::query(
"INSERT INTO threads (id,space_id,bot_id,user_id,title) VALUES ($1,$2,$3,$4,'New session')", "INSERT INTO threads (id,space_id,bot_id,user_id,title) VALUES ($1,$2,$3,$4,'新對話')",
) )
.bind(Uuid::new_v4().to_string()) .bind(Uuid::new_v4().to_string())
.bind(&actor.space_id) .bind(&actor.space_id)
@ -222,7 +261,9 @@ async fn delete_session(
.await .await
.map_err(|error| internal(error.to_string()))?; .map_err(|error| internal(error.to_string()))?;
} }
tx.commit().await.map_err(|error| internal(error.to_string()))?; tx.commit()
.await
.map_err(|error| internal(error.to_string()))?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
@ -234,18 +275,69 @@ async fn list_messages(
Ok(Json(messages_for_session(&state, &actor, &id).await?)) Ok(Json(messages_for_session(&state, &actor, &id).await?))
} }
async fn clear_messages(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Value>, ApiError> {
let actor = actor(&state).await?;
if scoped_session_row(&state, &actor, &id).await?.is_none() {
return Err((
StatusCode::NOT_FOUND,
Json(json!({"message":"session not found"})),
));
}
cancel_session_runs(&state, &id)
.await
.map_err(|error| internal(error))?;
let mut tx = state
.pool()
.begin()
.await
.map_err(|error| internal(error.to_string()))?;
sqlx::query("DELETE FROM messages WHERE thread_id=$1")
.bind(&id)
.execute(&mut *tx)
.await
.map_err(|error| internal(error.to_string()))?;
sqlx::query(
"UPDATE threads
SET next_message_seq=1, history_summary='', history_summary_seq=0,
history_compacted_at=NULL, updated_at=now()
WHERE id=$1 AND space_id=$2 AND user_id=$3",
)
.bind(&id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.execute(&mut *tx)
.await
.map_err(|error| internal(error.to_string()))?;
tx.commit()
.await
.map_err(|error| internal(error.to_string()))?;
let _ = append_event(&state, &id, "session.cleared", json!({"sessionId":id})).await;
Ok(Json(json!({"ok":true})))
}
async fn send_message( async fn send_message(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<String>, Path(id): Path<String>,
Json(input): Json<SendSessionMessageInput>, Json(input): Json<SendSessionMessageInput>,
) -> Result<(StatusCode, Json<Value>), ApiError> { ) -> Result<(StatusCode, Json<Value>), ApiError> {
let actor = actor(&state).await?; let actor = actor(&state).await?;
if input.text.trim().is_empty() { if input.text.trim().is_empty() && input.attachments.is_empty() {
return Err((StatusCode::BAD_REQUEST, Json(json!({"message":"empty message"})))); return Err((
StatusCode::BAD_REQUEST,
Json(json!({"message":"empty message"})),
));
} }
let session = scoped_session_row(&state, &actor, &id) let session = scoped_session_row(&state, &actor, &id)
.await? .await?
.ok_or_else(|| (StatusCode::NOT_FOUND, Json(json!({"message":"session not found"}))))?; .ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(json!({"message":"session not found"})),
)
})?;
let result = crate::runs::send( let result = crate::runs::send(
&state, &state,
&actor, &actor,
@ -254,12 +346,30 @@ async fn send_message(
input.text.trim(), input.text.trim(),
input.client_nonce.as_deref(), input.client_nonce.as_deref(),
&input.blocks, &input.blocks,
&input.attachments,
) )
.await .await
.map_err(|message| (StatusCode::BAD_REQUEST, Json(json!({"message":message}))))?; .map_err(|message| (StatusCode::BAD_REQUEST, Json(json!({"message":message}))))?;
Ok((StatusCode::ACCEPTED, Json(result))) Ok((StatusCode::ACCEPTED, Json(result)))
} }
async fn stop_session(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Value>, ApiError> {
let actor = actor(&state).await?;
if scoped_session_row(&state, &actor, &id).await?.is_none() {
return Err((
StatusCode::NOT_FOUND,
Json(json!({"message":"session not found"})),
));
}
cancel_session_runs(&state, &id)
.await
.map_err(|error| internal(error))?;
Ok(Json(json!({"ok":true})))
}
async fn events( async fn events(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<String>, Path(id): Path<String>,
@ -267,52 +377,52 @@ async fn events(
) -> Result<Sse<impl futures_util::Stream<Item = Result<Event, Infallible>>>, ApiError> { ) -> Result<Sse<impl futures_util::Stream<Item = Result<Event, Infallible>>>, ApiError> {
let actor = actor(&state).await?; let actor = actor(&state).await?;
if scoped_session_row(&state, &actor, &id).await?.is_none() { if scoped_session_row(&state, &actor, &id).await?.is_none() {
return Err((StatusCode::NOT_FOUND, Json(json!({"message":"session not found"})))); return Err((
StatusCode::NOT_FOUND,
Json(json!({"message":"session not found"})),
));
} }
let after = headers let after = headers
.get("last-event-id") .get("last-event-id")
.and_then(|value| value.to_str().ok()) .and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<i32>().ok()) .and_then(|value| value.parse::<i32>().ok())
.unwrap_or(0); .unwrap_or(0);
let stream_state = ( let stream_state = (state, id, actor, after, Vec::<(i32, String, Value)>::new());
state, let output = stream::unfold(
id, stream_state,
actor, |(state, id, actor, mut after, mut pending)| async move {
after, loop {
Vec::<(i32, String, Value)>::new(), if let Some((seq, kind, payload)) = pending.pop() {
); after = seq;
let output = stream::unfold(stream_state, |(state, id, actor, mut after, mut pending)| async move { let event = Event::default()
loop { .id(seq.to_string())
if let Some((seq, kind, payload)) = pending.pop() { .event(kind)
after = seq; .json_data(payload)
let event = Event::default() .unwrap_or_else(|_| Event::default().event("error").data("{}"));
.id(seq.to_string()) return Some((Ok(event), (state, id, actor, after, pending)));
.event(kind) }
.json_data(payload) match sqlx::query_as::<_, (i32, String, Value)>(
.unwrap_or_else(|_| Event::default().event("error").data("{}")); "SELECT e.seq,e.type,e.payload FROM events e
return Some((Ok(event), (state, id, actor, after, pending)));
}
match sqlx::query_as::<_, (i32, String, Value)>(
"SELECT e.seq,e.type,e.payload FROM events e
JOIN threads t ON t.id=e.thread_id JOIN threads t ON t.id=e.thread_id
WHERE e.thread_id=$1 AND e.seq>$2 AND t.space_id=$3 AND t.user_id=$4 WHERE e.thread_id=$1 AND e.seq>$2 AND t.space_id=$3 AND t.user_id=$4
ORDER BY e.seq ASC LIMIT 100", ORDER BY e.seq ASC LIMIT 100",
) )
.bind(&id) .bind(&id)
.bind(after) .bind(after)
.bind(&actor.space_id) .bind(&actor.space_id)
.bind(&actor.user_id) .bind(&actor.user_id)
.fetch_all(state.pool()) .fetch_all(state.pool())
.await .await
{ {
Ok(mut rows) if !rows.is_empty() => { Ok(mut rows) if !rows.is_empty() => {
rows.reverse(); rows.reverse();
pending = rows; pending = rows;
}
Ok(_) | Err(_) => tokio::time::sleep(Duration::from_millis(750)).await,
} }
Ok(_) | Err(_) => tokio::time::sleep(Duration::from_millis(750)).await,
} }
} },
}); );
Ok(Sse::new(output).keep_alive( Ok(Sse::new(output).keep_alive(
KeepAlive::new() KeepAlive::new()
.interval(Duration::from_secs(15)) .interval(Duration::from_secs(15))
@ -327,7 +437,7 @@ pub async fn default_session_for_bot(
) -> Result<Option<String>, sqlx::Error> { ) -> Result<Option<String>, sqlx::Error> {
sqlx::query_scalar( sqlx::query_scalar(
"SELECT id FROM threads "SELECT id FROM threads
WHERE bot_id=$1 AND space_id=$2 AND user_id=$3 AND status='active' WHERE bot_id=$1 AND space_id=$2 AND user_id=$3 AND status='active' AND room_id IS NULL
ORDER BY updated_at DESC, created_at ASC LIMIT 1", ORDER BY updated_at DESC, created_at ASC LIMIT 1",
) )
.bind(bot_id) .bind(bot_id)
@ -352,10 +462,16 @@ pub async fn messages_for_session(
Option<String>, Option<String>,
Option<String>, Option<String>,
chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>,
Option<String>,
Option<String>,
Option<String>,
Option<String>,
)> = sqlx::query_as( )> = sqlx::query_as(
"SELECT m.id, m.thread_id, m.seq, m.role, m.body, m.blocks, m.run_id, "SELECT m.id, m.thread_id, m.seq, m.role, m.body, m.blocks, m.run_id,
m.client_nonce, m.created_at m.client_nonce, m.created_at, m.speaker_bot_id, b.name, b.avatar_color, b.avatar_shape
FROM messages m JOIN threads t ON t.id=m.thread_id FROM messages m
JOIN threads t ON t.id=m.thread_id
LEFT JOIN bots b ON b.id=m.speaker_bot_id
WHERE t.id=$1 AND t.space_id=$2 AND t.user_id=$3 WHERE t.id=$1 AND t.space_id=$2 AND t.user_id=$3
ORDER BY m.seq ASC", ORDER BY m.seq ASC",
) )
@ -377,6 +493,10 @@ pub async fn messages_for_session(
run_id: row.6, run_id: row.6,
client_nonce: row.7, client_nonce: row.7,
created_at: row.8, created_at: row.8,
speaker_bot_id: row.9,
speaker_name: row.10,
speaker_color: row.11,
speaker_shape: row.12,
}) })
.collect()) .collect())
} }
@ -438,23 +558,99 @@ pub async fn append_event(
Ok(seq) Ok(seq)
} }
fn normalized_title(title: &str) -> String { pub(crate) fn normalized_title(title: &str) -> String {
let value: String = title.trim().chars().take(120).collect(); let value: String = title.trim().chars().take(120).collect();
if value.is_empty() { if value.is_empty() {
"New session".to_string() "新對話".to_string()
} else { } else {
value value
} }
} }
pub(crate) fn is_default_session_title(title: &str) -> bool {
let value = title.trim();
value.is_empty()
|| value == "New session"
|| value == "新對話"
|| value
.strip_prefix("對話 ")
.is_some_and(|rest| !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit()))
}
pub(crate) fn title_from_first_message(text: &str) -> String {
let line = text
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.unwrap_or("");
let truncated: String = line.chars().take(40).collect();
if truncated.is_empty() {
"新對話".to_string()
} else if line.chars().count() > 40 {
format!("{truncated}")
} else {
truncated
}
}
pub(crate) async fn cancel_session_runs(
state: &AppState,
thread_id: &str,
) -> Result<Vec<String>, String> {
let run_ids: Vec<String> = sqlx::query_scalar(
"UPDATE runs SET status='cancelled', completed_at=now(), updated_at=now()
WHERE thread_id=$1 AND status IN ('queued','leased','running','waiting_input','waiting_takeover')
RETURNING id",
)
.bind(thread_id)
.fetch_all(state.pool())
.await
.map_err(|error| error.to_string())?;
for run_id in &run_ids {
crate::computer::release_screen_execution(state, run_id).await?;
sqlx::query(
"UPDATE computers SET execution_bot_id=NULL, execution_run_id=NULL,
execution_lease_expires_at=NULL, updated_at=now()
WHERE execution_run_id=$1",
)
.bind(run_id)
.execute(state.pool())
.await
.map_err(|error| error.to_string())?;
}
Ok(run_ids)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::normalized_title; use super::{is_default_session_title, normalized_title, title_from_first_message};
#[test] #[test]
fn session_titles_are_bounded_and_have_a_default() { fn session_titles_are_bounded_and_have_a_default() {
assert_eq!(normalized_title(" "), "New session"); assert_eq!(normalized_title(" "), "新對話");
assert_eq!(normalized_title(" Research "), "Research"); assert_eq!(normalized_title(" Research "), "Research");
assert_eq!(normalized_title(&"x".repeat(150)).chars().count(), 120); assert_eq!(normalized_title(&"x".repeat(150)).chars().count(), 120);
} }
#[test]
fn default_titles_include_legacy_and_numbered_names() {
assert!(is_default_session_title("New session"));
assert!(is_default_session_title("新對話"));
assert!(is_default_session_title("對話 1"));
assert!(is_default_session_title("對話 12"));
assert!(!is_default_session_title("幫我查網站"));
assert!(!is_default_session_title("對話"));
assert!(!is_default_session_title("對話 一"));
}
#[test]
fn first_message_title_uses_first_line_and_truncates() {
assert_eq!(title_from_first_message("幫我查天氣"), "幫我查天氣");
assert_eq!(title_from_first_message("\n 第一行\n第二行"), "第一行");
assert_eq!(
title_from_first_message(&"".repeat(45)),
format!("{}", "".repeat(40))
);
assert_eq!(title_from_first_message(" \n"), "新對話");
}
} }

1775
crates/api/src/skills.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -2,11 +2,12 @@ use std::sync::Arc;
use lazyboy_control::SandboxProvider; use lazyboy_control::SandboxProvider;
use lazyboy_sandbox::{DockerSandbox, FakeSandbox}; use lazyboy_sandbox::{DockerSandbox, FakeSandbox};
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool; use sqlx::PgPool;
use sqlx::postgres::PgPoolOptions;
use crate::db::{Actor, Db};
use crate::auth::AuthConfig; use crate::auth::AuthConfig;
use crate::db::{Actor, Db};
use crate::mcp::McpHub;
use crate::memory::MemoryService; use crate::memory::MemoryService;
#[derive(Clone)] #[derive(Clone)]
@ -16,6 +17,7 @@ pub struct AppState {
pub data_dir: String, pub data_dir: String,
pub auth: AuthConfig, pub auth: AuthConfig,
pub memory: MemoryService, pub memory: MemoryService,
pub mcp: McpHub,
} }
impl AppState { impl AppState {
@ -36,6 +38,7 @@ impl AppState {
data_dir: std::env::var("DATA_DIR").unwrap_or_else(|_| "./data".into()), data_dir: std::env::var("DATA_DIR").unwrap_or_else(|_| "./data".into()),
auth: AuthConfig::from_env(), auth: AuthConfig::from_env(),
memory: MemoryService::from_env(), memory: MemoryService::from_env(),
mcp: McpHub::new(),
}) })
} }
@ -49,7 +52,10 @@ impl AppState {
} }
fn sandbox_from_env() -> Arc<dyn SandboxProvider> { fn sandbox_from_env() -> Arc<dyn SandboxProvider> {
match std::env::var("SANDBOX_PROVIDER").unwrap_or_else(|_| "docker".into()).as_str() { match std::env::var("SANDBOX_PROVIDER")
.unwrap_or_else(|_| "docker".into())
.as_str()
{
"fake" => Arc::new(FakeSandbox::new()), "fake" => Arc::new(FakeSandbox::new()),
_ => { _ => {
let url = std::env::var("SANDBOX_SUPERVISOR_URL") let url = std::env::var("SANDBOX_SUPERVISOR_URL")

View File

@ -1,16 +1,21 @@
use std::sync::Mutex; use std::sync::Mutex;
use lazyboy_contracts::{ComputerMode, ComputerObservation}; use lazyboy_contracts::{
ComputerAction, ComputerMode, ComputerObservation, PointerType, UiElement,
};
use lazyboy_control::{ use lazyboy_control::{
frames_match, parse_computer_actions, resolve_bot_workspace_cwd, resolve_bot_workspace_path, ActionError, ActionRequest, AdapterContext, CdpPage, CommandRequest, ComputerRef,
ActionRequest, AdapterContext, CommandRequest, ComputerRef, SandboxProvider, SandboxProvider, apply_element_targets, cdp_command_on, element_id, format_ui_elements,
frames_match, merge_page_elements, overlay_elements, parse_cdp_page, parse_computer_actions,
resolve_bot_workspace_cwd, resolve_bot_workspace_path,
}; };
use rig_core::completion::ToolDefinition; use rig_core::completion::ToolDefinition;
use serde_json::{json, Value}; use serde_json::{Value, json};
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use crate::db::Actor; use crate::db::Actor;
use crate::mcp::McpHub;
use crate::memory::{CreateMemoryInput, MemoryService}; use crate::memory::{CreateMemoryInput, MemoryService};
pub struct ToolCtx { pub struct ToolCtx {
@ -22,6 +27,9 @@ pub struct ToolCtx {
pub vision: bool, pub vision: bool,
pub gui_block: Option<String>, pub gui_block: Option<String>,
pub previous_frame: Mutex<Option<String>>, pub previous_frame: Mutex<Option<String>>,
pub elements: Mutex<Vec<UiElement>>,
pub miss_streak: Mutex<u32>,
pub click_misses: Mutex<u32>,
pub takeover_requested: Mutex<bool>, pub takeover_requested: Mutex<bool>,
pub pool: PgPool, pub pool: PgPool,
pub memory: MemoryService, pub memory: MemoryService,
@ -29,18 +37,19 @@ pub struct ToolCtx {
pub session_id: String, pub session_id: String,
pub run_id: String, pub run_id: String,
pub memory_enabled: bool, pub memory_enabled: bool,
pub mcp: McpHub,
} }
pub fn tool_definitions(memory_enabled: bool) -> Vec<ToolDefinition> { pub fn tool_definitions(memory_enabled: bool) -> Vec<ToolDefinition> {
let mut definitions = vec![ let mut definitions = vec![
ToolDefinition { ToolDefinition {
name: "computer_observe".into(), name: "computer_observe".into(),
description: "Capture a fresh desktop screenshot. Frame metadata comes back as text; the image is attached to the next model turn.".into(), description: "Capture a fresh desktop screenshot plus numbered targets. When Chromium is open this is the page DOM (click those ids); otherwise native windows. The image attaches only if the screen changed.".into(),
parameters: json!({"type":"object","properties":{}}), parameters: json!({"type":"object","properties":{}}),
}, },
ToolDefinition { ToolDefinition {
name: "computer_act".into(), name: "computer_act".into(),
description: "Drive the desktop like a person: move to the target, click, type, drag, and scroll. Coordinates are 1280x800 from the top-left. Always include x,y for click/scroll/drag. Batch a whole gesture in one call. The resulting screenshot is attached to the next turn.".into(), description: "Drive native desktop GUI (dialogs, canvas, XFCE). Prefer element id from the latest observation. For Chromium pages use the browser tool instead of clicking the window. x,y are 1280x800 fallback.".into(),
parameters: json!({ parameters: json!({
"type":"object", "type":"object",
"properties":{ "properties":{
@ -50,6 +59,7 @@ pub fn tool_definitions(memory_enabled: bool) -> Vec<ToolDefinition> {
"type":"object", "type":"object",
"properties":{ "properties":{
"kind":{"type":"string","enum":["click","move","down","up","hover","drag","type","key","scroll","wait","focus"]}, "kind":{"type":"string","enum":["click","move","down","up","hover","drag","type","key","scroll","wait","focus"]},
"element":{"type":"number"},
"title":{"type":"string"}, "title":{"type":"string"},
"x2":{"type":"number"}, "x2":{"type":"number"},
"y2":{"type":"number"}, "y2":{"type":"number"},
@ -109,6 +119,24 @@ pub fn tool_definitions(memory_enabled: bool) -> Vec<ToolDefinition> {
description: "Open a workspace file or http(s) URL on the desktop.".into(), description: "Open a workspace file or http(s) URL on the desktop.".into(),
parameters: json!({"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}), parameters: json!({"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}),
}, },
ToolDefinition {
name: "browser".into(),
description: "Control Chromium through the page DOM. Prefer this over computer_act for anything in the browser. snapshot returns numbered elements and visible text (no screenshot). click/type/navigate by element id or CSS selector. click scrolls off-screen elements into view and, if the control is [disabled], waits up to 45s (waitMs to change) for it to enable before clicking. Ids are renumbered after every page change. The human still sees the live window.".into(),
parameters: json!({
"type":"object",
"properties":{
"action":{"type":"string","enum":["snapshot","click","type","press","navigate","wait"]},
"element":{"type":"number"},
"selector":{"type":"string"},
"text":{"type":"string"},
"key":{"type":"string"},
"url":{"type":"string"},
"ms":{"type":"number"},
"waitMs":{"type":"number","description":"click only: how long to wait for a disabled control to enable (default 45000, max 120000)"}
},
"required":["action"]
}),
},
ToolDefinition { ToolDefinition {
name: "launch_app".into(), name: "launch_app".into(),
description: "Launch browser or terminal on the desktop.".into(), description: "Launch browser or terminal on the desktop.".into(),
@ -118,11 +146,25 @@ pub fn tool_definitions(memory_enabled: bool) -> Vec<ToolDefinition> {
"required":["application"] "required":["application"]
}), }),
}, },
ToolDefinition {
name: "wait".into(),
description: "Wait for the screen to change on its own (video playing, page loading, a button that enables later), then return a fresh observation. seconds: 1-60.".into(),
parameters: json!({
"type":"object",
"properties":{"seconds":{"type":"number","minimum":1,"maximum":60},"reason":{"type":"string"}},
"required":["seconds"]
}),
},
ToolDefinition { ToolDefinition {
name: "request_takeover".into(), name: "request_takeover".into(),
description: "Ask the user to take over for passwords, 2FA, CAPTCHA, or protected input. Never ask them to paste secrets in chat.".into(), description: "Ask the user to take over for passwords, 2FA, CAPTCHA, login walls, or when the right on-screen control cannot be found. Never ask them to paste secrets in chat.".into(),
parameters: json!({"type":"object","properties":{"reason":{"type":"string"}},"required":["reason"]}), parameters: json!({"type":"object","properties":{"reason":{"type":"string"}},"required":["reason"]}),
}, },
ToolDefinition {
name: "use_skill".into(),
description: "Load the playbook of a skill the human taught this bot by demonstration (see 'Taught skills' in your instructions). Returns intent, inputs and semantic steps to follow with the normal tools.".into(),
parameters: json!({"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}),
},
]; ];
if memory_enabled { if memory_enabled {
definitions.extend([ definitions.extend([
@ -171,6 +213,8 @@ pub async fn dispatch(ctx: &ToolCtx, name: &str, args: &Value) -> ToolOutcome {
match name { match name {
"computer_observe" => observe(ctx).await, "computer_observe" => observe(ctx).await,
"computer_act" => act(ctx, args).await, "computer_act" => act(ctx, args).await,
"wait" => wait_then_observe(ctx, args).await,
"browser" => browser(ctx, args).await,
"shell" => shell(ctx, args).await, "shell" => shell(ctx, args).await,
"list_files" => list_files(ctx, args).await, "list_files" => list_files(ctx, args).await,
"read_file" => read_file(ctx, args).await, "read_file" => read_file(ctx, args).await,
@ -192,6 +236,29 @@ pub async fn dispatch(ctx: &ToolCtx, name: &str, args: &Value) -> ToolOutcome {
pause: true, pause: true,
} }
} }
"use_skill" => {
let name = args.get("name").and_then(Value::as_str).unwrap_or("");
let skills = crate::skills::saved_skills(&ctx.pool, &ctx.bot_id).await;
match crate::skills::find_skill(&skills, name) {
Some(skill) => text_outcome(crate::skills::format_playbook_for_run(skill)),
None => text_outcome(format!(
"no taught skill named {name:?}. Available: {}",
if skills.is_empty() {
"none".to_string()
} else {
skills
.iter()
.map(|skill| skill.name.clone())
.collect::<Vec<_>>()
.join(", ")
}
)),
}
}
other if other.starts_with("mcp_") => match ctx.mcp.call(other, args).await {
Ok(text) => text_outcome(text),
Err(error) => text_outcome(format!("MCP 工具失敗:{error}")),
},
other => ToolOutcome { other => ToolOutcome {
text: format!("unknown tool {other}"), text: format!("unknown tool {other}"),
image: None, image: None,
@ -205,13 +272,24 @@ async fn remember(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
return text_outcome("memory is disabled for this agent"); return text_outcome("memory is disabled for this agent");
} }
let input = CreateMemoryInput { let input = CreateMemoryInput {
content: args.get("content").and_then(Value::as_str).unwrap_or("").to_string(), content: args
importance: args.get("importance").and_then(Value::as_f64).unwrap_or(0.5) as f32, .get("content")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
importance: args
.get("importance")
.and_then(Value::as_f64)
.unwrap_or(0.5) as f32,
session_id: Some(ctx.session_id.clone()), session_id: Some(ctx.session_id.clone()),
source_run_id: Some(ctx.run_id.clone()), source_run_id: Some(ctx.run_id.clone()),
source_message_id: None, source_message_id: None,
}; };
match ctx.memory.remember(&ctx.pool, &ctx.actor, &ctx.bot_id, input).await { match ctx
.memory
.remember(&ctx.pool, &ctx.actor, &ctx.bot_id, input)
.await
{
Ok(item) => text_outcome(json!({"ok":true,"memory":item}).to_string()), Ok(item) => text_outcome(json!({"ok":true,"memory":item}).to_string()),
Err(error) => text_outcome(error), Err(error) => text_outcome(error),
} }
@ -223,7 +301,11 @@ async fn recall_memory(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
} }
let query = args.get("query").and_then(Value::as_str).unwrap_or(""); let query = args.get("query").and_then(Value::as_str).unwrap_or("");
let limit = args.get("limit").and_then(Value::as_i64); let limit = args.get("limit").and_then(Value::as_i64);
match ctx.memory.recall(&ctx.pool, &ctx.actor, &ctx.bot_id, query, limit).await { match ctx
.memory
.recall(&ctx.pool, &ctx.actor, &ctx.bot_id, query, limit)
.await
{
Ok(items) => text_outcome(serde_json::to_string(&items).unwrap_or_else(|_| "[]".into())), Ok(items) => text_outcome(serde_json::to_string(&items).unwrap_or_else(|_| "[]".into())),
Err(error) => text_outcome(error), Err(error) => text_outcome(error),
} }
@ -233,17 +315,29 @@ async fn forget_memory(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
if !ctx.memory_enabled { if !ctx.memory_enabled {
return text_outcome("memory is disabled for this agent"); return text_outcome("memory is disabled for this agent");
} }
let Some(id) = args.get("memory_id").and_then(Value::as_str).and_then(|id| Uuid::parse_str(id).ok()) else { let Some(id) = args
.get("memory_id")
.and_then(Value::as_str)
.and_then(|id| Uuid::parse_str(id).ok())
else {
return text_outcome("memory_id must be a UUID"); return text_outcome("memory_id must be a UUID");
}; };
match ctx.memory.forget(&ctx.pool, &ctx.actor, &ctx.bot_id, id).await { match ctx
.memory
.forget(&ctx.pool, &ctx.actor, &ctx.bot_id, id)
.await
{
Ok(deleted) => text_outcome(json!({"ok":deleted}).to_string()), Ok(deleted) => text_outcome(json!({"ok":deleted}).to_string()),
Err(error) => text_outcome(error), Err(error) => text_outcome(error),
} }
} }
fn text_outcome(text: impl Into<String>) -> ToolOutcome { fn text_outcome(text: impl Into<String>) -> ToolOutcome {
ToolOutcome { text: text.into(), image: None, pause: false } ToolOutcome {
text: text.into(),
image: None,
pause: false,
}
} }
fn vision_guard(ctx: &ToolCtx) -> Option<ToolOutcome> { fn vision_guard(ctx: &ToolCtx) -> Option<ToolOutcome> {
@ -266,9 +360,19 @@ fn vision_guard(ctx: &ToolCtx) -> Option<ToolOutcome> {
} }
fn observation_text(note: &str, observation: &ComputerObservation, unchanged: bool) -> String { fn observation_text(note: &str, observation: &ComputerObservation, unchanged: bool) -> String {
let label = if observation
.elements
.iter()
.any(|element| element.kind.as_deref() == Some("dom"))
{
"Clickable page elements"
} else {
"Clickable windows"
};
format!( format!(
"{note}{}\n{}", "{note}{}\n{label}: {}\n{}",
if unchanged { " (screen unchanged)" } else { "" }, if unchanged { " (screen unchanged)" } else { "" },
format_ui_elements(&observation.elements),
json!({ json!({
"frameId": observation.frame_id, "frameId": observation.frame_id,
"width": observation.width, "width": observation.width,
@ -276,6 +380,7 @@ fn observation_text(note: &str, observation: &ComputerObservation, unchanged: bo
"capturedAt": observation.captured_at, "capturedAt": observation.captured_at,
"cursor": observation.cursor, "cursor": observation.cursor,
"activeWindow": observation.active_window, "activeWindow": observation.active_window,
"elements": observation.elements,
}) })
) )
} }
@ -285,7 +390,11 @@ async fn observe(ctx: &ToolCtx) -> ToolOutcome {
return blocked; return blocked;
} }
match ctx.sandbox.observe(&ctx.computer, &ctx.context).await { match ctx.sandbox.observe(&ctx.computer, &ctx.context).await {
Ok(observation) => pack_observation(ctx, "computer observed", observation), Ok(observation) => {
let (observation, note) =
attach_page_elements(ctx, observation, "computer observed").await;
pack_observation(ctx, &note, observation)
}
Err(error) => ToolOutcome { Err(error) => ToolOutcome {
text: error.to_string(), text: error.to_string(),
image: None, image: None,
@ -294,10 +403,251 @@ async fn observe(ctx: &ToolCtx) -> ToolOutcome {
} }
} }
/// Bounded so it always fits inside the 90s per-tool budget with an observe.
const MAX_WAIT_SECS: f64 = 60.0;
async fn wait_then_observe(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
let seconds = args
.get("seconds")
.and_then(Value::as_f64)
.unwrap_or(5.0)
.clamp(1.0, MAX_WAIT_SECS);
tokio::time::sleep(std::time::Duration::from_secs_f64(seconds)).await;
if vision_guard(ctx).is_some() {
return text_outcome(format!("waited {seconds:.0}s"));
}
match ctx.sandbox.observe(&ctx.computer, &ctx.context).await {
Ok(observation) => {
let note = format!("waited {seconds:.0}s");
let (observation, note) = attach_page_elements(ctx, observation, &note).await;
pack_observation(ctx, &note, observation)
}
Err(error) => text_outcome(format!("waited {seconds:.0}s; observe failed: {error}")),
}
}
async fn attach_page_elements(
ctx: &ToolCtx,
mut observation: ComputerObservation,
note: &str,
) -> (ComputerObservation, String) {
let Some(page) = cdp_snapshot(ctx, false).await else {
return (observation, note.to_string());
};
observation.elements = merge_page_elements(observation.elements, &page.elements);
let mut note = note.to_string();
if !page.url.is_empty() || !page.title.is_empty() {
note.push_str(&format!("\nPage: {} {}", page.title, page.url));
}
if !page.text.is_empty() {
note.push_str("\nVisible text:\n");
note.push_str(&page.text);
}
(observation, note)
}
async fn cdp_snapshot(ctx: &ToolCtx, ensure: bool) -> Option<CdpPage> {
let page = cdp_call(ctx, json!({"action": "snapshot", "ensure": ensure})).await;
if page.ok { Some(page) } else { None }
}
async fn cdp_call(ctx: &ToolCtx, request: Value) -> CdpPage {
let display = ctx.context.display.as_deref().unwrap_or(":1");
// Clicks may sit through a page's stay timer (CLICK_WAIT_MS in cdp.py).
let timeout_ms = if request.get("action").and_then(Value::as_str) == Some("click") {
let wait = request
.get("waitMs")
.and_then(Value::as_u64)
.unwrap_or(45_000)
.min(120_000);
wait + 25_000
} else {
20_000
};
let argv = cdp_command_on(display, ctx.context.profile_path.as_deref(), &request);
match ctx
.sandbox
.execute(
&ctx.computer,
CommandRequest {
argv,
cwd: None,
timeout_ms: Some(timeout_ms),
},
&ctx.context,
)
.await
{
Ok(result) => {
let raw = if result.stdout.trim().is_empty() {
result.stderr
} else {
result.stdout
};
parse_cdp_page(&raw)
}
Err(error) => CdpPage {
ok: false,
error: Some(error.to_string()),
..CdpPage::default()
},
}
}
async fn browser(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
if let Some(blocked) = vision_guard(ctx) {
return blocked;
}
let action = args
.get("action")
.and_then(Value::as_str)
.unwrap_or("snapshot");
let mut request = json!({
"action": action,
"ensure": true,
});
for key in ["url", "text", "key", "ms", "selector", "waitMs"] {
if let Some(value) = args.get(key) {
request[key] = value.clone();
}
}
if request.get("selector").and_then(Value::as_str).is_none() {
if let Some(id) = element_id(args.get("element").or_else(|| args.get("id"))) {
let elements = ctx.elements.lock().unwrap().clone();
match elements
.iter()
.find(|element| u64::from(element.id) == id)
.and_then(|element| element.selector.clone())
{
Some(selector) => request["selector"] = json!(selector),
None if matches!(action, "click" | "type") => {
return pause_unknown_element(ctx, id as u32, &elements);
}
None => {}
}
}
}
if matches!(action, "click") && request.get("selector").and_then(Value::as_str).is_none() {
let known: Vec<String> = ctx
.elements
.lock()
.unwrap()
.iter()
.filter(|element| element.selector.is_some())
.map(|element| format!("[{}] {}", element.id, element.title))
.take(12)
.collect();
return text_outcome(format!(
"browser click needs {{\"action\":\"click\",\"element\":N}} with a number from the last snapshot, or a CSS selector. You sent: {}. Known elements: {}",
serde_json::to_string(args).unwrap_or_default(),
if known.is_empty() {
"none yet, call browser snapshot first".to_string()
} else {
known.join(", ")
}
));
}
let page = cdp_call(ctx, request).await;
if !page.elements.is_empty() {
*ctx.elements.lock().unwrap() = page.elements.clone();
}
if !page.ok {
let mut text = page.error.unwrap_or_else(|| "browser failed".into());
if !page.elements.is_empty() {
text.push_str(&format!(
"\nPage: {} {}\nClickable page elements: {}",
page.title,
page.url,
format_ui_elements(&page.elements)
));
}
return text_outcome(text);
}
let mut text = browser_result_text(action, &page);
if let Some(seconds) = page.waited_seconds {
text.push_str(&format!(
" (the control was disabled; waited {seconds:.0}s for it to enable before clicking)"
));
}
if action == "snapshot" {
return ToolOutcome {
text,
image: None,
pause: false,
};
}
match ctx.sandbox.observe(&ctx.computer, &ctx.context).await {
Ok(observation) => {
let (observation, note) = attach_page_elements(ctx, observation, &text).await;
pack_observation(ctx, &note, observation)
}
Err(_) => ToolOutcome {
text,
image: None,
pause: false,
},
}
}
fn browser_result_text(action: &str, page: &CdpPage) -> String {
if matches!(action, "snapshot" | "navigate") {
format!(
"browser {action}\nPage: {} {}\nClickable page elements: {}\nVisible text:\n{}",
page.title,
page.url,
format_ui_elements(&page.elements),
page.text
)
} else {
format!("browser {action} ok")
}
}
async fn act(ctx: &ToolCtx, args: &Value) -> ToolOutcome { async fn act(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
if let Some(blocked) = vision_guard(ctx) { if let Some(blocked) = vision_guard(ctx) {
return blocked; return blocked;
} }
let mut args = args.clone();
let elements = ctx.elements.lock().unwrap().clone();
if let Some(actions) = args.get_mut("actions") {
if let Err(error) = apply_element_targets(actions, &elements) {
if let ActionError::UnknownElement(id) = error {
return pause_unknown_element(ctx, id, &elements);
}
return text_outcome(error.to_string());
}
if let Some(items) = actions.as_array_mut() {
for item in items {
let Some(kind) = item.get("kind").and_then(Value::as_str) else {
continue;
};
if kind != "click" {
continue;
}
let Some(id) = element_id(item.get("element")) else {
continue;
};
let Some(selector) = elements
.iter()
.find(|element| u64::from(element.id) == id)
.and_then(|element| element.selector.clone())
else {
continue;
};
let page = cdp_call(
ctx,
json!({"action":"click","selector":selector,"ensure":false}),
)
.await;
if page.ok {
if let Some(object) = item.as_object_mut() {
object.insert("kind".into(), json!("wait"));
object.insert("ms".into(), json!(80));
}
}
}
}
}
let actions = match parse_computer_actions(args.get("actions").unwrap_or(&Value::Null)) { let actions = match parse_computer_actions(args.get("actions").unwrap_or(&Value::Null)) {
Ok(actions) => actions, Ok(actions) => actions,
Err(error) => { Err(error) => {
@ -308,6 +658,7 @@ async fn act(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
}; };
} }
}; };
let had_click = actions.iter().any(action_is_click);
match ctx match ctx
.sandbox .sandbox
.act( .act(
@ -315,7 +666,7 @@ async fn act(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
ActionRequest { ActionRequest {
actions, actions,
observe: args.get("observe").and_then(Value::as_bool) != Some(false), observe: args.get("observe").and_then(Value::as_bool) != Some(false),
settle_ms: args.get("settle_ms").and_then(Value::as_u64).unwrap_or(120) as u32, settle_ms: args.get("settle_ms").and_then(Value::as_u64).unwrap_or(350) as u32,
display: ctx.context.display.clone(), display: ctx.context.display.clone(),
profile_path: ctx.context.profile_path.clone(), profile_path: ctx.context.profile_path.clone(),
}, },
@ -325,11 +676,15 @@ async fn act(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
{ {
Ok(result) => { Ok(result) => {
if let Some(observation) = result.observation { if let Some(observation) = result.observation {
pack_observation( let unchanged =
frames_match(ctx.previous_frame.lock().unwrap().as_deref(), &observation);
let mut outcome = pack_observation(
ctx, ctx,
&format!("completed {} computer action(s)", result.completed), &format!("completed {} computer action(s)", result.completed),
observation, observation,
) );
note_click_result(ctx, had_click, unchanged, &mut outcome);
outcome
} else { } else {
ToolOutcome { ToolOutcome {
text: json!({"ok": true, "completed": result.completed}).to_string(), text: json!({"ok": true, "completed": result.completed}).to_string(),
@ -346,12 +701,61 @@ async fn act(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
} }
} }
fn action_is_click(action: &ComputerAction) -> bool {
matches!(
action,
ComputerAction::Pointer {
pointer_type: PointerType::Click | PointerType::Down,
..
}
)
}
/// A stale element id is the model's mistake, not a reason to park the run
/// on the human: tell it what is visible now and let it re-observe.
fn pause_unknown_element(_ctx: &ToolCtx, id: u32, elements: &[UiElement]) -> ToolOutcome {
ToolOutcome {
text: format!(
"element {id} is not on screen any more. Visible now: {}. Call browser snapshot or computer_observe to get fresh ids, then retry.",
format_ui_elements(elements)
),
image: None,
pause: false,
}
}
/// Clicks that change nothing are common and usually recoverable (disabled
/// button, video still playing, slightly off target). Coach the model instead
/// of pausing; it can still call request_takeover when it is truly stuck.
fn note_click_result(ctx: &ToolCtx, had_click: bool, unchanged: bool, outcome: &mut ToolOutcome) {
if !had_click {
return;
}
let mut streak = ctx.miss_streak.lock().unwrap();
if unchanged {
*streak += 1;
*ctx.click_misses.lock().unwrap() += 1;
if *streak >= 2 {
outcome.text.push_str(
"\nThe last clicks changed nothing. Do not repeat the same click. Options: the control may be disabled until a video/loading finishes (use wait, then re-observe); the target may be off (use browser snapshot and click by element id, or pick coordinates from a fresh computer_observe); if the page needs login, CAPTCHA or a human decision, call request_takeover.",
);
}
} else {
*streak = 0;
}
}
fn pack_observation(ctx: &ToolCtx, note: &str, observation: ComputerObservation) -> ToolOutcome { fn pack_observation(ctx: &ToolCtx, note: &str, observation: ComputerObservation) -> ToolOutcome {
let unchanged = frames_match(ctx.previous_frame.lock().unwrap().as_deref(), &observation); let unchanged = frames_match(ctx.previous_frame.lock().unwrap().as_deref(), &observation);
*ctx.previous_frame.lock().unwrap() = Some(observation.frame_id.clone()); *ctx.previous_frame.lock().unwrap() = Some(observation.frame_id.clone());
*ctx.elements.lock().unwrap() = observation.elements.clone();
ToolOutcome { ToolOutcome {
text: observation_text(note, &observation, unchanged), text: observation_text(note, &observation, unchanged),
image: if unchanged { None } else { Some(observation.image) }, image: if unchanged {
None
} else {
Some(overlay_elements(&observation.image, &observation.elements))
},
pause: false, pause: false,
} }
} }
@ -400,7 +804,11 @@ async fn list_files(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
}; };
} }
}; };
match ctx.sandbox.list_files(&ctx.computer, &stored, &ctx.context).await { match ctx
.sandbox
.list_files(&ctx.computer, &stored, &ctx.context)
.await
{
Ok(entries) => ToolOutcome { Ok(entries) => ToolOutcome {
text: serde_json::to_string(&entries).unwrap_or_else(|_| "[]".into()), text: serde_json::to_string(&entries).unwrap_or_else(|_| "[]".into()),
image: None, image: None,
@ -426,7 +834,11 @@ async fn read_file(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
}; };
} }
}; };
match ctx.sandbox.read_file(&ctx.computer, &stored, &ctx.context).await { match ctx
.sandbox
.read_file(&ctx.computer, &stored, &ctx.context)
.await
{
Ok(bytes) => ToolOutcome { Ok(bytes) => ToolOutcome {
text: String::from_utf8_lossy(&bytes).into_owned(), text: String::from_utf8_lossy(&bytes).into_owned(),
image: None, image: None,
@ -441,7 +853,10 @@ async fn read_file(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
} }
async fn write_file(ctx: &ToolCtx, args: &Value) -> ToolOutcome { async fn write_file(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
let requested = args.get("path").and_then(Value::as_str).unwrap_or("notes.txt"); let requested = args
.get("path")
.and_then(Value::as_str)
.unwrap_or("notes.txt");
let content = args.get("content").and_then(Value::as_str).unwrap_or(""); let content = args.get("content").and_then(Value::as_str).unwrap_or("");
let stored = match resolve_bot_workspace_path(ctx.mode, &ctx.bot_id, requested) { let stored = match resolve_bot_workspace_path(ctx.mode, &ctx.bot_id, requested) {
Ok(path) => path, Ok(path) => path,
@ -514,7 +929,10 @@ async fn launch_app(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
if let Some(blocked) = vision_guard(ctx) { if let Some(blocked) = vision_guard(ctx) {
return blocked; return blocked;
} }
let application = args.get("application").and_then(Value::as_str).unwrap_or("browser"); let application = args
.get("application")
.and_then(Value::as_str)
.unwrap_or("browser");
let uri = args.get("uri").and_then(Value::as_str).map(str::to_string); let uri = args.get("uri").and_then(Value::as_str).map(str::to_string);
match ctx match ctx
.sandbox .sandbox

245
crates/api/src/workspace.rs Normal file
View File

@ -0,0 +1,245 @@
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::routing::get;
use axum::{Json, Router};
use lazyboy_contracts::{ModelProvider, catalog_models, default_model_id};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::db::Actor;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route(
"/api/workspace/settings",
get(get_settings).patch(update_settings),
)
.route("/api/workspace/models", get(list_models))
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ProviderInfo {
id: &'static str,
name: &'static str,
needs_base_url: bool,
needs_key: bool,
default_base_url: Option<&'static str>,
default_model: Option<&'static str>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ModelChoice {
id: String,
name: String,
}
fn provider_info(provider: ModelProvider) -> ProviderInfo {
ProviderInfo {
id: provider.as_str(),
name: match provider {
ModelProvider::Xai => "xAI",
ModelProvider::OpencodeGo => "OpenCode Go",
ModelProvider::OpenaiCompatible => "OpenAI 相容",
ModelProvider::Openai => "OpenAI",
ModelProvider::Anthropic => "Anthropic",
ModelProvider::Openrouter => "OpenRouter",
},
needs_base_url: provider.requires_base_url(),
needs_key: provider.requires_api_key(),
default_base_url: provider.default_base_url(),
default_model: default_model_id(provider),
}
}
async fn actor(state: &AppState) -> Result<Actor, StatusCode> {
state
.bootstrap()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
async fn get_settings(State(state): State<AppState>) -> Result<Json<Value>, StatusCode> {
let actor = actor(&state).await?;
let space = state
.db
.get_space(&actor)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
let provider = space
.default_model_provider
.parse::<ModelProvider>()
.unwrap_or(ModelProvider::Xai);
let env_key_set = std::env::var(provider.env_key_name())
.ok()
.filter(|value| !value.is_empty())
.is_some();
Ok(Json(json!({
"provider": provider.as_str(),
"modelId": space.default_model_id,
"baseUrl": space.default_model_base_url.unwrap_or_default(),
"apiKeySet": space.default_model_api_key.as_deref().is_some_and(|value| !value.is_empty()),
"envKeySet": env_key_set,
"envKeyName": provider.env_key_name(),
"providers": ModelProvider::selectable().iter().copied().map(provider_info).collect::<Vec<_>>(),
"models": catalog_models(provider).iter().map(|(id, name)| ModelChoice { id: (*id).into(), name: (*name).into() }).collect::<Vec<_>>(),
})))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct UpdateSettings {
provider: String,
model_id: String,
#[serde(default)]
base_url: Option<String>,
#[serde(default)]
api_key: Option<String>,
#[serde(default)]
clear_api_key: bool,
}
async fn update_settings(
State(state): State<AppState>,
Json(input): Json<UpdateSettings>,
) -> Result<Json<Value>, StatusCode> {
let actor = actor(&state).await?;
let provider = input
.provider
.parse::<ModelProvider>()
.map_err(|_| StatusCode::BAD_REQUEST)?;
if !ModelProvider::selectable().contains(&provider) {
return Err(StatusCode::BAD_REQUEST);
}
let model_id = input.model_id.trim();
if model_id.is_empty() {
return Err(StatusCode::BAD_REQUEST);
}
let base_url = input
.base_url
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
if provider.requires_base_url() && base_url.is_none() {
return Err(StatusCode::BAD_REQUEST);
}
let current = state
.db
.get_space(&actor)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let provider_changed = current
.as_ref()
.is_some_and(|space| space.default_model_provider != provider.as_str());
let supplied = input
.api_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
// A stored key belongs to the provider it was entered for. Carrying it
// over to a new provider shadows the env key and every run fails with
// "Incorrect API key", so drop it unless a new one is supplied.
let api_key = if input.clear_api_key || (provider_changed && supplied.is_none()) {
Some(None)
} else {
supplied.map(Some)
};
state
.db
.update_workspace_model(&actor, provider.as_str(), model_id, base_url, api_key)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
get_settings(State(state)).await
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ModelsQuery {
provider: String,
#[serde(default)]
base_url: Option<String>,
}
async fn list_models(
State(state): State<AppState>,
Query(query): Query<ModelsQuery>,
) -> Result<Json<Value>, StatusCode> {
let actor = actor(&state).await?;
let provider = query
.provider
.parse::<ModelProvider>()
.map_err(|_| StatusCode::BAD_REQUEST)?;
let space = state.db.get_space(&actor).await.ok().flatten();
let fallback = catalog_models(provider)
.iter()
.map(|(id, name)| ModelChoice {
id: (*id).into(),
name: (*name).into(),
})
.collect::<Vec<_>>();
let env_key = std::env::var(provider.env_key_name())
.ok()
.filter(|value| !value.is_empty());
let live = fetch_remote_models(
provider,
query.base_url.as_deref().or(space
.as_ref()
.and_then(|row| row.default_model_base_url.as_deref())),
space
.as_ref()
.and_then(|row| row.default_model_api_key.as_deref())
.or(env_key.as_deref()),
)
.await
.unwrap_or_default();
let models = if live.is_empty() { fallback } else { live };
Ok(Json(json!({ "models": models })))
}
async fn fetch_remote_models(
provider: ModelProvider,
base_url: Option<&str>,
api_key: Option<&str>,
) -> Result<Vec<ModelChoice>, String> {
let base = base_url
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.trim_end_matches('/').to_string())
.or_else(|| provider.default_base_url().map(str::to_string))
.ok_or_else(|| "missing base URL".to_string())?;
let url = format!("{base}/models");
let mut request = reqwest::Client::new().get(url);
if let Some(key) = api_key.filter(|value| !value.is_empty()) {
request = request.bearer_auth(key);
}
let body: Value = request
.send()
.await
.map_err(|error| error.to_string())?
.error_for_status()
.map_err(|error| error.to_string())?
.json()
.await
.map_err(|error| error.to_string())?;
let items = body
.get("data")
.and_then(Value::as_array)
.cloned()
.or_else(|| body.as_array().cloned())
.unwrap_or_default();
Ok(items
.into_iter()
.filter_map(|item| {
let id = item.get("id")?.as_str()?.to_string();
let name = item
.get("name")
.and_then(Value::as_str)
.unwrap_or(&id)
.to_string();
Some(ModelChoice { id, name })
})
.collect())
}

View File

@ -77,6 +77,38 @@ pub struct ActiveWindow {
pub title: Option<String>, pub title: Option<String>,
} }
/// A numbered on-screen target the model can click without guessing pixels.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct UiElement {
pub id: u32,
pub title: String,
pub x: u32,
pub y: u32,
pub w: u32,
pub h: u32,
/// CSS selector for in-page controls (Chromium CDP). 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.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
}
impl UiElement {
pub fn center(&self) -> (u32, u32) {
(
self.x.saturating_add(self.w / 2),
self.y.saturating_add(self.h / 2),
)
}
/// Page controls outside the viewport are reported with zero size: they
/// can be clicked through the DOM but have no pixel position.
pub fn is_offscreen(&self) -> bool {
self.w == 0 && self.h == 0 && self.selector.is_some()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ComputerObservation { pub struct ComputerObservation {
pub frame_id: String, pub frame_id: String,
@ -88,11 +120,13 @@ pub struct ComputerObservation {
pub height: u32, pub height: u32,
pub cursor: Option<CursorPosition>, pub cursor: Option<CursorPosition>,
pub active_window: Option<ActiveWindow>, pub active_window: Option<ActiveWindow>,
#[serde(default)]
pub elements: Vec<UiElement>,
} }
mod serde_bytes_opt { mod serde_bytes_opt {
use base64::engine::general_purpose::STANDARD;
use base64::Engine; use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use serde::{Deserialize, Deserializer, Serializer}; use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error> { pub fn serialize<S: Serializer>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error> {

View File

@ -1,5 +1,5 @@
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::{ComputerMode, ModelProvider}; use crate::{ComputerMode, ModelProvider};

View File

@ -184,6 +184,19 @@ pub struct ComputerStatus {
pub screen_height: u32, pub screen_height: u32,
pub home_revision: Option<String>, pub home_revision: Option<String>,
pub busy_bot_name: Option<String>, pub busy_bot_name: Option<String>,
pub busy_session_id: Option<String>,
pub busy_run_id: Option<String>,
/// What the active run is doing right now ("思考中", "browser: click #12"…).
#[serde(default)]
pub busy_step: Option<String>,
/// Run paused in `waiting_takeover` (bot asked for, or user forced, control).
#[serde(default)]
pub waiting_run_id: Option<String>,
#[serde(default)]
pub waiting_session_id: Option<String>,
/// Messages queued behind the active/paused run for this bot.
#[serde(default)]
pub queued_runs: u32,
pub multi_screen: bool, pub multi_screen: bool,
pub screen_id: Option<String>, pub screen_id: Option<String>,
pub display: Option<String>, pub display: Option<String>,
@ -195,8 +208,10 @@ pub const DEFAULT_SCREEN_HEIGHT: u32 = 800;
pub const TEAM_SCREEN_LIMIT: u32 = 8; pub const TEAM_SCREEN_LIMIT: u32 = 8;
pub const MULTI_SCREEN_UNAVAILABLE: &str = "This computer does not support multiple screens. Desktop tools are already in use on the shared display. File and shell tools still work."; pub const MULTI_SCREEN_UNAVAILABLE: &str = "This computer does not support multiple screens. Desktop tools are already in use on the shared display. File and shell tools still work.";
pub const TEAM_SCREENS_FULL: &str = "This Team computer has no free screens left. File and shell tools still work."; pub const TEAM_SCREENS_FULL: &str =
pub const PROFILE_LOCKED: &str = "Another bot is using this shared browser profile. File and shell tools still work."; "This Team computer has no free screens left. File and shell tools still work.";
pub const PROFILE_LOCKED: &str =
"Another bot is using this shared browser profile. File and shell tools still work.";
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {

View File

@ -1,14 +1,18 @@
mod action; mod action;
mod bot; mod bot;
mod computer; mod computer;
mod mcp;
mod model; mod model;
mod room;
mod run; mod run;
mod session; mod session;
pub use action::*; pub use action::*;
pub use bot::*; pub use bot::*;
pub use computer::*; pub use computer::*;
pub use mcp::*;
pub use model::*; pub use model::*;
pub use room::*;
pub use run::*; pub use run::*;
pub use session::*; pub use session::*;

View File

@ -0,0 +1,66 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpServer {
pub id: String,
pub name: String,
pub transport: String,
pub command: Option<String>,
pub args: Vec<String>,
pub env: Map<String, Value>,
pub url: Option<String>,
pub headers: Map<String, Value>,
pub enabled: bool,
pub status: String,
pub error: Option<String>,
pub tools: Vec<McpTool>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpTool {
pub name: String,
pub exposed_name: String,
pub description: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct UpsertMcpServerInput {
pub name: String,
pub transport: String,
#[serde(default)]
pub command: Option<String>,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: Map<String, Value>,
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub headers: Map<String, Value>,
#[serde(default = "default_true")]
pub enabled: bool,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct PatchMcpServerInput {
pub name: Option<String>,
pub transport: Option<String>,
pub command: Option<String>,
pub args: Option<Vec<String>>,
pub env: Option<Map<String, Value>>,
pub url: Option<String>,
pub headers: Option<Map<String, Value>>,
pub enabled: Option<bool>,
}

View File

@ -2,9 +2,11 @@ use serde::{Deserialize, Serialize};
use thiserror::Error; use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "kebab-case")]
pub enum ModelProvider { pub enum ModelProvider {
Xai, Xai,
OpencodeGo,
OpenaiCompatible,
Openai, Openai,
Anthropic, Anthropic,
Openrouter, Openrouter,
@ -14,6 +16,8 @@ impl ModelProvider {
pub fn as_str(self) -> &'static str { pub fn as_str(self) -> &'static str {
match self { match self {
Self::Xai => "xai", Self::Xai => "xai",
Self::OpencodeGo => "opencode-go",
Self::OpenaiCompatible => "openai-compatible",
Self::Openai => "openai", Self::Openai => "openai",
Self::Anthropic => "anthropic", Self::Anthropic => "anthropic",
Self::Openrouter => "openrouter", Self::Openrouter => "openrouter",
@ -23,21 +27,36 @@ impl ModelProvider {
pub fn env_key_name(self) -> &'static str { pub fn env_key_name(self) -> &'static str {
match self { match self {
Self::Xai => "XAI_API_KEY", Self::Xai => "XAI_API_KEY",
Self::Openai => "OPENAI_API_KEY", Self::OpencodeGo => "OPENCODE_GO_API_KEY",
Self::OpenaiCompatible | Self::Openai => "OPENAI_API_KEY",
Self::Anthropic => "ANTHROPIC_API_KEY", Self::Anthropic => "ANTHROPIC_API_KEY",
Self::Openrouter => "OPENROUTER_API_KEY", Self::Openrouter => "OPENROUTER_API_KEY",
} }
} }
pub fn requires_api_key(self) -> bool {
!matches!(self, Self::OpenaiCompatible)
}
pub fn requires_base_url(self) -> bool {
matches!(self, Self::OpenaiCompatible)
}
/// First-party OpenAI-compatible base URL when we own the client mapping. /// First-party OpenAI-compatible base URL when we own the client mapping.
pub fn default_base_url(self) -> Option<&'static str> { pub fn default_base_url(self) -> Option<&'static str> {
match self { match self {
Self::Xai => Some("https://api.x.ai/v1"), Self::Xai => Some("https://api.x.ai/v1"),
Self::OpencodeGo => Some("https://opencode.ai/zen/go/v1"),
Self::OpenaiCompatible => None,
Self::Openai => Some("https://api.openai.com/v1"), Self::Openai => Some("https://api.openai.com/v1"),
Self::Anthropic => None, Self::Anthropic => None,
Self::Openrouter => Some("https://openrouter.ai/api/v1"), Self::Openrouter => Some("https://openrouter.ai/api/v1"),
} }
} }
pub fn selectable() -> &'static [Self] {
&[Self::Xai, Self::OpencodeGo, Self::OpenaiCompatible]
}
} }
impl std::str::FromStr for ModelProvider { impl std::str::FromStr for ModelProvider {
@ -46,6 +65,8 @@ impl std::str::FromStr for ModelProvider {
fn from_str(value: &str) -> Result<Self, Self::Err> { fn from_str(value: &str) -> Result<Self, Self::Err> {
match value { match value {
"xai" => Ok(Self::Xai), "xai" => Ok(Self::Xai),
"opencode-go" | "opencodego" => Ok(Self::OpencodeGo),
"openai-compatible" | "openai_compatible" | "local" => Ok(Self::OpenaiCompatible),
"openai" => Ok(Self::Openai), "openai" => Ok(Self::Openai),
"anthropic" => Ok(Self::Anthropic), "anthropic" => Ok(Self::Anthropic),
"openrouter" => Ok(Self::Openrouter), "openrouter" => Ok(Self::Openrouter),
@ -64,33 +85,94 @@ pub struct ModelCapabilities {
} }
pub const DEFAULT_XAI_MODEL: &str = "grok-4.6"; pub const DEFAULT_XAI_MODEL: &str = "grok-4.6";
pub const DEFAULT_OPENCODE_GO_MODEL: &str = "glm-5.1";
pub fn default_model_id(provider: ModelProvider) -> Option<&'static str> { pub fn default_model_id(provider: ModelProvider) -> Option<&'static str> {
match provider { match provider {
ModelProvider::Xai => Some(DEFAULT_XAI_MODEL), ModelProvider::Xai => Some(DEFAULT_XAI_MODEL),
ModelProvider::Openai | ModelProvider::Anthropic | ModelProvider::Openrouter => None, ModelProvider::OpencodeGo => Some(DEFAULT_OPENCODE_GO_MODEL),
ModelProvider::OpenaiCompatible
| ModelProvider::Openai
| ModelProvider::Anthropic
| ModelProvider::Openrouter => None,
}
}
pub fn catalog_models(provider: ModelProvider) -> &'static [(&'static str, &'static str)] {
match provider {
ModelProvider::Xai => &[
("grok-4.6", "Grok 4.6"),
("grok-4.5", "Grok 4.5"),
("grok-4", "Grok 4"),
("grok-3", "Grok 3"),
("grok-3-mini", "Grok 3 Mini"),
("grok-3-fast", "Grok 3 Fast"),
],
ModelProvider::OpencodeGo => &[
("glm-5.1", "GLM-5.1"),
("glm-5", "GLM-5"),
("glm-5.2", "GLM-5.2"),
("kimi-k2.6", "Kimi K2.6"),
("kimi-k2.5", "Kimi K2.5"),
("kimi-k2.7-code", "Kimi K2.7 Code"),
("deepseek-v4-flash", "DeepSeek V4 Flash"),
("deepseek-v4-pro", "DeepSeek V4 Pro"),
("mimo-v2.5-pro", "MiMo V2.5 Pro"),
("minimax-m2.7", "MiniMax M2.7"),
("qwen3.6-plus", "Qwen3.6 Plus"),
],
ModelProvider::OpenaiCompatible
| ModelProvider::Openai
| ModelProvider::Anthropic
| ModelProvider::Openrouter => &[],
} }
} }
/// Best-effort catalog. Unknown ids for a wired provider default to "no vision" /// Best-effort catalog. Unknown ids for a wired provider default to "no vision"
/// so we never shove screenshots at a model that may not accept them. /// so we never shove screenshots at a model that may not accept them.
pub fn model_capabilities(provider: ModelProvider, model_id: &str) -> ModelCapabilities { pub fn model_capabilities(provider: ModelProvider, model_id: &str) -> ModelCapabilities {
let id = model_id.to_ascii_lowercase();
let vision = match provider { let vision = match provider {
ModelProvider::Xai => { ModelProvider::Xai => {
let id = model_id.to_ascii_lowercase();
id.starts_with("grok-4") id.starts_with("grok-4")
|| id.contains("vision") || id.contains("vision")
|| id.starts_with("grok-2") || id.starts_with("grok-2")
|| id.starts_with("grok-3") || id.starts_with("grok-3")
} }
ModelProvider::Openai => { ModelProvider::OpencodeGo => id.contains("vision") || id.contains("omni"),
let id = model_id.to_ascii_lowercase(); // OpenAI-compatible is also how OpenRouter / LiteLLM / vLLM gateways
id.contains("gpt-4o") || id.contains("gpt-5") || id.contains("vision") // are reached, so the id may be any vendor's multimodal model.
ModelProvider::OpenaiCompatible | ModelProvider::Openai => {
id.contains("gpt-4o")
|| id.contains("gpt-4.1")
|| id.contains("gpt-5")
|| id.starts_with("o3")
|| id.starts_with("o4")
|| id.contains("vision")
|| id.contains("llava")
|| id.contains("omni")
|| id.contains("claude")
|| id.contains("gemini")
|| id.contains("grok-4")
|| (id.contains("qwen") && (id.contains("vl") || id.contains("qwen3")))
|| id.contains("glm-4v")
|| id.contains("glm-4.5v")
|| id.contains("glm-5v")
|| id.contains("ui-tars")
|| id.contains("pixtral")
|| id.contains("internvl")
|| id.contains("llama-4")
|| id.contains("kimi-k2.5")
|| id.contains("kimi-k2.6")
|| id.contains("kimi-k2.7")
|| id.contains("computer-use")
} }
ModelProvider::Anthropic => model_id.to_ascii_lowercase().contains("claude"), ModelProvider::Anthropic => id.contains("claude"),
ModelProvider::Openrouter => { ModelProvider::Openrouter => {
let id = model_id.to_ascii_lowercase(); id.contains("vision")
id.contains("vision") || id.contains("gpt-4o") || id.contains("claude") || id.contains("grok") || id.contains("gpt-4o")
|| id.contains("claude")
|| id.contains("grok")
} }
}; };
ModelCapabilities { vision } ModelCapabilities { vision }

View File

@ -0,0 +1,35 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RoomMember {
pub id: String,
pub name: String,
pub avatar_color: String,
pub avatar_shape: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Room {
pub id: String,
pub name: String,
pub members: Vec<RoomMember>,
pub last_message_at: Option<DateTime<Utc>>,
pub last_preview: Option<String>,
pub unread_count: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateRoomInput {
pub name: String,
pub member_ids: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RoomStatus {
pub busy: Vec<RoomMember>,
}

View File

@ -30,7 +30,11 @@ impl RunStatus {
pub fn is_active(self) -> bool { pub fn is_active(self) -> bool {
matches!( matches!(
self, self,
Self::Queued | Self::Leased | Self::Running | Self::WaitingInput | Self::WaitingTakeover Self::Queued
| Self::Leased
| Self::Running
| Self::WaitingInput
| Self::WaitingTakeover
) )
} }
@ -52,8 +56,14 @@ impl RunStatus {
| Self::Cancelled | Self::Cancelled
| Self::Leased | Self::Leased
) )
| (Self::WaitingInput, Self::Queued | Self::Leased | Self::Cancelled) | (
| (Self::WaitingTakeover, Self::Queued | Self::Leased | Self::Cancelled) Self::WaitingInput,
Self::Queued | Self::Leased | Self::Cancelled
)
| (
Self::WaitingTakeover,
Self::Queued | Self::Leased | Self::Cancelled
)
| (Self::Failed, Self::Queued) | (Self::Failed, Self::Queued)
) )
} }

View File

@ -42,6 +42,10 @@ pub struct SessionMessage {
pub run_id: Option<String>, pub run_id: Option<String>,
pub client_nonce: Option<String>, pub client_nonce: Option<String>,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub speaker_bot_id: Option<String>,
pub speaker_name: Option<String>,
pub speaker_color: Option<String>,
pub speaker_shape: Option<String>,
} }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@ -51,4 +55,16 @@ pub struct SendSessionMessageInput {
pub client_nonce: Option<String>, pub client_nonce: Option<String>,
#[serde(default)] #[serde(default)]
pub blocks: Vec<Value>, pub blocks: Vec<Value>,
#[serde(default)]
pub attachments: Vec<SessionAttachment>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct SessionAttachment {
pub name: String,
#[serde(default)]
pub mime_type: String,
#[serde(alias = "contentBase64")]
pub content: String,
} }

View File

@ -14,3 +14,4 @@ sha2.workspace = true
hex.workspace = true hex.workspace = true
chrono.workspace = true chrono.workspace = true
async-trait = "0.1" async-trait = "0.1"
image.workspace = true

View File

@ -1,5 +1,5 @@
use lazyboy_contracts::{ComputerAction, PointerButton, PointerType, ScrollDirection}; use lazyboy_contracts::{ComputerAction, PointerButton, PointerType, ScrollDirection, UiElement};
use serde_json::Value; use serde_json::{Value, json};
use thiserror::Error; use thiserror::Error;
pub const MAX_COMPUTER_ACTIONS: usize = 24; pub const MAX_COMPUTER_ACTIONS: usize = 24;
@ -18,6 +18,54 @@ pub enum ActionError {
Unsupported(String), Unsupported(String),
#[error("computer action {0} must be a non-negative coordinate")] #[error("computer action {0} must be a non-negative coordinate")]
BadCoordinate(&'static str), BadCoordinate(&'static str),
#[error("computer action element {0} is not on the current screen")]
UnknownElement(u32),
#[error(
"element {0} is outside the browser viewport, so it has no screen position. Use browser {{\"action\":\"click\",\"element\":{0}}} (it scrolls into view) or scroll the page first."
)]
OffscreenElement(u32),
}
/// Models write element ids as `3`, `3.0`, `"3"` or `"[3]"`; accept them all
/// instead of failing the click.
pub fn element_id(value: Option<&Value>) -> Option<u64> {
match value? {
Value::Number(number) => number
.as_u64()
.or_else(|| number.as_f64().filter(|f| *f >= 0.0).map(|f| f.round() as u64)),
Value::String(text) => text
.trim()
.trim_start_matches(['#', '['])
.trim_end_matches(']')
.parse()
.ok(),
_ => None,
}
}
/// Fill x/y from a numbered on-screen element so the model can click by id.
pub fn apply_element_targets(value: &mut Value, elements: &[UiElement]) -> Result<(), ActionError> {
let Some(items) = value.as_array_mut() else {
return Ok(());
};
for raw in items {
let Some(action) = raw.as_object_mut() else {
continue;
};
let Some(id) = element_id(action.get("element")) else {
continue;
};
let Some(element) = elements.iter().find(|element| u64::from(element.id) == id) else {
return Err(ActionError::UnknownElement(id as u32));
};
if element.is_offscreen() {
return Err(ActionError::OffscreenElement(id as u32));
}
let (x, y) = element.center();
action.insert("x".into(), json!(x));
action.insert("y".into(), json!(y));
}
Ok(())
} }
pub fn parse_computer_actions(value: &Value) -> Result<Vec<ComputerAction>, ActionError> { pub fn parse_computer_actions(value: &Value) -> Result<Vec<ComputerAction>, ActionError> {
@ -61,7 +109,8 @@ pub fn parse_computer_actions(value: &Value) -> Result<Vec<ComputerAction>, Acti
pointer_type, pointer_type,
button: Some(button), button: Some(button),
}; };
let doubled = action.get("double").and_then(Value::as_bool) == Some(true) && kind == "click"; let doubled =
action.get("double").and_then(Value::as_bool) == Some(true) && kind == "click";
actions.push(pointer.clone()); actions.push(pointer.clone());
if doubled { if doubled {
actions.push(ComputerAction::Wait { ms: 70 }); actions.push(ComputerAction::Wait { ms: 70 });
@ -83,11 +132,17 @@ pub fn parse_computer_actions(value: &Value) -> Result<Vec<ComputerAction>, Acti
let x = coordinate(action.get("x"), "x")?; let x = coordinate(action.get("x"), "x")?;
let y = coordinate(action.get("y"), "y")?; let y = coordinate(action.get("y"), "y")?;
let to_x = coordinate( let to_x = coordinate(
action.get("x2").or_else(|| action.get("toX")).or_else(|| action.get("to_x")), action
.get("x2")
.or_else(|| action.get("toX"))
.or_else(|| action.get("to_x")),
"x2", "x2",
)?; )?;
let to_y = coordinate( let to_y = coordinate(
action.get("y2").or_else(|| action.get("toY")).or_else(|| action.get("to_y")), action
.get("y2")
.or_else(|| action.get("toY"))
.or_else(|| action.get("to_y")),
"y2", "y2",
)?; )?;
let button = if action.get("button").and_then(Value::as_str) == Some("right") { let button = if action.get("button").and_then(Value::as_str) == Some("right") {
@ -130,13 +185,16 @@ pub fn parse_computer_actions(value: &Value) -> Result<Vec<ComputerAction>, Acti
.and_then(Value::as_str) .and_then(Value::as_str)
.unwrap_or("") .unwrap_or("")
.to_string(); .to_string();
let modifiers = action.get("modifiers").and_then(Value::as_array).map(|items| { let modifiers = action
items .get("modifiers")
.iter() .and_then(Value::as_array)
.filter_map(Value::as_str) .map(|items| {
.map(str::to_string) items
.collect() .iter()
}); .filter_map(Value::as_str)
.map(str::to_string)
.collect()
});
actions.push(ComputerAction::Key { key, modifiers }); actions.push(ComputerAction::Key { key, modifiers });
} }
"scroll" => { "scroll" => {
@ -174,11 +232,13 @@ pub fn parse_computer_actions(value: &Value) -> Result<Vec<ComputerAction>, Acti
ms: bounded(action.get("ms"), 0, 1_200, 80), ms: bounded(action.get("ms"), 0, 1_200, 80),
}); });
} }
other => return Err(ActionError::Unsupported(if other.is_empty() { other => {
"(missing)".to_string() return Err(ActionError::Unsupported(if other.is_empty() {
} else { "(missing)".to_string()
other.to_string() } else {
})), other.to_string()
}));
}
} }
} }
if actions.len() > MAX_COMPUTER_ACTIONS { if actions.len() > MAX_COMPUTER_ACTIONS {
@ -210,6 +270,18 @@ mod tests {
use super::*; use super::*;
use serde_json::json; use serde_json::json;
#[test]
fn element_ids_accept_common_model_spellings() {
assert_eq!(element_id(Some(&json!(3))), Some(3));
assert_eq!(element_id(Some(&json!(3.0))), Some(3));
assert_eq!(element_id(Some(&json!("3"))), Some(3));
assert_eq!(element_id(Some(&json!("#12"))), Some(12));
assert_eq!(element_id(Some(&json!("[7]"))), Some(7));
assert_eq!(element_id(Some(&json!("Learn more"))), None);
assert_eq!(element_id(Some(&json!(-1))), None);
assert_eq!(element_id(None), None);
}
#[test] #[test]
fn parses_click_and_double_click() { fn parses_click_and_double_click() {
let actions = parse_computer_actions(&json!([ let actions = parse_computer_actions(&json!([
@ -253,7 +325,8 @@ mod tests {
#[test] #[test]
fn scroll_defaults_to_a_real_page_chunk() { fn scroll_defaults_to_a_real_page_chunk() {
let actions = parse_computer_actions(&json!([{"kind": "scroll", "direction": "down"}])).unwrap(); let actions =
parse_computer_actions(&json!([{"kind": "scroll", "direction": "down"}])).unwrap();
match &actions[0] { match &actions[0] {
ComputerAction::Scroll { amount, .. } => assert_eq!(*amount, Some(12)), ComputerAction::Scroll { amount, .. } => assert_eq!(*amount, Some(12)),
other => panic!("{other:?}"), other => panic!("{other:?}"),
@ -262,11 +335,61 @@ mod tests {
#[test] #[test]
fn rejects_empty_and_overflow() { fn rejects_empty_and_overflow() {
assert_eq!(parse_computer_actions(&json!([])).unwrap_err(), ActionError::Empty); assert_eq!(
parse_computer_actions(&json!([])).unwrap_err(),
ActionError::Empty
);
let too_many: Vec<_> = (0..25).map(|_| json!({"kind": "wait", "ms": 1})).collect(); let too_many: Vec<_> = (0..25).map(|_| json!({"kind": "wait", "ms": 1})).collect();
assert_eq!( assert_eq!(
parse_computer_actions(&json!(too_many)).unwrap_err(), parse_computer_actions(&json!(too_many)).unwrap_err(),
ActionError::TooMany ActionError::TooMany
); );
} }
#[test]
fn element_id_fills_click_coordinates() {
let mut actions = json!([{"kind": "click", "element": 1}]);
let elements = vec![UiElement {
id: 1,
title: "Terminal".into(),
x: 10,
y: 20,
w: 100,
h: 40,
..UiElement::default()
}];
apply_element_targets(&mut actions, &elements).unwrap();
assert_eq!(actions[0]["x"], 60);
assert_eq!(actions[0]["y"], 40);
let parsed = parse_computer_actions(&actions).unwrap();
assert!(matches!(
parsed[0],
ComputerAction::Pointer { x: 60, y: 40, .. }
));
}
#[test]
fn unknown_element_errors() {
let mut actions = json!([{"kind": "click", "element": 9}]);
assert_eq!(
apply_element_targets(&mut actions, &[]).unwrap_err(),
ActionError::UnknownElement(9)
);
}
#[test]
fn offscreen_page_elements_cannot_be_pixel_clicked() {
let below_fold = UiElement {
id: 3,
title: "Next [below viewport]".into(),
selector: Some("[data-lazyboy=\"3\"]".into()),
kind: Some("dom".into()),
..UiElement::default()
};
let mut actions = json!([{"kind": "click", "element": 3}]);
assert_eq!(
apply_element_targets(&mut actions, &[below_fold]).unwrap_err(),
ActionError::OffscreenElement(3)
);
}
} }

670
crates/control/src/cdp.py Normal file
View File

@ -0,0 +1,670 @@
import json, os, sys, time, socket, base64, struct, subprocess, urllib.request
def fail(msg):
print(json.dumps({"ok": False, "error": msg}))
sys.exit(0)
def http_json(url, timeout=2):
try:
with urllib.request.urlopen(url, timeout=timeout) as r:
return json.loads(r.read().decode())
except Exception:
return None
class Ws:
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
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())
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:
obj = self.recv_json()
if obj.get("id") == self.n:
if "error" in obj:
raise RuntimeError(str(obj["error"]))
return obj.get("result") or {}
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
def profile_alive(profile):
if not profile:
return False
try:
out = subprocess.check_output(["pgrep", "-af", "chromium"], text=True, stderr=subprocess.DEVNULL)
except Exception:
return False
for line in out.splitlines():
if ("--user-data-dir=%s" % profile) in line and "--type=" not in line:
return True
return False
def active_port(profile):
# Chromium writes the DevTools port it actually bound here. Trust it over
# our expected port so we attach to the window the human already sees.
if not profile:
return None
try:
with open(os.path.join(profile, "DevToolsActivePort")) as f:
return int(f.readline().strip())
except Exception:
return None
def spawn_browser(display, profile, port):
env = os.environ.copy()
env["DISPLAY"] = display
if profile:
env["LAZYBOY_BROWSER_PROFILE"] = profile
subprocess.Popen(
["lazyboy-browser", "--remote-debugging-port=%s" % port, "--remote-allow-origins=*"],
env=env,
start_new_session=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def connect(port):
tabs = http_json("http://127.0.0.1:%s/json/list" % port) or []
pages = [t for t in tabs if t.get("type") == "page" and t.get("webSocketDebuggerUrl")]
if not pages:
fail("no browser tab")
# /json/list is ordered by last activity, so pages[0] is the tab the human
# is looking at. Bring it to the front anyway so what the model reads and
# clicks is always the tab shown on the live screen.
pages.sort(key=lambda t: (t.get("url") or "").startswith("chrome://"), reverse=False)
page = pages[0]
if page.get("id"):
http_json("http://127.0.0.1:%s/json/activate/%s" % (port, page["id"]))
ws = Ws(page["webSocketDebuggerUrl"])
ws.call("Runtime.enable")
ws.call("Page.enable")
return ws
SNAP_JS = r"""
(() => {
const sels = 'a, button, input, textarea, select, summary, label, [role="button"], [role="link"], [role="textbox"], [role="checkbox"], [role="menuitem"], [contenteditable="true"]';
const chromeH = Math.max(0, (window.outerHeight || 0) - (window.innerHeight || 0));
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;
const seen = new Set();
const inView = [];
const offView = [];
for (const el of document.querySelectorAll(sels)) {
const r = el.getBoundingClientRect();
if (r.width < 4 || r.height < 4) continue;
const st = getComputedStyle(el);
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")) {
// 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;
const label = (owner && owner.innerText || el.getAttribute("aria-label") || el.value || "").replace(/\s+/g, " ").trim().slice(0, 70);
text = type + " " + label + (el.checked ? " (checked)" : "");
} else {
text = (el.innerText || el.value || el.getAttribute("aria-label") || el.getAttribute("placeholder") || el.getAttribute("name") || el.tagName)
.replace(/\s+/g, " ").trim().slice(0, 80);
}
if (!text.trim()) continue;
const key = [el.tagName, text, Math.round(r.x), Math.round(r.y)].join("|");
if (seen.has(key)) continue;
seen.add(key);
if (el.disabled || el.getAttribute("aria-disabled") === "true") text += " [disabled]";
const visible = !(r.bottom < 0 || r.right < 0 || r.top > innerHeight || r.left > innerWidth);
if (visible) {
inView.push({el, text, x: Math.max(0, Math.round(sx0 + r.x)), y: Math.max(0, Math.round(sy0 + r.y)), w: Math.round(r.width), h: Math.round(r.height)});
} else {
// Controls outside the viewport are still clickable by id: the click
// handler scrolls them into view. Zero size tells the desktop side not
// to paint or pixel-click them.
const where = r.top > innerHeight ? "below" : r.bottom < 0 ? "above" : "beside";
// Buttons and form controls (Next, Submit, radios) matter more than the
// hundredth body link, so they win the limited off-screen slots.
const link = el.tagName === "A" || el.getAttribute("role") === "link";
offView.push({el, text: text + " [" + where + " viewport]", x: 0, y: 0, w: 0, h: 0, rank: (link ? 1 : 0), dist: Math.abs(r.top > innerHeight ? r.top - innerHeight : r.bottom)});
}
}
offView.sort((a, b) => a.rank - b.rank || a.dist - b.dist);
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));
out.push({
id: n,
title: item.text,
tag: item.el.tagName.toLowerCase(),
selector: '[data-lazyboy="' + n + '"]',
kind: "dom",
x: item.x,
y: item.y,
w: item.w,
h: item.h
});
n += 1;
}
const body = (document.body && document.body.innerText || "").replace(/\s+/g, " ").trim().slice(0, 3000);
return {url: location.href, title: document.title || "", text: body, elements: out};
})()
"""
CLICK_JS = r"""
(sel) => {
const el = document.querySelector(sel);
if (!el) return {ok: false, error: "element gone"};
el.scrollIntoView({block: "center", inline: "nearest"});
const r = el.getBoundingClientRect();
el.focus();
el.click();
const chromeH = Math.max(0, (window.outerHeight || 0) - (window.innerHeight || 0));
const chromeW = Math.max(0, (window.outerWidth || 0) - (window.innerWidth || 0));
const sx = (window.screenX || 0) + Math.floor(chromeW / 2) + r.x + r.width / 2;
const sy = (window.screenY || 0) + chromeH + r.y + r.height / 2;
return {ok: true, x: Math.round(sx), y: Math.round(sy)};
}
"""
STATE_JS = r"""
(sel) => {
const el = document.querySelector(sel);
if (!el) return {found: false};
const disabled = !!el.disabled || el.getAttribute("aria-disabled") === "true";
// Training sites explain the lock next to the button ("Please watch the
// video", a countdown); surface that text so the model can decide how
// long to wait.
let hint = "";
const near = el.parentElement && el.parentElement.parentElement;
if (disabled && near) hint = (near.innerText || "").replace(/\s+/g, " ").trim().slice(0, 120);
return {found: true, disabled, hint};
}
"""
# Pages often lock Next for a few seconds (stay timers) or until a video ends.
# A human just waits and clicks; do the same instead of making the model plan
# a wait/observe/click loop it tends to abandon.
CLICK_WAIT_MS = 45000
def wait_until_enabled(ws, sel, wait_ms=None):
budget = CLICK_WAIT_MS if wait_ms is None else max(0, min(int(wait_ms), 120000))
started = time.time()
while True:
state = evaluate(ws, STATE_JS, sel) or {}
if not state.get("found"):
return None
if not state.get("disabled"):
return time.time() - started
if (time.time() - started) * 1000 >= budget:
return None
time.sleep(0.5)
def evaluate(ws, expression, args=None):
params = {"expression": expression, "returnByValue": True, "awaitPromise": True}
if args is not None:
params = {
"expression": "(%s)(%s)" % (expression, json.dumps(args)),
"returnByValue": True,
"awaitPromise": True,
}
result = ws.call("Runtime.evaluate", params)
val = (result.get("result") or {}).get("value")
if result.get("exceptionDetails"):
raise RuntimeError(str(result["exceptionDetails"]))
return val
def wait_for_visual_update(ws):
# CDP input and DOM clicks can complete before Chromium commits the next
# painted frame. The caller captures X11 immediately after this process
# 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)))")
except Exception:
pass
def snapshot(ws):
val = evaluate(ws, SNAP_JS) or {}
return {
"ok": True,
"action": "snapshot",
"url": val.get("url") or "",
"title": val.get("title") or "",
"text": val.get("text") or "",
"elements": val.get("elements") or [],
}
def pointer(display, x, y):
try:
subprocess.check_call(
["env", "DISPLAY=%s" % display, "xdotool", "mousemove", "--sync", "--", str(int(x)), str(int(y))],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except Exception:
pass
KEYS = {
"Return": (13, "Enter", "Enter"),
"Enter": (13, "Enter", "Enter"),
"Tab": (9, "Tab", "Tab"),
"BackSpace": (8, "Backspace", "Backspace"),
"Backspace": (8, "Backspace", "Backspace"),
"Escape": (27, "Escape", "Escape"),
"Esc": (27, "Escape", "Escape"),
"Space": (32, " ", "Space"),
}
def press(ws, key):
spec = KEYS.get(key) or KEYS.get(key.title())
if spec:
code, name, key_id = spec
for typ in ("keyDown", "keyUp"):
ws.call("Input.dispatchKeyEvent", {
"type": typ,
"windowsVirtualKeyCode": code,
"key": name,
"code": key_id,
})
return
ws.call("Input.dispatchKeyEvent", {"type": "keyDown", "text": key[:1]})
ws.call("Input.dispatchKeyEvent", {"type": "keyUp", "text": key[:1]})
# Injected into every page while a human demonstrates a task. It reports what
# the person did in terms of page semantics (which control, what text, which
# URL) rather than pixels, so the distilled skill can generalise. Secrets are
# masked before they leave the page.
RECORD_JS = r"""
(() => {
if (window.__lbTeachInstalled) return;
window.__lbTeachInstalled = true;
const send = (ev) => { try { ev.at = Date.now(); ev.url = location.href; window.__lbTeach(JSON.stringify(ev)); } catch (e) {} };
const clean = (s) => (s || "").replace(/\s+/g, " ").trim().slice(0, 120);
const secretRe = /pass|pwd|secret|token|otp|cvv|card|pin\b/i;
const isSecret = (el) => !el ? false : (el.type === "password" || secretRe.test(el.name || "") || secretRe.test(el.id || "") || secretRe.test(el.autocomplete || "") || secretRe.test(el.getAttribute && el.getAttribute("aria-label") || ""));
const labelFor = (el) => {
if (!el) return "";
if (el.labels && el.labels.length) return clean(el.labels[0].innerText);
const id = el.id && document.querySelector('label[for="' + el.id + '"]');
if (id) return clean(id.innerText);
return clean(el.getAttribute("aria-label") || el.placeholder || el.title || el.name || "");
};
const describe = (el) => {
if (!el || el.nodeType !== 1) return null;
const tag = el.tagName.toLowerCase();
const d = { tag, role: el.getAttribute("role") || "", text: clean(el.innerText || el.value || el.alt || el.getAttribute("aria-label") || el.title || el.placeholder || ""), label: labelFor(el) };
if (el.id) d.id = el.id;
if (el.name) d.name = el.name;
if (tag === "a" && el.href) d.href = el.href.slice(0, 200);
if (tag === "input") d.type = el.type || "text";
return d;
};
const actionable = (node) => {
let el = node;
for (let i = 0; el && i < 6; i++) {
if (el.nodeType === 1) {
const t = el.tagName.toLowerCase();
if (["a","button","input","select","textarea","summary","label","option"].includes(t) || el.getAttribute("role") || el.onclick || el.getAttribute("tabindex") !== null || el.isContentEditable) return el;
}
el = el.parentNode;
}
return node && node.nodeType === 1 ? node : null;
};
send({ t: "page", title: document.title });
document.addEventListener("click", (e) => {
const el = actionable(e.target);
const d = describe(el);
if (d) send({ t: "click", el: d, x: Math.round(e.clientX), y: Math.round(e.clientY) });
}, true);
const pending = new Map();
const flush = (el) => {
pending.delete(el);
const d = describe(el);
if (!d) return;
let value = el.isContentEditable ? el.innerText : (el.value || "");
if (el.tagName === "SELECT" && el.selectedOptions && el.selectedOptions[0]) value = el.selectedOptions[0].text;
if (el.type === "checkbox" || el.type === "radio") value = el.checked ? "checked" : "unchecked";
send({ t: "input", el: d, value: isSecret(el) ? "[redacted]" : clean(value) });
};
document.addEventListener("input", (e) => {
const el = e.target;
if (!el || !(el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable)) return;
clearTimeout(pending.get(el));
pending.set(el, setTimeout(() => flush(el), 900));
}, true);
document.addEventListener("change", (e) => { const el = e.target; if (el && el.nodeType === 1) { clearTimeout(pending.get(el)); flush(el); } }, true);
document.addEventListener("keydown", (e) => {
const special = ["Enter","Escape","Tab"].includes(e.key) || e.ctrlKey || e.metaKey || e.altKey;
if (!special || e.key === "Control" || e.key === "Meta" || e.key === "Alt" || e.key === "Shift") return;
const el = document.activeElement;
if (el && pending.has(el)) { clearTimeout(pending.get(el)); flush(el); }
const combo = [e.ctrlKey ? "Ctrl" : "", e.metaKey ? "Meta" : "", e.altKey ? "Alt" : "", e.shiftKey ? "Shift" : "", e.key].filter(Boolean).join("+");
send({ t: "key", key: combo, el: describe(el) });
}, true);
document.addEventListener("submit", (e) => { const f = e.target; send({ t: "submit", form: { action: (f && f.action || "").slice(0, 200), name: f && (f.name || f.id) || "" } }); }, true);
let lastScroll = 0;
window.addEventListener("scroll", () => { const now = Date.now(); if (now - lastScroll > 2000) { lastScroll = now; send({ t: "scroll", y: Math.round(window.scrollY) }); } }, true);
})()
"""
class Recorder:
"""Browser-level CDP session with flattened page sessions. Events from
every tab are appended to a JSONL file until the process is killed."""
def __init__(self, port, out):
info = http_json("http://127.0.0.1:%s/json/version" % port) or {}
url = info.get("webSocketDebuggerUrl")
if not url:
raise RuntimeError("browser has no DevTools endpoint")
self.ws = Ws(url)
self.out = open(out, "a", buffering=1)
self.sessions = {}
self.pending = []
self.last = (None, 0)
def emit(self, ev):
ev.setdefault("at", int(time.time() * 1000))
# Two sessions on one page (auto-attach + explicit) deliver the same
# binding call twice; a key repeat is never that fast either.
key = json.dumps({k: v for k, v in ev.items() if k != "at"}, sort_keys=True)
if key == self.last[0] and ev["at"] - self.last[1] < 800:
return
self.last = (key, ev["at"])
self.out.write(json.dumps(ev, ensure_ascii=False) + "\n")
def call(self, method, params=None, session=None):
self.ws.n += 1
msg = {"id": self.ws.n, "method": method}
if params:
msg["params"] = params
if session:
msg["sessionId"] = session
self.ws.sock.sendall(self.ws._frame(json.dumps(msg).encode()))
while True:
obj = self.ws.recv_json()
if obj.get("id") == self.ws.n:
if "error" in obj:
raise RuntimeError(str(obj["error"]))
return obj.get("result") or {}
self.pending.append(obj)
def attach(self, session, target):
if target.get("type") != "page" or not session:
return
target_id = target.get("targetId")
if session in self.sessions:
return
if target_id in self.sessions.values():
try:
self.call("Target.detachFromTarget", {"sessionId": session})
except Exception:
pass
return
self.sessions[session] = target_id
for method, params in (
("Runtime.enable", None),
("Page.enable", None),
("Runtime.addBinding", {"name": "__lbTeach"}),
("Page.addScriptToEvaluateOnNewDocument", {"source": RECORD_JS}),
("Runtime.evaluate", {"expression": RECORD_JS}),
):
try:
self.call(method, params, session)
except Exception:
pass
def handle(self, obj):
method = obj.get("method")
params = obj.get("params") or {}
if method == "Target.attachedToTarget":
self.attach(params.get("sessionId"), params.get("targetInfo") or {})
elif method == "Target.detachedFromTarget":
self.sessions.pop(params.get("sessionId"), None)
elif method == "Runtime.bindingCalled" and params.get("name") == "__lbTeach":
try:
self.emit(json.loads(params.get("payload") or "{}"))
except Exception:
pass
elif method == "Page.frameNavigated":
frame = params.get("frame") or {}
if not frame.get("parentId"):
self.emit({"t": "navigate", "url": frame.get("url") or ""})
elif method == "Target.targetInfoChanged":
info = params.get("targetInfo") or {}
if info.get("type") == "page" and info.get("title"):
self.emit({"t": "title", "url": info.get("url") or "", "title": info.get("title")})
def run(self):
self.ws.sock.settimeout(None)
self.call("Target.setDiscoverTargets", {"discover": True})
self.call("Target.setAutoAttach", {"autoAttach": True, "waitForDebuggerOnStart": False, "flatten": True})
for target in (self.call("Target.getTargets") or {}).get("targetInfos", []):
if target.get("type") == "page":
try:
result = self.call("Target.attachToTarget", {"targetId": target["targetId"], "flatten": True})
self.attach(result.get("sessionId"), target)
except Exception:
pass
self.emit({"t": "recorder", "state": "started"})
while True:
while self.pending:
self.handle(self.pending.pop(0))
self.handle(self.ws.recv_json())
def main():
req = json.loads(sys.argv[1])
action = req.get("action") or "snapshot"
display = req.get("display") or ":1"
profile = req.get("profile") or ""
port = int(req.get("port") or 9222)
ensure = bool(req.get("ensure"))
if action == "probe":
print(json.dumps({"ok": probe(port)}))
return
def bound_port():
if probe(port):
return port
bound = active_port(profile)
if bound and bound != port and probe(bound):
return bound
return None
restarted = False
ready_port = bound_port()
if ready_port is None and profile_alive(profile):
# The window may still be booting (lazyboy-screen just spawned it).
for _ in range(12):
time.sleep(0.25)
ready_port = bound_port()
if ready_port is not None:
break
if ready_port is not None:
port = ready_port
else:
if profile_alive(profile):
# Never kill the window the human is watching. Fall back to the
# screenshot tools, which see exactly what the live screen shows.
fail("browser is open but has no DevTools; use computer_observe/computer_act on it instead. Do not restart the browser.")
if not ensure:
fail("cdp unavailable")
spawn_browser(display, profile, port)
ready = False
for _ in range(24):
time.sleep(0.25)
if probe(port):
ready = True
break
if not ready:
fail("cdp unavailable")
restarted = True
if action == "ensure":
print(json.dumps({"ok": True, "restarted": restarted}))
return
if action == "record":
# Long-running: the API starts this detached and kills it on stop.
out = req.get("out") or "/tmp/lazyboy-teach.jsonl"
Recorder(port, out).run()
return
ws = connect(port)
try:
if action == "snapshot":
body = snapshot(ws)
body["restarted"] = restarted
print(json.dumps(body))
return
if action == "navigate":
url = req.get("url") or ""
if not url:
fail("url required")
ws.call("Page.navigate", {"url": url})
time.sleep(1.2)
body = snapshot(ws)
body["restarted"] = restarted
print(json.dumps(body))
return
if action == "click":
sel = req.get("selector") or ""
if not sel:
fail("selector required")
waited = wait_until_enabled(ws, sel, req.get("waitMs"))
if waited is None:
state = evaluate(ws, STATE_JS, sel) or {}
if not state.get("found"):
# Ids are renumbered whenever the page changes; hand back
# the fresh numbering so the model does not have to ask.
body = snapshot(ws)
body.update({"ok": False, "error": "element gone: the page changed and ids were renumbered. Use the fresh element list in this result."})
print(json.dumps(body))
return
fail("control %s is still disabled after waiting %ss (page says: %s). Use wait for longer if a video or timer must finish, then click again."
% (sel, int(req.get("waitMs") or CLICK_WAIT_MS) // 1000, state.get("hint") or "nothing"))
val = evaluate(ws, CLICK_JS, sel) or {}
if not val.get("ok"):
fail(val.get("error") or "click failed")
pointer(display, val.get("x") or 0, val.get("y") or 0)
wait_for_visual_update(ws)
out = {"ok": True, "action": "click", "selector": sel, "restarted": restarted}
if waited >= 1.0:
out["waitedSeconds"] = round(waited, 1)
print(json.dumps(out))
return
if action == "type":
sel = req.get("selector") or ""
text = req.get("text") or ""
if sel:
val = evaluate(ws, CLICK_JS, sel) or {}
if val.get("ok"):
pointer(display, val.get("x") or 0, val.get("y") or 0)
if text:
ws.call("Input.insertText", {"text": text})
wait_for_visual_update(ws)
print(json.dumps({"ok": True, "action": "type", "restarted": restarted}))
return
if action == "press":
key = req.get("key") or "Return"
press(ws, key)
wait_for_visual_update(ws)
print(json.dumps({"ok": True, "action": "press", "key": key, "restarted": restarted}))
return
if action == "wait":
ms = min(max(int(req.get("ms") or 400), 0), 5000)
time.sleep(ms / 1000.0)
print(json.dumps({"ok": True, "action": "wait", "ms": ms}))
return
fail("unsupported action")
finally:
ws.close()
if __name__ == "__main__":
try:
main()
except Exception as e:
fail(str(e))

242
crates/control/src/cdp.rs Normal file
View File

@ -0,0 +1,242 @@
use lazyboy_contracts::UiElement;
use serde_json::{Value, json};
use crate::x11::parse_ui_elements;
use crate::{PRIMARY_DISPLAY, normalize_display};
const CDP_PY: &str = include_str!("cdp.py");
pub fn devtools_port(display: &str) -> u16 {
let number = normalize_display(display)
.trim_start_matches(':')
.parse::<u16>()
.unwrap_or(1)
.max(1);
9221 + number
}
pub fn cdp_command_on(display: &str, profile: Option<&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)));
object
.entry("port")
.or_insert_with(|| json!(devtools_port(display)));
if let Some(profile) = profile.filter(|value| !value.is_empty()) {
object.entry("profile").or_insert_with(|| json!(profile));
}
}
vec![
"env".into(),
format!("DISPLAY={}", normalize_display(display)),
"python3".into(),
"-c".into(),
CDP_PY.into(),
body.to_string(),
]
}
pub fn cdp_command(request: &Value) -> Vec<String> {
cdp_command_on(PRIMARY_DISPLAY, None, request)
}
/// 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 {
format!("lazyboy-teach-{skill_id}")
}
pub fn teach_recorder_output(skill_id: &str) -> String {
format!("/tmp/{}.jsonl", teach_recorder_tag(skill_id))
}
/// Detached, long-running CDP recorder for a human demonstration. The script
/// is handed to `sh` as a positional argument so no shell quoting touches it.
pub fn cdp_record_command_on(display: &str, profile: Option<&str>, skill_id: &str) -> Vec<String> {
let mut request = json!({
"action": "record",
"ensure": true,
"out": teach_recorder_output(skill_id),
"tag": teach_recorder_tag(skill_id),
"display": normalize_display(display),
"port": devtools_port(display),
});
if let Some(profile) = profile.filter(|value| !value.is_empty()) {
request["profile"] = json!(profile);
}
vec![
"sh".into(),
"-c".into(),
"setsid nohup env DISPLAY=\"$0\" python3 -c \"$1\" \"$2\" >/dev/null 2>&1 </dev/null &".into(),
normalize_display(display).to_string(),
CDP_PY.into(),
request.to_string(),
]
}
pub fn cdp_record_stop_command(skill_id: &str) -> Vec<String> {
vec!["pkill".into(), "-f".into(), teach_recorder_tag(skill_id)]
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct CdpPage {
pub ok: bool,
pub error: Option<String>,
pub url: String,
pub title: String,
pub text: String,
pub restarted: bool,
/// Seconds the click waited for a disabled control to become enabled.
pub waited_seconds: Option<f64>,
pub elements: Vec<UiElement>,
}
pub fn parse_cdp_page(raw: &str) -> CdpPage {
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);
CdpPage {
ok,
error: if ok {
None
} else {
value
.get("error")
.and_then(Value::as_str)
.map(str::to_string)
.or_else(|| Some("cdp unavailable".into()))
},
waited_seconds: value.get("waitedSeconds").and_then(Value::as_f64),
url: value
.get("url")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
title: value
.get("title")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
text: value
.get("text")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
restarted: value.get("restarted").and_then(Value::as_bool) == Some(true),
elements: value
.get("elements")
.map(|items| parse_ui_elements(&items.to_string()))
.unwrap_or_default(),
}
}
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
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn debug_port_follows_the_display() {
assert_eq!(devtools_port(":1"), 9222);
assert_eq!(devtools_port(":2"), 9223);
assert_eq!(devtools_port("3"), 9224);
}
#[test]
fn command_passes_port_profile_and_script() {
let argv = cdp_command_on(
":2",
Some("/home/lazyboy/.browser-profiles/bots/a"),
&json!({"action": "snapshot", "ensure": true}),
);
assert!(argv.contains(&"DISPLAY=:2".into()));
assert!(argv.iter().any(|item| item.contains("python3")));
let payload = argv.last().unwrap();
assert!(payload.contains("\"port\":9223"));
assert!(payload.contains("bots/a"));
assert!(payload.contains("snapshot"));
}
#[test]
fn parses_snapshot_elements() {
let page = parse_cdp_page(
r#"{"ok":true,"url":"https://example.com","title":"Example","text":"Hello","elements":[{"id":1,"title":"Submit","selector":"[data-lazyboy=\"1\"]","kind":"dom","x":10,"y":20,"w":80,"h":24}]}"#,
);
assert!(page.ok);
assert_eq!(page.url, "https://example.com");
assert_eq!(page.elements.len(), 1);
assert_eq!(page.elements[0].title, "Submit");
assert_eq!(
page.elements[0].selector.as_deref(),
Some("[data-lazyboy=\"1\"]")
);
assert_eq!(page.elements[0].center(), (50, 32));
}
#[test]
fn merge_keeps_page_controls_and_native_dialogs() {
let windows = vec![
UiElement {
id: 1,
title: "Chromium".into(),
x: 0,
y: 0,
w: 1280,
h: 800,
kind: Some("window".into()),
..UiElement::default()
},
UiElement {
id: 2,
title: "Open File".into(),
x: 100,
y: 100,
w: 400,
h: 300,
kind: Some("window".into()),
..UiElement::default()
},
];
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 merged = merge_page_elements(windows, &page);
assert_eq!(merged.len(), 2);
assert_eq!(merged[0].title, "Login");
assert_eq!(merged[1].id, 2);
assert_eq!(merged[1].title, "Open File");
}
#[test]
fn unavailable_page_is_not_ok() {
let page = parse_cdp_page(r#"{"ok":false,"error":"cdp unavailable"}"#);
assert!(!page.ok);
assert_eq!(page.error.as_deref(), Some("cdp unavailable"));
}
}

View File

@ -39,7 +39,9 @@ pub fn can_take_screen_lease(existing: Option<&str>, incoming: Option<&str>) ->
match existing { match existing {
None => true, None => true,
Some(existing) if existing == incoming => true, Some(existing) if existing == incoming => true,
Some(existing) => parse_screen_lease_id(incoming).fence > parse_screen_lease_id(existing).fence, Some(existing) => {
parse_screen_lease_id(incoming).fence > parse_screen_lease_id(existing).fence
}
} }
} }

View File

@ -1,6 +1,8 @@
mod actions; mod actions;
mod cdp;
mod lease; mod lease;
mod observe; mod observe;
mod overlay;
mod path; mod path;
mod sandbox; mod sandbox;
mod screen; mod screen;
@ -8,8 +10,10 @@ mod takeover;
mod x11; mod x11;
pub use actions::*; pub use actions::*;
pub use cdp::*;
pub use lease::*; pub use lease::*;
pub use observe::*; pub use observe::*;
pub use overlay::*;
pub use path::*; pub use path::*;
pub use sandbox::*; pub use sandbox::*;
pub use screen::*; pub use screen::*;

View File

@ -1,5 +1,5 @@
use chrono::Utc; use chrono::Utc;
use lazyboy_contracts::{ActiveWindow, ComputerObservation, CursorPosition}; use lazyboy_contracts::{ActiveWindow, ComputerObservation, CursorPosition, UiElement};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
pub fn observation_from_png( pub fn observation_from_png(
@ -12,12 +12,40 @@ pub fn observation_from_png(
ComputerObservation { ComputerObservation {
frame_id: hex::encode(Sha256::digest(&image)), frame_id: hex::encode(Sha256::digest(&image)),
captured_at: Utc::now().to_rfc3339(), captured_at: Utc::now().to_rfc3339(),
mime_type: "image/png".to_string(), mime_type: sniff_image_mime(&image).to_string(),
image, image,
width, width,
height, height,
cursor, cursor,
active_window, active_window,
elements: Vec::new(),
}
}
pub fn observation_with_elements(
mut observation: ComputerObservation,
elements: Vec<UiElement>,
) -> ComputerObservation {
observation.elements = elements;
observation
}
pub fn format_ui_elements(elements: &[UiElement]) -> String {
if elements.is_empty() {
return "none".into();
}
elements
.iter()
.map(|element| format!("[{}] {}", element.id, element.title))
.collect::<Vec<_>>()
.join(" ")
}
fn sniff_image_mime(image: &[u8]) -> &'static str {
if image.len() >= 3 && image[0] == 0xFF && image[1] == 0xD8 && image[2] == 0xFF {
"image/jpeg"
} else {
"image/png"
} }
} }
@ -25,6 +53,33 @@ pub fn frames_match(previous_frame_id: Option<&str>, observation: &ComputerObser
previous_frame_id == Some(observation.frame_id.as_str()) previous_frame_id == Some(observation.frame_id.as_str())
} }
const SIGNATURE_W: u32 = 32;
const SIGNATURE_H: u32 = 18;
/// Coarse grayscale thumbnail of a screenshot. Two frames whose signatures
/// are `similar` differ only in small details (panel clock, caret blink),
/// which a byte-level `frame_id` comparison would treat as a new frame.
pub fn frame_signature(image: &[u8]) -> Option<Vec<u8>> {
let dynamic = image::load_from_memory(image).ok()?;
let thumb = dynamic
.resize_exact(SIGNATURE_W, SIGNATURE_H, image::imageops::FilterType::Triangle)
.to_luma8();
Some(thumb.into_raw())
}
/// True when fewer than 2% of the coarse cells moved by a visible amount.
pub fn signatures_similar(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() || a.is_empty() {
return false;
}
let changed = a
.iter()
.zip(b)
.filter(|(x, y)| x.abs_diff(**y) > 24)
.count();
changed * 50 < a.len()
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -38,4 +93,52 @@ mod tests {
let c = observation_from_png(vec![1, 2, 4], 1, 1, None, None); let c = observation_from_png(vec![1, 2, 4], 1, 1, None, None);
assert!(!frames_match(Some(&a.frame_id), &c)); assert!(!frames_match(Some(&a.frame_id), &c));
} }
#[test]
fn signature_ignores_tiny_changes_but_sees_big_ones() {
use image::{ImageEncoder, Rgb, RgbImage};
fn png(paint: impl Fn(u32, u32) -> Rgb<u8>) -> Vec<u8> {
let img = RgbImage::from_fn(320, 180, paint);
let mut out = Vec::new();
image::codecs::png::PngEncoder::new(&mut out)
.write_image(img.as_raw(), 320, 180, image::ExtendedColorType::Rgb8)
.unwrap();
out
}
let base = frame_signature(&png(|_, _| Rgb([240, 240, 240]))).unwrap();
// A panel clock flipping digits touches a couple of pixels only.
let clock = frame_signature(&png(|x, y| {
if x < 6 && y < 6 { Rgb([0, 0, 0]) } else { Rgb([240, 240, 240]) }
}))
.unwrap();
// A dialog covering a quarter of the screen.
let dialog = frame_signature(&png(|x, y| {
if x < 160 && y < 90 { Rgb([20, 20, 20]) } else { Rgb([240, 240, 240]) }
}))
.unwrap();
assert!(signatures_similar(&base, &clock));
assert!(!signatures_similar(&base, &dialog));
assert!(frame_signature(b"not an image").is_none());
}
#[test]
fn jpeg_magic_sets_mime() {
let observation = observation_from_png(vec![0xFF, 0xD8, 0xFF, 0x00], 1, 1, None, None);
assert_eq!(observation.mime_type, "image/jpeg");
}
#[test]
fn formats_numbered_windows() {
let elements = vec![UiElement {
id: 1,
title: "Chromium".into(),
x: 0,
y: 0,
w: 100,
h: 40,
..UiElement::default()
}];
assert_eq!(format_ui_elements(&elements), "[1] Chromium");
assert_eq!(format_ui_elements(&[]), "none");
}
} }

View File

@ -0,0 +1,271 @@
use std::io::Cursor;
use image::codecs::jpeg::JpegEncoder;
use image::{ExtendedColorType, Rgb, RgbImage};
use lazyboy_contracts::UiElement;
const YELLOW: Rgb<u8> = Rgb([255, 214, 0]);
const BLACK: Rgb<u8> = Rgb([16, 16, 16]);
const SCALE: u32 = 6;
/// 5x7 bitmap digits, MSB is the left pixel of each row.
const FONT: [[u8; 7]; 10] = [
[
0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110,
],
[
0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110,
],
[
0b01110, 0b10001, 0b00001, 0b00110, 0b01000, 0b10000, 0b11111,
],
[
0b01110, 0b10001, 0b00001, 0b00110, 0b00001, 0b10001, 0b01110,
],
[
0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010,
],
[
0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110,
],
[
0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110,
],
[
0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000,
],
[
0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110,
],
[
0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100,
],
];
/// Draw numbered boxes on a screenshot for the model. The live VNC feed is unchanged.
/// Empty element lists and undecodable bytes are returned as-is.
pub fn overlay_elements(bytes: &[u8], elements: &[UiElement]) -> Vec<u8> {
if elements.is_empty() {
return bytes.to_vec();
}
let Ok(dynamic) = image::load_from_memory(bytes) else {
return bytes.to_vec();
};
let mut img = dynamic.to_rgb8();
let (width, height) = img.dimensions();
if width == 0 || height == 0 {
return bytes.to_vec();
}
for element in elements
.iter()
.filter(|element| !element.is_offscreen())
.take(50)
{
paint_element(&mut img, element, width, height);
}
encode_jpeg(&img).unwrap_or_else(|| bytes.to_vec())
}
fn paint_element(img: &mut RgbImage, element: &UiElement, width: u32, height: u32) {
let x1 = element.x.min(width.saturating_sub(1));
let y1 = element.y.min(height.saturating_sub(1));
let x2 = element
.x
.saturating_add(element.w.max(1))
.saturating_sub(1)
.min(width.saturating_sub(1));
let y2 = element
.y
.saturating_add(element.h.max(1))
.saturating_sub(1)
.min(height.saturating_sub(1));
if x2 <= x1 || y2 <= y1 {
draw_badge(img, x1, y1, element.id);
return;
}
let area = u64::from(element.w.saturating_mul(element.h.max(1)));
let full = u64::from(width) * u64::from(height);
if full == 0 || area.saturating_mul(10) < full.saturating_mul(7) {
stroke_rect(img, x1, y1, x2, y2, BLACK, 6);
stroke_rect(img, x1, y1, x2, y2, YELLOW, 3);
}
draw_badge(img, x1, y1, element.id);
}
fn stroke_rect(
img: &mut RgbImage,
x1: u32,
y1: u32,
x2: u32,
y2: u32,
color: Rgb<u8>,
thickness: u32,
) {
for t in 0..thickness {
let left = x1.saturating_add(t).min(x2);
let right = x2.saturating_sub(t).max(x1);
let top = y1.saturating_add(t).min(y2);
let bottom = y2.saturating_sub(t).max(y1);
for x in left..=right {
put(img, x, top, color);
put(img, x, bottom, color);
}
for y in top..=bottom {
put(img, left, y, color);
put(img, right, y, color);
}
}
}
fn draw_badge(img: &mut RgbImage, x: u32, y: u32, id: u32) {
let label = id.to_string();
let digits = label.len() as u32;
let pad = 4;
let digit_w = 5 * SCALE + 2;
let digit_h = 7 * SCALE;
let bw = pad * 2 + digits * digit_w - 2;
let bh = pad * 2 + digit_h;
let (width, height) = img.dimensions();
let bx = x.min(width.saturating_sub(bw.max(1)));
let by = if y >= bh + 2 {
y.saturating_sub(bh + 2)
} else {
y.min(height.saturating_sub(bh.max(1)))
};
fill_rect(
img,
bx,
by,
bx.saturating_add(bw.saturating_sub(1)),
by.saturating_add(bh.saturating_sub(1)),
YELLOW,
);
stroke_rect(
img,
bx,
by,
bx.saturating_add(bw.saturating_sub(1)),
by.saturating_add(bh.saturating_sub(1)),
BLACK,
2,
);
for (i, ch) in label.bytes().enumerate() {
if !(b'0'..=b'9').contains(&ch) {
continue;
}
let dx = bx + pad + i as u32 * digit_w;
let dy = by + pad;
draw_digit(img, dx, dy, (ch - b'0') as usize);
}
}
fn fill_rect(img: &mut RgbImage, x1: u32, y1: u32, x2: u32, y2: u32, color: Rgb<u8>) {
for y in y1..=y2 {
for x in x1..=x2 {
put(img, x, y, color);
}
}
}
fn draw_digit(img: &mut RgbImage, x: u32, y: u32, digit: usize) {
let Some(rows) = FONT.get(digit) else {
return;
};
for (row, bits) in rows.iter().enumerate() {
for col in 0..5u32 {
if bits & (1 << (4 - col)) == 0 {
continue;
}
for oy in 0..SCALE {
for ox in 0..SCALE {
put(
img,
x.saturating_add(col * SCALE + ox),
y.saturating_add(row as u32 * SCALE + oy),
BLACK,
);
}
}
}
}
}
fn put(img: &mut RgbImage, x: u32, y: u32, color: Rgb<u8>) {
if x < img.width() && y < img.height() {
img.put_pixel(x, y, color);
}
}
fn encode_jpeg(img: &RgbImage) -> Option<Vec<u8>> {
let mut out = Cursor::new(Vec::new());
let mut encoder = JpegEncoder::new_with_quality(&mut out, 60);
encoder
.encode(
img.as_raw(),
img.width(),
img.height(),
ExtendedColorType::Rgb8,
)
.ok()?;
Some(out.into_inner())
}
#[cfg(test)]
mod tests {
use super::*;
use image::Rgb;
fn jpeg_of(width: u32, height: u32, color: Rgb<u8>) -> Vec<u8> {
let img = RgbImage::from_pixel(width, height, color);
encode_jpeg(&img).expect("jpeg")
}
#[test]
fn empty_elements_keeps_bytes() {
let bytes = vec![1, 2, 3, 4];
assert_eq!(overlay_elements(&bytes, &[]), bytes);
}
#[test]
fn paints_a_yellow_badge() {
let src = jpeg_of(160, 100, Rgb([8, 12, 40]));
let out = overlay_elements(
&src,
&[UiElement {
id: 7,
title: "Submit".into(),
x: 20,
y: 30,
w: 50,
h: 24,
..UiElement::default()
}],
);
assert!(out.starts_with(&[0xFF, 0xD8, 0xFF]));
let painted = image::load_from_memory(&out).unwrap().to_rgb8();
assert!(
painted
.pixels()
.any(|pixel| pixel[0] > 200 && pixel[1] > 180 && pixel[2] < 40),
"expected a yellow mark on the overlay"
);
}
#[test]
fn junk_bytes_pass_through() {
let junk = b"not-an-image";
let out = overlay_elements(
junk,
&[UiElement {
id: 1,
title: "x".into(),
x: 0,
y: 0,
w: 10,
h: 10,
..UiElement::default()
}],
);
assert_eq!(out, junk);
}
}

View File

@ -9,8 +9,14 @@ pub enum PathError {
pub fn normalize_workspace_path(value: &str) -> Result<String, PathError> { pub fn normalize_workspace_path(value: &str) -> Result<String, PathError> {
let normalized = value.replace('\\', "/").trim_start_matches('/').to_string(); let normalized = value.replace('\\', "/").trim_start_matches('/').to_string();
let segments: Vec<&str> = normalized.split('/').filter(|segment| !segment.is_empty()).collect(); let segments: Vec<&str> = normalized
if segments.iter().any(|segment| *segment == "." || *segment == "..") { .split('/')
.filter(|segment| !segment.is_empty())
.collect();
if segments
.iter()
.any(|segment| *segment == "." || *segment == "..")
{
return Err(PathError::EscapesWorkspace); return Err(PathError::EscapesWorkspace);
} }
Ok(segments.join("/")) Ok(segments.join("/"))
@ -124,7 +130,8 @@ mod tests {
#[test] #[test]
fn dedicated_paths_stay_at_home_root() { fn dedicated_paths_stay_at_home_root() {
let path = resolve_bot_workspace_path(ComputerMode::Dedicated, "bot-1", "notes/a.txt").unwrap(); let path =
resolve_bot_workspace_path(ComputerMode::Dedicated, "bot-1", "notes/a.txt").unwrap();
assert_eq!(path, "notes/a.txt"); assert_eq!(path, "notes/a.txt");
} }
@ -172,7 +179,8 @@ mod tests {
#[test] #[test]
fn private_homes_are_not_the_team_home() { fn private_homes_are_not_the_team_home() {
let team = lazyboy_contracts::computer_home_key(ComputerMode::Team, "space-1", None).unwrap(); let team =
lazyboy_contracts::computer_home_key(ComputerMode::Team, "space-1", None).unwrap();
let private = lazyboy_contracts::computer_home_key( let private = lazyboy_contracts::computer_home_key(
ComputerMode::Dedicated, ComputerMode::Dedicated,
"space-1", "space-1",
@ -181,7 +189,8 @@ mod tests {
.unwrap(); .unwrap();
assert_ne!(team, private); assert_ne!(team, private);
assert_eq!( assert_eq!(
resolve_bot_workspace_path(ComputerMode::Dedicated, "bot-private", "shared/secret.txt").unwrap(), resolve_bot_workspace_path(ComputerMode::Dedicated, "bot-private", "shared/secret.txt")
.unwrap(),
"shared/secret.txt" "shared/secret.txt"
); );
} }

View File

@ -147,7 +147,11 @@ pub trait SandboxProvider: Send + Sync {
context: &AdapterContext, context: &AdapterContext,
) -> Result<ComputerRef, SandboxError>; ) -> Result<ComputerRef, SandboxError>;
async fn prepare(&self, computer: &ComputerRef, context: &AdapterContext) -> Result<(), SandboxError> { async fn prepare(
&self,
computer: &ComputerRef,
context: &AdapterContext,
) -> Result<(), SandboxError> {
let _ = (computer, context); let _ = (computer, context);
Ok(()) Ok(())
} }
@ -185,7 +189,11 @@ pub trait SandboxProvider: Send + Sync {
self.provision(request, context).await self.provision(request, context).await
} }
async fn suspend(&self, computer: &ComputerRef, context: &AdapterContext) -> Result<(), SandboxError> { async fn suspend(
&self,
computer: &ComputerRef,
context: &AdapterContext,
) -> Result<(), SandboxError> {
self.stop(computer, context).await self.stop(computer, context).await
} }
@ -246,7 +254,15 @@ pub trait SandboxProvider: Send + Sync {
context: &AdapterContext, context: &AdapterContext,
) -> Result<(), SandboxError>; ) -> Result<(), SandboxError>;
async fn stop(&self, computer: &ComputerRef, context: &AdapterContext) -> Result<(), SandboxError>; async fn stop(
&self,
computer: &ComputerRef,
context: &AdapterContext,
) -> Result<(), SandboxError>;
async fn destroy(&self, computer: &ComputerRef, context: &AdapterContext) -> Result<(), SandboxError>; async fn destroy(
&self,
computer: &ComputerRef,
context: &AdapterContext,
) -> Result<(), SandboxError>;
} }

View File

@ -1,8 +1,8 @@
pub use lazyboy_contracts::TEAM_SCREEN_LIMIT;
use lazyboy_contracts::{ use lazyboy_contracts::{
BrowserProfileMode, ComputerCapabilities, MULTI_SCREEN_UNAVAILABLE, PROFILE_LOCKED, BrowserProfileMode, ComputerCapabilities, MULTI_SCREEN_UNAVAILABLE, PROFILE_LOCKED,
TEAM_SCREENS_FULL, TEAM_SCREENS_FULL,
}; };
pub use lazyboy_contracts::TEAM_SCREEN_LIMIT;
use thiserror::Error; use thiserror::Error;
use crate::HOME; use crate::HOME;
@ -66,7 +66,11 @@ pub fn allocate_slot(used: &[u32]) -> Option<u32> {
(0..TEAM_SCREEN_LIMIT).find(|slot| !used.contains(slot)) (0..TEAM_SCREEN_LIMIT).find(|slot| !used.contains(slot))
} }
pub fn browser_profile_path(mode: BrowserProfileMode, bot_id: &str, run_id: Option<&str>) -> String { pub fn browser_profile_path(
mode: BrowserProfileMode,
bot_id: &str,
run_id: Option<&str>,
) -> String {
match mode { match mode {
BrowserProfileMode::Shared => format!("{HOME}/.browser-profiles/chromium"), BrowserProfileMode::Shared => format!("{HOME}/.browser-profiles/chromium"),
BrowserProfileMode::PerBot => format!("{HOME}/.browser-profiles/bots/{bot_id}"), BrowserProfileMode::PerBot => format!("{HOME}/.browser-profiles/bots/{bot_id}"),
@ -82,7 +86,10 @@ pub fn profile_lock_key(mode: BrowserProfileMode, bot_id: &str, run_id: Option<&
BrowserProfileMode::Shared => "shared".into(), BrowserProfileMode::Shared => "shared".into(),
BrowserProfileMode::PerBot => format!("bot:{bot_id}"), BrowserProfileMode::PerBot => format!("bot:{bot_id}"),
BrowserProfileMode::PerTask => { BrowserProfileMode::PerTask => {
format!("task:{}", run_id.filter(|value| !value.is_empty()).unwrap_or("task")) format!(
"task:{}",
run_id.filter(|value| !value.is_empty()).unwrap_or("task")
)
} }
} }
} }
@ -132,7 +139,9 @@ pub fn admit_new_screen(
return Ok(slot); return Ok(slot);
} }
if !capabilities.multi_screen && !used_slots.is_empty() { if !capabilities.multi_screen && !used_slots.is_empty() {
return Err(GuiBlock::MultiScreenUnavailable { busy_bot_name: None }); return Err(GuiBlock::MultiScreenUnavailable {
busy_bot_name: None,
});
} }
allocate_slot(used_slots).ok_or(GuiBlock::ScreensFull) allocate_slot(used_slots).ok_or(GuiBlock::ScreensFull)
} }
@ -163,10 +172,16 @@ impl Default for ScreenTarget {
} }
impl ScreenTarget { impl ScreenTarget {
pub fn from_parts(display: Option<&str>, profile_path: Option<&str>, slot: Option<u32>) -> Self { pub fn from_parts(
display: Option<&str>,
profile_path: Option<&str>,
slot: Option<u32>,
) -> Self {
Self { Self {
display: normalize_display(display.unwrap_or_default()).to_string(), display: normalize_display(display.unwrap_or_default()).to_string(),
profile_path: profile_path.filter(|value| !value.is_empty()).map(str::to_string), profile_path: profile_path
.filter(|value| !value.is_empty())
.map(str::to_string),
slot: slot.unwrap_or(0), slot: slot.unwrap_or(0),
} }
} }
@ -222,7 +237,9 @@ mod tests {
#[test] #[test]
fn multi_screen_false_blocks_a_second_bot_gui_only() { fn multi_screen_false_blocks_a_second_bot_gui_only() {
let caps = ComputerCapabilities { multi_screen: false }; let caps = ComputerCapabilities {
multi_screen: false,
};
let err = admit_new_screen(&caps, &[0], None).unwrap_err(); let err = admit_new_screen(&caps, &[0], None).unwrap_err();
assert!(matches!(err, GuiBlock::MultiScreenUnavailable { .. })); assert!(matches!(err, GuiBlock::MultiScreenUnavailable { .. }));
assert!(admit_new_screen(&caps, &[0], Some(0)).is_ok()); assert!(admit_new_screen(&caps, &[0], Some(0)).is_ok());

View File

@ -1,23 +1,16 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use lazyboy_contracts::RunStatus; use lazyboy_contracts::RunStatus;
/// Mirrors computer.takeover: an execution lease blocks user control unless /// Humans may take the pointer during a live run. The worker pauses at the
/// the run is waiting for a human. /// next turn boundary (or aborts the in-flight model/tool call) instead of
/// competing for the mouse.
pub fn execution_blocks_user_takeover( pub fn execution_blocks_user_takeover(
has_lease: bool, _has_lease: bool,
lease_expires_at: Option<DateTime<Utc>>, _lease_expires_at: Option<DateTime<Utc>>,
run_status: Option<RunStatus>, _run_status: Option<RunStatus>,
now: DateTime<Utc>, _now: DateTime<Utc>,
) -> bool { ) -> bool {
if !has_lease { false
return false;
}
if run_status == Some(RunStatus::WaitingTakeover) {
return false;
}
let lease_active = lease_expires_at.is_some_and(|expires| expires > now);
let run_active = run_status.is_some_and(RunStatus::is_active);
lease_active || run_active
} }
pub fn user_holds_control( pub fn user_holds_control(
@ -44,8 +37,8 @@ mod tests {
} }
#[test] #[test]
fn live_run_blocks_takeover() { fn live_run_does_not_block_takeover() {
assert!(execution_blocks_user_takeover( assert!(!execution_blocks_user_takeover(
true, true,
Some(now() + Duration::minutes(4)), Some(now() + Duration::minutes(4)),
Some(RunStatus::Running), Some(RunStatus::Running),

View File

@ -1,6 +1,6 @@
use lazyboy_contracts::{ComputerAction, PointerButton, PointerType, ScrollDirection}; use lazyboy_contracts::{ComputerAction, PointerButton, PointerType, ScrollDirection};
use crate::screen::{normalize_display, PRIMARY_DISPLAY}; use crate::screen::{PRIMARY_DISPLAY, normalize_display};
pub const DISPLAY: &str = PRIMARY_DISPLAY; pub const DISPLAY: &str = PRIMARY_DISPLAY;
pub const HOME: &str = "/home/lazyboy"; pub const HOME: &str = "/home/lazyboy";
@ -82,7 +82,13 @@ pub fn xdotool_argv_on(display: &str, action: &ComputerAction) -> Option<Vec<Str
ComputerAction::Clipboard { text } => { ComputerAction::Clipboard { text } => {
let quoted = shell_single_quote(text); let quoted = shell_single_quote(text);
if looks_like_typed_ascii(text) { if looks_like_typed_ascii(text) {
argv.extend(["type".into(), "--delay".into(), "16".into(), "--".into(), text.clone()]); argv.extend([
"type".into(),
"--delay".into(),
"16".into(),
"--".into(),
text.clone(),
]);
} else { } else {
return Some(vec![ return Some(vec![
"env".into(), "env".into(),
@ -121,7 +127,9 @@ pub fn xdotool_argv_on(display: &str, action: &ComputerAction) -> Option<Vec<Str
), ),
]); ]);
} }
ComputerAction::Wait { .. } | ComputerAction::Open { .. } | ComputerAction::Launch { .. } => { ComputerAction::Wait { .. }
| ComputerAction::Open { .. }
| ComputerAction::Launch { .. } => {
return None; return None;
} }
} }
@ -134,7 +142,9 @@ fn shell_single_quote(value: &str) -> String {
fn looks_like_typed_ascii(text: &str) -> bool { fn looks_like_typed_ascii(text: &str) -> bool {
text.len() <= 48 text.len() <= 48
&& text.chars().all(|ch| ch.is_ascii() && (!ch.is_control() || ch == '\n' || ch == '\t')) && text
.chars()
.all(|ch| ch.is_ascii() && (!ch.is_control() || ch == '\n' || ch == '\t'))
} }
pub fn action_pause_ms(action: &ComputerAction) -> u64 { pub fn action_pause_ms(action: &ComputerAction) -> u64 {
@ -183,25 +193,107 @@ print(json.dumps({"x": int(vals.get("X") or 0), "y": int(vals.get("Y") or 0), "i
] ]
} }
pub fn parse_pointer_state(raw: &str) -> (Option<lazyboy_contracts::CursorPosition>, Option<lazyboy_contracts::ActiveWindow>) { pub fn window_list_command() -> Vec<String> {
let value: serde_json::Value = serde_json::from_str(raw.trim()).unwrap_or(serde_json::Value::Null); window_list_command_on(PRIMARY_DISPLAY)
let cursor = match (value.get("x").and_then(serde_json::Value::as_i64), value.get("y").and_then(serde_json::Value::as_i64)) { }
pub fn window_list_command_on(display: &str) -> Vec<String> {
vec![
"env".into(),
display_env(display),
"python3".into(),
"-c".into(),
r#"
import json, subprocess
def out(args):
try:
return subprocess.check_output(args, stderr=subprocess.DEVNULL, text=True)
except Exception:
return ""
els = []
n = 1
for line in out(["wmctrl", "-lG"]).splitlines():
parts = line.split(None, 7)
if len(parts) < 7:
continue
try:
x, y, w, h = int(parts[2]), int(parts[3]), int(parts[4]), int(parts[5])
except ValueError:
continue
if w < 32 or h < 16:
continue
title = parts[7].strip() if len(parts) > 7 else parts[6]
if not title or title in ("Desktop", "xfce4-panel"):
continue
els.append({"id": n, "title": title[:80], "kind": "window", "x": max(0, x), "y": max(0, y), "w": w, "h": h})
n += 1
print(json.dumps(els))
"#
.into(),
]
}
pub fn parse_ui_elements(raw: &str) -> Vec<lazyboy_contracts::UiElement> {
let value: serde_json::Value =
serde_json::from_str(raw.trim()).unwrap_or(serde_json::Value::Null);
let Some(items) = value.as_array() else {
return Vec::new();
};
items
.iter()
.filter_map(|item| {
Some(lazyboy_contracts::UiElement {
id: item.get("id")?.as_u64()? as u32,
title: item.get("title")?.as_str()?.to_string(),
x: item.get("x")?.as_u64()? as u32,
y: item.get("y")?.as_u64()? as u32,
w: item.get("w")?.as_u64()? as u32,
h: item.get("h")?.as_u64()? as u32,
selector: item
.get("selector")
.and_then(serde_json::Value::as_str)
.filter(|value| !value.is_empty())
.map(str::to_string),
kind: item
.get("kind")
.and_then(serde_json::Value::as_str)
.filter(|value| !value.is_empty())
.map(str::to_string),
})
})
.collect()
}
pub fn parse_pointer_state(
raw: &str,
) -> (
Option<lazyboy_contracts::CursorPosition>,
Option<lazyboy_contracts::ActiveWindow>,
) {
let value: serde_json::Value =
serde_json::from_str(raw.trim()).unwrap_or(serde_json::Value::Null);
let cursor = match (
value.get("x").and_then(serde_json::Value::as_i64),
value.get("y").and_then(serde_json::Value::as_i64),
) {
(Some(x), Some(y)) => Some(lazyboy_contracts::CursorPosition { (Some(x), Some(y)) => Some(lazyboy_contracts::CursorPosition {
x: x as i32, x: x as i32,
y: y as i32, y: y as i32,
}), }),
_ => None, _ => None,
}; };
let window = value.get("id").and_then(serde_json::Value::as_str).filter(|id| !id.is_empty()).map(|id| { let window = value
lazyboy_contracts::ActiveWindow { .get("id")
.and_then(serde_json::Value::as_str)
.filter(|id| !id.is_empty())
.map(|id| lazyboy_contracts::ActiveWindow {
id: id.to_string(), id: id.to_string(),
title: value title: value
.get("title") .get("title")
.and_then(serde_json::Value::as_str) .and_then(serde_json::Value::as_str)
.filter(|title| !title.is_empty()) .filter(|title| !title.is_empty())
.map(str::to_string), .map(str::to_string),
} });
});
(cursor, window) (cursor, window)
} }
@ -236,7 +328,11 @@ pub fn launch_argv_on(
Some(browser_argv(display, profile, uri)) Some(browser_argv(display, profile, uri))
} }
"xterm" | "terminal" | "xfce4-terminal" | "lazyboy-terminal" => { "xterm" | "terminal" | "xfce4-terminal" | "lazyboy-terminal" => {
let mut argv = vec!["env".into(), display_env(display), "lazyboy-terminal".into()]; let mut argv = vec![
"env".into(),
display_env(display),
"lazyboy-terminal".into(),
];
if let Some(uri) = uri { if let Some(uri) = uri {
argv.push(uri.into()); argv.push(uri.into());
} }
@ -252,6 +348,11 @@ fn browser_argv(display: &str, profile: Option<&str>, uri: Option<&str>) -> Vec<
argv.push(format!("LAZYBOY_BROWSER_PROFILE={profile}")); argv.push(format!("LAZYBOY_BROWSER_PROFILE={profile}"));
} }
argv.push("lazyboy-browser".into()); argv.push("lazyboy-browser".into());
argv.push(format!(
"--remote-debugging-port={}",
crate::devtools_port(display)
));
argv.push("--remote-allow-origins=*".into());
if let Some(uri) = uri { if let Some(uri) = uri {
argv.push(uri.into()); argv.push(uri.into());
} }
@ -267,7 +368,7 @@ pub fn screenshot_command_on(display: &str) -> Vec<String> {
"bash".into(), "bash".into(),
"-lc".into(), "-lc".into(),
format!( format!(
"DISPLAY={} xwd -root -silent | convert xwd:- png:-", "DISPLAY={} xwd -root -silent | convert xwd:- -quality 60 jpeg:-",
normalize_display(display) normalize_display(display)
), ),
] ]
@ -319,9 +420,43 @@ mod tests {
assert!(argv.contains(&"DISPLAY=:2".into())); assert!(argv.contains(&"DISPLAY=:2".into()));
let shot = screenshot_command_on(":3"); let shot = screenshot_command_on(":3");
assert!(shot.last().unwrap().contains("DISPLAY=:3")); assert!(shot.last().unwrap().contains("DISPLAY=:3"));
let browser = launch_argv_on(":2", Some("/home/lazyboy/.browser-profiles/bots/a"), "browser", None) assert!(shot.last().unwrap().contains("jpeg:-"));
.unwrap(); let browser = launch_argv_on(
":2",
Some("/home/lazyboy/.browser-profiles/bots/a"),
"browser",
None,
)
.unwrap();
assert!(browser.contains(&"DISPLAY=:2".into())); assert!(browser.contains(&"DISPLAY=:2".into()));
assert!(browser.iter().any(|item| item.contains("bots/a"))); assert!(browser.iter().any(|item| item.contains("bots/a")));
assert!(browser.contains(&"--remote-debugging-port=9223".into()));
}
#[test]
fn parses_window_list_elements() {
let elements =
parse_ui_elements(r#"[{"id":1,"title":"Chromium","x":0,"y":24,"w":1280,"h":776}]"#);
assert_eq!(elements.len(), 1);
assert_eq!(elements[0].title, "Chromium");
assert_eq!(elements[0].center(), (640, 412));
}
#[test]
fn parses_dom_element_selector() {
let elements = parse_ui_elements(
r#"[{"id":1,"title":"Submit","selector":"[data-lazyboy=\"1\"]","kind":"dom","x":10,"y":20,"w":80,"h":24}]"#,
);
assert_eq!(elements[0].kind.as_deref(), Some("dom"));
assert_eq!(
elements[0].selector.as_deref(),
Some("[data-lazyboy=\"1\"]")
);
}
#[test]
fn empty_or_junk_window_list_is_empty() {
assert!(parse_ui_elements("").is_empty());
assert!(parse_ui_elements("not-json").is_empty());
} }
} }

View File

@ -7,8 +7,9 @@ use axum::routing::{get, post};
use axum::{Json, Router}; use axum::{Json, Router};
use lazyboy_contracts::ComputerAction; use lazyboy_contracts::ComputerAction;
use lazyboy_control::{ use lazyboy_control::{
action_pause_ms, launch_argv_on, normalize_display, open_argv_on, parse_pointer_state, ActionRequest, PRIMARY_DISPLAY, action_pause_ms, launch_argv_on, normalize_display,
pointer_state_command_on, screenshot_command_on, xdotool_argv_on, ActionRequest, PRIMARY_DISPLAY, open_argv_on, parse_pointer_state, parse_ui_elements, pointer_state_command_on,
screenshot_command_on, window_list_command_on, xdotool_argv_on,
}; };
use tokio::io::AsyncReadExt; use tokio::io::AsyncReadExt;
use tokio::process::Command; use tokio::process::Command;
@ -48,7 +49,10 @@ fn authorized(headers: &HeaderMap, token: &str) -> bool {
} }
fn header_value<'a>(headers: &'a HeaderMap, name: &'static str) -> Option<&'a str> { fn header_value<'a>(headers: &'a HeaderMap, name: &'static str) -> Option<&'a str> {
headers.get(name).and_then(|value| value.to_str().ok()).filter(|value| !value.is_empty()) headers
.get(name)
.and_then(|value| value.to_str().ok())
.filter(|value| !value.is_empty())
} }
fn display_of(headers: &HeaderMap, fallback: Option<&str>) -> String { fn display_of(headers: &HeaderMap, fallback: Option<&str>) -> String {
@ -67,7 +71,10 @@ fn profile_of(headers: &HeaderMap, fallback: Option<&str>) -> Option<String> {
.map(str::to_string) .map(str::to_string)
} }
async fn observe(State(app): State<App>, headers: HeaderMap) -> Result<Json<serde_json::Value>, StatusCode> { async fn observe(
State(app): State<App>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, StatusCode> {
if !authorized(&headers, &app.token) { if !authorized(&headers, &app.token) {
return Err(StatusCode::UNAUTHORIZED); return Err(StatusCode::UNAUTHORIZED);
} }
@ -114,15 +121,22 @@ async fn act(
Ok(Json(body)) Ok(Json(body))
} }
async fn apply_action(display: &str, profile: Option<&str>, action: &ComputerAction) -> Result<(), String> { async fn apply_action(
display: &str,
profile: Option<&str>,
action: &ComputerAction,
) -> Result<(), String> {
match action { match action {
ComputerAction::Wait { ms } => { ComputerAction::Wait { ms } => {
sleep(Duration::from_millis(*ms as u64)).await; sleep(Duration::from_millis(*ms as u64)).await;
Ok(()) Ok(())
} }
ComputerAction::Open { path } => spawn_detached(&open_argv_on(display, profile, path)).await, ComputerAction::Open { path } => {
spawn_detached(&open_argv_on(display, profile, path)).await
}
ComputerAction::Focus { .. } => { ComputerAction::Focus { .. } => {
let argv = xdotool_argv_on(display, action).ok_or_else(|| "unsupported action".to_string())?; let argv =
xdotool_argv_on(display, action).ok_or_else(|| "unsupported action".to_string())?;
let output = Command::new(&argv[0]) let output = Command::new(&argv[0])
.args(&argv[1..]) .args(&argv[1..])
.output() .output()
@ -140,7 +154,8 @@ async fn apply_action(display: &str, profile: Option<&str>, action: &ComputerAct
spawn_detached(&argv).await spawn_detached(&argv).await
} }
other => { other => {
let argv = xdotool_argv_on(display, other).ok_or_else(|| "unsupported action".to_string())?; let argv =
xdotool_argv_on(display, other).ok_or_else(|| "unsupported action".to_string())?;
let output = Command::new(&argv[0]) let output = Command::new(&argv[0])
.args(&argv[1..]) .args(&argv[1..])
.output() .output()
@ -178,9 +193,22 @@ async fn observation_json(display: &str, png: Vec<u8>) -> serde_json::Value {
if let Some(window) = window { if let Some(window) = window {
body["activeWindow"] = serde_json::json!({ "id": window.id, "title": window.title }); body["activeWindow"] = serde_json::json!({ "id": window.id, "title": window.title });
} }
let elements = run_window_list(display).await;
if !elements.is_empty() {
body["elements"] = serde_json::to_value(elements).unwrap_or(serde_json::json!([]));
}
body body
} }
async fn run_window_list(display: &str) -> Vec<lazyboy_contracts::UiElement> {
let argv = window_list_command_on(display);
let output = Command::new(&argv[0]).args(&argv[1..]).output().await.ok();
let Some(output) = output else {
return Vec::new();
};
parse_ui_elements(&String::from_utf8_lossy(&output.stdout))
}
async fn run_pointer_state( async fn run_pointer_state(
display: &str, display: &str,
) -> ( ) -> (

View File

@ -1,6 +1,6 @@
use lazyboy_contracts::{ use lazyboy_contracts::{ModelCapabilities, ModelProvider, default_model_id, model_capabilities};
default_model_id, model_capabilities, ModelCapabilities, ModelProvider, DEFAULT_XAI_MODEL, use rig_core::client::CompletionClient;
}; use rig_core::providers::{openai, xai};
use thiserror::Error; use thiserror::Error;
#[derive(Debug, Error, Clone, PartialEq, Eq)] #[derive(Debug, Error, Clone, PartialEq, Eq)]
@ -8,10 +8,9 @@ pub enum ModelError {
#[error("unsupported_provider:{provider}")] #[error("unsupported_provider:{provider}")]
UnsupportedProvider { provider: String }, UnsupportedProvider { provider: String },
#[error("missing credential for {provider} ({env_key})")] #[error("missing credential for {provider} ({env_key})")]
MissingCredential { MissingCredential { provider: String, env_key: String },
provider: String, #[error("missing base URL for {provider}")]
env_key: String, MissingBaseUrl { provider: String },
},
#[error("unknown model provider: {0}")] #[error("unknown model provider: {0}")]
UnknownProvider(String), UnknownProvider(String),
#[error("model client error: {0}")] #[error("model client error: {0}")]
@ -39,6 +38,7 @@ impl CredentialChain {
pub struct ResolveModelRequest { pub struct ResolveModelRequest {
pub provider: ModelProvider, pub provider: ModelProvider,
pub model_id: Option<String>, pub model_id: Option<String>,
pub base_url: Option<String>,
pub credentials: CredentialChain, pub credentials: CredentialChain,
} }
@ -52,12 +52,9 @@ pub struct ResolvedBackend {
} }
/// Pick a backend without talking to the network. /// Pick a backend without talking to the network.
///
/// v1 only constructs an xAI client. Other providers stay in the enum so a later
/// mapping can land without changing Computer tools.
pub fn resolve_backend(request: ResolveModelRequest) -> Result<ResolvedBackend, ModelError> { pub fn resolve_backend(request: ResolveModelRequest) -> Result<ResolvedBackend, ModelError> {
match request.provider { match request.provider {
ModelProvider::Xai => {} ModelProvider::Xai | ModelProvider::OpencodeGo | ModelProvider::OpenaiCompatible => {}
other => { other => {
return Err(ModelError::UnsupportedProvider { return Err(ModelError::UnsupportedProvider {
provider: other.as_str().to_string(), provider: other.as_str().to_string(),
@ -65,56 +62,132 @@ pub fn resolve_backend(request: ResolveModelRequest) -> Result<ResolvedBackend,
} }
} }
let api_key = request.credentials.resolve().ok_or(ModelError::MissingCredential { let api_key = match request.credentials.resolve() {
provider: request.provider.as_str().to_string(), Some(key) => key.to_string(),
env_key: request.provider.env_key_name().to_string(), None if request.provider.requires_api_key() => {
})?; return Err(ModelError::MissingCredential {
provider: request.provider.as_str().to_string(),
env_key: request.provider.env_key_name().to_string(),
});
}
None => String::new(),
};
let model_id = request let model_id = request
.model_id .model_id
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.or_else(|| default_model_id(request.provider).map(str::to_string)) .or_else(|| default_model_id(request.provider).map(str::to_string))
.unwrap_or_else(|| DEFAULT_XAI_MODEL.to_string()); .ok_or_else(|| ModelError::ProviderClient("missing model id".into()))?;
let base_url = request let base_url = request
.provider .base_url
.default_base_url() .filter(|value| !value.trim().is_empty())
.unwrap_or("https://api.x.ai/v1") .or_else(|| request.provider.default_base_url().map(str::to_string))
.to_string(); .ok_or_else(|| ModelError::MissingBaseUrl {
provider: request.provider.as_str().to_string(),
})?;
let base_url = rewrite_loopback_host(&normalize_base_url(&base_url));
Ok(ResolvedBackend { Ok(ResolvedBackend {
provider: request.provider, provider: request.provider,
model_id: model_id.clone(), model_id: model_id.clone(),
capabilities: model_capabilities(request.provider, &model_id), capabilities: model_capabilities(request.provider, &model_id),
api_key: api_key.to_string(), api_key,
base_url, base_url,
}) })
} }
fn normalize_base_url(url: &str) -> String {
url.trim().trim_end_matches('/').to_string()
}
fn rewrite_loopback_host(url: &str) -> String {
let supervisor = std::env::var("SANDBOX_SUPERVISOR_URL").unwrap_or_default();
if !supervisor.contains("://supervisor") {
return url.to_string();
}
url.replace("://127.0.0.1", "://host.docker.internal")
.replace("://localhost", "://host.docker.internal")
}
pub enum DynModel {
Xai(xai::completion::CompletionModel),
OpenAi(openai::completion::CompletionModel),
OpenAiResponses(openai::responses_api::ResponsesCompletionModel),
}
fn uses_responses_api(provider: ModelProvider, model_id: &str) -> bool {
let id = model_id.to_ascii_lowercase();
provider == ModelProvider::OpencodeGo
&& (id.starts_with("gpt-") || id.starts_with("grok-") || id.starts_with("muse-"))
}
pub fn connect_model(backend: &ResolvedBackend) -> Result<DynModel, ModelError> {
match backend.provider {
ModelProvider::Xai => {
let client = xai::Client::new(&backend.api_key)
.map_err(|error| ModelError::ProviderClient(error.to_string()))?;
Ok(DynModel::Xai(client.completion_model(&backend.model_id)))
}
ModelProvider::OpencodeGo | ModelProvider::OpenaiCompatible => {
let key = if backend.api_key.is_empty() {
"local"
} else {
backend.api_key.as_str()
};
if uses_responses_api(backend.provider, &backend.model_id) {
let client = openai::Client::builder()
.api_key(key.to_string())
.base_url(&backend.base_url)
.build()
.map_err(|error| ModelError::ProviderClient(error.to_string()))?;
Ok(DynModel::OpenAiResponses(
client.completion_model(&backend.model_id),
))
} else {
let client = openai::CompletionsClient::builder()
.api_key(key.to_string())
.base_url(&backend.base_url)
.build()
.map_err(|error| ModelError::ProviderClient(error.to_string()))?;
Ok(DynModel::OpenAi(client.completion_model(&backend.model_id)))
}
}
other => Err(ModelError::UnsupportedProvider {
provider: other.as_str().to_string(),
}),
}
}
pub fn credential_from_env(provider: ModelProvider) -> Option<String> { pub fn credential_from_env(provider: ModelProvider) -> Option<String> {
std::env::var(provider.env_key_name()).ok().filter(|value| !value.is_empty()) std::env::var(provider.env_key_name())
.ok()
.filter(|value| !value.is_empty())
} }
/// Prove the xAI Rig client can be constructed from a resolved backend. /// Prove the xAI Rig client can be constructed from a resolved backend.
pub fn connect_xai(backend: &ResolvedBackend) -> Result<rig_core::providers::xai::Client, ModelError> { pub fn connect_xai(
backend: &ResolvedBackend,
) -> Result<rig_core::providers::xai::Client, ModelError> {
if backend.provider != ModelProvider::Xai { if backend.provider != ModelProvider::Xai {
return Err(ModelError::UnsupportedProvider { return Err(ModelError::UnsupportedProvider {
provider: backend.provider.as_str().to_string(), provider: backend.provider.as_str().to_string(),
}); });
} }
rig_core::providers::xai::Client::new(&backend.api_key).map_err(|error| { rig_core::providers::xai::Client::new(&backend.api_key)
ModelError::ProviderClient(error.to_string()) .map_err(|error| ModelError::ProviderClient(error.to_string()))
})
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use lazyboy_contracts::DEFAULT_XAI_MODEL;
fn xai_request(key: Option<&str>) -> ResolveModelRequest { fn xai_request(key: Option<&str>) -> ResolveModelRequest {
ResolveModelRequest { ResolveModelRequest {
provider: ModelProvider::Xai, provider: ModelProvider::Xai,
model_id: None, model_id: None,
base_url: None,
credentials: CredentialChain { credentials: CredentialChain {
bot: None, bot: None,
space: None, space: None,
@ -138,6 +211,7 @@ mod tests {
let backend = resolve_backend(ResolveModelRequest { let backend = resolve_backend(ResolveModelRequest {
provider: ModelProvider::Xai, provider: ModelProvider::Xai,
model_id: Some("grok-4.6".into()), model_id: Some("grok-4.6".into()),
base_url: None,
credentials: CredentialChain { credentials: CredentialChain {
bot: Some("bot-key".into()), bot: Some("bot-key".into()),
space: Some("space-key".into()), space: Some("space-key".into()),
@ -164,6 +238,7 @@ mod tests {
let error = resolve_backend(ResolveModelRequest { let error = resolve_backend(ResolveModelRequest {
provider, provider,
model_id: Some("whatever".into()), model_id: Some("whatever".into()),
base_url: None,
credentials: CredentialChain { credentials: CredentialChain {
bot: None, bot: None,
space: None, space: None,
@ -185,4 +260,70 @@ mod tests {
let error = "gemini".parse::<ModelProvider>().unwrap_err(); let error = "gemini".parse::<ModelProvider>().unwrap_err();
assert_eq!(error.0, "gemini"); assert_eq!(error.0, "gemini");
} }
#[test]
fn opencode_go_uses_zen_go_endpoint() {
let backend = resolve_backend(ResolveModelRequest {
provider: ModelProvider::OpencodeGo,
model_id: None,
base_url: None,
credentials: CredentialChain {
bot: None,
space: None,
env: Some("go-key".into()),
},
})
.unwrap();
assert_eq!(backend.model_id, "glm-5.1");
assert_eq!(backend.base_url, "https://opencode.ai/zen/go/v1");
assert!(connect_model(&backend).is_ok());
}
#[test]
fn opencode_routes_responses_models_to_the_responses_api() {
assert!(uses_responses_api(
ModelProvider::OpencodeGo,
"gpt-5.6-luna"
));
assert!(uses_responses_api(ModelProvider::OpencodeGo, "grok-4.6"));
assert!(!uses_responses_api(ModelProvider::OpencodeGo, "glm-5.1"));
assert!(!uses_responses_api(
ModelProvider::OpenaiCompatible,
"gpt-5.6-luna"
));
}
#[test]
fn openai_compatible_allows_empty_key_and_custom_url() {
let backend = resolve_backend(ResolveModelRequest {
provider: ModelProvider::OpenaiCompatible,
model_id: Some("qwen2.5".into()),
base_url: Some("http://127.0.0.1:8000/v1/".into()),
credentials: CredentialChain {
bot: None,
space: None,
env: None,
},
})
.unwrap();
assert_eq!(backend.api_key, "");
assert_eq!(backend.base_url, "http://127.0.0.1:8000/v1");
assert!(connect_model(&backend).is_ok());
}
#[test]
fn openai_compatible_requires_base_url() {
let error = resolve_backend(ResolveModelRequest {
provider: ModelProvider::OpenaiCompatible,
model_id: Some("local".into()),
base_url: None,
credentials: CredentialChain {
bot: None,
space: None,
env: None,
},
})
.unwrap_err();
assert!(matches!(error, ModelError::MissingBaseUrl { .. }));
}
} }

View File

@ -1,11 +1,12 @@
use async_trait::async_trait; use async_trait::async_trait;
use base64::Engine; use base64::Engine;
use lazyboy_contracts::{ActiveWindow, ComputerObservation, CursorPosition, SandboxKind};
use lazyboy_contracts::ComputerCapabilities; use lazyboy_contracts::ComputerCapabilities;
use lazyboy_contracts::{ActiveWindow, ComputerObservation, CursorPosition, SandboxKind};
use lazyboy_control::{ use lazyboy_control::{
observation_from_png, ActionRequest, ActionResult, AdapterContext, CommandRequest, CommandResult, ActionRequest, ActionResult, AdapterContext, CommandRequest, CommandResult, ComputerRef,
ComputerRef, EnsureScreenRequest, EnsureScreenResult, FileEntry, ProvisionRequest, SandboxError, EnsureScreenRequest, EnsureScreenResult, FileEntry, ProvisionRequest, SandboxError,
SandboxProvider, ScreenSession, SandboxProvider, ScreenSession, observation_from_png, observation_with_elements,
parse_ui_elements,
}; };
use reqwest::Client; use reqwest::Client;
use serde_json::Value; use serde_json::Value;
@ -80,7 +81,9 @@ impl SandboxProvider for DockerSandbox {
if !response.status().is_success() { if !response.status().is_success() {
let status = response.status(); let status = response.status();
let body = response.text().await.unwrap_or_default(); let body = response.text().await.unwrap_or_default();
return Err(SandboxError::message(format!("provision failed: {status} {body}"))); return Err(SandboxError::message(format!(
"provision failed: {status} {body}"
)));
} }
let body: Value = response let body: Value = response
.json() .json()
@ -125,14 +128,19 @@ impl SandboxProvider for DockerSandbox {
if !response.status().is_success() { if !response.status().is_success() {
let status = response.status(); let status = response.status();
let body = response.text().await.unwrap_or_default(); let body = response.text().await.unwrap_or_default();
return Err(SandboxError::message(format!("ensure screen failed: {status} {body}"))); return Err(SandboxError::message(format!(
"ensure screen failed: {status} {body}"
)));
} }
let body: Value = response let body: Value = response
.json() .json()
.await .await
.map_err(|error| SandboxError::message(error.to_string()))?; .map_err(|error| SandboxError::message(error.to_string()))?;
Ok(EnsureScreenResult { Ok(EnsureScreenResult {
slot: body.get("slot").and_then(Value::as_u64).unwrap_or(request.slot as u64) as u32, slot: body
.get("slot")
.and_then(Value::as_u64)
.unwrap_or(request.slot as u64) as u32,
display: body display: body
.get("display") .get("display")
.and_then(Value::as_str) .and_then(Value::as_str)
@ -160,6 +168,13 @@ impl SandboxProvider for DockerSandbox {
.send() .send()
.await .await
.map_err(|error| SandboxError::message(error.to_string()))?; .map_err(|error| SandboxError::message(error.to_string()))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(SandboxError::message(format!(
"exec failed: {status} {body}"
)));
}
response response
.json() .json()
.await .await
@ -330,7 +345,11 @@ impl SandboxProvider for DockerSandbox {
} }
} }
async fn stop(&self, computer: &ComputerRef, context: &AdapterContext) -> Result<(), SandboxError> { async fn stop(
&self,
computer: &ComputerRef,
context: &AdapterContext,
) -> Result<(), SandboxError> {
self.client self.client
.post(self.url(&format!("/computers/{}/stop", computer.id))) .post(self.url(&format!("/computers/{}/stop", computer.id)))
.headers(self.headers(context)) .headers(self.headers(context))
@ -340,7 +359,11 @@ impl SandboxProvider for DockerSandbox {
Ok(()) Ok(())
} }
async fn destroy(&self, computer: &ComputerRef, context: &AdapterContext) -> Result<(), SandboxError> { async fn destroy(
&self,
computer: &ComputerRef,
context: &AdapterContext,
) -> Result<(), SandboxError> {
self.client self.client
.delete(self.url(&format!("/computers/{}", computer.id))) .delete(self.url(&format!("/computers/{}", computer.id)))
.headers(self.headers(context)) .headers(self.headers(context))
@ -382,5 +405,12 @@ fn decode_observation(body: &Value) -> Result<ComputerObservation, SandboxError>
.map(str::to_string), .map(str::to_string),
}) })
}); });
Ok(observation_from_png(png, 1280, 800, cursor, window)) let elements = body
.get("elements")
.map(|value| parse_ui_elements(&value.to_string()))
.unwrap_or_default();
Ok(observation_with_elements(
observation_from_png(png, 1280, 800, cursor, window),
elements,
))
} }

View File

@ -4,8 +4,9 @@ use std::sync::Mutex;
use async_trait::async_trait; use async_trait::async_trait;
use lazyboy_contracts::{ComputerObservation, SandboxKind}; use lazyboy_contracts::{ComputerObservation, SandboxKind};
use lazyboy_control::{ use lazyboy_control::{
observation_from_png, ActionRequest, ActionResult, AdapterContext, CommandRequest, CommandResult, ActionRequest, ActionResult, AdapterContext, CommandRequest, CommandResult, ComputerRef,
ComputerRef, FileEntry, ProvisionRequest, SandboxError, SandboxProvider, ScreenSession, FileEntry, ProvisionRequest, SandboxError, SandboxProvider, ScreenSession,
observation_from_png,
}; };
const EMPTY_PNG: &[u8] = &[ const EMPTY_PNG: &[u8] = &[
@ -165,11 +166,19 @@ impl SandboxProvider for FakeSandbox {
Ok(()) Ok(())
} }
async fn stop(&self, _computer: &ComputerRef, _context: &AdapterContext) -> Result<(), SandboxError> { async fn stop(
&self,
_computer: &ComputerRef,
_context: &AdapterContext,
) -> Result<(), SandboxError> {
Ok(()) Ok(())
} }
async fn destroy(&self, computer: &ComputerRef, _context: &AdapterContext) -> Result<(), SandboxError> { async fn destroy(
&self,
computer: &ComputerRef,
_context: &AdapterContext,
) -> Result<(), SandboxError> {
self.files.lock().unwrap().remove(&computer.home_key); self.files.lock().unwrap().remove(&computer.home_key);
Ok(()) Ok(())
} }

View File

@ -3,22 +3,22 @@ use std::default::Default;
use std::path::PathBuf; use std::path::PathBuf;
use base64::Engine; use base64::Engine;
use bollard::Docker;
use bollard::container::{ use bollard::container::{
Config, CreateContainerOptions, ListContainersOptions, RemoveContainerOptions, StartContainerOptions, Config, CreateContainerOptions, ListContainersOptions, RemoveContainerOptions,
StopContainerOptions, StartContainerOptions, StopContainerOptions,
}; };
use bollard::exec::{CreateExecOptions, StartExecOptions, StartExecResults}; use bollard::exec::{CreateExecOptions, StartExecOptions, StartExecResults};
use bollard::models::{HostConfig, PortBinding}; use bollard::models::{HostConfig, PortBinding};
use bollard::network::CreateNetworkOptions; use bollard::network::CreateNetworkOptions;
use bollard::Docker;
use futures_util::StreamExt; use futures_util::StreamExt;
use lazyboy_control::{ use lazyboy_control::{
action_pause_ms, launch_argv_on, normalize_display, normalize_workspace_path, open_argv_on, ActionRequest, CommandRequest, CommandResult, EnsureScreenRequest, EnsureScreenResult, HOME,
pointer_state_command_on, screen_layout, screenshot_command_on, xdotool_argv_on, ActionRequest, ScreenTarget, TEAM_SCREEN_LIMIT, action_pause_ms, launch_argv_on, normalize_display,
CommandRequest, CommandResult, EnsureScreenRequest, EnsureScreenResult, ScreenTarget, HOME, normalize_workspace_path, open_argv_on, pointer_state_command_on, screen_layout,
TEAM_SCREEN_LIMIT, screenshot_command_on, window_list_command_on, xdotool_argv_on,
}; };
use tokio::time::{sleep, Duration}; use tokio::time::{Duration, sleep};
const SCREEN_PORT_COUNT: u16 = TEAM_SCREEN_LIMIT as u16; const SCREEN_PORT_COUNT: u16 = TEAM_SCREEN_LIMIT as u16;
@ -83,6 +83,7 @@ impl DockerHost {
.await .await
.ok(); .ok();
self.wait_running(&existing).await?; self.wait_running(&existing).await?;
self.wait_ready(&existing).await?;
let screen_url = self.screen_url(&existing, false).await.ok(); let screen_url = self.screen_url(&existing, false).await.ok();
return Ok(Provisioned { return Ok(Provisioned {
id: existing, id: existing,
@ -108,7 +109,7 @@ impl DockerHost {
port.clone(), port.clone(),
Some(vec![PortBinding { Some(vec![PortBinding {
host_ip: Some("127.0.0.1".into()), host_ip: Some("127.0.0.1".into()),
host_port: None, host_port: Some("0".into()),
}]), }]),
); );
exposed.insert(port, HashMap::new()); exposed.insert(port, HashMap::new());
@ -185,6 +186,11 @@ impl DockerHost {
.await .await
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
self.wait_running(&created.id).await?; self.wait_running(&created.id).await?;
// The container is reported as running before its primary desktop has
// finished booting. Returning sooner lets an API ensure-screen call
// win the screen lock, which makes the container's own startup time
// out and exit. Wait for the entrypoint's readiness marker first.
self.wait_ready(&created.id).await?;
let screen_url = self.screen_url(&created.id, false).await.ok(); let screen_url = self.screen_url(&created.id, false).await.ok();
Ok(Provisioned { Ok(Provisioned {
id: created.id, id: created.id,
@ -211,7 +217,8 @@ impl DockerHost {
} else { } else {
request.argv request.argv
}; };
self.exec_argv(id, &argv, Some(&cwd), &ScreenTarget::default()).await self.exec_argv(id, &argv, Some(&cwd), &ScreenTarget::default())
.await
} }
pub async fn exec_on( pub async fn exec_on(
@ -241,41 +248,86 @@ impl DockerHost {
} }
pub async fn observe(&self, id: &str) -> Result<Vec<u8>, String> { pub async fn observe(&self, id: &str) -> Result<Vec<u8>, String> {
Ok(self.observe_payload(id, &ScreenTarget::default()).await?.png) Ok(self
.observe_payload(id, &ScreenTarget::default())
.await?
.png)
} }
pub async fn observe_payload(&self, id: &str, target: &ScreenTarget) -> Result<ObservePayload, String> { pub async fn observe_payload(
if let Ok(value) = self.control_observe_json(id, target).await { &self,
return ObservePayload::from_json(value); id: &str,
} target: &ScreenTarget,
let (stdout, stderr, code) = self ) -> Result<ObservePayload, String> {
.exec_raw(id, &screenshot_command_on(&target.display), None, target) let mut body = if let Ok(value) = self.control_observe_json(id, target).await {
.await?; value
if code != 0 { } else {
return Err(String::from_utf8_lossy(&stderr).into_owned()); let (stdout, stderr, code) = self
} .exec_raw(id, &screenshot_command_on(&target.display), None, target)
let mut body = serde_json::json!({ .await?;
"png_base64": base64::engine::general_purpose::STANDARD.encode(&stdout) if code != 0 {
}); return Err(String::from_utf8_lossy(&stderr).into_owned());
if let Ok(meta) = self.pointer_state(id, target).await { }
if let serde_json::Value::Object(map) = meta { let mut body = serde_json::json!({
if let Some(obj) = body.as_object_mut() { "png_base64": base64::engine::general_purpose::STANDARD.encode(&stdout)
if let (Some(x), Some(y)) = (map.get("x"), map.get("y")) { });
obj.insert("cursor".into(), serde_json::json!({ "x": x, "y": y })); if let Ok(meta) = self.pointer_state(id, target).await {
} if let serde_json::Value::Object(map) = meta {
if map.get("id").and_then(serde_json::Value::as_str).is_some_and(|id| !id.is_empty()) { if let Some(obj) = body.as_object_mut() {
obj.insert( if let (Some(x), Some(y)) = (map.get("x"), map.get("y")) {
"activeWindow".into(), obj.insert("cursor".into(), serde_json::json!({ "x": x, "y": y }));
serde_json::json!({ "id": map.get("id"), "title": map.get("title") }), }
); if map
.get("id")
.and_then(serde_json::Value::as_str)
.is_some_and(|id| !id.is_empty())
{
obj.insert(
"activeWindow".into(),
serde_json::json!({ "id": map.get("id"), "title": map.get("title") }),
);
}
} }
} }
} }
} body
};
self.attach_window_elements(id, target, &mut body).await;
ObservePayload::from_json(body) ObservePayload::from_json(body)
} }
async fn pointer_state(&self, id: &str, target: &ScreenTarget) -> Result<serde_json::Value, String> { async fn attach_window_elements(
&self,
id: &str,
target: &ScreenTarget,
body: &mut serde_json::Value,
) {
if body
.get("elements")
.and_then(serde_json::Value::as_array)
.is_some_and(|items| !items.is_empty())
{
return;
}
let Ok(result) = self
.exec_argv(id, &window_list_command_on(&target.display), None, target)
.await
else {
return;
};
if result.code != 0 {
return;
}
if let Ok(elements) = serde_json::from_str::<serde_json::Value>(&result.stdout) {
body["elements"] = elements;
}
}
async fn pointer_state(
&self,
id: &str,
target: &ScreenTarget,
) -> Result<serde_json::Value, String> {
let result = self let result = self
.exec_argv(id, &pointer_state_command_on(&target.display), None, target) .exec_argv(id, &pointer_state_command_on(&target.display), None, target)
.await?; .await?;
@ -354,28 +406,49 @@ impl DockerHost {
self.screen_url_for(id, 0, interactive).await self.screen_url_for(id, 0, interactive).await
} }
pub async fn screen_url_for(&self, id: &str, slot: u32, interactive: bool) -> Result<String, String> { pub async fn screen_url_for(
&self,
id: &str,
slot: u32,
interactive: bool,
) -> Result<String, String> {
let layout = screen_layout(slot).map_err(|error| error.to_string())?; let layout = screen_layout(slot).map_err(|error| error.to_string())?;
let key = format!("{}/tcp", layout.view_port); let port = self.published_host_port(id, layout.view_port).await?;
let info = self
.docker
.inspect_container(id, None)
.await
.map_err(|error| error.to_string())?;
let port = info
.network_settings
.and_then(|settings| settings.ports)
.and_then(|ports| ports.get(&key).cloned())
.and_then(|bindings| bindings)
.and_then(|bindings| bindings.into_iter().next())
.and_then(|binding| binding.host_port)
.ok_or_else(|| format!("screen port {} is not published", layout.view_port))?;
let view = if interactive { "false" } else { "true" }; let view = if interactive { "false" } else { "true" };
Ok(format!( Ok(format!(
"http://127.0.0.1:{port}/vnc_lite.html?resize=scale&view_only={view}" "http://127.0.0.1:{port}/vnc_lite.html?resize=scale&view_only={view}"
)) ))
} }
async fn published_host_port(&self, id: &str, view_port: u16) -> Result<String, String> {
let key = format!("{view_port}/tcp");
for _ in 0..20 {
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);
if !running {
let exit = info.state.and_then(|state| state.exit_code).unwrap_or(1);
return Err(format!("computer is not running (exit {exit})"));
}
if let Some(port) = info
.network_settings
.and_then(|settings| settings.ports)
.and_then(|ports| ports.get(&key).cloned())
.flatten()
.and_then(|bindings| bindings.into_iter().next())
.and_then(|binding| binding.host_port)
.filter(|port| !port.is_empty())
{
return Ok(port);
}
sleep(Duration::from_millis(100)).await;
}
Err(format!("screen port {view_port} is not published"))
}
pub async fn ensure_screen( pub async fn ensure_screen(
&self, &self,
id: &str, id: &str,
@ -478,7 +551,12 @@ PY"#,
"python3 -c \"import os,base64,sys; p=sys.argv[1]; os.makedirs(os.path.dirname(p) or '.', exist_ok=True); open(p,'wb').write(base64.b64decode(sys.argv[2]))\" {target:?} {encoded:?}" "python3 -c \"import os,base64,sys; p=sys.argv[1]; os.makedirs(os.path.dirname(p) or '.', exist_ok=True); open(p,'wb').write(base64.b64decode(sys.argv[2]))\" {target:?} {encoded:?}"
); );
let result = self let result = self
.exec_argv(id, &["bash".into(), "-lc".into(), script], None, &ScreenTarget::default()) .exec_argv(
id,
&["bash".into(), "-lc".into(), script],
None,
&ScreenTarget::default(),
)
.await?; .await?;
if result.code != 0 { if result.code != 0 {
Err(result.stderr) Err(result.stderr)
@ -538,7 +616,11 @@ PY"#,
return Ok(false); return Ok(false);
} }
let running = info.state.as_ref().and_then(|state| state.running) == Some(true); let running = info.state.as_ref().and_then(|state| state.running) == Some(true);
let exit = info.state.as_ref().and_then(|state| state.exit_code).unwrap_or(0); let exit = info
.state
.as_ref()
.and_then(|state| state.exit_code)
.unwrap_or(0);
if !running && exit != 0 { if !running && exit != 0 {
return Ok(false); return Ok(false);
} }
@ -592,6 +674,33 @@ PY"#,
Err("container failed to start".into()) Err("container failed to start".into())
} }
async fn wait_ready(&self, id: &str) -> Result<(), String> {
for _ in 0..160 {
let info = self
.docker
.inspect_container(id, None)
.await
.map_err(|error| error.to_string())?;
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}"));
}
match self
.exec_argv(
id,
&["test".into(), "-f".into(), "/tmp/lazyboy/ready".into()],
None,
&ScreenTarget::default(),
)
.await
{
Ok(result) if result.code == 0 => return Ok(()),
Ok(_) | Err(_) => sleep(Duration::from_millis(250)).await,
}
}
Err("computer desktop failed to become ready".into())
}
async fn exec_argv( async fn exec_argv(
&self, &self,
id: &str, id: &str,
@ -643,14 +752,23 @@ PY"#,
let mut stderr = Vec::new(); let mut stderr = Vec::new();
if let StartExecResults::Attached { mut output, .. } = self if let StartExecResults::Attached { mut output, .. } = self
.docker .docker
.start_exec(&exec.id, Some(StartExecOptions { ..Default::default() })) .start_exec(
&exec.id,
Some(StartExecOptions {
..Default::default()
}),
)
.await .await
.map_err(|error| error.to_string())? .map_err(|error| error.to_string())?
{ {
while let Some(chunk) = output.next().await { while let Some(chunk) = output.next().await {
match chunk.map_err(|error| error.to_string())? { match chunk.map_err(|error| error.to_string())? {
bollard::container::LogOutput::StdOut { message } => stdout.extend_from_slice(&message), bollard::container::LogOutput::StdOut { message } => {
bollard::container::LogOutput::StdErr { message } => stderr.extend_from_slice(&message), stdout.extend_from_slice(&message)
}
bollard::container::LogOutput::StdErr { message } => {
stderr.extend_from_slice(&message)
}
_ => {} _ => {}
} }
} }
@ -663,11 +781,14 @@ PY"#,
Ok((stdout, stderr, inspect.exit_code.unwrap_or(1) as i32)) Ok((stdout, stderr, inspect.exit_code.unwrap_or(1) as i32))
} }
async fn control_observe_json(&self, id: &str, target: &ScreenTarget) -> Result<serde_json::Value, String> { async fn control_observe_json(
&self,
id: &str,
target: &ScreenTarget,
) -> Result<serde_json::Value, String> {
let script = format!( let script = format!(
"curl -fsS -H 'Authorization: Bearer {}' -H 'x-lazyboy-display: {}' http://127.0.0.1:7070/observe", "curl -fsS -H 'Authorization: Bearer {}' -H 'x-lazyboy-display: {}' http://127.0.0.1:7070/observe",
self.control_token, self.control_token, target.display
target.display
); );
let result = self let result = self
.exec_argv(id, &["bash".into(), "-lc".into(), script], None, target) .exec_argv(id, &["bash".into(), "-lc".into(), script], None, target)
@ -709,7 +830,9 @@ PY"#,
fn image_ids_match(wanted: &str, have: &str) -> bool { fn image_ids_match(wanted: &str, have: &str) -> bool {
let wanted = wanted.trim_start_matches("sha256:"); let wanted = wanted.trim_start_matches("sha256:");
let have = have.trim_start_matches("sha256:"); let have = have.trim_start_matches("sha256:");
!wanted.is_empty() && !have.is_empty() && (wanted == have || wanted.starts_with(have) || have.starts_with(wanted)) !wanted.is_empty()
&& !have.is_empty()
&& (wanted == have || wanted.starts_with(have) || have.starts_with(wanted))
} }
fn host_bind_path(path: &str) -> String { fn host_bind_path(path: &str) -> String {

View File

@ -8,7 +8,8 @@ use axum::routing::{delete, get, post};
use axum::{Json, Router}; use axum::{Json, Router};
use docker::DockerHost; use docker::DockerHost;
use lazyboy_control::{ use lazyboy_control::{
normalize_workspace_path, ActionRequest, CommandRequest, EnsureScreenRequest, ScreenTarget, HOME, ActionRequest, CommandRequest, EnsureScreenRequest, HOME, ScreenTarget,
normalize_workspace_path,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
@ -34,13 +35,14 @@ async fn main() {
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env().add_directive("info".parse().unwrap())) .with_env_filter(EnvFilter::from_default_env().add_directive("info".parse().unwrap()))
.init(); .init();
let token = std::env::var("SANDBOX_SUPERVISOR_TOKEN") let token =
.expect("SANDBOX_SUPERVISOR_TOKEN must be set"); std::env::var("SANDBOX_SUPERVISOR_TOKEN").expect("SANDBOX_SUPERVISOR_TOKEN must be set");
assert!( assert!(
token.len() >= 32 && token != "dev-token", token.len() >= 32 && token != "dev-token",
"SANDBOX_SUPERVISOR_TOKEN must be a non-default value of at least 32 characters" "SANDBOX_SUPERVISOR_TOKEN must be a non-default value of at least 32 characters"
); );
let image = std::env::var("LAZYBOY_COMPUTER_IMAGE").unwrap_or_else(|_| "lazyboy/computer:local".into()); let image =
std::env::var("LAZYBOY_COMPUTER_IMAGE").unwrap_or_else(|_| "lazyboy/computer:local".into());
let docker = DockerHost::connect(image, token.clone()) let docker = DockerHost::connect(image, token.clone())
.await .await
.expect("docker"); .expect("docker");
@ -49,7 +51,10 @@ async fn main() {
docker: Arc::new(docker), docker: Arc::new(docker),
}; };
let router = Router::new() let router = Router::new()
.route("/health", get(|| async { Json(serde_json::json!({"ok": true})) })) .route(
"/health",
get(|| async { Json(serde_json::json!({"ok": true})) }),
)
.route("/computers", post(provision)) .route("/computers", post(provision))
.route("/computers/{id}/exec", post(exec)) .route("/computers/{id}/exec", post(exec))
.route("/computers/{id}/observe", post(observe)) .route("/computers/{id}/observe", post(observe))
@ -120,15 +125,15 @@ async fn exec(
headers: HeaderMap, headers: HeaderMap,
Path(id): Path<String>, Path(id): Path<String>,
Json(body): Json<CommandRequest>, Json(body): Json<CommandRequest>,
) -> Result<Json<serde_json::Value>, StatusCode> { ) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
require_token(&headers, &app.token)?; require_token(&headers, &app.token).map_err(|status| (status, String::new()))?;
let result = app let result = app
.docker .docker
.exec_on(&id, body, &screen_target(&headers)) .exec_on(&id, body, &screen_target(&headers))
.await .await
.map_err(|error| { .map_err(|error| {
tracing::error!("exec: {error}"); tracing::error!("exec: {error}");
StatusCode::INTERNAL_SERVER_ERROR (StatusCode::INTERNAL_SERVER_ERROR, error)
})?; })?;
Ok(Json(serde_json::json!({ Ok(Json(serde_json::json!({
"stdout": result.stdout, "stdout": result.stdout,
@ -190,7 +195,10 @@ async fn screen_mode(
Json(body): Json<ScreenModeBody>, Json(body): Json<ScreenModeBody>,
) -> Result<Json<serde_json::Value>, StatusCode> { ) -> Result<Json<serde_json::Value>, StatusCode> {
require_token(&headers, &app.token)?; require_token(&headers, &app.token)?;
let slot = body.slot.or(screen_target(&headers).slot.into()).unwrap_or(0); let slot = body
.slot
.or(screen_target(&headers).slot.into())
.unwrap_or(0);
let url = app let url = app
.docker .docker
.screen_url_for(&id, slot, body.interactive) .screen_url_for(&id, slot, body.interactive)
@ -286,7 +294,10 @@ async fn stop(
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<StatusCode, StatusCode> { ) -> Result<StatusCode, StatusCode> {
require_token(&headers, &app.token)?; require_token(&headers, &app.token)?;
app.docker.stop(&id).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; app.docker
.stop(&id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
@ -296,7 +307,10 @@ async fn destroy(
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<StatusCode, StatusCode> { ) -> Result<StatusCode, StatusCode> {
require_token(&headers, &app.token)?; require_token(&headers, &app.token)?;
app.docker.destroy(&id).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; app.docker
.destroy(&id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }

View File

@ -58,6 +58,8 @@ services:
HOST_DATA_DIR: ${PWD}/data HOST_DATA_DIR: ${PWD}/data
API_BIND: 0.0.0.0:3100 API_BIND: 0.0.0.0:3100
XAI_API_KEY: ${XAI_API_KEY:-} XAI_API_KEY: ${XAI_API_KEY:-}
OPENCODE_GO_API_KEY: ${OPENCODE_GO_API_KEY:-}
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
LAZYBOY_MEMORY_ENABLED: ${LAZYBOY_MEMORY_ENABLED:-true} LAZYBOY_MEMORY_ENABLED: ${LAZYBOY_MEMORY_ENABLED:-true}
LAZYBOY_MEMORY_MODEL_CACHE: /data/fastembed LAZYBOY_MEMORY_MODEL_CACHE: /data/fastembed
LAZYBOY_MEMORY_TOP_K: ${LAZYBOY_MEMORY_TOP_K:-8} LAZYBOY_MEMORY_TOP_K: ${LAZYBOY_MEMORY_TOP_K:-8}

View File

@ -15,9 +15,25 @@ COPY apps/web apps/web
RUN cargo build --release -p lazyboy-api RUN cargo build --release -p lazyboy-api
FROM debian:trixie-slim FROM debian:trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/* 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 \
&& 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=build /src/target/release/lazyboy-api /usr/local/bin/lazyboy-api
COPY --from=web /src/apps/web/dist /web COPY --from=web /src/apps/web/dist /web
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 \
&& npm cache clean --force \
&& node -v && npx --version \
&& python3 -c "import mcp_server_fetch"
ENV LAZYBOY_WEB_DIR=/web ENV LAZYBOY_WEB_DIR=/web
ENV NPM_CONFIG_UPDATE_NOTIFIER=false
ENV NPM_CONFIG_FUND=false
EXPOSE 3100 EXPOSE 3100
CMD ["lazyboy-api"] CMD ["lazyboy-api"]

View File

@ -84,7 +84,6 @@ RUN useradd --create-home --uid 1000 --shell /bin/zsh lazyboy \
&& chown -R 1000:1000 /home/lazyboy /tmp/lazyboy && chown -R 1000:1000 /home/lazyboy /tmp/lazyboy
COPY --from=controld --chmod=755 /src/target/release/lazyboy-controld /usr/local/bin/lazyboy-controld COPY --from=controld --chmod=755 /src/target/release/lazyboy-controld /usr/local/bin/lazyboy-controld
COPY --chmod=755 image/computer/start.sh /usr/local/bin/lazyboy-computer
COPY --chmod=755 image/computer/lazyboy-screen /usr/local/bin/lazyboy-screen COPY --chmod=755 image/computer/lazyboy-screen /usr/local/bin/lazyboy-screen
COPY --chmod=755 image/computer/lazyboy-browser /usr/local/bin/lazyboy-browser COPY --chmod=755 image/computer/lazyboy-browser /usr/local/bin/lazyboy-browser
COPY --chmod=755 image/computer/lazyboy-terminal /usr/local/bin/lazyboy-terminal COPY --chmod=755 image/computer/lazyboy-terminal /usr/local/bin/lazyboy-terminal
@ -129,6 +128,7 @@ COPY --chmod=644 image/computer/xfce/helper-terminal.desktop /usr/share/xfce4/he
COPY --chmod=644 image/computer/xfce/helper-browser.desktop /usr/share/xfce4/helpers/lazyboy-browser.desktop COPY --chmod=644 image/computer/xfce/helper-browser.desktop /usr/share/xfce4/helpers/lazyboy-browser.desktop
COPY --chmod=755 image/computer/chromium /usr/local/bin/chromium COPY --chmod=755 image/computer/chromium /usr/local/bin/chromium
RUN sed -i 's|^Exec=/usr/bin/chromium|Exec=/usr/local/bin/lazyboy-browser|' /usr/share/applications/chromium.desktop || true RUN sed -i 's|^Exec=/usr/bin/chromium|Exec=/usr/local/bin/lazyboy-browser|' /usr/share/applications/chromium.desktop || true
COPY --chmod=755 image/computer/start.sh /usr/local/bin/lazyboy-computer
USER 1000:1000 USER 1000:1000
ENV HOME=/home/lazyboy DISPLAY=:1 SHELL=/bin/zsh TERM=xterm-256color \ ENV HOME=/home/lazyboy DISPLAY=:1 SHELL=/bin/zsh TERM=xterm-256color \

View File

@ -15,20 +15,36 @@ else
PROFILE="$HOME/.browser-profiles/displays/${display}" PROFILE="$HOME/.browser-profiles/displays/${display}"
fi fi
mkdir -p "$PROFILE" mkdir -p "$PROFILE"
# The bot drives this same window over DevTools (port 9221 + display number,
# see crates/control/src/cdp.rs). Every launch path (boot, panel launcher,
# xdg-open, launch_app) must open it, otherwise the model cannot attach to the
# browser the human is looking at and would have to restart it.
DEVTOOLS_PORT=$((9221 + display))
live=$(pgrep -f "chromium.*--user-data-dir=${PROFILE}" 2>/dev/null | head -1) live=$(pgrep -f "chromium.*--user-data-dir=${PROFILE}" 2>/dev/null | head -1)
lock="$(readlink "$PROFILE/SingletonLock" 2>/dev/null || true)" lock="$(readlink "$PROFILE/SingletonLock" 2>/dev/null || true)"
lock_pid="${lock##*-}" lock_pid="${lock##*-}"
if [ -n "$live" ]; then if [ -n "$live" ]; then
# A second Chromium on the same profile is the "Profile error occurred" dialog. # A second Chromium on the same profile is the "Profile error occurred" dialog.
# Only hand the URL to the existing process when its singleton is still valid. # Only hand URLs to the existing process (flags are meaningless to it and an
if [ -n "$lock_pid" ] && kill -0 "$lock_pid" 2>/dev/null; then # empty argument list would open a stray New Tab in the user's window).
exec /usr/bin/chromium --no-sandbox --user-data-dir="$PROFILE" "$@" urls=""
for arg in "$@"; do
case "$arg" in
-*) ;;
*) urls="$urls $arg" ;;
esac
done
if [ -n "$urls" ] && [ -n "$lock_pid" ] && kill -0 "$lock_pid" 2>/dev/null; then
# shellcheck disable=SC2086
exec /usr/bin/chromium --no-sandbox --user-data-dir="$PROFILE" $urls
fi fi
exit 0 exit 0
fi fi
rm -f "$PROFILE/SingletonLock" "$PROFILE/SingletonCookie" "$PROFILE/SingletonSocket" rm -f "$PROFILE/SingletonLock" "$PROFILE/SingletonCookie" "$PROFILE/SingletonSocket"
exec /usr/bin/chromium \ exec /usr/bin/chromium \
--no-sandbox \ --no-sandbox \
--remote-debugging-port="$DEVTOOLS_PORT" \
--remote-allow-origins='*' \
--test-type \ --test-type \
--disable-gpu \ --disable-gpu \
--disable-dev-shm-usage \ --disable-dev-shm-usage \

View File

@ -11,6 +11,7 @@ export LANG="${LANG:-zh_TW.UTF-8}"
export LC_ALL="${LC_ALL:-zh_TW.UTF-8}" export LC_ALL="${LC_ALL:-zh_TW.UTF-8}"
export LANGUAGE="${LANGUAGE:-zh_TW:zh:en}" export LANGUAGE="${LANGUAGE:-zh_TW:zh:en}"
mkdir -p "$HOME" /tmp/lazyboy /tmp/.X11-unix mkdir -p "$HOME" /tmp/lazyboy /tmp/.X11-unix
rm -f /tmp/lazyboy/ready
export PATH="$HOME/.local/bin:/usr/local/bin:$PATH" export PATH="$HOME/.local/bin:/usr/local/bin:$PATH"
cd "$HOME" cd "$HOME"
@ -23,6 +24,7 @@ if command -v dbus-launch >/dev/null 2>&1; then
fi fi
lazyboy-screen boot-primary || exit 1 lazyboy-screen boot-primary || exit 1
touch /tmp/lazyboy/ready
pid="" pid=""
if [[ -f /tmp/lazyboy/xvfb-1.pid ]]; then if [[ -f /tmp/lazyboy/xvfb-1.pid ]]; then
pid="$(cat /tmp/lazyboy/xvfb-1.pid)" pid="$(cat /tmp/lazyboy/xvfb-1.pid)"

View File

@ -0,0 +1,27 @@
-- Multi-agent group rooms: one conversation many bots can speak in.
CREATE TABLE IF NOT EXISTS rooms (
id TEXT PRIMARY KEY,
space_id TEXT NOT NULL REFERENCES spaces (id) ON DELETE CASCADE,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS rooms_space_user_idx ON rooms (space_id, user_id, updated_at DESC);
CREATE TABLE IF NOT EXISTS room_members (
room_id TEXT NOT NULL REFERENCES rooms (id) ON DELETE CASCADE,
bot_id TEXT NOT NULL REFERENCES bots (id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (room_id, bot_id)
);
CREATE INDEX IF NOT EXISTS room_members_bot_idx ON room_members (bot_id);
ALTER TABLE threads ADD COLUMN IF NOT EXISTS room_id TEXT REFERENCES rooms (id) ON DELETE CASCADE;
CREATE INDEX IF NOT EXISTS threads_room_idx ON threads (room_id) WHERE room_id IS NOT NULL;
ALTER TABLE messages ADD COLUMN IF NOT EXISTS speaker_bot_id TEXT REFERENCES bots (id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS messages_speaker_idx ON messages (speaker_bot_id) WHERE speaker_bot_id IS NOT NULL;

View File

@ -0,0 +1,20 @@
-- Workspace MCP servers. Agents share these tools.
CREATE TABLE IF NOT EXISTS mcp_servers (
id TEXT PRIMARY KEY,
space_id TEXT NOT NULL REFERENCES spaces (id) ON DELETE CASCADE,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
transport TEXT NOT NULL,
command TEXT,
args JSONB NOT NULL DEFAULT '[]'::jsonb,
env JSONB NOT NULL DEFAULT '{}'::jsonb,
url TEXT,
headers JSONB NOT NULL DEFAULT '{}'::jsonb,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (space_id, user_id, name)
);
CREATE INDEX IF NOT EXISTS mcp_servers_space_user_idx ON mcp_servers (space_id, user_id, created_at);

View File

@ -0,0 +1,3 @@
ALTER TABLE spaces
ADD COLUMN IF NOT EXISTS default_model_base_url TEXT,
ADD COLUMN IF NOT EXISTS default_model_api_key TEXT;

View File

@ -0,0 +1,26 @@
-- Skills taught by demonstration: the human drives the bot's desktop while we
-- record semantic events (DOM clicks, typed text, navigations) and keyframes,
-- then a model distils that into an intent-level playbook the bot can follow.
CREATE TABLE taught_skills (
id TEXT PRIMARY KEY,
space_id TEXT NOT NULL REFERENCES spaces (id) ON DELETE CASCADE,
user_id TEXT NOT NULL,
bot_id TEXT NOT NULL REFERENCES bots (id) ON DELETE CASCADE,
thread_id TEXT,
name TEXT NOT NULL DEFAULT '',
goal TEXT NOT NULL,
-- recording | drafting | draft | saved | failed
status TEXT NOT NULL,
playbook JSONB NOT NULL DEFAULT '{}'::jsonb,
recording JSONB NOT NULL DEFAULT '{"events":[],"frames":[]}'::jsonb,
error TEXT,
started_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
stopped_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX taught_skills_one_recording_idx
ON taught_skills (bot_id) WHERE status = 'recording';
CREATE INDEX taught_skills_bot_status_idx ON taught_skills (bot_id, status);

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