Gateway: add browser control UI
This commit is contained in:
17
README.md
17
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://<host>: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`)
|
||||
|
||||
51
docs/control-ui.md
Normal file
51
docs/control-ui.md
Normal file
@@ -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://<host>: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://<tailscale-ip>: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`).
|
||||
|
||||
@@ -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)
|
||||
|
||||
75
docs/web.md
Normal file
75
docs/web.md
Normal file
@@ -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://<host>: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://<tailscale-ip>: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
|
||||
```
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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>", "Port for the gateway WebSocket", "18789")
|
||||
.option(
|
||||
"--bind <mode>",
|
||||
'Bind mode ("loopback"|"tailnet"|"lan"|"auto"). Defaults to config gateway.bind (or loopback).',
|
||||
)
|
||||
.option(
|
||||
"--token <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<ReturnType<typeof startGatewayServer>> | 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}`);
|
||||
|
||||
@@ -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<ConfigFileSnapshot> {
|
||||
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");
|
||||
}
|
||||
|
||||
136
src/gateway/control-ui.ts
Normal file
136
src/gateway/control-ui.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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<SessionsListParams>(
|
||||
export const validateSessionsPatchParams = ajv.compile<SessionsPatchParams>(
|
||||
SessionsPatchParamsSchema,
|
||||
);
|
||||
export const validateConfigGetParams = ajv.compile<ConfigGetParams>(
|
||||
ConfigGetParamsSchema,
|
||||
);
|
||||
export const validateConfigSetParams = ajv.compile<ConfigSetParams>(
|
||||
ConfigSetParamsSchema,
|
||||
);
|
||||
export const validateCronListParams =
|
||||
ajv.compile<CronListParams>(CronListParamsSchema);
|
||||
export const validateCronStatusParams = ajv.compile<CronStatusParams>(
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, TSchema> = {
|
||||
NodeInvokeParams: NodeInvokeParamsSchema,
|
||||
SessionsListParams: SessionsListParamsSchema,
|
||||
SessionsPatchParams: SessionsPatchParamsSchema,
|
||||
ConfigGetParams: ConfigGetParamsSchema,
|
||||
ConfigSetParams: ConfigSetParamsSchema,
|
||||
CronJob: CronJobSchema,
|
||||
CronListParams: CronListParamsSchema,
|
||||
CronStatusParams: CronStatusParamsSchema,
|
||||
@@ -582,6 +596,8 @@ export type NodeDescribeParams = Static<typeof NodeDescribeParamsSchema>;
|
||||
export type NodeInvokeParams = Static<typeof NodeInvokeParamsSchema>;
|
||||
export type SessionsListParams = Static<typeof SessionsListParamsSchema>;
|
||||
export type SessionsPatchParams = Static<typeof SessionsPatchParamsSchema>;
|
||||
export type ConfigGetParams = Static<typeof ConfigGetParamsSchema>;
|
||||
export type ConfigSetParams = Static<typeof ConfigSetParamsSchema>;
|
||||
export type CronJob = Static<typeof CronJobSchema>;
|
||||
export type CronListParams = Static<typeof CronListParamsSchema>;
|
||||
export type CronStatusParams = Static<typeof CronStatusParamsSchema>;
|
||||
|
||||
@@ -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<void>;
|
||||
};
|
||||
|
||||
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<GatewayServer> {
|
||||
const host = "127.0.0.1";
|
||||
const httpServer: HttpServer = createHttpServer();
|
||||
export async function startGatewayServer(
|
||||
port = 18789,
|
||||
opts: GatewayServerOptions = {},
|
||||
): Promise<GatewayServer> {
|
||||
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<void>) | null = null;
|
||||
let bridge: Awaited<ReturnType<typeof startNodeBridgeServer>> | null = null;
|
||||
let canvasHost: CanvasHostServer | null = null;
|
||||
@@ -794,18 +878,18 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
|
||||
};
|
||||
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<GatewayServer> {
|
||||
{ sessionKey: string; clientRunId: string }
|
||||
>();
|
||||
const chatRunBuffers = new Map<string, string>();
|
||||
const chatDeltaSentAt = new Map<string, number>();
|
||||
const chatAbortControllers = new Map<
|
||||
string,
|
||||
{ controller: AbortController; sessionId: string; sessionKey: string }
|
||||
@@ -1171,6 +1256,63 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
|
||||
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<GatewayServer> {
|
||||
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<GatewayServer> {
|
||||
};
|
||||
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<GatewayServer> {
|
||||
) {
|
||||
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<GatewayServer> {
|
||||
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<GatewayServer> {
|
||||
respond(true, status, undefined);
|
||||
break;
|
||||
}
|
||||
case "config.get": {
|
||||
const params = (req.params ?? {}) as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
if (!validateSessionsListParams(params)) {
|
||||
@@ -3814,7 +4039,7 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
|
||||
});
|
||||
|
||||
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}`);
|
||||
|
||||
|
||||
14
ui/index.html
Normal file
14
ui/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Clawdis Control</title>
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
</head>
|
||||
<body>
|
||||
<clawdis-app></clawdis-app>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
17
ui/package.json
Normal file
17
ui/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
3
ui/src/main.ts
Normal file
3
ui/src/main.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import "./styles.css";
|
||||
import "./ui/app.ts";
|
||||
|
||||
106
ui/src/styles.css
Normal file
106
ui/src/styles.css
Normal file
@@ -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);
|
||||
}
|
||||
|
||||
695
ui/src/ui/app.ts
Normal file
695
ui/src/ui/app.ts
Normal file
@@ -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<Record<string, unknown>> = [];
|
||||
|
||||
@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<Record<string, unknown>>;
|
||||
};
|
||||
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`
|
||||
<span class="pill" title=${this.connected ? "connected" : "disconnected"}>
|
||||
<span class="statusDot ${this.connected ? "ok" : ""}"></span>
|
||||
<span class="mono">${proto}</span>
|
||||
<span class="mono">${this.settings.gatewayUrl}</span>
|
||||
</span>
|
||||
`;
|
||||
|
||||
return html`
|
||||
<div class="shell">
|
||||
<header>
|
||||
<div class="row">
|
||||
<div class="title">Clawdis Control</div>
|
||||
${connectedBadge}
|
||||
</div>
|
||||
<nav>
|
||||
${this.renderTabs()}
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<div class="grid">
|
||||
${this.renderSettingsCard()} ${this.renderActiveTab()}
|
||||
${this.lastError
|
||||
? html`<div class="card"><div class="error">${this.lastError}</div></div>`
|
||||
: nothing}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderTabs() {
|
||||
const tab = (id: Tab, label: string) => html`
|
||||
<div
|
||||
class="tab ${this.tab === id ? "active" : ""}"
|
||||
@click=${() => this.setTab(id)}
|
||||
>
|
||||
${label}
|
||||
</div>
|
||||
`;
|
||||
return html`${tab("chat", "Chat")} ${tab("nodes", "Nodes")}
|
||||
${tab("config", "Config")}`;
|
||||
}
|
||||
|
||||
private renderSettingsCard() {
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="split">
|
||||
<div class="field">
|
||||
<label>Gateway WebSocket URL</label>
|
||||
<input
|
||||
.value=${this.settings.gatewayUrl}
|
||||
@input=${(e: Event) => {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
this.applySettings({ ...this.settings, gatewayUrl: v });
|
||||
}}
|
||||
placeholder="ws://100.x.y.z:18789"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Gateway Token (CLAWDIS_GATEWAY_TOKEN)</label>
|
||||
<input
|
||||
.value=${this.settings.token}
|
||||
@input=${(e: Event) => {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
this.applySettings({ ...this.settings, token: v });
|
||||
}}
|
||||
placeholder="paste token"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" style="justify-content: space-between; margin-top: 10px;">
|
||||
<div class="muted">
|
||||
Tip: for Tailnet access, start the gateway with a token and bind to
|
||||
the Tailnet interface.
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="btn" @click=${() => this.connect()}>
|
||||
Reconnect
|
||||
</button>
|
||||
<button class="btn danger" @click=${() => this.client?.stop()}>
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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`
|
||||
<div class="card">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<div class="row">
|
||||
<div class="field" style="min-width: 220px;">
|
||||
<label>Session Key</label>
|
||||
<input
|
||||
.value=${this.sessionKey}
|
||||
@input=${(e: Event) => {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
this.sessionKey = v;
|
||||
this.applySettings({ ...this.settings, sessionKey: v });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="btn"
|
||||
?disabled=${this.chatLoading || !this.connected}
|
||||
@click=${() => this.loadChatHistory()}
|
||||
>
|
||||
${this.chatLoading ? "Loading…" : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
<div class="muted">Messages come from the session JSONL logs.</div>
|
||||
</div>
|
||||
|
||||
<div class="messages" style="margin-top: 12px;">
|
||||
${this.chatMessages.map((m) => renderMessage(m))}
|
||||
${this.chatStream
|
||||
? html`${renderMessage({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: this.chatStream }],
|
||||
})}`
|
||||
: nothing}
|
||||
</div>
|
||||
|
||||
<div class="compose" style="margin-top: 12px;">
|
||||
<div class="field">
|
||||
<label>Message</label>
|
||||
<textarea
|
||||
.value=${this.chatMessage}
|
||||
@input=${(e: Event) => {
|
||||
this.chatMessage = (e.target as HTMLTextAreaElement).value;
|
||||
}}
|
||||
placeholder="Ask the model…"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="row" style="justify-content: flex-end;">
|
||||
<button
|
||||
class="btn primary"
|
||||
?disabled=${this.chatSending || !this.connected}
|
||||
@click=${() => this.sendChat()}
|
||||
>
|
||||
${this.chatSending ? "Sending…" : "Send"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderNodes() {
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<div class="title">Nodes</div>
|
||||
<button
|
||||
class="btn"
|
||||
?disabled=${this.nodesLoading || !this.connected}
|
||||
@click=${() => this.loadNodes()}
|
||||
>
|
||||
${this.nodesLoading ? "Loading…" : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
<div class="nodes" style="margin-top: 12px;">
|
||||
${this.nodes.length === 0
|
||||
? html`<div class="muted">No nodes found.</div>`
|
||||
: this.nodes.map((n) => renderNode(n))}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderConfig() {
|
||||
const validity =
|
||||
this.configValid === null
|
||||
? "unknown"
|
||||
: this.configValid
|
||||
? "valid"
|
||||
: "invalid";
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<div class="row">
|
||||
<div class="title">Config</div>
|
||||
<span class="pill"><span class="mono">${validity}</span></span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button
|
||||
class="btn"
|
||||
?disabled=${this.configLoading || !this.connected}
|
||||
@click=${() => this.loadConfig()}
|
||||
>
|
||||
${this.configLoading ? "Loading…" : "Reload"}
|
||||
</button>
|
||||
<button
|
||||
class="btn primary"
|
||||
?disabled=${this.configSaving || !this.connected}
|
||||
@click=${() => this.saveConfig()}
|
||||
>
|
||||
${this.configSaving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="muted" style="margin-top: 10px;">
|
||||
Writes to <span class="mono">~/.clawdis/clawdis.json</span>. Some
|
||||
changes may require a gateway restart.
|
||||
</div>
|
||||
|
||||
<div class="field" style="margin-top: 12px;">
|
||||
<label>Raw JSON5</label>
|
||||
<textarea
|
||||
.value=${this.configRaw}
|
||||
@input=${(e: Event) => {
|
||||
this.configRaw = (e.target as HTMLTextAreaElement).value;
|
||||
}}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
${this.configIssues.length > 0
|
||||
? html`<div class="card" style="margin-top: 12px;">
|
||||
<div class="title">Issues</div>
|
||||
<div class="error">${JSON.stringify(this.configIssues, null, 2)}</div>
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderNode(node: Record<string, unknown>) {
|
||||
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`
|
||||
<div class="nodeRow">
|
||||
<div class="top">
|
||||
<div class="row">
|
||||
<span class="statusDot ${connected ? "ok" : ""}"></span>
|
||||
<div class="title">${title}</div>
|
||||
</div>
|
||||
<div class="row muted">
|
||||
<span>${paired ? "paired" : "unpaired"}</span>
|
||||
<span>·</span>
|
||||
<span>${connected ? "connected" : "offline"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="muted mono">
|
||||
${typeof node.nodeId === "string" ? node.nodeId : ""}
|
||||
${typeof node.remoteIp === "string" ? `· ${node.remoteIp}` : ""}
|
||||
${typeof node.version === "string" ? `· ${node.version}` : ""}
|
||||
</div>
|
||||
${caps.length > 0
|
||||
? html`<div class="chips">
|
||||
${caps.slice(0, 24).map((c) => html`<span class="chip">${String(c)}</span>`)}
|
||||
</div>`
|
||||
: nothing}
|
||||
${commands.length > 0
|
||||
? html`<div class="chips">
|
||||
${commands
|
||||
.slice(0, 24)
|
||||
.map((c) => html`<span class="chip">${String(c)}</span>`)}
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderMessage(message: unknown) {
|
||||
const m = message as Record<string, unknown>;
|
||||
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`
|
||||
<div class="msg ${klass}">
|
||||
<div class="meta">
|
||||
<span class="mono">${role}</span>
|
||||
<span class="mono">${ts}</span>
|
||||
</div>
|
||||
<div style="white-space: pre-wrap;">${text}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function extractText(message: unknown): string | null {
|
||||
const m = message as Record<string, unknown>;
|
||||
const content = m.content;
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
const parts = content
|
||||
.map((p) => {
|
||||
const item = p as Record<string, unknown>;
|
||||
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;
|
||||
}
|
||||
|
||||
170
ui/src/ui/gateway.ts
Normal file
170
ui/src/ui/gateway.ts
Normal file
@@ -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<string, Pending>();
|
||||
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<GatewayHelloOk>("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<T = unknown>(method: string, params?: unknown): Promise<T> {
|
||||
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<T>((resolve, reject) => {
|
||||
this.pending.set(id, { resolve: (v) => resolve(v as T), reject });
|
||||
});
|
||||
this.ws.send(JSON.stringify(frame));
|
||||
return p;
|
||||
}
|
||||
}
|
||||
44
ui/src/ui/storage.ts
Normal file
44
ui/src/ui/storage.ts
Normal file
@@ -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<UiSettings>;
|
||||
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));
|
||||
}
|
||||
|
||||
13
ui/tsconfig.json
Normal file
13
ui/tsconfig.json
Normal file
@@ -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"]
|
||||
}
|
||||
19
ui/vite.config.ts
Normal file
19
ui/vite.config.ts
Normal file
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user