feat/test #2

Merged
daniel.w merged 7 commits from feat/test into main 2026-09-05 05:14:44 +00:00
32 changed files with 2534 additions and 173 deletions
Showing only changes of commit 4ed1ab3b91 - Show all commits

1
.gitignore vendored
View File

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

117
Cargo.lock generated
View File

@ -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",
@ -2423,6 +2425,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 +2883,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 +3462,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 +4194,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 +5150,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 +5184,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 +5223,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 +5320,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"

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.
## 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.

View File

@ -10,9 +10,9 @@
"dependencies": {
"@fontsource/huninn": "^5.3.0",
"blobshape": "^1.0.0",
"lucide-react": "^0.468.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",
@ -1555,6 +1555,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 +1571,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 +1687,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",

View File

@ -1 +1,25 @@
{"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": {
"@fontsource/huninn": "^5.3.0",
"blobshape": "^1.0.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,408 @@
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, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { BotIcon, Brain, ChevronDown, ChevronsRight, CircleHelp, ClipboardPaste, Computer, Ellipsis, Info, LogOut, Megaphone, Pin, Plug, Plus, RefreshCw, Settings, Smartphone, Square, 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 type { AvatarShape, Bot, ComputerMode, ComputerStatus, McpServer, McpTransport, MemoryItem, Message, Room, RoomMember, Session } 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,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"|"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 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 Avatar({name,color,shape="blob",active=false,thinking=false,online=false,size=32}:{name:string;color?:string;shape?:AvatarShape;active?:boolean;thinking?:boolean;online?: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":shape==="cat"?"M22 42C16 36 14 20 17 8C28 14 38 22 42 28C47 26.5 53 26.5 58 28C62 22 72 14 83 8C86 20 84 36 78 42C86 50 90 58 90 64C90 82 73 94 50 94C27 94 10 82 10 64C10 58 14 50 22 42Z":shape==="bunny"?"M38 34C32 30 28 18 30 6C40 8 46 20 47 32C49 31 51 31 53 32C54 20 60 8 70 6C72 18 68 30 62 34C76 42 84 54 84 66C84 84 69 94 50 94C31 94 16 84 16 66C16 54 24 42 38 34Z":shape==="star"?"M50 8L62 38L94 40L69 60L77 91L50 74L23 91L31 60L6 40L38 38Z":shape==="heart"?"M50 90C30 74 10 58 10 38C10 22 22 12 35 12C43 12 48 16 50 22C52 16 57 12 65 12C78 12 90 22 90 38C90 58 70 74 50 90Z":shape==="egg"?"M50 6C32 6 16 34 16 58C16 80 31 94 50 94C69 94 84 80 84 58C84 34 68 6 50 6Z":shape==="ghost"?"M50 6C29 6 15 24 15 46L15 82C15 86 18 89 22 89C26 89 28 85 32 85C36 85 38 89 42 89C46 89 48 85 52 85C56 85 58 89 62 89C66 89 68 85 72 85C76 85 78 89 82 89C86 89 89 86 89 82L89 46C89 24 75 6 50 6Z":shape==="sprout"?"M50 36C48 20 38 10 24 10C26 26 36 34 48 36C30 38 18 52 18 66C18 84 32 94 50 94C68 94 82 84 82 66C82 52 70 38 52 36C64 34 74 26 76 10C62 10 52 20 50 36Z":shape==="cactus"?"M50 10C38 10 28 18 28 30L28 58L20 58C15 58 12 61 12 66C12 71 15 74 20 74L28 74L28 78C28 89 38 96 50 96C62 96 72 89 72 78L72 64L80 64C85 64 88 61 88 56C88 51 85 48 80 48L72 48L72 30C72 18 62 10 50 10Z":shape==="mushroom"?"M50 12C28 12 12 28 12 46C12 51 15 54 21 54L38 54C37 59 38 64 38 68L38 82C38 90 42 94 50 94C58 94 62 90 62 82L62 68C62 64 63 59 62 54L79 54C85 54 88 51 88 46C88 28 72 12 50 12Z":shape==="paw"?"M8 40A9 9 0 1 0 26 40A9 9 0 1 0 8 40Z M24.5 27A10.5 10.5 0 1 0 45.5 27A10.5 10.5 0 1 0 24.5 27Z M54.5 27A10.5 10.5 0 1 0 75.5 27A10.5 10.5 0 1 0 54.5 27Z M74 40A9 9 0 1 0 92 40A9 9 0 1 0 74 40Z M20 66A29 22 0 1 0 78 66A29 22 0 1 0 20 66Z":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>{(online||active)&&<i className="presence" aria-hidden="true"/>}</span>}
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 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?.65:.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 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>
}
function WorkspaceAvatar({name}:{name:string}){const parts=name.trim().split(/\s+/).filter(Boolean);const initials=(parts.length>1?parts.map(part=>part[0]).join(""):parts[0]?.slice(0,2)||"LB").slice(0,2).toUpperCase();return <span className="workspace-avatar" aria-hidden="true">{initials}</span>}
function modeLabel(mode:ComputerMode){return mode==="team"?t("sharedComputer"):t("privateComputer")}
function stateLabel(state:ComputerStatus["state"]){return ({stopped:t("stopped"),booting:t("booting"),running:t("running"),suspended:t("suspended"),error:t("error")})[state]}
function 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)}
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 [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 [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 [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<Room|null>(null); const [mcpServers,setMcpServers]=useState<McpServer[]>([]);
const [accountOpen,setAccountOpen]=useState(false); const [accountDialog,setAccountDialog]=useState<AccountDialog>(null);
const [workspaceName,setWorkspaceName]=useState(workspaceStart.name);
const sendingRef=useRef(false);
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<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 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 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 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 loadMcp=useCallback(async()=>{setMcpServers(await api<McpServer[]>("/api/mcp-servers").catch(()=>[] as McpServer[]))},[]);
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 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 messagesJob=api<Message[]>(`/api/sessions/${activeSessionId}/messages`).then(setMessages);
let computerBot=activeId;
if(activeRoomId){
try{const status=await api<{busy:RoomMember[]}>(`/api/rooms/${activeRoomId}/status`);setBusyMembers(status.busy);computerBot=status.busy[0]?.id||roomsRef.current.find(room=>room.id===activeRoomId)?.members[0]?.id||null}catch{setBusyMembers([]);computerBot=roomsRef.current.find(room=>room.id===activeRoomId)?.members[0]?.id||null}
}else setBusyMembers([]);
if(!computerBot){await messagesJob;return}
await Promise.all([messagesJob,api<ComputerStatus>(`/api/computer/${computerBot}/status`).then(setComputer),api<{url:string|null}>(`/api/computer/${computerBot}/screen`).then(screen=>setScreenUrl(screen.url)).catch(()=>setScreenUrl(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(()=>{if(!activeSessionId||(!activeId&&!activeRoomId)){setMessages([]);if(!activeId&&!activeRoomId)setComputer(blankComputer);return}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)},[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=()=>{setContext(null);setRoomContext(null);setSessionMenuOpen(false);setAccountOpen(false);setCreateMenuOpen(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);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)}}
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<Session>(`/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<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 send(event:FormEvent){event.preventDefault();if(sendingRef.current||busy)return;const text=draft.trim();if((!active&&!activeRoom)||!activeSessionId||!text)return;sendingRef.current=true;setDraft("");try{await action(async()=>{await api(`/api/sessions/${activeSessionId}/messages`,{method:"POST",body:JSON.stringify({text,clientNonce:crypto.randomUUID()})});await loadSessions()})}finally{sendingRef.current=false}}
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 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?<iframe className="desktop-frame" src={screenUrl} title="Agent computer" allow="fullscreen; clipboard-read; clipboard-write"/>:<EmptyComputer state={computer.state}/>;
function openBot(bot:Bot){setMobileNav(false);setSessionMenuOpen(false);if(bot.unreadCount>0)void inbox(bot,"read");if(bot.id===activeId&&!activeRoomId){if(!activeSessionId){const stored=readSessionStore()[bot.id];if(stored)setActiveSessionId(stored);else void loadSessions()}return}setMessages([]);setActiveSessionId(null);setActiveRoomId(null);setBusyMembers([]);setActiveId(bot.id)}
function 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)}
const frame=screenUrl?<iframe 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 <div className={`app-shell ${rightCollapsed?"right-collapsed":"right-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>
<label className="search"><Search/><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>
<button className="hidden-toggle" onClick={()=>setShowHidden(v=>!v)}>{showHidden?<EyeOff/>:<Eye/>}{showHidden?"隱藏已隱藏項目":"顯示已隱藏項目"}</button>
<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>
<div className="brand"><span>LazyBoy</span><div className="create-menu-wrap"><button className="icon-button" onClick={()=>setCreateMenuOpen(v=>!v)} aria-label={t("add")}><UseAnimations animation={plusToX} size={18} strokeColor="#dfdfe2"/></button>{createMenuOpen&&<div className="create-menu"><button onClick={()=>{setCreateMenuOpen(false);setCreateOpen(true)}}><BotIcon/>{t("addBot")}</button><button onClick={()=>{setCreateMenuOpen(false);setGroupOpen(true)}} disabled={bots.length===0}><Users/>{t("addGroup")}</button></div>}</div></div>
<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">
{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>}
{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 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" 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>
<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>
<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>
<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 name={active.name} color={active.avatarColor} shape={active.avatarShape} active online/><strong>{active.name}</strong><SessionMenu sessions={sessions} activeSessionId={activeSessionId} open={sessionMenuOpen} setOpen={setSessionMenuOpen} busy={busy} onSelect={selectSession} onCreate={createSession} onDelete={id=>{setSessionMenuOpen(false);setSessionToDelete(id)}} onClear={()=>{setSessionMenuOpen(false);setClearOpen(true)}}/><span className="grow"/>{topTools}</>:<><strong>{t("chooseBot")}</strong><span className="grow"/>{topTools}</>}</header>
<div className="messages">{(activeRoom||active)&&messages.length===0?<div className="welcome">{activeRoom?<AvatarStack members={activeRoom.members} size={56} online/>:<Avatar 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;return <div key={message.id} className={`message ${message.role} ${spoken?"spoken":""}`}>{spoken&&<span className="msg-avatar"><Avatar name={speakerName||"agent"} color={message.speakerColor||undefined} shape={speakerShape} size={22}/></span>}{spoken&&<b className="speaker" style={{color:message.speakerColor||undefined}}>{speakerName}</b>}<span className="message-body">{message.body}</span>{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 name={member.name} color={member.avatarColor} shape={member.avatarShape} thinking online/><span className="working-label">{t("working",{name:member.name})}</span></div>)}</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" onSubmit={send}><button type="button" className="composer-plus" disabled title={t("attachmentsUnavailable")} aria-label={t("attachmentsUnavailable")}><Plus/></button><textarea rows={1} value={draft} onChange={e=>setDraft(e.target.value)} onKeyDown={e=>{if(e.nativeEvent.isComposing||e.key==="Process")return;if(e.key==="Enter"&&!e.shiftKey){e.preventDefault();if(sendingRef.current||busy)return;e.currentTarget.form?.requestSubmit()}}} placeholder={activeSessionId&&chatName?t("messageTo",{name:chatName}):t("chooseConversationFirst")} disabled={!activeSessionId}/>{sessionBusy?<button type="button" className="send stop-send" title={t("stopConversation")} onClick={()=>void action(stopChat)}><Square/></button>:<button className="send" disabled={!activeSessionId||!draft.trim()||busy}><UseAnimations animation={arrowUp} size={20} strokeColor="#1b1b1c"/></button>}</form>
</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><ControlBar active={paneBot} computer={computer} busy={busy} action={action} paste={pasteClipboard} copy={copyClipboard} sessionId={activeSessionId}/></>}
</div>
{rightPart==="memory"&&paneBot&&<MemoryPane bot={paneBot} changed={loadBots}/>}
{rightPart==="plugins"&&<McpPane servers={mcpServers} reload={loadMcp}/>}
{rightPart==="settings"&&active&&<BotSettingsPane bot={active} 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 name={paneBot.name} color={paneBot.avatarColor} shape={paneBot.avatarShape} active online/><strong>{modeLabel(computer.mode)}</strong><span className="control-badge">{computer.controlHolder==="user"?t("userControlling"):computer.busyBotName?t("aiReadOnly"):t("readOnly")}</span></div><div><ControlButtons computer={computer} busy={busy} action={action} active={paneBot} sessionId={activeSessionId}/><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>}
{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)}}/>}
{groupOpen&&<CreateGroupDialog bots={bots} close={()=>setGroupOpen(false)} created={async()=>{setGroupOpen(false);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()})}/>}
{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)}}/>}
{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&&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()})}/>}
{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==="about"&&<AboutDialog close={()=>setAccountDialog(null)}/>}
{accountDialog==="help"&&<HelpDialog close={()=>setAccountDialog(null)}/>}
{accountDialog==="feedback"&&<FeedbackDialog close={()=>setAccountDialog(null)}/>}
</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 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 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 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>
}
function MemoryDialog({bot,close,changed}:{bot:Bot;close:()=>void;changed:()=>Promise<void>}){
const[items,setItems]=useState<MemoryItem[]>([]);const[draft,setDraft]=useState("");const[enabled,setEnabled]=useState(bot.memoryEnabled);const[busy,setBusy]=useState(false);const[error,setError]=useState("");
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 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 BotSettingsPane({bot,saved,onDelete}:{bot:Bot;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>(bot.avatarShape||"blob");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(bot.avatarShape||"blob");setTagText((bot.tags||[]).join("、"))},[bot.id,bot.name,bot.title,bot.description,bot.avatarColor,bot.avatarShape,bot.tags]);
const colors=["#08A99D","#F1F2F2","#956A43","#DD263B","#F36C05","#F39A00","#00B873","#1985E6","#7140D9","#DC2781","#A7A7A7"];
const shapes:AvatarShape[]=["round","blob","squircle","capsule","triangle","hexagon","cloud","drop","cat","bunny","star","heart","egg","ghost","sprout","cactus","mushroom","paw"];
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:shape,tags:tagText.split(/[、,]/).map(v=>v.trim()).filter(Boolean)})});await saved()}finally{setBusy(false)}}}>
<div className="avatar-editor"><Avatar name={name||bot.name} color={color} shape={shape} size={78}/><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">{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>{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]);
useEffect(()=>{load().catch(e=>setError(e instanceof Error?e.message:"載入失敗"))},[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)}}
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:"設定失敗")}}
return <div className="modal-backdrop"><div className="dialog settings-dialog memory-dialog">
<div className="dialog-title"><h2>{bot.name} </h2><button onClick={close}><X/></button></div>
<label><input type="checkbox" checked={enabled} onChange={e=>void toggle(e.target.checked)}/> Agent durable memory</label>
useEffect(()=>{load().catch(e=>setError(e instanceof Error?e.message:t("loadFailed")))},[load]);
useEffect(()=>setEnabled(bot.memoryEnabled),[bot.id,bot.memoryEnabled]);
const visible=items.filter(item=>!query.trim()||item.content.toLowerCase().includes(query.trim().toLowerCase()));
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)}}
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"))}}
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("")})}}>
<label><textarea value={draft} onChange={e=>setDraft(e.target.value)} rows={3} placeholder="只儲存明確偏好或事實;密碼與 token 會被拒絕。"/></label>
<button className="primary" disabled={busy||!draft.trim()}></button>
<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()}>{t("add")}</button>
</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>
{error&&<div className="error-banner">{error}</div>}
<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>
{items.length>0&&<label className="memory-search">{t("memorySearch")}<input value={query} onChange={e=>setQuery(e.target.value)} placeholder={t("filterMemory")}/></label>}
<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}>
{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[name,setName]=useState("");const[transport,setTransport]=useState<McpTransport>("http");
const[command,setCommand]=useState("");const[args,setArgs]=useState("");const[url,setUrl]=useState("");
const[envText,setEnvText]=useState("");const[headerText,setHeaderText]=useState("");
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>
<form className="mcp-add" onSubmit={e=>{e.preventDefault();const trimmed=name.trim();if(!trimmed)return;void run(async()=>{await api("/api/mcp-servers",{method:"POST",body:JSON.stringify({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})});setName("");setCommand("");setArgs("");setUrl("");setEnvText("");setHeaderText("")})}}>
<label>{t("name")}<input value={name} maxLength={40} onChange={e=>setName(e.target.value)} placeholder={t("mcpNamePlaceholder")}/></label>
<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="-y @modelcontextprotocol/server-github"/></label>
<label>{t("environmentVariables")}<textarea rows={3} value={envText} onChange={e=>setEnvText(e.target.value)} placeholder="GITHUB_TOKEN=…"/></label>
</>:<>
<label>{t("url")}<input value={url} onChange={e=>setUrl(e.target.value)} placeholder="https://mcp.example.com/mcp"/></label>
<label>{t("headers")}<textarea rows={3} value={headerText} onChange={e=>setHeaderText(e.target.value)} placeholder="Authorization=Bearer …"/></label>
</>}
<button className="primary" disabled={busy||!name.trim()||(transport==="stdio"?!command.trim():!url.trim())}>{busy?t("connecting"):t("connectMcp")}</button>
</form>
<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 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}:{computer:ComputerStatus;busy:boolean;action:(w:()=>Promise<unknown>)=>Promise<void>;active:Bot;sessionId?:string|null}){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")&&<UseAnimations animation={loading} size={17} wrapperStyle={{display:"inline-block",verticalAlign:"middle",marginRight:7}}/>}{computer.state==="booting"?t("bootingProgress"):t("openComputer")}</button>;if(working)return <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:"{}"});if(computer.takeoverRequested)await api(`/api/computer/${active.id}/takeover`,{method:"POST",body:"{}"})})}><Square/>{computer.takeoverRequested?t("stopAndTakeOver"):t("stopTask")}</button>;if(computer.controlHolder==="user")return <button className="outline" disabled={busy} onClick={()=>action(()=>api(`/api/computer/${active.id}/release`,{method:"POST",body:"{}"}))}>{t("releaseControl")}</button>;return <button className="primary" disabled={busy} onClick={()=>action(()=>api(`/api/computer/${active.id}/takeover`,{method:"POST",body:"{}"}))}>{t("takeControl")}</button>}
function ControlBar(props:{active:Bot;computer:ComputerStatus;busy:boolean;action:(w:()=>Promise<unknown>)=>Promise<void>;paste:()=>void;copy:()=>void;sessionId?:string|null}){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 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 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 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("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 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 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>}
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>}
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>}
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>}
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>}
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>}
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>}
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 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,56 @@
import UseAnimations from "react-useanimations";
import type { Animation } from "react-useanimations/utils";
import activity from "react-useanimations/lib/activity";
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 Pin=animatedIcon(pocket,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);

