feat(ui): expand control dashboard
This commit is contained in:
@@ -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 (it’s sent as `connect.params.auth.token`).
|
||||
Paste the token into the UI settings (sent as `connect.params.auth.token`).
|
||||
|
||||
## Building the UI
|
||||
|
||||
|
||||
51
docs/web.md
51
docs/web.md
@@ -12,8 +12,14 @@ The Gateway serves a small **browser Control UI** (Vite + Lit) from the same por
|
||||
|
||||
The UI talks directly to the Gateway WS and supports:
|
||||
- Chat (`chat.history`, `chat.send`, `chat.abort`)
|
||||
- Connections (provider status, WhatsApp QR, Telegram config)
|
||||
- Instances (`system-presence`)
|
||||
- Sessions (`sessions.list`, `sessions.patch`)
|
||||
- Cron (`cron.*`)
|
||||
- Skills (`skills.status`, `skills.update`, `skills.install`)
|
||||
- Nodes (`node.list`, `node.describe`, `node.invoke`)
|
||||
- Config (`config.get`, `config.set`) for `~/.clawdis/clawdis.json`
|
||||
- Debug (status/health/models snapshots + manual calls)
|
||||
|
||||
## Config (default-on)
|
||||
|
||||
@@ -28,11 +34,31 @@ You can control it via config:
|
||||
}
|
||||
```
|
||||
|
||||
## Tailnet access
|
||||
## Tailscale access
|
||||
|
||||
To access the UI across Tailscale, bind the Gateway to the Tailnet interface and require a token.
|
||||
### Integrated Serve (recommended)
|
||||
|
||||
### Via config (recommended)
|
||||
Keep the Gateway on loopback and let Tailscale Serve proxy it:
|
||||
|
||||
```json5
|
||||
{
|
||||
gateway: {
|
||||
bind: "loopback",
|
||||
tailscale: { mode: "serve" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then start the gateway:
|
||||
|
||||
```bash
|
||||
clawdis gateway
|
||||
```
|
||||
|
||||
Open:
|
||||
- `https://<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
|
||||
```
|
||||
|
||||
|
||||
@@ -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
89
ui/src/styles/base.css
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
350
ui/src/styles/components.css
Normal file
350
ui/src/styles/components.css
Normal 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
200
ui/src/styles/layout.css
Normal 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
368
ui/src/ui/app-render.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
888
ui/src/ui/app.ts
888
ui/src/ui/app.ts
@@ -1,203 +1,172 @@
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { LitElement, html, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
|
||||
import { GatewayBrowserClient, type GatewayEventFrame } from "./gateway";
|
||||
import { GatewayBrowserClient, type GatewayEventFrame, type GatewayHelloOk } from "./gateway";
|
||||
import { loadSettings, saveSettings, type UiSettings } from "./storage";
|
||||
import { renderApp } from "./app-render";
|
||||
import type { Tab } from "./navigation";
|
||||
import type {
|
||||
ConfigSnapshot,
|
||||
CronJob,
|
||||
CronRunLogEntry,
|
||||
CronStatus,
|
||||
HealthSnapshot,
|
||||
PresenceEntry,
|
||||
ProvidersStatusSnapshot,
|
||||
SessionsListResult,
|
||||
SkillStatusReport,
|
||||
StatusSummary,
|
||||
} from "./types";
|
||||
import type { CronFormState, TelegramForm } from "./ui-types";
|
||||
import { loadChatHistory, sendChat, handleChatEvent } from "./controllers/chat";
|
||||
import { loadNodes } from "./controllers/nodes";
|
||||
import { loadConfig } from "./controllers/config";
|
||||
import {
|
||||
loadProviders,
|
||||
logoutWhatsApp,
|
||||
saveTelegramConfig,
|
||||
startWhatsAppLogin,
|
||||
waitWhatsAppLogin,
|
||||
} from "./controllers/connections";
|
||||
import { loadPresence } from "./controllers/presence";
|
||||
import { loadSessions } from "./controllers/sessions";
|
||||
import {
|
||||
loadCronJobs,
|
||||
loadCronStatus,
|
||||
} from "./controllers/cron";
|
||||
import {
|
||||
loadSkills,
|
||||
} from "./controllers/skills";
|
||||
import { loadDebug } from "./controllers/debug";
|
||||
|
||||
type Tab = "chat" | "nodes" | "config";
|
||||
type EventLogEntry = {
|
||||
ts: number;
|
||||
event: string;
|
||||
payload?: unknown;
|
||||
};
|
||||
|
||||
const DEFAULT_CRON_FORM: CronFormState = {
|
||||
name: "",
|
||||
description: "",
|
||||
enabled: true,
|
||||
scheduleKind: "every",
|
||||
scheduleAt: "",
|
||||
everyAmount: "30",
|
||||
everyUnit: "minutes",
|
||||
cronExpr: "0 7 * * *",
|
||||
cronTz: "",
|
||||
sessionTarget: "main",
|
||||
wakeMode: "next-heartbeat",
|
||||
payloadKind: "systemEvent",
|
||||
payloadText: "",
|
||||
deliver: false,
|
||||
channel: "last",
|
||||
to: "",
|
||||
timeoutSeconds: "",
|
||||
postToMainPrefix: "",
|
||||
};
|
||||
|
||||
@customElement("clawdis-app")
|
||||
export class ClawdisApp extends LitElement {
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
.shell {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
nav {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
.tab {
|
||||
border: 1px solid transparent;
|
||||
padding: 7px 10px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
color: var(--muted);
|
||||
}
|
||||
.tab.active {
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 69, 0, 0.35);
|
||||
background: rgba(255, 69, 0, 0.12);
|
||||
}
|
||||
main {
|
||||
padding: 16px;
|
||||
max-width: 1120px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
border-radius: 16px;
|
||||
padding: 12px;
|
||||
}
|
||||
.statusDot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--danger);
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.statusDot.ok {
|
||||
background: var(--ok);
|
||||
}
|
||||
.title {
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.split {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 0.8fr;
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.split {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.messages {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.msg {
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 14px;
|
||||
padding: 10px 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
.msg .meta {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.msg.user {
|
||||
border-color: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
.msg.assistant {
|
||||
border-color: rgba(255, 69, 0, 0.25);
|
||||
background: rgba(255, 69, 0, 0.08);
|
||||
}
|
||||
.msgContent {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
.compose {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.compose textarea {
|
||||
min-height: 92px;
|
||||
font-family: var(--mono);
|
||||
}
|
||||
.nodes {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.nodeRow {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.nodeRow .top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.chip {
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 4px 8px;
|
||||
color: var(--muted);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.error {
|
||||
color: var(--danger);
|
||||
font-family: var(--mono);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
`;
|
||||
@state() settings: UiSettings = loadSettings();
|
||||
@state() password = "";
|
||||
@state() tab: Tab = "overview";
|
||||
@state() connected = false;
|
||||
@state() hello: GatewayHelloOk | null = null;
|
||||
@state() lastError: string | null = null;
|
||||
@state() eventLog: EventLogEntry[] = [];
|
||||
|
||||
@state() private settings: UiSettings = loadSettings();
|
||||
@state() private tab: Tab = "chat";
|
||||
@state() private connected = false;
|
||||
@state() private hello: unknown = null;
|
||||
@state() private lastError: string | null = null;
|
||||
@state() sessionKey = this.settings.sessionKey;
|
||||
@state() chatLoading = false;
|
||||
@state() chatSending = false;
|
||||
@state() chatMessage = "";
|
||||
@state() chatMessages: unknown[] = [];
|
||||
@state() chatStream: string | null = null;
|
||||
@state() chatRunId: string | null = null;
|
||||
@state() chatThinkingLevel: string | null = null;
|
||||
|
||||
@state() private sessionKey = this.settings.sessionKey;
|
||||
@state() private chatLoading = false;
|
||||
@state() private chatSending = false;
|
||||
@state() private chatMessage = "";
|
||||
@state() private chatMessages: unknown[] = [];
|
||||
@state() private chatStream: string | null = null;
|
||||
@state() private chatRunId: string | null = null;
|
||||
@state() nodesLoading = false;
|
||||
@state() nodes: Array<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;
|
||||
}
|
||||
|
||||
108
ui/src/ui/controllers/chat.ts
Normal file
108
ui/src/ui/controllers/chat.ts
Normal 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;
|
||||
}
|
||||
82
ui/src/ui/controllers/config.ts
Normal file
82
ui/src/ui/controllers/config.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
145
ui/src/ui/controllers/connections.ts
Normal file
145
ui/src/ui/controllers/connections.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
191
ui/src/ui/controllers/cron.ts
Normal file
191
ui/src/ui/controllers/cron.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
57
ui/src/ui/controllers/debug.ts
Normal file
57
ui/src/ui/controllers/debug.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
26
ui/src/ui/controllers/nodes.ts
Normal file
26
ui/src/ui/controllers/nodes.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
36
ui/src/ui/controllers/presence.ts
Normal file
36
ui/src/ui/controllers/presence.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
58
ui/src/ui/controllers/sessions.ts
Normal file
58
ui/src/ui/controllers/sessions.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
93
ui/src/ui/controllers/skills.ts
Normal file
93
ui/src/ui/controllers/skills.ts
Normal 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
54
ui/src/ui/format.ts
Normal 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
75
ui/src/ui/navigation.ts
Normal 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
58
ui/src/ui/presenter.ts
Normal 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
251
ui/src/ui/types.ts
Normal 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
31
ui/src/ui/ui-types.ts
Normal 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
116
ui/src/ui/views/chat.ts
Normal 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
61
ui/src/ui/views/config.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
|
||||
304
ui/src/ui/views/connections.ts
Normal file
304
ui/src/ui/views/connections.ts
Normal 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
390
ui/src/ui/views/cron.ts
Normal 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
129
ui/src/ui/views/debug.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
|
||||
75
ui/src/ui/views/instances.ts
Normal file
75
ui/src/ui/views/instances.ts
Normal 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
61
ui/src/ui/views/nodes.ts
Normal 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
184
ui/src/ui/views/overview.ts
Normal 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
165
ui/src/ui/views/sessions.ts
Normal 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
146
ui/src/ui/views/skills.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user