diff --git a/.dockerignore b/.dockerignore index 61f630c9..9549e7fc 100644 --- a/.dockerignore +++ b/.dockerignore @@ -27,6 +27,8 @@ __pycache__/ # Container metadata Dockerfile docker-compose.yml +compose.yaml +control-ui/ # Docs and license (not required at runtime) README.md diff --git a/.gitignore b/.gitignore index e8aaef53..d7c86d02 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,11 @@ memory/.channel/ # venv .venv +# Node build artifacts +node_modules/ +control-ui/dist/ +proofshot-artifacts/ + # editor backup files *~ Autotests/venv/ @@ -26,4 +31,4 @@ Autotests/logs/ # logs logs/*.log -logs/*.log.* \ No newline at end of file +logs/*.log.* diff --git a/README.md b/README.md index 44ba26f0..95cc3b68 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,46 @@ To reset OmegaClaw's memory: docker volume rm omegaclaw-memory ``` +### Run with the visual control panel + +The included Compose application starts a local React control panel first and +keeps the OmegaClaw agent stopped until it has a channel and LLM configuration. + +In Docker Desktop, open [`compose.yaml`](./compose.yaml), start the `control` +service, and use the published `3210:3210` port link. The page is also available +at [http://localhost:3210](http://localhost:3210). + +For a one-command start that also opens the default browser, use the launcher +for your operating system: + +```sh +# macOS, Linux, or WSL +./scripts/start-control-ui +``` + +```powershell +# Windows PowerShell +.\scripts\start-control-ui.ps1 +``` + +Select a communication channel and LLM provider in the page, enter their +credentials, accept the safety notice, and press **Start OmegaClaw**. Pressing +**Stop** stops the agent container without deleting its memory volume. Starting +again recreates only the agent container with the new settings. + +The control page is bound to `127.0.0.1` by default. It mounts the Docker socket +so it can manage the `omegaclaw` service declared in the same Compose file; do +not expose the control port to other machines. API credentials are not stored +in browser storage, but Docker necessarily places them in the agent container's +environment while it runs. + +Optional environment overrides: + +| Variable | Default | Purpose | +|---|---|---| +| `OMEGACLAW_CONTROL_PORT` | `3210` | Host port for the control page. | +| `OMEGACLAW_IMAGE` | `singularitynet/omegaclaw:latest` | OmegaClaw image started by the panel. | + --- ## Usage diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 00000000..108911a7 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,81 @@ +name: omegaclaw + +services: + control: + build: + context: . + dockerfile: control-ui/Dockerfile + image: omegaclaw-control-ui:local + container_name: omegaclaw-control + restart: unless-stopped + environment: + COMPOSE_PROJECT_NAME: omegaclaw + OMEGACLAW_CONTAINER_NAME: omegaclaw + OMEGACLAW_IMAGE: ${OMEGACLAW_IMAGE:-singularitynet/omegaclaw:latest} + ports: + - "127.0.0.1:${OMEGACLAW_CONTROL_PORT:-3210}:3210" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + labels: + org.opencontainers.image.title: OmegaClaw Control + org.opencontainers.image.description: Local configuration and lifecycle control for OmegaClaw + + omegaclaw: + profiles: [agent] + image: ${OMEGACLAW_IMAGE:-singularitynet/omegaclaw:latest} + pull_policy: missing + container_name: omegaclaw + restart: "no" + init: true + security_opt: + - no-new-privileges:true + extra_hosts: + - host.docker.internal:host-gateway + tmpfs: + - /tmp:size=64m,mode=1777 + - /var/tmp:size=64m,mode=1777 + - /run:size=16m,mode=755 + volumes: + - omegaclaw-memory:/PeTTa/repos/OmegaClaw-Core/memory + environment: + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + ASI_API_KEY: ${ASI_API_KEY:-} + ASIONE_API_KEY: ${ASIONE_API_KEY:-} + OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-} + OPENAIAPI_API_KEY: ${OPENAIAPI_API_KEY:-} + TG_BOT_TOKEN: ${TG_BOT_TOKEN:-} + SL_BOT_TOKEN: ${SL_BOT_TOKEN:-} + MM_BOT_TOKEN: ${MM_BOT_TOKEN:-} + MM_UPSTREAM_URL: ${OMEGACLAW_MM_URL:-https://chat.singularitynet.io} + OMEGACLAW_AUTH_SECRET: ${OMEGACLAW_AUTH_SECRET:-} + IMPORT_KB_ON_START: ${IMPORT_KB_ON_START:-0} + command: + - commchannel=${OMEGACLAW_CHANNEL:-irc} + - provider=${OMEGACLAW_PROVIDER:-Anthropic} + - embeddingprovider=${OMEGACLAW_EMBEDDING_PROVIDER:-Local} + - model=${OMEGACLAW_MODEL:-claude-opus-4-8} + - maxOutputToken=${OMEGACLAW_MAX_OUTPUT_TOKEN:-6000} + - reasoningMode=${OMEGACLAW_REASONING_MODE:-medium} + - openaiapi_url=${OMEGACLAW_OPENAIAPI_URL:-http://host.docker.internal:11434/v1} + - IRC_channel=${OMEGACLAW_IRC_CHANNEL:-##omegaclaw} + - IRC_server=${OMEGACLAW_IRC_SERVER:-irc.quakenet.org} + - IRC_port=${OMEGACLAW_IRC_PORT:-6667} + - IRC_user=${OMEGACLAW_IRC_USER:-omegaclaw} + - TG_CHAT_ID=${OMEGACLAW_TG_CHAT_ID:-} + - SL_CHANNEL_ID=${OMEGACLAW_SL_CHANNEL_ID:-} + - WS_URL=${OMEGACLAW_WS_URL:-} + - WS_TOKEN=${OMEGACLAW_WS_TOKEN:-} + - MM_URL=${OMEGACLAW_MM_URL:-https://chat.singularitynet.io} + - MM_CHANNEL_ID=${OMEGACLAW_MM_CHANNEL_ID:-} + - securityPolicyPath=/PeTTa/repos/OmegaClaw-Core/profile/policy.yaml + - memoryDirectory=$$MEMORY_DIR + labels: + io.omegaclaw.control.managed: "true" + io.omegaclaw.control.channel: ${OMEGACLAW_CHANNEL:-irc} + io.omegaclaw.control.provider: ${OMEGACLAW_PROVIDER:-Anthropic} + io.omegaclaw.control.model: ${OMEGACLAW_MODEL:-claude-opus-4-8} + +volumes: + omegaclaw-memory: + name: omegaclaw-memory diff --git a/control-ui/Dockerfile b/control-ui/Dockerfile new file mode 100644 index 00000000..25ea1927 --- /dev/null +++ b/control-ui/Dockerfile @@ -0,0 +1,31 @@ +# syntax=docker/dockerfile:1.7 + +FROM node:22-alpine AS frontend + +WORKDIR /build +COPY control-ui/package.json control-ui/package-lock.json ./ +RUN npm ci +COPY control-ui/index.html control-ui/vite.config.js ./ +COPY control-ui/src ./src +RUN npm run build + +FROM docker:28-cli AS runtime + +RUN apk add --no-cache nodejs + +WORKDIR /opt/omegaclaw-control +COPY control-ui/server.mjs ./server.mjs +COPY --from=frontend /build/dist ./dist +COPY compose.yaml /opt/omegaclaw/compose.yaml + +ENV NODE_ENV=production \ + PORT=3210 \ + COMPOSE_FILE=/opt/omegaclaw/compose.yaml \ + COMPOSE_PROJECT_NAME=omegaclaw + +EXPOSE 3210 + +HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -qO- http://127.0.0.1:3210/api/health >/dev/null || exit 1 + +ENTRYPOINT ["node", "server.mjs"] diff --git a/control-ui/Dockerfile.dockerignore b/control-ui/Dockerfile.dockerignore new file mode 100644 index 00000000..fb196f81 --- /dev/null +++ b/control-ui/Dockerfile.dockerignore @@ -0,0 +1,11 @@ +* +!compose.yaml +!control-ui/ +!control-ui/Dockerfile +!control-ui/package.json +!control-ui/package-lock.json +!control-ui/index.html +!control-ui/vite.config.js +!control-ui/server.mjs +!control-ui/src/ +!control-ui/src/** diff --git a/control-ui/index.html b/control-ui/index.html new file mode 100644 index 00000000..a254f9de --- /dev/null +++ b/control-ui/index.html @@ -0,0 +1,17 @@ + + + + + + + + Omega Control + + +
+ + + diff --git a/control-ui/package-lock.json b/control-ui/package-lock.json new file mode 100644 index 00000000..e181109b --- /dev/null +++ b/control-ui/package-lock.json @@ -0,0 +1,838 @@ +{ + "name": "omegaclaw-control-ui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "omegaclaw-control-ui", + "version": "0.1.0", + "dependencies": { + "react": "19.2.8", + "react-dom": "19.2.8" + }, + "devDependencies": { + "@vitejs/plugin-react": "6.0.5", + "vite": "8.2.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", + "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", + "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz", + "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz", + "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz", + "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz", + "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz", + "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz", + "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz", + "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz", + "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz", + "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz", + "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz", + "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", + "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/nanoid": { + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/rolldown": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", + "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.143.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.3", + "@rolldown/binding-darwin-arm64": "1.2.3", + "@rolldown/binding-darwin-x64": "1.2.3", + "@rolldown/binding-freebsd-x64": "1.2.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", + "@rolldown/binding-linux-arm64-gnu": "1.2.3", + "@rolldown/binding-linux-arm64-musl": "1.2.3", + "@rolldown/binding-linux-ppc64-gnu": "1.2.3", + "@rolldown/binding-linux-s390x-gnu": "1.2.3", + "@rolldown/binding-linux-x64-gnu": "1.2.3", + "@rolldown/binding-linux-x64-musl": "1.2.3", + "@rolldown/binding-openharmony-arm64": "1.2.3", + "@rolldown/binding-win32-arm64-msvc": "1.2.3", + "@rolldown/binding-win32-x64-msvc": "1.2.3" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/control-ui/package.json b/control-ui/package.json new file mode 100644 index 00000000..69d4d5e5 --- /dev/null +++ b/control-ui/package.json @@ -0,0 +1,21 @@ +{ + "name": "omegaclaw-control-ui", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0", + "build": "vite build", + "preview": "vite preview --host 0.0.0.0", + "check:server": "node --check server.mjs", + "test": "node --test server.test.mjs" + }, + "dependencies": { + "react": "19.2.8", + "react-dom": "19.2.8" + }, + "devDependencies": { + "@vitejs/plugin-react": "6.0.5", + "vite": "8.2.1" + } +} diff --git a/control-ui/server.mjs b/control-ui/server.mjs new file mode 100644 index 00000000..4ee4c4d1 --- /dev/null +++ b/control-ui/server.mjs @@ -0,0 +1,472 @@ +import { randomInt } from "node:crypto"; +import { spawn } from "node:child_process"; +import { createReadStream, existsSync } from "node:fs"; +import { createServer } from "node:http"; +import { extname, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +const PORT = Number(process.env.PORT || 3210); +const DIST_DIR = fileURLToPath(new URL("./dist/", import.meta.url)); +const COMPOSE_FILE = process.env.COMPOSE_FILE || "/opt/omegaclaw/compose.yaml"; +const PROJECT_NAME = process.env.COMPOSE_PROJECT_NAME || "omegaclaw"; +const AGENT_CONTAINER = process.env.OMEGACLAW_CONTAINER_NAME || "omegaclaw"; +const MAX_BODY_BYTES = 32 * 1024; +const MAX_COMMAND_OUTPUT = 64 * 1024; + +const CHANNELS = new Set(["irc", "telegram", "slack", "websocket", "mattermost"]); +const PROVIDERS = new Set([ + "Anthropic", + "OpenAI", + "ASICloud", + "ASIOne", + "OpenRouter", + "OpenAIAPI", +]); +const REASONING_MODES = new Set(["low", "medium", "high"]); +const API_KEY_ENV = { + Anthropic: "ANTHROPIC_API_KEY", + OpenAI: "OPENAI_API_KEY", + ASICloud: "ASI_API_KEY", + ASIOne: "ASIONE_API_KEY", + OpenRouter: "OPENROUTER_API_KEY", + OpenAIAPI: "OPENAIAPI_API_KEY", +}; + +const MIME_TYPES = { + ".css": "text/css; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".ico": "image/x-icon", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".png": "image/png", + ".svg": "image/svg+xml", + ".webp": "image/webp", +}; + +let operation = null; + +export class ValidationError extends Error {} + +function text(value, field, { required = true, max = 512 } = {}) { + const result = String(value ?? "").trim(); + if (required && !result) { + throw new ValidationError(`${field} is required.`); + } + if (result.length > max) { + throw new ValidationError(`${field} is too long.`); + } + if (/[\0\r\n]/.test(result)) { + throw new ValidationError(`${field} contains unsupported characters.`); + } + return result; +} + +function enumValue(value, values, field) { + const result = text(value, field); + if (!values.has(result)) { + throw new ValidationError(`${field} is not supported.`); + } + return result; +} + +function urlValue(value, field, protocols) { + const result = text(value, field, { max: 2048 }); + let parsed; + try { + parsed = new URL(result); + } catch { + throw new ValidationError(`${field} must be a valid URL.`); + } + if (!protocols.includes(parsed.protocol)) { + throw new ValidationError(`${field} must use ${protocols.join(" or ")}.`); + } + return result.replace(/\/$/, ""); +} + +export function validateConfiguration(input) { + if (!input || typeof input !== "object" || Array.isArray(input)) { + throw new ValidationError("A configuration object is required."); + } + + const channel = enumValue(input.channel, CHANNELS, "Channel"); + const provider = enumValue(input.provider, PROVIDERS, "LLM provider"); + const model = text(input.model, "Model", { max: 256 }); + const apiKey = text(input.apiKey, "API key", { max: 4096 }); + const authSecret = text(input.authSecret, "Authentication code", { max: 128 }); + if (authSecret.length < 4) { + throw new ValidationError("Authentication code must contain at least 4 characters."); + } + + const reasoningMode = enumValue( + input.reasoningMode || "medium", + REASONING_MODES, + "Reasoning mode", + ); + const maxOutputToken = Number(input.maxOutputToken || 6000); + if (!Number.isInteger(maxOutputToken) || maxOutputToken < 128 || maxOutputToken > 32000) { + throw new ValidationError("Max output tokens must be an integer from 128 to 32000."); + } + + const config = { + channel, + provider, + model, + apiKey, + authSecret, + reasoningMode, + maxOutputToken, + importKnowledge: input.importKnowledge === true, + openaiApiUrl: "http://host.docker.internal:11434/v1", + ircChannel: "##omegaclaw", + ircServer: "irc.quakenet.org", + ircPort: "6667", + ircUser: "omegaclaw", + telegramBotToken: "", + telegramChatId: "", + slackBotToken: "", + slackChannelId: "", + websocketUrl: "", + websocketToken: "", + mattermostUrl: "https://chat.singularitynet.io", + mattermostChannelId: "", + mattermostBotToken: "", + }; + + if (provider === "OpenAIAPI") { + config.openaiApiUrl = urlValue(input.openaiApiUrl, "OpenAI API endpoint", ["http:", "https:"]); + } + + if (channel === "irc") { + config.ircChannel = text(input.ircChannel, "IRC channel", { max: 128 }); + config.ircServer = text(input.ircServer || config.ircServer, "IRC server", { max: 253 }); + const port = Number(input.ircPort || 6667); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new ValidationError("IRC port must be from 1 to 65535."); + } + config.ircPort = String(port); + config.ircUser = text(input.ircUser || config.ircUser, "IRC nickname", { max: 64 }); + } + + if (channel === "telegram") { + config.telegramBotToken = text(input.telegramBotToken, "Telegram bot token", { max: 512 }); + config.telegramChatId = text(input.telegramChatId, "Telegram chat ID", { + required: false, + max: 128, + }); + } + + if (channel === "slack") { + config.slackBotToken = text(input.slackBotToken, "Slack bot token", { max: 512 }); + config.slackChannelId = text(input.slackChannelId, "Slack channel ID", { + required: false, + max: 128, + }); + } + + if (channel === "websocket") { + config.websocketUrl = urlValue(input.websocketUrl, "WebSocket URL", ["ws:", "wss:"]); + config.websocketToken = text(input.websocketToken, "WebSocket token", { + required: false, + max: 2048, + }); + } + + if (channel === "mattermost") { + config.mattermostUrl = urlValue(input.mattermostUrl, "Mattermost URL", ["http:", "https:"]); + config.mattermostChannelId = text(input.mattermostChannelId, "Mattermost channel ID", { + max: 128, + }); + config.mattermostBotToken = text(input.mattermostBotToken, "Mattermost bot token", { + max: 512, + }); + } + + return config; +} + +export function configurationEnvironment(config) { + const environment = { + ANTHROPIC_API_KEY: "", + OPENAI_API_KEY: "", + ASI_API_KEY: "", + ASIONE_API_KEY: "", + OPENROUTER_API_KEY: "", + OPENAIAPI_API_KEY: "", + TG_BOT_TOKEN: config.telegramBotToken, + SL_BOT_TOKEN: config.slackBotToken, + MM_BOT_TOKEN: config.mattermostBotToken, + OMEGACLAW_AUTH_SECRET: config.authSecret, + IMPORT_KB_ON_START: config.importKnowledge ? "1" : "0", + OMEGACLAW_CHANNEL: config.channel, + OMEGACLAW_PROVIDER: config.provider, + OMEGACLAW_EMBEDDING_PROVIDER: config.provider === "OpenAI" ? "OpenAI" : "Local", + OMEGACLAW_MODEL: config.model, + OMEGACLAW_REASONING_MODE: config.reasoningMode, + OMEGACLAW_MAX_OUTPUT_TOKEN: String(config.maxOutputToken), + OMEGACLAW_OPENAIAPI_URL: config.openaiApiUrl, + OMEGACLAW_IRC_CHANNEL: config.ircChannel, + OMEGACLAW_IRC_SERVER: config.ircServer, + OMEGACLAW_IRC_PORT: config.ircPort, + OMEGACLAW_IRC_USER: config.ircUser, + OMEGACLAW_TG_CHAT_ID: config.telegramChatId, + OMEGACLAW_SL_CHANNEL_ID: config.slackChannelId, + OMEGACLAW_WS_URL: config.websocketUrl, + OMEGACLAW_WS_TOKEN: config.websocketToken, + OMEGACLAW_MM_URL: config.mattermostUrl, + OMEGACLAW_MM_CHANNEL_ID: config.mattermostChannelId, + }; + environment[API_KEY_ENV[config.provider]] = config.apiKey; + return environment; +} + +function run(command, args, { environment = {}, timeout = 600_000 } = {}) { + return new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { + env: { ...process.env, ...environment }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let settled = false; + + const append = (current, chunk) => (current + chunk.toString()).slice(-MAX_COMMAND_OUTPUT); + child.stdout.on("data", (chunk) => { + stdout = append(stdout, chunk); + }); + child.stderr.on("data", (chunk) => { + stderr = append(stderr, chunk); + }); + + const timer = setTimeout(() => { + if (!settled) { + child.kill("SIGKILL"); + reject(new Error(`${command} timed out.`)); + } + }, timeout); + + child.on("error", (error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(error); + }); + + child.on("close", (code) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (code === 0) { + resolvePromise({ stdout: stdout.trim(), stderr: stderr.trim() }); + } else { + const detail = stderr.trim().split("\n").slice(-6).join("\n"); + reject(new Error(detail || `${command} exited with status ${code}.`)); + } + }); + }); +} + +function composeArgs(...args) { + return ["compose", "--project-name", PROJECT_NAME, "--file", COMPOSE_FILE, ...args]; +} + +async function agentStatus() { + const { stdout: containerId } = await run( + "docker", + ["ps", "--all", "--quiet", "--filter", `name=^/${AGENT_CONTAINER}$`], + { timeout: 10_000 }, + ); + if (!containerId) { + return { + running: false, + status: "not_created", + startedAt: null, + finishedAt: null, + exitCode: null, + error: "", + configuration: { channel: null, provider: null, model: null }, + operation, + }; + } + + const { stdout } = await run("docker", ["inspect", containerId], { timeout: 10_000 }); + const [container] = JSON.parse(stdout); + const labels = container?.Config?.Labels || {}; + const state = container?.State || {}; + return { + running: state.Running === true, + status: state.Status || "unknown", + startedAt: state.StartedAt || null, + finishedAt: state.FinishedAt || null, + exitCode: state.ExitCode ?? null, + error: state.Error || "", + configuration: { + channel: labels["io.omegaclaw.control.channel"] || null, + provider: labels["io.omegaclaw.control.provider"] || null, + model: labels["io.omegaclaw.control.model"] || null, + }, + operation, + }; +} + +function securityHeaders(response) { + response.setHeader("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"); + response.setHeader("Referrer-Policy", "no-referrer"); + response.setHeader("X-Content-Type-Options", "nosniff"); + response.setHeader("X-Frame-Options", "DENY"); +} + +function json(response, statusCode, body) { + securityHeaders(response); + response.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8" }); + response.end(JSON.stringify(body)); +} + +async function readJson(request) { + const contentType = String(request.headers["content-type"] || ""); + if (!contentType.toLowerCase().startsWith("application/json")) { + throw new ValidationError("Content-Type must be application/json."); + } + + const chunks = []; + let size = 0; + for await (const chunk of request) { + size += chunk.length; + if (size > MAX_BODY_BYTES) { + throw new ValidationError("Request body is too large."); + } + chunks.push(chunk); + } + try { + return JSON.parse(Buffer.concat(chunks).toString("utf8")); + } catch { + throw new ValidationError("Request body must contain valid JSON."); + } +} + +function requireControlRequest(request) { + if (request.headers["x-omega-control"] !== "browser") { + throw new ValidationError("Missing control request header."); + } +} + +async function startAgent(request, response) { + if (operation) { + return json(response, 409, { error: `A ${operation} operation is already in progress.` }); + } + + operation = "start"; + try { + const config = validateConfiguration(await readJson(request)); + await run( + "docker", + composeArgs("--profile", "agent", "up", "--detach", "--force-recreate", "--no-deps", "--pull", "missing", "omegaclaw"), + { + environment: configurationEnvironment(config), + timeout: 30 * 60_000, + }, + ); + operation = null; + const status = await agentStatus(); + return json(response, 200, { + ...status, + authSecret: config.authSecret, + message: "OmegaClaw started with the new configuration.", + }); + } finally { + operation = null; + } +} + +async function stopAgent(response) { + if (operation) { + return json(response, 409, { error: `A ${operation} operation is already in progress.` }); + } + + operation = "stop"; + try { + await run("docker", composeArgs("--profile", "agent", "stop", "--timeout", "20", "omegaclaw"), { + timeout: 60_000, + }); + operation = null; + return json(response, 200, { + ...(await agentStatus()), + message: "OmegaClaw stopped safely.", + }); + } finally { + operation = null; + } +} + +function serveStatic(request, response) { + const requestUrl = new URL(request.url, "http://localhost"); + const requestedPath = requestUrl.pathname === "/" ? "/index.html" : requestUrl.pathname; + let filePath = resolve(DIST_DIR, `.${decodeURIComponent(requestedPath)}`); + if (!filePath.startsWith(`${resolve(DIST_DIR)}${sep}`)) { + return json(response, 404, { error: "Not found." }); + } + if (!existsSync(filePath)) { + filePath = resolve(DIST_DIR, "index.html"); + } + + securityHeaders(response); + response.writeHead(200, { + "Cache-Control": extname(filePath) === ".html" ? "no-cache" : "public, max-age=31536000, immutable", + "Content-Type": MIME_TYPES[extname(filePath)] || "application/octet-stream", + }); + createReadStream(filePath).pipe(response); +} + +export function createControlServer() { + return createServer(async (request, response) => { + try { + const requestUrl = new URL(request.url, "http://localhost"); + + if (request.method === "GET" && requestUrl.pathname === "/api/health") { + return json(response, 200, { ok: true }); + } + if (request.method === "GET" && requestUrl.pathname === "/api/status") { + return json(response, 200, await agentStatus()); + } + if (request.method === "POST" && requestUrl.pathname === "/api/start") { + requireControlRequest(request); + return await startAgent(request, response); + } + if (request.method === "POST" && requestUrl.pathname === "/api/stop") { + requireControlRequest(request); + return await stopAgent(response); + } + if (requestUrl.pathname.startsWith("/api/")) { + return json(response, 404, { error: "API endpoint not found." }); + } + if (request.method !== "GET" && request.method !== "HEAD") { + return json(response, 405, { error: "Method not allowed." }); + } + return serveStatic(request, response); + } catch (error) { + const statusCode = error instanceof ValidationError ? 400 : 500; + const message = statusCode === 400 ? error.message : "The container operation failed. Check Docker Desktop and try again."; + if (statusCode === 500) { + console.error(`[control-api] ${error.message}`); + } + if (!response.headersSent) { + return json(response, statusCode, { error: message }); + } + response.end(); + } + }); +} + +if (process.env.NODE_ENV !== "test") { + const server = createControlServer(); + server.listen(PORT, "0.0.0.0", () => { + console.log(`Omega Control is ready on port ${PORT}.`); + }); + + for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, () => server.close(() => process.exit(0))); + } +} + +export function randomAuthSecret() { + return String(randomInt(100000, 1000000)); +} diff --git a/control-ui/server.test.mjs b/control-ui/server.test.mjs new file mode 100644 index 00000000..f2696751 --- /dev/null +++ b/control-ui/server.test.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +process.env.NODE_ENV = "test"; +const { configurationEnvironment, validateConfiguration, ValidationError } = await import("./server.mjs"); + +const valid = { + channel: "telegram", + provider: "OpenAI", + model: "gpt-5.5", + apiKey: "secret-key", + authSecret: "123456", + reasoningMode: "medium", + maxOutputToken: 6000, + telegramBotToken: "123:telegram-secret", + telegramChatId: "", +}; + +test("validates and maps a supported configuration", () => { + const config = validateConfiguration(valid); + const environment = configurationEnvironment(config); + + assert.equal(config.channel, "telegram"); + assert.equal(environment.OPENAI_API_KEY, "secret-key"); + assert.equal(environment.ANTHROPIC_API_KEY, ""); + assert.equal(environment.TG_BOT_TOKEN, "123:telegram-secret"); + assert.equal(environment.OMEGACLAW_EMBEDDING_PROVIDER, "OpenAI"); +}); + +test("rejects unsupported providers", () => { + assert.throws( + () => validateConfiguration({ ...valid, provider: "UnknownAI" }), + (error) => error instanceof ValidationError && /not supported/.test(error.message), + ); +}); + +test("requires channel-specific credentials", () => { + assert.throws( + () => validateConfiguration({ ...valid, telegramBotToken: "" }), + (error) => error instanceof ValidationError && /Telegram bot token is required/.test(error.message), + ); +}); + +test("rejects control characters in Compose values", () => { + assert.throws( + () => validateConfiguration({ ...valid, model: "gpt-safe\nINJECTED=value" }), + (error) => error instanceof ValidationError && /unsupported characters/.test(error.message), + ); +}); diff --git a/control-ui/src/App.jsx b/control-ui/src/App.jsx new file mode 100644 index 00000000..cf6308ed --- /dev/null +++ b/control-ui/src/App.jsx @@ -0,0 +1,494 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; + +const CHANNELS = [ + { id: "irc", name: "IRC", note: "Simple, open chat" }, + { id: "telegram", name: "Telegram", note: "Talk to your bot" }, + { id: "slack", name: "Slack", note: "Connect a workspace" }, + { id: "websocket", name: "WebSocket", note: "Bring your own client" }, + { id: "mattermost", name: "Mattermost", note: "Self-hosted teams" }, +]; + +const PROVIDERS = [ + { id: "Anthropic", name: "Anthropic", model: "claude-opus-4-8", mark: "A" }, + { id: "OpenAI", name: "OpenAI", model: "gpt-5.5", mark: "O" }, + { id: "ASICloud", name: "ASI Cloud", model: "minimax/minimax-m3", mark: "AC" }, + { id: "ASIOne", name: "ASI:One", model: "asi1-ultra", mark: "A1" }, + { id: "OpenRouter", name: "OpenRouter", model: "z-ai/glm-5.2", mark: "OR" }, + { id: "OpenAIAPI", name: "Custom API", model: "qwen3.5:9b", mark: "<>" }, +]; + +function generateAuthCode() { + const values = new Uint32Array(1); + window.crypto.getRandomValues(values); + return String(100000 + (values[0] % 900000)); +} + +const initialForm = { + channel: "irc", + provider: "Anthropic", + model: "claude-opus-4-8", + apiKey: "", + authSecret: generateAuthCode(), + reasoningMode: "medium", + maxOutputToken: 6000, + importKnowledge: false, + openaiApiUrl: "http://host.docker.internal:11434/v1", + ircChannel: "##omegaclaw", + ircServer: "irc.quakenet.org", + ircPort: 6667, + ircUser: "omegaclaw", + telegramBotToken: "", + telegramChatId: "", + slackBotToken: "", + slackChannelId: "", + websocketUrl: "", + websocketToken: "", + mattermostUrl: "https://chat.singularitynet.io", + mattermostChannelId: "", + mattermostBotToken: "", +}; + +async function api(path, options = {}) { + const response = await fetch(path, { + ...options, + headers: { + ...(options.body ? { "Content-Type": "application/json" } : {}), + ...(options.method === "POST" ? { "X-Omega-Control": "browser" } : {}), + ...options.headers, + }, + }); + const body = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(body.error || "The request failed."); + } + return body; +} + +function Icon({ name }) { + const paths = { + channel: <>, + brain: <>, + play: , + stop: , + key: <>, + refresh: <>, + eye: <>, + eyeOff: <>, + check: , + spark: <>, + }; + return ; +} + +function Field({ label, hint, children, wide = false }) { + return ( + + ); +} + +function SecretInput({ id, value, onChange, placeholder, required = false, autoComplete = "new-password" }) { + const [visible, setVisible] = useState(false); + return ( + + + + + ); +} + +function ChannelFields({ form, setField }) { + if (form.channel === "irc") { + return ( +
+ + setField("ircChannel", event.target.value)} required /> + + + setField("ircUser", event.target.value)} required /> + + + setField("ircServer", event.target.value)} required /> + + + setField("ircPort", event.target.value)} required /> + +
+ ); + } + + if (form.channel === "telegram") { + return ( +
+ + setField("telegramBotToken", event.target.value)} placeholder="123456:AA..." required /> + + + setField("telegramChatId", event.target.value)} placeholder="Auto-bind" /> + +
+ ); + } + + if (form.channel === "slack") { + return ( +
+ + setField("slackBotToken", event.target.value)} placeholder="xoxb-..." required /> + + + setField("slackChannelId", event.target.value)} placeholder="C0123456789" /> + +
+ ); + } + + if (form.channel === "websocket") { + return ( +
+ + setField("websocketUrl", event.target.value)} placeholder="wss://chat.example.com/agent" required /> + + + setField("websocketToken", event.target.value)} placeholder="Optional" /> + +
+ ); + } + + return ( +
+ + setField("mattermostUrl", event.target.value)} required /> + + + setField("mattermostChannelId", event.target.value)} required /> + + + setField("mattermostBotToken", event.target.value)} required /> + +
+ ); +} + +function StatusPill({ status, loading }) { + const running = status?.running; + return ( +
+ + {loading ? "Checking" : running ? "OmegaClaw online" : "OmegaClaw stopped"} +
+ ); +} + +function InteractionGuide({ started }) { + if (!started) return null; + const channel = CHANNELS.find((item) => item.id === started.channel)?.name || started.channel; + return ( +
+
+
+

Connection ready

+

Meet your claw in {channel}

+

+ Send auth {started.authSecret} as the first message. The first user who authenticates becomes the owner of this OmegaClaw memory. +

+ {started.channel === "irc" && ( + Open QuakeNet web chat ↗ + )} +
+
+ One-time auth code + {started.authSecret} +
+
+ ); +} + +export default function App() { + const [form, setForm] = useState(initialForm); + const [status, setStatus] = useState(null); + const [statusLoading, setStatusLoading] = useState(true); + const [busy, setBusy] = useState(""); + const [notice, setNotice] = useState(null); + const [accepted, setAccepted] = useState(false); + const [started, setStarted] = useState(null); + const [showApiKey, setShowApiKey] = useState(false); + + const selectedProvider = useMemo( + () => PROVIDERS.find((provider) => provider.id === form.provider), + [form.provider], + ); + + const setField = useCallback((field, value) => { + setForm((current) => ({ ...current, [field]: value })); + }, []); + + const refreshStatus = useCallback(async (quiet = false) => { + if (!quiet) setStatusLoading(true); + try { + const nextStatus = await api("/api/status"); + setStatus(nextStatus); + } catch (error) { + if (!quiet) setNotice({ type: "error", text: error.message }); + } finally { + if (!quiet) setStatusLoading(false); + } + }, []); + + useEffect(() => { + refreshStatus(); + const timer = window.setInterval(() => refreshStatus(true), 4000); + return () => window.clearInterval(timer); + }, [refreshStatus]); + + function chooseProvider(provider) { + setForm((current) => ({ ...current, provider: provider.id, model: provider.model })); + } + + async function handleStart(event) { + event.preventDefault(); + setBusy("start"); + setNotice(null); + try { + const result = await api("/api/start", { + method: "POST", + body: JSON.stringify(form), + }); + setStatus(result); + setStarted({ channel: form.channel, authSecret: result.authSecret }); + setNotice({ type: "success", text: result.message }); + } catch (error) { + setNotice({ type: "error", text: error.message }); + } finally { + setBusy(""); + } + } + + async function handleStop() { + setBusy("stop"); + setNotice(null); + try { + const result = await api("/api/stop", { method: "POST" }); + setStatus(result); + setStarted(null); + setNotice({ type: "success", text: result.message }); + } catch (error) { + setNotice({ type: "error", text: error.message }); + } finally { + setBusy(""); + } + } + + return ( +
+
+ + Ω + Omega Control + +
+ + +
+
+ +
+
+

Local control plane / OmegaClaw

+

Give your claw
a voice and a mind.

+

+ Choose where it listens, choose how it thinks, then bring the agent online. Your credentials stay in the local Docker runtime. +

+
+
+ +
+
+
+
+ 01 +
+
+

Connection

+

Channel

+

Where should OmegaClaw listen?

+
+
+ +
+ {CHANNELS.map((channel) => ( + + ))} +
+ +
{CHANNELS.find((channel) => channel.id === form.channel)?.name} details
+ + +
+
+ +
+ setField("authSecret", event.target.value)} minLength="4" required /> + +
+
+
+
+ +
+
+ 02 +
+
+

Intelligence

+

LLM

+

Which model should power the agent?

+
+
+ +
+ {PROVIDERS.map((provider) => ( + + ))} +
+ +
+ + + setField("apiKey", event.target.value)} + placeholder="Paste API key" + autoComplete="new-password" + spellCheck="false" + required + /> + + + + + setField("model", event.target.value)} required /> + + {form.provider === "OpenAIAPI" && ( + + setField("openaiApiUrl", event.target.value)} required /> + + )} +
+ +
+ Runtime tuning Optional +
+ + + + + setField("maxOutputToken", event.target.value)} /> + + +
+
+
+
+ +
+
+

