Gateway: add browser control UI
This commit is contained in:
3
ui/src/main.ts
Normal file
3
ui/src/main.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import "./styles.css";
|
||||
import "./ui/app.ts";
|
||||
|
||||
106
ui/src/styles.css
Normal file
106
ui/src/styles.css
Normal file
@@ -0,0 +1,106 @@
|
||||
:root {
|
||||
--bg: #0b0f19;
|
||||
--panel: rgba(255, 255, 255, 0.06);
|
||||
--panel2: rgba(255, 255, 255, 0.09);
|
||||
--text: rgba(255, 255, 255, 0.92);
|
||||
--muted: rgba(255, 255, 255, 0.65);
|
||||
--border: rgba(255, 255, 255, 0.12);
|
||||
--accent: #ff4500;
|
||||
--danger: #ff4d4f;
|
||||
--ok: #25d366;
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
"Liberation Mono", "Courier New", monospace;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font: 14px/1.35 system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial,
|
||||
"Apple Color Emoji", "Segoe UI Emoji";
|
||||
background: radial-gradient(1200px 800px at 25% 10%, #111b3a 0%, var(--bg) 55%)
|
||||
fixed;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--border);
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
padding: 7px 10px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn:hover {
|
||||
background: var(--panel2);
|
||||
}
|
||||
.btn.primary {
|
||||
border-color: rgba(255, 69, 0, 0.35);
|
||||
background: rgba(255, 69, 0, 0.18);
|
||||
}
|
||||
.btn.danger {
|
||||
border-color: rgba(255, 77, 79, 0.35);
|
||||
background: rgba(255, 77, 79, 0.16);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.field label {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.field input,
|
||||
.field textarea,
|
||||
.field select {
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
outline: none;
|
||||
}
|
||||
.field textarea {
|
||||
font-family: var(--mono);
|
||||
min-height: 220px;
|
||||
resize: vertical;
|
||||
white-space: pre;
|
||||
}
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
.mono {
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
695
ui/src/ui/app.ts
Normal file
695
ui/src/ui/app.ts
Normal file
@@ -0,0 +1,695 @@
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
|
||||
import { GatewayBrowserClient, type GatewayEventFrame } from "./gateway";
|
||||
import { loadSettings, saveSettings, type UiSettings } from "./storage";
|
||||
|
||||
type Tab = "chat" | "nodes" | "config";
|
||||
|
||||
@customElement("clawdis-app")
|
||||
export class ClawdisApp extends LitElement {
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
.shell {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
nav {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
.tab {
|
||||
border: 1px solid transparent;
|
||||
padding: 7px 10px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
color: var(--muted);
|
||||
}
|
||||
.tab.active {
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 69, 0, 0.35);
|
||||
background: rgba(255, 69, 0, 0.12);
|
||||
}
|
||||
main {
|
||||
padding: 16px;
|
||||
max-width: 1120px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
border-radius: 16px;
|
||||
padding: 12px;
|
||||
}
|
||||
.statusDot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--danger);
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.statusDot.ok {
|
||||
background: var(--ok);
|
||||
}
|
||||
.title {
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.split {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 0.8fr;
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.split {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.messages {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
.msg {
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 14px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.msg .meta {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.msg.user {
|
||||
border-color: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
.msg.assistant {
|
||||
border-color: rgba(255, 69, 0, 0.25);
|
||||
background: rgba(255, 69, 0, 0.08);
|
||||
}
|
||||
.compose {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.compose textarea {
|
||||
min-height: 92px;
|
||||
font-family: var(--mono);
|
||||
}
|
||||
.nodes {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.nodeRow {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.nodeRow .top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.chip {
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 4px 8px;
|
||||
color: var(--muted);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.error {
|
||||
color: var(--danger);
|
||||
font-family: var(--mono);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
`;
|
||||
|
||||
@state() private settings: UiSettings = loadSettings();
|
||||
@state() private tab: Tab = "chat";
|
||||
@state() private connected = false;
|
||||
@state() private hello: unknown = null;
|
||||
@state() private lastError: string | null = null;
|
||||
|
||||
@state() private sessionKey = this.settings.sessionKey;
|
||||
@state() private chatLoading = false;
|
||||
@state() private chatSending = false;
|
||||
@state() private chatMessage = "";
|
||||
@state() private chatMessages: unknown[] = [];
|
||||
@state() private chatStream: string | null = null;
|
||||
@state() private chatRunId: string | null = null;
|
||||
|
||||
@state() private nodesLoading = false;
|
||||
@state() private nodes: Array<Record<string, unknown>> = [];
|
||||
|
||||
@state() private configLoading = false;
|
||||
@state() private configRaw = "{\n}\n";
|
||||
@state() private configValid: boolean | null = null;
|
||||
@state() private configIssues: unknown[] = [];
|
||||
@state() private configSaving = false;
|
||||
|
||||
private client: GatewayBrowserClient | null = null;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.connect();
|
||||
}
|
||||
|
||||
private connect() {
|
||||
this.lastError = null;
|
||||
this.hello = null;
|
||||
this.connected = false;
|
||||
|
||||
this.client?.stop();
|
||||
this.client = new GatewayBrowserClient({
|
||||
url: this.settings.gatewayUrl,
|
||||
token: this.settings.token.trim() ? this.settings.token : undefined,
|
||||
clientName: "clawdis-control-ui",
|
||||
mode: "webchat",
|
||||
onHello: (hello) => {
|
||||
this.connected = true;
|
||||
this.hello = hello;
|
||||
void this.refreshActiveTab();
|
||||
},
|
||||
onClose: ({ code, reason }) => {
|
||||
this.connected = false;
|
||||
this.lastError = `disconnected (${code}): ${reason || "no reason"}`;
|
||||
},
|
||||
onEvent: (evt) => this.onEvent(evt),
|
||||
onGap: ({ expected, received }) => {
|
||||
this.lastError = `event gap detected (expected seq ${expected}, got ${received}); refresh recommended`;
|
||||
},
|
||||
});
|
||||
this.client.start();
|
||||
}
|
||||
|
||||
private onEvent(evt: GatewayEventFrame) {
|
||||
if (evt.event === "chat") {
|
||||
const payload = evt.payload as
|
||||
| {
|
||||
runId: string;
|
||||
sessionKey: string;
|
||||
state: "delta" | "final" | "aborted" | "error";
|
||||
message?: unknown;
|
||||
errorMessage?: string;
|
||||
}
|
||||
| undefined;
|
||||
if (!payload) return;
|
||||
if (payload.sessionKey !== this.sessionKey) return;
|
||||
if (payload.runId && this.chatRunId && payload.runId !== this.chatRunId)
|
||||
return;
|
||||
|
||||
if (payload.state === "delta") {
|
||||
this.chatStream = extractText(payload.message) ?? this.chatStream;
|
||||
} else if (payload.state === "final") {
|
||||
this.chatStream = null;
|
||||
this.chatRunId = null;
|
||||
void this.loadChatHistory();
|
||||
} else if (payload.state === "error") {
|
||||
this.chatStream = null;
|
||||
this.chatRunId = null;
|
||||
this.lastError = payload.errorMessage ?? "chat error";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshActiveTab() {
|
||||
if (this.tab === "chat") await this.loadChatHistory();
|
||||
if (this.tab === "nodes") await this.loadNodes();
|
||||
if (this.tab === "config") await this.loadConfig();
|
||||
}
|
||||
|
||||
private async loadChatHistory() {
|
||||
if (!this.client || !this.connected) return;
|
||||
this.chatLoading = true;
|
||||
this.lastError = null;
|
||||
try {
|
||||
const res = (await this.client.request("chat.history", {
|
||||
sessionKey: this.sessionKey,
|
||||
limit: 200,
|
||||
})) as { messages?: unknown[] };
|
||||
this.chatMessages = Array.isArray(res.messages) ? res.messages : [];
|
||||
} catch (err) {
|
||||
this.lastError = String(err);
|
||||
} finally {
|
||||
this.chatLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async sendChat() {
|
||||
if (!this.client || !this.connected) return;
|
||||
const msg = this.chatMessage.trim();
|
||||
if (!msg) return;
|
||||
|
||||
this.chatSending = true;
|
||||
this.lastError = null;
|
||||
const runId = crypto.randomUUID();
|
||||
this.chatRunId = runId;
|
||||
this.chatStream = "";
|
||||
try {
|
||||
await this.client.request("chat.send", {
|
||||
sessionKey: this.sessionKey,
|
||||
message: msg,
|
||||
deliver: false,
|
||||
idempotencyKey: runId,
|
||||
});
|
||||
this.chatMessage = "";
|
||||
// Final chat state will refresh history, but do an eager refresh in case
|
||||
// the run completed without emitting a chat event (older gateways).
|
||||
void this.loadChatHistory();
|
||||
} catch (err) {
|
||||
this.chatRunId = null;
|
||||
this.chatStream = null;
|
||||
this.lastError = String(err);
|
||||
} finally {
|
||||
this.chatSending = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadNodes() {
|
||||
if (!this.client || !this.connected) return;
|
||||
this.nodesLoading = true;
|
||||
this.lastError = null;
|
||||
try {
|
||||
const res = (await this.client.request("node.list", {})) as {
|
||||
nodes?: Array<Record<string, unknown>>;
|
||||
};
|
||||
this.nodes = Array.isArray(res.nodes) ? res.nodes : [];
|
||||
} catch (err) {
|
||||
this.lastError = String(err);
|
||||
} finally {
|
||||
this.nodesLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadConfig() {
|
||||
if (!this.client || !this.connected) return;
|
||||
this.configLoading = true;
|
||||
this.lastError = null;
|
||||
try {
|
||||
const res = (await this.client.request("config.get", {})) as {
|
||||
raw?: string | null;
|
||||
valid?: boolean;
|
||||
issues?: unknown[];
|
||||
config?: unknown;
|
||||
};
|
||||
if (typeof res.raw === "string") {
|
||||
this.configRaw = res.raw;
|
||||
} else {
|
||||
const cfg = res.config ?? {};
|
||||
this.configRaw = `${JSON.stringify(cfg, null, 2).trimEnd()}\n`;
|
||||
}
|
||||
this.configValid = typeof res.valid === "boolean" ? res.valid : null;
|
||||
this.configIssues = Array.isArray(res.issues) ? res.issues : [];
|
||||
} catch (err) {
|
||||
this.lastError = String(err);
|
||||
} finally {
|
||||
this.configLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async saveConfig() {
|
||||
if (!this.client || !this.connected) return;
|
||||
this.configSaving = true;
|
||||
this.lastError = null;
|
||||
try {
|
||||
await this.client.request("config.set", { raw: this.configRaw });
|
||||
await this.loadConfig();
|
||||
} catch (err) {
|
||||
this.lastError = String(err);
|
||||
} finally {
|
||||
this.configSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
private setTab(next: Tab) {
|
||||
this.tab = next;
|
||||
void this.refreshActiveTab();
|
||||
}
|
||||
|
||||
private applySettings(next: UiSettings) {
|
||||
this.settings = next;
|
||||
saveSettings(next);
|
||||
}
|
||||
|
||||
render() {
|
||||
const proto = this.settings.gatewayUrl.startsWith("wss://") ? "wss" : "ws";
|
||||
const connectedBadge = html`
|
||||
<span class="pill" title=${this.connected ? "connected" : "disconnected"}>
|
||||
<span class="statusDot ${this.connected ? "ok" : ""}"></span>
|
||||
<span class="mono">${proto}</span>
|
||||
<span class="mono">${this.settings.gatewayUrl}</span>
|
||||
</span>
|
||||
`;
|
||||
|
||||
return html`
|
||||
<div class="shell">
|
||||
<header>
|
||||
<div class="row">
|
||||
<div class="title">Clawdis Control</div>
|
||||
${connectedBadge}
|
||||
</div>
|
||||
<nav>
|
||||
${this.renderTabs()}
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<div class="grid">
|
||||
${this.renderSettingsCard()} ${this.renderActiveTab()}
|
||||
${this.lastError
|
||||
? html`<div class="card"><div class="error">${this.lastError}</div></div>`
|
||||
: nothing}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderTabs() {
|
||||
const tab = (id: Tab, label: string) => html`
|
||||
<div
|
||||
class="tab ${this.tab === id ? "active" : ""}"
|
||||
@click=${() => this.setTab(id)}
|
||||
>
|
||||
${label}
|
||||
</div>
|
||||
`;
|
||||
return html`${tab("chat", "Chat")} ${tab("nodes", "Nodes")}
|
||||
${tab("config", "Config")}`;
|
||||
}
|
||||
|
||||
private renderSettingsCard() {
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="split">
|
||||
<div class="field">
|
||||
<label>Gateway WebSocket URL</label>
|
||||
<input
|
||||
.value=${this.settings.gatewayUrl}
|
||||
@input=${(e: Event) => {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
this.applySettings({ ...this.settings, gatewayUrl: v });
|
||||
}}
|
||||
placeholder="ws://100.x.y.z:18789"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Gateway Token (CLAWDIS_GATEWAY_TOKEN)</label>
|
||||
<input
|
||||
.value=${this.settings.token}
|
||||
@input=${(e: Event) => {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
this.applySettings({ ...this.settings, token: v });
|
||||
}}
|
||||
placeholder="paste token"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" style="justify-content: space-between; margin-top: 10px;">
|
||||
<div class="muted">
|
||||
Tip: for Tailnet access, start the gateway with a token and bind to
|
||||
the Tailnet interface.
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="btn" @click=${() => this.connect()}>
|
||||
Reconnect
|
||||
</button>
|
||||
<button class="btn danger" @click=${() => this.client?.stop()}>
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderActiveTab() {
|
||||
if (this.tab === "chat") return this.renderChat();
|
||||
if (this.tab === "nodes") return this.renderNodes();
|
||||
if (this.tab === "config") return this.renderConfig();
|
||||
return nothing;
|
||||
}
|
||||
|
||||
private renderChat() {
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<div class="row">
|
||||
<div class="field" style="min-width: 220px;">
|
||||
<label>Session Key</label>
|
||||
<input
|
||||
.value=${this.sessionKey}
|
||||
@input=${(e: Event) => {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
this.sessionKey = v;
|
||||
this.applySettings({ ...this.settings, sessionKey: v });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="btn"
|
||||
?disabled=${this.chatLoading || !this.connected}
|
||||
@click=${() => this.loadChatHistory()}
|
||||
>
|
||||
${this.chatLoading ? "Loading…" : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
<div class="muted">Messages come from the session JSONL logs.</div>
|
||||
</div>
|
||||
|
||||
<div class="messages" style="margin-top: 12px;">
|
||||
${this.chatMessages.map((m) => renderMessage(m))}
|
||||
${this.chatStream
|
||||
? html`${renderMessage({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: this.chatStream }],
|
||||
})}`
|
||||
: nothing}
|
||||
</div>
|
||||
|
||||
<div class="compose" style="margin-top: 12px;">
|
||||
<div class="field">
|
||||
<label>Message</label>
|
||||
<textarea
|
||||
.value=${this.chatMessage}
|
||||
@input=${(e: Event) => {
|
||||
this.chatMessage = (e.target as HTMLTextAreaElement).value;
|
||||
}}
|
||||
placeholder="Ask the model…"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="row" style="justify-content: flex-end;">
|
||||
<button
|
||||
class="btn primary"
|
||||
?disabled=${this.chatSending || !this.connected}
|
||||
@click=${() => this.sendChat()}
|
||||
>
|
||||
${this.chatSending ? "Sending…" : "Send"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderNodes() {
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<div class="title">Nodes</div>
|
||||
<button
|
||||
class="btn"
|
||||
?disabled=${this.nodesLoading || !this.connected}
|
||||
@click=${() => this.loadNodes()}
|
||||
>
|
||||
${this.nodesLoading ? "Loading…" : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
<div class="nodes" style="margin-top: 12px;">
|
||||
${this.nodes.length === 0
|
||||
? html`<div class="muted">No nodes found.</div>`
|
||||
: this.nodes.map((n) => renderNode(n))}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderConfig() {
|
||||
const validity =
|
||||
this.configValid === null
|
||||
? "unknown"
|
||||
: this.configValid
|
||||
? "valid"
|
||||
: "invalid";
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<div class="row">
|
||||
<div class="title">Config</div>
|
||||
<span class="pill"><span class="mono">${validity}</span></span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button
|
||||
class="btn"
|
||||
?disabled=${this.configLoading || !this.connected}
|
||||
@click=${() => this.loadConfig()}
|
||||
>
|
||||
${this.configLoading ? "Loading…" : "Reload"}
|
||||
</button>
|
||||
<button
|
||||
class="btn primary"
|
||||
?disabled=${this.configSaving || !this.connected}
|
||||
@click=${() => this.saveConfig()}
|
||||
>
|
||||
${this.configSaving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="muted" style="margin-top: 10px;">
|
||||
Writes to <span class="mono">~/.clawdis/clawdis.json</span>. Some
|
||||
changes may require a gateway restart.
|
||||
</div>
|
||||
|
||||
<div class="field" style="margin-top: 12px;">
|
||||
<label>Raw JSON5</label>
|
||||
<textarea
|
||||
.value=${this.configRaw}
|
||||
@input=${(e: Event) => {
|
||||
this.configRaw = (e.target as HTMLTextAreaElement).value;
|
||||
}}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
${this.configIssues.length > 0
|
||||
? html`<div class="card" style="margin-top: 12px;">
|
||||
<div class="title">Issues</div>
|
||||
<div class="error">${JSON.stringify(this.configIssues, null, 2)}</div>
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderNode(node: Record<string, unknown>) {
|
||||
const connected = Boolean(node.connected);
|
||||
const paired = Boolean(node.paired);
|
||||
const title =
|
||||
(typeof node.displayName === "string" && node.displayName.trim()) ||
|
||||
(typeof node.nodeId === "string" ? node.nodeId : "unknown");
|
||||
const caps = Array.isArray(node.caps) ? (node.caps as unknown[]) : [];
|
||||
const commands = Array.isArray(node.commands) ? (node.commands as unknown[]) : [];
|
||||
return html`
|
||||
<div class="nodeRow">
|
||||
<div class="top">
|
||||
<div class="row">
|
||||
<span class="statusDot ${connected ? "ok" : ""}"></span>
|
||||
<div class="title">${title}</div>
|
||||
</div>
|
||||
<div class="row muted">
|
||||
<span>${paired ? "paired" : "unpaired"}</span>
|
||||
<span>·</span>
|
||||
<span>${connected ? "connected" : "offline"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="muted mono">
|
||||
${typeof node.nodeId === "string" ? node.nodeId : ""}
|
||||
${typeof node.remoteIp === "string" ? `· ${node.remoteIp}` : ""}
|
||||
${typeof node.version === "string" ? `· ${node.version}` : ""}
|
||||
</div>
|
||||
${caps.length > 0
|
||||
? html`<div class="chips">
|
||||
${caps.slice(0, 24).map((c) => html`<span class="chip">${String(c)}</span>`)}
|
||||
</div>`
|
||||
: nothing}
|
||||
${commands.length > 0
|
||||
? html`<div class="chips">
|
||||
${commands
|
||||
.slice(0, 24)
|
||||
.map((c) => html`<span class="chip">${String(c)}</span>`)}
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderMessage(message: unknown) {
|
||||
const m = message as Record<string, unknown>;
|
||||
const role = typeof m.role === "string" ? m.role : "unknown";
|
||||
const text =
|
||||
extractText(message) ??
|
||||
(typeof m.content === "string"
|
||||
? m.content
|
||||
: JSON.stringify(message, null, 2));
|
||||
|
||||
const ts =
|
||||
typeof m.timestamp === "number"
|
||||
? new Date(m.timestamp).toLocaleTimeString()
|
||||
: "";
|
||||
const klass =
|
||||
role === "assistant" ? "assistant" : role === "user" ? "user" : "";
|
||||
return html`
|
||||
<div class="msg ${klass}">
|
||||
<div class="meta">
|
||||
<span class="mono">${role}</span>
|
||||
<span class="mono">${ts}</span>
|
||||
</div>
|
||||
<div style="white-space: pre-wrap;">${text}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function extractText(message: unknown): string | null {
|
||||
const m = message as Record<string, unknown>;
|
||||
const content = m.content;
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
const parts = content
|
||||
.map((p) => {
|
||||
const item = p as Record<string, unknown>;
|
||||
if (item.type === "text" && typeof item.text === "string") return item.text;
|
||||
return null;
|
||||
})
|
||||
.filter((v): v is string => typeof v === "string");
|
||||
if (parts.length > 0) return parts.join("\n");
|
||||
}
|
||||
if (typeof m.text === "string") return m.text;
|
||||
return null;
|
||||
}
|
||||
|
||||
170
ui/src/ui/gateway.ts
Normal file
170
ui/src/ui/gateway.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
export type GatewayEventFrame = {
|
||||
type: "event";
|
||||
event: string;
|
||||
payload?: unknown;
|
||||
seq?: number;
|
||||
stateVersion?: { presence: number; health: number };
|
||||
};
|
||||
|
||||
export type GatewayResponseFrame = {
|
||||
type: "res";
|
||||
id: string;
|
||||
ok: boolean;
|
||||
payload?: unknown;
|
||||
error?: { code: string; message: string; details?: unknown };
|
||||
};
|
||||
|
||||
export type GatewayHelloOk = {
|
||||
type: "hello-ok";
|
||||
protocol: number;
|
||||
features?: { methods?: string[]; events?: string[] };
|
||||
snapshot?: unknown;
|
||||
policy?: { tickIntervalMs?: number };
|
||||
};
|
||||
|
||||
type Pending = {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (err: unknown) => void;
|
||||
};
|
||||
|
||||
export type GatewayBrowserClientOptions = {
|
||||
url: string;
|
||||
token?: string;
|
||||
clientName?: string;
|
||||
clientVersion?: string;
|
||||
platform?: string;
|
||||
mode?: string;
|
||||
instanceId?: string;
|
||||
onHello?: (hello: GatewayHelloOk) => void;
|
||||
onEvent?: (evt: GatewayEventFrame) => void;
|
||||
onClose?: (info: { code: number; reason: string }) => void;
|
||||
onGap?: (info: { expected: number; received: number }) => void;
|
||||
};
|
||||
|
||||
export class GatewayBrowserClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private pending = new Map<string, Pending>();
|
||||
private closed = false;
|
||||
private lastSeq: number | null = null;
|
||||
private backoffMs = 800;
|
||||
|
||||
constructor(private opts: GatewayBrowserClientOptions) {}
|
||||
|
||||
start() {
|
||||
this.closed = false;
|
||||
this.connect();
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.closed = true;
|
||||
this.ws?.close();
|
||||
this.ws = null;
|
||||
this.flushPending(new Error("gateway client stopped"));
|
||||
}
|
||||
|
||||
get connected() {
|
||||
return this.ws?.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
private connect() {
|
||||
if (this.closed) return;
|
||||
this.ws = new WebSocket(this.opts.url);
|
||||
this.ws.onopen = () => this.sendConnect();
|
||||
this.ws.onmessage = (ev) => this.handleMessage(String(ev.data ?? ""));
|
||||
this.ws.onclose = (ev) => {
|
||||
const reason = String(ev.reason ?? "");
|
||||
this.ws = null;
|
||||
this.flushPending(new Error(`gateway closed (${ev.code}): ${reason}`));
|
||||
this.opts.onClose?.({ code: ev.code, reason });
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
this.ws.onerror = () => {
|
||||
// ignored; close handler will fire
|
||||
};
|
||||
}
|
||||
|
||||
private scheduleReconnect() {
|
||||
if (this.closed) return;
|
||||
const delay = this.backoffMs;
|
||||
this.backoffMs = Math.min(this.backoffMs * 1.7, 15_000);
|
||||
window.setTimeout(() => this.connect(), delay);
|
||||
}
|
||||
|
||||
private flushPending(err: Error) {
|
||||
for (const [, p] of this.pending) p.reject(err);
|
||||
this.pending.clear();
|
||||
}
|
||||
|
||||
private sendConnect() {
|
||||
const params = {
|
||||
minProtocol: 2,
|
||||
maxProtocol: 2,
|
||||
client: {
|
||||
name: this.opts.clientName ?? "clawdis-control-ui",
|
||||
version: this.opts.clientVersion ?? "dev",
|
||||
platform: this.opts.platform ?? navigator.platform ?? "web",
|
||||
mode: this.opts.mode ?? "webchat",
|
||||
instanceId: this.opts.instanceId,
|
||||
},
|
||||
caps: [],
|
||||
auth: this.opts.token ? { token: this.opts.token } : undefined,
|
||||
userAgent: navigator.userAgent,
|
||||
locale: navigator.language,
|
||||
};
|
||||
|
||||
void this.request<GatewayHelloOk>("connect", params)
|
||||
.then((hello) => {
|
||||
this.backoffMs = 800;
|
||||
this.opts.onHello?.(hello);
|
||||
})
|
||||
.catch(() => {
|
||||
this.ws?.close(1008, "connect failed");
|
||||
});
|
||||
}
|
||||
|
||||
private handleMessage(raw: string) {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const frame = parsed as { type?: unknown };
|
||||
if (frame.type === "event") {
|
||||
const evt = parsed as GatewayEventFrame;
|
||||
const seq = typeof evt.seq === "number" ? evt.seq : null;
|
||||
if (seq !== null) {
|
||||
if (this.lastSeq !== null && seq > this.lastSeq + 1) {
|
||||
this.opts.onGap?.({ expected: this.lastSeq + 1, received: seq });
|
||||
}
|
||||
this.lastSeq = seq;
|
||||
}
|
||||
this.opts.onEvent?.(evt);
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.type === "res") {
|
||||
const res = parsed as GatewayResponseFrame;
|
||||
const pending = this.pending.get(res.id);
|
||||
if (!pending) return;
|
||||
this.pending.delete(res.id);
|
||||
if (res.ok) pending.resolve(res.payload);
|
||||
else pending.reject(new Error(res.error?.message ?? "request failed"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
request<T = unknown>(method: string, params?: unknown): Promise<T> {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
return Promise.reject(new Error("gateway not connected"));
|
||||
}
|
||||
const id = crypto.randomUUID();
|
||||
const frame = { type: "req", id, method, params };
|
||||
const p = new Promise<T>((resolve, reject) => {
|
||||
this.pending.set(id, { resolve: (v) => resolve(v as T), reject });
|
||||
});
|
||||
this.ws.send(JSON.stringify(frame));
|
||||
return p;
|
||||
}
|
||||
}
|
||||
44
ui/src/ui/storage.ts
Normal file
44
ui/src/ui/storage.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
const KEY = "clawdis.control.settings.v1";
|
||||
|
||||
export type UiSettings = {
|
||||
gatewayUrl: string;
|
||||
token: string;
|
||||
sessionKey: string;
|
||||
};
|
||||
|
||||
export function loadSettings(): UiSettings {
|
||||
const defaultUrl = (() => {
|
||||
const proto = location.protocol === "https:" ? "wss" : "ws";
|
||||
return `${proto}://${location.host}`;
|
||||
})();
|
||||
|
||||
const defaults: UiSettings = {
|
||||
gatewayUrl: defaultUrl,
|
||||
token: "",
|
||||
sessionKey: "main",
|
||||
};
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
if (!raw) return defaults;
|
||||
const parsed = JSON.parse(raw) as Partial<UiSettings>;
|
||||
return {
|
||||
gatewayUrl:
|
||||
typeof parsed.gatewayUrl === "string" && parsed.gatewayUrl.trim()
|
||||
? parsed.gatewayUrl.trim()
|
||||
: defaults.gatewayUrl,
|
||||
token: typeof parsed.token === "string" ? parsed.token : defaults.token,
|
||||
sessionKey:
|
||||
typeof parsed.sessionKey === "string" && parsed.sessionKey.trim()
|
||||
? parsed.sessionKey.trim()
|
||||
: defaults.sessionKey,
|
||||
};
|
||||
} catch {
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSettings(next: UiSettings) {
|
||||
localStorage.setItem(KEY, JSON.stringify(next));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user