View File

@ -1,25 +1,18 @@
import { zhTW } from "./locales/zh-TW";
export const supportedLocales = ["zh-TW"] as const;
export type Locale = (typeof supportedLocales)[number];
const messages = {
"zh-TW": {
search: "搜尋",
sharedComputer: "共用電腦",
privateComputer: "私人電腦",
stopped: "已關閉",
booting: "啟動中",
running: "執行中",
suspended: "休眠中",
error: "發生錯誤",
openComputer: "開啟電腦",
stopTask: "停止任務",
takeControl: "取得控制權",
releaseControl: "交還控制",
done: "完成",
skip: "略過",
},
} as const;
const messages = { "zh-TW": zhTW } as const;
export const locale: Locale = "zh-TW";
export type MessageKey = keyof (typeof messages)["zh-TW"];
export function t(key: MessageKey): string { return messages[locale][key]; }
export type MessageKey = keyof typeof zhTW;
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,49 @@
/** 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: "取得控制", 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: "另一則對話正在執行,這則會排隊。", attachmentsUnavailable: "附件功能尚未開放", messageTo: "傳訊息給 {name}", chooseConversationFirst: "先選擇對話", stopConversation: "停止對話",
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: "形狀", 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 server 後,所有 Agent 都能使用它的工具。stdio 會在 API 主機上啟動行程HTTP / SSE 連到遠端。", mcpNamePlaceholder: "例如github",
command: "Command", commandPlaceholder: "npx 或 uvx 或完整路徑", arguments: "參數", environmentVariables: "環境變數 KEY=value", headers: "Headers KEY=value", connecting: "連線中…", connectMcp: "接入 MCP",
noMcp: "還沒有 MCP。接入後工具會顯示在這裡Agent 對話時可以使用。", toolsCount: "{count} 個工具", disabled: "已關閉", disconnected: "未連線", noTools: "沒有可用工具", reconnect: "重新連線", disable: "停用", enable: "啟用",
preparingDesktop: "正在準備 Agent 的獨立桌面…", computerPreviewHint: "開啟電腦後,畫面會顯示在這裡。", bootingProgress: "啟動中…", 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 外掛在右側分頁。這裡只改這個工作區的顯示。", 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 可以使用。", 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,6 @@
import React from "react";
import ReactDOM from "react-dom/client";
import "@fontsource/huninn";
import "./styles.css";
import "./refinements.css";
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,11 +1,11 @@
.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)}
.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)}
.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}
.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:radial-gradient(circle at 34% 24%,color-mix(in srgb,var(--bot-color),white 30%),var(--bot-color) 58%,color-mix(in srgb,var(--bot-color),black 28%));box-shadow:0 2px max(4px,calc(var(--avatar-size)*.15)) rgba(0,0,0,.35),inset 0 1px 1px rgba(255,255,255,.32)}
.avatar-blob{border-radius:58% 42% 52% 48%/43% 58% 42% 57%;transform:rotate(-3deg)}
.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.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)}
.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:drop-shadow(0 2px 2px rgba(0,0,0,.3))}.avatar-shape path{fill:var(--bot-color);stroke:color-mix(in srgb,var(--bot-color),white 22%);stroke-width:.8;paint-order:stroke fill}
.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}
@ -13,7 +13,7 @@
.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:before,.avatar.robot.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.robot.thinking:after{inset:-8px;opacity:.45;filter:blur(3px);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}
@ -41,6 +41,7 @@
@keyframes thinking-dot{0%,60%,100%{opacity:.35;transform:translateY(0)}30%{opacity:1;transform:translateY(-3px)}}
@keyframes spin{to{transform:rotate(360deg)}}
@keyframes magic-orbit{to{transform:rotate(360deg)}}
@keyframes avatar-rainbow-glow{0%,100%{filter:drop-shadow(-3px -1px 2px #ff4fd8) drop-shadow(3px 1px 2px #34d9ff)}33%{filter:drop-shadow(1px -3px 2px #ffe66d) drop-shadow(-1px 3px 2px #58f39a)}66%{filter:drop-shadow(3px -1px 2px #ff7a59) drop-shadow(-3px 1px 2px #7c5cff)}}
@keyframes small-magic-line{to{background-position:200% 0}}
@media(max-width:1200px){.app-shell{grid-template-columns:230px minmax(0,1fr) 330px}}
@media(max-width:1050px){.app-shell{grid-template-columns:250px minmax(0,1fr)}}
@ -49,11 +50,208 @@
.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))}
.avatar-cloud .robot-eyes{transform:translateY(calc(var(--avatar-size) * .1))}.avatar-drop .robot-eyes{transform:translateY(calc(var(--avatar-size) * .12))}.avatar-cat .robot-eyes{transform:translateY(calc(var(--avatar-size) * 0.08))}.avatar-bunny .robot-eyes{transform:translateY(calc(var(--avatar-size) * 0.1))}.avatar-star .robot-eyes{transform:translateY(calc(var(--avatar-size) * 0.02)) scale(.68)}.avatar-heart .robot-eyes{transform:translateY(calc(var(--avatar-size) * -0.1))}.avatar-egg .robot-eyes{transform:translateY(calc(var(--avatar-size) * 0.08))}.avatar-ghost .robot-eyes{transform:translateY(calc(var(--avatar-size) * -0.03))}.avatar-sprout .robot-eyes{transform:translateY(calc(var(--avatar-size) * 0.12))}.avatar-cactus .robot-eyes{transform:translateY(calc(var(--avatar-size) * 0.05))}.avatar-mushroom .robot-eyes{transform:translateY(calc(var(--avatar-size) * -0.12))}.avatar-paw .robot-eyes{transform:translateY(calc(var(--avatar-size) * 0.14))}
.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)}
.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>.avatar .avatar-shape{width:100%;height:100%;color:unset}
.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-card-actions svg{width:13px;height:13px}
.create-menu-wrap{position:relative;margin-left:auto}.brand .create-menu-wrap .icon-button{margin-left:0}.create-menu{position:absolute;z-index:40;top:42px;right:0;display:grid;width:180px;padding:6px;border:1px solid var(--border);border-radius:12px;background:#18181b;box-shadow:0 18px 50px rgba(0,0,0,.5)}.create-menu button{display:flex;align-items:center;gap:9px;height:38px;padding:0 10px;border:0;border-radius:8px;background:transparent;color:var(--text);cursor:pointer}.create-menu button:hover:not(:disabled){background:rgba(255,255,255,.07)}.create-menu button:disabled{opacity:.4;cursor:not-allowed}.create-menu svg{width:16px}
.group-picker{display:grid;gap:5px;max-height:280px;margin:0;padding:0;overflow:auto;border:0}.group-picker legend{margin-bottom:8px;color:var(--muted)}.group-picker label{display:flex;align-items:center;gap:10px;padding:7px 9px;border-radius:9px;background:var(--inset);cursor:pointer}.group-picker input{width:16px;height:16px;margin:0}.group-picker .avatar{--avatar-size:28px!important}
.composer-plus:disabled{opacity:.35;cursor:not-allowed}
.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}
.message{position:relative;padding-right:28px}
.remember-msg{position:absolute;top:8px;right:4px;width:26px;height:26px;display:grid;place-items:center;border:0;border-radius:8px;background:transparent;color:var(--muted);cursor:pointer}
.messages .remember-msg{display:none}
.remember-msg svg{width:14px;height:14px}
.remember-msg:hover:not(:disabled){background:rgba(255,255,255,.07);color:var(--ink)}
.remember-msg.saved,.remember-msg:disabled{color:var(--accent);opacity:.9;cursor:default}
.panel-open-btn{display:none}
.side-card-backdrop{display:none}
.app-shell.right-open{grid-template-columns:clamp(220px,18vw,280px) minmax(0,1fr) clamp(320px,30vw,440px)}
.app-shell.right-collapsed{grid-template-columns:clamp(220px,18vw,280px) minmax(0,1fr) 52px}
.side-card{display:flex;flex-direction:column;min-width:0;min-height:0;background:var(--panel);border-right:0}
.side-card.collapsed{padding:10px 6px}
.side-rail{display:flex;flex-direction:column;align-items:center;gap:6px;height:100%}
.side-rail button{width:40px;height:40px;display:grid;place-items:center;border:0;border-radius:11px;background:transparent;color:var(--muted);cursor:pointer}
.side-rail button svg{width:18px;height:18px}
.side-rail button:hover:not(:disabled){background:var(--surface);color:var(--ink)}
.side-rail button.active{background:var(--surface);color:var(--ink)}
.side-rail button:disabled{opacity:.35;cursor:not-allowed}
.side-card-head{display:flex;align-items:center;gap:6px;height:72px;flex:0 0 72px;padding:0 10px 0 12px;border-bottom:1px solid #171719}
.side-card-title{min-width:0;flex:1;overflow:hidden;color:var(--muted);font-size:13px;text-overflow:ellipsis;white-space:nowrap}
.side-tabs{display:flex;gap:4px;min-width:0;flex:1;flex-wrap:wrap}
.side-tabs button{display:inline-flex;align-items:center;gap:6px;height:36px;padding:0 10px;border:0;border-radius:10px;background:transparent;color:var(--muted);font:inherit;font-size:13px;cursor:pointer}
.side-tabs button svg{width:15px;height:15px}
.side-tabs button:hover:not(:disabled){background:var(--surface);color:var(--ink)}
.side-tabs button.active{background:var(--surface);color:var(--ink)}
.side-tabs button:disabled{opacity:.35;cursor:not-allowed}
.side-card-body{display:flex;flex-direction:column;min-height:0;flex:1;padding:14px 16px 16px;overflow:hidden}
.computer-part{display:flex;flex-direction:column;min-height:0;flex:1;gap:10px}
.computer-part.hidden-part{display:none}
.computer-status-row{display:flex;align-items:center;gap:8px;color:var(--muted)}
.computer-status-row>span{margin-right:auto;color:var(--ink);font-weight:600}
.computer-part .preview{flex:1;min-height:160px;aspect-ratio:auto}
.pane-form,.memory-pane{display:grid;gap:14px;min-height:0;overflow:auto;padding-bottom:8px}
.pane-form label,.memory-pane label,.mcp-add label{display:grid;gap:8px;color:var(--muted);font-size:13px}
.pane-form input:not([type="checkbox"]):not([type="color"]),.memory-pane input:not([type="checkbox"]),.mcp-add input{height:40px;border:1px solid var(--border);border-radius:10px;background:#0c0c0d;color:var(--ink);padding:0 12px;outline:0}
.pane-form textarea,.memory-pane textarea,.mcp-add textarea{box-sizing:border-box;width:100%;resize:vertical;border:1px solid var(--border);border-radius:10px;background:#0c0c0d;color:var(--ink);padding:11px;font:inherit;outline:none}
.pane-actions{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
.pane-error{color:#ff8585;font-size:13px}
.settings-pane fieldset{margin:0;padding:0;border:0}
.settings-pane legend{margin-bottom:9px;color:var(--muted);font-size:13px}
.memory-pane form{display:flex;gap:10px;align-items:end}
.memory-pane form label{flex:1}
@media(max-width:1200px){.app-shell.right-open{grid-template-columns:230px minmax(0,1fr) 340px}.app-shell.right-collapsed{grid-template-columns:230px minmax(0,1fr) 52px}}
@media(max-width:1050px){
.panel-open-btn{display:inline-flex}
.app-shell.right-collapsed{grid-template-columns:250px minmax(0,1fr)}
.app-shell.right-collapsed .side-card{display:none}
.app-shell.right-open{grid-template-columns:250px minmax(0,1fr)}
.app-shell.right-open .side-card-backdrop{display:block;position:fixed;inset:0;z-index:44;background:rgba(0,0,0,.5)}
.app-shell.right-open .side-card{position:fixed;z-index:45;inset:0 0 0 auto;width:min(420px,100vw);box-shadow:-20px 0 60px #000}
}
.memory-help{margin:0;color:var(--muted);line-height:1.5}
.memory-pane .memory-toggle,.memory-toggle{display:flex;align-items:center;gap:8px}
.memory-search{display:grid;gap:8px}
.memory-clear-confirm{display:flex;align-items:center;gap:8px;margin-right:auto;color:var(--muted);font-size:13px}
.login-screen{display:grid;min-height:100%;place-items:center;padding:20px;background:radial-gradient(circle at 50% 20%,#18201e 0,#080809 55%)}.login-dialog{display:grid;justify-items:stretch}.login-dialog>.avatar{justify-self:center}.login-dialog h1,.login-dialog p{text-align:center}.login-dialog p{margin-top:-8px;color:var(--muted)}.login-error{color:#ff8585;font-size:13px}
.working-label{font-size:13px;letter-spacing:.02em;white-space:nowrap;background:linear-gradient(90deg,#7a8088 0%,#7a8088 28%,#fff 50%,#7a8088 72%,#7a8088 100%);background-size:220% 100%;-webkit-background-clip:text;background-clip:text;color:transparent;animation:working-shimmer 1.35s linear infinite}
@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.robot.thinking .avatar-shape{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:0 0 0 2px var(--side,#0b0b0c)}.avatar-stack .stack-item .avatar.avatar-triangle,.avatar-stack .stack-item .avatar.avatar-hexagon,.avatar-stack .stack-item .avatar.avatar-organic{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}
.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.robot{max-width:none;padding:0;background:radial-gradient(circle at 34% 24%,color-mix(in srgb,var(--bot-color),white 30%),var(--bot-color) 58%,color-mix(in srgb,var(--bot-color),black 28%));color:inherit;line-height:normal;white-space:normal}
.message.assistant .msg-avatar>.avatar.robot.avatar-organic{background:transparent}
.message .msg-avatar .avatar span{max-width:none;padding:0;border-radius: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}
/* Final visual polish: keep the small robot faces legible without making the
eyes dominate the avatar, and give stacked room avatars a deliberate rhythm. */
.robot-eyes{gap:calc(var(--avatar-size) * .12)}
.robot-eyes i{width:calc(var(--avatar-size) * .095);height:calc(var(--avatar-size) * .2);min-width:2px;min-height:4px}
.robot-face{width:18px;height:10px;border-width:1.5px}
.robot-face:before{top:-5px;left:7px;height:4px}
.robot-face i{top:2px;width:2px;height:3px}
.robot-face i:first-child{left:3px}.robot-face i:last-child{right:3px}
.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,.avatar-stack .stack-extra{box-shadow:0 0 0 2px var(--main,#0d0d0e)}
.avatar-stack .stack-item .avatar.avatar-organic{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-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}
}

View File

@ -1,8 +1,13 @@
export type ComputerMode = "team" | "dedicated";
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 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"|"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 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 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 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 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; display:string|null; profileMode:string; screenAvailable:boolean }
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 }

