From 6ff837125475fe0f48e245dd721af947e870cb33 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 21 Dec 2025 00:34:39 +0000 Subject: [PATCH] feat(ui): expand control dashboard --- docs/control-ui.md | 34 +- docs/web.md | 51 +- ui/src/styles.css | 109 +--- ui/src/styles/base.css | 89 +++ ui/src/styles/components.css | 350 +++++++++++ ui/src/styles/layout.css | 200 ++++++ ui/src/ui/app-render.ts | 368 +++++++++++ ui/src/ui/app.ts | 888 ++++++++------------------- ui/src/ui/controllers/chat.ts | 108 ++++ ui/src/ui/controllers/config.ts | 82 +++ ui/src/ui/controllers/connections.ts | 145 +++++ ui/src/ui/controllers/cron.ts | 191 ++++++ ui/src/ui/controllers/debug.ts | 57 ++ ui/src/ui/controllers/nodes.ts | 26 + ui/src/ui/controllers/presence.ts | 36 ++ ui/src/ui/controllers/sessions.ts | 58 ++ ui/src/ui/controllers/skills.ts | 93 +++ ui/src/ui/format.ts | 54 ++ ui/src/ui/navigation.ts | 75 +++ ui/src/ui/presenter.ts | 58 ++ ui/src/ui/types.ts | 251 ++++++++ ui/src/ui/ui-types.ts | 31 + ui/src/ui/views/chat.ts | 116 ++++ ui/src/ui/views/config.ts | 61 ++ ui/src/ui/views/connections.ts | 304 +++++++++ ui/src/ui/views/cron.ts | 390 ++++++++++++ ui/src/ui/views/debug.ts | 129 ++++ ui/src/ui/views/instances.ts | 75 +++ ui/src/ui/views/nodes.ts | 61 ++ ui/src/ui/views/overview.ts | 184 ++++++ ui/src/ui/views/sessions.ts | 165 +++++ ui/src/ui/views/skills.ts | 146 +++++ 32 files changed, 4226 insertions(+), 759 deletions(-) create mode 100644 ui/src/styles/base.css create mode 100644 ui/src/styles/components.css create mode 100644 ui/src/styles/layout.css create mode 100644 ui/src/ui/app-render.ts create mode 100644 ui/src/ui/controllers/chat.ts create mode 100644 ui/src/ui/controllers/config.ts create mode 100644 ui/src/ui/controllers/connections.ts create mode 100644 ui/src/ui/controllers/cron.ts create mode 100644 ui/src/ui/controllers/debug.ts create mode 100644 ui/src/ui/controllers/nodes.ts create mode 100644 ui/src/ui/controllers/presence.ts create mode 100644 ui/src/ui/controllers/sessions.ts create mode 100644 ui/src/ui/controllers/skills.ts create mode 100644 ui/src/ui/format.ts create mode 100644 ui/src/ui/navigation.ts create mode 100644 ui/src/ui/presenter.ts create mode 100644 ui/src/ui/types.ts create mode 100644 ui/src/ui/ui-types.ts create mode 100644 ui/src/ui/views/chat.ts create mode 100644 ui/src/ui/views/config.ts create mode 100644 ui/src/ui/views/connections.ts create mode 100644 ui/src/ui/views/cron.ts create mode 100644 ui/src/ui/views/debug.ts create mode 100644 ui/src/ui/views/instances.ts create mode 100644 ui/src/ui/views/nodes.ts create mode 100644 ui/src/ui/views/overview.ts create mode 100644 ui/src/ui/views/sessions.ts create mode 100644 ui/src/ui/views/skills.ts diff --git a/docs/control-ui.md b/docs/control-ui.md index 89d24b7b1..978573857 100644 --- a/docs/control-ui.md +++ b/docs/control-ui.md @@ -12,24 +12,48 @@ The Control UI is a small **Vite + Lit** single-page app served by the Gateway u It speaks **directly to the Gateway WebSocket** on the same port. +Auth is supplied during the WebSocket handshake via: +- `connect.params.auth.token` +- `connect.params.auth.password` (optional `username` for system/PAM) +The dashboard settings panel lets you store a token and optional username; passwords are not persisted. + ## 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`) +- Connections: WhatsApp/Telegram status + QR login + Telegram config (`providers.status`, `web.login.*`, `config.set`) +- Instances: presence list + refresh (`system-presence`) +- Sessions: list + per-session thinking/verbose overrides (`sessions.list`, `sessions.patch`) +- Cron jobs: list/add/run/enable/disable + run history (`cron.*`) +- Skills: status, enable/disable, install, API key updates (`skills.*`) +- Nodes: list + caps (`node.list`) +- Config: view/edit `~/.clawdis/clawdis.json` (`config.get`, `config.set`) +- Debug: status/health/models snapshots + event log + manual RPC calls (`status`, `health`, `models.list`) ## Tailnet access (recommended) -Expose the Gateway on your Tailscale interface and require a token: +### Integrated Tailscale Serve (preferred) + +Keep the Gateway on loopback and let Tailscale Serve proxy it with HTTPS: + +```bash +clawdis gateway --tailscale serve +``` + +Open: +- `https:///ui/` + +By default, the gateway trusts Tailscale identity headers in serve mode. You can still set +`CLAWDIS_GATEWAY_TOKEN` or `gateway.auth` if you want a shared secret instead. + +### Bind to tailnet + token (legacy) ```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`). +Paste the token into the UI settings (sent as `connect.params.auth.token`). ## Building the UI diff --git a/docs/web.md b/docs/web.md index d8ebfd293..7555d60a5 100644 --- a/docs/web.md +++ b/docs/web.md @@ -12,8 +12,14 @@ The Gateway serves a small **browser Control UI** (Vite + Lit) from the same por The UI talks directly to the Gateway WS and supports: - Chat (`chat.history`, `chat.send`, `chat.abort`) +- Connections (provider status, WhatsApp QR, Telegram config) +- Instances (`system-presence`) +- Sessions (`sessions.list`, `sessions.patch`) +- Cron (`cron.*`) +- Skills (`skills.status`, `skills.update`, `skills.install`) - Nodes (`node.list`, `node.describe`, `node.invoke`) - Config (`config.get`, `config.set`) for `~/.clawdis/clawdis.json` +- Debug (status/health/models snapshots + manual calls) ## Config (default-on) @@ -28,11 +34,31 @@ You can control it via config: } ``` -## Tailnet access +## Tailscale access -To access the UI across Tailscale, bind the Gateway to the Tailnet interface and require a token. +### Integrated Serve (recommended) -### Via config (recommended) +Keep the Gateway on loopback and let Tailscale Serve proxy it: + +```json5 +{ + gateway: { + bind: "loopback", + tailscale: { mode: "serve" } + } +} +``` + +Then start the gateway: + +```bash +clawdis gateway +``` + +Open: +- `https:///ui/` + +### Tailnet bind + token (legacy) ```json5 { @@ -53,16 +79,24 @@ clawdis gateway Open: - `http://:18789/ui/` -### Via CLI (one-off) +### Public internet (Funnel) -```bash -clawdis gateway --bind tailnet --token "…your token…" +```json5 +{ + gateway: { + bind: "loopback", + tailscale: { mode: "funnel" }, + auth: { mode: "system" } // or "password" with CLAWDIS_GATEWAY_PASSWORD + } +} ``` ## 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. +- Binding the Gateway to a non-loopback address **requires** auth (`CLAWDIS_GATEWAY_TOKEN` or `gateway.auth`). +- `gateway.auth.mode: "system"` uses PAM to verify your OS password. +- The UI sends `connect.params.auth.token` or `connect.params.auth.password`. +- Use `gateway.auth.allowTailscale: false` to require explicit credentials even in Serve mode. ## Building the UI @@ -72,4 +106,3 @@ The Gateway serves static files from `dist/control-ui`. Build them with: pnpm ui:install pnpm ui:build ``` - diff --git a/ui/src/styles.css b/ui/src/styles.css index 8f21e657e..8441178f2 100644 --- a/ui/src/styles.css +++ b/ui/src/styles.css @@ -1,106 +1,3 @@ -: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); -} - +@import "./styles/base.css"; +@import "./styles/layout.css"; +@import "./styles/components.css"; diff --git a/ui/src/styles/base.css b/ui/src/styles/base.css new file mode 100644 index 000000000..dd85475db --- /dev/null +++ b/ui/src/styles/base.css @@ -0,0 +1,89 @@ +@import url("https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600;9..144,700&family=JetBrains+Mono:wght@400;500&family=Space+Grotesk:wght@400;500;600;700&display=swap"); + +:root { + --bg: #0a0e14; + --bg-accent: #101826; + --panel: rgba(18, 24, 36, 0.92); + --panel-strong: rgba(24, 32, 46, 0.95); + --text: rgba(246, 248, 252, 0.95); + --muted: rgba(210, 218, 230, 0.62); + --border: rgba(255, 255, 255, 0.08); + --accent: #ff7a3d; + --accent-2: #36cfc9; + --ok: #1bd98a; + --warn: #f2c94c; + --danger: #ff5c5c; + --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + "Liberation Mono", "Courier New", monospace; + --font-body: "Space Grotesk", system-ui, sans-serif; + --font-display: "Fraunces", "Times New Roman", serif; + color-scheme: dark; +} + +* { + box-sizing: border-box; +} + +html, +body { + height: 100%; +} + +body { + margin: 0; + font: 15px/1.4 var(--font-body); + background: radial-gradient(1200px 900px at 20% 0%, #1a2740 0%, var(--bg) 55%) + fixed, + radial-gradient(900px 700px at 90% 10%, #241626 0%, transparent 55%) fixed, + var(--bg); + color: var(--text); +} + +body::before { + content: ""; + position: fixed; + inset: 0; + background: linear-gradient( + 135deg, + rgba(255, 255, 255, 0.03) 0%, + rgba(255, 255, 255, 0) 35% + ), + radial-gradient( + 600px 400px at 80% 80%, + rgba(54, 207, 201, 0.08), + transparent 60% + ); + pointer-events: none; + z-index: 0; +} + +clawdis-app { + display: block; + position: relative; + z-index: 1; + min-height: 100vh; +} + +a { + color: inherit; +} + +button, +input, +textarea, +select { + font: inherit; + color: inherit; +} + +@keyframes rise { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css new file mode 100644 index 000000000..3744e44f2 --- /dev/null +++ b/ui/src/styles/components.css @@ -0,0 +1,350 @@ +.card { + border: 1px solid var(--border); + background: var(--panel); + border-radius: 18px; + padding: 16px; + box-shadow: 0 18px 40px rgba(0, 0, 0, 0.25); + animation: rise 0.35s ease; +} + +.card-title { + font-family: var(--font-display); + font-size: 18px; +} + +.card-sub { + color: var(--muted); + font-size: 13px; +} + +.stat { + background: var(--panel-strong); + border-radius: 14px; + padding: 12px; + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.stat-label { + color: var(--muted); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.8px; +} + +.stat-value { + font-size: 18px; + margin-top: 6px; +} + +.stat-value.ok { + color: var(--ok); +} + +.stat-value.warn { + color: var(--warn); +} + +.stat-card { + display: grid; + gap: 6px; +} + +.note-title { + font-weight: 600; +} + +.status-list { + display: grid; + gap: 8px; +} + +.status-list div { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 6px 0; + border-bottom: 1px dashed rgba(255, 255, 255, 0.06); +} + +.status-list div:last-child { + border-bottom: none; +} + +.label { + color: var(--muted); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.6px; +} + +.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.3); +} + +.pill.danger { + border-color: rgba(255, 92, 92, 0.5); + color: var(--danger); +} + +.statusDot { + width: 8px; + height: 8px; + border-radius: 999px; + background: var(--danger); + box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.25); +} + +.statusDot.ok { + background: var(--ok); +} + +.btn { + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.04); + padding: 8px 12px; + border-radius: 10px; + cursor: pointer; +} + +.btn:hover { + background: rgba(255, 255, 255, 0.08); +} + +.btn.primary { + border-color: rgba(255, 122, 61, 0.35); + background: rgba(255, 122, 61, 0.18); +} + +.btn.danger { + border-color: rgba(255, 92, 92, 0.4); + background: rgba(255, 92, 92, 0.16); +} + +.field { + display: grid; + gap: 6px; +} + +.field span { + color: var(--muted); + font-size: 12px; +} + +.field input, +.field textarea, +.field select { + border: 1px solid var(--border); + background: rgba(0, 0, 0, 0.3); + border-radius: 10px; + padding: 8px 10px; + outline: none; +} + +.field textarea { + font-family: var(--mono); + min-height: 180px; + resize: vertical; + white-space: pre; +} + +.field.checkbox { + grid-template-columns: auto 1fr; + align-items: center; +} + +.form-grid { + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); +} + +.muted { + color: var(--muted); +} + +.mono { + font-family: var(--mono); +} + +.callout { + padding: 10px 12px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.callout.danger { + border-color: rgba(255, 92, 92, 0.4); + color: var(--danger); +} + +.code-block { + font-family: var(--mono); + font-size: 12px; + background: rgba(0, 0, 0, 0.35); + padding: 10px; + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.06); + max-height: 360px; + overflow: auto; +} + +.list { + display: grid; + gap: 12px; +} + +.list-item { + display: grid; + grid-template-columns: 1fr auto; + gap: 14px; + border: 1px solid var(--border); + border-radius: 14px; + padding: 12px; + background: rgba(0, 0, 0, 0.2); +} + +.list-main { + display: grid; + gap: 6px; +} + +.list-title { + font-weight: 600; +} + +.list-sub { + color: var(--muted); + font-size: 13px; +} + +.list-meta { + text-align: right; + color: var(--muted); + font-size: 12px; + display: grid; + gap: 4px; +} + +.chip-row { + 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.2); +} + +.chip-ok { + color: var(--ok); + border-color: rgba(27, 217, 138, 0.4); +} + +.chip-warn { + color: var(--warn); + border-color: rgba(242, 201, 76, 0.4); +} + +.table { + display: grid; + gap: 8px; +} + +.table-head, +.table-row { + display: grid; + grid-template-columns: 1.4fr 0.8fr 0.8fr 0.7fr 0.8fr 0.8fr; + gap: 12px; + align-items: center; +} + +.table-head { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.8px; + color: var(--muted); +} + +.table-row { + border: 1px solid var(--border); + padding: 10px; + border-radius: 12px; + background: rgba(0, 0, 0, 0.2); +} + +.messages { + display: grid; + gap: 10px; + max-height: 60vh; + overflow: auto; + padding: 8px; + min-width: 0; + border-radius: 12px; + background: rgba(0, 0, 0, 0.2); +} + +.msg { + border: 1px solid var(--border); + background: rgba(0, 0, 0, 0.2); + border-radius: 14px; + padding: 10px 12px; + min-width: 0; +} + +.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, 122, 61, 0.25); + background: rgba(255, 122, 61, 0.08); +} + +.msgContent { + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; +} + +.compose { + display: grid; + gap: 10px; +} + +.qr-wrap { + margin-top: 12px; + border-radius: 14px; + background: rgba(0, 0, 0, 0.2); + border: 1px dashed rgba(255, 255, 255, 0.12); + padding: 12px; + display: inline-flex; +} + +.qr-wrap img { + width: 180px; + height: 180px; + border-radius: 10px; + image-rendering: pixelated; +} + diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css new file mode 100644 index 000000000..0277730e4 --- /dev/null +++ b/ui/src/styles/layout.css @@ -0,0 +1,200 @@ +.shell { + min-height: 100vh; + display: grid; + grid-template-columns: 240px 1fr; + grid-template-rows: auto 1fr; + grid-template-areas: + "topbar topbar" + "nav content"; +} + +.topbar { + grid-area: topbar; + display: flex; + justify-content: space-between; + align-items: center; + padding: 18px 24px; + border-bottom: 1px solid var(--border); + background: rgba(10, 14, 20, 0.75); + backdrop-filter: blur(16px); +} + +.brand-title { + font-family: var(--font-display); + font-size: 22px; + letter-spacing: 0.4px; +} + +.brand-sub { + color: var(--muted); + font-size: 13px; +} + +.topbar-status { + display: flex; + align-items: center; + gap: 10px; +} + +.nav { + grid-area: nav; + padding: 18px 16px; + border-right: 1px solid var(--border); + background: rgba(10, 14, 20, 0.8); +} + +.nav-group { + margin-bottom: 18px; + display: grid; + gap: 6px; +} + +.nav-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 1.4px; + color: var(--muted); +} + +.nav-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 9px 12px; + border-radius: 12px; + border: 1px solid transparent; + background: rgba(255, 255, 255, 0.03); + color: var(--muted); + cursor: pointer; +} + +.nav-item.active { + color: var(--text); + border-color: rgba(255, 122, 61, 0.45); + background: rgba(255, 122, 61, 0.14); +} + +.content { + grid-area: content; + padding: 24px 28px 32px; + display: flex; + flex-direction: column; + gap: 18px; +} + +.content-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 12px; +} + +.page-title { + font-family: var(--font-display); + font-size: 24px; + letter-spacing: 0.4px; +} + +.page-sub { + color: var(--muted); + font-size: 13px; +} + +.page-meta { + display: flex; + gap: 10px; +} + +.grid { + display: grid; + gap: 16px; +} + +.grid-cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.grid-cols-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.stat-grid { + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); +} + +.note-grid { + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); +} + +.row { + display: flex; + gap: 12px; + align-items: center; +} + +.stack { + display: grid; + gap: 12px; +} + +.filters { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; +} + +@media (max-width: 1100px) { + .shell { + grid-template-columns: 1fr; + grid-template-rows: auto auto 1fr; + grid-template-areas: + "topbar" + "nav" + "content"; + } + + .nav { + display: flex; + gap: 16px; + overflow-x: auto; + border-right: none; + border-bottom: 1px solid var(--border); + } + + .nav-group { + grid-auto-flow: column; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + } + + .grid-cols-2, + .grid-cols-3 { + grid-template-columns: 1fr; + } + + .topbar { + flex-direction: column; + align-items: flex-start; + gap: 12px; + } + + .topbar-status { + width: 100%; + flex-wrap: wrap; + } + + .table-head, + .table-row { + grid-template-columns: 1fr; + } + + .list-item { + grid-template-columns: 1fr; + } +} + diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts new file mode 100644 index 000000000..918f6894d --- /dev/null +++ b/ui/src/ui/app-render.ts @@ -0,0 +1,368 @@ +import { html, nothing } from "lit"; + +import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway"; +import { TAB_GROUPS, subtitleForTab, titleForTab, type Tab } from "./navigation"; +import type { + ConfigSnapshot, + CronJob, + CronRunLogEntry, + CronStatus, + HealthSnapshot, + PresenceEntry, + ProvidersStatusSnapshot, + SessionsListResult, + SkillStatusReport, + StatusSummary, +} from "./types"; +import type { CronFormState, TelegramForm } from "./ui-types"; +import { renderChat } from "./views/chat"; +import { renderConfig } from "./views/config"; +import { renderConnections } from "./views/connections"; +import { renderCron } from "./views/cron"; +import { renderDebug } from "./views/debug"; +import { renderInstances } from "./views/instances"; +import { renderNodes } from "./views/nodes"; +import { renderOverview } from "./views/overview"; +import { renderSessions } from "./views/sessions"; +import { renderSkills } from "./views/skills"; +import { loadProviders } from "./controllers/connections"; +import { loadPresence } from "./controllers/presence"; +import { loadSessions, patchSession } from "./controllers/sessions"; +import { + installSkill, + loadSkills, + saveSkillApiKey, + updateSkillEdit, + updateSkillEnabled, +} from "./controllers/skills"; +import { loadNodes } from "./controllers/nodes"; +import { loadChatHistory } from "./controllers/chat"; +import { loadConfig, saveConfig } from "./controllers/config"; +import { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } from "./controllers/cron"; +import { loadDebug, callDebugMethod } from "./controllers/debug"; + +export type EventLogEntry = { + ts: number; + event: string; + payload?: unknown; +}; + +export type AppViewState = { + settings: { gatewayUrl: string; token: string; username: string; sessionKey: string }; + password: string; + tab: Tab; + connected: boolean; + hello: GatewayHelloOk | null; + lastError: string | null; + eventLog: EventLogEntry[]; + sessionKey: string; + chatLoading: boolean; + chatSending: boolean; + chatMessage: string; + chatMessages: unknown[]; + chatStream: string | null; + chatRunId: string | null; + chatThinkingLevel: string | null; + nodesLoading: boolean; + nodes: Array>; + configLoading: boolean; + configRaw: string; + configValid: boolean | null; + configIssues: unknown[]; + configSaving: boolean; + configSnapshot: ConfigSnapshot | null; + providersLoading: boolean; + providersSnapshot: ProvidersStatusSnapshot | null; + providersError: string | null; + providersLastSuccess: number | null; + whatsappLoginMessage: string | null; + whatsappLoginQrDataUrl: string | null; + whatsappLoginConnected: boolean | null; + whatsappBusy: boolean; + telegramForm: TelegramForm; + telegramSaving: boolean; + telegramTokenLocked: boolean; + telegramConfigStatus: string | null; + presenceLoading: boolean; + presenceEntries: PresenceEntry[]; + presenceError: string | null; + presenceStatus: string | null; + sessionsLoading: boolean; + sessionsResult: SessionsListResult | null; + sessionsError: string | null; + sessionsFilterActive: string; + sessionsFilterLimit: string; + sessionsIncludeGlobal: boolean; + sessionsIncludeUnknown: boolean; + cronLoading: boolean; + cronJobs: CronJob[]; + cronStatus: CronStatus | null; + cronError: string | null; + cronForm: CronFormState; + cronRunsJobId: string | null; + cronRuns: CronRunLogEntry[]; + cronBusy: boolean; + skillsLoading: boolean; + skillsReport: SkillStatusReport | null; + skillsError: string | null; + skillsFilter: string; + skillEdits: Record; + skillsBusyKey: string | null; + debugLoading: boolean; + debugStatus: StatusSummary | null; + debugHealth: HealthSnapshot | null; + debugModels: unknown[]; + debugHeartbeat: unknown | null; + debugCallMethod: string; + debugCallParams: string; + debugCallResult: string | null; + debugCallError: string | null; + client: GatewayBrowserClient | null; + connect: () => void; + setTab: (tab: Tab) => void; + applySettings: (next: AppViewState["settings"]) => void; + loadOverview: () => Promise; + loadCron: () => Promise; + handleWhatsAppStart: (force: boolean) => Promise; + handleWhatsAppWait: () => Promise; + handleWhatsAppLogout: () => Promise; + handleTelegramSave: () => Promise; + handleSendChat: () => Promise; +}; + +export function renderApp(state: AppViewState) { + const proto = state.settings.gatewayUrl.startsWith("wss://") ? "wss" : "ws"; + const presenceCount = state.presenceEntries.length; + const sessionsCount = state.sessionsResult?.count ?? null; + const cronNext = state.cronStatus?.nextWakeAtMs ?? null; + + return html` +
+
+
+
Clawdis Control
+
Gateway dashboard
+
+
+
+ + ${proto} + ${state.settings.gatewayUrl} +
+ + +
+
+ +
+
+
+
${titleForTab(state.tab)}
+
${subtitleForTab(state.tab)}
+
+
+ ${state.lastError + ? html`
${state.lastError}
` + : nothing} +
+
+ + ${state.tab === "overview" + ? renderOverview({ + connected: state.connected, + hello: state.hello, + settings: state.settings, + password: state.password, + lastError: state.lastError, + presenceCount, + sessionsCount, + cronEnabled: state.cronStatus?.enabled ?? null, + cronNext, + lastProvidersRefresh: state.providersLastSuccess, + onSettingsChange: (next) => state.applySettings(next), + onPasswordChange: (next) => (state.password = next), + onSessionKeyChange: (next) => { + state.sessionKey = next; + state.applySettings({ ...state.settings, sessionKey: next }); + }, + onRefresh: () => state.loadOverview(), + }) + : nothing} + + ${state.tab === "connections" + ? renderConnections({ + connected: state.connected, + loading: state.providersLoading, + snapshot: state.providersSnapshot, + lastError: state.providersError, + lastSuccessAt: state.providersLastSuccess, + whatsappMessage: state.whatsappLoginMessage, + whatsappQrDataUrl: state.whatsappLoginQrDataUrl, + whatsappConnected: state.whatsappLoginConnected, + whatsappBusy: state.whatsappBusy, + telegramForm: state.telegramForm, + telegramTokenLocked: state.telegramTokenLocked, + telegramSaving: state.telegramSaving, + telegramStatus: state.telegramConfigStatus, + onRefresh: (probe) => loadProviders(state, probe), + onWhatsAppStart: (force) => state.handleWhatsAppStart(force), + onWhatsAppWait: () => state.handleWhatsAppWait(), + onWhatsAppLogout: () => state.handleWhatsAppLogout(), + onTelegramChange: (patch) => updateTelegramForm(state, patch), + onTelegramSave: () => state.handleTelegramSave(), + }) + : nothing} + + ${state.tab === "instances" + ? renderInstances({ + loading: state.presenceLoading, + entries: state.presenceEntries, + lastError: state.presenceError, + statusMessage: state.presenceStatus, + onRefresh: () => loadPresence(state), + }) + : nothing} + + ${state.tab === "sessions" + ? renderSessions({ + loading: state.sessionsLoading, + result: state.sessionsResult, + error: state.sessionsError, + activeMinutes: state.sessionsFilterActive, + limit: state.sessionsFilterLimit, + includeGlobal: state.sessionsIncludeGlobal, + includeUnknown: state.sessionsIncludeUnknown, + onFiltersChange: (next) => { + state.sessionsFilterActive = next.activeMinutes; + state.sessionsFilterLimit = next.limit; + state.sessionsIncludeGlobal = next.includeGlobal; + state.sessionsIncludeUnknown = next.includeUnknown; + }, + onRefresh: () => loadSessions(state), + onPatch: (key, patch) => patchSession(state, key, patch), + }) + : nothing} + + ${state.tab === "cron" + ? renderCron({ + loading: state.cronLoading, + status: state.cronStatus, + jobs: state.cronJobs, + error: state.cronError, + busy: state.cronBusy, + form: state.cronForm, + runsJobId: state.cronRunsJobId, + runs: state.cronRuns, + onFormChange: (patch) => (state.cronForm = { ...state.cronForm, ...patch }), + onRefresh: () => state.loadCron(), + onAdd: () => addCronJob(state), + onToggle: (job, enabled) => toggleCronJob(state, job, enabled), + onRun: (job) => runCronJob(state, job), + onRemove: (job) => removeCronJob(state, job), + onLoadRuns: (jobId) => loadCronRuns(state, jobId), + }) + : nothing} + + ${state.tab === "skills" + ? renderSkills({ + loading: state.skillsLoading, + report: state.skillsReport, + error: state.skillsError, + filter: state.skillsFilter, + edits: state.skillEdits, + busyKey: state.skillsBusyKey, + onFilterChange: (next) => (state.skillsFilter = next), + onRefresh: () => loadSkills(state), + onToggle: (key, enabled) => updateSkillEnabled(state, key, enabled), + onEdit: (key, value) => updateSkillEdit(state, key, value), + onSaveKey: (key) => saveSkillApiKey(state, key), + onInstall: (name, installId) => installSkill(state, name, installId), + }) + : nothing} + + ${state.tab === "nodes" + ? renderNodes({ + loading: state.nodesLoading, + nodes: state.nodes, + onRefresh: () => loadNodes(state), + }) + : nothing} + + ${state.tab === "chat" + ? renderChat({ + sessionKey: state.sessionKey, + onSessionKeyChange: (next) => { + state.sessionKey = next; + state.applySettings({ ...state.settings, sessionKey: next }); + }, + thinkingLevel: state.chatThinkingLevel, + loading: state.chatLoading, + sending: state.chatSending, + messages: state.chatMessages, + stream: state.chatStream, + draft: state.chatMessage, + connected: state.connected, + onRefresh: () => loadChatHistory(state), + onDraftChange: (next) => (state.chatMessage = next), + onSend: () => state.handleSendChat(), + }) + : nothing} + + ${state.tab === "config" + ? renderConfig({ + raw: state.configRaw, + valid: state.configValid, + issues: state.configIssues, + loading: state.configLoading, + saving: state.configSaving, + connected: state.connected, + onRawChange: (next) => (state.configRaw = next), + onReload: () => loadConfig(state), + onSave: () => saveConfig(state), + }) + : nothing} + + ${state.tab === "debug" + ? renderDebug({ + loading: state.debugLoading, + status: state.debugStatus, + health: state.debugHealth, + models: state.debugModels, + heartbeat: state.debugHeartbeat, + eventLog: state.eventLog, + callMethod: state.debugCallMethod, + callParams: state.debugCallParams, + callResult: state.debugCallResult, + callError: state.debugCallError, + onCallMethodChange: (next) => (state.debugCallMethod = next), + onCallParamsChange: (next) => (state.debugCallParams = next), + onRefresh: () => loadDebug(state), + onCall: () => callDebugMethod(state), + }) + : nothing} +
+
+ `; +} + +function renderTab(state: AppViewState, tab: Tab) { + return html` + + `; +} diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index cb8374362..fceed164b 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -1,203 +1,172 @@ -import { LitElement, css, html, nothing } from "lit"; +import { LitElement, html, nothing } from "lit"; import { customElement, state } from "lit/decorators.js"; -import { GatewayBrowserClient, type GatewayEventFrame } from "./gateway"; +import { GatewayBrowserClient, type GatewayEventFrame, type GatewayHelloOk } from "./gateway"; import { loadSettings, saveSettings, type UiSettings } from "./storage"; +import { renderApp } from "./app-render"; +import type { Tab } from "./navigation"; +import type { + ConfigSnapshot, + CronJob, + CronRunLogEntry, + CronStatus, + HealthSnapshot, + PresenceEntry, + ProvidersStatusSnapshot, + SessionsListResult, + SkillStatusReport, + StatusSummary, +} from "./types"; +import type { CronFormState, TelegramForm } from "./ui-types"; +import { loadChatHistory, sendChat, handleChatEvent } from "./controllers/chat"; +import { loadNodes } from "./controllers/nodes"; +import { loadConfig } from "./controllers/config"; +import { + loadProviders, + logoutWhatsApp, + saveTelegramConfig, + startWhatsAppLogin, + waitWhatsAppLogin, +} from "./controllers/connections"; +import { loadPresence } from "./controllers/presence"; +import { loadSessions } from "./controllers/sessions"; +import { + loadCronJobs, + loadCronStatus, +} from "./controllers/cron"; +import { + loadSkills, +} from "./controllers/skills"; +import { loadDebug } from "./controllers/debug"; -type Tab = "chat" | "nodes" | "config"; +type EventLogEntry = { + ts: number; + event: string; + payload?: unknown; +}; + +const DEFAULT_CRON_FORM: CronFormState = { + name: "", + description: "", + enabled: true, + scheduleKind: "every", + scheduleAt: "", + everyAmount: "30", + everyUnit: "minutes", + cronExpr: "0 7 * * *", + cronTz: "", + sessionTarget: "main", + wakeMode: "next-heartbeat", + payloadKind: "systemEvent", + payloadText: "", + deliver: false, + channel: "last", + to: "", + timeoutSeconds: "", + postToMainPrefix: "", +}; @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; - min-width: 0; - } - .msg { - border: 1px solid var(--border); - background: rgba(0, 0, 0, 0.2); - border-radius: 14px; - padding: 10px 12px; - min-width: 0; - } - .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); - } - .msgContent { - white-space: pre-wrap; - overflow-wrap: anywhere; - word-break: break-word; - } - .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() settings: UiSettings = loadSettings(); + @state() password = ""; + @state() tab: Tab = "overview"; + @state() connected = false; + @state() hello: GatewayHelloOk | null = null; + @state() lastError: string | null = null; + @state() eventLog: EventLogEntry[] = []; - @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() sessionKey = this.settings.sessionKey; + @state() chatLoading = false; + @state() chatSending = false; + @state() chatMessage = ""; + @state() chatMessages: unknown[] = []; + @state() chatStream: string | null = null; + @state() chatRunId: string | null = null; + @state() chatThinkingLevel: 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() nodesLoading = false; + @state() nodes: Array> = []; - @state() private nodesLoading = false; - @state() private nodes: Array> = []; + @state() configLoading = false; + @state() configRaw = "{\n}\n"; + @state() configValid: boolean | null = null; + @state() configIssues: unknown[] = []; + @state() configSaving = false; + @state() configSnapshot: ConfigSnapshot | null = null; - @state() private configLoading = false; - @state() private configRaw = "{\n}\n"; - @state() private configValid: boolean | null = null; - @state() private configIssues: unknown[] = []; - @state() private configSaving = false; + @state() providersLoading = false; + @state() providersSnapshot: ProvidersStatusSnapshot | null = null; + @state() providersError: string | null = null; + @state() providersLastSuccess: number | null = null; + @state() whatsappLoginMessage: string | null = null; + @state() whatsappLoginQrDataUrl: string | null = null; + @state() whatsappLoginConnected: boolean | null = null; + @state() whatsappBusy = false; + @state() telegramForm: TelegramForm = { + token: "", + requireMention: true, + allowFrom: "", + proxy: "", + webhookUrl: "", + webhookSecret: "", + webhookPath: "", + }; + @state() telegramSaving = false; + @state() telegramTokenLocked = false; + @state() telegramConfigStatus: string | null = null; - private client: GatewayBrowserClient | null = null; + @state() presenceLoading = false; + @state() presenceEntries: PresenceEntry[] = []; + @state() presenceError: string | null = null; + @state() presenceStatus: string | null = null; + + @state() sessionsLoading = false; + @state() sessionsResult: SessionsListResult | null = null; + @state() sessionsError: string | null = null; + @state() sessionsFilterActive = ""; + @state() sessionsFilterLimit = "120"; + @state() sessionsIncludeGlobal = true; + @state() sessionsIncludeUnknown = false; + + @state() cronLoading = false; + @state() cronJobs: CronJob[] = []; + @state() cronStatus: CronStatus | null = null; + @state() cronError: string | null = null; + @state() cronForm: CronFormState = { ...DEFAULT_CRON_FORM }; + @state() cronRunsJobId: string | null = null; + @state() cronRuns: CronRunLogEntry[] = []; + @state() cronBusy = false; + + @state() skillsLoading = false; + @state() skillsReport: SkillStatusReport | null = null; + @state() skillsError: string | null = null; + @state() skillsFilter = ""; + @state() skillEdits: Record = {}; + @state() skillsBusyKey: string | null = null; + + @state() debugLoading = false; + @state() debugStatus: StatusSummary | null = null; + @state() debugHealth: HealthSnapshot | null = null; + @state() debugModels: unknown[] = []; + @state() debugHeartbeat: unknown | null = null; + @state() debugCallMethod = ""; + @state() debugCallParams = "{}"; + @state() debugCallResult: string | null = null; + @state() debugCallError: string | null = null; + + client: GatewayBrowserClient | null = null; + + createRenderRoot() { + return this; + } connectedCallback() { super.connectedCallback(); this.connect(); } - private connect() { + connect() { this.lastError = null; this.hello = null; this.connected = false; @@ -206,11 +175,16 @@ export class ClawdisApp extends LitElement { this.client = new GatewayBrowserClient({ url: this.settings.gatewayUrl, token: this.settings.token.trim() ? this.settings.token : undefined, + username: this.settings.username.trim() + ? this.settings.username.trim() + : undefined, + password: this.password.trim() ? this.password : undefined, clientName: "clawdis-control-ui", mode: "webchat", onHello: (hello) => { this.connected = true; this.hello = hello; + this.applySnapshot(hello); void this.refreshActiveTab(); }, onClose: ({ code, reason }) => { @@ -226,476 +200,112 @@ export class ClawdisApp extends LitElement { } private onEvent(evt: GatewayEventFrame) { + this.eventLog = [ + { ts: Date.now(), event: evt.event, payload: evt.payload }, + ...this.eventLog, + ].slice(0, 250); + 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; + const state = handleChatEvent(this, evt.payload as unknown); + if (state === "final") void loadChatHistory(this); + 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"; + if (evt.event === "presence") { + const payload = evt.payload as { presence?: PresenceEntry[] } | undefined; + if (payload?.presence && Array.isArray(payload.presence)) { + this.presenceEntries = payload.presence; + this.presenceError = null; + this.presenceStatus = null; } + return; + } + + if (evt.event === "cron" && this.tab === "cron") { + void this.loadCron(); } } - 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 applySnapshot(hello: GatewayHelloOk) { + const snapshot = hello.snapshot as + | { presence?: PresenceEntry[]; health?: HealthSnapshot } + | undefined; + if (snapshot?.presence && Array.isArray(snapshot.presence)) { + this.presenceEntries = snapshot.presence; + } + if (snapshot?.health) { + this.debugHealth = snapshot.health; } } - 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) { + applySettings(next: UiSettings) { this.settings = next; saveSettings(next); } + setTab(next: Tab) { + this.tab = next; + void this.refreshActiveTab(); + } + + private async refreshActiveTab() { + if (this.tab === "overview") await this.loadOverview(); + if (this.tab === "connections") await this.loadConnections(); + if (this.tab === "instances") await loadPresence(this); + if (this.tab === "sessions") await loadSessions(this); + if (this.tab === "cron") await this.loadCron(); + if (this.tab === "skills") await loadSkills(this); + if (this.tab === "nodes") await loadNodes(this); + if (this.tab === "chat") await loadChatHistory(this); + if (this.tab === "config") await loadConfig(this); + if (this.tab === "debug") await loadDebug(this); + } + + async loadOverview() { + await Promise.all([ + loadProviders(this, false), + loadPresence(this), + loadSessions(this), + loadCronStatus(this), + loadDebug(this), + ]); + } + + private async loadConnections() { + await Promise.all([loadProviders(this, true), loadConfig(this)]); + } + + async loadCron() { + await Promise.all([loadCronStatus(this), loadCronJobs(this)]); + } + + async handleSendChat() { + await sendChat(this); + void loadChatHistory(this); + } + + async handleWhatsAppStart(force: boolean) { + await startWhatsAppLogin(this, force); + await loadProviders(this, true); + } + + async handleWhatsAppWait() { + await waitWhatsAppLogin(this); + await loadProviders(this, true); + } + + async handleWhatsAppLogout() { + await logoutWhatsApp(this); + await loadProviders(this, true); + } + + async handleTelegramSave() { + await saveTelegramConfig(this); + await loadConfig(this); + await loadProviders(this, true); + } + 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} -
- `; + return renderApp(this); } } - -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/controllers/chat.ts b/ui/src/ui/controllers/chat.ts new file mode 100644 index 000000000..f5ba40055 --- /dev/null +++ b/ui/src/ui/controllers/chat.ts @@ -0,0 +1,108 @@ +import type { GatewayBrowserClient } from "../gateway"; + +export type ChatState = { + client: GatewayBrowserClient | null; + connected: boolean; + sessionKey: string; + chatLoading: boolean; + chatMessages: unknown[]; + chatThinkingLevel: string | null; + chatSending: boolean; + chatMessage: string; + chatRunId: string | null; + chatStream: string | null; + lastError: string | null; +}; + +type ChatEventPayload = { + runId: string; + sessionKey: string; + state: "delta" | "final" | "aborted" | "error"; + message?: unknown; + errorMessage?: string; +}; + +export async function loadChatHistory(state: ChatState) { + if (!state.client || !state.connected) return; + state.chatLoading = true; + state.lastError = null; + try { + const res = (await state.client.request("chat.history", { + sessionKey: state.sessionKey, + limit: 200, + })) as { messages?: unknown[]; thinkingLevel?: string | null }; + state.chatMessages = Array.isArray(res.messages) ? res.messages : []; + state.chatThinkingLevel = res.thinkingLevel ?? null; + } catch (err) { + state.lastError = String(err); + } finally { + state.chatLoading = false; + } +} + +export async function sendChat(state: ChatState) { + if (!state.client || !state.connected) return; + const msg = state.chatMessage.trim(); + if (!msg) return; + + state.chatSending = true; + state.lastError = null; + const runId = crypto.randomUUID(); + state.chatRunId = runId; + state.chatStream = ""; + try { + await state.client.request("chat.send", { + sessionKey: state.sessionKey, + message: msg, + deliver: false, + idempotencyKey: runId, + }); + state.chatMessage = ""; + } catch (err) { + state.chatRunId = null; + state.chatStream = null; + state.lastError = String(err); + } finally { + state.chatSending = false; + } +} + +export function handleChatEvent( + state: ChatState, + payload?: ChatEventPayload, +) { + if (!payload) return null; + if (payload.sessionKey !== state.sessionKey) return null; + if (payload.runId && state.chatRunId && payload.runId !== state.chatRunId) + return null; + + if (payload.state === "delta") { + state.chatStream = extractText(payload.message) ?? state.chatStream; + } else if (payload.state === "final") { + state.chatStream = null; + state.chatRunId = null; + } else if (payload.state === "error") { + state.chatStream = null; + state.chatRunId = null; + state.lastError = payload.errorMessage ?? "chat error"; + } + return payload.state; +} + +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/controllers/config.ts b/ui/src/ui/controllers/config.ts new file mode 100644 index 000000000..fe99633e1 --- /dev/null +++ b/ui/src/ui/controllers/config.ts @@ -0,0 +1,82 @@ +import type { GatewayBrowserClient } from "../gateway"; +import type { ConfigSnapshot } from "../types"; +import type { TelegramForm } from "../ui-types"; + +export type ConfigState = { + client: GatewayBrowserClient | null; + connected: boolean; + configLoading: boolean; + configRaw: string; + configValid: boolean | null; + configIssues: unknown[]; + configSaving: boolean; + configSnapshot: ConfigSnapshot | null; + lastError: string | null; + telegramForm: TelegramForm; + telegramConfigStatus: string | null; +}; + +export async function loadConfig(state: ConfigState) { + if (!state.client || !state.connected) return; + state.configLoading = true; + state.lastError = null; + try { + const res = (await state.client.request("config.get", {})) as ConfigSnapshot; + applyConfigSnapshot(state, res); + } catch (err) { + state.lastError = String(err); + } finally { + state.configLoading = false; + } +} + +export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot) { + state.configSnapshot = snapshot; + if (typeof snapshot.raw === "string") { + state.configRaw = snapshot.raw; + } else if (snapshot.config && typeof snapshot.config === "object") { + state.configRaw = `${JSON.stringify(snapshot.config, null, 2).trimEnd()}\n`; + } + state.configValid = typeof snapshot.valid === "boolean" ? snapshot.valid : null; + state.configIssues = Array.isArray(snapshot.issues) ? snapshot.issues : []; + + const config = snapshot.config ?? {}; + const telegram = (config.telegram ?? {}) as Record; + const allowFrom = Array.isArray(telegram.allowFrom) + ? (telegram.allowFrom as unknown[]) + .map((v) => String(v ?? "").trim()) + .filter((v) => v.length > 0) + .join(", ") + : typeof telegram.allowFrom === "string" + ? telegram.allowFrom + : ""; + + state.telegramForm = { + token: typeof telegram.botToken === "string" ? telegram.botToken : "", + requireMention: + typeof telegram.requireMention === "boolean" ? telegram.requireMention : true, + allowFrom, + proxy: typeof telegram.proxy === "string" ? telegram.proxy : "", + webhookUrl: typeof telegram.webhookUrl === "string" ? telegram.webhookUrl : "", + webhookSecret: + typeof telegram.webhookSecret === "string" ? telegram.webhookSecret : "", + webhookPath: typeof telegram.webhookPath === "string" ? telegram.webhookPath : "", + }; + + state.telegramConfigStatus = snapshot.valid === false ? "Config invalid." : null; +} + +export async function saveConfig(state: ConfigState) { + if (!state.client || !state.connected) return; + state.configSaving = true; + state.lastError = null; + try { + await state.client.request("config.set", { raw: state.configRaw }); + await loadConfig(state); + } catch (err) { + state.lastError = String(err); + } finally { + state.configSaving = false; + } +} + diff --git a/ui/src/ui/controllers/connections.ts b/ui/src/ui/controllers/connections.ts new file mode 100644 index 000000000..ca3a3fd73 --- /dev/null +++ b/ui/src/ui/controllers/connections.ts @@ -0,0 +1,145 @@ +import type { GatewayBrowserClient } from "../gateway"; +import { parseList } from "../format"; +import type { ConfigSnapshot, ProvidersStatusSnapshot } from "../types"; +import type { TelegramForm } from "../ui-types"; + +export type ConnectionsState = { + client: GatewayBrowserClient | null; + connected: boolean; + providersLoading: boolean; + providersSnapshot: ProvidersStatusSnapshot | null; + providersError: string | null; + providersLastSuccess: number | null; + whatsappLoginMessage: string | null; + whatsappLoginQrDataUrl: string | null; + whatsappLoginConnected: boolean | null; + whatsappBusy: boolean; + telegramForm: TelegramForm; + telegramSaving: boolean; + telegramTokenLocked: boolean; + telegramConfigStatus: string | null; + configSnapshot: ConfigSnapshot | null; +}; + +export async function loadProviders(state: ConnectionsState, probe: boolean) { + if (!state.client || !state.connected) return; + if (state.providersLoading) return; + state.providersLoading = true; + state.providersError = null; + try { + const res = (await state.client.request("providers.status", { + probe, + timeoutMs: 8000, + })) as ProvidersStatusSnapshot; + state.providersSnapshot = res; + state.providersLastSuccess = Date.now(); + state.telegramTokenLocked = res.telegram.tokenSource === "env"; + } catch (err) { + state.providersError = String(err); + } finally { + state.providersLoading = false; + } +} + +export async function startWhatsAppLogin(state: ConnectionsState, force: boolean) { + if (!state.client || !state.connected || state.whatsappBusy) return; + state.whatsappBusy = true; + try { + const res = (await state.client.request("web.login.start", { + force, + timeoutMs: 30000, + })) as { message?: string; qrDataUrl?: string }; + state.whatsappLoginMessage = res.message ?? null; + state.whatsappLoginQrDataUrl = res.qrDataUrl ?? null; + state.whatsappLoginConnected = null; + } catch (err) { + state.whatsappLoginMessage = String(err); + state.whatsappLoginQrDataUrl = null; + state.whatsappLoginConnected = null; + } finally { + state.whatsappBusy = false; + } +} + +export async function waitWhatsAppLogin(state: ConnectionsState) { + if (!state.client || !state.connected || state.whatsappBusy) return; + state.whatsappBusy = true; + try { + const res = (await state.client.request("web.login.wait", { + timeoutMs: 120000, + })) as { connected?: boolean; message?: string }; + state.whatsappLoginMessage = res.message ?? null; + state.whatsappLoginConnected = res.connected ?? null; + if (res.connected) state.whatsappLoginQrDataUrl = null; + } catch (err) { + state.whatsappLoginMessage = String(err); + state.whatsappLoginConnected = null; + } finally { + state.whatsappBusy = false; + } +} + +export async function logoutWhatsApp(state: ConnectionsState) { + if (!state.client || !state.connected || state.whatsappBusy) return; + state.whatsappBusy = true; + try { + await state.client.request("web.logout", {}); + state.whatsappLoginMessage = "Logged out."; + state.whatsappLoginQrDataUrl = null; + state.whatsappLoginConnected = null; + } catch (err) { + state.whatsappLoginMessage = String(err); + } finally { + state.whatsappBusy = false; + } +} + +export function updateTelegramForm( + state: ConnectionsState, + patch: Partial, +) { + state.telegramForm = { ...state.telegramForm, ...patch }; +} + +export async function saveTelegramConfig(state: ConnectionsState) { + if (!state.client || !state.connected) return; + if (state.telegramSaving) return; + state.telegramSaving = true; + state.telegramConfigStatus = null; + try { + const base = state.configSnapshot?.config ?? {}; + const config = { ...base } as Record; + const telegram = { ...(config.telegram ?? {}) } as Record; + if (!state.telegramTokenLocked) { + const token = state.telegramForm.token.trim(); + if (token) telegram.botToken = token; + else delete telegram.botToken; + } + telegram.requireMention = state.telegramForm.requireMention; + const allowFrom = parseList(state.telegramForm.allowFrom); + if (allowFrom.length > 0) telegram.allowFrom = allowFrom; + else delete telegram.allowFrom; + const proxy = state.telegramForm.proxy.trim(); + if (proxy) telegram.proxy = proxy; + else delete telegram.proxy; + const webhookUrl = state.telegramForm.webhookUrl.trim(); + if (webhookUrl) telegram.webhookUrl = webhookUrl; + else delete telegram.webhookUrl; + const webhookSecret = state.telegramForm.webhookSecret.trim(); + if (webhookSecret) telegram.webhookSecret = webhookSecret; + else delete telegram.webhookSecret; + const webhookPath = state.telegramForm.webhookPath.trim(); + if (webhookPath) telegram.webhookPath = webhookPath; + else delete telegram.webhookPath; + + config.telegram = telegram; + const raw = `${JSON.stringify(config, null, 2).trimEnd()}\n`; + await state.client.request("config.set", { raw }); + state.telegramConfigStatus = "Saved. Restart gateway if needed."; + } catch (err) { + state.telegramConfigStatus = String(err); + } finally { + state.telegramSaving = false; + } +} + diff --git a/ui/src/ui/controllers/cron.ts b/ui/src/ui/controllers/cron.ts new file mode 100644 index 000000000..fa90455d3 --- /dev/null +++ b/ui/src/ui/controllers/cron.ts @@ -0,0 +1,191 @@ +import { toNumber } from "../format"; +import type { GatewayBrowserClient } from "../gateway"; +import type { CronJob, CronRunLogEntry, CronStatus } from "../types"; +import type { CronFormState } from "../ui-types"; + +export type CronState = { + client: GatewayBrowserClient | null; + connected: boolean; + cronLoading: boolean; + cronJobs: CronJob[]; + cronStatus: CronStatus | null; + cronError: string | null; + cronForm: CronFormState; + cronRunsJobId: string | null; + cronRuns: CronRunLogEntry[]; + cronBusy: boolean; +}; + +export async function loadCronStatus(state: CronState) { + if (!state.client || !state.connected) return; + try { + const res = (await state.client.request("cron.status", {})) as CronStatus; + state.cronStatus = res; + } catch (err) { + state.cronError = String(err); + } +} + +export async function loadCronJobs(state: CronState) { + if (!state.client || !state.connected) return; + if (state.cronLoading) return; + state.cronLoading = true; + state.cronError = null; + try { + const res = (await state.client.request("cron.list", { + includeDisabled: true, + })) as { jobs?: CronJob[] }; + state.cronJobs = Array.isArray(res.jobs) ? res.jobs : []; + } catch (err) { + state.cronError = String(err); + } finally { + state.cronLoading = false; + } +} + +export function buildCronSchedule(form: CronFormState) { + if (form.scheduleKind === "at") { + const ms = Date.parse(form.scheduleAt); + if (!Number.isFinite(ms)) throw new Error("Invalid run time."); + return { kind: "at" as const, atMs: ms }; + } + if (form.scheduleKind === "every") { + const amount = toNumber(form.everyAmount, 0); + if (amount <= 0) throw new Error("Invalid interval amount."); + const unit = form.everyUnit; + const mult = unit === "minutes" ? 60_000 : unit === "hours" ? 3_600_000 : 86_400_000; + return { kind: "every" as const, everyMs: amount * mult }; + } + const expr = form.cronExpr.trim(); + if (!expr) throw new Error("Cron expression required."); + return { kind: "cron" as const, expr, tz: form.cronTz.trim() || undefined }; +} + +export function buildCronPayload(form: CronFormState) { + if (form.payloadKind === "systemEvent") { + const text = form.payloadText.trim(); + if (!text) throw new Error("System event text required."); + return { kind: "systemEvent" as const, text }; + } + const message = form.payloadText.trim(); + if (!message) throw new Error("Agent message required."); + const payload: { + kind: "agentTurn"; + message: string; + deliver?: boolean; + channel?: "last" | "whatsapp" | "telegram"; + to?: string; + timeoutSeconds?: number; + } = { kind: "agentTurn", message }; + if (form.deliver) payload.deliver = true; + if (form.channel) payload.channel = form.channel; + if (form.to.trim()) payload.to = form.to.trim(); + const timeoutSeconds = toNumber(form.timeoutSeconds, 0); + if (timeoutSeconds > 0) payload.timeoutSeconds = timeoutSeconds; + return payload; +} + +export async function addCronJob(state: CronState) { + if (!state.client || !state.connected || state.cronBusy) return; + state.cronBusy = true; + state.cronError = null; + try { + const schedule = buildCronSchedule(state.cronForm); + const payload = buildCronPayload(state.cronForm); + const job = { + name: state.cronForm.name.trim(), + description: state.cronForm.description.trim() || undefined, + enabled: state.cronForm.enabled, + schedule, + sessionTarget: state.cronForm.sessionTarget, + wakeMode: state.cronForm.wakeMode, + payload, + isolation: + state.cronForm.postToMainPrefix.trim() && + state.cronForm.sessionTarget === "isolated" + ? { postToMainPrefix: state.cronForm.postToMainPrefix.trim() } + : undefined, + }; + if (!job.name) throw new Error("Name required."); + await state.client.request("cron.add", job); + state.cronForm = { + ...state.cronForm, + name: "", + description: "", + payloadText: "", + }; + await loadCronJobs(state); + await loadCronStatus(state); + } catch (err) { + state.cronError = String(err); + } finally { + state.cronBusy = false; + } +} + +export async function toggleCronJob( + state: CronState, + job: CronJob, + enabled: boolean, +) { + if (!state.client || !state.connected || state.cronBusy) return; + state.cronBusy = true; + state.cronError = null; + try { + await state.client.request("cron.update", { id: job.id, patch: { enabled } }); + await loadCronJobs(state); + await loadCronStatus(state); + } catch (err) { + state.cronError = String(err); + } finally { + state.cronBusy = false; + } +} + +export async function runCronJob(state: CronState, job: CronJob) { + if (!state.client || !state.connected || state.cronBusy) return; + state.cronBusy = true; + state.cronError = null; + try { + await state.client.request("cron.run", { id: job.id, mode: "force" }); + await loadCronRuns(state, job.id); + } catch (err) { + state.cronError = String(err); + } finally { + state.cronBusy = false; + } +} + +export async function removeCronJob(state: CronState, job: CronJob) { + if (!state.client || !state.connected || state.cronBusy) return; + state.cronBusy = true; + state.cronError = null; + try { + await state.client.request("cron.remove", { id: job.id }); + if (state.cronRunsJobId === job.id) { + state.cronRunsJobId = null; + state.cronRuns = []; + } + await loadCronJobs(state); + await loadCronStatus(state); + } catch (err) { + state.cronError = String(err); + } finally { + state.cronBusy = false; + } +} + +export async function loadCronRuns(state: CronState, jobId: string) { + if (!state.client || !state.connected) return; + try { + const res = (await state.client.request("cron.runs", { + id: jobId, + limit: 50, + })) as { entries?: CronRunLogEntry[] }; + state.cronRunsJobId = jobId; + state.cronRuns = Array.isArray(res.entries) ? res.entries : []; + } catch (err) { + state.cronError = String(err); + } +} + diff --git a/ui/src/ui/controllers/debug.ts b/ui/src/ui/controllers/debug.ts new file mode 100644 index 000000000..78993a3af --- /dev/null +++ b/ui/src/ui/controllers/debug.ts @@ -0,0 +1,57 @@ +import type { GatewayBrowserClient } from "../gateway"; +import type { HealthSnapshot, StatusSummary } from "../types"; + +export type DebugState = { + client: GatewayBrowserClient | null; + connected: boolean; + debugLoading: boolean; + debugStatus: StatusSummary | null; + debugHealth: HealthSnapshot | null; + debugModels: unknown[]; + debugHeartbeat: unknown | null; + debugCallMethod: string; + debugCallParams: string; + debugCallResult: string | null; + debugCallError: string | null; +}; + +export async function loadDebug(state: DebugState) { + if (!state.client || !state.connected) return; + if (state.debugLoading) return; + state.debugLoading = true; + try { + const [status, health, models, heartbeat] = await Promise.all([ + state.client.request("status", {}), + state.client.request("health", {}), + state.client.request("models.list", {}), + state.client.request("last-heartbeat", {}), + ]); + state.debugStatus = status as StatusSummary; + state.debugHealth = health as HealthSnapshot; + const modelPayload = models as { models?: unknown[] } | undefined; + state.debugModels = Array.isArray(modelPayload?.models) + ? modelPayload?.models + : []; + state.debugHeartbeat = heartbeat as unknown; + } catch (err) { + state.debugCallError = String(err); + } finally { + state.debugLoading = false; + } +} + +export async function callDebugMethod(state: DebugState) { + if (!state.client || !state.connected) return; + state.debugCallError = null; + state.debugCallResult = null; + try { + const params = state.debugCallParams.trim() + ? (JSON.parse(state.debugCallParams) as unknown) + : {}; + const res = await state.client.request(state.debugCallMethod.trim(), params); + state.debugCallResult = JSON.stringify(res, null, 2); + } catch (err) { + state.debugCallError = String(err); + } +} + diff --git a/ui/src/ui/controllers/nodes.ts b/ui/src/ui/controllers/nodes.ts new file mode 100644 index 000000000..3fa39afd6 --- /dev/null +++ b/ui/src/ui/controllers/nodes.ts @@ -0,0 +1,26 @@ +import type { GatewayBrowserClient } from "../gateway"; + +export type NodesState = { + client: GatewayBrowserClient | null; + connected: boolean; + nodesLoading: boolean; + nodes: Array>; + lastError: string | null; +}; + +export async function loadNodes(state: NodesState) { + if (!state.client || !state.connected) return; + state.nodesLoading = true; + state.lastError = null; + try { + const res = (await state.client.request("node.list", {})) as { + nodes?: Array>; + }; + state.nodes = Array.isArray(res.nodes) ? res.nodes : []; + } catch (err) { + state.lastError = String(err); + } finally { + state.nodesLoading = false; + } +} + diff --git a/ui/src/ui/controllers/presence.ts b/ui/src/ui/controllers/presence.ts new file mode 100644 index 000000000..4154307b1 --- /dev/null +++ b/ui/src/ui/controllers/presence.ts @@ -0,0 +1,36 @@ +import type { GatewayBrowserClient } from "../gateway"; +import type { PresenceEntry } from "../types"; + +export type PresenceState = { + client: GatewayBrowserClient | null; + connected: boolean; + presenceLoading: boolean; + presenceEntries: PresenceEntry[]; + presenceError: string | null; + presenceStatus: string | null; +}; + +export async function loadPresence(state: PresenceState) { + if (!state.client || !state.connected) return; + if (state.presenceLoading) return; + state.presenceLoading = true; + state.presenceError = null; + state.presenceStatus = null; + try { + const res = (await state.client.request("system-presence", {})) as + | PresenceEntry[] + | undefined; + if (Array.isArray(res)) { + state.presenceEntries = res; + state.presenceStatus = res.length === 0 ? "No instances yet." : null; + } else { + state.presenceEntries = []; + state.presenceStatus = "No presence payload."; + } + } catch (err) { + state.presenceError = String(err); + } finally { + state.presenceLoading = false; + } +} + diff --git a/ui/src/ui/controllers/sessions.ts b/ui/src/ui/controllers/sessions.ts new file mode 100644 index 000000000..685660742 --- /dev/null +++ b/ui/src/ui/controllers/sessions.ts @@ -0,0 +1,58 @@ +import type { GatewayBrowserClient } from "../gateway"; +import { toNumber } from "../format"; +import type { SessionsListResult } from "../types"; + +export type SessionsState = { + client: GatewayBrowserClient | null; + connected: boolean; + sessionsLoading: boolean; + sessionsResult: SessionsListResult | null; + sessionsError: string | null; + sessionsFilterActive: string; + sessionsFilterLimit: string; + sessionsIncludeGlobal: boolean; + sessionsIncludeUnknown: boolean; +}; + +export async function loadSessions(state: SessionsState) { + if (!state.client || !state.connected) return; + if (state.sessionsLoading) return; + state.sessionsLoading = true; + state.sessionsError = null; + try { + const params: Record = { + includeGlobal: state.sessionsIncludeGlobal, + includeUnknown: state.sessionsIncludeUnknown, + }; + const activeMinutes = toNumber(state.sessionsFilterActive, 0); + const limit = toNumber(state.sessionsFilterLimit, 0); + if (activeMinutes > 0) params.activeMinutes = activeMinutes; + if (limit > 0) params.limit = limit; + const res = (await state.client.request("sessions.list", params)) as + | SessionsListResult + | undefined; + if (res) state.sessionsResult = res; + } catch (err) { + state.sessionsError = String(err); + } finally { + state.sessionsLoading = false; + } +} + +export async function patchSession( + state: SessionsState, + key: string, + patch: { thinkingLevel?: string | null; verboseLevel?: string | null }, +) { + if (!state.client || !state.connected) return; + const params: Record = { key }; + if ("thinkingLevel" in patch) params.thinkingLevel = patch.thinkingLevel; + if ("verboseLevel" in patch) params.verboseLevel = patch.verboseLevel; + try { + await state.client.request("sessions.patch", params); + await loadSessions(state); + } catch (err) { + state.sessionsError = String(err); + } +} + diff --git a/ui/src/ui/controllers/skills.ts b/ui/src/ui/controllers/skills.ts new file mode 100644 index 000000000..fe3329956 --- /dev/null +++ b/ui/src/ui/controllers/skills.ts @@ -0,0 +1,93 @@ +import type { GatewayBrowserClient } from "../gateway"; +import type { SkillStatusReport } from "../types"; + +export type SkillsState = { + client: GatewayBrowserClient | null; + connected: boolean; + skillsLoading: boolean; + skillsReport: SkillStatusReport | null; + skillsError: string | null; + skillsBusyKey: string | null; + skillEdits: Record; +}; + +export async function loadSkills(state: SkillsState) { + if (!state.client || !state.connected) return; + if (state.skillsLoading) return; + state.skillsLoading = true; + state.skillsError = null; + try { + const res = (await state.client.request("skills.status", {})) as + | SkillStatusReport + | undefined; + if (res) state.skillsReport = res; + } catch (err) { + state.skillsError = String(err); + } finally { + state.skillsLoading = false; + } +} + +export function updateSkillEdit( + state: SkillsState, + skillKey: string, + value: string, +) { + state.skillEdits = { ...state.skillEdits, [skillKey]: value }; +} + +export async function updateSkillEnabled( + state: SkillsState, + skillKey: string, + enabled: boolean, +) { + if (!state.client || !state.connected) return; + state.skillsBusyKey = skillKey; + state.skillsError = null; + try { + await state.client.request("skills.update", { skillKey, enabled }); + await loadSkills(state); + } catch (err) { + state.skillsError = String(err); + } finally { + state.skillsBusyKey = null; + } +} + +export async function saveSkillApiKey(state: SkillsState, skillKey: string) { + if (!state.client || !state.connected) return; + state.skillsBusyKey = skillKey; + state.skillsError = null; + try { + const apiKey = state.skillEdits[skillKey] ?? ""; + await state.client.request("skills.update", { skillKey, apiKey }); + await loadSkills(state); + } catch (err) { + state.skillsError = String(err); + } finally { + state.skillsBusyKey = null; + } +} + +export async function installSkill( + state: SkillsState, + name: string, + installId: string, +) { + if (!state.client || !state.connected) return; + state.skillsBusyKey = name; + state.skillsError = null; + try { + await state.client.request("skills.install", { + name, + installId, + timeoutMs: 120000, + }); + await loadSkills(state); + } catch (err) { + state.skillsError = String(err); + } finally { + state.skillsBusyKey = null; + } +} + diff --git a/ui/src/ui/format.ts b/ui/src/ui/format.ts new file mode 100644 index 000000000..e1f55e817 --- /dev/null +++ b/ui/src/ui/format.ts @@ -0,0 +1,54 @@ +export function formatMs(ms?: number | null): string { + if (!ms && ms !== 0) return "n/a"; + return new Date(ms).toLocaleString(); +} + +export function formatAgo(ms?: number | null): string { + if (!ms && ms !== 0) return "n/a"; + const diff = Date.now() - ms; + if (diff < 0) return "just now"; + const sec = Math.round(diff / 1000); + if (sec < 60) return `${sec}s ago`; + const min = Math.round(sec / 60); + if (min < 60) return `${min}m ago`; + const hr = Math.round(min / 60); + if (hr < 48) return `${hr}h ago`; + const day = Math.round(hr / 24); + return `${day}d ago`; +} + +export function formatDurationMs(ms?: number | null): string { + if (!ms && ms !== 0) return "n/a"; + if (ms < 1000) return `${ms}ms`; + const sec = Math.round(ms / 1000); + if (sec < 60) return `${sec}s`; + const min = Math.round(sec / 60); + if (min < 60) return `${min}m`; + const hr = Math.round(min / 60); + if (hr < 48) return `${hr}h`; + const day = Math.round(hr / 24); + return `${day}d`; +} + +export function formatList(values?: Array): string { + if (!values || values.length === 0) return "none"; + return values.filter((v): v is string => Boolean(v && v.trim())).join(", "); +} + +export function clampText(value: string, max = 120): string { + if (value.length <= max) return value; + return `${value.slice(0, Math.max(0, max - 1))}…`; +} + +export function toNumber(value: string, fallback: number): number { + const n = Number(value); + return Number.isFinite(n) ? n : fallback; +} + +export function parseList(input: string): string[] { + return input + .split(/[,\n]/) + .map((v) => v.trim()) + .filter((v) => v.length > 0); +} + diff --git a/ui/src/ui/navigation.ts b/ui/src/ui/navigation.ts new file mode 100644 index 000000000..1905be48b --- /dev/null +++ b/ui/src/ui/navigation.ts @@ -0,0 +1,75 @@ +export const TAB_GROUPS = [ + { + label: "Control", + tabs: ["overview", "connections", "instances", "sessions", "cron"], + }, + { label: "Agent", tabs: ["chat", "skills", "nodes"] }, + { label: "Gateway", tabs: ["config", "debug"] }, +] as const; + +export type Tab = + | "overview" + | "connections" + | "instances" + | "sessions" + | "cron" + | "skills" + | "nodes" + | "chat" + | "config" + | "debug"; + +export function titleForTab(tab: Tab) { + switch (tab) { + case "overview": + return "Overview"; + case "connections": + return "Connections"; + case "instances": + return "Instances"; + case "sessions": + return "Sessions"; + case "cron": + return "Cron Jobs"; + case "skills": + return "Skills"; + case "nodes": + return "Nodes"; + case "chat": + return "Chat"; + case "config": + return "Config"; + case "debug": + return "Debug"; + default: + return "Control"; + } +} + +export function subtitleForTab(tab: Tab) { + switch (tab) { + case "overview": + return "Gateway status, entry points, and a fast health read."; + case "connections": + return "Link providers and keep transport settings in sync."; + case "instances": + return "Presence beacons from connected clients and nodes."; + case "sessions": + return "Inspect active sessions and adjust per-session defaults."; + case "cron": + return "Schedule wakeups and recurring agent runs."; + case "skills": + return "Manage skill availability and API key injection."; + case "nodes": + return "Paired devices, capabilities, and command exposure."; + case "chat": + return "Direct gateway chat session for quick interventions."; + case "config": + return "Edit ~/.clawdis/clawdis.json safely."; + case "debug": + return "Gateway snapshots, events, and manual RPC calls."; + default: + return ""; + } +} + diff --git a/ui/src/ui/presenter.ts b/ui/src/ui/presenter.ts new file mode 100644 index 000000000..9f3df3dcb --- /dev/null +++ b/ui/src/ui/presenter.ts @@ -0,0 +1,58 @@ +import { formatAgo, formatDurationMs, formatMs } from "./format"; +import type { CronJob, GatewaySessionRow, PresenceEntry } from "./types"; + +export function formatPresenceSummary(entry: PresenceEntry): string { + const host = entry.host ?? "unknown"; + const ip = entry.ip ? `(${entry.ip})` : ""; + const mode = entry.mode ?? ""; + const version = entry.version ?? ""; + return `${host} ${ip} ${mode} ${version}`.trim(); +} + +export function formatPresenceAge(entry: PresenceEntry): string { + const ts = entry.ts ?? null; + return ts ? formatAgo(ts) : "n/a"; +} + +export function formatNextRun(ms?: number | null) { + if (!ms) return "n/a"; + return `${formatMs(ms)} (${formatAgo(ms)})`; +} + +export function formatSessionTokens(row: GatewaySessionRow) { + if (row.totalTokens == null) return "n/a"; + const total = row.totalTokens ?? 0; + const ctx = row.contextTokens ?? 0; + return ctx ? `${total} / ${ctx}` : String(total); +} + +export function formatEventPayload(payload: unknown): string { + if (payload == null) return ""; + try { + return JSON.stringify(payload, null, 2); + } catch { + return String(payload); + } +} + +export function formatCronState(job: CronJob) { + const state = job.state ?? {}; + const next = state.nextRunAtMs ? formatMs(state.nextRunAtMs) : "n/a"; + const last = state.lastRunAtMs ? formatMs(state.lastRunAtMs) : "n/a"; + const status = state.lastStatus ?? "n/a"; + return `${status} · next ${next} · last ${last}`; +} + +export function formatCronSchedule(job: CronJob) { + const s = job.schedule; + if (s.kind === "at") return `At ${formatMs(s.atMs)}`; + if (s.kind === "every") return `Every ${formatDurationMs(s.everyMs)}`; + return `Cron ${s.expr}${s.tz ? ` (${s.tz})` : ""}`; +} + +export function formatCronPayload(job: CronJob) { + const p = job.payload; + if (p.kind === "systemEvent") return `System: ${p.text}`; + return `Agent: ${p.message}`; +} + diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts new file mode 100644 index 000000000..52adfe050 --- /dev/null +++ b/ui/src/ui/types.ts @@ -0,0 +1,251 @@ +export type ProvidersStatusSnapshot = { + ts: number; + whatsapp: WhatsAppStatus; + telegram: TelegramStatus; +}; + +export type WhatsAppSelf = { + e164?: string | null; + jid?: string | null; +}; + +export type WhatsAppDisconnect = { + at: number; + status?: number | null; + error?: string | null; + loggedOut?: boolean | null; +}; + +export type WhatsAppStatus = { + configured: boolean; + linked: boolean; + authAgeMs?: number | null; + self?: WhatsAppSelf | null; + running: boolean; + connected: boolean; + lastConnectedAt?: number | null; + lastDisconnect?: WhatsAppDisconnect | null; + reconnectAttempts: number; + lastMessageAt?: number | null; + lastEventAt?: number | null; + lastError?: string | null; +}; + +export type TelegramBot = { + id?: number | null; + username?: string | null; +}; + +export type TelegramWebhook = { + url?: string | null; + hasCustomCert?: boolean | null; +}; + +export type TelegramProbe = { + ok: boolean; + status?: number | null; + error?: string | null; + elapsedMs?: number | null; + bot?: TelegramBot | null; + webhook?: TelegramWebhook | null; +}; + +export type TelegramStatus = { + configured: boolean; + tokenSource?: string | null; + running: boolean; + mode?: string | null; + lastStartAt?: number | null; + lastStopAt?: number | null; + lastError?: string | null; + probe?: TelegramProbe | null; + lastProbeAt?: number | null; +}; + +export type ConfigSnapshotIssue = { + path: string; + message: string; +}; + +export type ConfigSnapshot = { + path?: string | null; + exists?: boolean | null; + raw?: string | null; + parsed?: unknown; + valid?: boolean | null; + config?: Record | null; + issues?: ConfigSnapshotIssue[] | null; +}; + +export type PresenceEntry = { + instanceId?: string | null; + host?: string | null; + ip?: string | null; + version?: string | null; + platform?: string | null; + deviceFamily?: string | null; + modelIdentifier?: string | null; + mode?: string | null; + lastInputSeconds?: number | null; + reason?: string | null; + text?: string | null; + ts?: number | null; +}; + +export type GatewaySessionsDefaults = { + model: string | null; + contextTokens: number | null; +}; + +export type GatewaySessionRow = { + key: string; + kind: "direct" | "group" | "global" | "unknown"; + updatedAt: number | null; + sessionId?: string; + systemSent?: boolean; + abortedLastRun?: boolean; + thinkingLevel?: string; + verboseLevel?: string; + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; + model?: string; + contextTokens?: number; + syncing?: boolean | string; +}; + +export type SessionsListResult = { + ts: number; + path: string; + count: number; + defaults: GatewaySessionsDefaults; + sessions: GatewaySessionRow[]; +}; + +export type SessionsPatchResult = { + ok: true; + path: string; + key: string; + entry: { + sessionId: string; + updatedAt?: number; + thinkingLevel?: string; + verboseLevel?: string; + syncing?: boolean | string; + }; +}; + +export type CronSchedule = + | { kind: "at"; atMs: number } + | { kind: "every"; everyMs: number; anchorMs?: number } + | { kind: "cron"; expr: string; tz?: string }; + +export type CronSessionTarget = "main" | "isolated"; +export type CronWakeMode = "next-heartbeat" | "now"; + +export type CronPayload = + | { kind: "systemEvent"; text: string } + | { + kind: "agentTurn"; + message: string; + thinking?: string; + timeoutSeconds?: number; + deliver?: boolean; + channel?: "last" | "whatsapp" | "telegram"; + to?: string; + bestEffortDeliver?: boolean; + }; + +export type CronIsolation = { + postToMainPrefix?: string; +}; + +export type CronJobState = { + nextRunAtMs?: number; + runningAtMs?: number; + lastRunAtMs?: number; + lastStatus?: "ok" | "error" | "skipped"; + lastError?: string; + lastDurationMs?: number; +}; + +export type CronJob = { + id: string; + name: string; + description?: string; + enabled: boolean; + createdAtMs: number; + updatedAtMs: number; + schedule: CronSchedule; + sessionTarget: CronSessionTarget; + wakeMode: CronWakeMode; + payload: CronPayload; + isolation?: CronIsolation; + state?: CronJobState; +}; + +export type CronStatus = { + enabled: boolean; + jobCount: number; + nextWakeAtMs?: number | null; +}; + +export type CronRunLogEntry = { + ts: number; + jobId: string; + status: "ok" | "error" | "skipped"; + durationMs?: number; + error?: string; + summary?: string; +}; + +export type SkillsStatusConfigCheck = { + path: string; + value: unknown; + satisfied: boolean; +}; + +export type SkillInstallOption = { + id: string; + kind: "brew" | "node" | "go" | "uv"; + label: string; + bins: string[]; +}; + +export type SkillStatusEntry = { + name: string; + description: string; + source: string; + filePath: string; + baseDir: string; + skillKey: string; + primaryEnv?: string; + emoji?: string; + homepage?: string; + always: boolean; + disabled: boolean; + eligible: boolean; + requirements: { + bins: string[]; + env: string[]; + config: string[]; + }; + missing: { + bins: string[]; + env: string[]; + config: string[]; + }; + configChecks: SkillsStatusConfigCheck[]; + install: SkillInstallOption[]; +}; + +export type SkillStatusReport = { + workspaceDir: string; + managedSkillsDir: string; + skills: SkillStatusEntry[]; +}; + +export type StatusSummary = Record; + +export type HealthSnapshot = Record; + diff --git a/ui/src/ui/ui-types.ts b/ui/src/ui/ui-types.ts new file mode 100644 index 000000000..a640520a8 --- /dev/null +++ b/ui/src/ui/ui-types.ts @@ -0,0 +1,31 @@ +export type TelegramForm = { + token: string; + requireMention: boolean; + allowFrom: string; + proxy: string; + webhookUrl: string; + webhookSecret: string; + webhookPath: string; +}; + +export type CronFormState = { + name: string; + description: string; + enabled: boolean; + scheduleKind: "at" | "every" | "cron"; + scheduleAt: string; + everyAmount: string; + everyUnit: "minutes" | "hours" | "days"; + cronExpr: string; + cronTz: string; + sessionTarget: "main" | "isolated"; + wakeMode: "next-heartbeat" | "now"; + payloadKind: "systemEvent" | "agentTurn"; + payloadText: string; + deliver: boolean; + channel: "last" | "whatsapp" | "telegram"; + to: string; + timeoutSeconds: string; + postToMainPrefix: string; +}; + diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts new file mode 100644 index 000000000..1a14901cd --- /dev/null +++ b/ui/src/ui/views/chat.ts @@ -0,0 +1,116 @@ +import { html, nothing } from "lit"; + +export type ChatProps = { + sessionKey: string; + onSessionKeyChange: (next: string) => void; + thinkingLevel: string | null; + loading: boolean; + sending: boolean; + messages: unknown[]; + stream: string | null; + draft: string; + connected: boolean; + onRefresh: () => void; + onDraftChange: (next: string) => void; + onSend: () => void; +}; + +export function renderChat(props: ChatProps) { + return html` +
+
+
+ + +
+
+ Thinking: ${props.thinkingLevel ?? "inherit"} +
+
+ +
+ ${props.messages.map((m) => renderMessage(m))} + ${props.stream + ? html`${renderMessage({ + role: "assistant", + content: [{ type: "text", text: props.stream }], + })}` + : 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/views/config.ts b/ui/src/ui/views/config.ts new file mode 100644 index 000000000..9c72ee81c --- /dev/null +++ b/ui/src/ui/views/config.ts @@ -0,0 +1,61 @@ +import { html, nothing } from "lit"; + +export type ConfigProps = { + raw: string; + valid: boolean | null; + issues: unknown[]; + loading: boolean; + saving: boolean; + connected: boolean; + onRawChange: (next: string) => void; + onReload: () => void; + onSave: () => void; +}; + +export function renderConfig(props: ConfigProps) { + const validity = + props.valid == null ? "unknown" : props.valid ? "valid" : "invalid"; + return html` +
+
+
+
Config
+ ${validity} +
+
+ + +
+
+ +
+ Writes to ~/.clawdis/clawdis.json. Some changes + require a gateway restart. +
+ + + + ${props.issues.length > 0 + ? html`
+
${JSON.stringify(props.issues, null, 2)}
+
` + : nothing} +
+ `; +} + diff --git a/ui/src/ui/views/connections.ts b/ui/src/ui/views/connections.ts new file mode 100644 index 000000000..cdd415dc6 --- /dev/null +++ b/ui/src/ui/views/connections.ts @@ -0,0 +1,304 @@ +import { html, nothing } from "lit"; + +import { formatAgo } from "../format"; +import type { ProvidersStatusSnapshot } from "../types"; +import type { TelegramForm } from "../ui-types"; + +export type ConnectionsProps = { + connected: boolean; + loading: boolean; + snapshot: ProvidersStatusSnapshot | null; + lastError: string | null; + lastSuccessAt: number | null; + whatsappMessage: string | null; + whatsappQrDataUrl: string | null; + whatsappConnected: boolean | null; + whatsappBusy: boolean; + telegramForm: TelegramForm; + telegramTokenLocked: boolean; + telegramSaving: boolean; + telegramStatus: string | null; + onRefresh: (probe: boolean) => void; + onWhatsAppStart: (force: boolean) => void; + onWhatsAppWait: () => void; + onWhatsAppLogout: () => void; + onTelegramChange: (patch: Partial) => void; + onTelegramSave: () => void; +}; + +export function renderConnections(props: ConnectionsProps) { + const whatsapp = props.snapshot?.whatsapp; + const telegram = props.snapshot?.telegram; + + return html` +
+
+
WhatsApp
+
Link WhatsApp Web and monitor connection health.
+ +
+
+ Configured + ${whatsapp?.configured ? "Yes" : "No"} +
+
+ Linked + ${whatsapp?.linked ? "Yes" : "No"} +
+
+ Running + ${whatsapp?.running ? "Yes" : "No"} +
+
+ Connected + ${whatsapp?.connected ? "Yes" : "No"} +
+
+ Last connect + ${whatsapp?.lastConnectedAt ? formatAgo(whatsapp.lastConnectedAt) : "n/a"} +
+
+ Last message + ${whatsapp?.lastMessageAt ? formatAgo(whatsapp.lastMessageAt) : "n/a"} +
+
+ Auth age + + ${whatsapp?.authAgeMs != null ? formatDuration(whatsapp.authAgeMs) : "n/a"} + +
+
+ + ${whatsapp?.lastError + ? html`
+ ${whatsapp.lastError} +
` + : nothing} + + ${props.whatsappMessage + ? html`
+ ${props.whatsappMessage} +
` + : nothing} + + ${props.whatsappQrDataUrl + ? html`
+ WhatsApp QR +
` + : nothing} + +
+ + + + + +
+
+ +
+
Telegram
+
Bot token and delivery options.
+ +
+
+ Configured + ${telegram?.configured ? "Yes" : "No"} +
+
+ Running + ${telegram?.running ? "Yes" : "No"} +
+
+ Mode + ${telegram?.mode ?? "n/a"} +
+
+ Last start + ${telegram?.lastStartAt ? formatAgo(telegram.lastStartAt) : "n/a"} +
+
+ Last probe + ${telegram?.lastProbeAt ? formatAgo(telegram.lastProbeAt) : "n/a"} +
+
+ + ${telegram?.lastError + ? html`
+ ${telegram.lastError} +
` + : nothing} + + ${telegram?.probe + ? html`
+ Probe ${telegram.probe.ok ? "ok" : "failed"} · + ${telegram.probe.status ?? ""} + ${telegram.probe.error ?? ""} +
` + : nothing} + +
+ + + + + + + +
+ + ${props.telegramTokenLocked + ? html`
+ TELEGRAM_BOT_TOKEN is set in the environment. Config edits will not override it. +
` + : nothing} + + ${props.telegramStatus + ? html`
+ ${props.telegramStatus} +
` + : nothing} + +
+ + +
+
+
+ +
+
+
+
Connection health
+
Provider status snapshots from the gateway.
+
+
${props.lastSuccessAt ? formatAgo(props.lastSuccessAt) : "n/a"}
+
+ ${props.lastError + ? html`
+ ${props.lastError} +
` + : nothing} +
+${props.snapshot ? JSON.stringify(props.snapshot, null, 2) : "No snapshot yet."}
+      
+
+ `; +} + +function formatDuration(ms?: number | null) { + if (!ms && ms !== 0) return "n/a"; + const sec = Math.round(ms / 1000); + if (sec < 60) return `${sec}s`; + const min = Math.round(sec / 60); + if (min < 60) return `${min}m`; + const hr = Math.round(min / 60); + return `${hr}h`; +} diff --git a/ui/src/ui/views/cron.ts b/ui/src/ui/views/cron.ts new file mode 100644 index 000000000..016f5419e --- /dev/null +++ b/ui/src/ui/views/cron.ts @@ -0,0 +1,390 @@ +import { html, nothing } from "lit"; + +import { formatMs } from "../format"; +import { + formatCronPayload, + formatCronSchedule, + formatCronState, + formatNextRun, +} from "../presenter"; +import type { CronJob, CronRunLogEntry, CronStatus } from "../types"; +import type { CronFormState } from "../ui-types"; + +export type CronProps = { + loading: boolean; + status: CronStatus | null; + jobs: CronJob[]; + error: string | null; + busy: boolean; + form: CronFormState; + runsJobId: string | null; + runs: CronRunLogEntry[]; + onFormChange: (patch: Partial) => void; + onRefresh: () => void; + onAdd: () => void; + onToggle: (job: CronJob, enabled: boolean) => void; + onRun: (job: CronJob) => void; + onRemove: (job: CronJob) => void; + onLoadRuns: (jobId: string) => void; +}; + +export function renderCron(props: CronProps) { + return html` +
+
+
Scheduler
+
Gateway-owned cron scheduler status.
+
+
+
Enabled
+
+ ${props.status + ? props.status.enabled + ? "Yes" + : "No" + : "n/a"} +
+
+
+
Jobs
+
${props.status?.jobCount ?? "n/a"}
+
+
+
Next wake
+
${formatNextRun(props.status?.nextWakeAtMs ?? null)}
+
+
+
+ + ${props.error ? html`${props.error}` : nothing} +
+
+ +
+
New Job
+
Create a scheduled wakeup or agent run.
+
+ + + + +
+ ${renderScheduleFields(props)} +
+ + + +
+ + ${props.form.payloadKind === "agentTurn" + ? html` +
+ + + + + ${props.form.sessionTarget === "isolated" + ? html` + + ` + : nothing} +
+ ` + : nothing} +
+ +
+
+
+ +
+
Jobs
+
All scheduled jobs stored in the gateway.
+ ${props.jobs.length === 0 + ? html`
No jobs yet.
` + : html` +
+ ${props.jobs.map((job) => renderJob(job, props))} +
+ `} +
+ +
+
Run history
+
Latest runs for ${props.runsJobId ?? "(select a job)"}.
+ ${props.runs.length === 0 + ? html`
No runs yet.
` + : html` +
+ ${props.runs.map((entry) => renderRun(entry))} +
+ `} +
+ `; +} + +function renderScheduleFields(props: CronProps) { + const form = props.form; + if (form.scheduleKind === "at") { + return html` + + `; + } + if (form.scheduleKind === "every") { + return html` +
+ + +
+ `; + } + return html` +
+ + +
+ `; +} + +function renderJob(job: CronJob, props: CronProps) { + return html` +
+
+
${job.name}
+
${formatCronSchedule(job)}
+
${formatCronPayload(job)}
+
+ ${job.enabled ? "enabled" : "disabled"} + ${job.sessionTarget} + ${job.wakeMode} +
+
+
+
${formatCronState(job)}
+
+ + + + +
+
+
+ `; +} + +function renderRun(entry: CronRunLogEntry) { + return html` +
+
+
${entry.status}
+
${entry.summary ?? ""}
+
+
+
${formatMs(entry.ts)}
+
${entry.durationMs ?? 0}ms
+ ${entry.error ? html`
${entry.error}
` : nothing} +
+
+ `; +} + diff --git a/ui/src/ui/views/debug.ts b/ui/src/ui/views/debug.ts new file mode 100644 index 000000000..f1284145c --- /dev/null +++ b/ui/src/ui/views/debug.ts @@ -0,0 +1,129 @@ +import { html, nothing } from "lit"; + +import { formatEventPayload } from "../presenter"; + +type EventLogEntry = { + ts: number; + event: string; + payload?: unknown; +}; + +export type DebugProps = { + loading: boolean; + status: Record | null; + health: Record | null; + models: unknown[]; + heartbeat: unknown; + eventLog: EventLogEntry[]; + callMethod: string; + callParams: string; + callResult: string | null; + callError: string | null; + onCallMethodChange: (next: string) => void; + onCallParamsChange: (next: string) => void; + onRefresh: () => void; + onCall: () => void; +}; + +export function renderDebug(props: DebugProps) { + return html` +
+
+
+
+
Snapshots
+
Status, health, and heartbeat data.
+
+ +
+
+
+
Status
+
${JSON.stringify(props.status ?? {}, null, 2)}
+
+
+
Health
+
${JSON.stringify(props.health ?? {}, null, 2)}
+
+
+
Last heartbeat
+
${JSON.stringify(props.heartbeat ?? {}, null, 2)}
+
+
+
+ +
+
Manual RPC
+
Send a raw gateway method with JSON params.
+
+ + +
+
+ +
+ ${props.callError + ? html`
+ ${props.callError} +
` + : nothing} + ${props.callResult + ? html`
${props.callResult}
` + : nothing} +
+
+ +
+
Models
+
Catalog from models.list.
+
${JSON.stringify(
+        props.models ?? [],
+        null,
+        2,
+      )}
+
+ +
+
Event Log
+
Latest gateway events.
+ ${props.eventLog.length === 0 + ? html`
No events yet.
` + : html` +
+ ${props.eventLog.map( + (evt) => html` +
+
+
${evt.event}
+
${new Date(evt.ts).toLocaleTimeString()}
+
+
+
${formatEventPayload(evt.payload)}
+
+
+ `, + )} +
+ `} +
+ `; +} + diff --git a/ui/src/ui/views/instances.ts b/ui/src/ui/views/instances.ts new file mode 100644 index 000000000..dc9dad2f5 --- /dev/null +++ b/ui/src/ui/views/instances.ts @@ -0,0 +1,75 @@ +import { html, nothing } from "lit"; + +import { formatPresenceAge, formatPresenceSummary } from "../presenter"; +import type { PresenceEntry } from "../types"; + +export type InstancesProps = { + loading: boolean; + entries: PresenceEntry[]; + lastError: string | null; + statusMessage: string | null; + onRefresh: () => void; +}; + +export function renderInstances(props: InstancesProps) { + return html` +
+
+
+
Connected Instances
+
Presence beacons from the gateway and clients.
+
+ +
+ ${props.lastError + ? html`
+ ${props.lastError} +
` + : nothing} + ${props.statusMessage + ? html`
+ ${props.statusMessage} +
` + : nothing} +
+ ${props.entries.length === 0 + ? html`
No instances reported yet.
` + : props.entries.map((entry) => renderEntry(entry))} +
+
+ `; +} + +function renderEntry(entry: PresenceEntry) { + const lastInput = + entry.lastInputSeconds != null + ? `${entry.lastInputSeconds}s ago` + : "n/a"; + const mode = entry.mode ?? "unknown"; + return html` +
+
+
${entry.host ?? "unknown host"}
+
${formatPresenceSummary(entry)}
+
+ ${mode} + ${entry.platform ? html`${entry.platform}` : nothing} + ${entry.deviceFamily + ? html`${entry.deviceFamily}` + : nothing} + ${entry.modelIdentifier + ? html`${entry.modelIdentifier}` + : nothing} + ${entry.version ? html`${entry.version}` : nothing} +
+
+
+
${formatPresenceAge(entry)}
+
Last input ${lastInput}
+
Reason ${entry.reason ?? ""}
+
+
+ `; +} diff --git a/ui/src/ui/views/nodes.ts b/ui/src/ui/views/nodes.ts new file mode 100644 index 000000000..1b621a752 --- /dev/null +++ b/ui/src/ui/views/nodes.ts @@ -0,0 +1,61 @@ +import { html } from "lit"; + +export type NodesProps = { + loading: boolean; + nodes: Array>; + onRefresh: () => void; +}; + +export function renderNodes(props: NodesProps) { + return html` +
+
+
+
Nodes
+
Paired devices and live connections.
+
+ +
+
+ ${props.nodes.length === 0 + ? html`
No nodes found.
` + : props.nodes.map((n) => renderNode(n))} +
+
+ `; +} + +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}
+
+ ${typeof node.nodeId === "string" ? node.nodeId : ""} + ${typeof node.remoteIp === "string" ? ` · ${node.remoteIp}` : ""} + ${typeof node.version === "string" ? ` · ${node.version}` : ""} +
+
+ ${paired ? "paired" : "unpaired"} + + ${connected ? "connected" : "offline"} + + ${caps.slice(0, 12).map((c) => html`${String(c)}`)} + ${commands + .slice(0, 8) + .map((c) => html`${String(c)}`)} +
+
+
+ `; +} + diff --git a/ui/src/ui/views/overview.ts b/ui/src/ui/views/overview.ts new file mode 100644 index 000000000..5f4080a33 --- /dev/null +++ b/ui/src/ui/views/overview.ts @@ -0,0 +1,184 @@ +import { html } from "lit"; + +import type { GatewayHelloOk } from "../gateway"; +import { formatAgo, formatDurationMs } from "../format"; +import { formatNextRun } from "../presenter"; +import type { UiSettings } from "../storage"; + +export type OverviewProps = { + connected: boolean; + hello: GatewayHelloOk | null; + settings: UiSettings; + password: string; + lastError: string | null; + presenceCount: number; + sessionsCount: number | null; + cronEnabled: boolean | null; + cronNext: number | null; + lastProvidersRefresh: number | null; + onSettingsChange: (next: UiSettings) => void; + onPasswordChange: (next: string) => void; + onSessionKeyChange: (next: string) => void; + onRefresh: () => void; +}; + +export function renderOverview(props: OverviewProps) { + const snapshot = props.hello?.snapshot as + | { uptimeMs?: number; policy?: { tickIntervalMs?: number } } + | undefined; + const uptime = snapshot?.uptimeMs ? formatDurationMs(snapshot.uptimeMs) : "n/a"; + const tick = snapshot?.policy?.tickIntervalMs + ? `${snapshot.policy.tickIntervalMs}ms` + : "n/a"; + + return html` +
+
+
Gateway Access
+
Where the dashboard connects and how it authenticates.
+
+ + + + + +
+
+ + Reconnect to apply changes. +
+
+ +
+
Snapshot
+
Latest gateway handshake information.
+
+
+
Status
+
+ ${props.connected ? "Connected" : "Disconnected"} +
+
+
+
Uptime
+
${uptime}
+
+
+
Tick Interval
+
${tick}
+
+
+
Last Providers Refresh
+
+ ${props.lastProvidersRefresh + ? formatAgo(props.lastProvidersRefresh) + : "n/a"} +
+
+
+ ${props.lastError + ? html`
+ ${props.lastError} +
` + : html`
+ Use Connections to link WhatsApp and Telegram. +
`} +
+
+ +
+
+
Instances
+
${props.presenceCount}
+
Presence beacons in the last 5 minutes.
+
+
+
Sessions
+
${props.sessionsCount ?? "n/a"}
+
Recent session keys tracked by the gateway.
+
+
+
Cron
+
+ ${props.cronEnabled == null + ? "n/a" + : props.cronEnabled + ? "Enabled" + : "Disabled"} +
+
Next wake ${formatNextRun(props.cronNext)}
+
+
+ +
+
Notes
+
Quick reminders for remote control setups.
+
+
+
Tailscale serve
+
+ Prefer serve mode to keep the gateway on loopback with tailnet auth. +
+
+
+
Session hygiene
+
Use /new or sessions.patch to reset context.
+
+
+
Cron reminders
+
Use isolated sessions for recurring runs.
+
+
+
+ `; +} diff --git a/ui/src/ui/views/sessions.ts b/ui/src/ui/views/sessions.ts new file mode 100644 index 000000000..6eed6612e --- /dev/null +++ b/ui/src/ui/views/sessions.ts @@ -0,0 +1,165 @@ +import { html, nothing } from "lit"; + +import { formatAgo } from "../format"; +import { formatSessionTokens } from "../presenter"; +import type { GatewaySessionRow, SessionsListResult } from "../types"; + +export type SessionsProps = { + loading: boolean; + result: SessionsListResult | null; + error: string | null; + activeMinutes: string; + limit: string; + includeGlobal: boolean; + includeUnknown: boolean; + onFiltersChange: (next: { + activeMinutes: string; + limit: string; + includeGlobal: boolean; + includeUnknown: boolean; + }) => void; + onRefresh: () => void; + onPatch: ( + key: string, + patch: { thinkingLevel?: string | null; verboseLevel?: string | null }, + ) => void; +}; + +const THINK_LEVELS = ["", "off", "minimal", "low", "medium", "high"] as const; +const VERBOSE_LEVELS = ["", "off", "on"] as const; + +export function renderSessions(props: SessionsProps) { + const rows = props.result?.sessions ?? []; + return html` +
+
+
+
Sessions
+
Active session keys and per-session overrides.
+
+ +
+ +
+ + + + +
+ + ${props.error + ? html`
${props.error}
` + : nothing} + +
+ ${props.result ? `Store: ${props.result.path}` : ""} +
+ +
+
+
Key
+
Kind
+
Updated
+
Tokens
+
Thinking
+
Verbose
+
+ ${rows.length === 0 + ? html`
No sessions found.
` + : rows.map((row) => renderRow(row, props.onPatch))} +
+
+ `; +} + +function renderRow(row: GatewaySessionRow, onPatch: SessionsProps["onPatch"]) { + const updated = row.updatedAt ? formatAgo(row.updatedAt) : "n/a"; + const thinking = row.thinkingLevel ?? ""; + const verbose = row.verboseLevel ?? ""; + return html` +
+
${row.key}
+
${row.kind}
+
${updated}
+
${formatSessionTokens(row)}
+
+ +
+
+ +
+
+ `; +} diff --git a/ui/src/ui/views/skills.ts b/ui/src/ui/views/skills.ts new file mode 100644 index 000000000..05cf7ffc5 --- /dev/null +++ b/ui/src/ui/views/skills.ts @@ -0,0 +1,146 @@ +import { html, nothing } from "lit"; + +import { clampText } from "../format"; +import type { SkillStatusEntry, SkillStatusReport } from "../types"; + +export type SkillsProps = { + loading: boolean; + report: SkillStatusReport | null; + error: string | null; + filter: string; + edits: Record; + busyKey: string | null; + onFilterChange: (next: string) => void; + onRefresh: () => void; + onToggle: (skillKey: string, enabled: boolean) => void; + onEdit: (skillKey: string, value: string) => void; + onSaveKey: (skillKey: string) => void; + onInstall: (name: string, installId: string) => void; +}; + +export function renderSkills(props: SkillsProps) { + const skills = props.report?.skills ?? []; + const filter = props.filter.trim().toLowerCase(); + const filtered = filter + ? skills.filter((skill) => + [skill.name, skill.description, skill.source] + .join(" ") + .toLowerCase() + .includes(filter), + ) + : skills; + + return html` +
+
+
+
Skills
+
Bundled, managed, and workspace skills.
+
+ +
+ +
+ +
${filtered.length} shown
+
+ + ${props.error + ? html`
${props.error}
` + : nothing} + + ${filtered.length === 0 + ? html`
No skills found.
` + : html` +
+ ${filtered.map((skill) => renderSkill(skill, props))} +
+ `} +
+ `; +} + +function renderSkill(skill: SkillStatusEntry, props: SkillsProps) { + const busy = props.busyKey === skill.skillKey || props.busyKey === skill.name; + const apiKey = props.edits[skill.skillKey] ?? ""; + return html` +
+
+
+ ${skill.emoji ? `${skill.emoji} ` : ""}${skill.name} +
+
${clampText(skill.description, 140)}
+
+ ${skill.source} + + ${skill.eligible ? "eligible" : "blocked"} + + ${skill.disabled ? html`disabled` : nothing} +
+ ${skill.missing.bins.length + skill.missing.env.length + skill.missing.config.length > 0 + ? html` +
+ Missing: ${[ + ...skill.missing.bins.map((b) => `bin:${b}`), + ...skill.missing.env.map((e) => `env:${e}`), + ...skill.missing.config.map((c) => `config:${c}`), + ].join(", ")} +
+ ` + : nothing} +
+
+
+ + ${skill.install.length > 0 + ? html`` + : nothing} +
+ ${skill.primaryEnv + ? html` +
+ API key + + props.onEdit(skill.skillKey, (e.target as HTMLInputElement).value)} + /> +
+ + ` + : nothing} +
+
+ `; +} +