03 / Launch

+

{status?.running ? "OmegaClaw is active" : "Ready when you are"}

+

+ {status?.running && status.configuration?.provider + ? `${status.configuration.channel} · ${status.configuration.provider} · ${status.configuration.model}` + : "Review the settings, accept the notice, and start the agent."} +

+
+ +
+ + +
+ {notice &&
{notice.text}
} +
+ + + + +
+ Ω +

Runs locally through Docker Compose. Secrets are placed only in the managed container environment.

+ OmegaClaw Control / 0.1 +
+
+ ); +} diff --git a/control-ui/src/main.jsx b/control-ui/src/main.jsx new file mode 100644 index 00000000..9798c7ca --- /dev/null +++ b/control-ui/src/main.jsx @@ -0,0 +1,10 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App.jsx"; +import "./styles.css"; + +createRoot(document.getElementById("root")).render( + + + , +); diff --git a/control-ui/src/styles.css b/control-ui/src/styles.css new file mode 100644 index 00000000..e56fbe08 --- /dev/null +++ b/control-ui/src/styles.css @@ -0,0 +1,246 @@ +:root { + color: #eff7f2; + background: #08110f; + font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-synthesis: none; + text-rendering: optimizeLegibility; + --ink: #eff7f2; + --muted: #96a69f; + --faint: #62716b; + --panel: #101b18; + --panel-light: #14231f; + --line: rgba(219, 244, 231, 0.12); + --green: #a9f5c7; + --green-strong: #56d989; + --dark-green: #173b2b; + --amber: #f0c674; + --red: #ff8e82; +} + +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } +body { margin: 0; min-width: 320px; min-height: 100vh; background: #08110f; } +button, input, select { font: inherit; } +button, summary, label { -webkit-tap-highlight-color: transparent; } +button { color: inherit; } +a { color: var(--green); } +.icon { width: 1.25rem; height: 1.25rem; flex: none; } + +body::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + opacity: 0.28; + background-image: radial-gradient(rgba(230, 255, 239, 0.22) 0.55px, transparent 0.55px); + background-size: 5px 5px; + mask-image: linear-gradient(to bottom, black, transparent 72%); +} + +main { width: min(1440px, 100%); margin: 0 auto; padding: 0 4vw 3rem; } + +.topbar { + min-height: 86px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--line); +} + +.brand { display: inline-flex; align-items: center; gap: 0.7rem; color: var(--ink); text-decoration: none; font-size: 1rem; letter-spacing: -0.02em; } +.brand b { font-weight: 500; color: var(--muted); } +.brand-mark { + width: 2.35rem; + height: 2.35rem; + border-radius: 50%; + display: inline-grid; + place-items: center; + border: 1px solid rgba(169, 245, 199, 0.42); + color: var(--green); + font-family: Georgia, serif; + font-size: 1.3rem; + box-shadow: inset 0 0 20px rgba(86, 217, 137, 0.08), 0 0 28px rgba(86, 217, 137, 0.08); +} +.brand-mark.small { width: 1.9rem; height: 1.9rem; font-size: 1rem; } +.topbar-actions { display: flex; align-items: center; gap: 0.65rem; } +.refresh-button { display: grid; place-items: center; width: 2.7rem; height: 2.7rem; border: 1px solid var(--line); border-radius: 50%; background: transparent; cursor: pointer; } +.refresh-button:hover { color: var(--green); border-color: rgba(169, 245, 199, 0.35); } +.status-pill { display: flex; align-items: center; gap: 0.6rem; height: 2.7rem; padding: 0 1rem; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); font-size: 0.78rem; font-weight: 650; letter-spacing: 0.02em; background: rgba(16, 27, 24, 0.75); } +.status-dot { width: 0.48rem; height: 0.48rem; border-radius: 50%; background: #66716d; box-shadow: 0 0 0 4px rgba(102, 113, 109, 0.1); } +.is-running .status-dot { background: var(--green-strong); box-shadow: 0 0 0 4px rgba(86, 217, 137, 0.1), 0 0 12px rgba(86, 217, 137, 0.55); } +.is-running { color: var(--green); } + +.hero { min-height: 440px; display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(340px, 0.85fr); align-items: center; gap: 3rem; padding: 4.5rem 2vw 4rem; overflow: hidden; } +.eyebrow { margin: 0 0 0.8rem; color: var(--green); font-size: 0.68rem; line-height: 1.4; font-weight: 750; text-transform: uppercase; letter-spacing: 0.19em; } +.eyebrow span { color: var(--amber); } +.hero h1 { max-width: 760px; margin: 0; color: var(--ink); font-family: Georgia, "Times New Roman", serif; font-weight: 400; font-size: clamp(3rem, 6vw, 6.2rem); line-height: 0.96; letter-spacing: -0.065em; } +.hero h1 em { color: var(--green); font-weight: 400; } +.hero-lede { max-width: 590px; margin: 1.8rem 0 0; color: var(--muted); font-size: clamp(1rem, 1.35vw, 1.18rem); line-height: 1.7; } + +.hero-orbit { position: relative; justify-self: center; width: min(36vw, 440px); aspect-ratio: 1; display: grid; place-items: center; } +.hero-orbit::before { content: ""; position: absolute; width: 35%; height: 35%; border-radius: 50%; background: rgba(86, 217, 137, 0.11); filter: blur(35px); } +.orbit { position: absolute; border: 1px solid rgba(169, 245, 199, 0.19); border-radius: 50%; } +.orbit::before, .orbit::after { content: ""; position: absolute; width: 7px; height: 7px; border-radius: 50%; background: var(--green); box-shadow: 0 0 14px var(--green); } +.orbit-one { inset: 9%; animation: orbit-spin 22s linear infinite; } +.orbit-one::before { top: 7%; left: 24%; } +.orbit-one::after { right: -4px; top: 50%; background: var(--amber); box-shadow: 0 0 14px var(--amber); } +.orbit-two { inset: 24%; border-style: dashed; animation: orbit-spin 17s linear infinite reverse; } +.orbit-two::before { bottom: 8%; left: 22%; } +.orbit-two::after { display: none; } +.core-mark { width: 31%; aspect-ratio: 1; display: grid; place-items: center; border-radius: 50%; border: 1px solid rgba(169, 245, 199, 0.34); font-family: Georgia, serif; font-size: clamp(3rem, 7vw, 6rem); color: var(--green); background: radial-gradient(circle at 50% 40%, #17352a, #0d1815 68%); box-shadow: 0 0 70px rgba(86, 217, 137, 0.13), inset 0 0 45px rgba(86, 217, 137, 0.08); } +.orbit-label { position: absolute; padding: 0.4rem 0.65rem; color: var(--faint); border: 1px solid var(--line); border-radius: 4px; background: #0b1512; font-size: 0.55rem; font-weight: 700; letter-spacing: 0.15em; } +.label-channel { left: 2%; bottom: 22%; } +.label-model { right: 0; top: 25%; } +@keyframes orbit-spin { to { transform: rotate(360deg); } } + +.configuration-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; background: var(--line); border: 1px solid var(--line); border-radius: 22px; overflow: hidden; box-shadow: 0 40px 100px rgba(0, 0, 0, 0.24); } +.config-card { min-width: 0; padding: clamp(1.5rem, 3vw, 3rem); background: rgba(15, 27, 23, 0.96); } +.llm-card { background: rgba(18, 31, 27, 0.96); } +.card-heading { position: relative; display: grid; grid-template-columns: 3.6rem 1fr; align-items: start; gap: 1.15rem; margin-bottom: 2rem; } +.heading-icon { width: 3.6rem; height: 3.6rem; display: grid; place-items: center; border-radius: 14px; color: var(--green); background: linear-gradient(145deg, rgba(169, 245, 199, 0.12), rgba(169, 245, 199, 0.025)); border: 1px solid rgba(169, 245, 199, 0.16); } +.heading-icon .icon { width: 1.55rem; height: 1.55rem; } +.card-heading .eyebrow { margin-bottom: 0.25rem; } +.card-heading h2 { margin: 0; font-family: Georgia, serif; font-size: 2.2rem; font-weight: 400; letter-spacing: -0.04em; } +.card-heading p:last-child { margin: 0.3rem 0 0; color: var(--muted); font-size: 0.86rem; } +.step-number { position: absolute; right: 0; top: 0; color: rgba(239, 247, 242, 0.12); font-family: Georgia, serif; font-size: 2.1rem; } + +.channel-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.65rem; } +.channel-option, .provider-option { position: relative; border: 1px solid var(--line); background: rgba(6, 14, 12, 0.4); cursor: pointer; text-align: left; transition: border-color 150ms ease, background 150ms ease, transform 150ms ease; } +.channel-option:hover, .provider-option:hover { border-color: rgba(169, 245, 199, 0.3); transform: translateY(-1px); } +.channel-option { min-height: 68px; display: flex; flex-direction: column; justify-content: center; padding: 0.9rem 2.3rem 0.9rem 1rem; border-radius: 10px; } +.channel-option span { font-size: 0.87rem; font-weight: 680; } +.channel-option small { margin-top: 0.22rem; color: var(--faint); font-size: 0.67rem; } +.channel-option i, .provider-option i { position: absolute; display: none; place-items: center; border-radius: 50%; color: #0a1612; background: var(--green); } +.channel-option i { right: 0.75rem; top: 50%; width: 1.15rem; height: 1.15rem; transform: translateY(-50%); } +.channel-option i .icon, .provider-option i .icon { width: 0.75rem; height: 0.75rem; stroke-width: 3; } +.channel-option.selected, .provider-option.selected { border-color: rgba(169, 245, 199, 0.65); background: linear-gradient(135deg, rgba(86, 217, 137, 0.12), rgba(86, 217, 137, 0.035)); } +.channel-option.selected i, .provider-option.selected i { display: grid; } +.channel-option:last-child:nth-child(odd) { grid-column: span 2; } + +.section-divider { display: flex; align-items: center; gap: 0.7rem; margin: 2rem 0 1.15rem; color: var(--faint); font-size: 0.61rem; text-transform: uppercase; font-weight: 700; letter-spacing: 0.14em; } +.section-divider::before, .section-divider::after { content: ""; height: 1px; background: var(--line); } +.section-divider::before { width: 1rem; } +.section-divider::after { flex: 1; } + +.field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; } +.field { min-width: 0; display: flex; flex-direction: column; gap: 0.45rem; } +.field-wide { grid-column: 1 / -1; } +.field-label { color: #cbd8d1; font-size: 0.71rem; font-weight: 680; letter-spacing: 0.025em; } +.field-hint { color: var(--faint); font-size: 0.65rem; line-height: 1.45; } +input, select { width: 100%; min-width: 0; height: 2.9rem; border: 1px solid var(--line); border-radius: 8px; outline: none; padding: 0 0.85rem; color: var(--ink); background: rgba(4, 11, 9, 0.5); transition: border-color 150ms ease, box-shadow 150ms ease; } +input::placeholder { color: #50605a; } +input:focus, select:focus { border-color: rgba(169, 245, 199, 0.6); box-shadow: 0 0 0 3px rgba(86, 217, 137, 0.08); } +select { appearance: none; background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%), linear-gradient(135deg, var(--muted) 50%, transparent 50%); background-position: calc(100% - 16px) 20px, calc(100% - 11px) 20px; background-size: 5px 5px; background-repeat: no-repeat; } +.secret-input { position: relative; display: block; } +.secret-input input { padding-right: 3rem; } +.icon-button { position: absolute; right: 0.35rem; top: 50%; width: 2.2rem; height: 2.2rem; transform: translateY(-50%); display: grid; place-items: center; border: 0; border-radius: 6px; color: var(--faint); background: transparent; cursor: pointer; } +.icon-button:hover { color: var(--green); background: rgba(169, 245, 199, 0.06); } +.inline-control { display: flex; gap: 0.45rem; } +.inline-control button { border: 1px solid var(--line); border-radius: 8px; padding: 0 0.8rem; color: var(--green); background: rgba(169, 245, 199, 0.05); font-size: 0.68rem; font-weight: 700; cursor: pointer; } +.auth-row { display: grid; grid-template-columns: 2.5rem 1fr; gap: 0.8rem; align-items: start; margin-top: 1.2rem; padding: 1rem; border: 1px solid rgba(240, 198, 116, 0.16); border-radius: 11px; background: rgba(240, 198, 116, 0.035); } +.auth-row-icon { width: 2.5rem; height: 2.5rem; display: grid; place-items: center; color: var(--amber); border-radius: 8px; background: rgba(240, 198, 116, 0.08); } + +.provider-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.55rem; } +.provider-option { min-height: 78px; display: flex; flex-direction: column; justify-content: center; gap: 0.5rem; padding: 0.75rem; border-radius: 10px; } +.provider-option > span:last-of-type { color: #cbd8d1; font-size: 0.69rem; font-weight: 680; } +.provider-mark { width: 1.7rem; height: 1.7rem; display: grid; place-items: center; border: 1px solid rgba(239, 247, 242, 0.15); border-radius: 6px; color: var(--green); font-family: Georgia, serif; font-size: 0.66rem; } +.provider-option i { right: 0.5rem; top: 0.5rem; width: 1rem; height: 1rem; } +.llm-fields { display: grid; gap: 1rem; margin-top: 1.5rem; } +.advanced-settings { margin-top: 1.5rem; border-top: 1px solid var(--line); } +.advanced-settings summary { display: flex; justify-content: space-between; padding: 1rem 0 0; color: #cbd8d1; font-size: 0.72rem; font-weight: 700; cursor: pointer; list-style: none; } +.advanced-settings summary::-webkit-details-marker { display: none; } +.advanced-settings summary span { color: var(--faint); font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.13em; } +.advanced-grid { margin-top: 1rem; } +.toggle-row { display: grid; grid-template-columns: auto 1fr; column-gap: 0.7rem; align-items: center; cursor: pointer; } +.toggle-row input { position: absolute; opacity: 0; width: 1px; height: 1px; } +.toggle-track { grid-row: span 2; width: 2.5rem; height: 1.4rem; padding: 0.16rem; border-radius: 999px; background: #2a3833; transition: background 150ms ease; } +.toggle-track i { display: block; width: 1.08rem; height: 1.08rem; border-radius: 50%; background: #84928c; transition: transform 150ms ease, background 150ms ease; } +.toggle-row input:checked + .toggle-track { background: rgba(86, 217, 137, 0.3); } +.toggle-row input:checked + .toggle-track i { transform: translateX(1.08rem); background: var(--green); } +.toggle-row b { display: block; color: #cbd8d1; font-size: 0.72rem; } +.toggle-row small { display: block; margin-top: 0.2rem; color: var(--faint); font-size: 0.63rem; } + +.control-deck { position: relative; display: grid; grid-template-columns: minmax(230px, 0.8fr) minmax(240px, 0.75fr) minmax(440px, 1.45fr); align-items: center; gap: 2rem; margin: 1rem 0 0; padding: 2rem; border: 1px solid var(--line); border-radius: 18px; background: linear-gradient(115deg, rgba(16, 29, 25, 0.98), rgba(10, 20, 17, 0.98)); overflow: hidden; } +.control-deck::before { content: ""; position: absolute; width: 330px; height: 330px; right: 10%; border-radius: 50%; background: rgba(86, 217, 137, 0.05); filter: blur(45px); pointer-events: none; } +.control-copy { position: relative; } +.control-copy .eyebrow { margin-bottom: 0.35rem; } +.control-copy h2 { margin: 0; font-family: Georgia, serif; font-weight: 400; font-size: 1.6rem; letter-spacing: -0.035em; } +.control-copy > p:last-child { margin: 0.45rem 0 0; color: var(--faint); font-size: 0.69rem; line-height: 1.5; overflow-wrap: anywhere; } +.acceptance { position: relative; display: grid; grid-template-columns: 1.35rem 1fr; gap: 0.65rem; align-items: start; cursor: pointer; } +.acceptance input { position: absolute; opacity: 0; width: 1px; height: 1px; } +.acceptance > span { width: 1.35rem; height: 1.35rem; display: grid; place-items: center; border: 1px solid rgba(239, 247, 242, 0.2); border-radius: 5px; color: transparent; background: rgba(0, 0, 0, 0.18); } +.acceptance > span .icon { width: 0.8rem; height: 0.8rem; stroke-width: 3; } +.acceptance input:checked + span { color: #07110d; border-color: var(--green); background: var(--green); } +.acceptance input:focus-visible + span { outline: 3px solid rgba(169, 245, 199, 0.25); } +.acceptance small { color: var(--muted); font-size: 0.67rem; line-height: 1.5; } +.large-actions { position: relative; display: grid; grid-template-columns: minmax(250px, 1fr) minmax(150px, 0.55fr); gap: 0.7rem; } +.large-actions button { min-height: 72px; display: flex; align-items: center; gap: 0.9rem; border-radius: 12px; padding: 0.85rem 1.15rem; cursor: pointer; transition: transform 150ms ease, filter 150ms ease, opacity 150ms ease; } +.large-actions button:not(:disabled):hover { transform: translateY(-2px); filter: brightness(1.05); } +.large-actions button:disabled { cursor: not-allowed; opacity: 0.36; } +.button-icon { width: 2.65rem; height: 2.65rem; display: grid; place-items: center; border-radius: 9px; } +.button-icon .icon { width: 1.4rem; height: 1.4rem; } +.large-actions button > span:last-child { display: flex; flex-direction: column; gap: 0.2rem; text-align: left; } +.large-actions b { font-size: 0.86rem; } +.large-actions small { font-size: 0.61rem; opacity: 0.67; } +.start-button { border: 1px solid #c1f7d4; color: #07130d; background: var(--green); box-shadow: 0 12px 32px rgba(86, 217, 137, 0.13); } +.start-button .button-icon { color: var(--green); background: #10271c; } +.stop-button { border: 1px solid rgba(255, 142, 130, 0.22); color: #efb6af; background: rgba(255, 142, 130, 0.055); } +.stop-button .button-icon { color: var(--red); background: rgba(255, 142, 130, 0.09); } +.notice { grid-column: 1 / -1; position: relative; margin-top: -0.5rem; padding: 0.75rem 1rem; border-radius: 8px; font-size: 0.72rem; } +.notice.success { color: var(--green); background: rgba(86, 217, 137, 0.08); border: 1px solid rgba(86, 217, 137, 0.14); } +.notice.error { color: #ffc0b8; background: rgba(255, 105, 90, 0.08); border: 1px solid rgba(255, 105, 90, 0.14); } + +.ready-card { display: grid; grid-template-columns: auto 1fr auto; gap: 1.2rem; align-items: center; margin: 1rem 0 0; padding: 1.5rem 2rem; border: 1px solid rgba(169, 245, 199, 0.22); border-radius: 16px; background: linear-gradient(110deg, rgba(23, 59, 43, 0.55), rgba(11, 24, 19, 0.9)); } +.ready-icon { width: 3rem; height: 3rem; display: grid; place-items: center; border-radius: 50%; color: #08120d; background: var(--green); } +.ready-card .eyebrow { margin-bottom: 0.25rem; } +.ready-card h2 { margin: 0; font-family: Georgia, serif; font-size: 1.4rem; font-weight: 400; } +.ready-card p:not(.eyebrow) { max-width: 730px; margin: 0.35rem 0; color: var(--muted); font-size: 0.76rem; line-height: 1.6; } +.ready-card code { padding: 0.12rem 0.3rem; border-radius: 4px; color: var(--green); background: rgba(0, 0, 0, 0.28); } +.ready-card a { font-size: 0.7rem; font-weight: 700; text-decoration: none; } +.auth-ticket { min-width: 180px; padding: 0.9rem 1.2rem; border: 1px dashed rgba(169, 245, 199, 0.35); border-radius: 10px; text-align: center; background: rgba(2, 8, 6, 0.28); } +.auth-ticket span { display: block; color: var(--faint); font-size: 0.55rem; text-transform: uppercase; letter-spacing: 0.14em; } +.auth-ticket strong { display: block; margin-top: 0.3rem; color: var(--green); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 1.3rem; letter-spacing: 0.18em; } + +footer { min-height: 120px; display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 1rem; padding: 2rem 0; color: var(--faint); font-size: 0.63rem; } +footer p { margin: 0; } + +@media (max-width: 1120px) { + .configuration-grid { grid-template-columns: 1fr; } + .control-deck { grid-template-columns: 1fr 1fr; } + .large-actions { grid-column: 1 / -1; } +} + +@media (max-width: 760px) { + main { padding-inline: 1rem; } + .topbar { min-height: 72px; } + .status-pill { padding-inline: 0.7rem; } + .refresh-button { display: none; } + .hero { min-height: auto; grid-template-columns: 1fr; padding: 3.5rem 0 3rem; } + .hero-orbit { display: none; } + .hero h1 { font-size: clamp(3.2rem, 15vw, 5rem); } + .config-card { padding: 1.3rem; } + .provider-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .field-grid { grid-template-columns: 1fr; } + .field-wide { grid-column: auto; } + .control-deck { grid-template-columns: 1fr; padding: 1.3rem; } + .large-actions { grid-column: auto; grid-template-columns: 1fr; } + .ready-card { grid-template-columns: auto 1fr; padding: 1.3rem; } + .auth-ticket { grid-column: 1 / -1; } + footer { grid-template-columns: auto 1fr; } + footer > span:last-child { display: none; } +} + +@media (max-width: 430px) { + .brand > span:last-child { display: none; } + .channel-options { grid-template-columns: 1fr; } + .channel-option:last-child:nth-child(odd) { grid-column: auto; } + .provider-grid { grid-template-columns: 1fr 1fr; } + .card-heading { grid-template-columns: 3rem 1fr; } + .heading-icon { width: 3rem; height: 3rem; } + .inline-control { flex-direction: column; } + .inline-control button { min-height: 2.5rem; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; } +} diff --git a/control-ui/vite.config.js b/control-ui/vite.config.js new file mode 100644 index 00000000..dd6b2512 --- /dev/null +++ b/control-ui/vite.config.js @@ -0,0 +1,12 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + port: 3210, + proxy: { + "/api": "http://127.0.0.1:3211", + }, + }, +}); diff --git a/scripts/start-control-ui b/scripts/start-control-ui new file mode 100755 index 00000000..2b9fc065 --- /dev/null +++ b/scripts/start-control-ui @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +project_dir="$(cd -- "${script_dir}/.." && pwd)" +control_port="${OMEGACLAW_CONTROL_PORT:-3210}" +control_url="http://localhost:${control_port}" + +cd -- "$project_dir" +docker compose up --detach --build control + +echo "Waiting for Omega Control at ${control_url} ..." +for _ in $(seq 1 60); do + health="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' omegaclaw-control 2>/dev/null || true)" + if [ "$health" = "healthy" ]; then + break + fi + sleep 1 +done + +health="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' omegaclaw-control 2>/dev/null || true)" +if [ "$health" != "healthy" ]; then + echo "Omega Control did not become healthy. Run: docker compose logs control" >&2 + exit 1 +fi + +echo "Opening ${control_url}" +if command -v open >/dev/null 2>&1; then + open "$control_url" +elif command -v xdg-open >/dev/null 2>&1; then + xdg-open "$control_url" >/dev/null 2>&1 & +elif command -v cmd.exe >/dev/null 2>&1; then + cmd.exe /c start "" "$control_url" >/dev/null 2>&1 +else + echo "Open ${control_url} in your browser." +fi diff --git a/scripts/start-control-ui.ps1 b/scripts/start-control-ui.ps1 new file mode 100644 index 00000000..2ec2c8b2 --- /dev/null +++ b/scripts/start-control-ui.ps1 @@ -0,0 +1,30 @@ +$ErrorActionPreference = "Stop" + +$ProjectDirectory = Split-Path -Parent $PSScriptRoot +$ControlPort = if ($env:OMEGACLAW_CONTROL_PORT) { $env:OMEGACLAW_CONTROL_PORT } else { "3210" } +$ControlUrl = "http://localhost:$ControlPort" + +Push-Location $ProjectDirectory +try { + docker compose up --detach --build control + if ($LASTEXITCODE -ne 0) { throw "Docker Compose could not start Omega Control." } + + Write-Host "Waiting for Omega Control at $ControlUrl ..." + $Healthy = $false + for ($Attempt = 0; $Attempt -lt 60; $Attempt++) { + $Health = docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' omegaclaw-control 2>$null + if ($Health -eq "healthy") { + $Healthy = $true + break + } + Start-Sleep -Seconds 1 + } + + if (-not $Healthy) { + throw "Omega Control did not become healthy. Run: docker compose logs control" + } + + Start-Process $ControlUrl +} finally { + Pop-Location +}