View File

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

View File

@ -52,6 +52,8 @@ pub fn status_from(
screen_height: DEFAULT_SCREEN_HEIGHT,
home_revision: Some(computer.home_revision.clone()),
busy_bot_name,
busy_session_id: None,
busy_run_id: None,
multi_screen: true,
screen_id: screen.map(|row| row.id.clone()),
display: screen.map(|row| row.display.clone()),
@ -572,7 +574,7 @@ pub async fn takeover(state: &AppState, actor: &Actor, bot_id: &str) -> Result<(
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 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));
let run_status = active.as_ref().and_then(|(_, status, _)| parse_run_status(status));
if execution_blocks_user_takeover(
screen.execution_run_id.is_some(),
screen.execution_lease_expires_at,
@ -792,19 +794,22 @@ pub async fn current_status(state: &AppState, actor: &Actor, bot_id: &str) -> Re
.get_screen(&computer.id, bot_id)
.await
.map_err(|error| error.to_string())?;
let run_status = state
.db
.active_run(bot_id)
.await
.ok()
.flatten()
.and_then(|(_, run_status)| parse_run_status(&run_status));
let active = state.db.active_run(bot_id).await.ok().flatten();
let run_status = active
.as_ref()
.and_then(|(_, run_status, _)| parse_run_status(run_status));
let waiting_for_takeover = run_status == Some(lazyboy_contracts::RunStatus::WaitingTakeover);
let busy_bot_name = run_status
.filter(|status| status.is_active() && *status != lazyboy_contracts::RunStatus::WaitingTakeover)
.map(|_| bot.name);
let busy = run_status
.filter(|status| status.is_active() && *status != lazyboy_contracts::RunStatus::WaitingTakeover);
let busy_bot_name = busy.map(|_| bot.name.clone());
let mut status = status_from(bot_id, &computer, screen.as_ref(), busy_bot_name);
status.takeover_requested = waiting_for_takeover;
if busy.is_some() {
if let Some((run_id, _, thread_id)) = active {
status.busy_run_id = Some(run_id);
status.busy_session_id = Some(thread_id);
}
}
Ok(status)
}

