diff --git a/.env.example b/.env.example index b797561..b8e088c 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,6 @@ XAI_API_KEY= +OPENCODE_GO_API_KEY= +OPENAI_API_KEY= # Generate independent values with: openssl rand -hex 32 LAZYBOY_APP_TOKEN= SANDBOX_SUPERVISOR_TOKEN= diff --git a/.gitignore b/.gitignore index b615746..7c1fa46 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ image/computer/lazyboy-controld node_modules dist .DS_Store +.gstack/ diff --git a/Cargo.lock b/Cargo.lock index 0938bc5..9203251 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2020,6 +2020,7 @@ dependencies = [ "futures-util", "hex", "hmac", + "http", "http-body-util", "lazyboy-contracts", "lazyboy-control", @@ -2027,6 +2028,7 @@ dependencies = [ "lazyboy-sandbox", "reqwest 0.12.28", "rig-core", + "rmcp", "serde", "serde_json", "sha2", @@ -2058,6 +2060,7 @@ dependencies = [ "async-trait", "chrono", "hex", + "image", "lazyboy-contracts", "serde", "serde_json", @@ -2423,6 +2426,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "no_std_io2" version = "0.9.4" @@ -2869,6 +2884,20 @@ dependencies = [ "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]] name = "profiling" version = "1.0.18" @@ -3434,6 +3463,31 @@ dependencies = [ "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]] name = "rsa" version = "0.9.10" @@ -4141,6 +4195,19 @@ dependencies = [ "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]] name = "stable_deref_trait" version = "1.2.1" @@ -5084,6 +5151,27 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "windows-core" version = "0.62.2" @@ -5097,6 +5185,17 @@ dependencies = [ "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]] name = "windows-implement" version = "0.60.2" @@ -5125,6 +5224,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "windows-registry" version = "0.6.1" @@ -5212,6 +5321,15 @@ dependencies = [ "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]] name = "windows_aarch64_gnullvm" version = "0.48.5" diff --git a/Cargo.toml b/Cargo.toml index 284ab7f..3988c62 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ sha2 = "0.10" hex = "0.4" base64 = "0.22" chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } +image = { version = "0.25", default-features = false, features = ["jpeg", "png"] } rig-core = "0.42" async-trait = "0.1" tokio = { version = "1", features = ["macros", "rt-multi-thread", "process", "io-util", "fs", "signal", "time"] } diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6fb0f2d --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 8316160..f27a97e 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,39 @@ 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) 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`. -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. diff --git a/a.txt b/a.txt new file mode 100644 index 0000000..aaa90fc --- /dev/null +++ b/a.txt @@ -0,0 +1,51 @@ +我這邊體感滑,是因為多數工作走 Shell/API/瀏覽器 DOM,很少靠截圖猜 XY。你 repo 裡 瀏覽器這條其實已經對了;卡的是 桌面原生 App。 + +你已經有的(別拆) +能力 在哪 +Docker 真實 Linux 桌面 + Screen/lease/takeover supervisor / sandbox / control +shell 工具 crates/api/src/tools.rs +browser(CDP DOM snapshot/click by id/selector) control/cdp.rs + cdp.py;工具描述已寫「網頁優先用 browser」 +編號元素、apply_element_targets contracts/action.rs、control/actions.rs +畫面沒變就不重傳圖(frame signature) control/observe.rs +原生動作執行:xdotool / wmctrl control/x11.rs、image/computer/Dockerfile +這已經比純「截圖 computer-use」產品完整一截。 + +現在實際主路徑(為什麼不順) +computer_observe → 截圖 +(瀏覽器時)CDP 元素 + →(否則)只有 wmctrl 視窗級元素 +computer_act → element id 先換成中心點 XY → xdotool 點像素 + +關鍵缺口: + +P0|沒有 AT-SPI/無障礙樹 +Dockerfile 有 xdotool、wmctrl,沒有 at-spi2/python3-pyatspi 之類。 +原生「elements」在 window_list_command 只是 整窗標題,不是按鈕/輸入框。模型對 Thunar、對話框、面板仍要看圖或瞎點。 + +P0|element 最終還是 XY +apply_element_targets 填的是中心座標,執行仍走 ComputerAction::Pointer → xdotool。 +視窗一動、動畫、縮放就歪。agent-desktop/Cua 是 對 element 做 AX/AT-SPI action,不是點中心像素。 + +P1|ComputerAction 契約只有像素/鍵 +action.rs 沒有 ActByRef { id }、SetValue、A11yPress 這類語意動作。擴 CDP/a11y 都會卡在契約層。 + +P1|控的是容器桌面,不是你 Mac +對「操控我電腦」若指本機 Finder/系統設定,現在這條 stack 接不到 Mac AX。那是另一條 provider(agent-desktop/Cua),不是再調 xdotool。 + +P2|模型路由依賴 prompt +tools 已寫「Chromium 用 browser」,但若模型仍狂 computer_observe+座標,體感就跟我差一截。缺硬性策略(例如:有 CDP page 時禁止對 browser 窗做 computer_act click)。 + +跟「我為什麼滑」的對照 +路徑 我(Grok Bot) LazyBoy feat/test +Shell/檔案 預設 有 +瀏覽器 DOM/element ref 有 CDP,方向對 +桌面原生 另有 a11y/委派;截圖是備援 截圖 + xdotool 仍是主幹 +本機 Mac 受限/核准 Shell 尚未做 Host provider +所以:你換好幾套「控制」若不順,多半都還停在 視覺/座標層;你 repo 差的是把 AT-SPI(容器內) 接到 observe/act,並讓 browser/shell 硬優先。 + +建議下一步(只做這 3 件) +容器加 AT-SPI:image 裝 at-spi;observe 回傳 button/textfield 樹(精簡 YAML/JSON + stable id);act 對 id 做 DoAction/set text,失敗才 fallback XY。 +擴 ComputerAction/UiElement:kind: a11y|dom|window,ref 語意動作;computer_act 有 selector/a11y ref 時不要先轉座標。 +路由硬規則:Chromium 前台 → 強制 browser;能 shell 完成 → 禁止截圖迴圈;並加「連續同點無畫面變化 → 強制換策略」(你已有一點 stale-click 提示,可再硬一點)。 +本機 Mac 若也要滑:另開 HostComputerProvider(agent-desktop/Cua),別硬塞進現在的 X11 xdotool 路徑。 + +要的話我可以直接幫你開一張 P0 AT-SPI observe/act 的實作規格(改哪些檔、契約長怎樣),或直接在 feat/test 上開工。 \ No newline at end of file diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index 8beccd5..33a1897 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -8,11 +8,12 @@ "name": "lazyboy-web", "version": "0.1.0", "dependencies": { + "@blobatar/react": "^2.7.0", "@fontsource/huninn": "^5.3.0", - "blobshape": "^1.0.0", - "lucide-react": "^0.468.0", + "blobatar": "^2.7.0", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "react-useanimations": "^2.10.0" }, "devDependencies": { "@types/react": "^19.0.0", @@ -304,6 +305,16 @@ "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": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -1327,11 +1338,23 @@ "node": ">=6.0.0" } }, - "node_modules/blobshape": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/blobshape/-/blobshape-1.0.0.tgz", - "integrity": "sha512-GUD6tUIvj7k8zLSMtfdM1RyusWDjFqYL9g6J6QIgwxbVjL3b6mbRz5peerDc+6kbZX/14BSr3ed9sizrJKdkQQ==", - "license": "MIT" + "node_modules/blobatar": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/blobatar/-/blobatar-2.7.0.tgz", + "integrity": "sha512-tL1p2J3dXE/Z9U/ITwHqU7HBHmTH+Am+hif1J1hWAhvFT1VYC2HG+eXtN7081yuY+L2lrp9bs/VaF7dVH3S2jw==", + "license": "MIT", + "peerDependencies": { + "react": ">=18", + "vue": ">=3" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "vue": { + "optional": true + } + } }, "node_modules/browserslist": { "version": "4.28.8", @@ -1555,6 +1578,12 @@ "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": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -1565,15 +1594,6 @@ "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": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1690,6 +1710,19 @@ "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": { "version": "4.63.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", diff --git a/apps/web/package.json b/apps/web/package.json index 4cf56aa..c960204 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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"}} \ No newline at end of file +{ + "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" + } +} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index f765625..81c9fba 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,112 +1,652 @@ -import { FormEvent, useCallback, useEffect, useMemo, 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 { FormEvent, KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { BotIcon, Brain, ChevronDown, ChevronsRight, CircleHelp, ClipboardPaste, Computer, Download, Ellipsis, Info, LogOut, Megaphone, Paperclip, Pencil, Pin, Plug, Plus, RefreshCw, Settings, Smartphone, Sparkle, Square, Upload, Users, X } from "./animated-icons"; +import UseAnimations from "react-useanimations"; +import 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 { t } from "./i18n"; -import blobshape from "blobshape"; -import type { AvatarShape, Bot, ComputerMode, ComputerStatus, MemoryItem, Message, Session } from "./types"; +import { Avatar, AvatarLookProvider, AvatarStack, BLOBATAR_BACKGROUNDS, BLOBATAR_EXPRESSIONS, BLOBATAR_SHAPES, DEFAULT_LOOK, persistBlobatarShape, readAvatarLooks, resolveBlobatarShape, writeAvatarLook, type AvatarBackground, type AvatarExpression, type AvatarLook } from "./avatar"; +import { t, type MessageKey } from "./i18n"; +import type { AvatarShape, Bot, ComputerMode, ComputerStatus, McpCatalogEntry, McpServer, McpTransport, MemoryItem, Message, MessageFile, ModelProviderId, Playbook, PlaybookInput, PlaybookStep, Room, RoomMember, Session, TaughtSkill, WorkspaceSettings } from "./types"; -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{try{const raw=localStorage.getItem(SESSION_STORE);return raw?JSON.parse(raw) as Record:{}}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 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 {path&&}} +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 } 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 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((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
{preview?:}
{file.name}{typeof file.size==="number"?formatBytes(file.size):ext}
{onRemove&&}
} +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;ib.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(){ - const [bots,setBots]=useState([]); const [activeId,setActiveId]=useState(null); + const [bots,setBots]=useState([]); const [rooms,setRooms]=useState([]); const [activeId,setActiveId]=useState(null); const [activeRoomId,setActiveRoomId]=useState(null); const [busyMembers,setBusyMembers]=useState([]); const [sessions,setSessions]=useState([]); const [activeSessionId,setActiveSessionId]=useState(null); const [messages,setMessages]=useState([]); const [computer,setComputer]=useState(blankComputer); const [screenUrl,setScreenUrl]=useState(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(paneStart.part); + const [sessionMenuOpen,setSessionMenuOpen]=useState(false); const [clearOpen,setClearOpen]=useState(false); const [sessionToDelete,setSessionToDelete]=useState(null); const [remembered,setRemembered]=useState>({}); const [mobileNav,setMobileNav]=useState(false); const [error,setError]=useState(null); const [busy,setBusy]=useState(false); 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 [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(null); const [mcpServers,setMcpServers]=useState([]); + const [accountOpen,setAccountOpen]=useState(false); const [accountDialog,setAccountDialog]=useState(null); + const [skills,setSkills]=useState([]); const [plusOpen,setPlusOpen]=useState(false); const [skillQuery,setSkillQuery]=useState(""); const [teachOpen,setTeachOpen]=useState(false); const [editingSkillId,setEditingSkillId]=useState(null); + const [workspaceName,setWorkspaceName]=useState(workspaceStart.name); + const [looks,setLooks]=useState(readAvatarLooks); + const sendingRef=useRef(false); const refreshSeqRef=useRef(0); const importRef=useRef(null); const attachRef=useRef(null); + const [pendingFiles,setPendingFiles]=useState([]); + const messageEndRef=useRef(null); + const sentHistoryRef=useRef([]); const historyIndexRef=useRef(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 sections=useMemo(()=>{const map=new Map();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();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("/api/bots");setBots(next);setActiveId(id=>id&&next.some(b=>b.id===id)?id:next[0]?.id||null)},[]); - const loadSessions=useCallback(async()=>{if(!activeId){setSessions([]);setActiveSessionId(null);return}const next=await api(`/api/bots/${activeId}/sessions`);setSessions(next);setActiveSessionId(id=>id&&next.some(session=>session.id===id)?id:next[0]?.id||null)},[activeId]); - const refresh=useCallback(async()=>{if(!activeId||!activeSessionId)return;const [nextMessages,nextComputer,screen]=await Promise.all([api(`/api/sessions/${activeSessionId}/messages`),api(`/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 loadMcp=useCallback(async()=>{setMcpServers(await api("/api/mcp-servers").catch(()=>[] as McpServer[]))},[]); + const loadBots=useCallback(async()=>{const [next,nextRooms]=await Promise.all([api("/api/bots"),api("/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 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(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(`/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(`/api/bots/${computerBot}/skills`).catch(()=>[] as TaughtSkill[]); + const status=await api(`/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(()=>{setMessages([]);setActiveSessionId(null);loadSessions().catch(e=>setError(e.message))},[loadSessions]); - 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(()=>{if(activeId||activeRoomId||bots.length===0)return;setActiveId(bots[0].id)},[bots,activeId,activeRoomId]); + 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?.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 close=()=>setContext(null);window.addEventListener("click",close);return()=>window.removeEventListener("click",close)},[]); + 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=(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){setBusy(true);setError(null);try{await work();await refresh()}catch(e){setError(e instanceof Error?e.message:"操作失敗")}finally{setBusy(false)}} - 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()})}))} - async function createSession(){if(!active)return;await action(async()=>{const session=await api(`/api/bots/${active.id}/sessions`,{method:"POST",body:JSON.stringify({title:`對話 ${sessions.length+1}`})});await loadSessions();setActiveSessionId(session.id);setMessages([])})} + const sessionBusy=workingMembers.length>0; + const otherSessionBusy=Boolean(computer.busyBotName&&!sessionBusy); + const chatName=activeRoom?.name||active?.name||""; + + async function action(work:()=>Promise){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(".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(`/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){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(sessionsPath,{method:"POST",body:JSON.stringify({title:t("newConversation")})});writeSessionStore(sessionStoreKey,session.id);const next=await api(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(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(".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()} - function openBot(bot:Bot){setActiveSessionId(null);setActiveId(bot.id);setMobileNav(false);if(bot.unreadCount>0)void inbox(bot,"read")} - const frame=screenUrl?