feat(ui): expand control dashboard

This commit is contained in:
Peter Steinberger
2025-12-21 00:34:39 +00:00
parent 7b6eaa819e
commit 6ff8371254
32 changed files with 4226 additions and 759 deletions

View File

@@ -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://<magicdns>/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://<tailscale-ip>:18789/ui/`
Paste the token into the UI settings (its sent as `connect.params.auth.token`).
Paste the token into the UI settings (sent as `connect.params.auth.token`).
## Building the UI

View File

@@ -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://<magicdns>/ui/`
### Tailnet bind + token (legacy)
```json5
{
@@ -53,16 +79,24 @@ clawdis gateway
Open:
- `http://<tailscale-ip>: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
```

View File

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

89
ui/src/styles/base.css Normal file
View File

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

View File

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

200
ui/src/styles/layout.css Normal file
View File

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

368
ui/src/ui/app-render.ts Normal file
View File

@@ -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<Record<string, unknown>>;
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<string, string>;
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<void>;
loadCron: () => Promise<void>;
handleWhatsAppStart: (force: boolean) => Promise<void>;
handleWhatsAppWait: () => Promise<void>;
handleWhatsAppLogout: () => Promise<void>;
handleTelegramSave: () => Promise<void>;
handleSendChat: () => Promise<void>;
};
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`
<div class="shell">
<header class="topbar">
<div class="brand">
<div class="brand-title">Clawdis Control</div>
<div class="brand-sub">Gateway dashboard</div>
</div>
<div class="topbar-status">
<div class="pill">
<span class="statusDot ${state.connected ? "ok" : ""}"></span>
<span class="mono">${proto}</span>
<span class="mono">${state.settings.gatewayUrl}</span>
</div>
<button class="btn" @click=${() => state.connect()}>Reconnect</button>
<button class="btn danger" @click=${() => state.client?.stop()}>
Disconnect
</button>
</div>
</header>
<aside class="nav">
${TAB_GROUPS.map(
(group) => html`
<div class="nav-group">
<div class="nav-label">${group.label}</div>
${group.tabs.map((tab) => renderTab(state, tab))}
</div>
`,
)}
</aside>
<main class="content">
<section class="content-header">
<div>
<div class="page-title">${titleForTab(state.tab)}</div>
<div class="page-sub">${subtitleForTab(state.tab)}</div>
</div>
<div class="page-meta">
${state.lastError
? html`<div class="pill danger">${state.lastError}</div>`
: nothing}
</div>
</section>
${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}
</main>
</div>
`;
}
function renderTab(state: AppViewState, tab: Tab) {
return html`
<button
class="nav-item ${state.tab === tab ? "active" : ""}"
@click=${() => state.setTab(tab)}
>
<span>${titleForTab(tab)}</span>
</button>
`;
}

View File

@@ -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<Record<string, unknown>> = [];
@state() private nodesLoading = false;
@state() private nodes: Array<Record<string, unknown>> = [];
@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<string, string> = {};
@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<Record<string, unknown>>;
};
this.nodes = Array.isArray(res.nodes) ? res.nodes : [];
} catch (err) {
this.lastError = String(err);
} finally {
this.nodesLoading = false;
}
}
private async loadConfig() {
if (!this.client || !this.connected) return;
this.configLoading = true;
this.lastError = null;
try {
const res = (await this.client.request("config.get", {})) as {
raw?: string | null;
valid?: boolean;
issues?: unknown[];
config?: unknown;
};
if (typeof res.raw === "string") {
this.configRaw = res.raw;
} else {
const cfg = res.config ?? {};
this.configRaw = `${JSON.stringify(cfg, null, 2).trimEnd()}\n`;
}
this.configValid = typeof res.valid === "boolean" ? res.valid : null;
this.configIssues = Array.isArray(res.issues) ? res.issues : [];
} catch (err) {
this.lastError = String(err);
} finally {
this.configLoading = false;
}
}
private async saveConfig() {
if (!this.client || !this.connected) return;
this.configSaving = true;
this.lastError = null;
try {
await this.client.request("config.set", { raw: this.configRaw });
await this.loadConfig();
} catch (err) {
this.lastError = String(err);
} finally {
this.configSaving = false;
}
}
private setTab(next: Tab) {
this.tab = next;
void this.refreshActiveTab();
}
private applySettings(next: UiSettings) {
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`
<span class="pill" title=${this.connected ? "connected" : "disconnected"}>
<span class="statusDot ${this.connected ? "ok" : ""}"></span>
<span class="mono">${proto}</span>
<span class="mono">${this.settings.gatewayUrl}</span>
</span>
`;
return html`
<div class="shell">
<header>
<div class="row">
<div class="title">Clawdis Control</div>
${connectedBadge}
</div>
<nav>
${this.renderTabs()}
</nav>
</header>
<main>
<div class="grid">
${this.renderSettingsCard()} ${this.renderActiveTab()}
${this.lastError
? html`<div class="card"><div class="error">${this.lastError}</div></div>`
: nothing}
</div>
</main>
</div>
`;
}
private renderTabs() {
const tab = (id: Tab, label: string) => html`
<div
class="tab ${this.tab === id ? "active" : ""}"
@click=${() => this.setTab(id)}
>
${label}
</div>
`;
return html`${tab("chat", "Chat")} ${tab("nodes", "Nodes")}
${tab("config", "Config")}`;
}
private renderSettingsCard() {
return html`
<div class="card">
<div class="split">
<div class="field">
<label>Gateway WebSocket URL</label>
<input
.value=${this.settings.gatewayUrl}
@input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
this.applySettings({ ...this.settings, gatewayUrl: v });
}}
placeholder="ws://100.x.y.z:18789"
/>
</div>
<div class="field">
<label>Gateway Token (CLAWDIS_GATEWAY_TOKEN)</label>
<input
.value=${this.settings.token}
@input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
this.applySettings({ ...this.settings, token: v });
}}
placeholder="paste token"
/>
</div>
</div>
<div class="row" style="justify-content: space-between; margin-top: 10px;">
<div class="muted">
Tip: for Tailnet access, start the gateway with a token and bind to
the Tailnet interface.
</div>
<div class="row">
<button class="btn" @click=${() => this.connect()}>
Reconnect
</button>
<button class="btn danger" @click=${() => this.client?.stop()}>
Disconnect
</button>
</div>
</div>
</div>
`;
}
private renderActiveTab() {
if (this.tab === "chat") return this.renderChat();
if (this.tab === "nodes") return this.renderNodes();
if (this.tab === "config") return this.renderConfig();
return nothing;
}
private renderChat() {
return html`
<div class="card">
<div class="row" style="justify-content: space-between;">
<div class="row">
<div class="field" style="min-width: 220px;">
<label>Session Key</label>
<input
.value=${this.sessionKey}
@input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
this.sessionKey = v;
this.applySettings({ ...this.settings, sessionKey: v });
}}
/>
</div>
<button
class="btn"
?disabled=${this.chatLoading || !this.connected}
@click=${() => this.loadChatHistory()}
>
${this.chatLoading ? "Loading…" : "Refresh"}
</button>
</div>
<div class="muted">Messages come from the session JSONL logs.</div>
</div>
<div class="messages" style="margin-top: 12px;">
${this.chatMessages.map((m) => renderMessage(m))}
${this.chatStream
? html`${renderMessage({
role: "assistant",
content: [{ type: "text", text: this.chatStream }],
})}`
: nothing}
</div>
<div class="compose" style="margin-top: 12px;">
<div class="field">
<label>Message</label>
<textarea
.value=${this.chatMessage}
@input=${(e: Event) => {
this.chatMessage = (e.target as HTMLTextAreaElement).value;
}}
placeholder="Ask the model…"
></textarea>
</div>
<div class="row" style="justify-content: flex-end;">
<button
class="btn primary"
?disabled=${this.chatSending || !this.connected}
@click=${() => this.sendChat()}
>
${this.chatSending ? "Sending…" : "Send"}
</button>
</div>
</div>
</div>
`;
}
private renderNodes() {
return html`
<div class="card">
<div class="row" style="justify-content: space-between;">
<div class="title">Nodes</div>
<button
class="btn"
?disabled=${this.nodesLoading || !this.connected}
@click=${() => this.loadNodes()}
>
${this.nodesLoading ? "Loading…" : "Refresh"}
</button>
</div>
<div class="nodes" style="margin-top: 12px;">
${this.nodes.length === 0
? html`<div class="muted">No nodes found.</div>`
: this.nodes.map((n) => renderNode(n))}
</div>
</div>
`;
}
private renderConfig() {
const validity =
this.configValid === null
? "unknown"
: this.configValid
? "valid"
: "invalid";
return html`
<div class="card">
<div class="row" style="justify-content: space-between;">
<div class="row">
<div class="title">Config</div>
<span class="pill"><span class="mono">${validity}</span></span>
</div>
<div class="row">
<button
class="btn"
?disabled=${this.configLoading || !this.connected}
@click=${() => this.loadConfig()}
>
${this.configLoading ? "Loading…" : "Reload"}
</button>
<button
class="btn primary"
?disabled=${this.configSaving || !this.connected}
@click=${() => this.saveConfig()}
>
${this.configSaving ? "Saving…" : "Save"}
</button>
</div>
</div>
<div class="muted" style="margin-top: 10px;">
Writes to <span class="mono">~/.clawdis/clawdis.json</span>. Some
changes may require a gateway restart.
</div>
<div class="field" style="margin-top: 12px;">
<label>Raw JSON5</label>
<textarea
.value=${this.configRaw}
@input=${(e: Event) => {
this.configRaw = (e.target as HTMLTextAreaElement).value;
}}
></textarea>
</div>
${this.configIssues.length > 0
? html`<div class="card" style="margin-top: 12px;">
<div class="title">Issues</div>
<div class="error">${JSON.stringify(this.configIssues, null, 2)}</div>
</div>`
: nothing}
</div>
`;
return renderApp(this);
}
}
function renderNode(node: Record<string, unknown>) {
const connected = Boolean(node.connected);
const paired = Boolean(node.paired);
const title =
(typeof node.displayName === "string" && node.displayName.trim()) ||
(typeof node.nodeId === "string" ? node.nodeId : "unknown");
const caps = Array.isArray(node.caps) ? (node.caps as unknown[]) : [];
const commands = Array.isArray(node.commands) ? (node.commands as unknown[]) : [];
return html`
<div class="nodeRow">
<div class="top">
<div class="row">
<span class="statusDot ${connected ? "ok" : ""}"></span>
<div class="title">${title}</div>
</div>
<div class="row muted">
<span>${paired ? "paired" : "unpaired"}</span>
<span>·</span>
<span>${connected ? "connected" : "offline"}</span>
</div>
</div>
<div class="muted mono">
${typeof node.nodeId === "string" ? node.nodeId : ""}
${typeof node.remoteIp === "string" ? `· ${node.remoteIp}` : ""}
${typeof node.version === "string" ? `· ${node.version}` : ""}
</div>
${caps.length > 0
? html`<div class="chips">
${caps.slice(0, 24).map((c) => html`<span class="chip">${String(c)}</span>`)}
</div>`
: nothing}
${commands.length > 0
? html`<div class="chips">
${commands
.slice(0, 24)
.map((c) => html`<span class="chip">${String(c)}</span>`)}
</div>`
: nothing}
</div>
`;
}
function renderMessage(message: unknown) {
const m = message as Record<string, unknown>;
const role = typeof m.role === "string" ? m.role : "unknown";
const text =
extractText(message) ??
(typeof m.content === "string"
? m.content
: JSON.stringify(message, null, 2));
const ts =
typeof m.timestamp === "number"
? new Date(m.timestamp).toLocaleTimeString()
: "";
const klass =
role === "assistant" ? "assistant" : role === "user" ? "user" : "";
return html`
<div class="msg ${klass}">
<div class="meta">
<span class="mono">${role}</span>
<span class="mono">${ts}</span>
</div>
<div class="msgContent">${text}</div>
</div>
`;
}
function extractText(message: unknown): string | null {
const m = message as Record<string, unknown>;
const content = m.content;
if (typeof content === "string") return content;
if (Array.isArray(content)) {
const parts = content
.map((p) => {
const item = p as Record<string, unknown>;
if (item.type === "text" && typeof item.text === "string") return item.text;
return null;
})
.filter((v): v is string => typeof v === "string");
if (parts.length > 0) return parts.join("\n");
}
if (typeof m.text === "string") return m.text;
return null;
}

View File

@@ -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<string, unknown>;
const content = m.content;
if (typeof content === "string") return content;
if (Array.isArray(content)) {
const parts = content
.map((p) => {
const item = p as Record<string, unknown>;
if (item.type === "text" && typeof item.text === "string") return item.text;
return null;
})
.filter((v): v is string => typeof v === "string");
if (parts.length > 0) return parts.join("\n");
}
if (typeof m.text === "string") return m.text;
return null;
}

View File

@@ -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<string, unknown>;
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;
}
}

View File

@@ -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<TelegramForm>,
) {
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<string, unknown>;
const telegram = { ...(config.telegram ?? {}) } as Record<string, unknown>;
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;
}
}

View File

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

View File

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

View File

@@ -0,0 +1,26 @@
import type { GatewayBrowserClient } from "../gateway";
export type NodesState = {
client: GatewayBrowserClient | null;
connected: boolean;
nodesLoading: boolean;
nodes: Array<Record<string, unknown>>;
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<Record<string, unknown>>;
};
state.nodes = Array.isArray(res.nodes) ? res.nodes : [];
} catch (err) {
state.lastError = String(err);
} finally {
state.nodesLoading = false;
}
}

View File

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

View File

@@ -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<string, unknown> = {
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<string, unknown> = { 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);
}
}

View File

@@ -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<string, string>;
};
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;
}
}

54
ui/src/ui/format.ts Normal file
View File

@@ -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 | null | undefined>): 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);
}

75
ui/src/ui/navigation.ts Normal file
View File

@@ -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 "";
}
}

58
ui/src/ui/presenter.ts Normal file
View File

@@ -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}`;
}

