diff --git a/README.md b/README.md index 9b12e558b..c8c670b82 100644 --- a/README.md +++ b/README.md @@ -22,13 +22,14 @@ WhatsApp / Telegram │ ▼ ┌──────────────────────────┐ - │ Gateway │ ws://127.0.0.1:18789 (loopback-only) + │ Gateway │ ws://127.0.0.1:18789 (default: loopback) + │ (control UI) │ http://127.0.0.1:18789/ui/ │ (single source) │ tcp://0.0.0.0:18790 (optional Bridge) └───────────┬───────────────┘ │ ├─ Pi agent (RPC) ├─ CLI (clawdis …) - ├─ WebChat (loopback UI) + ├─ Control UI (browser) ├─ macOS app (Clawdis.app) └─ iOS node via Bridge + pairing ``` @@ -60,7 +61,9 @@ Only the Pi CLI is supported now; legacy Claude/Codex/Gemini paths have been rem ## Network model (the “new reality”) - **One Gateway per host**. The Gateway is the only process allowed to own the WhatsApp Web session. -- **Loopback-first**: the Gateway WebSocket listens on `ws://127.0.0.1:18789` and is not exposed on the LAN. +- **Loopback-first**: the Gateway WebSocket listens on `ws://127.0.0.1:18789` by default. + - To expose it on your tailnet, set `gateway.bind: "tailnet"` (or run `clawdis gateway --bind tailnet`) and set `CLAWDIS_GATEWAY_TOKEN` (required for non-loopback binds). + - The browser Control UI is served from the Gateway at `http://:18789/ui/` when assets are built. - **Bridge for nodes**: when enabled, the Gateway also exposes a bridge on `tcp://0.0.0.0:18790` for paired nodes (Bonjour-discoverable). For tailnet-only setups, set `bridge.bind: "tailnet"` in `~/.clawdis/clawdis.json`. - **Remote control**: use a VPN/tailnet or an SSH tunnel (`ssh -N -L 18789:127.0.0.1:18789 user@host`). The macOS app can drive this flow. - **Wide-Area Bonjour (optional)**: for auto-discovery across networks (Vienna ⇄ London) over Tailscale, use unicast DNS-SD on `clawdis.internal.`; see `docs/bonjour.md`. @@ -79,6 +82,7 @@ Runtime requirement: **Node ≥22.0.0** (not bundled). The macOS app and CLI bot # From source (recommended while the npm package is still settling) pnpm install pnpm build +pnpm ui:build # Link your WhatsApp (stores creds under ~/.clawdis/credentials) pnpm clawdis login @@ -86,6 +90,9 @@ pnpm clawdis login # Start the gateway (WebSocket control plane) pnpm clawdis gateway --port 18789 --verbose +# Open the browser Control UI (after ui:build) +# http://127.0.0.1:18789/ui/ + # Send a WhatsApp message (WhatsApp sends go through the Gateway) pnpm clawdis send --to +1234567890 --message "Hello from the CLAWDIS!" @@ -156,6 +163,7 @@ Optional: enable/configure clawd’s dedicated browser control (defaults are alr - [Configuration Guide](./docs/configuration.md) - [Gateway runbook](./docs/gateway.md) +- [Web surfaces (Control UI)](./docs/web.md) - [Discovery + transports](./docs/discovery.md) - [Bonjour / mDNS + Wide-Area Bonjour](./docs/bonjour.md) - [Agent Runtime](./docs/agent.md) @@ -197,14 +205,13 @@ Bot-mode support (grammY only) shares the same `main` session as WhatsApp/WebCha | `clawdis send` | Send a message (WhatsApp default; `--provider telegram` for bot mode). WhatsApp sends go via the Gateway WS; Telegram sends are direct. | | `clawdis agent` | Talk directly to the agent (no WhatsApp send) | | `clawdis browser ...` | Manage clawd’s dedicated browser (status/tabs/open/screenshot). | -| `clawdis gateway` | Start the Gateway server (WS control plane). Params: `--port`, `--token`, `--force`, `--verbose`. | +| `clawdis gateway` | Start the Gateway server (WS control plane). Params: `--port`, `--bind`, `--token`, `--force`, `--verbose`. | | `clawdis gateway health|status|send|agent|call` | Gateway WS clients; assume a running gateway. | | `clawdis wake` | Enqueue a system event and optionally trigger a heartbeat via the Gateway. | | `clawdis cron ...` | Manage scheduled jobs (via Gateway). | | `clawdis nodes ...` | Manage nodes (pairing + status) via the Gateway. | | `clawdis status` | Web session health + session store summary | | `clawdis health` | Reports cached provider state from the running gateway. | -| `clawdis webchat` | Start the loopback-only WebChat HTTP server | #### Gateway client params (WS only) - `--url` (default `ws://127.0.0.1:18789`) diff --git a/docs/control-ui.md b/docs/control-ui.md new file mode 100644 index 000000000..35f0f1d91 --- /dev/null +++ b/docs/control-ui.md @@ -0,0 +1,51 @@ +--- +summary: "Browser-based control UI for the Gateway (chat, nodes, config)" +read_when: + - You want to operate the Gateway from a browser + - You want Tailnet access without SSH tunnels +--- +# Control UI (browser) + +The Control UI is a small **Vite + Lit** single-page app served by the Gateway under: + +- `http://:18789/ui/` + +It speaks **directly to the Gateway WebSocket** on the same port. + +## What it can do (today) +- Chat with the model via Gateway WS (`chat.history`, `chat.send`, `chat.abort`) +- List nodes via Gateway WS (`node.list`) +- View/edit `~/.clawdis/clawdis.json` via Gateway WS (`config.get`, `config.set`) + +## Tailnet access (recommended) + +Expose the Gateway on your Tailscale interface and require a token: + +```bash +clawdis gateway --bind tailnet --token "$(openssl rand -hex 32)" +``` + +Then open: + +- `http://:18789/ui/` + +Paste the token into the UI settings (it’s sent as `connect.params.auth.token`). + +## Building the UI + +The Gateway serves static files from `dist/control-ui`. Build them with: + +```bash +pnpm ui:install +pnpm ui:build +``` + +For local development (separate dev server): + +```bash +pnpm ui:install +pnpm ui:dev +``` + +Then point the UI at your Gateway WS URL (e.g. `ws://127.0.0.1:18789`). + diff --git a/docs/index.md b/docs/index.md index 5df8cc52e..be4cf6434 100644 --- a/docs/index.md +++ b/docs/index.md @@ -50,7 +50,8 @@ Most operations flow through the **Gateway** (`clawdis gateway`), a single long- ## Network model - **One Gateway per host**: it is the only process allowed to own the WhatsApp Web session. -- **Loopback-first**: Gateway WS is `ws://127.0.0.1:18789` (not exposed on the LAN). +- **Loopback-first**: Gateway WS defaults to `ws://127.0.0.1:18789`. + - For Tailnet access, run `clawdis gateway --bind tailnet --token ...` (token is required for non-loopback binds). - **Bridge for nodes**: optional LAN/tailnet-facing bridge on `tcp://0.0.0.0:18790` for paired nodes (Bonjour-discoverable). - **Canvas host**: LAN/tailnet HTTP file server (default `18793`) for node WebViews; see `docs/configuration.md` (`canvasHost`). - **Remote use**: SSH tunnel or tailnet/VPN; see `docs/remote.md` and `docs/discovery.md`. @@ -117,10 +118,12 @@ Example: - [Clawd personal assistant setup](./clawd.md) - [AGENTS.md template (default)](./AGENTS.default.md) - [Gateway runbook](./gateway.md) + - [Web surfaces (Control UI)](./web.md) - [Discovery + transports](./discovery.md) - [Remote access](./remote.md) - Providers and UX: - [WebChat](./webchat.md) + - [Control UI (browser)](./control-ui.md) - [Telegram](./telegram.md) - [Group messages](./group-messages.md) - [Media: images](./images.md) diff --git a/docs/web.md b/docs/web.md new file mode 100644 index 000000000..d8ebfd293 --- /dev/null +++ b/docs/web.md @@ -0,0 +1,75 @@ +--- +summary: "Gateway web surfaces: Control UI, bind modes, and security" +read_when: + - You want to access the Gateway over Tailscale + - You want the browser Control UI and config editing +--- +# Web (Gateway) + +The Gateway serves a small **browser Control UI** (Vite + Lit) from the same port as the Gateway WebSocket: + +- `http://:18789/ui/` + +The UI talks directly to the Gateway WS and supports: +- Chat (`chat.history`, `chat.send`, `chat.abort`) +- Nodes (`node.list`, `node.describe`, `node.invoke`) +- Config (`config.get`, `config.set`) for `~/.clawdis/clawdis.json` + +## Config (default-on) + +The Control UI is **enabled by default** when assets are present (`dist/control-ui`). +You can control it via config: + +```json5 +{ + gateway: { + controlUi: { enabled: true } // set false to disable /ui/ + } +} +``` + +## Tailnet access + +To access the UI across Tailscale, bind the Gateway to the Tailnet interface and require a token. + +### Via config (recommended) + +```json5 +{ + gateway: { + bind: "tailnet", + controlUi: { enabled: true } + } +} +``` + +Then start the gateway (token required for non-loopback binds): + +```bash +export CLAWDIS_GATEWAY_TOKEN="…your token…" +clawdis gateway +``` + +Open: +- `http://:18789/ui/` + +### Via CLI (one-off) + +```bash +clawdis gateway --bind tailnet --token "…your token…" +``` + +## Security notes + +- Binding the Gateway to a non-loopback address **requires** `CLAWDIS_GATEWAY_TOKEN`. +- The token is sent as `connect.params.auth.token` by the UI and other clients. + +## Building the UI + +The Gateway serves static files from `dist/control-ui`. Build them with: + +```bash +pnpm ui:install +pnpm ui:build +``` + diff --git a/package.json b/package.json index 2d10db5c3..bce3432f7 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,9 @@ "dev": "tsx src/index.ts", "docs:list": "tsx scripts/docs-list.ts", "build": "tsc -p tsconfig.json", + "ui:install": "pnpm -C ui install", + "ui:dev": "pnpm -C ui dev", + "ui:build": "pnpm -C ui build", "start": "tsx src/index.ts", "clawdis": "tsx src/index.ts", "clawdis:rpc": "tsx src/index.ts agent --mode rpc --json", diff --git a/src/cli/gateway-cli.coverage.test.ts b/src/cli/gateway-cli.coverage.test.ts index fb00e9280..f43f8f1a9 100644 --- a/src/cli/gateway-cli.coverage.test.ts +++ b/src/cli/gateway-cli.coverage.test.ts @@ -26,7 +26,8 @@ vi.mock("../gateway/call.js", () => ({ })); vi.mock("../gateway/server.js", () => ({ - startGatewayServer: (port: number) => startGatewayServer(port), + startGatewayServer: (port: number, opts?: unknown) => + startGatewayServer(port, opts), })); vi.mock("../globals.js", () => ({ diff --git a/src/cli/gateway-cli.ts b/src/cli/gateway-cli.ts index 65e054e19..2f53d1ea6 100644 --- a/src/cli/gateway-cli.ts +++ b/src/cli/gateway-cli.ts @@ -1,4 +1,5 @@ import type { Command } from "commander"; +import { loadConfig } from "../config/config.js"; import { callGateway, randomIdempotencyKey } from "../gateway/call.js"; import { startGatewayServer } from "../gateway/server.js"; import { @@ -46,6 +47,10 @@ export function registerGatewayCli(program: Command) { .command("gateway") .description("Run the WebSocket Gateway") .option("--port ", "Port for the gateway WebSocket", "18789") + .option( + "--bind ", + 'Bind mode ("loopback"|"tailnet"|"lan"|"auto"). Defaults to config gateway.bind (or loopback).', + ) .option( "--token ", "Shared token required in connect.params.auth.token (default: CLAWDIS_GATEWAY_TOKEN env if set)", @@ -115,6 +120,22 @@ export function registerGatewayCli(program: Command) { if (opts.token) { process.env.CLAWDIS_GATEWAY_TOKEN = String(opts.token); } + const cfg = loadConfig(); + const bindRaw = String(opts.bind ?? cfg.gateway?.bind ?? "loopback"); + const bind = + bindRaw === "loopback" || + bindRaw === "tailnet" || + bindRaw === "lan" || + bindRaw === "auto" + ? bindRaw + : null; + if (!bind) { + defaultRuntime.error( + 'Invalid --bind (use "loopback", "tailnet", "lan", or "auto")', + ); + defaultRuntime.exit(1); + return; + } let server: Awaited> | null = null; let shuttingDown = false; @@ -161,7 +182,7 @@ export function registerGatewayCli(program: Command) { process.once("SIGINT", onSigint); try { - server = await startGatewayServer(port); + server = await startGatewayServer(port, { bind }); } catch (err) { if (err instanceof GatewayLockError) { defaultRuntime.error(`Gateway failed to start: ${err.message}`); diff --git a/src/config/config.ts b/src/config/config.ts index 0ab587981..adead7b83 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -101,6 +101,20 @@ export type CanvasHostConfig = { port?: number; }; +export type GatewayControlUiConfig = { + /** If false, the Gateway will not serve the Control UI under /ui/. Default: true. */ + enabled?: boolean; +}; + +export type GatewayConfig = { + /** + * Bind address policy for the Gateway WebSocket + Control UI HTTP server. + * Default: loopback (127.0.0.1). + */ + bind?: BridgeBindMode; + controlUi?: GatewayControlUiConfig; +}; + export type ClawdisConfig = { identity?: { name?: string; @@ -148,6 +162,7 @@ export type ClawdisConfig = { bridge?: BridgeConfig; discovery?: DiscoveryConfig; canvasHost?: CanvasHostConfig; + gateway?: GatewayConfig; }; // New branding path (preferred) @@ -311,8 +326,40 @@ const ClawdisSchema = z.object({ port: z.number().int().positive().optional(), }) .optional(), + gateway: z + .object({ + bind: z + .union([ + z.literal("auto"), + z.literal("lan"), + z.literal("tailnet"), + z.literal("loopback"), + ]) + .optional(), + controlUi: z + .object({ + enabled: z.boolean().optional(), + }) + .optional(), + }) + .optional(), }); +export type ConfigValidationIssue = { + path: string; + message: string; +}; + +export type ConfigFileSnapshot = { + path: string; + exists: boolean; + raw: string | null; + parsed: unknown; + valid: boolean; + config: ClawdisConfig; + issues: ConfigValidationIssue[]; +}; + function escapeRegExp(text: string): string { return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } @@ -371,3 +418,106 @@ export function loadConfig(): ClawdisConfig { return {}; } } + +export function validateConfigObject( + raw: unknown, +): + | { ok: true; config: ClawdisConfig } + | { ok: false; issues: ConfigValidationIssue[] } { + const validated = ClawdisSchema.safeParse(raw); + if (!validated.success) { + return { + ok: false, + issues: validated.error.issues.map((iss) => ({ + path: iss.path.join("."), + message: iss.message, + })), + }; + } + return { ok: true, config: applyIdentityDefaults(validated.data) }; +} + +export function parseConfigJson5( + raw: string, +): { ok: true; parsed: unknown } | { ok: false; error: string } { + try { + return { ok: true, parsed: JSON5.parse(raw) as unknown }; + } catch (err) { + return { ok: false, error: String(err) }; + } +} + +export async function readConfigFileSnapshot(): Promise { + const configPath = CONFIG_PATH_CLAWDIS; + const exists = fs.existsSync(configPath); + if (!exists) { + return { + path: configPath, + exists: false, + raw: null, + parsed: {}, + valid: true, + config: {}, + issues: [], + }; + } + + try { + const raw = fs.readFileSync(configPath, "utf-8"); + const parsedRes = parseConfigJson5(raw); + if (!parsedRes.ok) { + return { + path: configPath, + exists: true, + raw, + parsed: {}, + valid: false, + config: {}, + issues: [ + { path: "", message: `JSON5 parse failed: ${parsedRes.error}` }, + ], + }; + } + + const validated = validateConfigObject(parsedRes.parsed); + if (!validated.ok) { + return { + path: configPath, + exists: true, + raw, + parsed: parsedRes.parsed, + valid: false, + config: {}, + issues: validated.issues, + }; + } + + return { + path: configPath, + exists: true, + raw, + parsed: parsedRes.parsed, + valid: true, + config: validated.config, + issues: [], + }; + } catch (err) { + return { + path: configPath, + exists: true, + raw: null, + parsed: {}, + valid: false, + config: {}, + issues: [{ path: "", message: `read failed: ${String(err)}` }], + }; + } +} + +export async function writeConfigFile(cfg: ClawdisConfig) { + await fs.promises.mkdir(path.dirname(CONFIG_PATH_CLAWDIS), { + recursive: true, + }); + const json = JSON.stringify(cfg, null, 2).trimEnd().concat("\n"); + await fs.promises.writeFile(CONFIG_PATH_CLAWDIS, json, "utf-8"); +} diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts new file mode 100644 index 000000000..1268b509c --- /dev/null +++ b/src/gateway/control-ui.ts @@ -0,0 +1,136 @@ +import fs from "node:fs"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const UI_PREFIX = "/ui/"; + +function resolveControlUiRoot(): string | null { + const here = path.dirname(fileURLToPath(import.meta.url)); + const candidates = [ + // Running from dist: dist/gateway/control-ui.js -> dist/control-ui + path.resolve(here, "../control-ui"), + // Running from source: src/gateway/control-ui.ts -> dist/control-ui + path.resolve(here, "../../dist/control-ui"), + // Fallback to cwd (dev) + path.resolve(process.cwd(), "dist", "control-ui"), + ]; + for (const dir of candidates) { + if (fs.existsSync(path.join(dir, "index.html"))) return dir; + } + return null; +} + +function contentTypeForExt(ext: string): string { + switch (ext) { + case ".html": + return "text/html; charset=utf-8"; + case ".js": + return "application/javascript; charset=utf-8"; + case ".css": + return "text/css; charset=utf-8"; + case ".json": + case ".map": + return "application/json; charset=utf-8"; + case ".svg": + return "image/svg+xml"; + case ".png": + return "image/png"; + case ".jpg": + case ".jpeg": + return "image/jpeg"; + case ".ico": + return "image/x-icon"; + case ".txt": + return "text/plain; charset=utf-8"; + default: + return "application/octet-stream"; + } +} + +function respondNotFound(res: ServerResponse) { + res.statusCode = 404; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("Not Found"); +} + +function serveFile(res: ServerResponse, filePath: string) { + const ext = path.extname(filePath).toLowerCase(); + res.setHeader("Content-Type", contentTypeForExt(ext)); + // Static UI should never be cached aggressively while iterating; allow the + // browser to revalidate. + res.setHeader("Cache-Control", "no-cache"); + res.end(fs.readFileSync(filePath)); +} + +function isSafeRelativePath(relPath: string) { + if (!relPath) return false; + const normalized = path.posix.normalize(relPath); + if (normalized.startsWith("../") || normalized === "..") return false; + if (normalized.includes("\0")) return false; + return true; +} + +export function handleControlUiHttpRequest( + req: IncomingMessage, + res: ServerResponse, +): boolean { + const urlRaw = req.url; + if (!urlRaw) return false; + if (req.method !== "GET" && req.method !== "HEAD") { + res.statusCode = 405; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("Method Not Allowed"); + return true; + } + + const url = new URL(urlRaw, "http://localhost"); + + if (url.pathname === "/ui") { + res.statusCode = 302; + res.setHeader("Location", UI_PREFIX); + res.end(); + return true; + } + + if (!url.pathname.startsWith(UI_PREFIX)) return false; + + const root = resolveControlUiRoot(); + if (!root) { + res.statusCode = 503; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end( + "Control UI assets not found. Build them with `pnpm ui:build` (or run `pnpm ui:dev` during development).", + ); + return true; + } + + const rel = url.pathname.slice(UI_PREFIX.length); + const requested = rel && !rel.endsWith("/") ? rel : `${rel}index.html`; + const fileRel = requested || "index.html"; + if (!isSafeRelativePath(fileRel)) { + respondNotFound(res); + return true; + } + + const filePath = path.join(root, fileRel); + if (!filePath.startsWith(root)) { + respondNotFound(res); + return true; + } + + if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { + serveFile(res, filePath); + return true; + } + + // SPA fallback (client-side router): serve index.html for unknown paths. + const indexPath = path.join(root, "index.html"); + if (fs.existsSync(indexPath)) { + serveFile(res, indexPath); + return true; + } + + respondNotFound(res); + return true; +} diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index 3e31ff1b5..33c8f3ee2 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -9,6 +9,10 @@ import { ChatEventSchema, ChatHistoryParamsSchema, ChatSendParamsSchema, + type ConfigGetParams, + ConfigGetParamsSchema, + type ConfigSetParams, + ConfigSetParamsSchema, type ConnectParams, ConnectParamsSchema, type CronAddParams, @@ -125,6 +129,12 @@ export const validateSessionsListParams = ajv.compile( export const validateSessionsPatchParams = ajv.compile( SessionsPatchParamsSchema, ); +export const validateConfigGetParams = ajv.compile( + ConfigGetParamsSchema, +); +export const validateConfigSetParams = ajv.compile( + ConfigSetParamsSchema, +); export const validateCronListParams = ajv.compile(CronListParamsSchema); export const validateCronStatusParams = ajv.compile( @@ -181,6 +191,8 @@ export { NodeInvokeParamsSchema, SessionsListParamsSchema, SessionsPatchParamsSchema, + ConfigGetParamsSchema, + ConfigSetParamsSchema, CronJobSchema, CronListParamsSchema, CronStatusParamsSchema, @@ -218,6 +230,8 @@ export type { NodePairRequestParams, NodePairListParams, NodePairApproveParams, + ConfigGetParams, + ConfigSetParams, NodePairRejectParams, NodePairVerifyParams, NodeListParams, diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index e78c68cf2..aab7c0371 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -291,6 +291,18 @@ export const SessionsPatchParamsSchema = Type.Object( { additionalProperties: false }, ); +export const ConfigGetParamsSchema = Type.Object( + {}, + { additionalProperties: false }, +); + +export const ConfigSetParamsSchema = Type.Object( + { + raw: NonEmptyString, + }, + { additionalProperties: false }, +); + export const CronScheduleSchema = Type.Union([ Type.Object( { @@ -541,6 +553,8 @@ export const ProtocolSchemas: Record = { NodeInvokeParams: NodeInvokeParamsSchema, SessionsListParams: SessionsListParamsSchema, SessionsPatchParams: SessionsPatchParamsSchema, + ConfigGetParams: ConfigGetParamsSchema, + ConfigSetParams: ConfigSetParamsSchema, CronJob: CronJobSchema, CronListParams: CronListParamsSchema, CronStatusParams: CronStatusParamsSchema, @@ -582,6 +596,8 @@ export type NodeDescribeParams = Static; export type NodeInvokeParams = Static; export type SessionsListParams = Static; export type SessionsPatchParams = Static; +export type ConfigGetParams = Static; +export type ConfigSetParams = Static; export type CronJob = Static; export type CronListParams = Static; export type CronStatusParams = Static; diff --git a/src/gateway/server.ts b/src/gateway/server.ts index df00274ae..ccc62ca59 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -26,7 +26,15 @@ import { createDefaultDeps } from "../cli/deps.js"; import { agentCommand } from "../commands/agent.js"; import { getHealthSnapshot, type HealthSummary } from "../commands/health.js"; import { getStatusSummary } from "../commands/status.js"; -import { type ClawdisConfig, loadConfig } from "../config/config.js"; +import { + type ClawdisConfig, + CONFIG_PATH_CLAWDIS, + loadConfig, + parseConfigJson5, + readConfigFileSnapshot, + validateConfigObject, + writeConfigFile, +} from "../config/config.js"; import { loadSessionStore, resolveStorePath, @@ -90,6 +98,7 @@ import { setHeartbeatsEnabled } from "../web/auto-reply.js"; import { sendMessageWhatsApp } from "../web/outbound.js"; import { requestReplyHeartbeatNow } from "../web/reply-heartbeat-wake.js"; import { buildMessageWithAttachments } from "./chat-attachments.js"; +import { handleControlUiHttpRequest } from "./control-ui.js"; import { type ConnectParams, ErrorCodes, @@ -105,6 +114,8 @@ import { validateChatAbortParams, validateChatHistoryParams, validateChatSendParams, + validateConfigGetParams, + validateConfigSetParams, validateConnectParams, validateCronAddParams, validateCronListParams, @@ -183,6 +194,8 @@ type SessionsPatchResult = { const METHODS = [ "health", "status", + "config.get", + "config.set", "voicewake.get", "voicewake.set", "sessions.list", @@ -233,6 +246,27 @@ export type GatewayServer = { close: () => Promise; }; +export type GatewayServerOptions = { + /** + * Bind address policy for the Gateway WebSocket/HTTP server. + * - loopback: 127.0.0.1 + * - lan: 0.0.0.0 + * - tailnet: bind only to the Tailscale IPv4 address (100.64.0.0/10) + * - auto: prefer tailnet, else LAN + */ + bind?: import("../config/config.js").BridgeBindMode; + /** + * Advanced override for the bind host, bypassing bind resolution. + * Prefer `bind` unless you really need a specific address. + */ + host?: string; + /** + * If false, do not serve the browser Control UI under /ui/. + * Default: config `gateway.controlUi.enabled` (or true when absent). + */ + controlUiEnabled?: boolean; +}; + function isLoopbackAddress(ip: string | undefined): boolean { if (!ip) return false; if (ip === "127.0.0.1") return true; @@ -242,6 +276,21 @@ function isLoopbackAddress(ip: string | undefined): boolean { return false; } +function resolveGatewayBindHost( + bind: import("../config/config.js").BridgeBindMode | undefined, +): string | null { + const mode = bind ?? "loopback"; + if (mode === "loopback") return "127.0.0.1"; + if (mode === "lan") return "0.0.0.0"; + if (mode === "tailnet") return pickPrimaryTailnetIPv4() ?? null; + if (mode === "auto") return pickPrimaryTailnetIPv4() ?? "0.0.0.0"; + return "127.0.0.1"; +} + +function isLoopbackHost(host: string): boolean { + return isLoopbackAddress(host); +} + let presenceVersion = 1; let healthVersion = 1; let healthCache: HealthSummary | null = null; @@ -774,9 +823,44 @@ async function refreshHealthSnapshot(_opts?: { probe?: boolean }) { return healthRefresh; } -export async function startGatewayServer(port = 18789): Promise { - const host = "127.0.0.1"; - const httpServer: HttpServer = createHttpServer(); +export async function startGatewayServer( + port = 18789, + opts: GatewayServerOptions = {}, +): Promise { + const cfgForServer = loadConfig(); + const bindMode = opts.bind ?? cfgForServer.gateway?.bind ?? "loopback"; + const bindHost = opts.host ?? resolveGatewayBindHost(bindMode); + if (!bindHost) { + throw new Error( + "gateway bind is tailnet, but no tailnet interface was found; refusing to start gateway", + ); + } + const controlUiEnabled = + opts.controlUiEnabled ?? cfgForServer.gateway?.controlUi?.enabled ?? true; + if (!isLoopbackHost(bindHost) && !getGatewayToken()) { + throw new Error( + `refusing to bind gateway to ${bindHost}:${port} without CLAWDIS_GATEWAY_TOKEN`, + ); + } + + const httpServer: HttpServer = createHttpServer((req, res) => { + // Don't interfere with WebSocket upgrades; ws handles the 'upgrade' event. + if (String(req.headers.upgrade ?? "").toLowerCase() === "websocket") return; + + if (controlUiEnabled) { + if (req.url === "/") { + res.statusCode = 302; + res.setHeader("Location", "/ui/"); + res.end(); + return; + } + if (handleControlUiHttpRequest(req, res)) return; + } + + res.statusCode = 404; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("Not Found"); + }); let bonjourStop: (() => Promise) | null = null; let bridge: Awaited> | null = null; let canvasHost: CanvasHostServer | null = null; @@ -794,18 +878,18 @@ export async function startGatewayServer(port = 18789): Promise { }; httpServer.once("error", onError); httpServer.once("listening", onListening); - httpServer.listen(port, host); + httpServer.listen(port, bindHost); }); } catch (err) { const code = (err as NodeJS.ErrnoException).code; if (code === "EADDRINUSE") { throw new GatewayLockError( - `another gateway instance is already listening on ws://${host}:${port}`, + `another gateway instance is already listening on ws://${bindHost}:${port}`, err, ); } throw new GatewayLockError( - `failed to bind gateway socket on ws://${host}:${port}: ${String(err)}`, + `failed to bind gateway socket on ws://${bindHost}:${port}: ${String(err)}`, err, ); } @@ -827,6 +911,7 @@ export async function startGatewayServer(port = 18789): Promise { { sessionKey: string; clientRunId: string } >(); const chatRunBuffers = new Map(); + const chatDeltaSentAt = new Map(); const chatAbortControllers = new Map< string, { controller: AbortController; sessionId: string; sessionKey: string } @@ -1171,6 +1256,63 @@ export async function startGatewayServer(port = 18789): Promise { const snap = await refreshHealthSnapshot({ probe: false }); return { ok: true, payloadJSON: JSON.stringify(snap) }; } + case "config.get": { + const params = parseParams(); + if (!validateConfigGetParams(params)) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: `invalid config.get params: ${formatValidationErrors(validateConfigGetParams.errors)}`, + }, + }; + } + const snapshot = await readConfigFileSnapshot(); + return { ok: true, payloadJSON: JSON.stringify(snapshot) }; + } + case "config.set": { + const params = parseParams(); + if (!validateConfigSetParams(params)) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: `invalid config.set params: ${formatValidationErrors(validateConfigSetParams.errors)}`, + }, + }; + } + const raw = String((params as { raw?: unknown }).raw ?? ""); + const parsedRes = parseConfigJson5(raw); + if (!parsedRes.ok) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: parsedRes.error, + }, + }; + } + const validated = validateConfigObject(parsedRes.parsed); + if (!validated.ok) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: "invalid config", + details: { issues: validated.issues }, + }, + }; + } + await writeConfigFile(validated.config); + return { + ok: true, + payloadJSON: JSON.stringify({ + ok: true, + path: CONFIG_PATH_CLAWDIS, + config: validated.config, + }), + }; + } case "sessions.list": { const params = parseParams(); if (!validateSessionsListParams(params)) { @@ -1366,6 +1508,7 @@ export async function startGatewayServer(port = 18789): Promise { active.controller.abort(); chatAbortControllers.delete(runId); chatRunBuffers.delete(runId); + chatDeltaSentAt.delete(runId); const current = chatRunSessions.get(active.sessionId); if ( current?.clientRunId === runId && @@ -1970,6 +2113,23 @@ export async function startGatewayServer(port = 18789): Promise { }; if (evt.stream === "assistant" && typeof evt.data?.text === "string") { chatRunBuffers.set(clientRunId, evt.data.text); + const now = Date.now(); + const last = chatDeltaSentAt.get(clientRunId) ?? 0; + // Throttle UI delta events so slow clients don't accumulate unbounded buffers. + if (now - last >= 150) { + chatDeltaSentAt.set(clientRunId, now); + const payload = { + ...base, + state: "delta" as const, + message: { + role: "assistant", + content: [{ type: "text", text: evt.data.text }], + timestamp: now, + }, + }; + broadcast("chat", payload, { dropIfSlow: true }); + bridgeSendToSession(sessionKey, "chat", payload); + } } else if ( evt.stream === "job" && typeof evt.data?.state === "string" && @@ -1977,6 +2137,7 @@ export async function startGatewayServer(port = 18789): Promise { ) { const text = chatRunBuffers.get(clientRunId)?.trim() ?? ""; chatRunBuffers.delete(clientRunId); + chatDeltaSentAt.delete(clientRunId); if (evt.data.state === "done") { const payload = { ...base, @@ -2457,6 +2618,7 @@ export async function startGatewayServer(port = 18789): Promise { active.controller.abort(); chatAbortControllers.delete(runId); chatRunBuffers.delete(runId); + chatDeltaSentAt.delete(runId); const current = chatRunSessions.get(active.sessionId); if ( current?.clientRunId === runId && @@ -2795,6 +2957,69 @@ export async function startGatewayServer(port = 18789): Promise { respond(true, status, undefined); break; } + case "config.get": { + const params = (req.params ?? {}) as Record; + if (!validateConfigGetParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid config.get params: ${formatValidationErrors(validateConfigGetParams.errors)}`, + ), + ); + break; + } + const snapshot = await readConfigFileSnapshot(); + respond(true, snapshot, undefined); + break; + } + case "config.set": { + const params = (req.params ?? {}) as Record; + if (!validateConfigSetParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid config.set params: ${formatValidationErrors(validateConfigSetParams.errors)}`, + ), + ); + break; + } + const raw = String((params as { raw?: unknown }).raw ?? ""); + const parsedRes = parseConfigJson5(raw); + if (!parsedRes.ok) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, parsedRes.error), + ); + break; + } + const validated = validateConfigObject(parsedRes.parsed); + if (!validated.ok) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "invalid config", { + details: { issues: validated.issues }, + }), + ); + break; + } + await writeConfigFile(validated.config); + respond( + true, + { + ok: true, + path: CONFIG_PATH_CLAWDIS, + config: validated.config, + }, + undefined, + ); + break; + } case "sessions.list": { const params = (req.params ?? {}) as Record; if (!validateSessionsListParams(params)) { @@ -3814,7 +4039,7 @@ export async function startGatewayServer(port = 18789): Promise { }); defaultRuntime.log( - `gateway listening on ws://127.0.0.1:${port} (PID ${process.pid})`, + `gateway listening on ws://${bindHost}:${port} (PID ${process.pid})`, ); defaultRuntime.log(`gateway log file: ${getResolvedLoggerSettings().file}`); diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 000000000..c6296e9f7 --- /dev/null +++ b/ui/index.html @@ -0,0 +1,14 @@ + + + + + + Clawdis Control + + + + + + + + diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 000000000..792b148f0 --- /dev/null +++ b/ui/package.json @@ -0,0 +1,17 @@ +{ + "name": "clawdis-control-ui", + "private": true, + "type": "module", + "scripts": { + "dev": "pnpm dlx vite@8.0.0-beta.3", + "build": "pnpm dlx vite@8.0.0-beta.3 build", + "preview": "pnpm dlx vite@8.0.0-beta.3 preview" + }, + "dependencies": { + "lit": "^3.3.1" + }, + "devDependencies": { + "typescript": "^5.9.3", + "vite": "8.0.0-beta.3" + } +} diff --git a/ui/src/main.ts b/ui/src/main.ts new file mode 100644 index 000000000..31d493921 --- /dev/null +++ b/ui/src/main.ts @@ -0,0 +1,3 @@ +import "./styles.css"; +import "./ui/app.ts"; + diff --git a/ui/src/styles.css b/ui/src/styles.css new file mode 100644 index 000000000..8f21e657e --- /dev/null +++ b/ui/src/styles.css @@ -0,0 +1,106 @@ +:root { + --bg: #0b0f19; + --panel: rgba(255, 255, 255, 0.06); + --panel2: rgba(255, 255, 255, 0.09); + --text: rgba(255, 255, 255, 0.92); + --muted: rgba(255, 255, 255, 0.65); + --border: rgba(255, 255, 255, 0.12); + --accent: #ff4500; + --danger: #ff4d4f; + --ok: #25d366; + --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + "Liberation Mono", "Courier New", monospace; + color-scheme: dark; +} + +html, +body { + height: 100%; +} + +body { + margin: 0; + font: 14px/1.35 system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, + "Apple Color Emoji", "Segoe UI Emoji"; + background: radial-gradient(1200px 800px at 25% 10%, #111b3a 0%, var(--bg) 55%) + fixed; + color: var(--text); +} + +a { + color: inherit; +} + +button, +input, +textarea, +select { + font: inherit; + color: inherit; +} + +.row { + display: flex; + gap: 12px; + align-items: center; +} + +.pill { + display: inline-flex; + align-items: center; + gap: 8px; + border: 1px solid var(--border); + padding: 6px 10px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.2); +} + +.btn { + border: 1px solid var(--border); + background: var(--panel); + padding: 7px 10px; + border-radius: 10px; + cursor: pointer; +} +.btn:hover { + background: var(--panel2); +} +.btn.primary { + border-color: rgba(255, 69, 0, 0.35); + background: rgba(255, 69, 0, 0.18); +} +.btn.danger { + border-color: rgba(255, 77, 79, 0.35); + background: rgba(255, 77, 79, 0.16); +} + +.field { + display: grid; + gap: 6px; +} +.field label { + color: var(--muted); + font-size: 12px; +} +.field input, +.field textarea, +.field select { + border: 1px solid var(--border); + background: rgba(0, 0, 0, 0.25); + border-radius: 10px; + padding: 8px 10px; + outline: none; +} +.field textarea { + font-family: var(--mono); + min-height: 220px; + resize: vertical; + white-space: pre; +} +.muted { + color: var(--muted); +} +.mono { + font-family: var(--mono); +} + diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts new file mode 100644 index 000000000..16678d855 --- /dev/null +++ b/ui/src/ui/app.ts @@ -0,0 +1,695 @@ +import { LitElement, css, html, nothing } from "lit"; +import { customElement, state } from "lit/decorators.js"; + +import { GatewayBrowserClient, type GatewayEventFrame } from "./gateway"; +import { loadSettings, saveSettings, type UiSettings } from "./storage"; + +type Tab = "chat" | "nodes" | "config"; + +@customElement("clawdis-app") +export class ClawdisApp extends LitElement { + static styles = css` + :host { + display: block; + height: 100%; + } + .shell { + height: 100%; + display: grid; + grid-template-rows: auto 1fr; + } + header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 14px 16px; + border-bottom: 1px solid var(--border); + background: rgba(0, 0, 0, 0.18); + backdrop-filter: blur(14px); + } + nav { + display: flex; + gap: 10px; + align-items: center; + } + .tab { + border: 1px solid transparent; + padding: 7px 10px; + border-radius: 10px; + cursor: pointer; + user-select: none; + color: var(--muted); + } + .tab.active { + color: var(--text); + border-color: rgba(255, 69, 0, 0.35); + background: rgba(255, 69, 0, 0.12); + } + main { + padding: 16px; + max-width: 1120px; + width: 100%; + margin: 0 auto; + } + .grid { + display: grid; + gap: 14px; + } + .card { + border: 1px solid var(--border); + background: var(--panel); + border-radius: 16px; + padding: 12px; + } + .statusDot { + width: 8px; + height: 8px; + border-radius: 999px; + background: var(--danger); + box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.3); + } + .statusDot.ok { + background: var(--ok); + } + .title { + font-weight: 650; + letter-spacing: 0.2px; + } + .split { + display: grid; + grid-template-columns: 1.2fr 0.8fr; + gap: 14px; + align-items: start; + } + @media (max-width: 900px) { + .split { + grid-template-columns: 1fr; + } + } + .messages { + display: grid; + gap: 10px; + max-height: 60vh; + overflow: auto; + padding: 8px; + } + .msg { + border: 1px solid var(--border); + background: rgba(0, 0, 0, 0.2); + border-radius: 14px; + padding: 10px 12px; + } + .msg .meta { + font-size: 12px; + color: var(--muted); + display: flex; + justify-content: space-between; + gap: 10px; + margin-bottom: 6px; + } + .msg.user { + border-color: rgba(255, 255, 255, 0.14); + } + .msg.assistant { + border-color: rgba(255, 69, 0, 0.25); + background: rgba(255, 69, 0, 0.08); + } + .compose { + display: grid; + gap: 10px; + } + .compose textarea { + min-height: 92px; + font-family: var(--mono); + } + .nodes { + display: grid; + gap: 10px; + } + .nodeRow { + display: grid; + gap: 6px; + padding: 10px; + border: 1px solid var(--border); + border-radius: 14px; + background: rgba(0, 0, 0, 0.18); + } + .nodeRow .top { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: center; + } + .chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + } + .chip { + font-size: 12px; + border: 1px solid var(--border); + border-radius: 999px; + padding: 4px 8px; + color: var(--muted); + background: rgba(0, 0, 0, 0.18); + } + .error { + color: var(--danger); + font-family: var(--mono); + white-space: pre-wrap; + } + `; + + @state() private settings: UiSettings = loadSettings(); + @state() private tab: Tab = "chat"; + @state() private connected = false; + @state() private hello: unknown = null; + @state() private lastError: string | null = null; + + @state() private sessionKey = this.settings.sessionKey; + @state() private chatLoading = false; + @state() private chatSending = false; + @state() private chatMessage = ""; + @state() private chatMessages: unknown[] = []; + @state() private chatStream: string | null = null; + @state() private chatRunId: string | null = null; + + @state() private nodesLoading = false; + @state() private nodes: Array> = []; + + @state() private configLoading = false; + @state() private configRaw = "{\n}\n"; + @state() private configValid: boolean | null = null; + @state() private configIssues: unknown[] = []; + @state() private configSaving = false; + + private client: GatewayBrowserClient | null = null; + + connectedCallback() { + super.connectedCallback(); + this.connect(); + } + + private connect() { + this.lastError = null; + this.hello = null; + this.connected = false; + + this.client?.stop(); + this.client = new GatewayBrowserClient({ + url: this.settings.gatewayUrl, + token: this.settings.token.trim() ? this.settings.token : undefined, + clientName: "clawdis-control-ui", + mode: "webchat", + onHello: (hello) => { + this.connected = true; + this.hello = hello; + void this.refreshActiveTab(); + }, + onClose: ({ code, reason }) => { + this.connected = false; + this.lastError = `disconnected (${code}): ${reason || "no reason"}`; + }, + onEvent: (evt) => this.onEvent(evt), + onGap: ({ expected, received }) => { + this.lastError = `event gap detected (expected seq ${expected}, got ${received}); refresh recommended`; + }, + }); + this.client.start(); + } + + private onEvent(evt: GatewayEventFrame) { + if (evt.event === "chat") { + const payload = evt.payload as + | { + runId: string; + sessionKey: string; + state: "delta" | "final" | "aborted" | "error"; + message?: unknown; + errorMessage?: string; + } + | undefined; + if (!payload) return; + if (payload.sessionKey !== this.sessionKey) return; + if (payload.runId && this.chatRunId && payload.runId !== this.chatRunId) + return; + + if (payload.state === "delta") { + this.chatStream = extractText(payload.message) ?? this.chatStream; + } else if (payload.state === "final") { + this.chatStream = null; + this.chatRunId = null; + void this.loadChatHistory(); + } else if (payload.state === "error") { + this.chatStream = null; + this.chatRunId = null; + this.lastError = payload.errorMessage ?? "chat error"; + } + } + } + + private async refreshActiveTab() { + if (this.tab === "chat") await this.loadChatHistory(); + if (this.tab === "nodes") await this.loadNodes(); + if (this.tab === "config") await this.loadConfig(); + } + + private async loadChatHistory() { + if (!this.client || !this.connected) return; + this.chatLoading = true; + this.lastError = null; + try { + const res = (await this.client.request("chat.history", { + sessionKey: this.sessionKey, + limit: 200, + })) as { messages?: unknown[] }; + this.chatMessages = Array.isArray(res.messages) ? res.messages : []; + } catch (err) { + this.lastError = String(err); + } finally { + this.chatLoading = false; + } + } + + private async sendChat() { + if (!this.client || !this.connected) return; + const msg = this.chatMessage.trim(); + if (!msg) return; + + this.chatSending = true; + this.lastError = null; + const runId = crypto.randomUUID(); + this.chatRunId = runId; + this.chatStream = ""; + try { + await this.client.request("chat.send", { + sessionKey: this.sessionKey, + message: msg, + deliver: false, + idempotencyKey: runId, + }); + this.chatMessage = ""; + // Final chat state will refresh history, but do an eager refresh in case + // the run completed without emitting a chat event (older gateways). + void this.loadChatHistory(); + } catch (err) { + this.chatRunId = null; + this.chatStream = null; + this.lastError = String(err); + } finally { + this.chatSending = false; + } + } + + private async loadNodes() { + if (!this.client || !this.connected) return; + this.nodesLoading = true; + this.lastError = null; + try { + const res = (await this.client.request("node.list", {})) as { + nodes?: Array>; + }; + this.nodes = Array.isArray(res.nodes) ? res.nodes : []; + } catch (err) { + this.lastError = String(err); + } finally { + this.nodesLoading = false; + } + } + + private async loadConfig() { + if (!this.client || !this.connected) return; + this.configLoading = true; + this.lastError = null; + try { + const res = (await this.client.request("config.get", {})) as { + raw?: string | null; + valid?: boolean; + issues?: unknown[]; + config?: unknown; + }; + if (typeof res.raw === "string") { + this.configRaw = res.raw; + } else { + const cfg = res.config ?? {}; + this.configRaw = `${JSON.stringify(cfg, null, 2).trimEnd()}\n`; + } + this.configValid = typeof res.valid === "boolean" ? res.valid : null; + this.configIssues = Array.isArray(res.issues) ? res.issues : []; + } catch (err) { + this.lastError = String(err); + } finally { + this.configLoading = false; + } + } + + private async saveConfig() { + if (!this.client || !this.connected) return; + this.configSaving = true; + this.lastError = null; + try { + await this.client.request("config.set", { raw: this.configRaw }); + await this.loadConfig(); + } catch (err) { + this.lastError = String(err); + } finally { + this.configSaving = false; + } + } + + private setTab(next: Tab) { + this.tab = next; + void this.refreshActiveTab(); + } + + private applySettings(next: UiSettings) { + this.settings = next; + saveSettings(next); + } + + render() { + const proto = this.settings.gatewayUrl.startsWith("wss://") ? "wss" : "ws"; + const connectedBadge = html` + + + ${proto} + ${this.settings.gatewayUrl} + + `; + + return html` +
+
+
+
Clawdis Control
+ ${connectedBadge} +
+ +
+
+
+ ${this.renderSettingsCard()} ${this.renderActiveTab()} + ${this.lastError + ? html`
${this.lastError}
` + : nothing} +
+
+
+ `; + } + + private renderTabs() { + const tab = (id: Tab, label: string) => html` +
this.setTab(id)} + > + ${label} +
+ `; + return html`${tab("chat", "Chat")} ${tab("nodes", "Nodes")} + ${tab("config", "Config")}`; + } + + private renderSettingsCard() { + return html` +
+
+
+ + { + const v = (e.target as HTMLInputElement).value; + this.applySettings({ ...this.settings, gatewayUrl: v }); + }} + placeholder="ws://100.x.y.z:18789" + /> +
+
+ + { + const v = (e.target as HTMLInputElement).value; + this.applySettings({ ...this.settings, token: v }); + }} + placeholder="paste token" + /> +
+
+
+
+ Tip: for Tailnet access, start the gateway with a token and bind to + the Tailnet interface. +
+
+ + +
+
+
+ `; + } + + private renderActiveTab() { + if (this.tab === "chat") return this.renderChat(); + if (this.tab === "nodes") return this.renderNodes(); + if (this.tab === "config") return this.renderConfig(); + return nothing; + } + + private renderChat() { + return html` +
+
+
+
+ + { + const v = (e.target as HTMLInputElement).value; + this.sessionKey = v; + this.applySettings({ ...this.settings, sessionKey: v }); + }} + /> +
+ +
+
Messages come from the session JSONL logs.
+
+ +
+ ${this.chatMessages.map((m) => renderMessage(m))} + ${this.chatStream + ? html`${renderMessage({ + role: "assistant", + content: [{ type: "text", text: this.chatStream }], + })}` + : nothing} +
+ +
+
+ + +
+
+ +
+
+
+ `; + } + + private renderNodes() { + return html` +
+
+
Nodes
+ +
+
+ ${this.nodes.length === 0 + ? html`
No nodes found.
` + : this.nodes.map((n) => renderNode(n))} +
+
+ `; + } + + private renderConfig() { + const validity = + this.configValid === null + ? "unknown" + : this.configValid + ? "valid" + : "invalid"; + return html` +
+
+
+
Config
+ ${validity} +
+
+ + +
+
+ +
+ Writes to ~/.clawdis/clawdis.json. Some + changes may require a gateway restart. +
+ +
+ + +
+ + ${this.configIssues.length > 0 + ? html`
+
Issues
+
${JSON.stringify(this.configIssues, null, 2)}
+
` + : nothing} +
+ `; + } +} + +function renderNode(node: Record) { + const connected = Boolean(node.connected); + const paired = Boolean(node.paired); + const title = + (typeof node.displayName === "string" && node.displayName.trim()) || + (typeof node.nodeId === "string" ? node.nodeId : "unknown"); + const caps = Array.isArray(node.caps) ? (node.caps as unknown[]) : []; + const commands = Array.isArray(node.commands) ? (node.commands as unknown[]) : []; + return html` +
+
+
+ +
${title}
+
+
+ ${paired ? "paired" : "unpaired"} + · + ${connected ? "connected" : "offline"} +
+
+
+ ${typeof node.nodeId === "string" ? node.nodeId : ""} + ${typeof node.remoteIp === "string" ? `· ${node.remoteIp}` : ""} + ${typeof node.version === "string" ? `· ${node.version}` : ""} +
+ ${caps.length > 0 + ? html`
+ ${caps.slice(0, 24).map((c) => html`${String(c)}`)} +
` + : nothing} + ${commands.length > 0 + ? html`
+ ${commands + .slice(0, 24) + .map((c) => html`${String(c)}`)} +
` + : nothing} +
+ `; +} + +function renderMessage(message: unknown) { + const m = message as Record; + const role = typeof m.role === "string" ? m.role : "unknown"; + const text = + extractText(message) ?? + (typeof m.content === "string" + ? m.content + : JSON.stringify(message, null, 2)); + + const ts = + typeof m.timestamp === "number" + ? new Date(m.timestamp).toLocaleTimeString() + : ""; + const klass = + role === "assistant" ? "assistant" : role === "user" ? "user" : ""; + return html` +
+
+ ${role} + ${ts} +
+
${text}
+
+ `; +} + +function extractText(message: unknown): string | null { + const m = message as Record; + const content = m.content; + if (typeof content === "string") return content; + if (Array.isArray(content)) { + const parts = content + .map((p) => { + const item = p as Record; + if (item.type === "text" && typeof item.text === "string") return item.text; + return null; + }) + .filter((v): v is string => typeof v === "string"); + if (parts.length > 0) return parts.join("\n"); + } + if (typeof m.text === "string") return m.text; + return null; +} + diff --git a/ui/src/ui/gateway.ts b/ui/src/ui/gateway.ts new file mode 100644 index 000000000..5d5ad6426 --- /dev/null +++ b/ui/src/ui/gateway.ts @@ -0,0 +1,170 @@ +export type GatewayEventFrame = { + type: "event"; + event: string; + payload?: unknown; + seq?: number; + stateVersion?: { presence: number; health: number }; +}; + +export type GatewayResponseFrame = { + type: "res"; + id: string; + ok: boolean; + payload?: unknown; + error?: { code: string; message: string; details?: unknown }; +}; + +export type GatewayHelloOk = { + type: "hello-ok"; + protocol: number; + features?: { methods?: string[]; events?: string[] }; + snapshot?: unknown; + policy?: { tickIntervalMs?: number }; +}; + +type Pending = { + resolve: (value: unknown) => void; + reject: (err: unknown) => void; +}; + +export type GatewayBrowserClientOptions = { + url: string; + token?: string; + clientName?: string; + clientVersion?: string; + platform?: string; + mode?: string; + instanceId?: string; + onHello?: (hello: GatewayHelloOk) => void; + onEvent?: (evt: GatewayEventFrame) => void; + onClose?: (info: { code: number; reason: string }) => void; + onGap?: (info: { expected: number; received: number }) => void; +}; + +export class GatewayBrowserClient { + private ws: WebSocket | null = null; + private pending = new Map(); + private closed = false; + private lastSeq: number | null = null; + private backoffMs = 800; + + constructor(private opts: GatewayBrowserClientOptions) {} + + start() { + this.closed = false; + this.connect(); + } + + stop() { + this.closed = true; + this.ws?.close(); + this.ws = null; + this.flushPending(new Error("gateway client stopped")); + } + + get connected() { + return this.ws?.readyState === WebSocket.OPEN; + } + + private connect() { + if (this.closed) return; + this.ws = new WebSocket(this.opts.url); + this.ws.onopen = () => this.sendConnect(); + this.ws.onmessage = (ev) => this.handleMessage(String(ev.data ?? "")); + this.ws.onclose = (ev) => { + const reason = String(ev.reason ?? ""); + this.ws = null; + this.flushPending(new Error(`gateway closed (${ev.code}): ${reason}`)); + this.opts.onClose?.({ code: ev.code, reason }); + this.scheduleReconnect(); + }; + this.ws.onerror = () => { + // ignored; close handler will fire + }; + } + + private scheduleReconnect() { + if (this.closed) return; + const delay = this.backoffMs; + this.backoffMs = Math.min(this.backoffMs * 1.7, 15_000); + window.setTimeout(() => this.connect(), delay); + } + + private flushPending(err: Error) { + for (const [, p] of this.pending) p.reject(err); + this.pending.clear(); + } + + private sendConnect() { + const params = { + minProtocol: 2, + maxProtocol: 2, + client: { + name: this.opts.clientName ?? "clawdis-control-ui", + version: this.opts.clientVersion ?? "dev", + platform: this.opts.platform ?? navigator.platform ?? "web", + mode: this.opts.mode ?? "webchat", + instanceId: this.opts.instanceId, + }, + caps: [], + auth: this.opts.token ? { token: this.opts.token } : undefined, + userAgent: navigator.userAgent, + locale: navigator.language, + }; + + void this.request("connect", params) + .then((hello) => { + this.backoffMs = 800; + this.opts.onHello?.(hello); + }) + .catch(() => { + this.ws?.close(1008, "connect failed"); + }); + } + + private handleMessage(raw: string) { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return; + } + + const frame = parsed as { type?: unknown }; + if (frame.type === "event") { + const evt = parsed as GatewayEventFrame; + const seq = typeof evt.seq === "number" ? evt.seq : null; + if (seq !== null) { + if (this.lastSeq !== null && seq > this.lastSeq + 1) { + this.opts.onGap?.({ expected: this.lastSeq + 1, received: seq }); + } + this.lastSeq = seq; + } + this.opts.onEvent?.(evt); + return; + } + + if (frame.type === "res") { + const res = parsed as GatewayResponseFrame; + const pending = this.pending.get(res.id); + if (!pending) return; + this.pending.delete(res.id); + if (res.ok) pending.resolve(res.payload); + else pending.reject(new Error(res.error?.message ?? "request failed")); + return; + } + } + + request(method: string, params?: unknown): Promise { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + return Promise.reject(new Error("gateway not connected")); + } + const id = crypto.randomUUID(); + const frame = { type: "req", id, method, params }; + const p = new Promise((resolve, reject) => { + this.pending.set(id, { resolve: (v) => resolve(v as T), reject }); + }); + this.ws.send(JSON.stringify(frame)); + return p; + } +} diff --git a/ui/src/ui/storage.ts b/ui/src/ui/storage.ts new file mode 100644 index 000000000..e91ccd5b0 --- /dev/null +++ b/ui/src/ui/storage.ts @@ -0,0 +1,44 @@ +const KEY = "clawdis.control.settings.v1"; + +export type UiSettings = { + gatewayUrl: string; + token: string; + sessionKey: string; +}; + +export function loadSettings(): UiSettings { + const defaultUrl = (() => { + const proto = location.protocol === "https:" ? "wss" : "ws"; + return `${proto}://${location.host}`; + })(); + + const defaults: UiSettings = { + gatewayUrl: defaultUrl, + token: "", + sessionKey: "main", + }; + + try { + const raw = localStorage.getItem(KEY); + if (!raw) return defaults; + const parsed = JSON.parse(raw) as Partial; + return { + gatewayUrl: + typeof parsed.gatewayUrl === "string" && parsed.gatewayUrl.trim() + ? parsed.gatewayUrl.trim() + : defaults.gatewayUrl, + token: typeof parsed.token === "string" ? parsed.token : defaults.token, + sessionKey: + typeof parsed.sessionKey === "string" && parsed.sessionKey.trim() + ? parsed.sessionKey.trim() + : defaults.sessionKey, + }; + } catch { + return defaults; + } +} + +export function saveSettings(next: UiSettings) { + localStorage.setItem(KEY, JSON.stringify(next)); +} + diff --git a/ui/tsconfig.json b/ui/tsconfig.json new file mode 100644 index 000000000..9d70bb949 --- /dev/null +++ b/ui/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "skipLibCheck": true, + "types": ["vite/client"], + "useDefineForClassFields": false + }, + "include": ["src"] +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts new file mode 100644 index 000000000..9361e01e3 --- /dev/null +++ b/ui/vite.config.ts @@ -0,0 +1,19 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite"; + +const here = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + base: "/ui/", + build: { + outDir: path.resolve(here, "../dist/control-ui"), + emptyOutDir: true, + sourcemap: true, + }, + server: { + host: true, + port: 5173, + strictPort: true, + }, +});