View File

@ -115,8 +115,8 @@ impl Db {
"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,
(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,
(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,
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 AND t.room_id IS NULL) AS last_message_at,
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",
)
@ -129,7 +129,7 @@ impl Db {
let thread_id: (String,) =
sqlx::query_as(
"SELECT id 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 room_id IS NULL
ORDER BY updated_at DESC, created_at ASC LIMIT 1",
)
.bind(&bot.id)
@ -150,8 +150,8 @@ impl Db {
"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,
(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,
(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,
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 AND t.room_id IS NULL) AS last_message_at,
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",
)
@ -214,7 +214,7 @@ impl Db {
.bind(memory_enabled)
.execute(&mut *tx)
.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(&actor.space_id)
.bind(&bot_id)
@ -278,9 +278,9 @@ impl Db {
.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(
"SELECT id, status FROM runs
"SELECT id, status, thread_id FROM runs
WHERE bot_id = $1
AND status IN ('queued','leased','running','waiting_input','waiting_takeover')
ORDER BY created_at DESC LIMIT 1",

View File

@ -1,7 +1,9 @@
mod auth;
mod computer;
mod db;
mod mcp;
mod memory;
mod rooms;
mod routes;
mod runs;
mod screen_proxy;
@ -39,7 +41,11 @@ async fn main() {
let state = AppState::connect(&database_url)
.await
.expect("database");
state.bootstrap().await.expect("bootstrap");
let actor = state.bootstrap().await.expect("bootstrap");
let mcp_state = state.clone();
tokio::spawn(async move {
mcp_state.mcp.reconnect_all(mcp_state.pool(), &actor).await;
});
let worker_state = state.clone();
tokio::spawn(async move {

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

@ -0,0 +1,694 @@
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 rmcp::model::{CallToolRequestParams, ClientInfo, Tool};
use rmcp::service::RunningService;
use rmcp::transport::child_process::TokioChildProcess;
use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig;
use rmcp::transport::StreamableHttpClientTransport;
use rig_core::completion::ToolDefinition;
use rmcp::{RoleClient, ServiceExt};
use serde_json::{json, Map, Value};
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>;
#[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| error.to_string())?;
ClientInfo::default()
.serve(transport)
.await
.map_err(|error| 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| error.to_string())
}
other => Err(format!("不支援的 transport{other}")),
}
}
#[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))
}
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 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(40).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 error = None;
let mut tools = Vec::new();
if row.enabled {
match tokio::time::timeout(Duration::from_secs(25), state.mcp.connect_row(&row)).await {
Ok(Ok(connected)) => {
status = "connected".into();
tools = connected;
}
Ok(Err(message)) => {
error = Some(message);
}
Err(_) => {
error = Some("連線逾時25 秒)".into());
}
}
}
row.last_error = error.clone();
Ok((
StatusCode::CREATED,
Json(row.into_server(status, error, 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(Duration::from_secs(25), state.mcp.connect_row(&row)).await {
Ok(Ok(connected)) => {
status = "connected".into();
tools = connected;
}
Ok(Err(message)) => error = Some(message),
Err(_) => error = Some("連線逾時25 秒)".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(Duration::from_secs(25), 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("連線逾時25 秒)".into()),
Vec::new(),
)))
}
}
}

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

@ -0,0 +1,314 @@
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::{json, Value};
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

@ -15,6 +15,8 @@ pub fn router(state: AppState) -> Router {
Router::new()
.merge(crate::sessions::router())
.merge(crate::memory::router())
.merge(crate::rooms::router())
.merge(crate::mcp::router())
.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}/stop", post(stop_task))
@ -184,7 +186,9 @@ async fn update_bot(
"blob" | "round" | "diamond" | "squircle" | "capsule" |
"triangle" | "hexagon" | "cloud" | "drop" |
"organic-4" | "organic-5" | "organic-6" | "organic-7" |
"organic-8" | "organic-9" | "organic-10" | "organic-11"
"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 {
return Err((StatusCode::BAD_REQUEST, Json(json!({"message":"設定格式不正確"}))));

View File

@ -53,8 +53,8 @@ pub async fn send(
blocks: &[Value],
) -> Result<Value, String> {
let mut tx = state.pool().begin().await.map_err(|error| error.to_string())?;
let scoped: Option<i32> = sqlx::query_scalar(
"SELECT 1 FROM threads t JOIN bots b ON b.id=t.bot_id
let scoped: Option<(String, Option<String>)> = sqlx::query_as(
"SELECT t.title, t.room_id FROM threads t JOIN bots b ON b.id=t.bot_id
WHERE t.id=$1 AND t.bot_id=$2 AND t.space_id=$3 AND t.user_id=$4
AND b.space_id=$3 AND b.user_id=$4 AND t.status='active'
FOR UPDATE OF t",
@ -66,9 +66,9 @@ pub async fn send(
.fetch_optional(&mut *tx)
.await
.map_err(|error| error.to_string())?;
if scoped.is_none() {
let Some((current_title, room_id)) = scoped else {
return Err("session not found".into());
}
};
if let Some(nonce) = client_nonce {
let existing: Option<(String, Option<String>)> = sqlx::query_as(
"SELECT id, run_id FROM messages WHERE thread_id=$1 AND client_nonce=$2",
@ -113,20 +113,47 @@ pub async fn send(
.execute(&mut *tx)
.await
.map_err(|error| error.to_string())?;
sqlx::query(
"INSERT INTO runs (id,space_id,bot_id,thread_id,user_id,status,prompt,checkpoint)
VALUES ($1,$2,$3,$4,$5,'queued',$6,$7)",
)
.bind(&run_id)
.bind(&actor.space_id)
.bind(bot_id)
.bind(&thread_id)
.bind(&actor.user_id)
.bind(text)
.bind(json!({"messageSeq":seq}))
.execute(&mut *tx)
.await
.map_err(|error| error.to_string())?;
if crate::sessions::is_default_session_title(&current_title) {
sqlx::query("UPDATE threads SET title=$2 WHERE id=$1")
.bind(thread_id)
.bind(crate::sessions::title_from_first_message(text))
.execute(&mut *tx)
.await
.map_err(|error| error.to_string())?;
}
let mut member_ids: Vec<String> = if let Some(room_id) = room_id.as_deref() {
sqlx::query_scalar("SELECT bot_id FROM room_members WHERE room_id=$1 ORDER BY created_at")
.bind(room_id)
.fetch_all(&mut *tx)
.await
.map_err(|error| error.to_string())?
} else {
vec![bot_id.to_string()]
};
if member_ids.is_empty() {
member_ids.push(bot_id.to_string());
}
for (index, member_id) in member_ids.iter().enumerate() {
let member_run = if index == 0 {
run_id.clone()
} else {
Uuid::new_v4().to_string()
};
sqlx::query(
"INSERT INTO runs (id,space_id,bot_id,thread_id,user_id,status,prompt,checkpoint)
VALUES ($1,$2,$3,$4,$5,'queued',$6,$7)",
)
.bind(&member_run)
.bind(&actor.space_id)
.bind(member_id)
.bind(&thread_id)
.bind(&actor.user_id)
.bind(text)
.bind(json!({"messageSeq":seq}))
.execute(&mut *tx)
.await
.map_err(|error| error.to_string())?;
}
let event_seq: i32 = sqlx::query_scalar(
"UPDATE threads SET next_event_seq=next_event_seq+1 WHERE id=$1 RETURNING next_event_seq",
)
@ -151,6 +178,13 @@ pub async fn send(
.fetch_one(&mut *tx)
.await
.map_err(|error| error.to_string())?;
if let Some(room_id) = room_id.as_deref() {
sqlx::query("UPDATE rooms SET updated_at=now() WHERE id=$1")
.bind(room_id)
.execute(&mut *tx)
.await
.map_err(|error| error.to_string())?;
}
tx.commit().await.map_err(|error| error.to_string())?;
Ok(json!({
"messageId": message_id,
@ -234,6 +268,7 @@ pub async fn worker_loop(state: AppState) {
&state,
&thread_id,
&run_id,
&bot_id,
&format!("Run failed after retries: {error}"),
)
.await;
@ -348,9 +383,14 @@ async fn execute_run(
session_id: thread_id.to_string(),
run_id: run_id.to_string(),
memory_enabled: bot.memory_enabled && state.memory.globally_enabled(),
mcp: state.mcp.clone(),
});
let defs = tool_definitions(ctx.memory_enabled);
let mut defs = tool_definitions(ctx.memory_enabled);
let mcp_defs = state.mcp.definitions().await;
if !mcp_defs.is_empty() {
defs.extend(mcp_defs);
}
let (summary, summary_seq): (String, i32) = sqlx::query_as(
"SELECT history_summary, history_summary_seq FROM threads
WHERE id=$1 AND space_id=$2 AND user_id=$3",
@ -369,11 +409,13 @@ async fn execute_run(
.fetch_one(state.pool())
.await
.map_err(|error| error.to_string())?;
let recent: Vec<(String, String)> = sqlx::query_as(
"SELECT role,body FROM (
SELECT role,body,seq FROM messages
WHERE thread_id=$1 AND seq>$2 AND seq<$3
ORDER BY seq DESC LIMIT 30
let recent: Vec<(String, String, Option<String>, Option<String>)> = sqlx::query_as(
"SELECT role, body, speaker_bot_id, speaker_name FROM (
SELECT m.role, m.body, m.seq, m.speaker_bot_id, b.name AS speaker_name
FROM messages m
LEFT JOIN bots b ON b.id=m.speaker_bot_id
WHERE m.thread_id=$1 AND m.seq>$2 AND m.seq<$3
ORDER BY m.seq DESC LIMIT 30
) history ORDER BY seq ASC",
)
.bind(thread_id)
@ -390,16 +432,21 @@ async fn execute_run(
))],
});
}
for (role, body) in recent {
for (role, body, speaker_id, speaker_name) in recent {
if role == "user" {
history.push(Message::User {
content: vec![UserContent::text(body)],
});
} else {
} else if speaker_id.as_deref() == Some(bot_id) || speaker_id.is_none() {
history.push(Message::Assistant {
id: None,
content: vec![AssistantContent::text(body)],
});
} else {
let name = speaker_name.unwrap_or_else(|| "agent".into());
history.push(Message::User {
content: vec![UserContent::text(format!("[{name}]: {body}"))],
});
}
}
let mut first = vec![UserContent::text(prompt)];
@ -426,6 +473,36 @@ async fn execute_run(
} else {
format!("{SYSTEM}\n\nBot-specific instructions:\n{}", bot.instructions.trim())
};
let room_mates: Vec<String> = sqlx::query_scalar(
"SELECT b.name FROM threads t
JOIN room_members m ON m.room_id=t.room_id
JOIN bots b ON b.id=m.bot_id
WHERE t.id=$1 AND b.id<>$2
ORDER BY b.name",
)
.bind(thread_id)
.bind(bot_id)
.fetch_all(state.pool())
.await
.unwrap_or_default();
if !room_mates.is_empty() {
preamble.push_str(&format!(
"\n\nYou are {} in a group chat with: {}. Reply as yourself only. Other agents' lines are prefixed with [Name]. Do not speak for them.",
bot.name,
room_mates.join("")
));
}
let mcp_names: Vec<String> = defs
.iter()
.filter(|tool| tool.name.starts_with("mcp_"))
.map(|tool| tool.name.clone())
.collect();
if !mcp_names.is_empty() {
preamble.push_str(&format!(
"\n\nMCP tools available: {}. Use them when they help complete the user's request.",
mcp_names.join(", ")
));
}
if !memory.is_empty() {
preamble.push_str("\n\n");
preamble.push_str(&memory);
@ -512,7 +589,7 @@ async fn execute_run(
.execute(state.pool())
.await
.map_err(|error| error.to_string())?;
append_bot_message(state, thread_id, run_id, &outcome.text).await?;
append_bot_message(state, thread_id, run_id, bot_id, &outcome.text).await?;
return Ok(());
}
}
@ -533,7 +610,7 @@ async fn execute_run(
if status.as_deref() == Some("cancelled") {
return Ok(());
}
append_bot_message(state, thread_id, run_id, &final_text).await?;
append_bot_message(state, thread_id, run_id, bot_id, &final_text).await?;
let completed = sqlx::query(
"UPDATE runs
SET status='completed', completed_at=now(), updated_at=now(),
@ -604,7 +681,7 @@ async fn latest_screenshot(ctx: &ToolCtx) -> Option<Vec<u8>> {
}
}
async fn append_bot_message(state: &AppState, thread_id: &str, run_id: &str, body: &str) -> Result<(), String> {
async fn append_bot_message(state: &AppState, thread_id: &str, run_id: &str, bot_id: &str, body: &str) -> Result<(), String> {
let mut tx = state.pool().begin().await.map_err(|error| error.to_string())?;
let seq: i32 = sqlx::query_scalar(
"UPDATE threads SET next_message_seq=next_message_seq+1,updated_at=now()
@ -616,14 +693,15 @@ async fn append_bot_message(state: &AppState, thread_id: &str, run_id: &str, bod
.map_err(|error| error.to_string())?;
let message_id = Uuid::new_v4().to_string();
sqlx::query(
"INSERT INTO messages (id,thread_id,seq,role,body,run_id)
VALUES ($1,$2,$3,'assistant',$4,$5)",
"INSERT INTO messages (id,thread_id,seq,role,body,run_id,speaker_bot_id)
VALUES ($1,$2,$3,'assistant',$4,$5,$6)",
)
.bind(&message_id)
.bind(thread_id)
.bind(seq)
.bind(body)
.bind(run_id)
.bind(bot_id)
.execute(&mut *tx)
.await
.map_err(|error| error.to_string())?;

View File

@ -4,7 +4,7 @@ use std::time::Duration;
use axum::extract::{Path, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::routing::get;
use axum::routing::{get, post};
use axum::{Json, Router};
use futures_util::stream;
use lazyboy_contracts::{
@ -27,9 +27,10 @@ pub fn router() -> Router<AppState> {
)
.route(
"/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}/stop", post(stop_session))
}
async fn actor(state: &AppState) -> Result<Actor, ApiError> {
@ -47,7 +48,7 @@ fn internal(message: String) -> ApiError {
)
}
fn session_from_row(
pub(crate) fn session_from_row(
row: (
String,
String,
@ -94,7 +95,7 @@ async fn list_sessions(
"SELECT id, bot_id, title, status, created_at, updated_at, next_message_seq,
history_summary, history_summary_seq
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",
)
.bind(bot_id)
@ -191,6 +192,12 @@ async fn delete_session(
Path(id): Path<String>,
) -> Result<StatusCode, 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()))?;
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",
@ -212,7 +219,7 @@ async fn delete_session(
.map_err(|error| internal(error.to_string()))?;
if !remaining {
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(&actor.space_id)
@ -234,6 +241,40 @@ async fn list_messages(
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(
State(state): State<AppState>,
Path(id): Path<String>,
@ -260,6 +301,20 @@ async fn send_message(
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(
State(state): State<AppState>,
Path(id): Path<String>,
@ -327,7 +382,7 @@ pub async fn default_session_for_bot(
) -> Result<Option<String>, sqlx::Error> {
sqlx::query_scalar(
"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",
)
.bind(bot_id)
@ -352,10 +407,16 @@ pub async fn messages_for_session(
Option<String>,
Option<String>,
chrono::DateTime<chrono::Utc>,
Option<String>,
Option<String>,
Option<String>,
Option<String>,
)> = sqlx::query_as(
"SELECT m.id, m.thread_id, m.seq, m.role, m.body, m.blocks, m.run_id,
m.client_nonce, m.created_at
FROM messages m JOIN threads t ON t.id=m.thread_id
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
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
ORDER BY m.seq ASC",
)
@ -377,6 +438,10 @@ pub async fn messages_for_session(
run_id: row.6,
client_nonce: row.7,
created_at: row.8,
speaker_bot_id: row.9,
speaker_name: row.10,
speaker_color: row.11,
speaker_shape: row.12,
})
.collect())
}
@ -438,23 +503,96 @@ pub async fn append_event(
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();
if value.is_empty() {
"New session".to_string()
"新對話".to_string()
} else {
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)]
mod tests {
use super::normalized_title;
use super::{is_default_session_title, normalized_title, title_from_first_message};
#[test]
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(&"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"), "新對話");
}
}

View File

@ -7,6 +7,7 @@ use sqlx::PgPool;
use crate::db::{Actor, Db};
use crate::auth::AuthConfig;
use crate::mcp::McpHub;
use crate::memory::MemoryService;
#[derive(Clone)]
@ -16,6 +17,7 @@ pub struct AppState {
pub data_dir: String,
pub auth: AuthConfig,
pub memory: MemoryService,
pub mcp: McpHub,
}
impl AppState {
@ -36,6 +38,7 @@ impl AppState {
data_dir: std::env::var("DATA_DIR").unwrap_or_else(|_| "./data".into()),
auth: AuthConfig::from_env(),
memory: MemoryService::from_env(),
mcp: McpHub::new(),
})
}

View File

@ -11,6 +11,7 @@ use sqlx::PgPool;
use uuid::Uuid;
use crate::db::Actor;
use crate::mcp::McpHub;
use crate::memory::{CreateMemoryInput, MemoryService};
pub struct ToolCtx {
@ -29,6 +30,7 @@ pub struct ToolCtx {
pub session_id: String,
pub run_id: String,
pub memory_enabled: bool,
pub mcp: McpHub,
}
pub fn tool_definitions(memory_enabled: bool) -> Vec<ToolDefinition> {
@ -192,6 +194,10 @@ pub async fn dispatch(ctx: &ToolCtx, name: &str, args: &Value) -> ToolOutcome {
pause: true,
}
}
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 {
text: format!("unknown tool {other}"),
image: None,

View File

@ -184,6 +184,8 @@ pub struct ComputerStatus {
pub screen_height: u32,
pub home_revision: Option<String>,
pub busy_bot_name: Option<String>,
pub busy_session_id: Option<String>,
pub busy_run_id: Option<String>,
pub multi_screen: bool,
pub screen_id: Option<String>,
pub display: Option<String>,

View File

@ -1,14 +1,18 @@
mod action;
mod bot;
mod computer;
mod mcp;
mod model;
mod room;
mod run;
mod session;
pub use action::*;
pub use bot::*;
pub use computer::*;
pub use mcp::*;
pub use model::*;
pub use room::*;
pub use run::*;
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

@ -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

@ -42,6 +42,10 @@ pub struct SessionMessage {
pub run_id: Option<String>,
pub client_nonce: Option<String>,
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)]

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

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