251
ui/src/ui/types.ts Normal file
View File

@@ -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<string, unknown> | 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<string, unknown>;
export type HealthSnapshot = Record<string, unknown>;

31
ui/src/ui/ui-types.ts Normal file
View File

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

116
ui/src/ui/views/chat.ts Normal file
View File

@@ -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`
<section class="card">
<div class="row" style="justify-content: space-between;">
<div class="row">
<label class="field" style="min-width: 220px;">
<span>Session Key</span>
<input
.value=${props.sessionKey}
@input=${(e: Event) =>
props.onSessionKeyChange((e.target as HTMLInputElement).value)}
/>
</label>
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Loading…" : "Refresh"}
</button>
</div>
<div class="muted">
Thinking: ${props.thinkingLevel ?? "inherit"}
</div>
</div>
<div class="messages" style="margin-top: 12px;">
${props.messages.map((m) => renderMessage(m))}
${props.stream
? html`${renderMessage({
role: "assistant",
content: [{ type: "text", text: props.stream }],
})}`
: nothing}
</div>
<div class="compose" style="margin-top: 12px;">
<label class="field">
<span>Message</span>
<textarea
.value=${props.draft}
@input=${(e: Event) =>
props.onDraftChange((e.target as HTMLTextAreaElement).value)}
placeholder="Ask the model…"
></textarea>
</label>
<div class="row" style="justify-content: flex-end;">
<button
class="btn primary"
?disabled=${props.sending || !props.connected}
@click=${props.onSend}
>
${props.sending ? "Sending…" : "Send"}
</button>
</div>
</div>
</section>
`;
}
function renderMessage(message: unknown) {
const m = message as Record<string, unknown>;
const role = typeof m.role === "string" ? m.role : "unknown";
const text =
extractText(message) ??
(typeof m.content === "string"
? m.content
: JSON.stringify(message, null, 2));
const ts =
typeof m.timestamp === "number"
? new Date(m.timestamp).toLocaleTimeString()
: "";
const klass = role === "assistant" ? "assistant" : role === "user" ? "user" : "";
return html`
<div class="msg ${klass}">
<div class="meta">
<span class="mono">${role}</span>
<span class="mono">${ts}</span>
</div>
<div class="msgContent">${text}</div>
</div>
`;
}
function extractText(message: unknown): string | null {
const m = message as Record<string, unknown>;
const content = m.content;
if (typeof content === "string") return content;
if (Array.isArray(content)) {
const parts = content
.map((p) => {
const item = p as Record<string, unknown>;
if (item.type === "text" && typeof item.text === "string") return item.text;
return null;
})
.filter((v): v is string => typeof v === "string");
if (parts.length > 0) return parts.join("\n");
}
if (typeof m.text === "string") return m.text;
return null;
}

61
ui/src/ui/views/config.ts Normal file
View File

@@ -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`
<section class="card">
<div class="row" style="justify-content: space-between;">
<div class="row">
<div class="card-title">Config</div>
<span class="pill">${validity}</span>
</div>
<div class="row">
<button class="btn" ?disabled=${props.loading} @click=${props.onReload}>
${props.loading ? "Loading…" : "Reload"}
</button>
<button
class="btn primary"
?disabled=${props.saving || !props.connected}
@click=${props.onSave}
>
${props.saving ? "Saving…" : "Save"}
</button>
</div>
</div>
<div class="muted" style="margin-top: 10px;">
Writes to <span class="mono">~/.clawdis/clawdis.json</span>. Some changes
require a gateway restart.
</div>
<label class="field" style="margin-top: 12px;">
<span>Raw JSON5</span>
<textarea
.value=${props.raw}
@input=${(e: Event) =>
props.onRawChange((e.target as HTMLTextAreaElement).value)}
></textarea>
</label>
${props.issues.length > 0
? html`<div class="callout danger" style="margin-top: 12px;">
<pre class="code-block">${JSON.stringify(props.issues, null, 2)}</pre>
</div>`
: nothing}
</section>
`;
}

View File

@@ -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<TelegramForm>) => void;
onTelegramSave: () => void;
};
export function renderConnections(props: ConnectionsProps) {
const whatsapp = props.snapshot?.whatsapp;
const telegram = props.snapshot?.telegram;
return html`
<section class="grid grid-cols-2">
<div class="card">
<div class="card-title">WhatsApp</div>
<div class="card-sub">Link WhatsApp Web and monitor connection health.</div>
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${whatsapp?.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Linked</span>
<span>${whatsapp?.linked ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${whatsapp?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Connected</span>
<span>${whatsapp?.connected ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Last connect</span>
<span>${whatsapp?.lastConnectedAt ? formatAgo(whatsapp.lastConnectedAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last message</span>
<span>${whatsapp?.lastMessageAt ? formatAgo(whatsapp.lastMessageAt) : "n/a"}</span>
</div>
<div>
<span class="label">Auth age</span>
<span>
${whatsapp?.authAgeMs != null ? formatDuration(whatsapp.authAgeMs) : "n/a"}
</span>
</div>
</div>
${whatsapp?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${whatsapp.lastError}
</div>`
: nothing}
${props.whatsappMessage
? html`<div class="callout" style="margin-top: 12px;">
${props.whatsappMessage}
</div>`
: nothing}
${props.whatsappQrDataUrl
? html`<div class="qr-wrap">
<img src=${props.whatsappQrDataUrl} alt="WhatsApp QR" />
</div>`
: nothing}
<div class="row" style="margin-top: 14px; flex-wrap: wrap;">
<button
class="btn primary"
?disabled=${props.whatsappBusy}
@click=${() => props.onWhatsAppStart(false)}
>
${props.whatsappBusy ? "Working…" : "Show QR"}
</button>
<button
class="btn"
?disabled=${props.whatsappBusy}
@click=${() => props.onWhatsAppStart(true)}
>
Relink
</button>
<button
class="btn"
?disabled=${props.whatsappBusy}
@click=${() => props.onWhatsAppWait()}
>
Wait for scan
</button>
<button
class="btn danger"
?disabled=${props.whatsappBusy}
@click=${() => props.onWhatsAppLogout()}
>
Logout
</button>
<button class="btn" @click=${() => props.onRefresh(true)}>
Refresh
</button>
</div>
</div>
<div class="card">
<div class="card-title">Telegram</div>
<div class="card-sub">Bot token and delivery options.</div>
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${telegram?.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${telegram?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Mode</span>
<span>${telegram?.mode ?? "n/a"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${telegram?.lastStartAt ? formatAgo(telegram.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${telegram?.lastProbeAt ? formatAgo(telegram.lastProbeAt) : "n/a"}</span>
</div>
</div>
${telegram?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${telegram.lastError}
</div>`
: nothing}
${telegram?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${telegram.probe.ok ? "ok" : "failed"} ·
${telegram.probe.status ?? ""}
${telegram.probe.error ?? ""}
</div>`
: nothing}
<div class="form-grid" style="margin-top: 16px;">
<label class="field">
<span>Bot token</span>
<input
type="password"
.value=${props.telegramForm.token}
?disabled=${props.telegramTokenLocked}
@input=${(e: Event) =>
props.onTelegramChange({
token: (e.target as HTMLInputElement).value,
})}
/>
</label>
<label class="field">
<span>Require mention</span>
<select
.value=${props.telegramForm.requireMention ? "yes" : "no"}
@change=${(e: Event) =>
props.onTelegramChange({
requireMention: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>Allow from</span>
<input
.value=${props.telegramForm.allowFrom}
@input=${(e: Event) =>
props.onTelegramChange({
allowFrom: (e.target as HTMLInputElement).value,
})}
placeholder="123456789, @team"
/>
</label>
<label class="field">
<span>Proxy</span>
<input
.value=${props.telegramForm.proxy}
@input=${(e: Event) =>
props.onTelegramChange({
proxy: (e.target as HTMLInputElement).value,
})}
placeholder="socks5://localhost:9050"
/>
</label>
<label class="field">
<span>Webhook URL</span>
<input
.value=${props.telegramForm.webhookUrl}
@input=${(e: Event) =>
props.onTelegramChange({
webhookUrl: (e.target as HTMLInputElement).value,
})}
placeholder="https://example.com/telegram-webhook"
/>
</label>
<label class="field">
<span>Webhook secret</span>
<input
.value=${props.telegramForm.webhookSecret}
@input=${(e: Event) =>
props.onTelegramChange({
webhookSecret: (e.target as HTMLInputElement).value,
})}
placeholder="secret"
/>
</label>
<label class="field">
<span>Webhook path</span>
<input
.value=${props.telegramForm.webhookPath}
@input=${(e: Event) =>
props.onTelegramChange({
webhookPath: (e.target as HTMLInputElement).value,
})}
placeholder="/telegram-webhook"
/>
</label>
</div>
${props.telegramTokenLocked
? html`<div class="callout" style="margin-top: 12px;">
TELEGRAM_BOT_TOKEN is set in the environment. Config edits will not override it.
</div>`
: nothing}
${props.telegramStatus
? html`<div class="callout" style="margin-top: 12px;">
${props.telegramStatus}
</div>`
: nothing}
<div class="row" style="margin-top: 14px;">
<button
class="btn primary"
?disabled=${props.telegramSaving}
@click=${() => props.onTelegramSave()}
>
${props.telegramSaving ? "Saving…" : "Save"}
</button>
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
</div>
</div>
</section>
<section class="card" style="margin-top: 18px;">
<div class="row" style="justify-content: space-between;">
<div>
<div class="card-title">Connection health</div>
<div class="card-sub">Provider status snapshots from the gateway.</div>
</div>
<div class="muted">${props.lastSuccessAt ? formatAgo(props.lastSuccessAt) : "n/a"}</div>
</div>
${props.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${props.lastError}
</div>`
: nothing}
<pre class="code-block" style="margin-top: 12px;">
${props.snapshot ? JSON.stringify(props.snapshot, null, 2) : "No snapshot yet."}
</pre>
</section>
`;
}
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`;
}

390
ui/src/ui/views/cron.ts Normal file
View File

@@ -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<CronFormState>) => 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`
<section class="grid grid-cols-2">
<div class="card">
<div class="card-title">Scheduler</div>
<div class="card-sub">Gateway-owned cron scheduler status.</div>
<div class="stat-grid" style="margin-top: 16px;">
<div class="stat">
<div class="stat-label">Enabled</div>
<div class="stat-value">
${props.status
? props.status.enabled
? "Yes"
: "No"
: "n/a"}
</div>
</div>
<div class="stat">
<div class="stat-label">Jobs</div>
<div class="stat-value">${props.status?.jobCount ?? "n/a"}</div>
</div>
<div class="stat">
<div class="stat-label">Next wake</div>
<div class="stat-value">${formatNextRun(props.status?.nextWakeAtMs ?? null)}</div>
</div>
</div>
<div class="row" style="margin-top: 12px;">
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Refreshing…" : "Refresh"}
</button>
${props.error ? html`<span class="muted">${props.error}</span>` : nothing}
</div>
</div>
<div class="card">
<div class="card-title">New Job</div>
<div class="card-sub">Create a scheduled wakeup or agent run.</div>
<div class="form-grid" style="margin-top: 16px;">
<label class="field">
<span>Name</span>
<input
.value=${props.form.name}
@input=${(e: Event) =>
props.onFormChange({ name: (e.target as HTMLInputElement).value })}
/>
</label>
<label class="field">
<span>Description</span>
<input
.value=${props.form.description}
@input=${(e: Event) =>
props.onFormChange({ description: (e.target as HTMLInputElement).value })}
/>
</label>
<label class="field checkbox">
<span>Enabled</span>
<input
type="checkbox"
.checked=${props.form.enabled}
@change=${(e: Event) =>
props.onFormChange({ enabled: (e.target as HTMLInputElement).checked })}
/>
</label>
<label class="field">
<span>Schedule</span>
<select
.value=${props.form.scheduleKind}
@change=${(e: Event) =>
props.onFormChange({
scheduleKind: (e.target as HTMLSelectElement).value as CronFormState["scheduleKind"],
})}
>
<option value="every">Every</option>
<option value="at">At</option>
<option value="cron">Cron</option>
</select>
</label>
</div>
${renderScheduleFields(props)}
<div class="form-grid" style="margin-top: 12px;">
<label class="field">
<span>Session</span>
<select
.value=${props.form.sessionTarget}
@change=${(e: Event) =>
props.onFormChange({
sessionTarget: (e.target as HTMLSelectElement).value as CronFormState["sessionTarget"],
})}
>
<option value="main">Main</option>
<option value="isolated">Isolated</option>
</select>
</label>
<label class="field">
<span>Wake mode</span>
<select
.value=${props.form.wakeMode}
@change=${(e: Event) =>
props.onFormChange({
wakeMode: (e.target as HTMLSelectElement).value as CronFormState["wakeMode"],
})}
>
<option value="next-heartbeat">Next heartbeat</option>
<option value="now">Now</option>
</select>
</label>
<label class="field">
<span>Payload</span>
<select
.value=${props.form.payloadKind}
@change=${(e: Event) =>
props.onFormChange({
payloadKind: (e.target as HTMLSelectElement).value as CronFormState["payloadKind"],
})}
>
<option value="systemEvent">System event</option>
<option value="agentTurn">Agent turn</option>
</select>
</label>
</div>
<label class="field" style="margin-top: 12px;">
<span>${props.form.payloadKind === "systemEvent" ? "System text" : "Agent message"}</span>
<textarea
.value=${props.form.payloadText}
@input=${(e: Event) =>
props.onFormChange({
payloadText: (e.target as HTMLTextAreaElement).value,
})}
rows="4"
></textarea>
</label>
${props.form.payloadKind === "agentTurn"
? html`
<div class="form-grid" style="margin-top: 12px;">
<label class="field checkbox">
<span>Deliver</span>
<input
type="checkbox"
.checked=${props.form.deliver}
@change=${(e: Event) =>
props.onFormChange({
deliver: (e.target as HTMLInputElement).checked,
})}
/>
</label>
<label class="field">
<span>Channel</span>
<select
.value=${props.form.channel}
@change=${(e: Event) =>
props.onFormChange({
channel: (e.target as HTMLSelectElement).value as CronFormState["channel"],
})}
>
<option value="last">Last</option>
<option value="whatsapp">WhatsApp</option>
<option value="telegram">Telegram</option>
</select>
</label>
<label class="field">
<span>To</span>
<input
.value=${props.form.to}
@input=${(e: Event) =>
props.onFormChange({ to: (e.target as HTMLInputElement).value })}
placeholder="+1555… or chat id"
/>
</label>
<label class="field">
<span>Timeout (seconds)</span>
<input
.value=${props.form.timeoutSeconds}
@input=${(e: Event) =>
props.onFormChange({
timeoutSeconds: (e.target as HTMLInputElement).value,
})}
/>
</label>
${props.form.sessionTarget === "isolated"
? html`
<label class="field">
<span>Post to main prefix</span>
<input
.value=${props.form.postToMainPrefix}
@input=${(e: Event) =>
props.onFormChange({
postToMainPrefix: (e.target as HTMLInputElement).value,
})}
/>
</label>
`
: nothing}
</div>
`
: nothing}
<div class="row" style="margin-top: 14px;">
<button class="btn primary" ?disabled=${props.busy} @click=${props.onAdd}>
${props.busy ? "Saving…" : "Add job"}
</button>
</div>
</div>
</section>
<section class="card" style="margin-top: 18px;">
<div class="card-title">Jobs</div>
<div class="card-sub">All scheduled jobs stored in the gateway.</div>
${props.jobs.length === 0
? html`<div class="muted" style="margin-top: 12px;">No jobs yet.</div>`
: html`
<div class="list" style="margin-top: 12px;">
${props.jobs.map((job) => renderJob(job, props))}
</div>
`}
</section>
<section class="card" style="margin-top: 18px;">
<div class="card-title">Run history</div>
<div class="card-sub">Latest runs for ${props.runsJobId ?? "(select a job)"}.</div>
${props.runs.length === 0
? html`<div class="muted" style="margin-top: 12px;">No runs yet.</div>`
: html`
<div class="list" style="margin-top: 12px;">
${props.runs.map((entry) => renderRun(entry))}
</div>
`}
</section>
`;
}
function renderScheduleFields(props: CronProps) {
const form = props.form;
if (form.scheduleKind === "at") {
return html`
<label class="field" style="margin-top: 12px;">
<span>Run at</span>
<input
type="datetime-local"
.value=${form.scheduleAt}
@input=${(e: Event) =>
props.onFormChange({
scheduleAt: (e.target as HTMLInputElement).value,
})}
/>
</label>
`;
}
if (form.scheduleKind === "every") {
return html`
<div class="form-grid" style="margin-top: 12px;">
<label class="field">
<span>Every</span>
<input
.value=${form.everyAmount}
@input=${(e: Event) =>
props.onFormChange({
everyAmount: (e.target as HTMLInputElement).value,
})}
/>
</label>
<label class="field">
<span>Unit</span>
<select
.value=${form.everyUnit}
@change=${(e: Event) =>
props.onFormChange({
everyUnit: (e.target as HTMLSelectElement).value as CronFormState["everyUnit"],
})}
>
<option value="minutes">Minutes</option>
<option value="hours">Hours</option>
<option value="days">Days</option>
</select>
</label>
</div>
`;
}
return html`
<div class="form-grid" style="margin-top: 12px;">
<label class="field">
<span>Expression</span>
<input
.value=${form.cronExpr}
@input=${(e: Event) =>
props.onFormChange({ cronExpr: (e.target as HTMLInputElement).value })}
/>
</label>
<label class="field">
<span>Timezone (optional)</span>
<input
.value=${form.cronTz}
@input=${(e: Event) =>
props.onFormChange({ cronTz: (e.target as HTMLInputElement).value })}
/>
</label>
</div>
`;
}
function renderJob(job: CronJob, props: CronProps) {
return html`
<div class="list-item">
<div class="list-main">
<div class="list-title">${job.name}</div>
<div class="list-sub">${formatCronSchedule(job)}</div>
<div class="muted">${formatCronPayload(job)}</div>
<div class="chip-row" style="margin-top: 6px;">
<span class="chip">${job.enabled ? "enabled" : "disabled"}</span>
<span class="chip">${job.sessionTarget}</span>
<span class="chip">${job.wakeMode}</span>
</div>
</div>
<div class="list-meta">
<div>${formatCronState(job)}</div>
<div class="row" style="justify-content: flex-end; margin-top: 8px;">
<button
class="btn"
?disabled=${props.busy}
@click=${() => props.onToggle(job, !job.enabled)}
>
${job.enabled ? "Disable" : "Enable"}
</button>
<button class="btn" ?disabled=${props.busy} @click=${() => props.onRun(job)}>
Run
</button>
<button
class="btn"
?disabled=${props.busy}
@click=${() => props.onLoadRuns(job.id)}
>
Runs
</button>
<button
class="btn danger"
?disabled=${props.busy}
@click=${() => props.onRemove(job)}
>
Remove
</button>
</div>
</div>
</div>
`;
}
function renderRun(entry: CronRunLogEntry) {
return html`
<div class="list-item">
<div class="list-main">
<div class="list-title">${entry.status}</div>
<div class="list-sub">${entry.summary ?? ""}</div>
</div>
<div class="list-meta">
<div>${formatMs(entry.ts)}</div>
<div class="muted">${entry.durationMs ?? 0}ms</div>
${entry.error ? html`<div class="muted">${entry.error}</div>` : nothing}
</div>
</div>
`;
}

129
ui/src/ui/views/debug.ts Normal file
View File

@@ -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<string, unknown> | null;
health: Record<string, unknown> | 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`
<section class="grid grid-cols-2">
<div class="card">
<div class="row" style="justify-content: space-between;">
<div>
<div class="card-title">Snapshots</div>
<div class="card-sub">Status, health, and heartbeat data.</div>
</div>
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Refreshing…" : "Refresh"}
</button>
</div>
<div class="stack" style="margin-top: 12px;">
<div>
<div class="muted">Status</div>
<pre class="code-block">${JSON.stringify(props.status ?? {}, null, 2)}</pre>
</div>
<div>
<div class="muted">Health</div>
<pre class="code-block">${JSON.stringify(props.health ?? {}, null, 2)}</pre>
</div>
<div>
<div class="muted">Last heartbeat</div>
<pre class="code-block">${JSON.stringify(props.heartbeat ?? {}, null, 2)}</pre>
</div>
</div>
</div>
<div class="card">
<div class="card-title">Manual RPC</div>
<div class="card-sub">Send a raw gateway method with JSON params.</div>
<div class="form-grid" style="margin-top: 16px;">
<label class="field">
<span>Method</span>
<input
.value=${props.callMethod}
@input=${(e: Event) =>
props.onCallMethodChange((e.target as HTMLInputElement).value)}
placeholder="system-presence"
/>
</label>
<label class="field">
<span>Params (JSON)</span>
<textarea
.value=${props.callParams}
@input=${(e: Event) =>
props.onCallParamsChange((e.target as HTMLTextAreaElement).value)}
rows="6"
></textarea>
</label>
</div>
<div class="row" style="margin-top: 12px;">
<button class="btn primary" @click=${props.onCall}>Call</button>
</div>
${props.callError
? html`<div class="callout danger" style="margin-top: 12px;">
${props.callError}
</div>`
: nothing}
${props.callResult
? html`<pre class="code-block" style="margin-top: 12px;">${props.callResult}</pre>`
: nothing}
</div>
</section>
<section class="card" style="margin-top: 18px;">
<div class="card-title">Models</div>
<div class="card-sub">Catalog from models.list.</div>
<pre class="code-block" style="margin-top: 12px;">${JSON.stringify(
props.models ?? [],
null,
2,
)}</pre>
</section>
<section class="card" style="margin-top: 18px;">
<div class="card-title">Event Log</div>
<div class="card-sub">Latest gateway events.</div>
${props.eventLog.length === 0
? html`<div class="muted" style="margin-top: 12px;">No events yet.</div>`
: html`
<div class="list" style="margin-top: 12px;">
${props.eventLog.map(
(evt) => html`
<div class="list-item">
<div class="list-main">
<div class="list-title">${evt.event}</div>
<div class="list-sub">${new Date(evt.ts).toLocaleTimeString()}</div>
</div>
<div class="list-meta">
<pre class="code-block">${formatEventPayload(evt.payload)}</pre>
</div>
</div>
`,
)}
</div>
`}
</section>
`;
}

View File

@@ -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`
<section class="card">
<div class="row" style="justify-content: space-between;">
<div>
<div class="card-title">Connected Instances</div>
<div class="card-sub">Presence beacons from the gateway and clients.</div>
</div>
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Loading…" : "Refresh"}
</button>
</div>
${props.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${props.lastError}
</div>`
: nothing}
${props.statusMessage
? html`<div class="callout" style="margin-top: 12px;">
${props.statusMessage}
</div>`
: nothing}
<div class="list" style="margin-top: 16px;">
${props.entries.length === 0
? html`<div class="muted">No instances reported yet.</div>`
: props.entries.map((entry) => renderEntry(entry))}
</div>
</section>
`;
}
function renderEntry(entry: PresenceEntry) {
const lastInput =
entry.lastInputSeconds != null
? `${entry.lastInputSeconds}s ago`
: "n/a";
const mode = entry.mode ?? "unknown";
return html`
<div class="list-item">
<div class="list-main">
<div class="list-title">${entry.host ?? "unknown host"}</div>
<div class="list-sub">${formatPresenceSummary(entry)}</div>
<div class="chip-row">
<span class="chip">${mode}</span>
${entry.platform ? html`<span class="chip">${entry.platform}</span>` : nothing}
${entry.deviceFamily
? html`<span class="chip">${entry.deviceFamily}</span>`
: nothing}
${entry.modelIdentifier
? html`<span class="chip">${entry.modelIdentifier}</span>`
: nothing}
${entry.version ? html`<span class="chip">${entry.version}</span>` : nothing}
</div>
</div>
<div class="list-meta">
<div>${formatPresenceAge(entry)}</div>
<div class="muted">Last input ${lastInput}</div>
<div class="muted">Reason ${entry.reason ?? ""}</div>
</div>
</div>
`;
}

61
ui/src/ui/views/nodes.ts Normal file
View File

@@ -0,0 +1,61 @@
import { html } from "lit";
export type NodesProps = {
loading: boolean;
nodes: Array<Record<string, unknown>>;
onRefresh: () => void;
};
export function renderNodes(props: NodesProps) {
return html`
<section class="card">
<div class="row" style="justify-content: space-between;">
<div>
<div class="card-title">Nodes</div>
<div class="card-sub">Paired devices and live connections.</div>
</div>
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Loading…" : "Refresh"}
</button>
</div>
<div class="list" style="margin-top: 16px;">
${props.nodes.length === 0
? html`<div class="muted">No nodes found.</div>`
: props.nodes.map((n) => renderNode(n))}
</div>
</section>
`;
}
function renderNode(node: Record<string, unknown>) {
const connected = Boolean(node.connected);
const paired = Boolean(node.paired);
const title =
(typeof node.displayName === "string" && node.displayName.trim()) ||
(typeof node.nodeId === "string" ? node.nodeId : "unknown");
const caps = Array.isArray(node.caps) ? (node.caps as unknown[]) : [];
const commands = Array.isArray(node.commands) ? (node.commands as unknown[]) : [];
return html`
<div class="list-item">
<div class="list-main">
<div class="list-title">${title}</div>
<div class="list-sub">
${typeof node.nodeId === "string" ? node.nodeId : ""}
${typeof node.remoteIp === "string" ? ` · ${node.remoteIp}` : ""}
${typeof node.version === "string" ? ` · ${node.version}` : ""}
</div>
<div class="chip-row" style="margin-top: 6px;">
<span class="chip">${paired ? "paired" : "unpaired"}</span>
<span class="chip ${connected ? "chip-ok" : "chip-warn"}">
${connected ? "connected" : "offline"}
</span>
${caps.slice(0, 12).map((c) => html`<span class="chip">${String(c)}</span>`)}
${commands
.slice(0, 8)
.map((c) => html`<span class="chip">${String(c)}</span>`)}
</div>
</div>
</div>
`;
}

184
ui/src/ui/views/overview.ts Normal file
View File

@@ -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`
<section class="grid grid-cols-2">
<div class="card">
<div class="card-title">Gateway Access</div>
<div class="card-sub">Where the dashboard connects and how it authenticates.</div>
<div class="form-grid" style="margin-top: 16px;">
<label class="field">
<span>WebSocket URL</span>
<input
.value=${props.settings.gatewayUrl}
@input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
props.onSettingsChange({ ...props.settings, gatewayUrl: v });
}}
placeholder="ws://100.x.y.z:18789"
/>
</label>
<label class="field">
<span>Gateway Token</span>
<input
.value=${props.settings.token}
@input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
props.onSettingsChange({ ...props.settings, token: v });
}}
placeholder="CLAWDIS_GATEWAY_TOKEN"
/>
</label>
<label class="field">
<span>Username (system auth)</span>
<input
.value=${props.settings.username}
@input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
props.onSettingsChange({ ...props.settings, username: v });
}}
placeholder="optional"
/>
</label>
<label class="field">
<span>Password (not stored)</span>
<input
type="password"
.value=${props.password}
@input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
props.onPasswordChange(v);
}}
placeholder="system or shared password"
/>
</label>
<label class="field">
<span>Default Session Key</span>
<input
.value=${props.settings.sessionKey}
@input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
props.onSessionKeyChange(v);
}}
/>
</label>
</div>
<div class="row" style="margin-top: 14px;">
<button class="btn" @click=${() => props.onRefresh()}>Refresh</button>
<span class="muted">Reconnect to apply changes.</span>
</div>
</div>
<div class="card">
<div class="card-title">Snapshot</div>
<div class="card-sub">Latest gateway handshake information.</div>
<div class="stat-grid" style="margin-top: 16px;">
<div class="stat">
<div class="stat-label">Status</div>
<div class="stat-value ${props.connected ? "ok" : "warn"}">
${props.connected ? "Connected" : "Disconnected"}
</div>
</div>
<div class="stat">
<div class="stat-label">Uptime</div>
<div class="stat-value">${uptime}</div>
</div>
<div class="stat">
<div class="stat-label">Tick Interval</div>
<div class="stat-value">${tick}</div>
</div>
<div class="stat">
<div class="stat-label">Last Providers Refresh</div>
<div class="stat-value">
${props.lastProvidersRefresh
? formatAgo(props.lastProvidersRefresh)
: "n/a"}
</div>
</div>
</div>
${props.lastError
? html`<div class="callout danger" style="margin-top: 14px;">
${props.lastError}
</div>`
: html`<div class="callout" style="margin-top: 14px;">
Use Connections to link WhatsApp and Telegram.
</div>`}
</div>
</section>
<section class="grid grid-cols-3" style="margin-top: 18px;">
<div class="card stat-card">
<div class="stat-label">Instances</div>
<div class="stat-value">${props.presenceCount}</div>
<div class="muted">Presence beacons in the last 5 minutes.</div>
</div>
<div class="card stat-card">
<div class="stat-label">Sessions</div>
<div class="stat-value">${props.sessionsCount ?? "n/a"}</div>
<div class="muted">Recent session keys tracked by the gateway.</div>
</div>
<div class="card stat-card">
<div class="stat-label">Cron</div>
<div class="stat-value">
${props.cronEnabled == null
? "n/a"
: props.cronEnabled
? "Enabled"
: "Disabled"}
</div>
<div class="muted">Next wake ${formatNextRun(props.cronNext)}</div>
</div>
</section>
<section class="card" style="margin-top: 18px;">
<div class="card-title">Notes</div>
<div class="card-sub">Quick reminders for remote control setups.</div>
<div class="note-grid" style="margin-top: 14px;">
<div>
<div class="note-title">Tailscale serve</div>
<div class="muted">
Prefer serve mode to keep the gateway on loopback with tailnet auth.
</div>
</div>
<div>
<div class="note-title">Session hygiene</div>
<div class="muted">Use /new or sessions.patch to reset context.</div>
</div>
<div>
<div class="note-title">Cron reminders</div>
<div class="muted">Use isolated sessions for recurring runs.</div>
</div>
</div>
</section>
`;
}

165
ui/src/ui/views/sessions.ts Normal file
View File

@@ -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`
<section class="card">
<div class="row" style="justify-content: space-between;">
<div>
<div class="card-title">Sessions</div>
<div class="card-sub">Active session keys and per-session overrides.</div>
</div>
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Loading…" : "Refresh"}
</button>
</div>
<div class="filters" style="margin-top: 14px;">
<label class="field">
<span>Active within (minutes)</span>
<input
.value=${props.activeMinutes}
@input=${(e: Event) =>
props.onFiltersChange({
activeMinutes: (e.target as HTMLInputElement).value,
limit: props.limit,
includeGlobal: props.includeGlobal,
includeUnknown: props.includeUnknown,
})}
/>
</label>
<label class="field">
<span>Limit</span>
<input
.value=${props.limit}
@input=${(e: Event) =>
props.onFiltersChange({
activeMinutes: props.activeMinutes,
limit: (e.target as HTMLInputElement).value,
includeGlobal: props.includeGlobal,
includeUnknown: props.includeUnknown,
})}
/>
</label>
<label class="field checkbox">
<span>Include global</span>
<input
type="checkbox"
.checked=${props.includeGlobal}
@change=${(e: Event) =>
props.onFiltersChange({
activeMinutes: props.activeMinutes,
limit: props.limit,
includeGlobal: (e.target as HTMLInputElement).checked,
includeUnknown: props.includeUnknown,
})}
/>
</label>
<label class="field checkbox">
<span>Include unknown</span>
<input
type="checkbox"
.checked=${props.includeUnknown}
@change=${(e: Event) =>
props.onFiltersChange({
activeMinutes: props.activeMinutes,
limit: props.limit,
includeGlobal: props.includeGlobal,
includeUnknown: (e.target as HTMLInputElement).checked,
})}
/>
</label>
</div>
${props.error
? html`<div class="callout danger" style="margin-top: 12px;">${props.error}</div>`
: nothing}
<div class="muted" style="margin-top: 12px;">
${props.result ? `Store: ${props.result.path}` : ""}
</div>
<div class="table" style="margin-top: 16px;">
<div class="table-head">
<div>Key</div>
<div>Kind</div>
<div>Updated</div>
<div>Tokens</div>
<div>Thinking</div>
<div>Verbose</div>
</div>
${rows.length === 0
? html`<div class="muted">No sessions found.</div>`
: rows.map((row) => renderRow(row, props.onPatch))}
</div>
</section>
`;
}
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`
<div class="table-row">
<div class="mono">${row.key}</div>
<div>${row.kind}</div>
<div>${updated}</div>
<div>${formatSessionTokens(row)}</div>
<div>
<select
.value=${thinking}
@change=${(e: Event) => {
const value = (e.target as HTMLSelectElement).value;
onPatch(row.key, { thinkingLevel: value || null });
}}
>
${THINK_LEVELS.map((level) =>
html`<option value=${level}>${level || "inherit"}</option>`,
)}
</select>
</div>
<div>
<select
.value=${verbose}
@change=${(e: Event) => {
const value = (e.target as HTMLSelectElement).value;
onPatch(row.key, { verboseLevel: value || null });
}}
>
${VERBOSE_LEVELS.map((level) =>
html`<option value=${level}>${level || "inherit"}</option>`,
)}
</select>
</div>
</div>
`;
}

146
ui/src/ui/views/skills.ts Normal file
View File

@@ -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<string, string>;
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`
<section class="card">
<div class="row" style="justify-content: space-between;">
<div>
<div class="card-title">Skills</div>
<div class="card-sub">Bundled, managed, and workspace skills.</div>
</div>
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Loading…" : "Refresh"}
</button>
</div>
<div class="filters" style="margin-top: 14px;">
<label class="field" style="flex: 1;">
<span>Filter</span>
<input
.value=${props.filter}
@input=${(e: Event) =>
props.onFilterChange((e.target as HTMLInputElement).value)}
placeholder="Search skills"
/>
</label>
<div class="muted">${filtered.length} shown</div>
</div>
${props.error
? html`<div class="callout danger" style="margin-top: 12px;">${props.error}</div>`
: nothing}
${filtered.length === 0
? html`<div class="muted" style="margin-top: 16px;">No skills found.</div>`
: html`
<div class="list" style="margin-top: 16px;">
${filtered.map((skill) => renderSkill(skill, props))}
</div>
`}
</section>
`;
}
function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
const busy = props.busyKey === skill.skillKey || props.busyKey === skill.name;
const apiKey = props.edits[skill.skillKey] ?? "";
return html`
<div class="list-item">
<div class="list-main">
<div class="list-title">
${skill.emoji ? `${skill.emoji} ` : ""}${skill.name}
</div>
<div class="list-sub">${clampText(skill.description, 140)}</div>
<div class="chip-row" style="margin-top: 6px;">
<span class="chip">${skill.source}</span>
<span class="chip ${skill.eligible ? "chip-ok" : "chip-warn"}">
${skill.eligible ? "eligible" : "blocked"}
</span>
${skill.disabled ? html`<span class="chip chip-warn">disabled</span>` : nothing}
</div>
${skill.missing.bins.length + skill.missing.env.length + skill.missing.config.length > 0
? html`
<div class="muted" style="margin-top: 6px;">
Missing: ${[
...skill.missing.bins.map((b) => `bin:${b}`),
...skill.missing.env.map((e) => `env:${e}`),
...skill.missing.config.map((c) => `config:${c}`),
].join(", ")}
</div>
`
: nothing}
</div>
<div class="list-meta">
<div class="row" style="justify-content: flex-end; flex-wrap: wrap;">
<button
class="btn"
?disabled=${busy}
@click=${() => props.onToggle(skill.skillKey, skill.disabled)}
>
${skill.disabled ? "Enable" : "Disable"}
</button>
${skill.install.length > 0
? html`<button
class="btn"
?disabled=${busy}
@click=${() => props.onInstall(skill.name, skill.install[0].id)}
>
${skill.install[0].label}
</button>`
: nothing}
</div>
${skill.primaryEnv
? html`
<div class="field" style="margin-top: 10px;">
<span>API key</span>
<input
type="password"
.value=${apiKey}
@input=${(e: Event) =>
props.onEdit(skill.skillKey, (e.target as HTMLInputElement).value)}
/>
</div>
<button
class="btn primary"
style="margin-top: 8px;"
?disabled=${busy}
@click=${() => props.onSaveKey(skill.skillKey)}
>
Save key
</button>
`
: nothing}
</div>
</div>
`;
}